Construir una API REST con PHP puro: routing, respuestas JSON y autenticación con token

Construir una API REST con PHP puro —sin Laravel, Slim ni ningún framework— es un ejercicio que ayuda a entender cómo funciona el enrutado, cómo se procesan los cuerpos JSON y cómo se gestiona la autenticación. El resultado es un servidor funcional en pocos ficheros, con dependencias cero y rendimiento óptimo para proyectos pequeños o microservicios.

Estructura del proyecto

api/
??? .htaccess          # Redirigir todo al front controller
??? index.php          # Front controller (punto de entrada único)
??? Router.php         # Enrutador manual
??? Request.php        # Wrapper del request HTTP
??? Response.php       # Helper de respuestas JSON
??? controllers/
    ??? ArticulosController.php
    ??? UsuariosController.php

Front controller: .htaccess

# .htaccess
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

Toda petición que no sea un fichero o directorio existente se redirige a index.php.

index.php: el front controller

<?php
declare(strict_types=1);

header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');

require __DIR__ . '/Router.php';
require __DIR__ . '/Request.php';
require __DIR__ . '/Response.php';
require __DIR__ . '/controllers/ArticulosController.php';

$req = new Request();
$router = new Router($req);

// Definir rutas
$router->get('/articulos',         [ArticulosController::class, 'index']);
$router->get('/articulos/{id}',    [ArticulosController::class, 'show']);
$router->post('/articulos',        [ArticulosController::class, 'store']);
$router->put('/articulos/{id}',    [ArticulosController::class, 'update']);
$router->delete('/articulos/{id}', [ArticulosController::class, 'destroy']);

// Middleware de autenticación para rutas que lo necesiten
$router->middleware('/articulos', fn($req) => autenticar($req));

$router->dispatch();

Router manual con match

<?php
class Router {
    private array $rutas = [];
    private array $middlewares = [];

    public function __construct(private Request $req) {}

    public function get(string $path, array $handler): void    { $this->add('GET', $path, $handler); }
    public function post(string $path, array $handler): void   { $this->add('POST', $path, $handler); }
    public function put(string $path, array $handler): void    { $this->add('PUT', $path, $handler); }
    public function delete(string $path, array $handler): void { $this->add('DELETE', $path, $handler); }

    private function add(string $method, string $path, array $handler): void {
        // Convertir {id} ? (?P<id>[^/]+) para regex
        $patron = '#^' . preg_replace('/{(w+)}/', '(?P<$1>[^/]+)', $path) . '$#';
        $this->rutas[] = compact('method', 'patron', 'handler');
    }

    public function middleware(string $prefijo, callable $fn): void {
        $this->middlewares[] = ['prefijo' => $prefijo, 'fn' => $fn];
    }

    public function dispatch(): void {
        $method = $this->req->method();
        $uri    = parse_url($this->req->uri(), PHP_URL_PATH);
        $uri    = rtrim($uri, '/') ?: '/';

        foreach ($this->rutas as $ruta) {
            if ($ruta['method'] !== $method) continue;
            if (!preg_match($ruta['patron'], $uri, $m)) continue;

            // Extraer parámetros de ruta
            $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY);
            $this->req->setParams($params);

            // Ejecutar middlewares que apliquen
            foreach ($this->middlewares as $mw) {
                if (str_starts_with($uri, $mw['prefijo'])) {
                    ($mw['fn'])($this->req);
                }
            }

            // Ejecutar el handler
            [$clase, $metodo] = $ruta['handler'];
            (new $clase())->$metodo($this->req);
            return;
        }

        Response::json(['error' => 'Not Found'], 404);
    }
}

Request: leer el cuerpo y los parámetros

<?php
class Request {
    private array $params = [];
    private mixed $body   = null;

    public function method(): string {
        return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
    }

    public function uri(): string {
        return $_SERVER['REQUEST_URI'] ?? '/';
    }

    public function param(string $clave, mixed $defecto = null): mixed {
        return $this->params[$clave] ?? $defecto;
    }

    public function setParams(array $params): void {
        $this->params = $params;
    }

    public function query(string $clave, mixed $defecto = null): mixed {
        return $_GET[$clave] ?? $defecto;
    }

    public function body(): array {
        if ($this->body === null) {
            $raw = file_get_contents('php://input');
            $this->body = json_decode($raw, true) ?? [];
        }
        return $this->body;
    }

    public function input(string $clave, mixed $defecto = null): mixed {
        return $this->body()[$clave] ?? $defecto;
    }

    public function header(string $nombre): string {
        $clave = 'HTTP_' . strtoupper(str_replace('-', '_', $nombre));
        return $_SERVER[$clave] ?? '';
    }

    public function bearerToken(): ?string {
        $auth = $this->header('Authorization');
        if (str_starts_with($auth, 'Bearer ')) {
            return substr($auth, 7);
        }
        return null;
    }
}

Response: respuestas JSON con códigos HTTP

<?php
class Response {
    public static function json(mixed $datos, int $status = 200): never {
        http_response_code($status);
        echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
        exit;
    }

    public static function ok(mixed $datos): never {
        self::json($datos, 200);
    }

    public static function created(mixed $datos): never {
        self::json($datos, 201);
    }

    public static function noContent(): never {
        http_response_code(204);
        exit;
    }

    public static function error(string $mensaje, int $status = 400): never {
        self::json(['error' => $mensaje], $status);
    }

    public static function notFound(string $recurso = 'Recurso'): never {
        self::error("$recurso no encontrado", 404);
    }
}

Autenticación con Bearer token

<?php
function autenticar(Request $req): void {
    $token = $req->bearerToken();

    if (!$token) {
        Response::error('Se requiere token de autenticación', 401);
    }

    // Validar contra la base de datos o un listado de tokens
    $pdo   = obtenerConexion();
    $stmt  = $pdo->prepare('SELECT id_usuario FROM api_tokens WHERE token = ? AND activo = 1');
    $stmt->execute([hash('sha256', $token)]);  // guardamos el hash, no el token en claro
    $fila  = $stmt->fetch();

    if (!$fila) {
        Response::error('Token inválido o expirado', 401);
    }

    // Disponible para el handler
    $_REQUEST['auth_user_id'] = $fila['id_usuario'];
}

ArticulosController: GET, POST y DELETE

<?php
class ArticulosController {
    private PDO $pdo;

    public function __construct() {
        $this->pdo = new PDO(
            'mysql:host=localhost;dbname=miapi;charset=utf8mb4',
            'usuario', 'contraseña',
            [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
        );
    }

    // GET /articulos
    public function index(Request $req): void {
        $limite  = min((int)($req->query('limite', 20)), 100);
        $pagina  = max((int)($req->query('pagina', 1)), 1);
        $offset  = ($pagina - 1) * $limite;

        $stmt = $this->pdo->prepare(
            'SELECT id, titulo, slug, fecha FROM articulos ORDER BY fecha DESC LIMIT ? OFFSET ?'
        );
        $stmt->execute([$limite, $offset]);

        Response::ok([
            'datos'  => $stmt->fetchAll(PDO::FETCH_ASSOC),
            'pagina' => $pagina,
            'limite' => $limite,
        ]);
    }

    // GET /articulos/{id}
    public function show(Request $req): void {
        $id   = (int)$req->param('id');
        $stmt = $this->pdo->prepare('SELECT * FROM articulos WHERE id = ?');
        $stmt->execute([$id]);
        $art  = $stmt->fetch(PDO::FETCH_ASSOC);

        if (!$art) Response::notFound('Artículo');
        Response::ok($art);
    }

    // POST /articulos
    public function store(Request $req): void {
        $titulo     = trim($req->input('titulo', ''));
        $contenido  = trim($req->input('contenido', ''));

        if (!$titulo || !$contenido) {
            Response::error('título y contenido son obligatorios');
        }

        $stmt = $this->pdo->prepare(
            'INSERT INTO articulos (titulo, contenido, fecha) VALUES (?, ?, NOW())'
        );
        $stmt->execute([$titulo, $contenido]);

        Response::created(['id' => $this->pdo->lastInsertId(), 'titulo' => $titulo]);
    }

    // DELETE /articulos/{id}
    public function destroy(Request $req): void {
        $id   = (int)$req->param('id');
        $stmt = $this->pdo->prepare('DELETE FROM articulos WHERE id = ?');
        $stmt->execute([$id]);

        if ($stmt->rowCount() === 0) Response::notFound('Artículo');
        Response::noContent();
    }
}

Probar la API con curl

# Listar artículos
curl https://api.ejemplo.com/articulos?limite=5

# Obtener uno
curl https://api.ejemplo.com/articulos/42

# Crear con token
curl -X POST https://api.ejemplo.com/articulos 
  -H "Authorization: Bearer mi_token_secreto" 
  -H "Content-Type: application/json" 
  -d '{"titulo":"Nuevo artículo","contenido":"Cuerpo del artículo"}'

# Eliminar
curl -X DELETE https://api.ejemplo.com/articulos/42 
  -H "Authorization: Bearer mi_token_secreto"

Esta estructura de front controller + router + request/response es exactamente lo que implementan frameworks como Slim o Lumen, pero en pocas decenas de líneas. Para proyectos simples es más que suficiente y ayuda a entender por qué los frameworks hacen lo que hacen.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP