API RESTful en PHP con JWT, PDO y estructura modular
Proyecto práctico: construimos una pequeña API REST que permite registro, login y obtener el perfil del usuario. Usamos PDO para consultas seguras y JWT para autenticación sin estado. Te doy la estructura, el código completo de los archivos principales y las razones detrás de cada decisión.
Requisitos previos
- PHP 8+
- Composer
- MySQL o MariaDB
- Extensiones: pdo_mysql
Estructura de carpetas
project/
composer.json
config.php
public/
index.php
.htaccess
src/
Database.php
Auth.php
UserController.php
Middleware.php
Base de datos (tabla users)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
name VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Instala la dependencia JWT:
composer require firebase/php-jwt
config.php
Archivo de configuración simple. En producción usa variables de entorno o un secret manager.
[
'host' => '127.0.0.1',
'dbname' => 'api_db',
'user' => 'api_user',
'pass' => 'secret',
],
'jwt' => [
'secret' => 'CHANGE_THIS_TO_A_RANDOM_SECRET',
'issuer' => 'your-domain.com',
'aud' => 'your-domain.com',
'expire' => 3600, // segundos
],
];
src/Database.php
\PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
]);
}
return self::$pdo;
}
}
src/Auth.php
secret = $cfg['jwt']['secret'];
$this->expire = $cfg['jwt']['expire'];
$this->issuer = $cfg['jwt']['issuer'];
$this->aud = $cfg['jwt']['aud'];
}
public function generateToken(int $userId): string
{
$now = time();
$payload = [
'iat' => $now,
'nbf' => $now,
'exp' => $now + $this->expire,
'iss' => $this->issuer,
'aud' => $this->aud,
'sub' => $userId,
];
return JWT::encode($payload, $this->secret, 'HS256');
}
public function verifyToken(string $token): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->secret, 'HS256'));
// JWT::decode returns an object; convert to array
return (array) $decoded;
} catch (Exception $e) {
return null;
}
}
}
src/UserController.php
db = $db;
$this->auth = $auth;
}
public function register(array $data)
{
if (empty($data['email']) || empty($data['password'])) {
return $this->json(400, ['error' => 'email y password son obligatorios']);
}
$email = strtolower(trim($data['email']));
$passwordHash = password_hash($data['password'], PASSWORD_DEFAULT);
$name = $data['name'] ?? null;
try {
$stmt = $this->db->prepare('INSERT INTO users (email, password, name) VALUES (:email, :password, :name)');
$stmt->execute([':email' => $email, ':password' => $passwordHash, ':name' => $name]);
$id = (int) $this->db->lastInsertId();
return $this->json(201, ['id' => $id, 'email' => $email, 'name' => $name]);
} catch (\PDOException $e) {
if ($e->getCode() === '23000') { // duplicate
return $this->json(409, ['error' => 'email ya registrado']);
}
return $this->json(500, ['error' => 'error de servidor']);
}
}
public function login(array $data)
{
if (empty($data['email']) || empty($data['password'])) {
return $this->json(400, ['error' => 'email y password son obligatorios']);
}
$stmt = $this->db->prepare('SELECT id, password FROM users WHERE email = :email');
$stmt->execute([':email' => strtolower(trim($data['email']))]);
$user = $stmt->fetch();
if (!$user || !password_verify($data['password'], $user['password'])) {
return $this->json(401, ['error' => 'credenciales inválidas']);
}
$token = $this->auth->generateToken((int) $user['id']);
return $this->json(200, ['token' => $token, 'token_type' => 'bearer']);
}
public function profile(array $jwtPayload)
{
$userId = $jwtPayload['sub'] ?? null;
if (!$userId) return $this->json(401, ['error' => 'token inválido']);
$stmt = $this->db->prepare('SELECT id, email, name, created_at FROM users WHERE id = :id');
$stmt->execute([':id' => $userId]);
$user = $stmt->fetch();
if (!$user) return $this->json(404, ['error' => 'usuario no encontrado']);
return $this->json(200, ['user' => $user]);
}
private function json(int $status, array $data)
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
}
}
src/Middleware.php (autenticación)
auth = $auth;
}
public function authenticate(): ?array
{
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!$h || !str_starts_with($h, 'Bearer ')) return null;
$token = trim(substr($h, 7));
return $this->auth->verifyToken($token);
}
}
public/index.php (router simple)
register($body);
exit;
}
if ($method === 'POST' && $path === '/login') {
$controller->login($body);
exit;
}
if ($method === 'GET' && $path === '/me') {
$payload = $middleware->authenticate();
if (!$payload) {
http_response_code(401);
header('Content-Type: application/json');
echo json_encode(['error' => 'no autorizado']);
exit;
}
$controller->profile($payload);
exit;
}
// 404
http_response_code(404);
header('Content-Type: application/json');
echo json_encode(['error' => 'ruta no encontrada']);
public/.htaccess (opcional, para Apache)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
Por qué así (decisiones técnicas)
- PDO con prepared statements: evita inyecciones SQL y es fácil de usar.
- password_hash / password_verify: manejo seguro de contraseñas con salt y algorítmos modernos.
- JWT: token sin estado que permite escalabilidad. Firmado con HS256 y secreto en config (mejor en env).
- Estructura modular: clases pequeñas (Database, Auth, Controller, Middleware) para mantener responsabilidad única y facilitar tests.
- Control de errores mínimo y códigos HTTP adecuados: facilita debugging y cliente predecible.
Pruebas rápidas con curl
# Registrar
curl -X POST -H 'Content-Type: application/json' -d '{"email":"dev@example.com","password":"secret","name":"Dev"}' http://localhost/register
# Login
curl -X POST -H 'Content-Type: application/json' -d '{"email":"dev@example.com","password":"secret"}' http://localhost/login
# Obtener perfil (reemplaza TOKEN por el token recibido)
curl -H 'Authorization: Bearer TOKEN' http://localhost/me
Mejoras y advertencias de seguridad
- No guardes secretos en archivos de configuración en producción. Usa variables de entorno o un vault.
- Implementa refresh tokens y revocación si necesitas logout forzado o control fino de sesiones.
- Limita intentos de login y registra eventos de seguridad para detección de abuso.
- Siempre sirve la API por HTTPS para proteger tokens en tránsito.
- Firme tokens con una clave robusta y rota la clave periódicamente; considera firmar con claves asimétricas (RS256) para mayor seguridad y separación de roles.
Próximo paso: añade refresco de token con JWT + tabla de refresh tokens en la base de datos (almacena token hashed), y middleware para roles/permissions. También considera usar un micro-framework (Slim, Lumen) para routing más robusto y middleware reutilizable.
Consejo avanzado: si vas a escalar a múltiples instancias, evita revocar tokens simplemente borrando estado local: implementa una lista de revocación centralizada con TTL o usa short-living access tokens junto con refresh tokens almacenados de forma segura y rotables.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación