Proyecto final PHP: sistema de usuarios completo con login, roles, CRUD, PDO y MVC


En este proyecto final de PHP vamos a integrar muchos de los conceptos vistos a lo largo de la ruta de aprendizaje construyendo una aplicación real: un sistema de gestión de usuarios con registro, login, roles, CRUD, PDO, validaciones, seguridad y arquitectura MVC.

La idea no es solamente copiar código. El objetivo es entender cómo se conectan entre sí las distintas partes de una aplicación PHP moderna y cómo organizar el proyecto para que sea más fácil de mantener.

¿Qué vamos a construir?

El sistema tendrá las siguientes funcionalidades:

  • registro de usuarios;
  • inicio y cierre de sesión;
  • contraseñas almacenadas con password_hash();
  • autenticación con sesiones;
  • roles de usuario y administrador;
  • protección de páginas según permisos;
  • listado de usuarios;
  • alta, edición y eliminación;
  • búsqueda;
  • consultas preparadas con PDO;
  • validación de datos;
  • protección CSRF;
  • arquitectura MVC;
  • autoloading PSR-4 mediante Composer.

Antes de empezar

Este proyecto supone que ya manejás los conceptos principales de la ruta de PHP. Si algún tema todavía no está claro, podés repasarlo antes de continuar:

1. Estructura del proyecto

Vamos a separar responsabilidades utilizando una estructura MVC sencilla:

proyecto-usuarios/
├── app/
│   ├── Controllers/
│   │   ├── AuthController.php
│   │   └── UsuarioController.php
│   ├── Models/
│   │   └── Usuario.php
│   ├── Repositories/
│   │   └── UsuarioRepository.php
│   ├── Services/
│   │   └── AuthService.php
│   └── Views/
│       ├── auth/
│       │   ├── login.php
│       │   └── registro.php
│       └── usuarios/
│           ├── index.php
│           ├── crear.php
│           └── editar.php
├── config/
│   └── database.php
├── public/
│   └── index.php
├── routes/
│   └── web.php
├── vendor/
└── composer.json

La ventaja de esta estructura es que cada carpeta tiene una responsabilidad clara. El controlador coordina, el modelo representa los datos, el repositorio accede a la base, el servicio concentra lógica y la vista genera la interfaz.

2. Crear la base de datos

Vamos a utilizar una tabla usuarios con los datos básicos de autenticación y autorización.

CREATE DATABASE club_php
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;

USE club_php;

CREATE TABLE usuarios (
    id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    email VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    rol ENUM('admin', 'usuario') NOT NULL DEFAULT 'usuario',
    activo TINYINT(1) NOT NULL DEFAULT 1,
    creado_en TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

La contraseña tendrá hasta 255 caracteres porque password_hash() genera hashes largos cuyo tamaño puede cambiar según el algoritmo utilizado.

3. Configurar Composer y PSR-4

El archivo composer.json puede ser:

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

Después ejecutamos:

composer dump-autoload

Y en el punto de entrada cargamos el autoloader:

<span style="color:#c4b5fd"><?php

require dirname(__DIR__) . '/vendor/autoload.php';

session_start();

4. Conexión a MySQL con PDO

Archivo config/database.php:

<span style="color:#c4b5fd"><?php

return new PDO(
    'mysql:host=localhost;dbname=club_php;charset=utf8mb4',
    'root',
    '',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

Desactivar la emulación de consultas preparadas permite delegar el preparado al motor cuando el driver lo soporta.

5. Modelo Usuario

<span style="color:#c4b5fd"><?php

namespace App\Models;

class Usuario
{
    public function __construct(
        public ?int $id,
        public string $nombre,
        public string $email,
        public string $rol = 'usuario',
        public bool $activo = true
    ) {
    }
}

En un proyecto más grande podríamos agregar getters, setters, objetos de valor o métodos de dominio. Para este ejemplo nos interesa mantener la estructura simple.

6. Repositorio de usuarios

El repositorio concentra el acceso a la base de datos.

<span style="color:#c4b5fd"><?php

namespace App\Repositories;

use PDO;

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

    public function todos(string $buscar = ''): array
    {
        $stmt = $this->pdo->prepare(
            'SELECT id, nombre, email, rol, activo, creado_en
             FROM usuarios
             WHERE nombre LIKE :buscar
                OR email LIKE :buscar
             ORDER BY id DESC'
        );

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

        return $stmt->fetchAll();
    }

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

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

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }

    public function buscarPorEmail(string $email): ?array
    {
        $stmt = $this->pdo->prepare(
            'SELECT * FROM usuarios WHERE email = :email'
        );

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

        $usuario = $stmt->fetch();

        return $usuario ?: null;
    }
}

7. Registrar usuarios

Para registrar un usuario necesitamos validar los datos y almacenar la contraseña de forma segura.

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

$errores = [];

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

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

if (strlen($password) < 8) {
    $errores[] = 'La contraseña debe tener al menos 8 caracteres.';
}

if (!$errores) {
    $hash = password_hash($password, PASSWORD_DEFAULT);

    $stmt = $pdo->prepare(
        'INSERT INTO usuarios (nombre, email, password)
         VALUES (:nombre, :email, :password)'
    );

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

8. Login seguro

El login debe buscar el usuario por email y después comprobar la contraseña con password_verify().

$usuario = $repository->buscarPorEmail($email);

if (
    !$usuario ||
    !$usuario['activo'] ||
    !password_verify($password, $usuario['password'])
) {
    $error = 'Credenciales inválidas.';
} else {
    session_regenerate_id(true);

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

    header('Location: /usuarios');
    exit;
}

Es importante no indicar si falló el email o la contraseña por separado. Un mensaje genérico evita revelar información innecesaria sobre las cuentas existentes.

9. Cerrar sesión

session_start();

$_SESSION = [];

session_destroy();

header('Location: /login');
exit;

10. Middleware de autenticación

Podemos crear una función simple para evitar acceder a páginas privadas sin iniciar sesión.

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

11. Control de acceso por rol

Ahora agregamos una segunda comprobación para las funciones administrativas.

function requireAdmin(): void
{
    requireLogin();

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

Esto nos permite restringir, por ejemplo, la creación, edición y eliminación de usuarios.

12. Listado y búsqueda

En el controlador obtenemos el término enviado por GET:

$buscar = trim($_GET['buscar'] ?? '');

$usuarios = $repository->todos($buscar);

require dirname(__DIR__) . '/Views/usuarios/index.php';

Y en la vista:

<form method="get">
    <input
        type="text"
        name="buscar"
        value="<?= htmlspecialchars($buscar) ?>"
        placeholder="Buscar por nombre o email"
    >

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

13. Crear usuarios desde el panel

El administrador puede reutilizar gran parte de la validación del registro, pero además definir rol y estado.

$rol = $_POST['rol'] ?? 'usuario';

$rolesPermitidos = ['admin', 'usuario'];

if (!in_array($rol, $rolesPermitidos, true)) {
    $errores[] = 'Rol inválido.';
}

Nunca debemos confiar directamente en el valor recibido desde un select. El usuario puede modificar manualmente una petición HTTP.

14. Editar usuarios

Primero validamos el ID:

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

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

$usuario = $repository->buscarPorId($id);

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

Después actualizamos únicamente los campos permitidos.

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

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

15. Eliminar usuarios

Las eliminaciones deberían enviarse mediante POST y no mediante un enlace GET.

<form method="post" action="/usuarios/eliminar">
    <input type="hidden" name="id" value="<?= $usuario['id'] ?>">

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

En una aplicación real también podría ser preferible utilizar una baja lógica mediante un campo activo en lugar de eliminar definitivamente el registro.

16. Protección CSRF

Generamos un token por sesión:

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

Lo enviamos dentro de cada formulario sensible:

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

Y lo comprobamos al recibir la petición:

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

17. Escapar datos en las vistas

Los datos provenientes de usuarios o de la base deben escaparse al mostrarlos en HTML:

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

Esto ayuda a prevenir ataques XSS cuando mostramos contenido potencialmente manipulable.

18. Router básico

Para no depender de muchos archivos públicos podemos centralizar las rutas en public/index.php.

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

switch ($ruta) {
    case '/login':
        $authController->login();
        break;

    case '/logout':
        $authController->logout();
        break;

    case '/usuarios':
        $usuarioController->index();
        break;

    case '/usuarios/crear':
        $usuarioController->crear();
        break;

    default:
        http_response_code(404);
        echo 'Página no encontrada.';
}

19. Patrón Post/Redirect/Get

Después de guardar, editar o eliminar conviene redireccionar:

$repository->actualizar($id, $datos);

header('Location: /usuarios');
exit;

Así evitamos que el navegador vuelva a enviar el formulario cuando el usuario actualiza la página.

20. Flujo completo del sistema

El funcionamiento general queda así:

  1. el usuario abre una URL;
  2. el router determina qué controlador ejecutar;
  3. el controlador valida autenticación y permisos;
  4. el controlador recibe y valida los datos;
  5. el repositorio consulta o modifica MySQL mediante PDO;
  6. el controlador carga una vista;
  7. la vista muestra los datos escapados correctamente.

Arquitectura final

El proyecto combina varias capas:

  • Router: decide qué acción ejecutar.
  • Controller: coordina la petición.
  • Service: contiene lógica de negocio.
  • Repository: accede a MySQL.
  • Model: representa entidades.
  • View: genera la interfaz HTML.
  • Session: mantiene la autenticación.

Mejoras que podés agregar

  • paginación;
  • recuperación de contraseña;
  • confirmación de email;
  • auditoría de accesos;
  • fecha del último login;
  • permisos más específicos además de roles;
  • baja lógica;
  • logs de errores;
  • API REST;
  • tests automatizados;
  • variables de entorno para credenciales;
  • inyección de dependencias.

Desafío final

Agregá una tabla de permisos y construí un sistema RBAC donde un rol pueda tener múltiples permisos. Por ejemplo:

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

Después modificá el middleware para comprobar permisos en lugar de depender exclusivamente del nombre del rol.

¿Qué aprendimos?

Este proyecto reúne prácticamente toda la ruta inicial de PHP en una sola aplicación. Trabajamos con formularios, sesiones, autenticación, autorización, PDO, consultas preparadas, CRUD, POO, MVC, Composer y seguridad.

Entender cómo se conectan estas piezas es especialmente útil antes de pasar a frameworks como Laravel, porque muchas de estas ideas siguen existiendo allí, aunque el framework las resuelva mediante herramientas propias.

Seguí aprendiendo PHP

Podés recorrer todas las guías desde la página principal:

Aprender PHP desde cero: ruta completa →

También podés practicar con:

¿Te sirvió este proyecto? ☕

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

☕ Apoyar con Mercado Pago

🌎 Apoyar con PayPal


Descubre más desde Club Programador

Suscríbete y recibe las últimas entradas en tu correo electrónico.

Deja una respuesta

Descubre más desde Club Programador

Suscríbete ahora para seguir leyendo y obtener acceso al archivo completo.

Seguir leyendo