MVC en PHP desde cero: Modelo, Vista y Controlador con un ejemplo completo

Hasta ahora construimos formularios, consultas con PDO, CRUD, autenticación, permisos, seguridad y programación orientada a objetos. El siguiente paso es aprender a organizar mejor una aplicación PHP.

Una de las arquitecturas más conocidas para separar responsabilidades es MVC: Modelo, Vista y Controlador.

En esta guía vamos a entender qué hace cada parte y construiremos un ejemplo sencillo con productos y MySQL.

¿Qué problema resuelve MVC?

En proyectos pequeños es común comenzar con archivos que mezclan:

  • HTML;
  • consultas SQL;
  • validaciones;
  • lógica de negocio;
  • redirecciones;
  • procesamiento de formularios.

Por ejemplo, un único archivo podría consultar productos, procesar un POST, validar datos y generar todo el HTML.

Eso puede funcionar al principio, pero a medida que la aplicación crece se vuelve difícil de mantener.

MVC intenta separar esas responsabilidades.

¿Qué significa MVC?

MVC significa:

  • Model: Modelo;
  • View: Vista;
  • Controller: Controlador.

Modelo

El Modelo se ocupa de trabajar con los datos y con la lógica relacionada con ellos.

Por ejemplo:

  • consultar productos;
  • buscar un producto por ID;
  • crear registros;
  • actualizarlos;
  • eliminarlos.

Vista

La Vista se ocupa de presentar la información al usuario.

Normalmente contiene:

  • HTML;
  • tablas;
  • formularios;
  • mensajes;
  • pequeñas expresiones PHP para mostrar datos.

La vista no debería contener consultas SQL.

Controlador

El Controlador coordina la aplicación.

Por ejemplo:

  1. recibe una petición;
  2. solicita información al modelo;
  3. procesa la lógica necesaria;
  4. envía datos a la vista.

Flujo básico de MVC

Usuario
   ↓
Controlador
   ↓
Modelo
   ↓
Base de datos
   ↓
Controlador
   ↓
Vista
   ↓
Usuario

No todas las aplicaciones MVC implementan exactamente el mismo flujo, pero esta idea ayuda a entender la separación de responsabilidades.

Estructura de carpetas

Podemos comenzar con una estructura simple:

proyecto/
│
├── app/
│   ├── Controllers/
│   │   └── ProductoController.php
│   │
│   ├── Models/
│   │   └── Producto.php
│   │
│   └── Views/
│       └── productos/
│           ├── index.php
│           └── create.php
│
├── config/
│   └── database.php
│
└── public/
    └── index.php

Base de datos de ejemplo

Vamos a utilizar una tabla productos:

CREATE TABLE productos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    precio DECIMAL(10,2) NOT NULL,
    stock INT NOT NULL DEFAULT 0
);

Conexión a MySQL

En config/database.php podemos centralizar la conexión:

<?php

$host = 'localhost';
$db = 'tienda';
$user = 'root';
$password = '';

$dsn = "mysql:host=$host;dbname=$db;charset=utf8mb4";

$pdo = new PDO($dsn, $user, $password, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);

?>

Crear el Modelo Producto

El modelo será responsable de consultar la tabla.

<?php

class Producto
{
    public function __construct(
        private PDO $pdo
    ) {
    }

    public function todos(): array
    {
        $stmt = $this->pdo->query(
            'SELECT id, nombre, precio, stock
             FROM productos
             ORDER BY id DESC'
        );

        return $stmt->fetchAll();
    }
}

?>

El modelo conoce PDO y la base de datos. La vista no necesita saber cómo se obtuvieron los productos.

Crear el Controlador

En app/Controllers/ProductoController.php:

<?php

class ProductoController
{
    public function __construct(
        private Producto $producto
    ) {
    }

    public function index(): void
    {
        $productos = $this->producto->todos();

        require __DIR__
            . '/../Views/productos/index.php';
    }
}

?>

El controlador solicita los datos al modelo y carga la vista.

Crear la Vista

En app/Views/productos/index.php:

<h1>Productos</h1>

<table>
    <thead>
        <tr>
            <th>ID</th>
            <th>Nombre</th>
            <th>Precio</th>
            <th>Stock</th>
        </tr>
    </thead>

    <tbody>

    <?php foreach ($productos as $producto): ?>

        <tr>
            <td>
                <?= $producto['id'] ?>
            </td>

            <td>
                <?= htmlspecialchars(
                    $producto['nombre'],
                    ENT_QUOTES,
                    'UTF-8'
                ) ?>
            </td>

            <td>
                $<?= number_format(
                    $producto['precio'],
                    2,
                    ',',
                    '.'
                ) ?>
            </td>

            <td>
                <?= $producto['stock'] ?>
            </td>
        </tr>

    <?php endforeach; ?>

    </tbody>
</table>

Crear un punto de entrada

Podemos utilizar public/index.php como punto de entrada:

<?php

require __DIR__ . '/../config/database.php';
require __DIR__ . '/../app/Models/Producto.php';
require __DIR__ . '/../app/Controllers/ProductoController.php';

$modelo = new Producto($pdo);

$controller = new ProductoController($modelo);

$controller->index();

?>

¿Qué logramos?

Ahora cada responsabilidad está separada:

  • Producto.php trabaja con los datos;
  • ProductoController.php coordina la petición;
  • index.php muestra el HTML.

Agregar un formulario de creación

Podemos agregar un método al controlador:

public function create(): void
{
    require __DIR__
        . '/../Views/productos/create.php';
}

Vista para crear un producto

<h1>Nuevo producto</h1>

<form method="POST" action="?accion=store">

    <label>Nombre</label>
    <input type="text" name="nombre" required>

    <label>Precio</label>
    <input type="number" step="0.01" name="precio" required>

    <label>Stock</label>
    <input type="number" name="stock" required>

    <button type="submit">
        Guardar
    </button>

</form>

Agregar el método crear() al Modelo

public function crear(
    string $nombre,
    float $precio,
    int $stock
): bool {
    $sql = 'INSERT INTO productos
            (nombre, precio, stock)
            VALUES (:nombre, :precio, :stock)';

    $stmt = $this->pdo->prepare($sql);

    return $stmt->execute([
        'nombre' => $nombre,
        'precio' => $precio,
        'stock' => $stock
    ]);
}

Agregar store() al Controlador

public function store(): void
{
    $nombre = trim($_POST['nombre'] ?? '');
    $precio = filter_input(
        INPUT_POST,
        'precio',
        FILTER_VALIDATE_FLOAT
    );

    $stock = filter_input(
        INPUT_POST,
        'stock',
        FILTER_VALIDATE_INT
    );

    if (
        $nombre === '' ||
        $precio === false ||
        $stock === false
    ) {
        exit('Datos inválidos');
    }

    $this->producto->crear(
        $nombre,
        $precio,
        $stock
    );

    header('Location: index.php');
    exit;
}

Agregar un router simple

Podemos decidir qué método ejecutar mediante un parámetro:

$accion = $_GET['accion'] ?? 'index';

switch ($accion) {

    case 'create':
        $controller->create();
        break;

    case 'store':
        $controller->store();
        break;

    default:
        $controller->index();
}

Esto todavía es un router muy básico, pero sirve para entender la idea.

Modelo no significa necesariamente tabla

Es común pensar que cada modelo debe representar una tabla exactamente, pero el concepto es más amplio.

El modelo representa los datos y reglas de negocio de la aplicación.

En proyectos simples puede coincidir bastante con una tabla, pero en sistemas mayores puede incluir lógica más compleja.

La Vista no debería consultar la base

Evitemos algo como esto dentro de una vista:

// No recomendado dentro de una vista

$stmt = $pdo->query(
    'SELECT * FROM productos'
);

La vista debería recibir los datos ya preparados.

El Controlador tampoco debería hacer todo

Otro error frecuente es mover todo el código del archivo original hacia el controlador.

Un controlador gigantesco sigue siendo difícil de mantener.

La idea es que coordine responsabilidades, no que concentre toda la aplicación.

Separar la lógica de negocio

A medida que el proyecto crece, podemos tener otras capas o servicios.

Por ejemplo:

app/
├── Controllers/
├── Models/
├── Services/
└── Views/

Un servicio podría encargarse de reglas específicas que no pertenecen directamente a una vista o a una consulta SQL.

MVC y seguridad

La arquitectura no reemplaza las buenas prácticas vistas anteriormente.

Seguimos necesitando:

  • consultas preparadas;
  • validación de datos;
  • protección CSRF;
  • escape de salida;
  • autenticación;
  • autorización.

MVC no es una regla rígida

Existen diferentes formas de implementar MVC.

Dos frameworks pueden utilizar el patrón y tener estructuras distintas.

Lo importante para comenzar es comprender la separación de responsabilidades.

¿Cómo se relaciona esto con Laravel?

Laravel utiliza muchos de estos conceptos.

Por ejemplo:

  • los Models representan y trabajan con datos;
  • los Controllers reciben peticiones y coordinan acciones;
  • las vistas se construyen normalmente con Blade;
  • las rutas determinan qué controlador debe ejecutarse.

Laravel agrega muchas herramientas alrededor de esta arquitectura, pero entender MVC primero hace que sea mucho más fácil comprender el framework.

Errores comunes al aprender MVC

  • poner consultas SQL dentro de las vistas;
  • crear controladores que hacen absolutamente todo;
  • mezclar HTML dentro de los modelos;
  • crear una clase por cada pequeño fragmento de código sin necesidad;
  • pensar que MVC obliga a usar una estructura exacta;
  • creer que usar carpetas llamadas Models, Views y Controllers ya convierte automáticamente un proyecto en MVC.

Ejercicio práctico

Extendé el ejemplo de productos para implementar un CRUD completo utilizando MVC.

Debería incluir:

  1. listar productos;
  2. crear productos;
  3. editar productos;
  4. eliminar productos;
  5. validar formularios;
  6. usar consultas preparadas;
  7. proteger la eliminación mediante POST;
  8. separar correctamente Modelo, Vista y Controlador.

¿Qué sigue?

En la próxima guía vamos a aprender Composer y autoloading en PHP.

Veremos:

  • qué es Composer;
  • qué es composer.json;
  • cómo instalar paquetes;
  • qué es vendor;
  • autoloading;
  • PSR-4;
  • cómo dejar de utilizar múltiples require manuales.

Ese paso nos acercará todavía más al funcionamiento interno de proyectos PHP modernos y frameworks como Laravel.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Programación orientada a objetos en PHP: clases, objetos, herencia e interfaces

Hasta ahora trabajamos con PHP utilizando variables, funciones, formularios, PDO, sesiones, autenticación y seguridad. El siguiente paso es aprender programación orientada a objetos, una forma de organizar el código en clases y objetos.

Este enfoque es fundamental para trabajar en proyectos medianos o grandes y es la base de muchos frameworks modernos, incluido Laravel.

¿Qué es la programación orientada a objetos?

La programación orientada a objetos, también llamada POO u OOP, organiza el código alrededor de objetos que representan entidades del sistema.

Por ejemplo, en una aplicación podemos tener objetos como:

  • Usuario;
  • Producto;
  • Pedido;
  • Curso;
  • Factura.

Cada objeto puede tener datos y comportamientos propios.

¿Qué es una clase?

Una clase funciona como una plantilla para crear objetos.

<?php

class Usuario
{
}

?>

Crear un objeto

Para crear una instancia utilizamos new:

$usuario = new Usuario();

$usuario es ahora un objeto de la clase Usuario.

Propiedades

Las propiedades representan los datos del objeto.

class Usuario
{
    public string $nombre;
    public string $email;
}

Podemos asignar valores:

$usuario = new Usuario();

$usuario->nombre = 'Ana';
$usuario->email = 'ana@email.com';

Métodos

Los métodos representan comportamientos del objeto.

class Usuario
{
    public string $nombre;

    public function saludar(): string
    {
        return 'Hola, soy ' . $this->nombre;
    }
}

Para llamarlo:

$usuario = new Usuario();
$usuario->nombre = 'Ana';

echo $usuario->saludar();

¿Qué significa $this?

$this hace referencia al objeto actual.

Por ejemplo:

$this->nombre

significa “la propiedad nombre de este objeto”.

El constructor

El constructor se ejecuta automáticamente al crear un objeto.

class Usuario
{
    public string $nombre;
    public string $email;

    public function __construct(string $nombre, string $email)
    {
        $this->nombre = $nombre;
        $this->email = $email;
    }
}

Ahora podemos crear el objeto directamente con sus datos:

$usuario = new Usuario(
    'Ana',
    'ana@email.com'
);

Promoción de propiedades en el constructor

En versiones modernas de PHP podemos simplificar el código:

class Usuario
{
    public function __construct(
        public string $nombre,
        public string $email
    ) {
    }
}

Visibilidad: public, protected y private

Las propiedades y métodos pueden tener distintos niveles de acceso.

public

Puede utilizarse desde cualquier parte:

public string $nombre;

private

Solo puede utilizarse dentro de la misma clase:

private string $password;

protected

Puede utilizarse dentro de la clase y sus clases hijas:

protected string $rol;

Encapsulamiento

El encapsulamiento consiste en proteger el estado interno de un objeto y controlar cómo se accede a él.

class Cuenta
{
    private float $saldo = 0;

    public function depositar(float $importe): void
    {
        if ($importe <= 0) {
            return;
        }

        $this->saldo += $importe;
    }

    public function obtenerSaldo(): float
    {
        return $this->saldo;
    }
}

El saldo no puede modificarse directamente desde afuera.

Getters y setters

Los getters permiten leer valores y los setters modificarlos de forma controlada.

class Producto
{
    private float $precio;

    public function setPrecio(float $precio): void
    {
        if ($precio < 0) {
            throw new InvalidArgumentException(
                'El precio no puede ser negativo'
            );
        }

        $this->precio = $precio;
    }

    public function getPrecio(): float
    {
        return $this->precio;
    }
}

Herencia

Una clase puede heredar propiedades y métodos de otra utilizando extends.

class Usuario
{
    public function __construct(
        protected string $nombre
    ) {
    }

    public function getNombre(): string
    {
        return $this->nombre;
    }
}

class Administrador extends Usuario
{
    public function eliminarUsuario(): string
    {
        return 'Usuario eliminado';
    }
}

Sobrescribir métodos

Una clase hija puede redefinir un método de la clase padre.

class Usuario
{
    public function descripcion(): string
    {
        return 'Usuario del sistema';
    }
}

class Administrador extends Usuario
{
    public function descripcion(): string
    {
        return 'Administrador del sistema';
    }
}

parent::

Podemos llamar a un método de la clase padre utilizando parent::.

class Administrador extends Usuario
{
    public function descripcion(): string
    {
        return parent::descripcion()
            . ' con permisos administrativos';
    }
}

Clases abstractas

Una clase abstracta sirve como base para otras clases y no puede instanciarse directamente.

abstract class Empleado
{
    public function __construct(
        protected string $nombre
    ) {
    }

    abstract public function calcularSueldo(): float;
}

Una clase hija debe implementar el método abstracto:

class EmpleadoMensual extends Empleado
{
    public function __construct(
        string $nombre,
        private float $sueldo
    ) {
        parent::__construct($nombre);
    }

    public function calcularSueldo(): float
    {
        return $this->sueldo;
    }
}

Interfaces

Una interfaz define qué métodos debe implementar una clase.

interface Notificable
{
    public function enviar(string $mensaje): void;
}

Después podemos crear distintas implementaciones:

class NotificacionEmail implements Notificable
{
    public function enviar(string $mensaje): void
    {
        echo 'Enviando email: ' . $mensaje;
    }
}

