Skip to content

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étodoURLAcciónCódigo de éxito
GET/api/tareasListar200 OK
GET/api/tareas/5Ver una200 OK
POST/api/tareasCrear201 Created
PUT / PATCH/api/tareas/5Actualizar (todo / parte)200 OK
DELETE/api/tareas/5Borrar204 No Content

Códigos de error más usados:

CódigoSignificado
400 Bad RequestJSON mal formado
401 UnauthorizedFalta autenticación o el token es inválido
403 ForbiddenAutenticado, pero sin permiso
404 Not FoundEl recurso no existe
405 Method Not AllowedMétodo no soportado en esa URL
422 Unprocessable ContentDatos con errores de validación
500 Internal Server ErrorError 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.php

Enviar 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.php

Con 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" -i

Tambié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 ​

  1. Agrega paginación a GET /api/tareas con ?pagina=2&por_pagina=10 y devuelve también un objeto meta con total, pagina y paginas.
  2. Agrega búsqueda: GET /api/tareas?q=cliente.
  3. 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/json y el código HTTP correcto.
  • Lee el cuerpo con php://input y json_decode(..., JSON_THROW_ON_ERROR).
  • Un único index.php (front controller) más .htaccess enruta 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).

Academia Web — aprende, copia y construye.