1. Páginas que se actualizan sin recargarse
En Desarrollo Web en Entorno Cliente aprendiste a pedir datos con fetch sin recargar la página. Este tema es la otra mitad de esa conversación: qué debe hacer el servidor para que esas peticiones funcionen bien, y cómo combinar tu aplicación con servicios de otros (mapas, pagos, tiempo, inicio de sesión con Google) para construir aplicaciones híbridas.
Para el servidor, una petición AJAX es una petición HTTP como cualquier otra. La diferencia está en lo que responde: en lugar de una página completa, devuelve solo lo necesario, ya sea un JSON con datos que el JavaScript del navegador convertirá en HTML, o un fragmento de HTML ya hecho que se inserta en la página tal cual.
| Estrategia | El servidor devuelve | Ventajas | Inconvenientes |
|---|---|---|---|
| JSON + JavaScript | Datos | El cliente tiene el control total; la misma API sirve a la app móvil | La plantilla se duplica en JavaScript |
| Fragmentos de HTML (HTMX, Turbo, Livewire) | Trozos de HTML renderizados con tus plantillas | Casi sin JavaScript; una sola plantilla | Menos adecuado para interfaces muy interactivas |
| Página completa | Todo el documento | Lo más simple | Recarga todo; experiencia más lenta |
Una misma ruta, varios formatos
El navegador indica qué prefiere con la cabecera Accept. Así, una ruta puede devolver la página completa cuando se visita normalmente y JSON o un fragmento cuando la pide el JavaScript. Es la negociación de contenido, y permite que la página funcione también sin JavaScript (mejora progresiva).
1$productos = $repo->buscar($_GET['q'] ?? '');
2
3$accept = $_SERVER['HTTP_ACCEPT'] ?? '';
4if (str_contains($accept, 'application/json')) {
5 header('Content-Type: application/json; charset=utf-8');
6 echo json_encode($productos, JSON_UNESCAPED_UNICODE);
7} elseif (isset($_SERVER['HTTP_HX_REQUEST'])) { // la petición la hace HTMX
8 echo $vista->render('productos/_lista', ['productos' => $productos], layout: null);
9} else {
10 echo $vista->render('productos/index', ['productos' => $productos]);
11}2. Endpoints para interfaces interactivas
Estas son las piezas que más a menudo te pedirán en una aplicación real. Todas siguen el mismo patrón: el navegador envía una petición pequeña, el servidor valida, consulta y responde rápido.
| Interacción | Petición | Respuesta del servidor |
|---|---|---|
| Validación en vivo (¿email libre?) | GET /api/usuarios/disponible?email=… | { disponible: true|false } |
| Autocompletado | GET /api/municipios?q=log | Array de sugerencias (máx. 5-10) |
| Scroll infinito o «Ver más» | GET /api/articulos?despues=128 | Siguientes elementos y si hay más |
| Guardado automático | PATCH /api/borradores/7 cada pocos segundos | 204 o la versión guardada |
| «Me gusta», añadir al carrito | POST /api/articulos/7/me-gusta | El nuevo total (para la actualización optimista) |
| Subida de ficheros con progreso | POST multipart con XMLHttpRequest | La URL del fichero guardado |
1<?php
2header('Content-Type: application/json; charset=utf-8');
3header('Cache-Control: no-store');
4
5$email = trim($_GET['email'] ?? '');
6if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
7 http_response_code(422);
8 echo json_encode(['disponible' => false, 'motivo' => 'formato']);
9 exit;
10}
11$s = conectar()->prepare('SELECT 1 FROM usuarios WHERE email = ?');
12$s->execute([$email]);
13echo json_encode(['disponible' => !$s->fetchColumn()]);1const campo = document.querySelector("#email");
2const aviso = document.querySelector("#aviso-email");
3let espera;
4
5campo.addEventListener("input", () => {
6 clearTimeout(espera);
7 espera = setTimeout(async () => { // espera a que deje de escribir
8 const r = await fetch("/api/disponible.php?email=" + encodeURIComponent(campo.value), {
9 headers: { Accept: "application/json" },
10 });
11 const { disponible } = await r.json();
12 aviso.textContent = disponible ? "" : "Ese email ya está registrado";
13 }, 400);
14});La validación en vivo no sustituye a la del formulario
3. Modificar la estructura de la página con fragmentos: HTMX
HTMX es una pequeña librería que devuelve el protagonismo al servidor: añades atributos al HTML que dicen qué petición hacer, cuándo y dónde colocar la respuesta, y el servidor devuelve fragmentos de HTML generados con tus plantillas de siempre. Es una forma muy productiva de hacer interfaces dinámicas con PHP, y la misma idea está detrás de Laravel Livewire y de Hotwire (Ruby on Rails).
1<script src="https://unpkg.com/htmx.org@2"></script>
2
3<input type="search" name="q" placeholder="Buscar productos…"
4 hx-get="/productos" hx-trigger="input changed delay:300ms" hx-target="#resultados">
5<div id="resultados"><!-- aquí el servidor coloca el fragmento --></div>
6
7<button hx-post="/carrito/7" hx-target="#contador-carrito" hx-swap="outerHTML">
8 Añadir al carrito
9</button>1<?php if (!$productos): ?>
2 <p>No hay resultados para «<?= e($q) ?>».</p>
3<?php endif ?>
4<?php foreach ($productos as $p): ?>
5 <article class="producto">
6 <h3><?= e($p['nombre']) ?></h3>
7 <p><?= number_format($p['precio'], 2, ',', '.') ?> €</p>
8 </article>
9<?php endforeach ?>4. Información en tiempo real desde PHP
El modelo de PHP (un proceso por petición que termina enseguida) no está pensado para conexiones que duran horas, pero hay opciones para cada necesidad:
| Técnica | En PHP | Úsala para |
|---|---|---|
| Sondeo (polling) | Un endpoint normal que el cliente llama cada N segundos | Datos que cambian poco; lo más sencillo |
| Server-Sent Events | Un script que responde text/event-stream y va enviando eventos | Notificaciones, progreso de una tarea, marcadores |
| WebSockets | Un servidor aparte en bucle de eventos: Ratchet, Laravel Reverb, Soketi, o un servicio (Pusher, Ably) | Chats, colaboración, juegos |
1<?php
2header('Content-Type: text/event-stream');
3header('Cache-Control: no-store');
4header('X-Accel-Buffering: no'); // que Nginx no acumule la salida
5session_write_close(); // libera la sesión: si no, bloquea las demás peticiones
6
7for ($i = 1; $i <= 10 && !connection_aborted(); $i++) {
8 $estado = consultarProgresoExportacion($_GET['tarea']);
9 echo "event: progreso\n";
10 echo 'data: ' . json_encode(['porcentaje' => $estado['porcentaje']]) . "\n\n";
11 @ob_flush();
12 flush(); // envía ya, sin esperar al final del script
13 if ($estado['porcentaje'] >= 100) break;
14 sleep(1);
15}
16
17// En el cliente: new EventSource('/progreso.php?tarea=42').addEventListener('progreso', …)Cada conexión abierta ocupa un proceso
5. Aplicaciones híbridas: reutilizar código e información
Ninguna aplicación moderna se construye desde cero. Se reutiliza código (paquetes de Composer y npm) y se reutiliza información y funciones de otros servicios a través de sus APIs. Combinar datos propios con los de servicios externos da lugar a las aplicaciones híbridas o mashups: la web de un hotel con el mapa de OpenStreetMap, el tiempo de la zona, las reseñas de Google y el pago con Stripe.
Una aplicación híbrida es una cocina con proveedores
| Necesidad | Servicios habituales | Cómo se integra |
|---|---|---|
| Mapas | OpenStreetMap + Leaflet, Google Maps, Mapbox | Librería JavaScript en el cliente; geocodificación desde el servidor |
| Pagos | Stripe, Redsys, PayPal, Bizum (vía pasarela) | Página de pago del proveedor + webhook firmado |
| Correo | SMTP, Brevo, Mailgun, Amazon SES | Librería (Symfony Mailer, PHPMailer) con cola de envío |
| Inicio de sesión | Google, GitHub, Microsoft (OAuth 2.0 / OpenID Connect) | Redirección, código de autorización y token |
| Datos abiertos | datos.gob.es, AEMET OpenData, INE, Open-Meteo | API REST con caché en tu servidor |
| Contenido embebido | YouTube, Vimeo, redes sociales | Iframes o widgets (ojo con las cookies de terceros) |
| Ficheros | Amazon S3, Cloudflare R2 | SDK o Flysystem; URLs firmadas temporales |
Consumir una API desde el servidor
1// Con funciones nativas
2$contexto = stream_context_create(['http' => ['timeout' => 5, 'header' => "Accept: application/json\r\n"]]);
3$respuesta = @file_get_contents('https://api.open-meteo.com/v1/forecast?latitude=42.23&longitude=-2.10¤t=temperature_2m', false, $contexto);
4if ($respuesta === false) {
5 // plan B: mostrar la página sin el tiempo, nunca un error en blanco
6}
7$tiempo = json_decode($respuesta, true);
8
9// Con Guzzle (composer require guzzlehttp/guzzle), más cómodo para APIs complejas
10$cliente = new GuzzleHttp\Client(['base_uri' => 'https://api.ejemplo.com/', 'timeout' => 5]);
11$datos = json_decode($cliente->get('pedidos', ['query' => ['estado' => 'pendiente']])->getBody(), true);6. Incorporar funcionalidades específicas: pagos, webhooks e inicio de sesión
Un pago con pasarela
Nunca proceses tú los datos de la tarjeta: la pasarela te ofrece una página o un formulario alojado por ella, y así tu servidor no toca datos de pago (y no tienes que cumplir la exigente normativa PCI DSS). El flujo típico combina una redirección con un aviso de servidor a servidor:
- 01
Crear el pago
Tu servidor crea una sesión de pago en la pasarela (importe, pedido, URL de vuelta) con su clave secreta.
- 02
Página de la pasarela
El usuario paga en la página del proveedor, que gestiona la tarjeta, Bizum o la autenticación del banco.
- 03
Vuelta a tu web
El navegador vuelve a tu URL de éxito. ¡No te fíes de ella para dar el pedido por pagado!
- 04
Webhook
La pasarela avisa a tu servidor con una petición firmada: esa es la confirmación real del pago.
- 05
Marcar pagado
Verificas la firma, compruebas que no lo procesaste ya (idempotencia) y actualizas el pedido.
1<?php
2$cuerpo = file_get_contents('php://input'); // el cuerpo crudo: la firma se calcula sobre él
3$firma = $_SERVER['HTTP_FIRMA'] ?? '';
4
5if (!firmaValida($firma, $cuerpo, getenv('PAGOS_WEBHOOK_SECRETO'))) { // el reto del tema
6 http_response_code(400);
7 exit;
8}
9$evento = json_decode($cuerpo, true);
10
11if ($evento['tipo'] === 'pago.completado') {
12 $s = conectar()->prepare("UPDATE pedidos SET estado = 'pagado' WHERE id = ? AND estado = 'pendiente'");
13 $s->execute([$evento['pedido']]); // si ya estaba pagado, no hace nada: idempotente
14}
15http_response_code(200); // responder rápido: si no, la pasarela reintentaIniciar sesión con Google (OAuth 2.0 y OpenID Connect)
- Tu web redirige al usuario a Google con tu identificador de cliente, los permisos pedidos (
openid email profile), la URL de vuelta y unstatealeatorio guardado en la sesión (protege de CSRF). - El usuario inicia sesión en Google (tú nunca ves su contraseña) y acepta.
- Google redirige a tu URL de vuelta con un
codede un solo uso y el mismostate, que compruebas. - Tu servidor canjea el code (con tu secreto de cliente, de servidor a servidor) por un
id_token: un JWT firmado por Google con el email y el identificador del usuario. - Validas el token (firma con las claves públicas de Google, emisor, audiencia y caducidad), buscas o creas el usuario y abres tu sesión normal.
En la práctica se usa una librería probada (league/oauth2-client, Laravel Socialite) en lugar de implementarlo a mano: los detalles de seguridad son muchos y delicados.
7. Depender de otros sin sufrir: robustez, legalidad y pruebas
Cada servicio externo es una pieza que puede fallar, ir lenta, cambiar su API o subir el precio. Estas prácticas convierten esos fallos en molestias menores:
- Tiempo máximo en todas las llamadas: si el otro servicio se cuelga, tu página no debe colgarse con él.
- Caché de las respuestas que cambian poco (el tiempo, un tipo de cambio, la geolocalización de una dirección).
- Reintentos con espera creciente solo para errores pasajeros (timeouts, 502, 503, 429) y solo en operaciones idempotentes.
- Cortacircuitos: si el servicio falla repetidamente, deja de llamarlo un tiempo (el reto más difícil del tema).
- Trabajo en segundo plano: enviar correos o llamar a APIs lentas desde una cola, no dentro de la petición del usuario.
- Degradación elegante: sin mapa o sin tiempo, la página debe seguir funcionando.
- Datos externos como no confiables: valida lo que recibes y escápalo al mostrarlo.
- Claves en variables de entorno, nunca en el código ni en el JavaScript del cliente.
Protección de datos
Probar sin depender de la red
1interface ServicioTiempo
2{
3 /** @return array{temperatura: float, cielo: string}|null */
4 public function actual(float $lat, float $lon): ?array;
5}
6
7final class OpenMeteo implements ServicioTiempo { /* llamada HTTP real con timeout y caché */ }
8
9final class TiempoFalso implements ServicioTiempo // para los tests
10{
11 public function __construct(private ?array $respuesta) {}
12 public function actual(float $lat, float $lon): ?array { return $this->respuesta; }
13}
14
15// El test comprueba que la ficha de la tienda se muestra aunque el servicio falle
16$html = (new FichaTiendaControlador(new TiempoFalso(null)))->mostrar(1);
17$this->assertStringContainsString('Calle Mayor', $html);
18$this->assertStringNotContainsString('°C', $html);Para probar a mano, los proveedores ofrecen entornos de pruebas (sandbox) con claves de test y tarjetas ficticias, y herramientas para enviar webhooks de prueba a tu máquina (Stripe CLI, o un túnel como ngrok).
8. Ejemplo resuelto: la ficha de una tienda física
Una cadena de tiendas quiere, para cada local, una página con su dirección en un mapa, el tiempo que hace ahora en esa ciudad y un formulario de contacto que valide el email mientras se escribe. Combinamos datos propios (las tiendas), dos servicios externos gratuitos y un endpoint AJAX.
- Datos propios: la tienda (nombre, dirección, latitud, longitud) sale de nuestra base de datos.
- El tiempo se pide a Open-Meteo desde el servidor, con timeout de 3 s y caché de 10 minutos por tienda. Si falla, la página se muestra sin ese bloque.
- El mapa lo dibuja Leaflet en el navegador con los mosaicos de OpenStreetMap (con su atribución obligatoria); el servidor solo pasa las coordenadas.
- El formulario usa el endpoint de validación en vivo y, al enviarse, se valida de nuevo en el servidor.
1final class OpenMeteo implements ServicioTiempo
2{
3 public function __construct(private Cache $cache) {}
4
5 public function actual(float $lat, float $lon): ?array
6 {
7 return $this->cache->recordar("tiempo:$lat:$lon", 600, function () use ($lat, $lon) {
8 $url = 'https://api.open-meteo.com/v1/forecast?' . http_build_query([
9 'latitude' => $lat, 'longitude' => $lon, 'current' => 'temperature_2m,weather_code',
10 ]);
11 $ctx = stream_context_create(['http' => ['timeout' => 3, 'ignore_errors' => true]]);
12 $json = @file_get_contents($url, false, $ctx);
13 $datos = $json ? json_decode($json, true) : null;
14 if (!isset($datos['current']['temperature_2m'])) {
15 return null; // se guarda en caché también el fallo, para no insistir
16 }
17 return [
18 'temperatura' => (float) $datos['current']['temperature_2m'],
19 'cielo' => ($datos['current']['weather_code'] ?? 0) < 3 ? 'despejado' : 'nublado',
20 ];
21 });
22 }
23}1<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
2<h1><?= e($tienda['nombre']) ?></h1>
3<p><?= e($tienda['direccion']) ?></p>
4
5<?php if ($tiempo): ?>
6 <p class="tiempo">Ahora: <?= round($tiempo['temperatura']) ?> °C, <?= $tiempo['cielo'] ?></p>
7<?php endif ?>
8
9<div id="mapa" style="height: 320px" data-lat="<?= $tienda['lat'] ?>" data-lon="<?= $tienda['lon'] ?>"></div>
10<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
11<script>
12 const el = document.querySelector("#mapa");
13 const pos = [Number(el.dataset.lat), Number(el.dataset.lon)];
14 const mapa = L.map(el).setView(pos, 16);
15 L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
16 attribution: "© colaboradores de OpenStreetMap",
17 }).addTo(mapa);
18 L.marker(pos).addTo(mapa);
19</script>Fíjate en tres decisiones: el tiempo se pide desde el servidor (así se cachea para todos los visitantes y la clave, si la hubiera, no se expone); las coordenadas llegan al JavaScript en atributos data- (nada de escribir PHP dentro del script); y si Open-Meteo no responde, la ficha se muestra igual, solo que sin el tiempo.
9. Retos
Tres piezas del lado del servidor que encontrarás en casi cualquier aplicación híbrida: el endpoint de un autocompletado, la verificación de los avisos firmados de una pasarela de pagos y un cortacircuitos que protege tu aplicación cuando un servicio externo se cae.
Un campo de búsqueda de municipios pide sugerencias al servidor mientras el usuario escribe. Cada línea de la entrada es la query string que llega (q=…). Responde con un array JSON (una línea por petición, sin escapar las tildes) de como mucho 5 objetos {"nombre", "provincia"}: primero los municipios que empiezan por el texto y después los que lo contienen en otra posición, cada grupo en orden alfabético. La búsqueda ignora mayúsculas, tildes y espacios a los lados. Con menos de 2 caracteres, responde [].
Cuando un pago se completa, la pasarela avisa a tu servidor con una petición (un webhook). Cualquiera podría enviar una petición falsa a esa URL, así que la pasarela la firma: la cabecera Firma es «t=segundos,v1=hmac», donde hmac es hash_hmac('sha256', "$t.$cuerpo", secreto). Puede haber varios v1 (mientras se cambia de secreto) y basta con que coincida uno. Cada línea de la entrada es «ahora|cabecera|cuerpo». Responde «Aceptado: tipo» (el campo tipo del JSON del cuerpo), «Rechazado: cabecera» si falta t o no hay ningún v1, «Rechazado: caducado» si la firma tiene más de 300 segundos (evita que alguien reenvíe un aviso antiguo) o «Rechazado: firma».
Si la API de envíos se cae, cada petición a tu tienda esperaría el tiempo máximo antes de fallar, y la tienda entera se volvería lenta. El patrón cortacircuitos (circuit breaker) lo evita. Empieza cerrado (las llamadas pasan). Tras 3 fallos seguidos se abre: durante 30 segundos las peticiones se rechazan al momento sin llamar. Pasados 30 segundos, la siguiente petición es una prueba: si sale bien se cierra (y se reinicia la cuenta de fallos); si falla, vuelve a abrirse otros 30 segundos. Un éxito con el circuito cerrado también reinicia la cuenta. Cada línea es «segundo resultado», donde resultado es lo que respondería el servicio si se le llamara (ok o fallo). Escribe, según el caso: «t: ok (cerrado)», «t: fallo (cerrado, N fallos)», «t: fallo → abierto», «t: rechazada (abierto)», «t: prueba ok → cerrado» o «t: prueba fallo → abierto». Al final, «Llamadas reales: N».
10. Ejercicios
¿JSON, fragmento o página?
Para cada caso, decide si el servidor debería devolver JSON, un fragmento de HTML o la página completa, y justifícalo: 1. Una app móvil pide la lista de pedidos. 2. Un buscador en vivo hecho con HTMX. 3. Un gráfico de ventas dibujado con Chart.js. 4. Un usuario abre un enlace compartido a un producto. 5. El botón «Ver más comentarios» de un blog hecho con PHP y plantillas.
Scroll infinito con paginación por cursor
Crea /api/articulos que devuelva los 10 artículos más recientes y, si recibe ?antes_de=ID, los 10 siguientes más antiguos que ese id, junto con un campo mas (true/false). Después, en el cliente, carga más artículos cuando el usuario se acerque al final con IntersectionObserver. ¿Por qué el cursor (antes_de) es mejor aquí que OFFSET si mientras tanto se publican artículos nuevos?
Buscador y carrito con HTMX
Sin escribir JavaScript propio, añade a un listado de productos en PHP: un buscador que filtra mientras escribes (300 ms tras la última tecla), un botón «Añadir al carrito» en cada producto que actualiza el contador de la cabecera, y un indicador de carga. Todo debe seguir funcionando con JavaScript desactivado (el formulario y los botones hacen peticiones normales).
Un mashup de datos abiertos
Elige un conjunto de datos abiertos de tu comunidad o ayuntamiento en datos.gob.es (bibliotecas, puntos de recarga, fuentes, aparcamientos…) y construye una página que lo muestre en un mapa de Leaflet con filtros, combinado con al menos otra fuente (el tiempo de Open-Meteo, la dirección obtenida por geocodificación inversa de Nominatim…). Descarga y guarda en caché los datos en tu servidor (no desde el navegador), respeta los límites de uso y la licencia de cada fuente y muéstralas en la página.
Correos en una cola
Al registrarse, un usuario debe recibir un correo de bienvenida. Enviarlo dentro de la petición hace que el registro tarde 2-3 segundos y falle si el servidor de correo no responde. Diseña e implementa una cola en base de datos: el registro solo inserta una tarea (tipo, datos en JSON, intentos, disponible_desde, estado); un script trabajador (php trabajador.php, ejecutado cada minuto o en bucle) toma tareas pendientes de forma segura aunque haya dos trabajadores a la vez, las ejecuta y, si fallan, las reprograma con espera creciente hasta 5 intentos.
Inicio de sesión con GitHub
Implementa «Entrar con GitHub» sin librerías, siguiendo el flujo OAuth 2.0 con código de autorización y PKCE: registra una OAuth App en GitHub; en /auth/github genera state y code_verifier, guarda ambos en la sesión y redirige a la página de autorización con code_challenge (S256); en /auth/github/vuelta comprueba el state, canjea el code (con client_secret y code_verifier) por un access_token, pide /user y /user/emails a la API de GitHub, busca o crea el usuario (vinculado por el id de GitHub, no por el email) y abre sesión. Explica qué ataque evita cada comprobación.
11. Errores típicos
- Dar un pedido por pagado porque el navegador volvió a la URL de éxito: la confirmación es el webhook verificado.
- Llamar a APIs externas sin timeout, o en cada visita sin caché.
- Poner claves secretas en el JavaScript o en el repositorio.
- Olvidar session_write_close() en peticiones largas (SSE, subidas): la sesión bloquea las demás peticiones del mismo usuario.
- Mezclar PHP dentro de un script de JavaScript sin escapar: usa atributos data- o json_encode con JSON_HEX_TAG.
- Endpoints AJAX sin protección: también necesitan autenticación, CSRF en las escrituras y límite de peticiones.
- Embeber servicios de terceros sin avisar en la política de privacidad y de cookies.
Autoevaluación
0/10 respondidas · 0 aciertosElige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.
1.¿Qué debe devolver un endpoint PHP al que llama fetch para comprobar si un nombre de usuario está libre?
2.¿Qué propone HTMX?
3.¿Con qué tecnología puede PHP empujar notificaciones al navegador de forma sencilla, sin servidor adicional?
4.¿Qué es una aplicación web híbrida (mashup)?
5.¿Por qué hay que verificar la firma de un webhook?
6.Tu página llama a la API del tiempo, que tarda 30 segundos en responder cuando falla. ¿Qué haces?
7.¿Dónde debe guardarse la clave secreta de la API de pagos?
8.En el inicio de sesión con Google (OAuth 2.0 / OpenID Connect), ¿quién comprueba la contraseña del usuario?
9.¿Qué hace un cortacircuitos (circuit breaker)?
10.¿Cómo pruebas automáticamente un código que llama a una API de terceros?