Polimorfismo

Gracias a una interfaz podemos trabajar con diferentes implementaciones utilizando el mismo contrato.

function notificar(
    Notificable $servicio,
    string $mensaje
): void {
    $servicio->enviar($mensaje);
}

La función no necesita saber cómo se envía realmente la notificación.

Propiedades y métodos static

Un miembro static pertenece a la clase y no a una instancia concreta.

class Conversor
{
    public static function kilometrosAMillas(float $km): float
    {
        return $km * 0.621371;
    }
}

Se utiliza así:

$millas = Conversor::kilometrosAMillas(10);

Constantes de clase

class Pedido
{
    public const ESTADO_PENDIENTE = 'pendiente';
    public const ESTADO_PAGADO = 'pagado';
}

Podemos acceder mediante:

Pedido::ESTADO_PAGADO

Namespaces

Cuando un proyecto crece, los namespaces ayudan a organizar clases y evitar conflictos de nombres.

<?php

namespace App\Models;

class Usuario
{
}

?>

Luego podemos importar la clase:

use App\Models\Usuario;

$usuario = new Usuario();

Ejemplo práctico: clase Producto

class Producto
{
    public function __construct(
        private int $id,
        private string $nombre,
        private float $precio,
        private int $stock
    ) {
    }

    public function getNombre(): string
    {
        return $this->nombre;
    }

    public function getPrecio(): float
    {
        return $this->precio;
    }

    public function tieneStock(): bool
    {
        return $this->stock > 0;
    }

    public function descontarStock(int $cantidad): void
    {
        if ($cantidad <= 0 || $cantidad > $this->stock) {
            throw new InvalidArgumentException(
                'Cantidad inválida'
            );
        }

        $this->stock -= $cantidad;
    }
}

Ventajas de la programación orientada a objetos

  • organiza mejor proyectos grandes;
  • permite reutilizar código;
  • facilita separar responsabilidades;
  • mejora el mantenimiento;
  • permite crear componentes reemplazables;
  • facilita las pruebas automatizadas;
  • es la base conceptual de frameworks como Laravel.

No todo necesita ser una clase

La POO es una herramienta, no una obligación.

Crear clases sin una responsabilidad clara puede hacer que el código sea más complejo en lugar de mejorarlo.

Una buena clase debería representar una responsabilidad concreta y mantener relacionados sus datos y comportamientos.

Errores comunes

  • hacer todas las propiedades públicas;
  • crear clases que solamente agrupan funciones sin una responsabilidad clara;
  • abusar de la herencia;
  • crear jerarquías demasiado complejas;
  • utilizar métodos static para todo;
  • confundir una clase con un objeto;
  • no utilizar tipos de datos cuando ayudan a detectar errores;
  • crear clases gigantes que hacen demasiadas cosas.

Ejercicio práctico

Creá un pequeño sistema de biblioteca utilizando POO.

Definí:

  • una clase Libro con título, autor y disponibilidad;
  • una clase Usuario;
  • un método para prestar un libro;
  • un método para devolverlo;
  • encapsulamiento para impedir cambios inválidos;
  • una interfaz Notificable para informar cuando se realiza un préstamo.

¿Qué sigue?

En la próxima guía vamos a organizar un proyecto PHP utilizando una arquitectura más clara: MVC (Modelo, Vista y Controlador).

Veremos cómo separar:

  • acceso a datos;
  • lógica de negocio;
  • controladores;
  • vistas;
  • rutas.

Esto nos va a acercar mucho más a la estructura que utilizan frameworks como Laravel.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Seguridad en PHP: prevenir SQL Injection, XSS, CSRF y validar datos correctamente

Después de implementar login, sesiones, roles y permisos, el siguiente paso es aprender a proteger correctamente una aplicación PHP.

La seguridad no consiste en una sola técnica. Una aplicación puede utilizar contraseñas seguras y, al mismo tiempo, ser vulnerable a inyección SQL, XSS o CSRF.

En esta guía vamos a repasar algunas de las vulnerabilidades más comunes y cómo prevenirlas.

1. SQL Injection

Una inyección SQL ocurre cuando datos ingresados por el usuario terminan formando parte directa de una consulta SQL.

Este código es inseguro:

<?php

$email = $_POST['email'];

$sql = "SELECT * FROM usuarios WHERE email = '$email'";

$usuario = $pdo->query($sql)->fetch();

?>

El problema es que el contenido de $email se concatena directamente dentro de la consulta.

La solución: consultas preparadas

Con PDO debemos separar los datos de la consulta:

<?php

$sql = 'SELECT * FROM usuarios WHERE email = :email';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'email' => $email
]);

$usuario = $stmt->fetch(PDO::FETCH_ASSOC);

?>

El marcador :email evita que el valor recibido sea interpretado como parte de la consulta SQL.

No confiar en addslashes()

Escapar manualmente textos no reemplaza a las consultas preparadas.

La opción recomendada es utilizar prepare() y execute().

2. XSS: Cross-Site Scripting

XSS aparece cuando mostramos contenido enviado por un usuario sin escapar correctamente el HTML.

Por ejemplo:

echo $_POST['nombre'];

Si el usuario envía código HTML o JavaScript, ese contenido podría ejecutarse en el navegador.

Escapar la salida con htmlspecialchars()

echo htmlspecialchars(
    $_POST['nombre'],
    ENT_QUOTES,
    'UTF-8'
);

Esto convierte caracteres especiales en entidades HTML y evita que el navegador los interprete como etiquetas.

Escapar al mostrar, no necesariamente al guardar

Una regla práctica importante es guardar los datos de forma adecuada y aplicar el escape según el contexto de salida.

Por ejemplo:

<h1>
    <?= htmlspecialchars($usuario['nombre'], ENT_QUOTES, 'UTF-8') ?>
</h1>

3. Validación y sanitización

Validar significa comprobar que un dato cumple las reglas esperadas.

Sanitizar significa limpiar o transformar un valor.

Validar un email

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errores[] = 'El email no es válido';
}

Validar un número entero

$edad = filter_input(INPUT_POST, 'edad', FILTER_VALIDATE_INT);

if ($edad === false) {
    $errores[] = 'La edad debe ser un número entero';
}

No confiar solamente en validaciones HTML

Este campo:

<input type="email" name="email" required>

mejora la experiencia del usuario, pero no reemplaza la validación en PHP.

Las peticiones pueden enviarse sin utilizar el formulario del navegador.

4. CSRF: Cross-Site Request Forgery

Un ataque CSRF intenta lograr que un usuario autenticado ejecute una acción sin darse cuenta.

Por ejemplo, una petición podría intentar eliminar un usuario utilizando la sesión activa de un administrador.

Crear un token CSRF

Podemos generar un valor aleatorio y guardarlo en la sesión:

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

?>

Agregar el token al formulario

<input
    type="hidden"
    name="csrf_token"
    value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
>

Validar el token

if (
    !isset($_POST['csrf_token']) ||
    !hash_equals(
        $_SESSION['csrf_token'],
        $_POST['csrf_token']
    )
) {
    http_response_code(403);
    exit('Token CSRF inválido');
}

Las operaciones importantes deberían usar POST

No conviene eliminar datos utilizando enlaces como:

<a href="eliminar.php?id=10">Eliminar</a>

Para operaciones que modifican información es preferible utilizar formularios con método POST y protección CSRF.

5. Contraseñas seguras

Las contraseñas nunca deben almacenarse en texto plano.

Para guardarlas:

$hash = password_hash(
    $password,
    PASSWORD_DEFAULT
);

Para comprobarlas:

if (password_verify($password, $hash)) {
    // Contraseña correcta
}

No utilizar MD5 o SHA1 para contraseñas

Aunque son funciones hash, md5() y sha1() no están diseñadas para almacenar contraseñas de forma segura.

PHP ofrece específicamente:

  • password_hash();
  • password_verify();
  • password_needs_rehash().

6. Seguridad de las sesiones

Después de un login correcto conviene regenerar el identificador:

session_regenerate_id(true);

También podemos configurar las cookies de sesión:

session_set_cookie_params([
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Lax'
]);

session_start();

secure debe utilizarse cuando el sitio funciona mediante HTTPS.

7. No mostrar errores sensibles en producción

Durante el desarrollo puede ser útil visualizar errores, pero en producción pueden revelar información interna.

Por ejemplo:

  • rutas del servidor;
  • nombres de archivos;
  • estructura de la base de datos;
  • credenciales mal configuradas;
  • detalles del código.

En producción conviene registrar errores en logs y mostrar al usuario mensajes genéricos.

8. Manejo seguro de archivos subidos

Las subidas de archivos requieren controles adicionales.

No debemos confiar solamente en:

$_FILES['archivo']['type']

Ese valor proviene del cliente.

Podemos analizar el tipo MIME real con finfo:

$finfo = new finfo(FILEINFO_MIME_TYPE);

$tipo = $finfo->file(
    $_FILES['archivo']['tmp_name']
);

Después podemos compararlo contra una lista permitida.

$permitidos = [
    'image/jpeg',
    'image/png'
];

if (!in_array($tipo, $permitidos, true)) {
    exit('Tipo de archivo no permitido');
}

No utilizar directamente el nombre original

En vez de confiar en el nombre recibido, podemos generar uno aleatorio:

$nombreSeguro = bin2hex(random_bytes(16)) . '.jpg';

9. Validar identificadores

Un identificador recibido mediante GET también debe validarse.

$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);

if (!$id) {
    http_response_code(400);
    exit('ID inválido');
}

10. Autorización en cada operación

Aunque un usuario conozca una URL, no debería poder ejecutar una operación sin permiso.

Por ejemplo:

if (!tienePermiso(
    $pdo,
    $_SESSION['usuario_id'],
    'usuarios.eliminar'
)) {
    http_response_code(403);
    exit('Acceso denegado');
}

11. Variables de entorno y credenciales

No conviene publicar credenciales directamente en archivos accesibles o repositorios públicos.

Información como:

  • usuario de MySQL;
  • contraseña de la base;
  • tokens de APIs;
  • claves privadas;

debe tratarse como información sensible.

12. Aplicar el principio de mínimo privilegio

Un usuario de base de datos utilizado por una aplicación no necesariamente necesita permisos administrativos completos.

La idea es conceder solamente los privilegios necesarios para que la aplicación funcione.

Ejemplo de formulario más seguro

<?php

session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

?>

<form method="POST">

    <input
        type="hidden"
        name="csrf_token"
        value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>"
    >

    <label>Email</label>

    <input
        type="email"
        name="email"
        required
    >

    <button type="submit">
        Guardar
    </button>

</form>

Procesar el formulario

<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    if (
        !isset($_POST['csrf_token']) ||
        !hash_equals(
            $_SESSION['csrf_token'],
            $_POST['csrf_token']
        )
    ) {
        http_response_code(403);
        exit('Solicitud inválida');
    }

    $email = filter_input(
        INPUT_POST,
        'email',
        FILTER_VALIDATE_EMAIL
    );

    if (!$email) {
        exit('Email inválido');
    }

    $stmt = $pdo->prepare(
        'UPDATE usuarios
         SET email = :email
         WHERE id = :id'
    );

    $stmt->execute([
        'email' => $email,
        'id' => $_SESSION['usuario_id']
    ]);
}

?>

Checklist de seguridad para una aplicación PHP

  • usar PDO con consultas preparadas;
  • validar todos los datos externos;
  • escapar correctamente el contenido al mostrarlo;
  • proteger formularios sensibles contra CSRF;
  • usar password_hash() y password_verify();
  • regenerar la sesión después del login;
  • verificar roles y permisos en el servidor;
  • no confiar en cookies ni campos ocultos;
  • validar correctamente archivos subidos;
  • no mostrar errores internos en producción;
  • utilizar HTTPS;
  • mantener PHP y las dependencias actualizadas.

Errores comunes

  • creer que la validación de JavaScript es suficiente;
  • concatenar valores dentro de consultas SQL;
  • mostrar contenido de usuarios sin escapar;
  • usar GET para acciones destructivas;
  • guardar contraseñas con MD5 o SHA1;
  • confiar en el nombre o tipo MIME enviado por un archivo;
  • ocultar botones sin proteger la operación real;
  • guardar secretos directamente en repositorios públicos.

Ejercicio práctico

Tomá el sistema de usuarios de las guías anteriores y agregale:

  1. consultas preparadas en todas las operaciones;
  2. escape de nombres y datos mostrados en HTML;
  3. validación del email;
  4. token CSRF para editar y eliminar usuarios;
  5. protección por permisos;
  6. regeneración de sesión al iniciar sesión;
  7. mensajes de error seguros.

¿Qué sigue?

En la próxima guía podemos avanzar con programación orientada a objetos en PHP, un paso fundamental antes de comenzar a trabajar con arquitecturas más organizadas y frameworks como Laravel.

Veremos clases, objetos, propiedades, métodos, constructores, encapsulamiento, herencia e interfaces.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Autenticación y autorización en PHP: roles, permisos y protección de páginas

En la guía anterior vimos cómo implementar un sistema básico de login y logout con sesiones, cookies, PDO y contraseñas seguras. Ahora vamos a dar el siguiente paso: controlar qué puede hacer cada usuario.

Para eso necesitamos distinguir dos conceptos:

  • autenticación: comprobar quién es el usuario;
  • autorización: decidir qué acciones puede realizar.

Autenticación vs autorización

Cuando un usuario inicia sesión correctamente, queda autenticado.

Pero eso no significa que pueda acceder a cualquier parte del sistema.

Por ejemplo:

  • un usuario común puede ver su perfil;
  • un administrador puede gestionar usuarios;
  • un moderador puede editar contenidos;
  • un invitado no puede acceder a páginas privadas.

Agregar un rol a la tabla de usuarios

Una forma simple de comenzar es agregar una columna rol.

ALTER TABLE usuarios
ADD rol VARCHAR(50) NOT NULL DEFAULT 'usuario';

Por ejemplo, podemos tener:

  • usuario;
  • admin;
  • moderador.

Guardar el rol en la sesión

Después de validar el login, podemos guardar el rol del usuario:

$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['usuario_nombre'] = $usuario['nombre'];
$_SESSION['usuario_rol'] = $usuario['rol'];

Proteger una página para usuarios autenticados

<?php

session_start();

if (!isset($_SESSION['usuario_id'])) {
    header('Location: login.php');
    exit;
}

?>

Proteger una página solo para administradores

<?php

session_start();

if (
    !isset($_SESSION['usuario_id']) ||
    $_SESSION['usuario_rol'] !== 'admin'
) {
    http_response_code(403);
    exit('Acceso denegado');
}

?>

No alcanza con ocultar botones

Un error muy común es esconder botones en la interfaz y pensar que eso protege la acción.

Por ejemplo:

<?php if ($_SESSION['usuario_rol'] === 'admin'): ?>

    <a href="eliminar_usuario.php?id=15">
        Eliminar usuario
    </a>

<?php endif; ?>

Esto mejora la interfaz, pero no reemplaza el control en el servidor.

La página eliminar_usuario.php también debe verificar que el usuario tenga permisos.

Centralizar la verificación

En lugar de repetir las mismas condiciones en todas las páginas, podemos crear funciones.

<?php

function usuarioAutenticado(): bool
{
    return isset($_SESSION['usuario_id']);
}

function esAdmin(): bool
{
    return isset($_SESSION['usuario_rol'])
        && $_SESSION['usuario_rol'] === 'admin';
}

?>

Crear funciones para exigir permisos

function requireLogin(): void
{
    if (!isset($_SESSION['usuario_id'])) {
        header('Location: login.php');
        exit;
    }
}

