JSON y APIs en PHP
En esta lección
Hoy casi todas las aplicaciones se comunican entre sí con JSON: la web del tiempo que consulta una previsión, el móvil que pide tus reservas al servidor, la página que se actualiza con fetch sin recargar. En esta lección aprenderás a convertir datos de PHP a JSON y al revés sin sorpresas, a devolver JSON desde tus scripts, a consumir APIs de otros con file_get_contents y cURL, y a construir una pequeña API REST completa con PHP puro.
De PHP a JSON con json_encode
Ya conoces el formato JSON de JSON en JavaScript y lo usaste para guardar datos en Ficheros y subidas. La función json_encode() convierte un valor de PHP en una cadena JSON:
<?php
$ruta = [
'nombre' => 'Ruta del Cares',
'km' => 12,
'circular' => false,
'pueblos' => ['Poncebos', 'Caín'],
'desnivel' => null,
];
echo json_encode($ruta), "\n";
// {"nombre":"Ruta del Cares","km":12,"circular":false,"pueblos":["Poncebos","Caín"],"desnivel":null}
Así se traducen los tipos:
| PHP | JSON |
|---|---|
int, float | número |
string | cadena |
true / false | true / false |
null | null |
| Array con índices 0, 1, 2… seguidos (una lista) | array [...] |
| Array con claves de texto o índices salteados | objeto {...} |
| Objeto | objeto con sus propiedades públicas |
La penúltima fila da sorpresas. PHP decide entre array y objeto mirando las claves:
<?php
echo json_encode(['Poncebos', 'Caín', 'Bulnes'], JSON_UNESCAPED_UNICODE), "\n";
$filtrada = array_filter([5, 12, 8, 21], fn($km) => $km > 10);
echo json_encode($filtrada), "\n";
echo json_encode(array_values($filtrada)), "\n";
echo json_encode([]), ' ', json_encode((object) []), "\n";
// ["Poncebos","Caín","Bulnes"]
// {"1":12,"3":21}
// [12,21]
// [] {}
array_filter() conserva las claves originales (1 y 3), así que el resultado ya no es una lista y se convierte en un objeto. Quien reciba ese JSON en JavaScript esperará un array y fallará al hacer .map(). La solución es pasar el array por array_values() antes de convertirlo. Y si necesitas un objeto vacío {} en lugar de [], usa (object) [].
Opciones de json_encode
El segundo parámetro acepta opciones que se combinan con |:
| Opción | Efecto |
|---|---|
JSON_UNESCAPED_UNICODE | Escribe Caín en vez de Caín |
JSON_UNESCAPED_SLASHES | Escribe https://... en vez de https:\/\/... |
JSON_PRETTY_PRINT | Sangría y saltos de línea, para leerlo una persona |
JSON_THROW_ON_ERROR | Lanza JsonException si algo no se puede convertir |
JSON_PRESERVE_ZERO_FRACTION | Escribe 12.0 en vez de 12 para los float |
<?php
$refugio = ['nombre' => 'Refugio de Áliva', 'web' => 'https://ejemplo.es/aliva', 'plazas' => 40];
echo json_encode($refugio, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), "\n";
// {
// "nombre": "Refugio de Áliva",
// "web": "https://ejemplo.es/aliva",
// "plazas": 40
// }
Para respuestas de una API no hace falta JSON_PRETTY_PRINT (ocupa más). Para archivos que vaya a leer una persona, sí.
De JSON a PHP con json_decode
json_decode() hace lo contrario. Por defecto convierte los objetos JSON en objetos de la clase genérica stdClass; con true como segundo argumento, en arrays asociativos, que es lo más cómodo en PHP:
<?php
$json = '{"socio": "Irene", "bonos": [10, 5], "activo": true}';
$obj = json_decode($json);
echo $obj->socio, "\n";
$arr = json_decode($json, true);
echo $arr['socio'], ' tiene ', array_sum($arr['bonos']), " entradas\n";
// Irene
// Irene tiene 15 entradas
Cuando el JSON está mal
Si el texto no es JSON válido, json_decode() devuelve null sin avisar. El problema es que null también es un JSON válido ('null'), así que no puedes distinguir un caso de otro. La solución moderna es la opción JSON_THROW_ON_ERROR, que lanza una JsonException (lo viste en Excepciones):
<?php
$recibido = '{"socio": "Irene",}'; // coma sobrante
var_dump(json_decode($recibido, true));
try {
$datos = json_decode($recibido, true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo 'JSON no válido: ', $e->getMessage(), "\n";
}
var_dump(json_validate('{"ok": true}'));
// NULL
// JSON no válido: Syntax error
// bool(true)
json_validate() (PHP 8.3) solo comprueba si una cadena es JSON válido, sin convertirla. En código antiguo verás json_last_error() y json_last_error_msg() después de cada llamada; hacen lo mismo de forma más incómoda.
Objetos a JSON: JsonSerializable
Al convertir un objeto, json_encode() solo incluye sus propiedades públicas. Si quieres decidir tú qué sale (ocultar datos internos, añadir campos calculados, formatear fechas), implementa la interfaz JsonSerializable, que viste en Herencia e interfaces:
<?php
class Socio implements JsonSerializable
{
public function __construct(
private int $id,
private string $nombre,
private string $hashClave,
private DateTimeImmutable $alta,
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'nombre' => $this->nombre,
'alta' => $this->alta->format('Y-m-d'),
];
}
}
$socio = new Socio(7, 'Hugo', '$2y$12$...', new DateTimeImmutable('2026-09-15'));
echo json_encode($socio), "\n";
// {"id":7,"nombre":"Hugo","alta":"2026-09-15"}
El hash de la contraseña nunca sale del servidor, aunque alguien convierta el objeto a JSON sin pensar.
Devolver JSON desde PHP
Un script PHP no tiene por qué generar HTML. Si en lugar de una página devuelve JSON, cualquier programa podrá usar esos datos: tu JavaScript con fetch, una app móvil u otro servidor. Para hacerlo bien hacen falta tres cosas:
- La cabecera
Content-Type: application/json, para que quien lo reciba sepa qué es. - El código de estado HTTP correcto: 200 si todo va bien, 404 si no existe, etc.
- Que no salga nada más: ni HTML, ni espacios, ni avisos de PHP. Un solo carácter extra rompe el JSON.
Lo práctico es una función que haga las tres cosas y termine el script:
<?php
function responderJson(mixed $datos, int $estado = 200): never
{
http_response_code($estado);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
exit;
}
// horario.php
$horario = [
['dia' => 'lunes', 'actividad' => 'Yoga', 'hora' => '19:00'],
['dia' => 'miércoles', 'actividad' => 'Escalada infantil', 'hora' => '17:30'],
];
responderJson($horario);
// [{"dia":"lunes","actividad":"Yoga","hora":"19:00"},{"dia":"miércoles","actividad":"Escalada infantil","hora":"17:30"}]
El tipo de retorno never (PHP 8.1) indica que la función nunca vuelve: siempre termina con exit o con una excepción.
Cuidado: igual que con
header()en otras lecciones, la cabecera debe enviarse antes de cualquier salida. Y en una API, pondisplay_errors = Off: un Warning impreso en mitad de la respuesta la convierte en JSON no válido.
Consumir una API
Ahora al revés: tu PHP pide datos a otro servidor. Muchos servicios públicos ofrecen APIs JSON (tiempo, mapas, transporte). Hay dos formas de hacerlo.
Con file_get_contents
file_get_contents() también sabe leer URL (si la opción allow_url_fopen del php.ini está activada, que es lo habitual). Con stream_context_create() puedes poner un tiempo máximo, cabeceras o el método:
<?php
$contexto = stream_context_create([
'http' => [
'timeout' => 5, // segundos como máximo
'header' => "Accept: application/json\r\n",
'ignore_errors' => true, // leer el cuerpo aunque sea un 404
],
]);
$respuesta = file_get_contents('http://localhost:8000/api/rutas', false, $contexto);
if ($respuesta === false) {
exit("No se pudo conectar con la API\n");
}
$estado = http_get_last_response_headers()[0]; // "HTTP/1.1 200 OK"
$rutas = json_decode($respuesta, true, flags: JSON_THROW_ON_ERROR);
echo $estado, ' - ', count($rutas), " rutas\n";
// HTTP/1.1 200 OK - 2 rutas
(La URL es la de la mini API que construirás más abajo.) http_get_last_response_headers() es de PHP 8.4; en código anterior verás la variable mágica $http_response_header. Es una opción sencilla para peticiones GET, pero se queda corta para todo lo demás.
Con cURL
cURL es la extensión estándar para peticiones HTTP serias: todos los métodos, cabeceras, autenticación, control fino de errores. Se usa en cuatro pasos: crear la petición, configurarla, ejecutarla y revisar el resultado. Este ejemplo envía una ruta nueva por POST con un cuerpo JSON:
<?php
$nueva = ['nombre' => 'Senda del Pas', 'km' => 9.5];
$ch = curl_init('http://localhost:8000/api/rutas');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true, // devolver la respuesta en vez de imprimirla
CURLOPT_TIMEOUT => 5,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($nueva),
CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Accept: application/json'],
]);
$cuerpo = curl_exec($ch);
if ($cuerpo === false) {
exit('Error de conexión: ' . curl_error($ch) . "\n");
}
$estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$datos = json_decode($cuerpo, true, flags: JSON_THROW_ON_ERROR);
if ($estado === 201) {
echo "Creada con id {$datos['id']}\n";
} else {
echo "Error $estado: {$datos['error']}\n";
}
// Creada con id 3
Fíjate en que hay dos tipos de fallo: que no se pueda conectar (curl_exec() devuelve false) y que el servidor responda con un error (código 4xx o 5xx). Comprueba siempre los dos.
Nota: en código anterior a PHP 8 verás
curl_close($ch)al final. Ya no hace falta: la conexión se libera sola, y en PHP 8.5 esa función está obsoleta.
Consejo: muchas APIs públicas piden una clave (API key). Nunca la escribas en el código que subes a GitHub: guárdala en un archivo de configuración fuera del repositorio o en una variable de entorno (
getenv('CLAVE_TIEMPO')). Si trabajas mucho con APIs, la librería Guzzle, que instalarás con Composer, simplifica cURL.
Una mini API REST
Vamos a juntarlo todo en una API para gestionar rutas de senderismo. Recuerda de API REST la idea: cada recurso tiene una URL y el método HTTP indica la acción.
| Método y URL | Acción | Respuesta correcta |
|---|---|---|
GET /api/rutas | Listar todas | 200 y la lista |
GET /api/rutas/2 | Ver una | 200 y la ruta, o 404 |
POST /api/rutas | Crear (JSON en el cuerpo) | 201 y la ruta creada, o 422 si los datos no son válidos |
DELETE /api/rutas/2 | Borrar | 204 sin cuerpo, o 404 |
Para no depender aún de la base de datos, guardamos las rutas en un archivo JSON. En CRUD con PDO tienes todo lo necesario para cambiar estas dos funciones por consultas a MySQL.
<?php
// api.php
declare(strict_types=1);
const ARCHIVO = __DIR__ . '/rutas.json';
function responderJson(mixed $datos, int $estado = 200): never
{
http_response_code($estado);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
exit;
}
function leerRutas(): array
{
return is_file(ARCHIVO)
? json_decode(file_get_contents(ARCHIVO), true, flags: JSON_THROW_ON_ERROR)
: [];
}
function guardarRutas(array $rutas): void
{
$json = json_encode(array_values($rutas), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
file_put_contents(ARCHIVO, $json, LOCK_EX);
}
// 1. Analizar la petición: método y URL
$metodo = $_SERVER['REQUEST_METHOD'];
$camino = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
if (!preg_match('#^/api/rutas(?:/(\d+))?$#', $camino, $partes)) {
responderJson(['error' => 'Recurso no encontrado'], 404);
}
$id = isset($partes[1]) ? (int) $partes[1] : null;
$rutas = leerRutas();
// 2. Colección: /api/rutas
if ($id === null) {
if ($metodo === 'GET') {
responderJson($rutas);
}
if ($metodo === 'POST') {
try {
$datos = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException) {
responderJson(['error' => 'El cuerpo debe ser JSON válido'], 400);
}
$nombre = trim((string) ($datos['nombre'] ?? ''));
$km = filter_var($datos['km'] ?? null, FILTER_VALIDATE_FLOAT);
$errores = [];
if ($nombre === '') {
$errores['nombre'] = 'Obligatorio';
}
if ($km === false || $km <= 0) {
$errores['km'] = 'Debe ser un número mayor que 0';
}
if ($errores) {
responderJson(['error' => 'Datos no válidos', 'campos' => $errores], 422);
}
$nueva = [
'id' => max([0, ...array_column($rutas, 'id')]) + 1,
'nombre' => $nombre,
'km' => $km,
];
$rutas[] = $nueva;
guardarRutas($rutas);
responderJson($nueva, 201);
}
responderJson(['error' => 'Método no permitido'], 405);
}
// 3. Elemento: /api/rutas/{id}
$indice = array_search($id, array_column($rutas, 'id'), true);
if ($indice === false) {
responderJson(['error' => "No existe la ruta $id"], 404);
}
if ($metodo === 'GET') {
responderJson($rutas[$indice]);
}
if ($metodo === 'DELETE') {
array_splice($rutas, $indice, 1);
guardarRutas($rutas);
http_response_code(204);
exit;
}
responderJson(['error' => 'Método no permitido'], 405);
Las piezas nuevas:
php://inputcontiene el cuerpo crudo de la petición.$_POSTsolo se rellena con formularios clásicos; cuando el cliente envía JSON, hay que leerlo de aquí y decodificarlo.- La expresión regular
#^/api/rutas(?:/(\d+))?$#acepta/api/rutasy/api/rutas/seguido de un número, que queda en$partes[1]. - Los códigos de estado dicen qué ha pasado sin que el cliente tenga que leer el mensaje: 400 si el JSON está mal escrito, 422 si es JSON pero los datos no valen, 404 si no existe, 405 si el método no se admite en esa URL.
- La validación es la misma que aprendiste en Validar y sanear: nunca te fíes de los datos que llegan, vengan de un formulario o de una API.
Probarla
El servidor integrado de PHP acepta un script enrutador: si lo pasas como último argumento, todas las peticiones van a ese archivo, sea cual sea la URL.
php -S localhost:8000 api.php
Desde otra terminal, prueba cada operación con el comando curl (-i muestra el código de estado y las cabeceras; aquí solo copiamos la primera línea y el cuerpo):
curl -i -X POST localhost:8000/api/rutas -H 'Content-Type: application/json' -d '{"nombre": "Pico Dobra", "km": 11.5}'
# HTTP/1.1 201 Created
# {"id":1,"nombre":"Pico Dobra","km":11.5}
curl -i -X POST localhost:8000/api/rutas -H 'Content-Type: application/json' -d '{"nombre": "", "km": "mucho"}'
# HTTP/1.1 422 Unprocessable Entity
# {"error":"Datos no válidos","campos":{"nombre":"Obligatorio","km":"Debe ser un número mayor que 0"}}
curl localhost:8000/api/rutas
# [{"id":1,"nombre":"Pico Dobra","km":11.5}]
curl -i -X DELETE localhost:8000/api/rutas/1
# HTTP/1.1 204 No Content
curl -i localhost:8000/api/rutas/1
# HTTP/1.1 404 Not Found
# {"error":"No existe la ruta 1"}
También puedes usar herramientas gráficas como Postman, Bruno o la extensión Thunder Client de VS Code. Y desde JavaScript, con fetch (lo viste en fetch y async/await):
const respuesta = await fetch('/api/rutas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nombre: 'Ruta del Cares', km: 12 }),
});
const ruta = await respuesta.json();
console.log(respuesta.status, ruta.id);
Nota: en Apache no se usa el servidor integrado; para que todas las URL
/api/...lleguen aapi.phpse añade una regla de reescritura en un archivo.htaccess. Y si tu JavaScript está en otro dominio que la API, el navegador bloqueará la respuesta salvo que la API envíe la cabeceraAccess-Control-Allow-Origin(es lo que se llama CORS).
Para ampliarla, añade PUT /api/rutas/{id} para modificar una ruta (lee php://input igual que en el POST) y protege las operaciones que cambian datos con una clave o un login.
Errores frecuentes
- Olvidar
trueenjson_decode()y luego usar$datos['nombre']: es un objetostdClass, no un array. Error: Cannot use object of type stdClass as array. - No comprobar si el JSON es válido: sin
JSON_THROW_ON_ERROR, un error se convierte en unnullsilencioso. - Listas que se convierten en objetos: tras
array_filter()ounset(), usaarray_values()antes dejson_encode(). - Salida extra en una API: un
echode depuración, un aviso de PHP o un espacio antes de<?phprompen el JSON. Desactivadisplay_errorsy revisa concurl -i. - Devolver siempre 200, incluso con errores: el cliente no se entera de que algo ha fallado. Usa el código adecuado.
- Buscar en
$_POSTlos datos JSON: llegan porphp://input. - Peticiones sin tiempo máximo: si la API externa no responde, tu página se queda colgada. Pon
timeoutoCURLOPT_TIMEOUT. - Claves de API en el código: muévelas a la configuración o a variables de entorno.
Resumen
| Tarea | Código |
|---|---|
| PHP a JSON | json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) |
| JSON legible | JSON_PRETTY_PRINT |
| JSON a array | json_decode($json, true, flags: JSON_THROW_ON_ERROR) |
| Comprobar si es JSON | json_validate($json) |
| Reindexar una lista | array_values($array) |
| Objeto a medida | implements JsonSerializable y jsonSerialize(): array |
| Responder JSON | http_response_code(201); header('Content-Type: application/json; charset=utf-8'); echo json_encode(...) |
| Leer el cuerpo JSON | file_get_contents('php://input') |
| GET sencillo | file_get_contents($url, false, stream_context_create([...])) |
| Petición completa | curl_init, curl_setopt_array, curl_exec, curl_getinfo($ch, CURLINFO_RESPONSE_CODE) |
| Probar una API | php -S localhost:8000 api.php y curl -i |
En la siguiente lección aprenderás a instalar librerías y a cargar tus clases automáticamente con el gestor de dependencias de PHP: Composer.
Pon a prueba lo que has aprendido
¿Te ha quedado claro? Márcala y verás tu progreso en el explorador.