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:
- PHP y MySQL con PDO
- CRUD completo con PHP, MySQL y PDO
- Sesiones y cookies en PHP
- Autenticación y autorización
- Seguridad en PHP
- Programación orientada a objetos
- MVC en PHP
- Composer y autoloading PSR-4
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í:
- el usuario abre una URL;
- el router determina qué controlador ejecutar;
- el controlador valida autenticación y permisos;
- el controlador recibe y valida los datos;
- el repositorio consulta o modifica MySQL mediante PDO;
- el controlador carga una vista;
- 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.verusuarios.crearusuarios.editarusuarios.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:
- 20 ejercicios de PHP para principiantes
- 20 ejercicios intermedios de PHP
- 20 ejercicios avanzados de PHP
¿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:
Descubre más desde Club Programador
Suscríbete y recibe las últimas entradas en tu correo electrónico.