function requireAdmin(): void
{
    requireLogin();

    if ($_SESSION['usuario_rol'] !== 'admin') {
        http_response_code(403);
        exit('No tenés permisos para acceder a esta sección');
    }
}

Después podemos utilizar:

<?php

session_start();

require 'auth.php';

requireAdmin();

?>

Roles en una aplicación real

Para proyectos pequeños, guardar el rol directamente en la tabla usuarios puede ser suficiente.

Sin embargo, cuando el sistema crece suele ser mejor separar la información.

Por ejemplo:

CREATE TABLE roles (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(50) NOT NULL UNIQUE
);
ALTER TABLE usuarios
ADD rol_id INT,
ADD FOREIGN KEY (rol_id)
    REFERENCES roles(id);

¿Qué son los permisos?

Un rol es una agrupación de capacidades.

Por ejemplo, el rol admin podría tener estos permisos:

  • ver usuarios;
  • crear usuarios;
  • editar usuarios;
  • eliminar usuarios.

Mientras que el rol usuario podría tener solamente:

  • ver su perfil;
  • editar su perfil.

Modelo con roles y permisos

Una estructura más flexible podría utilizar:

  • usuarios;
  • roles;
  • permisos;
  • rol_permiso.

Esto permite asignar muchos permisos a cada rol.

Tabla de permisos

CREATE TABLE permisos (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL UNIQUE
);

Tabla intermedia rol_permiso

CREATE TABLE rol_permiso (
    rol_id INT NOT NULL,
    permiso_id INT NOT NULL,

    PRIMARY KEY (rol_id, permiso_id),

    FOREIGN KEY (rol_id)
        REFERENCES roles(id),

    FOREIGN KEY (permiso_id)
        REFERENCES permisos(id)
);

Consultar los permisos de un usuario

SELECT p.nombre
FROM usuarios u
INNER JOIN roles r
    ON u.rol_id = r.id
INNER JOIN rol_permiso rp
    ON r.id = rp.rol_id
INNER JOIN permisos p
    ON rp.permiso_id = p.id
WHERE u.id = :usuario_id;

Función tienePermiso()

Una implementación simple podría ser:

function tienePermiso(PDO $pdo, int $usuarioId, string $permiso): bool
{
    $sql = 'SELECT COUNT(*)
            FROM usuarios u
            INNER JOIN roles r
                ON u.rol_id = r.id
            INNER JOIN rol_permiso rp
                ON r.id = rp.rol_id
            INNER JOIN permisos p
                ON rp.permiso_id = p.id
            WHERE u.id = :usuario_id
            AND p.nombre = :permiso';

    $stmt = $pdo->prepare($sql);

    $stmt->execute([
        'usuario_id' => $usuarioId,
        'permiso' => $permiso
    ]);

    return (bool) $stmt->fetchColumn();
}

Usar permisos para proteger acciones

if (!tienePermiso(
    $pdo,
    $_SESSION['usuario_id'],
    'usuarios.eliminar'
)) {
    http_response_code(403);
    exit('Acceso denegado');
}

Controlar acciones, no solo páginas

Los permisos deben aplicarse también sobre operaciones específicas.

Por ejemplo, aunque un usuario pueda acceder a una lista, eso no significa necesariamente que pueda:

  • crear registros;
  • editarlos;
  • eliminarlos;
  • exportarlos.

HTTP 401 y 403

Son dos códigos que suelen confundirse.

  • 401 Unauthorized: normalmente indica que falta autenticación válida.
  • 403 Forbidden: el usuario está identificado, pero no tiene permiso para realizar la acción.

No confiar en datos enviados por el cliente

Nunca debemos decidir permisos basándonos en un campo enviado desde un formulario.

Esto sería inseguro:

<input type="hidden" name="rol" value="admin">

El usuario puede modificar fácilmente ese valor.

El rol y los permisos deben obtenerse desde una fuente confiable, como la sesión y la base de datos.

Actualizar permisos durante una sesión

Si guardamos el rol únicamente en $_SESSION y luego un administrador cambia ese rol en la base de datos, la sesión podría conservar temporalmente el valor anterior.

En sistemas donde los permisos cambian con frecuencia puede ser conveniente consultar los permisos desde la base de datos o implementar una estrategia para actualizar la sesión.

Ejemplo de estructura de archivos

proyecto/
│
├── config/
│   └── conexion.php
│
├── auth/
│   └── auth.php
│
├── admin/
│   ├── index.php
│   └── usuarios.php
│
├── usuario/
│   └── perfil.php
│
├── login.php
├── logout.php
└── index.php

Esto ayuda a separar responsabilidades y mantener el proyecto ordenado.

Errores comunes

  • confundir autenticación con autorización;
  • proteger solamente los botones y no las acciones del servidor;
  • repetir controles de permisos por todo el proyecto;
  • confiar en roles enviados mediante formularios o parámetros;
  • dar permisos excesivos a usuarios comunes;
  • guardar demasiada información sensible dentro de la sesión;
  • no devolver una respuesta adecuada cuando falta autorización.

Ejercicio práctico

Extendé el sistema de login de la guía anterior para tener dos roles:

  • admin;
  • usuario.

Después implementá:

  1. una página accesible por cualquier usuario autenticado;
  2. una página disponible solamente para administradores;
  3. un menú que muestre opciones diferentes según el rol;
  4. protección en el servidor para las operaciones administrativas;
  5. una respuesta HTTP 403 cuando un usuario no tenga permisos.

¿Qué sigue?

En la próxima guía podemos avanzar con un tema fundamental para cualquier aplicación web: seguridad en PHP.

Veremos:

  • inyección SQL;
  • XSS;
  • CSRF;
  • validación y sanitización;
  • protección de formularios;
  • manejo seguro de contraseñas y sesiones.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Sesiones y cookies en PHP: login, logout y manejo de usuarios

Después de aprender a trabajar con formularios, PDO y un CRUD completo, el siguiente paso natural en PHP es entender cómo mantener información de un usuario entre distintas páginas.

Para eso PHP ofrece dos mecanismos fundamentales:

  • sesiones, mediante $_SESSION;
  • cookies, almacenadas en el navegador del usuario.

En esta guía vamos a utilizarlas para construir la base de un sistema de login y logout con PHP y MySQL.

¿Qué problema resuelven las sesiones?

HTTP es un protocolo sin estado. Eso significa que, por defecto, cada petición que hacemos a una página web es independiente de la anterior.

Si un usuario inicia sesión en login.php y después entra a panel.php, necesitamos alguna forma de recordar que ese usuario ya fue autenticado.

Las sesiones permiten guardar información del usuario en el servidor y asociarla a sus siguientes peticiones.

Iniciar una sesión con session_start()

Antes de utilizar $_SESSION debemos iniciar la sesión:

<?php

session_start();

?>

En general, session_start() debe ejecutarse antes de enviar contenido HTML al navegador.

Guardar datos en $_SESSION

Podemos guardar valores como si se tratara de un array asociativo:

<?php

session_start();

$_SESSION['usuario_id'] = 15;
$_SESSION['usuario_nombre'] = 'Ana';

?>

Estos valores estarán disponibles en otras páginas mientras la sesión continúe activa.

Leer datos de sesión

<?php

session_start();

if (isset($_SESSION['usuario_id'])) {
    echo 'Usuario autenticado: ' . $_SESSION['usuario_nombre'];
}

?>

Crear la tabla de usuarios

Para construir un login necesitamos almacenar los usuarios en la base de datos.

CREATE TABLE usuarios (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    activo BOOLEAN DEFAULT TRUE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

La columna password tendrá suficiente espacio para almacenar el hash generado por PHP.

Nunca guardar contraseñas en texto plano

Una contraseña no debe almacenarse directamente en la base de datos.

PHP incluye password_hash(), que genera un hash seguro:

<?php

$password = 'MiClaveSegura123';

$hash = password_hash($password, PASSWORD_DEFAULT);

echo $hash;

?>

El resultado será diferente de la contraseña original y es lo que debemos guardar en la base.

Registrar un usuario con PDO

<?php

$nombre = $_POST['nombre'] ?? '';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

$hash = password_hash($password, PASSWORD_DEFAULT);

$sql = 'INSERT INTO usuarios (nombre, email, password)
        VALUES (:nombre, :email, :password)';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'password' => $hash
]);

?>

Formulario de login

<form method="POST" action="login.php">

    <label>Email</label>
    <input type="email" name="email" required>

    <label>Contraseña</label>
    <input type="password" name="password" required>

    <button type="submit">Ingresar</button>

</form>

Buscar al usuario por email

Cuando recibimos el formulario, buscamos al usuario mediante una consulta preparada:

<?php

session_start();

$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';

$sql = 'SELECT id, nombre, email, password, activo
        FROM usuarios
        WHERE email = :email
        LIMIT 1';

$stmt = $pdo->prepare($sql);
$stmt->execute([
    'email' => $email
]);

$usuario = $stmt->fetch(PDO::FETCH_ASSOC);

?>

Validar la contraseña con password_verify()

No debemos comparar la contraseña ingresada con el hash utilizando ==.

PHP proporciona password_verify():

if (
    $usuario
    && $usuario['activo']
    && password_verify($password, $usuario['password'])
) {
    // Login correcto
}

Crear la sesión después del login

Si los datos son correctos, guardamos solo la información necesaria en la sesión:

if (
    $usuario
    && $usuario['activo']
    && password_verify($password, $usuario['password'])
) {
    session_regenerate_id(true);

    $_SESSION['usuario_id'] = $usuario['id'];
    $_SESSION['usuario_nombre'] = $usuario['nombre'];

    header('Location: panel.php');
    exit;
}

session_regenerate_id(true) ayuda a reducir el riesgo de ataques de fijación de sesión después de autenticar al usuario.

Ejemplo completo de login.php

<?php

session_start();

require 'conexion.php';

$error = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $email = trim($_POST['email'] ?? '');
    $password = $_POST['password'] ?? '';

    $sql = 'SELECT id, nombre, password, activo
            FROM usuarios
            WHERE email = :email
            LIMIT 1';

    $stmt = $pdo->prepare($sql);

    $stmt->execute([
        'email' => $email
    ]);

    $usuario = $stmt->fetch(PDO::FETCH_ASSOC);

    if (
        $usuario
        && $usuario['activo']
        && password_verify($password, $usuario['password'])
    ) {
        session_regenerate_id(true);

        $_SESSION['usuario_id'] = $usuario['id'];
        $_SESSION['usuario_nombre'] = $usuario['nombre'];

        header('Location: panel.php');
        exit;
    }

    $error = 'Email o contraseña incorrectos';
}

?>

Es mejor mostrar un mensaje genérico como “Email o contraseña incorrectos” en lugar de revelar si el email existe o no.

Proteger una página privada

En panel.php podemos verificar si existe la sesión:

<?php

session_start();

if (!isset($_SESSION['usuario_id'])) {
    header('Location: login.php');
    exit;
}

?>

Después podemos mostrar información del usuario:

<h1>
    Bienvenido,
    <?= htmlspecialchars($_SESSION['usuario_nombre']) ?>
</h1>

Crear logout.php

Para cerrar sesión:

<?php

session_start();

$_SESSION = [];

session_destroy();

header('Location: login.php');
exit;

?>

¿Qué es una cookie?

Una cookie es un pequeño valor almacenado por el navegador y enviado posteriormente al servidor.

Podemos crear una cookie con setcookie():

<?php

setcookie(
    'tema',
    'oscuro',
    [
        'expires' => time() + 3600 * 24 * 30,
        'path' => '/',
        'httponly' => true,
        'samesite' => 'Lax'
    ]
);

?>

Leer una cookie

<?php

$tema = $_COOKIE['tema'] ?? 'claro';

echo $tema;

?>

Sesiones vs cookies

Aunque están relacionadas, no cumplen exactamente la misma función.

Sesiones

  • los datos principales se mantienen en el servidor;
  • son apropiadas para identificar a un usuario autenticado;
  • normalmente el navegador conserva solo un identificador de sesión.

Cookies

  • se almacenan en el navegador;
  • pueden sobrevivir al cierre del navegador según su fecha de expiración;
  • son útiles para preferencias y otras necesidades persistentes;
  • el usuario puede modificarlas, por lo que nunca debemos confiar ciegamente en su contenido.

No guardar contraseñas en cookies

Nunca debemos hacer algo como:

setcookie('password', $password);

Las contraseñas no deben almacenarse en cookies, sesiones, archivos de texto ni registros de log en texto plano.

¿Y la opción “Recordarme”?

Una función de “Recordarme” no debería guardar directamente el identificador del usuario ni su contraseña como mecanismo de autenticación.

Una implementación seria utiliza un token aleatorio almacenado de forma segura en la base de datos y una cookie asociada a ese token.

Ese tema merece una implementación específica y es mejor abordarlo después de comprender bien sesiones, seguridad y autenticación.

Configurar cookies de sesión de forma más segura

En una aplicación servida mediante HTTPS podemos mejorar la configuración de la cookie de sesión:

<?php

session_set_cookie_params([
    'httponly' => true,
    'secure' => true,
    'samesite' => 'Lax'
]);

session_start();

?>

secure debe utilizarse cuando el sitio trabaja mediante HTTPS.

Evitar guardar información innecesaria en la sesión

No necesitamos guardar el registro completo del usuario.

Normalmente alcanza con almacenar identificadores y algunos datos mínimos:

$_SESSION['usuario_id'] = $usuario['id'];
$_SESSION['usuario_nombre'] = $usuario['nombre'];

Si necesitamos información actualizada, podemos consultar nuevamente la base utilizando el identificador.

Errores comunes

  • olvidar ejecutar session_start();
  • enviar HTML antes de utilizar header() o setcookie();
  • guardar contraseñas en texto plano;
  • comparar contraseñas sin password_verify();
  • guardar datos sensibles dentro de cookies;
  • no regenerar el identificador de sesión después del login;
  • no verificar la sesión en cada página privada;
  • mostrar mensajes que permiten descubrir si una cuenta existe;
  • confiar en valores provenientes de cookies como si fueran datos seguros.

Ejercicio práctico

Creá un pequeño sistema con estas páginas:

  1. registro.php: crear un usuario utilizando password_hash();
  2. login.php: validar email y contraseña;
  3. panel.php: permitir el acceso solo a usuarios autenticados;
  4. logout.php: finalizar la sesión;
  5. una cookie para recordar una preferencia como el tema claro u oscuro.

¿Qué sigue?

En la próxima guía podemos avanzar con autenticación y autorización en PHP: roles, permisos, protección de rutas y control de acceso para distintos tipos de usuarios.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador para seguir publicando ejercicios, proyectos y guías gratuitas, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

CRUD completo con PHP, MySQL y PDO: crear, listar, editar, eliminar y buscar

Ya vimos cómo conectar PHP con MySQL mediante PDO, cómo leer datos y cómo ejecutar INSERT, UPDATE y DELETE.

Ahora vamos a integrar todo eso en una pequeña aplicación funcional. Construiremos un CRUD completo de usuarios con listado, búsqueda, alta, edición y eliminación.

CRUD es el acrónimo de:

Create  → crear
Read    → leer
Update  → actualizar
Delete  → eliminar

Para profundizar en SQL: en este proyecto utilizamos SELECT, WHERE, LIKE, INSERT, UPDATE y DELETE. La teoría de esas sentencias está desarrollada en nuestra ruta de aprendizaje SQL / MySQL.

Qué vamos a construir

La aplicación tendrá estas pantallas:

  • index.php: listado y búsqueda de usuarios;
  • crear.php: alta de usuarios;
  • editar.php: modificación;
  • eliminar.php: eliminación mediante POST;
  • conexion.php: conexión centralizada mediante PDO.

