Cómo construir una API RESTful segura en PHP con JWT y Eloquent
En este tutorial práctico vas a crear una API REST básica en PHP que cubre registro, login con JWT y acceso protegido a recursos usando Eloquent como ORM. Incluye estructura de carpetas, código completo y explicaciones de por qué se hace cada cosa.
Estructura de carpetas
my-api/
├─ app/
│ ├─ Controllers/
│ │ └─ AuthController.php
│ ├─ Middleware/
│ │ └─ JwtMiddleware.php
│ └─ Models/
│ └─ User.php
├─ public/
│ └─ index.php
├─ bootstrap.php
├─ routes.php
├─ composer.json
└─ .env
Dependencias (composer)
{
"require": {
"php": ">=7.4",
"illuminate/database": "^8.0",
"vlucas/phpdotenv": "^5.0",
"firebase/php-jwt": "^6.0",
"nikic/fast-route": "^1.3"
},
"autoload": {
"psr-4": {
"App\\": "app/"
}
}
}
Instala con:
composer install
.env (ejemplo)
APP_ENV=local
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=myapi
DB_USERNAME=root
DB_PASSWORD=secret
JWT_SECRET=tu_clave_secreta_muy_larga
bootstrap.php
<?php
use Illuminate\Database\Capsule\Manager as Capsule;
require __DIR__ . '/vendor/autoload.php';
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
$capsule = new Capsule;
$capsule->addConnection([
'driver' => getenv('DB_CONNECTION'),
'host' => getenv('DB_HOST'),
'database' => getenv('DB_DATABASE'),
'username' => getenv('DB_USERNAME'),
'password' => getenv('DB_PASSWORD'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'prefix' => '',
]);
$capsule->setAsGlobal();
$capsule->bootEloquent();
Modelo: app/Models/User.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $table = 'users';
protected $fillable = ['name', 'email', 'password'];
protected $hidden = ['password'];
public $timestamps = true;
}
Controlador: app/Controllers/AuthController.php
<?php
namespace App\Controllers;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthController
{
public function register($data)
{
// Validación mínima
if (!isset($data['email'], $data['password'], $data['name'])) {
http_response_code(422);
return ['error' => 'Faltan campos'];
}
if (User::where('email', $data['email'])->exists()) {
http_response_code(409);
return ['error' => 'Email ya registrado'];
}
$user = User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
]);
http_response_code(201);
return ['user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email]];
}
public function login($data)
{
if (!isset($data['email'], $data['password'])) {
http_response_code(422);
return ['error' => 'Faltan campos'];
}
$user = User::where('email', $data['email'])->first();
if (!$user || !password_verify($data['password'], $user->password)) {
http_response_code(401);
return ['error' => 'Credenciales inválidas'];
}
$now = time();
$payload = [
'iat' => $now,
'exp' => $now + 3600, // 1 hora
'sub' => $user->id,
];
$jwt = JWT::encode($payload, getenv('JWT_SECRET'), 'HS256');
return ['token' => $jwt];
}
public function profile($userId)
{
$user = User::find($userId);
if (!$user) {
http_response_code(404);
return ['error' => 'Usuario no encontrado'];
}
return ['user' => ['id' => $user->id, 'name' => $user->name, 'email' => $user->email]];
}
}
Middleware JWT: app/Middleware/JwtMiddleware.php
<?php
namespace App\Middleware;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class JwtMiddleware
{
public static function getUserIdFromHeader()
{
$headers = getallheaders();
if (!isset($headers['Authorization'])) return null;
$auth = $headers['Authorization'];
if (strpos($auth, 'Bearer ') !== 0) return null;
$token = trim(str_replace('Bearer ', '', $auth));
try {
$decoded = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
return $decoded->sub ?? null;
} catch (\Exception $e) {
return null;
}
}
}
Rutas: routes.php
<?php
use App\Controllers\AuthController;
use App\Middleware\JwtMiddleware;
$dispatcher = FastRoute\simpleDispatcher(function(FastRoute\RouteCollector $r) {
$r->addRoute('POST', '/register', ['AuthController', 'register']);
$r->addRoute('POST', '/login', ['AuthController', 'login']);
$r->addRoute('GET', '/profile', ['AuthController', 'profile']);
});
// dispatcher se devolverá al public/index.php para ejecutar
return $dispatcher;
Front controller: public/index.php
<?php
require __DIR__ . '/../bootstrap.php';
use App\Controllers\AuthController;
use App\Middleware\JwtMiddleware;
$dispatcher = require __DIR__ . '/../routes.php';
$httpMethod = $_SERVER['REQUEST_METHOD'];
$uri = $_SERVER['REQUEST_URI'];
// Limpiar query string
if (false !== $pos = strpos($uri, '?')) {
$uri = substr($uri, 0, $pos);
}
$uri = rawurldecode($uri);
$routeInfo = $dispatcher->dispatch($httpMethod, $uri);
$controller = new AuthController();
switch ($routeInfo[0]) {
case FastRoute\Dispatcher::NOT_FOUND:
http_response_code(404);
echo json_encode(['error' => 'Not found']);
break;
case FastRoute\Dispatcher::METHOD_NOT_ALLOWED:
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
break;
case FastRoute\Dispatcher::FOUND:
$handler = $routeInfo[1];
$vars = $routeInfo[2];
list($class, $method) = $handler;
// Simple mapping a métodos del controlador
$input = json_decode(file_get_contents('php://input'), true) ?? [];
if ($method === 'profile') {
$userId = JwtMiddleware::getUserIdFromHeader();
if (!$userId) {
http_response_code(401);
echo json_encode(['error' => 'Token inválido o ausente']);
break;
}
$response = $controller->profile($userId);
echo json_encode($response);
break;
}
if ($method === 'register') {
$response = $controller->register($input);
echo json_encode($response);
break;
}
if ($method === 'login') {
$response = $controller->login($input);
echo json_encode($response);
break;
}
// fallback
http_response_code(500);
echo json_encode(['error' => 'Handler no implementado']);
break;
}
DB: tabla users (ejemplo MySQL)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(150) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
created_at TIMESTAMP NULL DEFAULT NULL,
updated_at TIMESTAMP NULL DEFAULT NULL
);
Pruebas con curl
# Registrar
curl -X POST http://localhost:8000/register \
-H "Content-Type: application/json" \
-d '{"name":"Juan","email":"juan@ejemplo.com","password":"secret"}'
# Login
curl -X POST http://localhost:8000/login \
-H "Content-Type: application/json" \
-d '{"email":"juan@ejemplo.com","password":"secret"}'
# Acceder a perfil (reemplazar TOKEN)
curl -X GET http://localhost:8000/profile \
-H "Authorization: Bearer TOKEN"
Por qué esta arquitectura
- FastRoute: routing simple y rápido sin framework completo.
- Eloquent: ORM maduro para consultas y modelos, reduce boilerplate SQL.
- JWT: tokens compactos y stateless para APIs. Ideal para microservicios y SPAs.
- Dotenv: separar configuración sensible del código.
- Middleware: separación clara de responsabilidades (autenticación fuera del controlador).
Seguridad y buenas prácticas que debes aplicar
- Usa HTTPS obligatorio en producción: JWT en claro es inseguro en HTTP.
- Almacena JWT_SECRET en un gestor de secretos o variables de entorno seguras.
- No guardes tokens de larga duración sin control: implementar refresh tokens y revocación (blacklist) si necesitas logout inmediato.
- Valida entradas con reglas robustas (email, password strength) y maneja errores con códigos HTTP adecuados.
- Considera claims adicionales en JWT (aud, iss) y validación de los mismos.
- Control de versiones de la API y límites de tasa (rate limiting).
Extensiones recomendadas
- Implementar refresh tokens y rotación de claves.
- Añadir tests automáticos (PHPUnit) para endpoints y middleware.
- Integrar logging estructurado y monitoreo (Sentry, Prometheus).
Próximo paso sugerido: implementa refresh tokens con almacenamiento en base de datos y listas de revocación para poder invalidar sesiones. Ten en cuenta la rotación de claves JWT y siempre obliga HTTPS en producción.
Advertencia: nunca expongas tu JWT_SECRET en repositorios públicos y revisa la duración de tus tokens según el riesgo de la aplicación.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación