libro / blog / api-rest-php

// capítulo 11 · apis

Cómo crear y consumir una API REST con PHP y JavaScript

Gustavo Arias10 min de lectura

TL;DR Una API REST expone recursos (/productos) que se manipulan con métodos HTTP (GET, POST, PUT, DELETE) y responden en JSON con el código de estado adecuado. En PHP basta un archivo que lea el método, consulte MySQL con PDO y devuelva json_encode(); en el navegador, fetch hace el resto.

Cuando tu frontend necesita datos sin recargar la página, o cuando quieres que una app móvil use el mismo backend que tu web, necesitas una API. Es el punto en el que tu proyecto deja de ser “páginas PHP” y pasa a ser una arquitectura full stack de verdad. El libro Programación Full-Stack dedica un capítulo a consumir y crear APIs RESTful; aquí construimos una sobre el CRUD de productos.

Qué es una API REST, en concreto

REST es un estilo de diseño con unas pocas ideas clave:

Diseña los endpoints

Método Ruta Acción Respuesta correcta
GET /api/productos Listar 200 + array
GET /api/productos/5 Ver uno 200, o 404 si no existe
POST /api/productos Crear 201 + el producto creado
PUT /api/productos/5 Reemplazar 200 + el producto
DELETE /api/productos/5 Eliminar 204 sin cuerpo

Y para los errores: 400 o 422 si los datos no son válidos, 401 si falta autenticación, 404 si el recurso no existe, 405 si el método no está permitido y 500 si falla el servidor.

La API en PHP

Para tener URLs limpias, un .htaccess redirige todas las rutas de /api/ a un solo archivo:

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [QSA,L]
<?php
declare(strict_types=1);
require_once __DIR__ . '/../config/db.php';
require_once __DIR__ . '/../helpers.php'; // validarProducto()

header('Content-Type: application/json; charset=utf-8');

function responder(int $codigo, $datos = null): void
{
    http_response_code($codigo);
    if ($datos !== null) {
        echo json_encode($datos, JSON_UNESCAPED_UNICODE);
    }
    exit;
}

// "/mi-app/api/productos/5" → ['productos', '5']
$ruta    = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$tramo   = substr($ruta, strpos($ruta, '/api/') + strlen('/api/'));
$partes  = explode('/', trim($tramo, '/'));
$recurso = $partes[0] ?? '';
$id      = isset($partes[1]) ? filter_var($partes[1], FILTER_VALIDATE_INT) : null;
$metodo  = $_SERVER['REQUEST_METHOD'];

if ($recurso !== 'productos') {
    responder(404, ['error' => 'Recurso no encontrado']);
}

try {
    // GET /productos
    if ($metodo === 'GET' && $id === null) {
        $filas = db()->query('SELECT id, nombre, precio, stock FROM productos ORDER BY id DESC')->fetchAll();
        responder(200, $filas);
    }

    // GET /productos/5
    if ($metodo === 'GET') {
        $stmt = db()->prepare('SELECT id, nombre, precio, stock FROM productos WHERE id = ?');
        $stmt->execute([$id]);
        $producto = $stmt->fetch();
        $producto ? responder(200, $producto) : responder(404, ['error' => 'Producto no encontrado']);
    }

    // POST /productos — el cuerpo llega como JSON, no en $_POST
    if ($metodo === 'POST' && $id === null) {
        $entrada = json_decode(file_get_contents('php://input'), true) ?? [];
        [$datos, $errores] = validarProducto($entrada);
        if ($errores) {
            responder(422, ['errores' => $errores]);
        }
        $stmt = db()->prepare('INSERT INTO productos (nombre, precio, stock) VALUES (:nombre, :precio, :stock)');
        $stmt->execute($datos);
        responder(201, ['id' => (int) db()->lastInsertId()] + $datos);
    }

    // DELETE /productos/5
    if ($metodo === 'DELETE' && $id) {
        $stmt = db()->prepare('DELETE FROM productos WHERE id = ?');
        $stmt->execute([$id]);
        $stmt->rowCount() ? responder(204) : responder(404, ['error' => 'Producto no encontrado']);
    }

    responder(405, ['error' => 'Método no permitido']);
} catch (Throwable $e) {
    error_log($e->getMessage());
    responder(500, ['error' => 'Error interno']);
}

Detalles que marcan la diferencia:

El ejemplo no incluye PUT para no alargarlo: es igual que POST, pero con UPDATE ... WHERE id = :id y respondiendo 404 si el producto no existe.

Pruébala con curl

$ curl http://localhost/mi-app/api/productos

$ curl -X POST http://localhost/mi-app/api/productos \
       -H "Content-Type: application/json" \
       -d '{"nombre":"Webcam HD","precio":32000,"stock":8}'

$ curl -i -X DELETE http://localhost/mi-app/api/productos/3

La opción -i muestra las cabeceras, útil para comprobar que el código de estado es el que esperas. Si prefieres una interfaz gráfica, Postman o la extensión Thunder Client de VS Code hacen lo mismo.

Consúmela desde JavaScript

// Cárgalo con <script type="module" src="app.js"> para poder usar await fuera de funciones
const API = 'api/productos';

async function api(url, opciones = {}) {
    const res = await fetch(url, {
        headers: { 'Content-Type': 'application/json' },
        ...opciones,
    });
    if (res.status === 204) return null;
    const data = await res.json();
    if (!res.ok) throw data; // { error } o { errores }
    return data;
}

// Listar
const productos = await api(API);

// Crear
try {
    const nuevo = await api(API, {
        method: 'POST',
        body: JSON.stringify({ nombre: 'Webcam HD', precio: 32000, stock: 8 }),
    });
    console.log('Creado con id', nuevo.id);
} catch (e) {
    console.warn(e.errores ?? e.error);
}

// Eliminar
await api(`${API}/3`, { method: 'DELETE' });

Si quieres repasar fetch, async/await y cómo pintar los resultados en el DOM, tienes todo en el artículo sobre JavaScript y el DOM.

Errores comunes

Referencia de códigos de estado: HTTP response status codes en MDN.

Completa el stack: de la API al despliegue

El libro Programación Full-Stack sigue con el despliegue en producción y tres proyectos reales donde frontend, API y base de datos trabajan juntos.

Comprar en Amazon