Estructura del proyecto

crud-usuarios/
│
├── conexion.php
├── index.php
├── crear.php
├── editar.php
└── eliminar.php

Base de datos

Seguiremos usando la base club_programador y la tabla usuarios:

CREATE TABLE usuarios (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    edad INT
);

1. conexion.php

Centralizamos la conexión para no repetirla en cada archivo.

<?php

$host = 'localhost';
$base = 'club_programador';
$usuario = 'root';
$password = '';

$dsn =
    "mysql:host=$host;dbname=$base;charset=utf8mb4";

try {

    $pdo = new PDO(
        $dsn,
        $usuario,
        $password,
        [
            PDO::ATTR_ERRMODE =>
                PDO::ERRMODE_EXCEPTION,

            PDO::ATTR_DEFAULT_FETCH_MODE =>
                PDO::FETCH_ASSOC
        ]
    );

} catch (PDOException $e) {

    exit(
        'No se pudo conectar a la base de datos'
    );
}

2. index.php: listado y buscador

La página principal mostrará todos los usuarios y permitirá buscar por nombre o email.

<?php

require 'conexion.php';

$busqueda = trim($_GET['q'] ?? '');

if ($busqueda === '') {

    $sql =
        'SELECT id, nombre, email, edad
         FROM usuarios
         ORDER BY nombre';

    $stmt = $pdo->query($sql);

} else {

    $sql =
        'SELECT id, nombre, email, edad
         FROM usuarios
         WHERE nombre LIKE :busqueda
            OR email LIKE :busqueda
         ORDER BY nombre';

    $stmt = $pdo->prepare($sql);

    $stmt->execute([
        'busqueda' => '%' . $busqueda . '%'
    ]);
}

$usuarios = $stmt->fetchAll();

$mensaje = $_GET['mensaje'] ?? '';

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Usuarios</title>
</head>
<body>

<h1>Usuarios</h1>

<p>
    <a href="crear.php">
        Crear usuario
    </a>
</p>

<?php if ($mensaje === 'creado'): ?>
    <p>Usuario creado correctamente.</p>
<?php elseif ($mensaje === 'actualizado'): ?>
    <p>Usuario actualizado correctamente.</p>
<?php elseif ($mensaje === 'eliminado'): ?>
    <p>Usuario eliminado correctamente.</p>
<?php endif; ?>

<form method="get">

    <input
        type="text"
        name="q"
        placeholder="Buscar..."
        value="<?php echo htmlspecialchars(
            $busqueda,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <button type="submit">
        Buscar
    </button>

</form>

<hr>

<?php if (empty($usuarios)): ?>

    <p>No se encontraron usuarios.</p>

<?php else: ?>

    <table border="1" cellpadding="8">

        <thead>
        <tr>
            <th>ID</th>
            <th>Nombre</th>
            <th>Email</th>
            <th>Edad</th>
            <th>Acciones</th>
        </tr>
        </thead>

        <tbody>

        <?php foreach ($usuarios as $usuario): ?>

            <tr>

                <td>
                    <?php echo $usuario['id']; ?>
                </td>

                <td>
                    <?php echo htmlspecialchars(
                        $usuario['nombre'],
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>
                </td>

                <td>
                    <?php echo htmlspecialchars(
                        $usuario['email'],
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>
                </td>

                <td>
                    <?php echo htmlspecialchars(
                        $usuario['edad'],
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>
                </td>

                <td>

                    <a href="editar.php?id=<?php
                        echo $usuario['id'];
                    ?>">
                        Editar
                    </a>

                    <form
                        method="post"
                        action="eliminar.php"
                        style="display:inline"
                        onsubmit="return confirm(
                            '¿Eliminar este usuario?'
                        );"
                    >

                        <input
                            type="hidden"
                            name="id"
                            value="<?php
                                echo $usuario['id'];
                            ?>"
                        >

                        <button type="submit">
                            Eliminar
                        </button>

                    </form>

                </td>

            </tr>

        <?php endforeach; ?>

        </tbody>

    </table>

<?php endif; ?>

</body>
</html>

Qué hace index.php

La página combina varias cosas que ya aprendimos:

  • recibe un filtro mediante GET;
  • usa una consulta fija cuando no existe búsqueda;
  • usa una consulta preparada cuando existe un valor dinámico;
  • recupera los resultados con fetchAll();
  • recorre los registros con foreach;
  • escapa los datos antes de mostrarlos en HTML;
  • ofrece acciones de editar y eliminar.

Para profundizar en SQL: en este proyecto utilizamos SELECT, WHERE, LIKE, INSERT, UPDATE y DELETE. La teoría de esas sentencias está desarrollada en nuestra ruta de aprendizaje SQL / MySQL.

3. crear.php

Esta pantalla permitirá agregar usuarios nuevos.

<?php

require 'conexion.php';

$nombre = '';
$email = '';
$edad = '';

$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $edad = trim($_POST['edad'] ?? '');

    if ($nombre === '') {
        $errores[] =
            'El nombre es obligatorio';
    }

    if (!filter_var(
        $email,
        FILTER_VALIDATE_EMAIL
    )) {
        $errores[] =
            'El email no es válido';
    }

    if (
        $edad !== '' &&
        filter_var(
            $edad,
            FILTER_VALIDATE_INT
        ) === false
    ) {
        $errores[] =
            'La edad debe ser un número entero';
    }

    if (empty($errores)) {

        $sql =
            'INSERT INTO usuarios
                (nombre, email, edad)
             VALUES
                (:nombre, :email, :edad)';

        $stmt = $pdo->prepare($sql);

        $stmt->execute([
            'nombre' => $nombre,
            'email' => $email,
            'edad' =>
                $edad === ''
                ? null
                : (int) $edad
        ]);

        header(
            'Location: index.php?mensaje=creado'
        );

        exit;
    }
}

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Crear usuario</title>
</head>
<body>

<h1>Crear usuario</h1>

<?php if (!empty($errores)): ?>

    <ul>
        <?php foreach ($errores as $error): ?>
            <li>
                <?php echo htmlspecialchars(
                    $error,
                    ENT_QUOTES,
                    'UTF-8'
                ); ?>
            </li>
        <?php endforeach; ?>
    </ul>

<?php endif; ?>

<form method="post">

    <label for="nombre">
        Nombre:
    </label>

    <input
        type="text"
        id="nombre"
        name="nombre"
        value="<?php echo htmlspecialchars(
            $nombre,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <label for="email">
        Email:
    </label>

    <input
        type="email"
        id="email"
        name="email"
        value="<?php echo htmlspecialchars(
            $email,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <label for="edad">
        Edad:
    </label>

    <input
        type="number"
        id="edad"
        name="edad"
        value="<?php echo htmlspecialchars(
            $edad,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <button type="submit">
        Guardar
    </button>

    <a href="index.php">
        Cancelar
    </a>

</form>

</body>
</html>

Post/Redirect/Get en el alta

Después de insertar el usuario no mostramos directamente una página de éxito.

En cambio hacemos:

header(
    'Location: index.php?mensaje=creado'
);

exit;

Esto evita que el navegador repita el INSERT si el usuario actualiza la página.

4. editar.php

La edición tiene dos etapas:

GET  → cargar el usuario
POST → validar y actualizar

Primero obtenemos y validamos el ID.

$id = filter_input(
    INPUT_GET,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

Cargar el usuario

$sql =
    'SELECT id, nombre, email, edad
     FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'id' => $id
]);

$usuario = $stmt->fetch();

if (!$usuario) {
    exit('Usuario no encontrado');
}

editar.php completo

<?php

require 'conexion.php';

$id = filter_input(
    INPUT_GET,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

$stmt = $pdo->prepare(
    'SELECT id, nombre, email, edad
     FROM usuarios
     WHERE id = :id'
);

$stmt->execute([
    'id' => $id
]);

$usuario = $stmt->fetch();

if (!$usuario) {
    exit('Usuario no encontrado');
}

$nombre = $usuario['nombre'];
$email = $usuario['email'];
$edad = $usuario['edad'];

$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $edad = trim($_POST['edad'] ?? '');

    if ($nombre === '') {
        $errores[] =
            'El nombre es obligatorio';
    }

    if (!filter_var(
        $email,
        FILTER_VALIDATE_EMAIL
    )) {
        $errores[] =
            'El email no es válido';
    }

    if (
        $edad !== '' &&
        filter_var(
            $edad,
            FILTER_VALIDATE_INT
        ) === false
    ) {
        $errores[] =
            'La edad no es válida';
    }

    if (empty($errores)) {

        $sql =
            'UPDATE usuarios
             SET nombre = :nombre,
                 email = :email,
                 edad = :edad
             WHERE id = :id';

        $stmt = $pdo->prepare($sql);

        $stmt->execute([
            'nombre' => $nombre,
            'email' => $email,
            'edad' =>
                $edad === ''
                ? null
                : (int) $edad,
            'id' => $id
        ]);

        header(
            'Location: index.php?mensaje=actualizado'
        );

        exit;
    }
}

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Editar usuario</title>
</head>
<body>

<h1>Editar usuario</h1>

<?php if (!empty($errores)): ?>

    <ul>
        <?php foreach ($errores as $error): ?>
            <li>
                <?php echo htmlspecialchars(
                    $error,
                    ENT_QUOTES,
                    'UTF-8'
                ); ?>
            </li>
        <?php endforeach; ?>
    </ul>

<?php endif; ?>

<form method="post">

    <label for="nombre">
        Nombre:
    </label>

    <input
        type="text"
        id="nombre"
        name="nombre"
        value="<?php echo htmlspecialchars(
            $nombre,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <label for="email">
        Email:
    </label>

    <input
        type="email"
        id="email"
        name="email"
        value="<?php echo htmlspecialchars(
            $email,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <label for="edad">
        Edad:
    </label>

    <input
        type="number"
        id="edad"
        name="edad"
        value="<?php echo htmlspecialchars(
            $edad,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <br><br>

    <button type="submit">
        Guardar cambios
    </button>

    <a href="index.php">
        Cancelar
    </a>

</form>

</body>
</html>

5. eliminar.php

La eliminación se ejecutará solamente mediante POST.

<?php

require 'conexion.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    exit('Método no permitido');
}

$id = filter_input(
    INPUT_POST,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

$stmt = $pdo->prepare(
    'DELETE FROM usuarios
     WHERE id = :id'
);

$stmt->execute([
    'id' => $id
]);

header(
    'Location: index.php?mensaje=eliminado'
);

exit;

Por qué DELETE usa POST

Eliminar información cambia el estado de la aplicación. No debería ocurrir simplemente al visitar una URL.

Por eso en index.php utilizamos:

<form
    method="post"
    action="eliminar.php"
>

    <input
        type="hidden"
        name="id"
        value="<?php echo $usuario['id']; ?>"
    >

    <button type="submit">
        Eliminar
    </button>

</form>

Flujo completo del CRUD

LISTAR
index.php
    ↓
SELECT
    ↓
tabla HTML


CREAR
crear.php
    ↓
formulario POST
    ↓
validación
    ↓
INSERT
    ↓
redirect a index.php


EDITAR
editar.php?id=5
    ↓
SELECT
    ↓
formulario precargado
    ↓
POST
    ↓
UPDATE
    ↓
redirect


ELIMINAR
index.php
    ↓
formulario POST
    ↓
eliminar.php
    ↓
DELETE
    ↓
redirect

Buscar forma parte del Read

En un CRUD real, leer datos no significa solamente listar todos los registros.

También suele incluir:

  • búsqueda;
  • filtros;
  • ordenamiento;
  • paginación;
  • vista de detalle.

En nuestro ejemplo incorporamos búsqueda mediante LIKE.

Para profundizar en SQL: en este proyecto utilizamos SELECT, WHERE, LIKE, INSERT, UPDATE y DELETE. La teoría de esas sentencias está desarrollada en nuestra ruta de aprendizaje SQL / MySQL.

Separar responsabilidades

Aunque esta estructura es correcta para aprender, vemos que crear.php y editar.php comienzan a tener bastante código.

En proyectos mayores normalmente separamos:

acceso a datos
validación
lógica de negocio
controladores
vistas

Ese tipo de organización nos prepara para comprender posteriormente frameworks como Laravel.

Un posible siguiente paso: reutilizar el formulario

Crear y editar contienen HTML muy parecido.

Podríamos extraerlo a un archivo común, por ejemplo:

formulario.php

y reutilizarlo desde ambas pantallas.

Ese tipo de mejoras reduce duplicación, aunque en esta guía mantuvimos los archivos completos para que pueda entenderse cada flujo por separado.

Seguridad: lo que ya estamos haciendo

Nuestro CRUD aplica varias prácticas importantes:

  • consultas preparadas;
  • validación de formularios;
  • validación de IDs;
  • escapado de salida con htmlspecialchars();
  • POST para eliminaciones;
  • Post/Redirect/Get.

Lo que todavía falta en una aplicación real

Este CRUD es didáctico. Un sistema de producción todavía debería considerar, entre otras cosas:

  • autenticación de usuarios;
  • autorización y permisos;
  • protección CSRF;
  • registro controlado de errores;
  • validaciones de negocio más completas;
  • manejo de duplicados, por ejemplo emails únicos;
  • paginación para grandes cantidades de registros;
  • separación más clara de responsabilidades.

Qué ocurre si el email ya existe

La base tiene una restricción UNIQUE sobre email. Si intentamos insertar un email duplicado, MySQL rechazará la operación.

Esto muestra algo importante: la validación de PHP y las restricciones de la base de datos trabajan juntas.

La aplicación puede validar primero, pero la base debe seguir protegiendo la integridad de los datos.

Mejoras propuestas para practicar

  • agregar una página de detalle;
  • agregar teléfono;
  • agregar estado activo/inactivo;
  • permitir ordenar por nombre;
  • agregar filtro por edad;
  • agregar paginación;
  • mejorar el diseño con CSS;
  • reutilizar el formulario de crear/editar;
  • manejar el error de email duplicado;
  • agregar un campo de fecha de creación.

Ejercicios

  1. crear los cinco archivos del proyecto;
  2. probar el listado sin usuarios;
  3. crear tres usuarios;
  4. buscar por nombre;
  5. buscar por email;
  6. editar un usuario;
  7. eliminar un usuario;
  8. comprobar que actualizar la página no repite una operación POST;
  9. mostrar un mensaje cuando una búsqueda no devuelve resultados;
  10. agregar una página de detalle;
  11. agregar un campo nuevo a la tabla y a todos los formularios;
  12. implementar una mejora propia al CRUD.

Qué aprendimos

  • qué significa CRUD;
  • cómo organizar un CRUD sencillo en varios archivos;
  • cómo listar datos desde MySQL;
  • cómo implementar búsqueda;
  • cómo crear registros;
  • cómo precargar y editar registros;
  • cómo eliminar mediante POST;
  • cómo utilizar Post/Redirect/Get;
  • cómo integrar formularios, PDO, MySQL y HTML;
  • qué diferencias existen entre un ejemplo didáctico y una aplicación de producción.

Continuá aprendiendo SQL / MySQL

Este CRUD utiliza varias consultas SQL, pero el objetivo de esta guía es integrarlas con PHP. Para profundizar en consultas, filtros, relaciones, índices, normalización y otros temas de base de datos:

👉 Ver la ruta completa de SQL / MySQL

¿Qué sigue?

Después de construir nuestro primer CRUD, el siguiente paso natural es aprender a trabajar con sesiones y autenticación en PHP: iniciar sesión, mantener un usuario autenticado, cerrar sesión y proteger páginas privadas.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

INSERT, UPDATE y DELETE con PHP y PDO: guardar, editar y eliminar datos

En la guía anterior conectamos PHP con MySQL utilizando PDO y aprendimos a leer datos con SELECT.

Ahora vamos a avanzar con las operaciones que permiten crear, modificar y eliminar registros. Es decir, vamos a empezar a trabajar con la parte operativa de una aplicación real.

¿Querés profundizar en la parte SQL?

En esta guía vamos a utilizar INSERT, UPDATE, DELETE y WHERE. La teoría completa de estas sentencias está desarrollada en nuestra ruta de aprendizaje SQL / MySQL.

Las tres operaciones que veremos

  • INSERT: crear registros nuevos;
  • UPDATE: modificar registros existentes;
  • DELETE: eliminar registros.

Las tres operaciones se realizarán con consultas preparadas mediante PDO.

Tabla de ejemplo

Seguimos trabajando con la tabla usuarios:

usuarios
-------------------------
id
nombre
email
edad

Insertar un usuario

La consulta SQL básica sería:

INSERT INTO usuarios (nombre, email, edad)
VALUES ('Ana', 'ana@email.com', 25);

Pero desde PHP no debemos concatenar directamente valores recibidos del usuario.

Utilizamos placeholders:

$sql =
    'INSERT INTO usuarios
        (nombre, email, edad)
     VALUES
        (:nombre, :email, :edad)';

$stmt = $pdo->prepare($sql);

Ejecutar el INSERT

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'edad' => $edad
]);

Formulario de alta

<form method="post">

    <label for="nombre">Nombre:</label>
    <input
        type="text"
        id="nombre"
        name="nombre"
    >

    <br><br>

    <label for="email">Email:</label>
    <input
        type="email"
        id="email"
        name="email"
    >

    <br><br>

    <label for="edad">Edad:</label>
    <input
        type="number"
        id="edad"
        name="edad"
    >

    <br><br>

    <button type="submit">
        Guardar
    </button>

</form>

Procesar el formulario

<?php

require 'conexion.php';

$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $edad = trim($_POST['edad'] ?? '');

    if ($nombre === '') {
        $errores[] = 'El nombre es obligatorio';
    }

    if (!filter_var(
        $email,
        FILTER_VALIDATE_EMAIL
    )) {
        $errores[] = 'El email no es válido';
    }

    if ($edad === '' || !is_numeric($edad)) {
        $errores[] = 'La edad no es válida';
    }

    if (empty($errores)) {

        $sql =
            'INSERT INTO usuarios
                (nombre, email, edad)
             VALUES
                (:nombre, :email, :edad)';

        $stmt = $pdo->prepare($sql);

        $stmt->execute([
            'nombre' => $nombre,
            'email' => $email,
            'edad' => $edad
        ]);

        echo 'Usuario guardado correctamente';
    }
}

Recuperar el ID insertado

Si la tabla utiliza una clave autoincremental, PDO puede devolver el último ID generado:

$id = $pdo->lastInsertId();

echo 'Nuevo usuario: ' . $id;

Esto puede ser útil si después queremos redirigir al detalle del registro recién creado.

Modificar un registro con UPDATE

Supongamos que queremos cambiar nombre y email de un usuario.

$sql =
    'UPDATE usuarios
     SET nombre = :nombre,
         email = :email,
         edad = :edad
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'nombre' => $nombre,
    'email' => $email,
    'edad' => $edad,
    'id' => $id
]);

¿Querés profundizar en la parte SQL?

En esta guía vamos a utilizar INSERT, UPDATE, DELETE y WHERE. La teoría completa de estas sentencias está desarrollada en nuestra ruta de aprendizaje SQL / MySQL.

¿Por qué WHERE es fundamental en UPDATE?

La cláusula WHERE indica qué registro debe modificarse.

Una consulta como:

UPDATE usuarios
SET nombre = 'Ana';

podría modificar todos los registros de la tabla.

Por eso, antes de ejecutar un UPDATE, debemos revisar con especial cuidado la condición utilizada.

Editar un usuario: primer paso

Normalmente recibimos el ID mediante GET:

editar.php?id=5

Lo validamos:

$id = filter_input(
    INPUT_GET,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

Buscar el usuario antes de editar

$sql =
    'SELECT id, nombre, email, edad
     FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'id' => $id
]);

$usuario = $stmt->fetch();

if (!$usuario) {
    exit('Usuario no encontrado');
}

Precargar el formulario

<form method="post">

    <input
        type="text"
        name="nombre"
        value="<?php echo htmlspecialchars(
            $usuario['nombre'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <input
        type="email"
        name="email"
        value="<?php echo htmlspecialchars(
            $usuario['email'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <input
        type="number"
        name="edad"
        value="<?php echo htmlspecialchars(
            $usuario['edad'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <button type="submit">
        Guardar cambios
    </button>

</form>

Procesar la edición

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $edad = trim($_POST['edad'] ?? '');

    if ($nombre === '') {
        $errores[] = 'El nombre es obligatorio';
    }

    if (!filter_var(
        $email,
        FILTER_VALIDATE_EMAIL
    )) {
        $errores[] = 'El email no es válido';
    }

    if (empty($errores)) {

        $sql =
            'UPDATE usuarios
             SET nombre = :nombre,
                 email = :email,
                 edad = :edad
             WHERE id = :id';

        $stmt = $pdo->prepare($sql);

        $stmt->execute([
            'nombre' => $nombre,
            'email' => $email,
            'edad' => $edad,
            'id' => $id
        ]);

        echo 'Usuario actualizado';
    }
}

Eliminar un registro

La operación DELETE también debe incluir una condición:

DELETE FROM usuarios
WHERE id = 5;

Desde PHP:

$sql =
    'DELETE FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'id' => $id
]);

No eliminar mediante enlaces GET

No conviene hacer algo como:

<a href="eliminar.php?id=5">
    Eliminar
</a>

Un enlace representa naturalmente una navegación. Para una acción destructiva es preferible utilizar un formulario con POST.

Formulario para eliminar

<form
    method="post"
    action="eliminar.php"
>

    <input
        type="hidden"
        name="id"
        value="5"
    >

    <button type="submit">
        Eliminar
    </button>

</form>

Procesar la eliminación

<?php

require 'conexion.php';

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    exit('Método no permitido');
}

$id = filter_input(
    INPUT_POST,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

$sql =
    'DELETE FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'id' => $id
]);

echo 'Usuario eliminado';

Validar que el registro exista

Antes de modificar o eliminar, podemos comprobar que el registro exista.

$stmt = $pdo->prepare(
    'SELECT id
     FROM usuarios
     WHERE id = :id'
);

$stmt->execute([
    'id' => $id
]);

if (!$stmt->fetch()) {
    exit('Usuario no encontrado');
}

Estructura sencilla de archivos

usuarios/
│
├── conexion.php
├── index.php
├── crear.php
├── editar.php
└── eliminar.php

Por ahora es una estructura simple y didáctica. Más adelante veremos cómo organizar aplicaciones más grandes.

index.php

Puede mostrar el listado de usuarios y los botones de acción:

<?php foreach ($usuarios as $usuario): ?>

    <p>

        <?php echo htmlspecialchars(
            $usuario['nombre'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>

        <a href="editar.php?id=<?php
            echo $usuario['id'];
        ?>">
            Editar
        </a>

        <form
            method="post"
            action="eliminar.php"
            style="display:inline"
        >
            <input
                type="hidden"
                name="id"
                value="<?php
                    echo $usuario['id'];
                ?>"
            >

            <button type="submit">
                Eliminar
            </button>
        </form>

    </p>

<?php endforeach; ?>

Mensajes después de una operación

En una aplicación real suele ser más cómodo redirigir después de insertar, actualizar o eliminar.

header(
    'Location: index.php?mensaje=creado'
);

exit;

Luego podemos mostrar un mensaje:

$mensaje = $_GET['mensaje'] ?? '';

if ($mensaje === 'creado') {
    echo 'Usuario creado correctamente';
}

Por qué redirigir después de POST

Después de procesar un formulario, una redirección evita que el navegador intente repetir la operación si el usuario actualiza la página.

Este patrón se conoce habitualmente como:

POST
 ↓
procesamiento
 ↓
redirect
 ↓
GET

También se lo conoce como Post/Redirect/Get.

Una mejora importante: confirmar eliminaciones

Podemos pedir confirmación en el navegador:

<form
    method="post"
    action="eliminar.php"
    onsubmit="return confirm(
        '¿Seguro que querés eliminar este usuario?'
    );"
>

Esto mejora la experiencia, aunque la autorización real de la operación siempre debe controlarse también en el servidor.

Consultas preparadas en todas las operaciones

En las tres operaciones mantenemos el mismo patrón:

$sql = '... :valor ...';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'valor' => $dato
]);

Esto evita construir SQL concatenando datos externos.

Errores comunes

  • concatenar valores del usuario dentro de SQL;
  • olvidar el WHERE en un UPDATE;
  • olvidar el WHERE en un DELETE;
  • usar GET para ejecutar una eliminación;
  • no validar IDs;
  • no comprobar si el registro existe;
  • no validar el formulario antes de guardar;
  • no utilizar consultas preparadas;
  • actualizar o eliminar registros sin verificar permisos;
  • repetir un POST accidentalmente al recargar la página.

Buenas prácticas

  • validar siempre los datos antes de escribir en la base;
  • utilizar prepare() y execute();
  • validar IDs con filtros apropiados;
  • comprobar la existencia del registro;
  • usar POST para acciones que modifican datos;
  • redireccionar después de operaciones exitosas;
  • escapar datos al mostrarlos en HTML;
  • controlar permisos antes de modificar o eliminar;
  • revisar cuidadosamente las condiciones de UPDATE y DELETE.

Ejercicios

  1. crear un formulario para insertar usuarios;
  2. validar nombre, email y edad;
  3. guardar el usuario mediante PDO;
  4. mostrar el ID generado con lastInsertId();
  5. crear una página de edición;
  6. precargar el formulario con los datos actuales;
  7. actualizar mediante una consulta preparada;
  8. crear un formulario POST para eliminar;
  9. validar el ID recibido;
  10. mostrar mensajes después de cada operación;
  11. aplicar Post/Redirect/Get;
  12. agregar confirmación antes de eliminar.

Qué aprendimos

  • cómo insertar registros con PDO;
  • cómo recuperar el ID insertado;
  • cómo modificar registros con UPDATE;
  • cómo precargar un formulario;
  • cómo eliminar registros;
  • por qué debemos validar IDs;
  • por qué las operaciones destructivas no deberían ejecutarse por GET;
  • cómo combinar formularios, PDO y consultas preparadas;
  • qué es Post/Redirect/Get.

Continuá aprendiendo SQL / MySQL

Para profundizar en INSERT, UPDATE, DELETE, restricciones, relaciones y demás conceptos propios de la base de datos, podés seguir nuestra ruta completa:

👉 Ver la ruta SQL / MySQL

¿Qué sigue?

En la próxima guía vamos a integrar todo lo aprendido en un CRUD completo con PHP, MySQL y PDO: listado, alta, edición, eliminación y búsqueda dentro de una pequeña aplicación funcional.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

PHP y MySQL con PDO: conexión, consultas preparadas y lectura de datos

Hasta ahora nuestros ejemplos de PHP trabajaron con datos que existían solamente mientras se ejecutaba el script. En una aplicación real normalmente necesitamos guardar información de manera permanente.

Para eso vamos a conectar PHP con MySQL utilizando PDO. Esta guía se concentra en la conexión, las consultas de lectura y las consultas preparadas. En la siguiente veremos altas, modificaciones y eliminaciones.

¿Todavía no manejás SQL?

En esta guía utilizaremos instrucciones como CREATE TABLE, SELECT, WHERE y ORDER BY, pero no vamos a repetir toda su teoría. Para aprenderlas desde cero podés seguir nuestra ruta completa:

👉 Ruta de aprendizaje SQL / MySQL

¿Qué papel cumple MySQL?

PHP ejecuta la lógica de nuestra aplicación. MySQL se encarga de almacenar y consultar los datos.

Navegador
    ↓
PHP
    ↓
PDO
    ↓
MySQL
    ↓
PDO
    ↓
PHP
    ↓
HTML
    ↓
Navegador

PDO funciona como la capa que utilizaremos desde PHP para comunicarnos con la base de datos.

¿Qué es PDO?

PDO significa PHP Data Objects. Es una extensión de PHP que ofrece una interfaz consistente para trabajar con bases de datos.

Entre otras cosas nos permite:

  • conectarnos a MySQL;
  • ejecutar consultas;
  • obtener resultados;
  • utilizar consultas preparadas;
  • manejar errores mediante excepciones;
  • trabajar de una forma más segura con datos recibidos del usuario.

La tabla que utilizaremos

Para los ejemplos vamos a trabajar con una base llamada club_programador y una tabla usuarios.

CREATE DATABASE club_programador;

USE club_programador;

CREATE TABLE usuarios (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    edad INT
);

Recordatorio: si conceptos como clave primaria, AUTO_INCREMENT, VARCHAR o restricciones todavía no te resultan familiares, están desarrollados en la ruta SQL / MySQL.

Datos de ejemplo

INSERT INTO usuarios (nombre, email, edad)
VALUES
    ('Ana', 'ana@email.com', 25),
    ('Juan', 'juan@email.com', 31),
    ('Lucía', 'lucia@email.com', 22);

Conectarse a MySQL con PDO

Creamos un archivo conexion.php:

<?php

$host = 'localhost';
$base = 'club_programador';
$usuario = 'root';
$password = '';

$dsn = "mysql:host=$host;dbname=$base;charset=utf8mb4";

$pdo = new PDO(
    $dsn,
    $usuario,
    $password
);

¿Qué es el DSN?

El DSN describe cómo debe conectarse PDO.

mysql:host=localhost;dbname=club_programador;charset=utf8mb4

Sus partes principales son:

  • mysql: controlador que utilizaremos;
  • host: servidor donde está MySQL;
  • dbname: nombre de la base;
  • charset: codificación utilizada en la conexión.

Usar utf8mb4

Para aplicaciones nuevas conviene trabajar con utf8mb4, ya que permite almacenar correctamente Unicode completo.

Manejar errores con excepciones

La conexión puede fallar por muchos motivos: servidor apagado, contraseña incorrecta, base inexistente, etc.

Podemos pedirle a PDO que lance excepciones:

$pdo->setAttribute(
    PDO::ATTR_ERRMODE,
    PDO::ERRMODE_EXCEPTION
);

Y manejar la conexión mediante try/catch:

<?php

$host = 'localhost';
$base = 'club_programador';
$usuario = 'root';
$password = '';

$dsn =
    "mysql:host=$host;dbname=$base;charset=utf8mb4";

try {

    $pdo = new PDO(
        $dsn,
        $usuario,
        $password
    );

    $pdo->setAttribute(
        PDO::ATTR_ERRMODE,
        PDO::ERRMODE_EXCEPTION
    );

    echo 'Conexión correcta';

} catch (PDOException $e) {

    echo 'No se pudo conectar a la base de datos';

}

En producción no conviene mostrar al usuario detalles internos de la excepción.

Configurar el modo de resultados

También podemos configurar PDO para que devuelva filas como arrays asociativos:

$pdo->setAttribute(
    PDO::ATTR_DEFAULT_FETCH_MODE,
    PDO::FETCH_ASSOC
);

Así una fila puede verse como:

[
    'id' => 1,
    'nombre' => 'Ana',
    'email' => 'ana@email.com',
    'edad' => 25
]

Archivo conexion.php recomendado

<?php

$host = 'localhost';
$base = 'club_programador';
$usuario = 'root';
$password = '';

$dsn =
    "mysql:host=$host;dbname=$base;charset=utf8mb4";

try {

    $pdo = new PDO(
        $dsn,
        $usuario,
        $password,
        [
            PDO::ATTR_ERRMODE =>
                PDO::ERRMODE_EXCEPTION,

            PDO::ATTR_DEFAULT_FETCH_MODE =>
                PDO::FETCH_ASSOC
        ]
    );

} catch (PDOException $e) {

    exit('No se pudo conectar a la base de datos');
}

Primer SELECT desde PHP

Una vez conectados podemos ejecutar una consulta:

$sql = 'SELECT id, nombre, email, edad
        FROM usuarios';

$stmt = $pdo->query($sql);

query() ejecuta la consulta y devuelve un objeto que representa el resultado.

La sentencia SELECT está explicada en detalle, junto con filtros, ordenamiento y agregaciones, dentro de la ruta de SQL / MySQL.

Obtener una fila con fetch()

$usuario = $stmt->fetch();

echo $usuario['nombre'];

fetch() obtiene una fila del resultado.

Obtener todas las filas con fetchAll()

$usuarios = $stmt->fetchAll();

Ahora $usuarios es un array que contiene todas las filas.

Recorrer los resultados

foreach ($usuarios as $usuario) {

    echo $usuario['nombre'];
    echo '<br>';
}

Mostrar resultados dentro de HTML

<?php

require 'conexion.php';

$sql =
    'SELECT id, nombre, email, edad
     FROM usuarios
     ORDER BY nombre';

$stmt = $pdo->query($sql);

$usuarios = $stmt->fetchAll();

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Usuarios</title>
</head>
<body>

<h1>Usuarios</h1>

<ul>

    <?php foreach ($usuarios as $usuario): ?>

        <li>
            <?php echo htmlspecialchars(
                $usuario['nombre'],
                ENT_QUOTES,
                'UTF-8'
            ); ?>

            -

            <?php echo htmlspecialchars(
                $usuario['email'],
                ENT_QUOTES,
                'UTF-8'
            ); ?>
        </li>

    <?php endforeach; ?>

</ul>

</body>
</html>

Separar conexión y consulta

Una estructura sencilla puede quedar así:

mi-proyecto/
│
├── conexion.php
├── usuarios.php
└── index.php

La conexión queda centralizada y los demás archivos pueden reutilizarla mediante:

require 'conexion.php';

¿Cuándo usar query()?

query() resulta práctico cuando la consulta es completamente fija y no contiene datos proporcionados por el usuario.

$stmt = $pdo->query(
    'SELECT id, nombre
     FROM usuarios
     ORDER BY nombre'
);

El problema aparece cuando necesitamos incorporar valores dinámicos.

El error que debemos evitar

Supongamos que recibimos un ID desde la URL.

No debemos construir la consulta concatenando directamente ese valor:

$id = $_GET['id'];

$sql =
    "SELECT *
     FROM usuarios
     WHERE id = " . $id;

Además de producir código frágil, concatenar datos externos dentro de SQL puede abrir la puerta a inyección SQL.

¿Qué es una inyección SQL?

Ocurre cuando un dato controlado por un usuario modifica la estructura de una consulta SQL de una forma que la aplicación no esperaba.

La solución habitual es utilizar consultas preparadas.

Consultas preparadas con prepare()

$sql =
    'SELECT id, nombre, email, edad
     FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

:id es un placeholder: un lugar reservado para un valor.

Ejecutar la consulta preparada

$stmt->execute([
    'id' => 1
]);

$usuario = $stmt->fetch();

PDO se encarga de enviar la consulta y el valor de forma apropiada.

Ejemplo completo: buscar por ID

<?php

require 'conexion.php';

$id = $_GET['id'] ?? null;

if ($id === null) {
    exit('Falta el ID');
}

$sql =
    'SELECT id, nombre, email, edad
     FROM usuarios
     WHERE id = :id';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'id' => $id
]);

$usuario = $stmt->fetch();

if (!$usuario) {
    exit('Usuario no encontrado');
}

?>

<h1>
    <?php echo htmlspecialchars(
        $usuario['nombre'],
        ENT_QUOTES,
        'UTF-8'
    ); ?>
</h1>

<p>
    Email:
    <?php echo htmlspecialchars(
        $usuario['email'],
        ENT_QUOTES,
        'UTF-8'
    ); ?>
</p>

Filtrar por email

$sql =
    'SELECT id, nombre, email
     FROM usuarios
     WHERE email = :email';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'email' => $email
]);

$usuario = $stmt->fetch();

Buscar por nombre con LIKE

$sql =
    'SELECT id, nombre, email
     FROM usuarios
     WHERE nombre LIKE :nombre
     ORDER BY nombre';

$stmt = $pdo->prepare($sql);

$stmt->execute([
    'nombre' => '%' . $busqueda . '%'
]);

$usuarios = $stmt->fetchAll();

Si querés entender mejor operadores como LIKE, filtros con WHERE, ordenamiento con ORDER BY y demás herramientas SQL, consultá la ruta SQL / MySQL. En este curso de PHP nos concentramos en cómo ejecutar esas consultas desde PHP.

query() frente a prepare()

Una regla sencilla para comenzar:

  • si la consulta es fija y no incorpora datos externos, query() puede ser suficiente;
  • si la consulta contiene valores variables, especialmente datos del usuario, preferí prepare() y execute().

PDO no reemplaza la validación

Las consultas preparadas ayudan a separar datos y SQL, pero todavía debemos validar los datos según las reglas de nuestra aplicación.

Por ejemplo, si esperamos un ID entero:

$id = filter_input(
    INPUT_GET,
    'id',
    FILTER_VALIDATE_INT
);

if ($id === false || $id === null) {
    exit('ID no válido');
}

Flujo completo de una consulta

1. El navegador solicita usuarios.php
2. PHP carga conexion.php
3. PDO se conecta a MySQL
4. PHP prepara o ejecuta un SELECT
5. MySQL procesa la consulta
6. MySQL devuelve las filas
7. PDO convierte los resultados
8. PHP trabaja con arrays
9. PHP genera HTML
10. El navegador recibe el HTML

Ejemplo práctico: listado con buscador

<?php

require 'conexion.php';

$busqueda = trim($_GET['q'] ?? '');

if ($busqueda === '') {

    $stmt = $pdo->query(
        'SELECT id, nombre, email
         FROM usuarios
         ORDER BY nombre'
    );

} else {

    $stmt = $pdo->prepare(
        'SELECT id, nombre, email
         FROM usuarios
         WHERE nombre LIKE :busqueda
         ORDER BY nombre'
    );

    $stmt->execute([
        'busqueda' => '%' . $busqueda . '%'
    ]);
}

$usuarios = $stmt->fetchAll();

?>

<form method="get">

    <input
        type="text"
        name="q"
        value="<?php echo htmlspecialchars(
            $busqueda,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <button type="submit">
        Buscar
    </button>

</form>

<ul>

<?php foreach ($usuarios as $usuario): ?>

    <li>
        <?php echo htmlspecialchars(
            $usuario['nombre'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>

        -

        <?php echo htmlspecialchars(
            $usuario['email'],
            ENT_QUOTES,
            'UTF-8'
        ); ?>
    </li>

<?php endforeach; ?>

</ul>

Qué estamos reutilizando de guías anteriores

Este ejemplo combina muchos conceptos que ya aprendimos:

  • formularios GET;
  • $_GET;
  • arrays;
  • condicionales;
  • foreach;
  • funciones incorporadas;
  • htmlspecialchars();
  • validación;
  • HTML y PHP en un mismo archivo.

Ahora agregamos una nueva pieza: persistencia mediante MySQL.

Errores comunes

  • escribir mal el host, base, usuario o contraseña;
  • olvidar especificar utf8mb4;
  • mostrar detalles internos de excepciones a usuarios finales;
  • concatenar valores recibidos del usuario dentro de SQL;
  • confundir fetch() con fetchAll();
  • olvidar ejecutar una consulta preparada;
  • suponer que una consulta siempre devuelve filas;
  • mostrar resultados en HTML sin escaparlos;
  • creer que PDO reemplaza la validación de los datos.

Buenas prácticas

  • centralizar la conexión;
  • utilizar utf8mb4;
  • trabajar con excepciones;
  • preferir arrays asociativos para resultados;
  • usar consultas preparadas cuando intervienen valores variables;
  • validar entradas antes de utilizarlas;
  • escapar datos cuando se imprimen en HTML;
  • mantener separada la teoría SQL de la lógica PHP cuando sea posible.

Ejercicios

  1. crear la base club_programador;
  2. crear la tabla usuarios;
  3. insertar cinco usuarios de prueba;
  4. crear conexion.php;
  5. listar todos los usuarios;
  6. mostrar solamente nombre y email;
  7. ordenar por nombre;
  8. buscar un usuario por ID mediante una consulta preparada;
  9. buscar por email;
  10. crear un buscador por nombre con LIKE;
  11. mostrar un mensaje cuando no existen resultados;
  12. crear una página de detalle usuario.php?id=1.

Qué aprendimos

  • cómo se relacionan PHP y MySQL;
  • qué es PDO;
  • qué es un DSN;
  • cómo conectarnos a MySQL;
  • cómo manejar errores;
  • cómo ejecutar un SELECT;
  • cómo usar fetch() y fetchAll();
  • cómo recorrer resultados;
  • qué son las consultas preparadas;
  • qué son los placeholders;
  • por qué debemos evitar concatenar datos del usuario dentro de SQL;
  • cómo integrar consultas, arrays y HTML.

Continuá aprendiendo SQL / MySQL

En las próximas guías de PHP utilizaremos cada vez más SQL. Siempre que necesites profundizar en una sentencia o concepto de base de datos, podés volver a nuestra ruta específica:

👉 Ver la ruta completa de SQL / MySQL

¿Qué sigue?

En la próxima guía veremos INSERT, UPDATE y DELETE con PHP y PDO: cómo guardar formularios en MySQL, modificar registros existentes y eliminar datos utilizando consultas preparadas.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Formularios avanzados en PHP: select, radio, checkbox y subida de archivos

En la guía anterior vimos cómo enviar un formulario HTML a PHP utilizando GET y POST, cómo validar datos y cómo volver a mostrar los valores cuando existe un error.

Ahora vamos a trabajar con formularios más completos: select, radio buttons, checkboxes, múltiples valores y carga de archivos. Son controles que aparecen constantemente en registros, perfiles, sistemas administrativos y aplicaciones reales.

Formulario avanzado: qué vamos a construir

Vamos a crear un formulario de perfil con:

  • nombre y email;
  • provincia mediante select;
  • tipo de usuario mediante radio buttons;
  • intereses mediante checkboxes;
  • biografía con textarea;
  • foto de perfil mediante carga de archivo;
  • validación del lado del servidor.

El formulario HTML completo

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Perfil</title>
</head>
<body>

    <h1>Completar perfil</h1>

    <form method="post" enctype="multipart/form-data">

        <label for="nombre">Nombre:</label>
        <input type="text" id="nombre" name="nombre">

        <br><br>

        <label for="email">Email:</label>
        <input type="email" id="email" name="email">

        <br><br>

        <label for="provincia">Provincia:</label>
        <select id="provincia" name="provincia">
            <option value="">Seleccionar...</option>
            <option value="la-pampa">La Pampa</option>
            <option value="cordoba">Córdoba</option>
            <option value="mendoza">Mendoza</option>
        </select>

        <br><br>

        <p>Tipo de usuario:</p>

        <label>
            <input type="radio" name="tipo" value="estudiante">
            Estudiante
        </label>

        <label>
            <input type="radio" name="tipo" value="profesional">
            Profesional
        </label>

        <br><br>

        <p>Intereses:</p>

        <label>
            <input type="checkbox" name="intereses[]" value="php">
            PHP
        </label>

        <label>
            <input type="checkbox" name="intereses[]" value="javascript">
            JavaScript
        </label>

        <label>
            <input type="checkbox" name="intereses[]" value="mysql">
            MySQL
        </label>

        <br><br>

        <label for="bio">Biografía:</label>
        <textarea id="bio" name="bio" rows="5"></textarea>

        <br><br>

        <label for="foto">Foto de perfil:</label>
        <input type="file" id="foto" name="foto">

        <br><br>

        <button type="submit">Guardar perfil</button>

    </form>

</body>
</html>

Select

El elemento select permite elegir una opción de una lista.

<select name="provincia">
    <option value="">Seleccionar...</option>
    <option value="la-pampa">La Pampa</option>
    <option value="cordoba">Córdoba</option>
</select>

Lo que recibe PHP es el atributo value de la opción seleccionada.

$provincia = $_POST['provincia'] ?? '';

Validar un select

No alcanza con comprobar que el valor no esté vacío. También conviene verificar que pertenezca al conjunto permitido.

$provinciasPermitidas = [
    'la-pampa',
    'cordoba',
    'mendoza'
];

if (!in_array($provincia, $provinciasPermitidas, true)) {
    $errores[] = 'La provincia seleccionada no es válida';
}

Esto evita confiar ciegamente en lo enviado por el navegador.

Radio buttons

Los radio buttons permiten elegir una sola opción dentro de un grupo.

<label>
    <input type="radio" name="tipo" value="estudiante">
    Estudiante
</label>

<label>
    <input type="radio" name="tipo" value="profesional">
    Profesional
</label>

Ambos comparten el mismo name. Por eso solamente uno puede quedar seleccionado.

$tipo = $_POST['tipo'] ?? '';

Checkboxes

Los checkboxes permiten seleccionar varias opciones.

Para recibir múltiples valores en PHP utilizamos corchetes en el atributo name:

<input type="checkbox" name="intereses[]" value="php">
<input type="checkbox" name="intereses[]" value="javascript">
<input type="checkbox" name="intereses[]" value="mysql">

Si el usuario selecciona PHP y MySQL, PHP puede recibir:

$_POST['intereses'] = [
    'php',
    'mysql'
];

Leer varios checkboxes

$intereses = $_POST['intereses'] ?? [];

foreach ($intereses as $interes) {
    echo htmlspecialchars($interes, ENT_QUOTES, 'UTF-8');
}

Validar múltiples valores

Conviene comprobar cada elemento recibido.

$interesesPermitidos = [
    'php',
    'javascript',
    'mysql'
];

foreach ($intereses as $interes) {

    if (!in_array($interes, $interesesPermitidos, true)) {
        $errores[] = 'Se recibió un interés no válido';
        break;
    }
}

Mantener un option seleccionado

Cuando el formulario tiene errores, podemos conservar la opción elegida:

<option
    value="la-pampa"
    <?php echo $provincia === 'la-pampa' ? 'selected' : ''; ?>
>
    La Pampa
</option>

Mantener un radio seleccionado

<input
    type="radio"
    name="tipo"
    value="estudiante"
    <?php echo $tipo === 'estudiante' ? 'checked' : ''; ?>
>

Mantener checkboxes seleccionados

<input
    type="checkbox"
    name="intereses[]"
    value="php"
    <?php echo in_array('php', $intereses, true) ? 'checked' : ''; ?>
>

Textarea

En un textarea el valor no se coloca en un atributo value. Se escribe entre la etiqueta de apertura y la de cierre.

<textarea name="bio"><?php
echo htmlspecialchars(
    $bio,
    ENT_QUOTES,
    'UTF-8'
);
?></textarea>

Carga de archivos

Para subir archivos necesitamos agregar este atributo al formulario:

<form method="post" enctype="multipart/form-data">

Sin multipart/form-data, el archivo no se enviará correctamente.

El input file

<input type="file" name="foto">

Los archivos no llegan mediante $_POST. PHP utiliza otra superglobal:

$_FILES

Qué contiene $_FILES

Para un campo llamado foto podemos tener:

$_FILES['foto'] = [
    'name' => 'perfil.jpg',
    'type' => 'image/jpeg',
    'tmp_name' => '/tmp/php123.tmp',
    'error' => 0,
    'size' => 153245
];

Los datos más importantes son:

  • name: nombre original;
  • tmp_name: ubicación temporal;
  • error: código de error;
  • size: tamaño en bytes.

Comprobar si la carga fue correcta

if (
    isset($_FILES['foto']) &&
    $_FILES['foto']['error'] === UPLOAD_ERR_OK
) {
    echo 'El archivo llegó correctamente';
}

Validar el tamaño

Por ejemplo, podemos establecer un máximo de 2 MB:

$maximo = 2 * 1024 * 1024;

if ($_FILES['foto']['size'] > $maximo) {
    $errores[] = 'La imagen no puede superar los 2 MB';
}

No confiar solamente en la extensión

Un nombre como foto.jpg no garantiza que el archivo realmente sea una imagen.

La validación de archivos requiere especial cuidado. Para una introducción simple podemos comenzar comprobando extensión y tamaño, pero en una aplicación real también conviene verificar el tipo real del archivo.

Obtener la extensión

$extension = strtolower(
    pathinfo(
        $_FILES['foto']['name'],
        PATHINFO_EXTENSION
    )
);

Validar extensiones permitidas

$permitidas = ['jpg', 'jpeg', 'png'];

if (!in_array($extension, $permitidas, true)) {
    $errores[] = 'Formato de imagen no permitido';
}

Mover el archivo subido

Cuando PHP recibe un archivo, primero lo guarda temporalmente.

Si todo es correcto, podemos moverlo:

$destino = __DIR__ . '/uploads/perfil.' . $extension;

if (!move_uploaded_file(
    $_FILES['foto']['tmp_name'],
    $destino
)) {
    $errores[] = 'No se pudo guardar la imagen';
}

move_uploaded_file() está diseñada específicamente para archivos recibidos mediante HTTP POST.

Evitar usar directamente el nombre original

No conviene guardar archivos utilizando directamente:

$_FILES['foto']['name']

El nombre puede contener datos inesperados o coincidir con otro archivo existente.

En proyectos reales suele generarse un nombre controlado por la aplicación.

Formulario completo con procesamiento

Ahora vamos a unir todos los conceptos en un archivo llamado perfil.php.

<?php

$nombre = '';
$email = '';
$provincia = '';
$tipo = '';
$intereses = [];
$bio = '';

$errores = [];
$guardado = false;

$provinciasPermitidas = [
    'la-pampa',
    'cordoba',
    'mendoza'
];

$tiposPermitidos = [
    'estudiante',
    'profesional'
];

$interesesPermitidos = [
    'php',
    'javascript',
    'mysql'
];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $provincia = $_POST['provincia'] ?? '';
    $tipo = $_POST['tipo'] ?? '';
    $intereses = $_POST['intereses'] ?? [];
    $bio = trim($_POST['bio'] ?? '');

    if ($nombre === '') {
        $errores[] = 'El nombre es obligatorio';
    }

    if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errores[] = 'El email no es válido';
    }

    if (!in_array(
        $provincia,
        $provinciasPermitidas,
        true
    )) {
        $errores[] = 'Provincia no válida';
    }

    if (!in_array(
        $tipo,
        $tiposPermitidos,
        true
    )) {
        $errores[] = 'Tipo de usuario no válido';
    }

    if (!is_array($intereses)) {
        $intereses = [];
        $errores[] = 'Los intereses no son válidos';
    }

    foreach ($intereses as $interes) {

        if (!in_array(
            $interes,
            $interesesPermitidos,
            true
        )) {
            $errores[] = 'Interés no válido';
            break;
        }
    }

    if (
        isset($_FILES['foto']) &&
        $_FILES['foto']['error'] === UPLOAD_ERR_OK
    ) {

        $maximo = 2 * 1024 * 1024;

        if ($_FILES['foto']['size'] > $maximo) {
            $errores[] =
                'La imagen no puede superar los 2 MB';
        }

        $extension = strtolower(
            pathinfo(
                $_FILES['foto']['name'],
                PATHINFO_EXTENSION
            )
        );

        $extensionesPermitidas = [
            'jpg',
            'jpeg',
            'png'
        ];

        if (!in_array(
            $extension,
            $extensionesPermitidas,
            true
        )) {
            $errores[] =
                'Formato de imagen no permitido';
        }
    }

    if (empty($errores)) {

        if (
            isset($_FILES['foto']) &&
            $_FILES['foto']['error'] === UPLOAD_ERR_OK
        ) {

            $nombreArchivo =
                'perfil_' . time() . '.' . $extension;

            $destino =
                __DIR__ . '/uploads/' . $nombreArchivo;

            if (!move_uploaded_file(
                $_FILES['foto']['tmp_name'],
                $destino
            )) {
                $errores[] =
                    'No se pudo guardar la imagen';
            }
        }
    }

    if (empty($errores)) {
        $guardado = true;
    }
}

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Perfil</title>
</head>
<body>

<h1>Completar perfil</h1>

<?php if ($guardado): ?>

    <p>Perfil guardado correctamente.</p>

<?php else: ?>

    <?php if (!empty($errores)): ?>

        <ul>
            <?php foreach ($errores as $error): ?>
                <li>
                    <?php echo htmlspecialchars(
                        $error,
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>
                </li>
            <?php endforeach; ?>
        </ul>

    <?php endif; ?>

    <form
        method="post"
        enctype="multipart/form-data"
    >

        <label for="nombre">Nombre:</label>

        <input
            type="text"
            id="nombre"
            name="nombre"
            value="<?php echo htmlspecialchars(
                $nombre,
                ENT_QUOTES,
                'UTF-8'
            ); ?>"
        >

        <br><br>

        <label for="email">Email:</label>

        <input
            type="email"
            id="email"
            name="email"
            value="<?php echo htmlspecialchars(
                $email,
                ENT_QUOTES,
                'UTF-8'
            ); ?>"
        >

        <br><br>

        <label for="provincia">
            Provincia:
        </label>

        <select
            id="provincia"
            name="provincia"
        >
            <option value="">
                Seleccionar...
            </option>

            <option
                value="la-pampa"
                <?php echo
                    $provincia === 'la-pampa'
                    ? 'selected'
                    : '';
                ?>
            >
                La Pampa
            </option>

            <option
                value="cordoba"
                <?php echo
                    $provincia === 'cordoba'
                    ? 'selected'
                    : '';
                ?>
            >
                Córdoba
            </option>

            <option
                value="mendoza"
                <?php echo
                    $provincia === 'mendoza'
                    ? 'selected'
                    : '';
                ?>
            >
                Mendoza
            </option>
        </select>

        <br><br>

        <p>Tipo de usuario:</p>

        <label>
            <input
                type="radio"
                name="tipo"
                value="estudiante"
                <?php echo
                    $tipo === 'estudiante'
                    ? 'checked'
                    : '';
                ?>
            >
            Estudiante
        </label>

        <label>
            <input
                type="radio"
                name="tipo"
                value="profesional"
                <?php echo
                    $tipo === 'profesional'
                    ? 'checked'
                    : '';
                ?>
            >
            Profesional
        </label>

        <br><br>

        <p>Intereses:</p>

        <label>
            <input
                type="checkbox"
                name="intereses[]"
                value="php"
                <?php echo in_array(
                    'php',
                    $intereses,
                    true
                ) ? 'checked' : ''; ?>
            >
            PHP
        </label>

        <label>
            <input
                type="checkbox"
                name="intereses[]"
                value="javascript"
                <?php echo in_array(
                    'javascript',
                    $intereses,
                    true
                ) ? 'checked' : ''; ?>
            >
            JavaScript
        </label>

        <label>
            <input
                type="checkbox"
                name="intereses[]"
                value="mysql"
                <?php echo in_array(
                    'mysql',
                    $intereses,
                    true
                ) ? 'checked' : ''; ?>
            >
            MySQL
        </label>

        <br><br>

        <label for="bio">Biografía:</label>

        <textarea
            id="bio"
            name="bio"
            rows="5"
        ><?php echo htmlspecialchars(
            $bio,
            ENT_QUOTES,
            'UTF-8'
        ); ?></textarea>

        <br><br>

        <label for="foto">
            Foto de perfil:
        </label>

        <input
            type="file"
            id="foto"
            name="foto"
            accept=".jpg,.jpeg,.png"
        >

        <br><br>

        <button type="submit">
            Guardar perfil
        </button>

    </form>

<?php endif; ?>

</body>
</html>

Qué ocurre en este ejemplo

La secuencia es:

1. El usuario abre perfil.php
2. PHP muestra el formulario
3. El usuario completa los campos
4. Selecciona opciones
5. Adjunta una imagen
6. Presiona Guardar perfil
7. El navegador envía POST multipart/form-data
8. Los campos llegan a $_POST
9. El archivo llega a $_FILES
10. PHP valida todos los datos
11. Si hay errores, vuelve a mostrar el formulario
12. Si todo está correcto, mueve el archivo
13. La aplicación continúa con el procesamiento

Campos opcionales

No todos los campos tienen que ser obligatorios.

Por ejemplo, una biografía puede ser opcional:

$bio = trim($_POST['bio'] ?? '');

if ($bio !== '' && strlen($bio) > 500) {
    $errores[] =
        'La biografía no puede superar 500 caracteres';
}

Primero comprobamos si existe contenido y solamente entonces aplicamos la regla adicional.

HTML no es una barrera de seguridad

Podemos escribir:

<input
    type="file"
    name="foto"
    accept=".jpg,.jpeg,.png"
>

pero accept solamente ayuda al usuario a elegir archivos adecuados. La validación real debe realizarse nuevamente en PHP.

Errores comunes

  • olvidar enctype="multipart/form-data";
  • intentar leer archivos desde $_POST;
  • olvidar los corchetes en checkboxes múltiples;
  • confiar en valores de select, radio o checkbox sin validarlos;
  • confiar solamente en la extensión del archivo;
  • guardar directamente el nombre original;
  • no limitar el tamaño de los archivos;
  • no conservar las opciones seleccionadas cuando existe un error;
  • confiar solamente en atributos HTML como required o accept.

Buenas prácticas

  • validar todos los datos nuevamente en PHP;
  • usar listas de valores permitidos;
  • tratar los archivos con reglas más estrictas que un campo de texto;
  • generar nombres controlados para los archivos;
  • limitar tamaño y formatos;
  • mostrar mensajes claros al usuario;
  • mantener los datos cargados después de un error;
  • escapar siempre la salida cuando se imprime dentro de HTML.

Ejercicios

  1. crear un select con cinco provincias;
  2. crear un grupo de radio buttons para elegir nivel de experiencia;
  3. crear checkboxes para seleccionar tres tecnologías;
  4. mostrar en PHP todas las tecnologías elegidas;
  5. mantener seleccionados los campos después de un error;
  6. agregar una biografía opcional con máximo de caracteres;
  7. crear un input de archivo para una imagen;
  8. limitar el archivo a 2 MB;
  9. aceptar solamente JPG y PNG;
  10. guardar el archivo dentro de una carpeta uploads;
  11. crear un formulario de perfil completo con todos los controles vistos;
  12. agregar nuevas reglas de validación al ejemplo final.

Qué aprendimos

  • cómo utilizar select;
  • cómo trabajar con radio buttons;
  • cómo enviar múltiples checkboxes;
  • cómo recibir arrays desde un formulario;
  • cómo conservar selecciones después de un error;
  • cómo procesar un textarea;
  • cómo funciona multipart/form-data;
  • cómo utilizar $_FILES;
  • cómo validar tamaño y extensión;
  • cómo mover archivos subidos;
  • por qué la validación del servidor sigue siendo obligatoria.

¿Qué sigue?

Con esto ya tenemos una base muy sólida de PHP. El siguiente paso será comenzar con PHP y MySQL: conexión a una base de datos mediante PDO, consultas preparadas y lectura de registros.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal

Formularios HTML con PHP: GET, POST, validación y procesamiento paso a paso

En las guías anteriores trabajamos con variables, condicionales, bucles, arrays y funciones. Ahora vamos a conectar PHP con algo que cualquier usuario puede utilizar directamente: un formulario HTML.

La idea de esta guía es entender la secuencia completa, no solamente copiar código. Vamos a construir un formulario real, enviarlo al servidor, recibir los datos con PHP, validarlos y volver a mostrar el formulario cuando exista un error.

La secuencia completa

Cuando una persona completa un formulario y presiona el botón Enviar, ocurre algo parecido a esto:

Formulario HTML
      ↓
El usuario completa los campos
      ↓
Presiona "Enviar"
      ↓
El navegador crea una petición HTTP
      ↓
Los datos llegan al servidor
      ↓
PHP recibe los datos
      ↓
PHP valida la información
      ↓
Se procesa la operación
      ↓
El servidor devuelve una respuesta HTML

Este flujo es fundamental para entender el desarrollo web con PHP.

Primer formulario HTML

Empecemos con un archivo muy simple llamado formulario.html:

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Mi primer formulario</title>
</head>
<body>

    <h1>Registro de usuario</h1>

    <form action="procesar.php" method="post">
        <label for="nombre">Nombre:</label>
        <input type="text" id="nombre" name="nombre">

        <br><br>

        <label for="email">Email:</label>
        <input type="email" id="email" name="email">

        <br><br>

        <button type="submit">Enviar</button>
    </form>

</body>
</html>

¿Qué hace la etiqueta form?

La etiqueta <form> indica que ese bloque contiene datos que pueden enviarse al servidor.

<form action="procesar.php" method="post">

Hay dos atributos muy importantes:

  • action: indica a qué archivo se enviarán los datos;
  • method: indica cómo se enviarán.

El atributo name es clave

Dentro de un formulario, PHP recibe los campos utilizando el atributo name.

<input type="text" name="nombre">

El atributo id es útil para asociar etiquetas, JavaScript o CSS. Pero para enviar el dato al servidor, el atributo importante es name.

Qué recibe PHP

Si el usuario escribe:

Nombre: Ana
Email: ana@email.com

el navegador enviará aproximadamente estos pares de datos:

nombre = Ana
email = ana@email.com

Como usamos method="post", PHP los coloca dentro de la variable superglobal $_POST.

procesar.php

Creamos ahora un segundo archivo:

<?php

$nombre = $_POST['nombre'];
$email = $_POST['email'];

echo "Nombre: " . $nombre;
echo "<br>";
echo "Email: " . $email;

Si completamos el formulario y presionamos Enviar, el navegador solicitará procesar.php y PHP podrá leer los valores enviados.

¿Qué es $_POST?

$_POST es un array asociativo especial proporcionado por PHP.

Si el formulario contiene:

<input type="text" name="nombre">
<input type="email" name="email">

PHP puede recibir algo similar a:

$_POST = [
    'nombre' => 'Ana',
    'email' => 'ana@email.com'
];

Por eso lo que aprendimos sobre arrays asociativos empieza a tener una aplicación directa.

Variables superglobales

PHP incluye algunas variables especiales que están disponibles en cualquier ámbito del script. Se llaman superglobales.

Algunas de las más utilizadas son:

$_GET
$_POST
$_FILES
$_SERVER
$_SESSION
$_COOKIE

En esta guía nos concentraremos principalmente en $_GET, $_POST y $_SERVER.

GET y POST

Los dos métodos más comunes para enviar formularios son GET y POST.

Formulario usando GET

