Skip to content

API REST JSON en PHP ​

Una API REST pequeña pero bien hecha, sin frameworks: un router que entiende GET, POST, PUT, PATCH y DELETE, parámetros en la URL (/api/productos/15), respuestas JSON con códigos HTTP correctos, CORS y autenticación por token.

Ideal para alimentar una app móvil, un frontend en React/Vue o un sitio estático que necesite datos dinámicos.

Estructura ​

txt
api/
├── .htaccess        ← manda todo a index.php
├── index.php        ← define las rutas
├── Router.php       ← router mínimo
└── controladores/
    └── productos.php

Router ​

php
<?php
final class Router
{
    private array $rutas = [];

    public function __construct(private string $prefijo = '') {}

    public function get(string $ruta, callable $accion): void    { $this->agregar('GET', $ruta, $accion); }
    public function post(string $ruta, callable $accion): void   { $this->agregar('POST', $ruta, $accion); }
    public function put(string $ruta, callable $accion): void    { $this->agregar('PUT', $ruta, $accion); }
    public function patch(string $ruta, callable $accion): void  { $this->agregar('PATCH', $ruta, $accion); }
    public function delete(string $ruta, callable $accion): void { $this->agregar('DELETE', $ruta, $accion); }

    private function agregar(string $metodo, string $ruta, callable $accion): void
    {
        // Convierte "/productos/{id}" en la expresión regular "#^/productos/(?P<id>[^/]+)$#"
        $regex = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $this->prefijo . $ruta);
        $this->rutas[] = [$metodo, '#^' . $regex . '$#', $accion];
    }

    public function despachar(string $metodo, string $uri): void
    {
        $ruta = rtrim(parse_url($uri, PHP_URL_PATH), '/') ?: '/';
        $metodoPermitido = false;

        foreach ($this->rutas as [$m, $regex, $accion]) {
            if (preg_match($regex, $ruta, $coincidencias)) {
                if ($m !== $metodo) {
                    $metodoPermitido = true;
                    continue;
                }
                // Sólo los grupos con nombre ({id}, {slug}...)
                $params = array_filter($coincidencias, 'is_string', ARRAY_FILTER_USE_KEY);
                $accion(...$params);
                return;
            }
        }
        $metodoPermitido
            ? responder(['error' => 'Método no permitido'], 405)
            : responder(['error' => 'Ruta no encontrada'], 404);
    }
}

/** Envía JSON y termina */
function responder(mixed $datos, int $codigo = 200): never
{
    http_response_code($codigo);
    if ($codigo !== 204) {
        echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }
    exit;
}

/** Lee el cuerpo JSON de la petición */
function cuerpo_json(): array
{
    $datos = json_decode(file_get_contents('php://input'), true);
    if (!is_array($datos)) {
        responder(['error' => 'JSON no válido'], 400);
    }
    return $datos;
}

Punto de entrada y rutas ​

php
<?php
declare(strict_types=1);

require __DIR__ . '/../includes/db.php';
require __DIR__ . '/Router.php';
require __DIR__ . '/controladores/productos.php';

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

// --- CORS: permite que tu frontend (otro dominio) consuma la API ---
$origenesPermitidos = ['http://localhost:5173', 'https://www.tudominio.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');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    responder(null, 204); // respuesta al "preflight" del navegador
}

// --- Autenticación simple por token (Authorization: Bearer xxx) ---
function requiere_token(): void
{
    $cabecera = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
    $token = str_starts_with($cabecera, 'Bearer ') ? substr($cabecera, 7) : '';
    if (!$token || !hash_equals((string) env('API_TOKEN'), $token)) {
        responder(['error' => 'No autorizado'], 401);
    }
}

// --- Errores no controlados → JSON 500 ---
set_exception_handler(function (Throwable $e) {
    error_log($e->getMessage());
    responder(['error' => env('APP_ENV') === 'local' ? $e->getMessage() : 'Error interno'], 500);
});

$router = new Router('/api');

$router->get('/productos', 'productos_listar');
$router->get('/productos/{id}', 'productos_ver');
$router->post('/productos', function () { requiere_token(); productos_crear(); });
$router->put('/productos/{id}', function (string $id) { requiere_token(); productos_actualizar($id); });
$router->delete('/productos/{id}', function (string $id) { requiere_token(); productos_eliminar($id); });

$router->get('/', fn() => responder(['api' => 'Mi tienda', 'version' => '1.0']));

$router->despachar($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);

Controlador ​

