20 ejercicios avanzados de PHP resueltos: POO, MySQL, seguridad y MVC

Estos 20 ejercicios avanzados de PHP resueltos están pensados para quienes ya dominan sintaxis, formularios, PDO y bases de datos y quieren practicar conceptos de aplicaciones reales.

Vas a trabajar con POO, seguridad, autenticación, roles, consultas preparadas, MVC y Composer.

1. Crear una clase Producto

<?php
class Producto
{
    public function __construct(
        private string $nombre,
        private float $precio
    ) {
    }

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

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

2. Encapsular el stock de un producto

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

    $this->stock -= $cantidad;
}

3. Crear una interfaz de notificaciones

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

4. Implementar notificaciones por email

class EmailNotificacion implements Notificable
{
    public function enviar(string $mensaje): void
    {
        echo 'Email: ' . $mensaje;
    }
}

5. Crear un repositorio con PDO

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

    public function todos(): array
    {
        return $this->pdo
            ->query('SELECT * FROM productos')
            ->fetchAll(PDO::FETCH_ASSOC);
    }
}

6. Buscar por ID con una consulta preparada

public function buscar(int $id): ?array
{
    $stmt = $this->pdo->prepare(
        'SELECT * FROM productos WHERE id = :id'
    );

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

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

    return $resultado ?: null;
}

7. Implementar una transacción

$pdo->beginTransaction();

try {
    // operaciones relacionadas

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

8. Login seguro con password_verify()

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

    $_SESSION['usuario_id'] = $usuario['id'];
}

9. Middleware simple de autenticación

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

10. Controlar acceso por rol

function requireAdmin(): void
{
    requireLogin();

    if ($_SESSION['rol'] !== 'admin') {
        http_response_code(403);
        exit('Acceso denegado');
    }
}

11. Generar un token CSRF

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

12. Validar el token CSRF

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

13. Crear una capa de servicio

class ProductoService
{
    public function __construct(
        private ProductoRepository $repository
    ) {
    }

    public function listarActivos(): array
    {
        return array_filter(
            $this->repository->todos(),
            fn($producto) => $producto['activo']
        );
    }
}

14. Crear un controlador MVC

class ProductoController
{
    public function __construct(
        private ProductoService $service
    ) {
    }

    public function index(): void
    {
        $productos = $this->service->listarActivos();

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

15. Crear un router simple

$ruta = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

switch ($ruta) {
    case '/productos':
        $controller->index();
        break;

    default:
        http_response_code(404);
}

16. Usar namespaces

<?php

namespace App\Services;

class ProductoService
{
}
?>

17. Configurar autoload PSR-4

{
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}

Después:

composer dump-autoload

18. Crear una búsqueda paginada con PDO

$buscar = trim($_GET['buscar'] ?? '');
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$limite = 20;
$offset = ($pagina - 1) * $limite;

$stmt = $pdo->prepare(
    'SELECT *
     FROM productos
     WHERE nombre LIKE :buscar
     ORDER BY id DESC
     LIMIT :limite OFFSET :offset'
);

$stmt->bindValue(':buscar', '%' . $buscar . '%');
$stmt->bindValue(':limite', $limite, PDO::PARAM_INT);
$stmt->bindValue(':offset', $offset, PDO::PARAM_INT);
$stmt->execute();

19. Registrar errores en un archivo

try {
    // operación
} catch (Throwable $e) {
    error_log(
        $e->getMessage() . PHP_EOL,
        3,
        __DIR__ . '/app.log'
    );
}

20. Diseñar un mini sistema MVC completo

Objetivo: construir una aplicación de gestión de productos con:

  • login;
  • roles;
  • CRUD;
  • PDO;
  • consultas preparadas;
  • CSRF;
  • arquitectura MVC;
  • autoload PSR-4;
  • validación;
  • paginación y búsqueda.

Una estructura posible:

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

config/
public/
vendor/
composer.json

¿Qué deberías poder hacer después?

Si podés resolver estos desafíos entendiendo cada parte, ya tenés una base muy sólida de PHP moderno y estás en condiciones de comenzar a trabajar con frameworks como Laravel con mucha más claridad.

Serie de ejercicios de PHP

También podés ver todos los contenidos prácticos en la sección Ejercicios resueltos.

¿Te sirvió esta guía? ☕

☕ 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