Cómo construir una API RESTful en PHP con Slim, PDO y JWT
Guía práctica para montar una API segura y ligera usando Slim Framework, PDO para acceso a la base de datos y JSON Web Tokens para autenticación. Incluye estructura de carpetas, código completo mínimo y explicación del por qué de cada decisión.
Requisitos
- PHP 8.0+
- Composer
- MySQL o similar
- Conocimientos básicos de HTTP y JSON
Estructura de proyecto
project/ ├─ public/ │ └─ index.php ├─ src/ │ ├─ controllers/ │ │ ├─ AuthController.php │ │ └─ UserController.php │ ├─ middleware/ │ │ └─ JwtMiddleware.php │ ├─ models/ │ │ └─ User.php │ ├─ routes.php │ └─ dependencies.php ├─ config/ │ └─ settings.php ├─ vendor/ ├─ composer.json └─ migrations/ └─ create_users.sql
Dependencias (composer)
{
'require': {
'slim/slim': '^4.10',
'slim/psr7': '^1.6',
'firebase/php-jwt': '^6.4',
'vlucas/phpdotenv': '^5.5'
}
}
Base de datos (SQL)
-- migrations/create_users.sql CREATE TABLE users ( id INT AUTO_INCREMENT PRIMARY KEY, email VARCHAR(255) NOT NULL UNIQUE, password_hash VARCHAR(255) NOT NULL, name VARCHAR(100), created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );
Configuración
// config/settings.php
return [
'db' => [
'dsn' => 'mysql:host=127.0.0.1;dbname=myapi;charset=utf8mb4',
'user' => 'dbuser',
'pass' => 'dbpass'
],
'jwt' => [
'secret' => 'cambia_esta_clave_super_secreta',
'expires_in' => 3600
]
];
Punto de entrada
// public/index.php run();
Dependencias y contenedor
// src/dependencies.php
getContainer();
$container->set('db', function() use ($settings) {
$db = $settings['db'];
$pdo = new PDO($db['dsn'], $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
return $pdo;
});
$container->set('settings', function() use ($settings) {
return $settings;
});
};
Rutas
// src/routes.php
post('/login', '\\App\\Controllers\\AuthController:login');
$app->post('/register', '\\App\\Controllers\\AuthController:register');
// Protected group
$app->group('/api', function($group) {
$group->get('/me', '\\App\\Controllers\\UserController:me');
})->add(new \\App\\Middleware\\JwtMiddleware($settings['jwt']['secret']));
};
Controlador de autenticación
// src/controllers/AuthController.php
container = $c; }
public function register(Request $req, Response $res) {
$data = (array)$req->getParsedBody();
if (empty($data['email']) || empty($data['password'])) {
return $res->withStatus(400)->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'email y password requeridos']));
}
$pdo = $this->container->get('db');
$hash = password_hash($data['password'], PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)');
$stmt->execute([$data['email'], $hash, $data['name'] ?? null]);
return $res->withHeader('Content-Type', 'application/json')
->write(json_encode(['ok' => true]));
}
public function login(Request $req, Response $res) {
$data = (array)$req->getParsedBody();
$pdo = $this->container->get('db');
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ?');
$stmt->execute([$data['email']]);
$user = $stmt->fetch();
if (!$user || !password_verify($data['password'], $user['password_hash'])) {
return $res->withStatus(401)->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'credenciales inválidas']));
}
$settings = $this->container->get('settings');
$now = time();
$exp = $now + $settings['jwt']['expires_in'];
$payload = [
'sub' => $user['id'],
'iat' => $now,
'exp' => $exp
];
$token = JWT::encode($payload, $settings['jwt']['secret'], 'HS256');
return $res->withHeader('Content-Type', 'application/json')
->write(json_encode(['token' => $token]));
}
}
Middleware JWT
// src/middleware/JwtMiddleware.php
secret = $secret; }
public function __invoke($request, $handler) {
$header = $request->getHeaderLine('Authorization');
if (!$header || strpos($header, 'Bearer ') !== 0) {
return (new \Slim\Psr7\Response())->withStatus(401)->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'token requerido']));
}
$token = substr($header, 7);
try {
$decoded = JWT::decode($token, new Key($this->secret, 'HS256'));
} catch (\Exception $e) {
return (new \Slim\Psr7\Response())->withStatus(401)->withHeader('Content-Type', 'application/json')
->write(json_encode(['error' => 'token inválido']));
}
// Adjunta el user id al request para downstream
$request = $request->withAttribute('user_id', $decoded->sub);
return $handler->handle($request);
}
}
Controlador de usuario
// src/controllers/UserController.php
container = $c; }
public function me(Request $req, Response $res) {
$id = $req->getAttribute('user_id');
$pdo = $this->container->get('db');
$stmt = $pdo->prepare('SELECT id, email, name, created_at FROM users WHERE id = ?');
$stmt->execute([$id]);
$user = $stmt->fetch();
return $res->withHeader('Content-Type', 'application/json')
->write(json_encode($user ?: []));
}
}
Por qué estas decisiones
- Slim: micro-framework muy ligero para APIs, alta compatibilidad PSR-7/15.
- PDO: consultas preparadas y control de errores; evita inyección SQL cuando se usa correctamente.
- JWT: buen trade-off para APIs stateless; evita lecturas de sesión en cada petición.
- password_hash/password_verify: hashing seguro sin implementar tu propia lógica.
Pruebas rápidas
- Registrar: POST /register {"email":"a@b.com","password":"1234"}
- Login: POST /login {"email":"a@b.com","password":"1234"} -> recibe token
- Obtener perfil: GET /api/me con Header Authorization: Bearer <token>
Puntos críticos de seguridad
- Cambia la clave JWT por algo con suficiente entropía y guárdala en entorno (no en código).
- Considera rotación de claves y lista de revocación para tokens comprometidos.
- Limita el tiempo de vida del token y usa refresh tokens con almacenamiento seguro si necesitas sesiones más largas.
- Valida y sanitiza entradas; usa CORS y rate limiting en producción.
Siguiente paso: empaca la app en Docker, añade tests automatizados (PHPUnit), un endpoint de refresh seguro y considera usar una capa de caching y rate limiter (Redis) para producción. Ten cuidado con la revocación de JWTs: sin un mecanismo de revocación los tokens permanecen válidos hasta expirar.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación