API REST en PHP: no hace falta un framework para entender cómo funciona una API. En un solo archivo de unas 60 líneas cabe una API de tareas con rutas, JSON, códigos de estado, validación y base de datos, y se prueba con curl.
También te puede interesar
El código completo
Toda la API está en un archivo, router.php, que recibe todas las peticiones. Se arranca con el servidor integrado (php -S localhost:8000 router.php); en producción, Nginx enviaría todas las peticiones a ese mismo archivo, como en el proyecto del curso.
<?php
declare(strict_types=1);
// API REST mínima de tareas: GET, POST y DELETE sobre /api/tareas, con SQLite.
header('Content-Type: application/json; charset=utf-8');
function responder(int $codigo, array $datos): never
{
http_response_code($codigo);
echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
exit;
}
$pdo = new PDO('sqlite:' . sys_get_temp_dir() . '/api-tareas.sqlite', options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE IF NOT EXISTS tareas (id INTEGER PRIMARY KEY, texto TEXT NOT NULL, hecha INTEGER NOT NULL DEFAULT 0)');
$metodo = $_SERVER['REQUEST_METHOD'];
$ruta = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// /api/tareas o /api/tareas/{id}
if (preg_match('#^/api/tareas(?:/(\d+))?$#', $ruta, $m) !== 1) {
responder(404, ['error' => 'Ruta desconocida']);
}
$id = isset($m[1]) ? (int) $m[1] : null;
if ($metodo === 'GET' && $id === null) {
$tareas = $pdo->query('SELECT id, texto, hecha FROM tareas ORDER BY id')->fetchAll();
responder(200, array_map(fn(array $t): array => ['id' => (int) $t['id'], 'texto' => $t['texto'], 'hecha' => (bool) $t['hecha']], $tareas));
}
if ($metodo === 'GET') {
$consulta = $pdo->prepare('SELECT id, texto, hecha FROM tareas WHERE id = ?');
$consulta->execute([$id]);
$t = $consulta->fetch() ?: responder(404, ['error' => "No existe la tarea $id"]);
responder(200, ['id' => (int) $t['id'], 'texto' => $t['texto'], 'hecha' => (bool) $t['hecha']]);
}
if ($metodo === 'POST' && $id === null) {
try {
$entrada = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
responder(400, ['error' => 'El cuerpo no es JSON válido']);
}
$texto = is_array($entrada) && is_string($entrada['texto'] ?? null) ? trim($entrada['texto']) : '';
if ($texto === '' || mb_strlen($texto) > 200) {
responder(422, ['error' => 'El campo «texto» es obligatorio y como mucho de 200 caracteres']);
}
$pdo->prepare('INSERT INTO tareas (texto) VALUES (?)')->execute([$texto]);
$nuevo = (int) $pdo->lastInsertId();
header("Location: /api/tareas/$nuevo");
responder(201, ['id' => $nuevo, 'texto' => $texto, 'hecha' => false]);
}
if ($metodo === 'DELETE' && $id !== null) {
$borrado = $pdo->prepare('DELETE FROM tareas WHERE id = ?');
$borrado->execute([$id]);
$borrado->rowCount() === 1 ? responder(200, ['borrada' => $id]) : responder(404, ['error' => "No existe la tarea $id"]);
}
header('Allow: GET, POST, DELETE');
responder(405, ['error' => "Método $metodo no permitido aquí"]);Lo esencial de una API REST está en tres decisiones:
- La ruta y el método dicen qué hacer:
GET /api/tareaslista,GET /api/tareas/2devuelve una,POSTcrea yDELETEborra. La expresión regular separa el id de la ruta. - El código de estado dice qué pasó: 200 bien, 201 creado (con la cabecera
Locationde lo nuevo), 400 petición mal formada, 404 no existe, 405 método no permitido, 422 datos que no pasan la validación. - Todo es JSON, también los errores, con la cabecera
Content-Type: application/json. La funciónresponder, de tiponever, envía la respuesta y termina, así que ninguna rama sigue ejecutándose por error.
Probarla con curl
Crear una tarea:
curl -i -X POST -H 'Content-Type: application/json' -d '{"texto": "Comprar café"}' localhost:8000/api/tareasHTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/tareas/1
{"id":1,"texto":"Comprar café","hecha":false}Después de crear una segunda, la lista y una sola:
curl -i localhost:8000/api/tareasHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[{"id":1,"texto":"Comprar café","hecha":false},{"id":2,"texto":"Llamar al banco","hecha":false}]curl -i localhost:8000/api/tareas/2HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"id":2,"texto":"Llamar al banco","hecha":false}Borrar, y lo que pasa con una tarea que no existe o un método que la ruta no admite:
curl -i -X DELETE localhost:8000/api/tareas/1HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{"borrada":1}curl -i localhost:8000/api/tareas/99HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"No existe la tarea 99"}curl -i -X PUT localhost:8000/api/tareas/2HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Allow: GET, POST, DELETE
{"error":"Método PUT no permitido aquí"}Validar lo que llega
Una API recibe peticiones de cualquiera, no solo de tu aplicación. Por eso el código no se fía del cuerpo: comprueba que sea JSON, que sea un objeto, que texto sea un texto y que tenga una longitud razonable:
cuerpo que no es JSONHTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"error":"El cuerpo no es JSON válido"}texto vacíoHTTP/1.1 422 Unknown Status Code
Content-Type: application/json; charset=utf-8
{"error":"El campo «texto» es obligatorio y como mucho de 200 caracteres"}Ese «Unknown Status Code» del 422 lo escribe el servidor integrado de PHP, que no conoce el nombre de ese código; Nginx o Apache ponen «Unprocessable Content». Lo que leen los programas es el número. Y en la base de datos, consultas preparadas siempre: el texto de la tarea viaja como dato, nunca dentro del SQL.
Authorization), comprueba en cada consulta que la tarea es de ese usuario, como en el proyecto, y limita cuántas peticiones acepta por minuto. Para algo más grande, un framework como Slim, Symfony o Laravel resuelve las rutas, la validación y los errores por ti.Resumen
| Para | Usa |
|---|---|
| Responder JSON | header('Content-Type: application/json') y json_encode |
| El código de estado | http_response_code(201) |
| Leer un cuerpo JSON | json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR) |
| Rutas con id | preg_match('#^/api/tareas(?:/(\d+))?$#', $ruta, $m) |
| Método no permitido | 405 y la cabecera Allow |
Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio: la aplicación, con el servidor integrado de PHP (php -S) y sin acceso a la red, y las peticiones, con curl desde el mismo contenedor. Las respuestas están copiadas de esa ejecución.