php
<?php
function productos_listar(): void
{
    $porPagina = min(50, max(1, (int) ($_GET['limite'] ?? 20)));
    $pagina = max(1, (int) ($_GET['pagina'] ?? 1));
    $offset = ($pagina - 1) * $porPagina;

    $total = (int) db()->query('SELECT COUNT(*) FROM productos WHERE activo = 1')->fetchColumn();
    $stmt = db()->prepare("SELECT id, nombre, precio, stock FROM productos WHERE activo = 1
                           ORDER BY id DESC LIMIT $porPagina OFFSET $offset");
    $stmt->execute();

    responder([
        'data' => array_map('formatear_producto', $stmt->fetchAll()),
        'meta' => ['pagina' => $pagina, 'limite' => $porPagina, 'total' => $total,
                   'paginas' => (int) ceil($total / $porPagina)],
    ]);
}

function productos_ver(string $id): void
{
    $stmt = db()->prepare('SELECT id, nombre, descripcion, precio, stock FROM productos WHERE id = ?');
    $stmt->execute([(int) $id]);
    $p = $stmt->fetch();
    $p ? responder(['data' => formatear_producto($p)]) : responder(['error' => 'Producto no encontrado'], 404);
}

function productos_crear(): void
{
    $d = cuerpo_json();
    $errores = validar_producto($d);
    if ($errores) responder(['error' => 'Datos no válidos', 'campos' => $errores], 422);

    db()->prepare('INSERT INTO productos (nombre, descripcion, precio, stock) VALUES (?, ?, ?, ?)')
        ->execute([trim($d['nombre']), $d['descripcion'] ?? null, $d['precio'], $d['stock'] ?? 0]);

    $id = (int) db()->lastInsertId();
    header("Location: /api/productos/$id");
    productos_ver_creado($id);
}

function productos_ver_creado(int $id): void
{
    $stmt = db()->prepare('SELECT id, nombre, descripcion, precio, stock FROM productos WHERE id = ?');
    $stmt->execute([$id]);
    responder(['data' => formatear_producto($stmt->fetch())], 201);
}

function productos_actualizar(string $id): void
{
    $d = cuerpo_json();
    $errores = validar_producto($d);
    if ($errores) responder(['error' => 'Datos no válidos', 'campos' => $errores], 422);

    $stmt = db()->prepare('UPDATE productos SET nombre = ?, descripcion = ?, precio = ?, stock = ? WHERE id = ?');
    $stmt->execute([trim($d['nombre']), $d['descripcion'] ?? null, $d['precio'], $d['stock'] ?? 0, (int) $id]);
    productos_ver($id);
}

function productos_eliminar(string $id): void
{
    $stmt = db()->prepare('DELETE FROM productos WHERE id = ?');
    $stmt->execute([(int) $id]);
    $stmt->rowCount() ? responder(null, 204) : responder(['error' => 'Producto no encontrado'], 404);
}

function validar_producto(array $d): array
{
    $e = [];
    if (empty(trim($d['nombre'] ?? '')))                       $e['nombre'] = 'Obligatorio';
    if (!isset($d['precio']) || !is_numeric($d['precio']) || $d['precio'] < 0) $e['precio'] = 'Debe ser un número positivo';
    if (isset($d['stock']) && !is_int($d['stock']))            $e['stock'] = 'Debe ser entero';
    return $e;
}

function formatear_producto(array $p): array
{
    // Tipos correctos en el JSON (PDO devuelve DECIMAL como texto)
    return [
        'id'          => (int) $p['id'],
        'nombre'      => $p['nombre'],
        'descripcion' => $p['descripcion'] ?? null,
        'precio'      => (float) $p['precio'],
        'stock'       => (int) $p['stock'],
    ];
}

.htaccess ​

apache
# api/.htaccess
RewriteEngine On
# Pasa la cabecera Authorization a PHP (algunos hostings la eliminan)
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

Probar la API ​

bash
# Listar
curl http://localhost/api/productos?pagina=1&limite=5

# Crear (con token)
curl -X POST http://localhost/api/productos \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer mi-token-secreto" \
  -d '{"nombre":"Taza","precio":129.5,"stock":20}'

# Eliminar
curl -X DELETE http://localhost/api/productos/3 -H "Authorization: Bearer mi-token-secreto"

Desde JavaScript:

js
const res = await fetch('/api/productos?pagina=1');
const { data, meta } = await res.json();
console.log(data, meta.total);

Códigos HTTP que debes usar ​

CódigoCuándo
200 OKLectura o actualización correcta
201 CreatedRecurso creado (con cabecera Location)
204 No ContentEliminado, sin cuerpo
400 Bad RequestJSON mal formado
401 UnauthorizedFalta token o es inválido
403 ForbiddenToken válido pero sin permiso
404 Not FoundNo existe la ruta o el recurso
422 UnprocessableFalló la validación
500 Internal ErrorError del servidor

Buena práctica

Agrega API_TOKEN=un-valor-largo-y-aleatorio al .env (genera uno con php -r "echo bin2hex(random_bytes(32));"). Para APIs con usuarios reales usa tokens por usuario guardados en la base de datos o JWT.

Error común

Imprimir un echo o un var_dump de depuración antes del JSON. El cliente recibirá JSON inválido. Usa error_log() para depurar.

Academia Web — aprende, copia y construye.