<form action="buscar.php" method="get">
    <input type="text" name="q">
    <button type="submit">Buscar</button>
</form>

Si escribimos:

php

el navegador podría abrir una URL similar a:

buscar.php?q=php

¿Qué significa la URL?

La parte que aparece después del signo ? se llama query string.

?q=php

Si hubiera más de un dato:

buscar.php?categoria=php&nivel=basico

Los valores se separan con &.

Leer datos GET

<?php

$busqueda = $_GET['q'];

echo "Buscaste: " . $busqueda;

¿Cuándo conviene GET?

GET es especialmente útil cuando la información representa una consulta y queremos que pueda formar parte de la URL.

Ejemplos:

  • buscadores;
  • filtros;
  • paginación;
  • ordenamientos;
  • parámetros que queremos poder compartir mediante un enlace.

¿Cuándo conviene POST?

POST suele utilizarse cuando estamos enviando información para realizar una operación.

Ejemplos:

  • registrar un usuario;
  • crear una publicación;
  • enviar un mensaje;
  • guardar cambios;
  • procesar un formulario de contacto.

Que POST no muestre los datos en la URL no significa que los datos estén cifrados. La protección durante el transporte depende de utilizar HTTPS.

Evitar errores cuando un campo no existe

Este código puede generar una advertencia si todavía no se envió el formulario:

$nombre = $_POST['nombre'];

Una alternativa muy utilizada es el operador ??:

$nombre = $_POST['nombre'] ?? '';

Si la clave nombre no existe, se utiliza un string vacío.

Detectar si el formulario fue enviado

Podemos consultar el método HTTP utilizando $_SERVER['REQUEST_METHOD'].

<?php

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    echo "El formulario fue enviado";
}

Esto permite tener el formulario y su procesamiento dentro del mismo archivo.

Formulario y PHP en el mismo archivo

Ahora vamos a crear registro.php.

<?php

$nombre = '';
$email = '';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $nombre = $_POST['nombre'] ?? '';
    $email = $_POST['email'] ?? '';
}

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Registro</title>
</head>
<body>

    <h1>Registro</h1>

    <form method="post">
        <label for="nombre">Nombre:</label>
        <input
            type="text"
            id="nombre"
            name="nombre"
            value="<?php echo htmlspecialchars($nombre); ?>"
        >

        <br><br>

        <label for="email">Email:</label>
        <input
            type="email"
            id="email"
            name="email"
            value="<?php echo htmlspecialchars($email); ?>"
        >

        <br><br>

        <button type="submit">Enviar</button>
    </form>

</body>
</html>

Como no indicamos action, el formulario se envía al mismo archivo.

¿Por qué usamos htmlspecialchars()?

Supongamos que un usuario escribe caracteres especiales o incluso código HTML.

Si imprimimos ese contenido directamente dentro de la página, el navegador podría interpretarlo como HTML.

htmlspecialchars() convierte caracteres especiales en una representación segura para mostrarlos como texto.

echo htmlspecialchars($nombre);

Esto es muy importante al mostrar información que proviene del usuario.

Validar campos obligatorios

Ahora vamos a comprobar que nombre y email tengan contenido.

<?php

$nombre = $_POST['nombre'] ?? '';
$email = $_POST['email'] ?? '';

$errores = [];

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    if (trim($nombre) === '') {
        $errores[] = 'El nombre es obligatorio';
    }

    if (trim($email) === '') {
        $errores[] = 'El email es obligatorio';
    }
}

¿Qué hace trim()?

trim() elimina espacios al comienzo y al final de un string.

De esta manera, un usuario que escriba solamente espacios no supera la validación.

Validar un email

PHP incluye filter_var(), que permite aplicar distintos filtros.

if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
    $errores[] = 'El email no es válido';
}

No necesitamos construir manualmente una expresión regular para un caso común como este.

Validar edad

Agreguemos un campo numérico:

<label for="edad">Edad:</label>
<input
    type="number"
    id="edad"
    name="edad"
    min="0"
>

Y en PHP:

$edad = $_POST['edad'] ?? '';

if ($edad === '') {
    $errores[] = 'La edad es obligatoria';
} elseif (filter_var($edad, FILTER_VALIDATE_INT) === false) {
    $errores[] = 'La edad debe ser un número entero';
} elseif ((int) $edad < 18) {
    $errores[] = 'Debés ser mayor de edad';
}

Validación del navegador y validación del servidor

HTML también permite realizar algunas validaciones:

<input type="text" name="nombre" required>
<input type="email" name="email" required>
<input type="number" name="edad" min="18" required>

Estas validaciones mejoran la experiencia del usuario, pero no reemplazan la validación en PHP.

Un usuario o programa puede enviar una petición directamente al servidor sin utilizar nuestro formulario HTML.

Por eso, las reglas importantes siempre deben comprobarse nuevamente en el servidor.

Validar no es lo mismo que sanitizar

Validar significa comprobar si un dato cumple determinadas reglas.

Por ejemplo:

¿El email tiene un formato válido?
¿La edad es un entero?
¿El nombre está completo?

Sanitizar o escapar significa preparar los datos para un contexto determinado.

Por ejemplo, al mostrar texto dentro de HTML:

htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8');

Una buena aplicación suele necesitar ambas cosas.

Mostrar errores en el formulario

<?php if (!empty($errores)): ?>

    <ul>
        <?php foreach ($errores as $error): ?>
            <li>
                <?php echo htmlspecialchars($error); ?>
            </li>
        <?php endforeach; ?>
    </ul>

<?php endif; ?>

¿Qué hace empty()?

empty() permite comprobar si una variable está vacía.

if (empty($errores)) {
    echo "No hay errores";
}

Como $errores es un array, un array sin elementos se considera vacío.

Mantener los valores cuando ocurre un error

Si el usuario completa un formulario y hay un error, no queremos obligarlo a escribir todo de nuevo.

Por eso usamos el valor recibido nuevamente en el atributo value:

<input
    type="text"
    name="nombre"
    value="<?php echo htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8'); ?>"
>

De esta manera el campo conserva lo que había escrito.

Formulario completo: HTML + PHP + validación

Veamos ahora el ejemplo completo en un único archivo llamado registro.php.

<?php

$nombre = '';
$email = '';
$edad = '';
$mensaje = '';

$errores = [];
$enviado = false;

if ($_SERVER['REQUEST_METHOD'] === 'POST') {

    $nombre = trim($_POST['nombre'] ?? '');
    $email = trim($_POST['email'] ?? '');
    $edad = trim($_POST['edad'] ?? '');
    $mensaje = trim($_POST['mensaje'] ?? '');

    if ($nombre === '') {
        $errores[] = 'El nombre es obligatorio';
    }

    if ($email === '') {
        $errores[] = 'El email es obligatorio';
    } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
        $errores[] = 'El email no es válido';
    }

    if ($edad === '') {
        $errores[] = 'La edad es obligatoria';
    } elseif (filter_var($edad, FILTER_VALIDATE_INT) === false) {
        $errores[] = 'La edad debe ser un número entero';
    } elseif ((int) $edad < 18) {
        $errores[] = 'Debés ser mayor de edad';
    }

    if ($mensaje === '') {
        $errores[] = 'El mensaje es obligatorio';
    }

    if (empty($errores)) {
        $enviado = true;
    }
}

?>

<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <title>Formulario de registro</title>
</head>
<body>

    <h1>Formulario de registro</h1>

    <?php if ($enviado): ?>

        <h2>Datos recibidos correctamente</h2>

        <p>
            Nombre:
            <?php echo htmlspecialchars($nombre, ENT_QUOTES, 'UTF-8'); ?>
        </p>

        <p>
            Email:
            <?php echo htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); ?>
        </p>

        <p>
            Edad:
            <?php echo htmlspecialchars($edad, ENT_QUOTES, 'UTF-8'); ?>
        </p>

        <p>
            Mensaje:
            <?php echo nl2br(
                htmlspecialchars($mensaje, ENT_QUOTES, 'UTF-8')
            ); ?>
        </p>

    <?php else: ?>

        <?php if (!empty($errores)): ?>

            <ul>
                <?php foreach ($errores as $error): ?>
                    <li>
                        <?php echo htmlspecialchars(
                            $error,
                            ENT_QUOTES,
                            'UTF-8'
                        ); ?>
                    </li>
                <?php endforeach; ?>
            </ul>

        <?php endif; ?>

        <form method="post">

            <div>
                <label for="nombre">Nombre:</label>

                <input
                    type="text"
                    id="nombre"
                    name="nombre"
                    value="<?php echo htmlspecialchars(
                        $nombre,
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>"
                    required
                >
            </div>

            <br>

            <div>
                <label for="email">Email:</label>

                <input
                    type="email"
                    id="email"
                    name="email"
                    value="<?php echo htmlspecialchars(
                        $email,
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>"
                    required
                >
            </div>

            <br>

            <div>
                <label for="edad">Edad:</label>

                <input
                    type="number"
                    id="edad"
                    name="edad"
                    min="18"
                    value="<?php echo htmlspecialchars(
                        $edad,
                        ENT_QUOTES,
                        'UTF-8'
                    ); ?>"
                    required
                >
            </div>

            <br>

            <div>
                <label for="mensaje">Mensaje:</label>

                <textarea
                    id="mensaje"
                    name="mensaje"
                    rows="5"
                    required
                ><?php echo htmlspecialchars(
                    $mensaje,
                    ENT_QUOTES,
                    'UTF-8'
                ); ?></textarea>
            </div>

            <br>

            <button type="submit">
                Enviar
            </button>

        </form>

    <?php endif; ?>

</body>
</html>

Analicemos la secuencia de este ejemplo

1. El navegador solicita registro.php.

Como todavía no se envió ningún formulario, la petición normalmente es GET.

GET /registro.php

2. PHP ejecuta el archivo.

La condición:

$_SERVER['REQUEST_METHOD'] === 'POST'

es falsa, por lo que simplemente se muestra el formulario.

3. El usuario completa los campos.

Nombre: Ana
Email: ana@email.com
Edad: 25
Mensaje: Quiero registrarme

4. El usuario presiona Enviar.

El navegador genera una nueva petición, esta vez usando POST:

POST /registro.php

5. PHP recibe los datos.

$_POST['nombre']
$_POST['email']
$_POST['edad']
$_POST['mensaje']

6. PHP valida cada dato.

Si existe algún problema, se agrega un mensaje al array $errores.

7. Si hay errores, el formulario vuelve a mostrarse.

Los valores anteriores se conservan para que el usuario pueda corregir solamente lo necesario.

8. Si no hay errores, se procesa la información.

En nuestro ejemplo simplemente mostramos los datos. Más adelante podríamos:

  • guardarlos en MySQL;
  • enviar un correo;
  • registrar un usuario;
  • crear una reserva;
  • generar una factura;
  • ejecutar cualquier otra lógica de negocio.

¿Dónde termina HTML y dónde empieza PHP?

Una de las particularidades de PHP es que podemos alternar entre HTML y PHP dentro del mismo archivo.

<h1>Bienvenido</h1>

<?php

$nombre = 'Ana';

?>

<p>
    Hola <?php echo htmlspecialchars($nombre); ?>
</p>

El servidor procesa primero el PHP. El navegador recibe solamente el HTML resultante.

El navegador nunca recibe el código PHP

Esto es importante:

Servidor:
registro.php
    ↓
PHP ejecuta el código
    ↓
genera HTML
    ↓
Navegador recibe HTML

El usuario no descarga el código PHP original desde el navegador.

Ejemplo práctico con GET: buscador

<?php

$q = trim($_GET['q'] ?? '');

?>

<form method="get">

    <label for="q">Buscar:</label>

    <input
        type="text"
        id="q"
        name="q"
        value="<?php echo htmlspecialchars(
            $q,
            ENT_QUOTES,
            'UTF-8'
        ); ?>"
    >

    <button type="submit">
        Buscar
    </button>

</form>

<?php if ($q !== ''): ?>

    <p>
        Buscando resultados para:
        <strong>
            <?php echo htmlspecialchars(
                $q,
                ENT_QUOTES,
                'UTF-8'
            ); ?>
        </strong>
    </p>

<?php endif; ?>

Si buscamos php arrays, la URL podría ser:

buscar.php?q=php+arrays

Esto tiene sentido porque una búsqueda es algo que puede compartirse mediante una URL.

Un error común: confiar solo en required

Este formulario parece validar el nombre:

<input type="text" name="nombre" required>

Pero un cliente HTTP podría enviar directamente:

POST /registro.php

nombre=

sin utilizar nuestro HTML.

Por eso seguimos necesitando:

if (trim($_POST['nombre'] ?? '') === '') {
    $errores[] = 'El nombre es obligatorio';
}

Otro error común: imprimir datos directamente

No conviene hacer esto con información proporcionada por el usuario:

echo $_POST['nombre'];

Es preferible escapar la salida:

echo htmlspecialchars(
    $_POST['nombre'] ?? '',
    ENT_QUOTES,
    'UTF-8'
);

HTMLspecialchars no reemplaza la validación

htmlspecialchars() sirve para mostrar contenido dentro de HTML de manera segura.

No comprueba si:

  • un email es válido;
  • un número está dentro de un rango;
  • una fecha existe;
  • un usuario tiene permisos;
  • un dato cumple las reglas de negocio.

Cada problema necesita su propia validación.

Errores comunes

  • olvidar el atributo name;
  • confundir id con name;
  • usar $_GET cuando el formulario envía por POST;
  • acceder a una clave de $_POST antes de comprobar si existe;
  • confiar solamente en la validación HTML;
  • mostrar datos del usuario sin escaparlos;
  • confundir validación con sanitización;
  • no conservar los valores del formulario después de un error;
  • usar GET para operaciones que modifican información.

Buenas prácticas

  • usar GET para consultas y filtros;
  • usar POST para operaciones que envían o modifican información;
  • validar siempre en el servidor;
  • escapar la salida al imprimir datos dentro de HTML;
  • utilizar mensajes de error claros;
  • mantener los valores que el usuario ya escribió;
  • separar progresivamente la presentación de la lógica cuando el proyecto crece.

Ejercicios

  1. crear un formulario con nombre y apellido usando POST;
  2. mostrar los datos recibidos con PHP;
  3. crear un buscador sencillo usando GET;
  4. agregar un campo email y validarlo con filter_var();
  5. agregar edad y exigir un mínimo de 18 años;
  6. guardar los errores dentro de un array;
  7. mostrar todos los errores con foreach;
  8. mantener los valores escritos cuando hay un error;
  9. agregar un campo select para elegir una provincia;
  10. agregar varios checkbox y observar cómo llegan a PHP;
  11. crear un formulario de contacto completo;
  12. explicar con tus palabras la secuencia navegador → HTTP → PHP → respuesta.

Qué aprendimos

  • cómo construir un formulario HTML;
  • qué hacen action y method;
  • por qué name es fundamental;
  • diferencia entre GET y POST;
  • cómo funcionan $_GET y $_POST;
  • qué son las superglobales;
  • cómo detectar el método HTTP;
  • cómo validar campos;
  • cómo validar emails y números;
  • diferencia entre validación y sanitización;
  • cómo usar htmlspecialchars();
  • cómo conservar valores después de un error;
  • cómo combinar HTML y PHP en un mismo archivo;
  • cómo funciona la secuencia completa de envío de un formulario.

¿Qué sigue?

En la próxima guía podemos profundizar en formularios PHP más completos: select, checkbox, radio buttons, múltiples valores, archivos con $_FILES y validaciones más avanzadas.

¿Te sirvió esta guía? ☕

Si este contenido te ayudó y querés apoyar a Club Programador, podés colaborar mediante:

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal