Tema
API REST con PHP puro
Hoy muchas aplicaciones separan el backend (datos y lógica) del frontend (la interfaz en React, Vue, una app móvil...). Se comunican mediante una API que intercambia JSON. En esta lección construirás una API REST completa con PHP puro: rutas, métodos HTTP, códigos de estado, validación y autenticación con token.
Concepto
Una API es como el mesero de un restaurante: el cliente (frontend) no entra a la cocina (base de datos), sino que hace pedidos al mesero con un formato acordado, y el mesero trae la respuesta.
REST es un estilo para diseñar APIs sobre HTTP:
- Cada recurso tiene una URL:
/api/tareas,/api/tareas/5. - El método HTTP indica la acción.
- Las respuestas usan JSON y códigos de estado HTTP.
| Método | URL | Acción | Código de éxito |
|---|---|---|---|
| GET | /api/tareas | Listar | 200 OK |
| GET | /api/tareas/5 | Ver una | 200 OK |
| POST | /api/tareas | Crear | 201 Created |
| PUT / PATCH | /api/tareas/5 | Actualizar (todo / parte) | 200 OK |
| DELETE | /api/tareas/5 | Borrar | 204 No Content |
Códigos de error más usados:
| Código | Significado |
|---|---|
| 400 Bad Request | JSON mal formado |
| 401 Unauthorized | Falta autenticación o el token es inválido |
| 403 Forbidden | Autenticado, pero sin permiso |
| 404 Not Found | El recurso no existe |
| 405 Method Not Allowed | Método no soportado en esa URL |
| 422 Unprocessable Content | Datos con errores de validación |
| 500 Internal Server Error | Error en el servidor |
Base de datos
sql
CREATE DATABASE IF NOT EXISTS api_tareas CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE api_tareas;
CREATE TABLE tareas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(150) NOT NULL,
completada TINYINT(1) NOT NULL DEFAULT 0,
creada_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
CREATE TABLE api_tokens (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
nombre VARCHAR(100) NOT NULL,
token_hash CHAR(64) NOT NULL UNIQUE -- guardamos el hash SHA-256, no el token
) ENGINE=InnoDB;Estructura
txt
api/
├── public/
│ ├── index.php ← punto de entrada único (front controller)
│ └── .htaccess ← envía todas las peticiones a index.php
└── src/
├── db.php
├── respuesta.php
└── tareas.phpEnviar todo a index.php
apache
# public/.htaccess
RewriteEngine On
# Si el archivo o carpeta no existe, pasa la petición a index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]
# Apache a veces elimina la cabecera Authorization; esto la conserva
RewriteCond %{HTTP:Authorization} .
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]Con el servidor integrado no hace falta .htaccess: php -S localhost:8000 -t public public/index.php envía todo a index.php.
Utilidades de respuesta
php
<?php
// src/respuesta.php
declare(strict_types=1);
/**
* Envía una respuesta JSON con su código HTTP y termina.
*/
function json(mixed $datos, int $codigo = 200): never
{
http_response_code($codigo);
header('Content-Type: application/json; charset=utf-8');
if ($codigo !== 204) {
echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
}
exit;
}
function error(string $mensaje, int $codigo, array $extra = []): never
{
json(['error' => $mensaje] + $extra, $codigo);
}
/**
* Lee el cuerpo JSON de la petición y lo devuelve como array.
*/
function leerJson(): array
{
$cuerpo = file_get_contents('php://input');
if ($cuerpo === '' || $cuerpo === false) {
return [];
}
try {
$datos = json_decode($cuerpo, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException) {
error('El cuerpo no es JSON válido.', 400);
}
if (!is_array($datos)) {
error('Se esperaba un objeto JSON.', 400);
}
return $datos;
}Error común
Intentar leer el JSON con $_POST. $_POST sólo se llena con formularios (application/x-www-form-urlencoded o multipart/form-data). Un cuerpo JSON se lee con file_get_contents('php://input').
php
<?php
// src/db.php
declare(strict_types=1);
function db(): PDO
{
static $pdo = null;
return $pdo ??= new PDO(
'mysql:host=127.0.0.1;dbname=api_tareas;charset=utf8mb4',
'root',
'',
[
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]
);
}??= asigna sólo si la variable es null.
Controlador de tareas
php
<?php
// src/tareas.php
declare(strict_types=1);
// Convierte una fila de BD al formato público de la API
function formatearTarea(array $fila): array
{
return [
'id' => (int) $fila['id'],
'titulo' => $fila['titulo'],
'completada' => (bool) $fila['completada'],
'creada_en' => date(DATE_ATOM, strtotime($fila['creada_en'])),
];
}
function buscarTareaOFallar(int $id): array
{
$stmt = db()->prepare('SELECT * FROM tareas WHERE id = ?');
$stmt->execute([$id]);
return $stmt->fetch() ?: error('Tarea no encontrada.', 404);
}
function validarTarea(array $datos, bool $parcial = false): array
{
$errores = [];
$limpio = [];
if (!$parcial || array_key_exists('titulo', $datos)) {
$titulo = trim((string) ($datos['titulo'] ?? ''));
if ($titulo === '' || mb_strlen($titulo) > 150) {
$errores['titulo'] = 'El título es obligatorio y de máximo 150 caracteres.';
}
$limpio['titulo'] = $titulo;
}
if (array_key_exists('completada', $datos)) {
if (!is_bool($datos['completada'])) {
$errores['completada'] = 'Debe ser true o false.';
}
$limpio['completada'] = (int) $datos['completada'];
}
if ($errores) {
error('Datos inválidos.', 422, ['errores' => $errores]);
}
return $limpio;
}
// GET /api/tareas?completada=1
function listarTareas(): never
{
$sql = 'SELECT * FROM tareas';
$params = [];
if (isset($_GET['completada'])) {
$sql .= ' WHERE completada = ?';
$params[] = $_GET['completada'] === '1' ? 1 : 0;
}
$stmt = db()->prepare($sql . ' ORDER BY id DESC');
$stmt->execute($params);
json(['data' => array_map('formatearTarea', $stmt->fetchAll())]);
}
// GET /api/tareas/{id}
function verTarea(int $id): never
{
json(['data' => formatearTarea(buscarTareaOFallar($id))]);
}
// POST /api/tareas
function crearTarea(): never
{
$datos = validarTarea(leerJson());
$stmt = db()->prepare('INSERT INTO tareas (titulo, completada) VALUES (?, ?)');
$stmt->execute([$datos['titulo'], $datos['completada'] ?? 0]);
$id = (int) db()->lastInsertId();
header("Location: /api/tareas/$id"); // dónde encontrar el recurso nuevo
json(['data' => formatearTarea(buscarTareaOFallar($id))], 201);
}
// PUT /api/tareas/{id} (todos los campos) y PATCH (sólo algunos)
function actualizarTarea(int $id, bool $parcial): never
{
buscarTareaOFallar($id);
$datos = validarTarea(leerJson(), $parcial);
if ($datos) {
// Construimos el SET sólo con columnas de una lista conocida (validadas arriba)
$sets = implode(', ', array_map(fn ($col) => "$col = :$col", array_keys($datos)));
$stmt = db()->prepare("UPDATE tareas SET $sets WHERE id = :id");
$stmt->execute($datos + ['id' => $id]);
}
json(['data' => formatearTarea(buscarTareaOFallar($id))]);
}
// DELETE /api/tareas/{id}
function borrarTarea(int $id): never
{
buscarTareaOFallar($id);
db()->prepare('DELETE FROM tareas WHERE id = ?')->execute([$id]);
json(null, 204);
}El enrutador: index.php
php
<?php
// public/index.php
declare(strict_types=1);
require __DIR__ . '/../src/respuesta.php';
require __DIR__ . '/../src/db.php';
require __DIR__ . '/../src/tareas.php';
// --- CORS: permite que un frontend en otro dominio consuma la API ---
$origenesPermitidos = ['http://localhost:5173', 'https://app.midominio.com'];
$origen = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origen, $origenesPermitidos, true)) {
header("Access-Control-Allow-Origin: $origen");
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
}
// El navegador envía OPTIONS antes de ciertas peticiones ("preflight")
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
// --- Manejo global de errores: siempre respondemos JSON ---
set_exception_handler(function (Throwable $e): void {
error_log((string) $e);
error('Error interno del servidor.', 500);
});
// --- Autenticación por token Bearer ---
function autenticar(): void
{
$cabecera = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+(\S+)$/', $cabecera, $m)) {
error('Token requerido.', 401);
}
$stmt = db()->prepare('SELECT id FROM api_tokens WHERE token_hash = ?');
$stmt->execute([hash('sha256', $m[1])]);
if (!$stmt->fetch()) {
error('Token inválido.', 401);
}
}
// --- Enrutamiento ---
$metodo = $_SERVER['REQUEST_METHOD'];
$ruta = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
// /api/tareas
if ($ruta === '/api/tareas') {
match ($metodo) {
'GET' => listarTareas(),
'POST' => (function () { autenticar(); crearTarea(); })(),
default => error('Método no permitido.', 405),
};
}
// /api/tareas/{id}
if (preg_match('#^/api/tareas/(\d+)$#', $ruta, $m)) {
$id = (int) $m[1];
if ($metodo !== 'GET') {
autenticar(); // sólo lectura es pública
}
match ($metodo) {
'GET' => verTarea($id),
'PUT' => actualizarTarea($id, parcial: false),
'PATCH' => actualizarTarea($id, parcial: true),
'DELETE' => borrarTarea($id),
default => error('Método no permitido.', 405),
};
}
error('Ruta no encontrada.', 404);Crear un token
php
<?php
// crear-token.php (ejecútalo una vez desde la terminal: php crear-token.php)
require __DIR__ . '/src/db.php';
$token = bin2hex(random_bytes(32)); // 64 caracteres aleatorios
db()->prepare('INSERT INTO api_tokens (nombre, token_hash) VALUES (?, ?)')
->execute(['App móvil', hash('sha256', $token)]);
echo "Guarda este token, no se volverá a mostrar:\n$token\n";Buena práctica
Guarda en la base de datos el hash del token, no el token. Si alguien roba tu base de datos, no podrá usar los tokens. Es la misma idea que con las contraseñas (ver Contraseñas y autenticación).
Probar la API
Arranca el servidor:
bash
php -S localhost:8000 -t public public/index.phpCon curl (viene en Windows 10/11; en PowerShell usa curl.exe):
bash
# Listar
curl http://localhost:8000/api/tareas
# Crear (con token)
curl -X POST http://localhost:8000/api/tareas \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TU_TOKEN" \
-d '{"titulo": "Enviar cotización al cliente"}'
# Marcar como completada
curl -X PATCH http://localhost:8000/api/tareas/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TU_TOKEN" \
-d '{"completada": true}'
# Borrar
curl -X DELETE http://localhost:8000/api/tareas/1 -H "Authorization: Bearer TU_TOKEN" -iTambién puedes usar extensiones como Thunder Client o REST Client en VS Code, o programas como Postman, Insomnia o Bruno.
Respuesta de ejemplo:
json
{
"data": {
"id": 1,
"titulo": "Enviar cotización al cliente",
"completada": false,
"creada_en": "2026-10-18T14:35:09-06:00"
}
}Consumir la API desde JavaScript
js
// Desde un frontend (por ejemplo en http://localhost:5173)
const API = 'http://localhost:8000/api';
const TOKEN = 'TU_TOKEN'; // en una app real, obtenido tras iniciar sesión
async function crearTarea(titulo) {
const res = await fetch(`${API}/tareas`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${TOKEN}`,
},
body: JSON.stringify({ titulo }),
});
const json = await res.json();
if (!res.ok) {
// 422: mostrar los errores de validación
console.error(json.error, json.errores);
return null;
}
return json.data;
}
crearTarea('Revisar diseño').then(console.log);Error común
Responder siempre con código 200 y un campo "ok": false cuando hay errores. Los clientes de la API (y herramientas como fetch con res.ok) dependen del código HTTP. Usa 4xx para errores del cliente y 5xx para errores del servidor.
Ejercicios
- Agrega paginación a
GET /api/tareascon?pagina=2&por_pagina=10y devuelve también un objetometacontotal,paginaypaginas. - Agrega búsqueda:
GET /api/tareas?q=cliente. - Escribe una pequeña página HTML con JavaScript que liste las tareas de la API y permita crear nuevas.
Ver solución
php
<?php
// 1 y 2: nueva versión de listarTareas()
function listarTareas(): never
{
$where = [];
$params = [];
if (isset($_GET['completada'])) {
$where[] = 'completada = :completada';
$params['completada'] = $_GET['completada'] === '1' ? 1 : 0;
}
if (!empty($_GET['q'])) {
$where[] = 'titulo LIKE :q';
$params['q'] = '%' . $_GET['q'] . '%';
}
$sqlWhere = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 10)));
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$stmt = db()->prepare("SELECT COUNT(*) FROM tareas $sqlWhere");
$stmt->execute($params);
$total = (int) $stmt->fetchColumn();
$stmt = db()->prepare("SELECT * FROM tareas $sqlWhere ORDER BY id DESC LIMIT :lim OFFSET :off");
foreach ($params as $k => $v) $stmt->bindValue($k, $v);
$stmt->bindValue('lim', $porPagina, PDO::PARAM_INT);
$stmt->bindValue('off', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
json([
'data' => array_map('formatearTarea', $stmt->fetchAll()),
'meta' => [
'total' => $total,
'pagina' => $pagina,
'paginas' => (int) ceil($total / $porPagina),
],
]);
}html
<!-- 3: cliente.html (sírvelo desde otro puerto y añádelo a $origenesPermitidos) -->
<!DOCTYPE html>
<html lang="es">
<head><meta charset="UTF-8"><title>Tareas</title></head>
<body>
<form id="form">
<input id="titulo" placeholder="Nueva tarea" required>
<button>Agregar</button>
</form>
<ul id="lista"></ul>
<script>
const API = 'http://localhost:8000/api/tareas';
const TOKEN = 'TU_TOKEN';
const lista = document.querySelector('#lista');
async function cargar() {
const { data } = await (await fetch(API)).json();
lista.innerHTML = '';
for (const t of data) {
const li = document.createElement('li');
li.textContent = (t.completada ? '✔ ' : '') + t.titulo; // textContent evita XSS
lista.append(li);
}
}
document.querySelector('#form').addEventListener('submit', async (e) => {
e.preventDefault();
const input = document.querySelector('#titulo');
await fetch(API, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + TOKEN },
body: JSON.stringify({ titulo: input.value }),
});
input.value = '';
cargar();
});
cargar();
</script>
</body>
</html>Resumen
- Una API REST expone recursos en URLs y usa los métodos HTTP como acciones.
- Responde siempre JSON con
Content-Type: application/jsony el código HTTP correcto. - Lee el cuerpo con
php://inputyjson_decode(..., JSON_THROW_ON_ERROR). - Un único
index.php(front controller) más.htaccessenruta todas las peticiones. - Valida y devuelve 422 con los errores por campo.
- Autentica con tokens Bearer guardados como hash y configura CORS sólo para orígenes conocidos (ver CORS).