Lección 8 de 8: un proyecto en PHP con todo lo del curso, junto: una aplicación web pequeña pero completa, con usuarios, base de datos, pruebas y análisis, y las medidas de seguridad que no pueden faltar. Al final, cómo ponerla en un servidor de verdad.
Qué vamos a construir
Una lista de tareas para varios usuarios: cada uno entra con su correo y su contraseña, añade tareas y las marca como hechas, y nadie puede ver ni tocar las de otro. Es pequeña, pero tiene todo lo que tiene una aplicación web de verdad, y junta lo visto en el curso:
- Clases con tipos y espacios de nombres, cargadas por Composer (lecciones 5 y 6).
- Una base de datos SQLite con PDO y consultas preparadas (lección 7).
- Contraseñas con
password_hash, sesiones con cookies seguras y tokens CSRF en todos los formularios. - Pruebas con PHPUnit, análisis con PHPStan en el nivel máximo y
composer audit. - Una configuración de Nginx para ponerla en un servidor.
Sin framework, a propósito: así se ve qué hace cada pieza. Cuando uses Laravel o Symfony, reconocerás todo esto, solo que hecho por ellos.
La estructura
Esta separación es la primera medida de seguridad del proyecto. Si la raíz de la web fuera la carpeta del proyecto, cualquiera podría descargar la base de datos con una URL, o pedir directamente archivos de vendor/ que nunca debieron ejecutarse sueltos. Con public/ como raíz, todo pasa por index.php, el controlador frontal.
El composer.json declara además la extensión de SQLite como requisito, para que Composer avise si el servidor no la tiene:
{
"name": "decodigo/tareas",
"description": "Proyecto de la lección 8 del curso de PHP de decodigo.com",
"type": "project",
"require": {
"php": ">=8.5",
"ext-pdo_sqlite": "*"
},
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"App\\Tests\\": "tests/"
}
}
}La base de datos
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class BaseDeDatos
{
public static function abrir(string $dsn): PDO
{
$pdo = new PDO($dsn, options: [
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
hash TEXT NOT NULL
)');
$pdo->exec('CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY,
usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
texto TEXT NOT NULL,
hecha INTEGER NOT NULL DEFAULT 0
)');
return $pdo;
}
}La clase abre la conexión y crea las tablas si no existen. UNIQUE en el correo impide dos cuentas iguales, y la columna hash guarda el resultado de password_hash, nunca la contraseña. En un proyecto más grande, la creación de tablas iría en migraciones aparte.
Usuarios y contraseñas
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Usuarios
{
public function __construct(private PDO $pdo) {}
public function registrar(string $email, #[\SensitiveParameter] string $clave): int
{
if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
throw new \InvalidArgumentException('El correo no es válido');
}
if (mb_strlen($clave) < 12) {
throw new \InvalidArgumentException('La contraseña debe tener al menos 12 caracteres');
}
$alta = $this->pdo->prepare('INSERT INTO usuarios (email, hash) VALUES (?, ?)');
$alta->execute([mb_strtolower($email), password_hash($clave, PASSWORD_DEFAULT)]);
return (int) $this->pdo->lastInsertId();
}
/** Devuelve el id del usuario, o null si el correo o la contraseña no coinciden. */
public function autenticar(string $email, #[\SensitiveParameter] string $clave): ?int
{
$consulta = $this->pdo->prepare('SELECT id, hash FROM usuarios WHERE email = ?');
$consulta->execute([mb_strtolower($email)]);
/** @var array{id: int, hash: string}|false $fila */
$fila = $consulta->fetch();
if ($fila === false || !password_verify($clave, $fila['hash'])) {
return null;
}
if (password_needs_rehash($fila['hash'], PASSWORD_DEFAULT)) {
$this->pdo->prepare('UPDATE usuarios SET hash = ? WHERE id = ?')
->execute([password_hash($clave, PASSWORD_DEFAULT), $fila['id']]);
}
return $fila['id'];
}
}Lo importante:
- Se valida el correo y se exige una longitud mínima a la contraseña. Doce caracteres es una recomendación habitual hoy; más que obligar a mezclar símbolos, importa que sea larga.
- El correo se guarda en minúsculas, para que «Ana@Ejemplo.com» y «ana@ejemplo.com» sean la misma cuenta.
autenticardevuelve lo mismo,null, tanto si el correo no existe como si la contraseña es mala, y el formulario muestra un único mensaje: así no le dice a nadie qué correos tienen cuenta.password_needs_rehashaprovecha el momento en que se conoce la contraseña para actualizar su resumen si el algoritmo o el coste han cambiado.#[\SensitiveParameter]mantiene las contraseñas fuera de cualquier traza de error (lección 4).
Las tareas, y de quién son
<?php
declare(strict_types=1);
namespace App;
use PDO;
final class Tareas
{
public function __construct(private PDO $pdo) {}
public function crear(int $usuarioId, string $texto): int
{
$texto = trim($texto);
if ($texto === '' || mb_strlen($texto) > 200) {
throw new \InvalidArgumentException('La tarea debe tener entre 1 y 200 caracteres');
}
$this->pdo->prepare('INSERT INTO tareas (usuario_id, texto) VALUES (?, ?)')->execute([$usuarioId, $texto]);
return (int) $this->pdo->lastInsertId();
}
/** @return list<array{id: int, texto: string, hecha: int}> */
public function deUsuario(int $usuarioId): array
{
$consulta = $this->pdo->prepare('SELECT id, texto, hecha FROM tareas WHERE usuario_id = ? ORDER BY id');
$consulta->execute([$usuarioId]);
/** @var list<array{id: int, texto: string, hecha: int}> */
return $consulta->fetchAll();
}
/** Marca la tarea como hecha solo si es de ese usuario. Devuelve false si no existe o es de otro. */
public function completar(int $usuarioId, int $tareaId): bool
{
$cambio = $this->pdo->prepare('UPDATE tareas SET hecha = 1 WHERE id = ? AND usuario_id = ?');
$cambio->execute([$tareaId, $usuarioId]);
return $cambio->rowCount() === 1;
}
}Ojo con la seguridad: acceder a datos de otro usuario cambiando un número
La ruta para completar una tarea es /tareas/2/hecha, y ese 2 lo puede cambiar cualquiera. Si el código buscara la tarea solo por su id, cualquier usuario con sesión podría completar, o en otra aplicación leer o borrar, los datos de otro. Este fallo de control de acceso es de los más comunes en aplicaciones reales, porque todo parece funcionar mientras cada uno usa solo sus enlaces.
La defensa está en el SQL de completar: WHERE id = ? AND usuario_id = ?. La tarea solo cambia si es del usuario de la sesión, y rowCount() dice si cambió algo. Es lo que comprobamos al final, cuando Luis intenta completar una tarea de Ana. Dos detalles: el id del usuario sale siempre de la sesión, nunca de un campo del formulario, y la respuesta es un 404, «no existe», en vez de un 403, para no confirmar que esa tarea existe.
Las piezas de la web
El token CSRF de la lección 7, en su propia clase:
<?php
declare(strict_types=1);
namespace App;
final class Csrf
{
public static function token(): string
{
if (!isset($_SESSION['csrf']) || !is_string($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
public static function valido(mixed $enviado): bool
{
return is_string($enviado) && hash_equals(self::token(), $enviado);
}
}Una vista mínima: carga una plantilla y le pasa los datos y una función $e para escapar. Ninguna plantilla escribe nada sin pasar por ella:
<?php
declare(strict_types=1);
namespace App;
final class Vista
{
public function __construct(private string $carpeta) {}
/** @param array<string, mixed> $datos */
public function mostrar(string $plantilla, array $datos = []): string
{
$e = static fn(string $s): string => htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
ob_start();
(function (string $_archivo, array $datos, \Closure $e): void {
require $_archivo;
})("{$this->carpeta}/{$plantilla}.php", $datos, $e);
return (string) ob_get_clean();
}
}<h1>Tus tareas</h1> <ul> <?php foreach ($datos['tareas'] as $t): ?> <li><?= $t['hecha'] ? '✔' : '·' ?> <?= $e($t['texto']) ?></li> <?php endforeach; ?> </ul> <form method="post" action="/tareas"> <input type="hidden" name="csrf" value="<?= $e($datos['csrf']) ?>"> <input name="texto"> <button>Añadir</button> </form>
Y el controlador frontal, que recibe todas las peticiones, comprueba el token de cualquier POST, distingue las rutas públicas de las que exigen sesión y llama a las clases:
<?php
declare(strict_types=1);
use App\BaseDeDatos;
use App\Csrf;
use App\Tareas;
use App\Usuarios;
use App\Vista;
require __DIR__ . '/../vendor/autoload.php';
session_start([
'cookie_httponly' => true,
'cookie_secure' => true,
'cookie_samesite' => 'Lax',
'use_strict_mode' => true,
]);
$pdo = BaseDeDatos::abrir(getenv('TAREAS_DSN') ?: 'sqlite:' . __DIR__ . '/../datos/tareas.sqlite');
$usuarios = new Usuarios($pdo);
$tareas = new Tareas($pdo);
$vista = new Vista(__DIR__ . '/../plantillas');
$metodo = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$ruta = is_string($uri) ? (string) parse_url($uri, PHP_URL_PATH) : '/';
$yo = $_SESSION['usuario'] ?? null;
/** Un campo del formulario como texto: si falta o llega como array, texto vacío. */
function campo(string $nombre): string
{
$valor = $_POST[$nombre] ?? '';
return is_string($valor) ? $valor : '';
}
function redirigir(string $a): never
{
header("Location: $a", true, 303);
exit;
}
// Todo POST lleva el token CSRF
if ($metodo === 'POST' && !Csrf::valido(campo('csrf'))) {
http_response_code(403);
exit("Petición rechazada\n");
}
// Rutas públicas
if ($ruta === '/entrar') {
if ($metodo === 'POST') {
$id = $usuarios->autenticar(campo('email'), campo('clave'));
if ($id !== null) {
session_regenerate_id(true);
$_SESSION['usuario'] = $id;
redirigir('/tareas');
}
http_response_code(401);
echo $vista->mostrar('entrar', ['csrf' => Csrf::token(), 'error' => 'Correo o contraseña incorrectos']);
exit;
}
echo $vista->mostrar('entrar', ['csrf' => Csrf::token()]);
exit;
}
// A partir de aquí, hace falta haber entrado
if (!is_int($yo)) {
redirigir('/entrar');
}
if ($ruta === '/tareas' && $metodo === 'GET') {
echo $vista->mostrar('tareas', ['csrf' => Csrf::token(), 'tareas' => $tareas->deUsuario($yo)]);
exit;
}
if ($ruta === '/tareas' && $metodo === 'POST') {
$tareas->crear($yo, campo('texto'));
redirigir('/tareas');
}
if ($metodo === 'POST' && preg_match('#^/tareas/(\d+)/hecha$#', $ruta, $m) === 1) {
if ($tareas->completar($yo, (int) $m[1])) {
redirigir('/tareas');
}
http_response_code(404);
exit("No existe esa tarea\n");
}
http_response_code(404);
echo "Página no encontrada\n";Al entrar, session_regenerate_id(true) cambia el identificador de sesión, y las redirecciones usan el código 303 para que, al recargar la página, el navegador no vuelva a enviar el formulario.
Probar y analizar
Las pruebas usan una base SQLite en memoria, que se crea de cero para cada prueba en setUp. Prueban lo que más importa que no se rompa: que la contraseña correcta funciona y las malas no, y que nadie toca las tareas de otro.
<?php
declare(strict_types=1);
namespace App\Tests;
use App\BaseDeDatos;
use App\Tareas;
use App\Usuarios;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class TareasTest extends TestCase
{
private Usuarios $usuarios;
private Tareas $tareas;
protected function setUp(): void
{
$pdo = BaseDeDatos::abrir('sqlite::memory:');
$this->usuarios = new Usuarios($pdo);
$this->tareas = new Tareas($pdo);
}
#[Test]
public function la_contrasena_correcta_da_acceso_y_la_incorrecta_no(): void
{
$id = $this->usuarios->registrar('Ana@Ejemplo.com', 'una-clave-bien-larga');
$this->assertSame($id, $this->usuarios->autenticar('ana@ejemplo.com', 'una-clave-bien-larga'));
$this->assertNull($this->usuarios->autenticar('ana@ejemplo.com', 'otra-clave-cualquiera'));
$this->assertNull($this->usuarios->autenticar('nadie@ejemplo.com', 'una-clave-bien-larga'));
}
#[Test]
public function rechaza_contrasenas_cortas(): void
{
$this->expectException(\InvalidArgumentException::class);
$this->usuarios->registrar('eva@ejemplo.com', 'corta');
}
#[Test]
public function cada_usuario_ve_solo_sus_tareas(): void
{
$ana = $this->usuarios->registrar('ana@ejemplo.com', 'una-clave-bien-larga');
$luis = $this->usuarios->registrar('luis@ejemplo.com', 'otra-clave-bien-larga');
$this->tareas->crear($ana, 'Comprar café');
$this->tareas->crear($luis, 'Llamar al banco');
$this->assertSame(['Comprar café'], array_column($this->tareas->deUsuario($ana), 'texto'));
}
#[Test]
public function nadie_puede_completar_la_tarea_de_otro(): void
{
$ana = $this->usuarios->registrar('ana@ejemplo.com', 'una-clave-bien-larga');
$luis = $this->usuarios->registrar('luis@ejemplo.com', 'otra-clave-bien-larga');
$tarea = $this->tareas->crear($ana, 'Comprar café');
$this->assertFalse($this->tareas->completar($luis, $tarea));
$this->assertTrue($this->tareas->completar($ana, $tarea));
}
}vendor/bin/phpunit tests --testdox
salidaPHPUnit 13.4.5 by Sebastian Bergmann and contributors. Runtime: PHP 8.5.12 .... 4 / 4 (100%) Time: 00:01.101, Memory: 20.00 MB Tareas (App\Tests\Tareas) ✔ La contrasena correcta da acceso y la incorrecta no ✔ Rechaza contrasenas cortas ✔ Cada usuario ve solo sus tareas ✔ Nadie puede completar la tarea de otro OK (4 tests, 7 assertions)
Tarda algo más de un segundo, casi todo en password_hash, que es lento a propósito.
El error que te ahorra: datos de la petición sin comprobar
La primera versión de index.php leía los campos así, y funcionaba en todas las pruebas manuales:
25 $ruta = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); 43 $id = $usuarios->autenticar((string) ($_POST['email'] ?? ''), (string) ($_POST['clave'] ?? '')); 68 $tareas->crear($yo, (string) ($_POST['texto'] ?? ''));
vendor/bin/phpstan analyse src public --level=max ------ ----------------------------------------------------------------------
Line public/index.php
------ ----------------------------------------------------------------------
25 Parameter #1 $url of function parse_url expects string, mixed given.
🪪 argument.type
43 Cannot cast mixed to string.
🪪 cast.string
43 Cannot cast mixed to string.
🪪 cast.string
68 Cannot cast mixed to string.
🪪 cast.string
------ ----------------------------------------------------------------------
[ERROR] Found 4 errorsPHPStan tiene razón: lo que hay en $_POST o $_SERVER es mixed, puede ser un texto, un array o no estar. Un campo enviado como email[]=x llega como array, y (string) sobre un array da el texto «Array» y un aviso. La versión final usa la función campo(), que solo acepta textos, y el análisis queda limpio:
salida [OK] No errors
composer audit
salidaNo security vulnerability advisories found.
Recorrerla de verdad
Con el servidor integrado (php -S localhost:8000 -t public public/index.php) y dos usuarios dados de alta, hicimos este recorrido con curl. Sin sesión, cualquier página lleva a /entrar:
GET /tareasHTTP/1.1 303 See Other Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax Location: /entrar Content-type: text/html; charset=UTF-8
Una contraseña equivocada recibe un 401 y el mensaje genérico; la buena, la cookie de sesión nueva y la redirección:
POST /entrar (contraseña equivocada)HTTP/1.1 401 Unauthorized Content-type: text/html; charset=UTF-8 <h1>Entrar</h1> <p class="error">Correo o contraseña incorrectos</p><form method="post" action="/entrar"> <input type="hidden" name="csrf" value="…"> <input name="email" type="email"> <input name="clave" type="password"> <button>Entrar</button> </form>
POST /entrar (contraseña correcta)HTTP/1.1 303 See Other Set-Cookie: PHPSESSID=…; path=/; secure; HttpOnly; SameSite=Lax Location: /tareas Content-type: text/html; charset=UTF-8
Ana añadió dos tareas, una con una etiqueta HTML en el texto, y completó la primera. La lista sale con el HTML escapado:
GET /tareas (Ana)<h1>Tus tareas</h1> <ul> <li>✔ Comprar café</li> <li>· Revisar <b>facturas</b></li> </ul> <form method="post" action="/tareas"> <input type="hidden" name="csrf" value="…"> <input name="texto"> <button>Añadir</button> </form>
Un POST sin token se rechaza:
POST /tareas sin tokenHTTP/1.1 403 Forbidden Content-type: text/html; charset=UTF-8 Petición rechazada
Y Luis, con su propia sesión y un token válido, intenta completar la tarea 2, que es de Ana:
POST /tareas/2/hecha (Luis)HTTP/1.1 404 Not Found Content-type: text/html; charset=UTF-8 No existe esa tarea
GET /tareas (Luis)<h1>Tus tareas</h1> <ul> </ul> <form method="post" action="/tareas"> <input type="hidden" name="csrf" value="…"> <input name="texto"> <button>Añadir</button> </form>
Ponerla en un servidor
En producción, PHP no usa el servidor integrado sino PHP-FPM, un servicio que mantiene procesos de PHP listos, detrás de Nginx o Apache. Esta es una configuración de Nginx para el proyecto, comprobada con nginx -t:
server {
listen 80;
server_name tareas.ejemplo.com;
# Solo la carpeta public/ es visible desde internet
root /var/www/tareas/public;
index index.php;
location / {
try_files $uri /index.php$is_args$args;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_pass unix:/run/php/php8.5-fpm.sock;
}
# Cualquier otro .php, aunque exista, no se ejecuta
location ~ \.php$ {
return 404;
}
}nginx -tnginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful
La raíz es public/, cualquier ruta que no sea un archivo existente va a index.php, y ningún otro .php se ejecuta aunque alguien consiga subir uno. La ruta del socket de PHP-FPM depende de la distribución. Antes de abrirla al público:
- HTTPS siempre, por ejemplo con un certificado de Let’s Encrypt: sin él, la opción
securede la cookie impide que la sesión funcione, y así debe ser. - Las dependencias de producción y nada más:
composer install --no-dev --optimize-autoloaderquita PHPUnit y PHPStan y genera un autoloader más rápido. - Los permisos justos: el usuario de PHP-FPM solo necesita escribir en
datos/; el código debe ser de solo lectura para él. - Un
php.inide producción. Lo esencial:
display_errors = Off log_errors = On expose_php = Off session.use_strict_mode = 1 session.cookie_httponly = 1 session.cookie_secure = 1 session.cookie_samesite = Lax opcache.enable = 1
composer install --no-dev --optimize-autoloaderInstalling dependencies from lock file Verifying lock file contents can be installed on current platform. Package operations: 0 installs, 0 updates, 28 removals - Removing theseer/tokenizer (2.0.1) - Removing staabm/side-effects-detector (1.0.5) - Removing sebastian/version (7.0.1) ... Generating optimized autoload files
Y lo más importante, que no es una configuración sino una costumbre: mantener PHP y las dependencias al día. Una aplicación perfecta sobre un PHP sin parches es vulnerable; lo vimos en octubre de 2026 con PHP2Shell. Cómo hacerlo lo contamos en Versiones y parches, y la lista completa de fallos que hay que vigilar está en Los fallos de seguridad típicos de PHP.
Retos
Reto 1. Añade una ruta POST /tareas/{id}/borrar que borre una tarea. ¿Qué tiene que llevar el SQL para que nadie borre las de otro?
Ver la solución
public function borrar(int $usuarioId, int $tareaId): bool
{
$borrado = $this->pdo->prepare('DELETE FROM tareas WHERE id = ? AND usuario_id = ?');
$borrado->execute([$tareaId, $usuarioId]);
return $borrado->rowCount() === 1;
}Lo mismo que completar: el usuario_id en el WHERE, sacado de la sesión. Y su prueba, igual que la de completar: Luis no puede borrar la tarea de Ana. En index.php, otra rama como la de /hecha; el token CSRF ya lo comprueba el principio del archivo para todos los POST.
Reto 2. Ahora mismo, alguien puede probar contraseñas sin límite. ¿Cómo lo frenarías?
Ver la solución
Contando los intentos fallidos por correo y por dirección IP en una tabla, y rechazando durante unos minutos a partir de cierto número, por ejemplo cinco en quince minutos. El mensaje debe ser el mismo exista o no la cuenta. En producción se suele combinar con un límite en el propio Nginx (limit_req) y, para cuentas importantes, con un segundo factor de autenticación.
Reto 3. Un compañero propone guardar en la sesión el array entero del usuario, con su hash, «para no consultar la base en cada página». ¿Qué le dices?
Ver la solución
Que en la sesión va lo mínimo, el id del usuario. Los datos de sesión se guardan en archivos del servidor, y cualquier fallo que los exponga expondría también los resúmenes de las contraseñas. Además, si el usuario cambia de contraseña o se le quitan permisos, la sesión seguiría con los datos viejos. Consultar por id con una clave primaria es rapidísimo.
Fin del curso
Has llegado al final: de echo "Hola, mundo" a una aplicación con usuarios, pruebas y despliegue, con la seguridad en cada paso. Para comprobar lo que sabes, prueba el test de 24 preguntas; para seguir, los ejemplos de PHP resuelven tareas concretas, y las rarezas y fallos de diseño te ahorrarán más de un susto.
Todo el proyecto se comprobó con PHP 8.5.12 en contenedores limpios: dependencias instaladas con Composer 2.10.3 (PHPUnit 13.4.5, PHPStan 2.3.3), la aplicación servida con el servidor integrado sin acceso a la red y recorrida con curl, y la configuración de Nginx validada con nginx -t. Las salidas están copiadas de esa ejecución.
