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.
