Cómo construir una API REST con PHP 8, Slim y JWT paso a paso

php Cómo construir una API REST con PHP 8, Slim y JWT paso a paso

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.php expone la app; src contiene lógica; config inicializa 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 base64 en lugar de JWT: no es seguro para autenticación.
  • Guardar contraseña en texto: usa password_hash y password_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_DEBUG en 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.

Comentarios
¿Quieres comentar?

Inicia sesión con Telegram para participar en la conversación


Comentarios (0)

Aún no hay comentarios. ¡Sé el primero en comentar!

Iniciar Sesión