Cómo construir una API REST con PHP 8, Slim y JWT paso a paso
En este tutorial práctico vas a crear una API REST simple con autenticación por JWT usando PHP 8, Slim 4 y Eloquent (Illuminate/Database). Verás estructura de carpetas, código completo, por qué se hacen así y ejemplos de uso con curl.
Requisitos
- PHP 8.0+
- Composer
- MySQL o MariaDB (puedes usar SQLite para pruebas)
- Conocimientos básicos de PSR-7 y middleware
Estructura del proyecto
project/
├─ public/
│ └─ index.php
├─ src/
│ ├─ Controllers/
│ │ └─ AuthController.php
│ ├─ Middleware/
│ │ └─ JwtMiddleware.php
│ └─ Models/
│ └─ User.php
├─ config/
│ └─ database.php
├─ migrations/
│ └─ 2026_01_create_users.sql
├─ .env
├─ composer.json
└─ README.md
Dependencias (Composer)
composer require slim/slim:"^4.0" slim/psr7 vlucas/phpdotenv illuminate/database firebase/php-jwt
Explicación rápida de por qué:
- Slim: router y manejo de peticiones PSR-7 de forma ligera.
- Illuminate/Database: Eloquent ORM, cómodo para consultas y modelos.
- firebase/php-jwt: crear y validar JWTs.
- vlucas/phpdotenv: manejar variables de entorno (secretos fuera del código).
Archivo .env (ejemplo)
APP_ENV=development
APP_DEBUG=true
DB_DRIVER=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=api_db
DB_USERNAME=root
DB_PASSWORD=secret
JWT_SECRET=YourSuperSecretKeyHere
JWT_TTL=3600 # segundos
Configuración de Eloquent (config/database.php)
<?php
use Illuminate\Database\Capsule\Manager as Capsule;
return function () {
$capsule = new Capsule;
$capsule->addConnection([
'driver' => getenv('DB_DRIVER'),
'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 User (src/Models/User.php)
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class User extends Model
{
protected $table = 'users';
protected $fillable = ['email', 'password', 'name'];
protected $hidden = ['password'];
public $timestamps = true;
}
Controlador de Auth (src/Controllers/AuthController.php)
<?php
namespace App\Controllers;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use App\Models\User;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
class AuthController
{
public function register(Request $request, Response $response)
{
$data = (array) $request->getParsedBody();
if (empty($data['email']) || empty($data['password'])) {
$response->getBody()->write(json_encode(['error' => 'email and password required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
if (User::where('email', $data['email'])->exists()) {
$response->getBody()->write(json_encode(['error' => 'user already exists']));
return $response->withStatus(409)->withHeader('Content-Type', 'application/json');
}
$user = User::create([
'email' => $data['email'],
'name' => $data['name'] ?? null,
'password' => password_hash($data['password'], PASSWORD_DEFAULT),
]);
$response->getBody()->write(json_encode(['user' => ['id' => $user->id, 'email' => $user->email]]));
return $response->withStatus(201)->withHeader('Content-Type', 'application/json');
}
public function login(Request $request, Response $response)
{
$data = (array) $request->getParsedBody();
if (empty($data['email']) || empty($data['password'])) {
$response->getBody()->write(json_encode(['error' => 'email and password required']));
return $response->withStatus(400)->withHeader('Content-Type', 'application/json');
}
$user = User::where('email', $data['email'])->first();
if (! $user || ! password_verify($data['password'], $user->password)) {
$response->getBody()->write(json_encode(['error' => 'invalid credentials']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$now = time();
$ttl = (int) getenv('JWT_TTL');
$payload = [
'iat' => $now,
'exp' => $now + $ttl,
'sub' => $user->id,
'email' => $user->email
];
$jwt = JWT::encode($payload, getenv('JWT_SECRET'), 'HS256');
$response->getBody()->write(json_encode(['token' => $jwt]));
return $response->withHeader('Content-Type', 'application/json');
}
public function me(Request $request, Response $response)
{
$user = $request->getAttribute('user');
$response->getBody()->write(json_encode(['user' => $user]));
return $response->withHeader('Content-Type', 'application/json');
}
}
Middleware JWT (src/Middleware/JwtMiddleware.php)
<?php
namespace App\Middleware;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\ResponseInterface as Response;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use App\Models\User;
class JwtMiddleware
{
public function __invoke(Request $request, Response $response, $next)
{
$authHeader = $request->getHeaderLine('Authorization');
if (! $authHeader || ! preg_match('/Bearer\s+(.*)$/i', $authHeader, $matches)) {
$response->getBody()->write(json_encode(['error' => 'Token not provided']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$token = $matches[1];
try {
$decoded = JWT::decode($token, new Key(getenv('JWT_SECRET'), 'HS256'));
} catch (\Exception $e) {
$response->getBody()->write(json_encode(['error' => 'Invalid token', 'message' => $e->getMessage()]));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
$user = User::find($decoded->sub);
if (! $user) {
$response->getBody()->write(json_encode(['error' => 'User not found']));
return $response->withStatus(401)->withHeader('Content-Type', 'application/json');
}
// Attach user simple payload to request
$request = $request->withAttribute('user', ['id' => $user->id, 'email' => $user->email]);
return $next($request, $response);
}
}
Bootstrap público (public/index.php)
<?php
require __DIR__ . '/../vendor/autoload.php';
use Slim\Factory\AppFactory;
use Dotenv\Dotenv;
$dotenv = Dotenv::createImmutable(__DIR__ . '/../');
$dotenv->load();
// Eloquent
(require __DIR__ . '/../config/database.php')();
$app = AppFactory::create();
$app->addBodyParsingMiddleware();
$app->addRoutingMiddleware();
// Error handling (simplified for dev)
$errorMiddleware = $app->addErrorMiddleware((bool) getenv('APP_DEBUG'), true, true);
// Controllers
use App\Controllers\AuthController;
use App\Middleware\JwtMiddleware;
$authController = new AuthController();
$jwtMiddleware = new JwtMiddleware();
$app->post('/register', [$authController, 'register']);
$app->post('/login', [$authController, 'login']);
$app->get('/me', [$authController, 'me'])->add($jwtMiddleware);
$app->run();
Migration SQL (migrations/2026_01_create_users.sql)
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Pruebas rápidas con curl
Registrar:
curl -X POST http://localhost/register \
-H "Content-Type: application/json" \
-d '{"email":"dev@ejemplo.com","password":"secret123","name":"Dev"}'
Login:
curl -X POST http://localhost/login \
-H "Content-Type: application/json" \
-d '{"email":"dev@ejemplo.com","password":"secret123"}'
# Respuesta: {"token":"eyJ0eXAiOi..."}
Acceder a ruta protegida:
curl http://localhost/me \
-H "Authorization: Bearer eyJ0eXAiOi..."
Por qué esta arquitectura
- Separación clara:
public/index.phpexpone la app;srccontiene lógica;configinicializa dependencias. - Eloquent permite modelos sencillos sin acoplar tu código a consultas SQL manuales.
- JWT suministra autenticación sin estado (stateless), ideal para APIs escalables.
- Dotenv mantiene secretos fuera del repo; nunca comites
.env.
Buenas prácticas y seguridad importantes
- Usa HTTPS siempre; sin TLS el JWT puede ser interceptado.
- Guarda la clave JWT en una variable de entorno segura y rota la clave periódicamente.
- Usa claims mínimos y expiraciones cortas (e.g., 15 min) y refresh tokens si necesitas sesiones largas.
- Considera algoritmos asimétricos (RS256) en entornos distribuidos para poder rotar claves públicas sin invalidar clientes.
- No guardes información sensible en el payload del JWT.
- Implementa revocación: si necesitas invalidar tokens, usa una lista negra (DB/Redis) o mantén un token version en el usuario.
- Valida input y sanitiza donde corresponda; evita SQL inyection incluso con Eloquent (usa bind parameters y consultas Eloquent).
- Protege endpoints con rate limiting y CORS configurado correctamente.
Errores comunes y cómo evitarlos
- Usar
base64en lugar de JWT: no es seguro para autenticación. - Guardar contraseña en texto: usa
password_hashypassword_verify. - No validar expiración del token: confía siempre en la verificación de la librería JWT.
- Exponer detalles de errores en producción: desactiva
APP_DEBUGen producción.
Siguientes pasos recomendados
- Agregar refresh tokens con almacenamiento en Redis o DB para sesión renovable.
- Implementar pruebas automáticas (PHPUnit) para endpoints y middleware.
- Desplegar detrás de un gateway (NGINX) con rate limiting y TLS, y añadir monitorización.
Consejo avanzado: para infra distribuida, considera emitir JWTs firmados con RS256 y gestionar claves públicas mediante un endpoint JWKS; así puedes rotar claves sin invalidar clientes de forma inmediata. Advertencia: no confundas stateless con sin control —implementa revocación cuando sea necesario y evita expiraciones largas.
¿Quieres comentar?
Inicia sesión con Telegram para participar en la conversación