Laravel 13.32, publicada el 15 de septiembre de 2026, trae un driver de broadcasting para Mercure, un protocolo que envía eventos en tiempo real al navegador por HTTP. Con el hub de Mercure que incluye FrankenPHP, el mismo servidor entrega las páginas y los eventos, sin un servidor de WebSockets aparte.
En esta entrada vamos a configurar el driver con el hub de FrankenPHP, emitir eventos en canales públicos y privados y escucharlos con Laravel Echo. Por el camino verás los 2 errores que te puedes encontrar y cómo resolverlos. Todo está probado, en el momento de crear esta entrada, con Laravel 13.35.0, FrankenPHP 1.13.1 con PHP 8.5.11 y laravel-echo 2.6.1, con los eventos comprobados en Chrome.
Qué es Mercure y qué ha cambiado
Mercure es un protocolo para enviar actualizaciones del servidor a los clientes. El navegador abre una conexión con un hub con EventSource, la API nativa de Server-Sent Events, y el servidor publica en ese hub cada actualización con una petición POST. Todo va por HTTP, sin WebSockets.
Entre septiembre y octubre de 2026 han coincidido 4 novedades:
- Laravel 13.32 (15 de septiembre) añade el driver
mercure(PR #61474) y la opción--mercuredel comandoinstall:broadcasting. - Mercure 1.0, la primera versión estable del protocolo, sale el 16 de septiembre (notas de la versión).
- FrankenPHP 1.13 (4 de octubre) lleva Mercure 1.0 en su hub. Esa versión corrige además 5 vulnerabilidades, 2 de ellas de gravedad alta, así que si ya usas FrankenPHP te conviene actualizar (notas de la versión).
- Laravel Echo 2.6 (7 de octubre) añade el conector de Mercure para el navegador (notas de la versión). En npm, la primera versión disponible es la 2.6.1.
Si FrankenPHP es nuevo para ti, tienes una introducción en Iniciándose con FrankenPHP. Para seguir esta entrada necesitas un proyecto con Laravel 13.32 o posterior. Además de PHP, Composer y Node en tu máquina, como en cualquier proyecto de Laravel, necesitas Docker para ejecutar la imagen oficial de FrankenPHP.
Instalar el driver de Mercure
En la raíz de tu proyecto, ejecuta el instalador de broadcasting con la opción de Mercure:
php artisan install:broadcasting --mercure
El instalador te hace 6 preguntas. Fíjate sobre todo en la primera:
- En
Which Mercure hub would you like to use?eligeA standalone Mercure hub, aunque el hub vaya a ser el de FrankenPHP. Con la otra opción,FrankenPHP's built-in hub, Laravel publica con la funciónmercure_publish(), que solo existe dentro del servidor de FrankenPHP, yphp artisandeja de funcionar en la terminal. Más abajo tienes el error y por qué pasa. - En
Mercure Hub URL, la URL en la que Laravel publica los eventos:http://localhost:8000/.well-known/mercure. - En
Mercure Hub Public URL, la URL a la que se conecta el navegador. Te propone la misma, y en local es la que necesitamos. - El secreto,
Mercure JWT Secret, lo puedes dejar vacío y el instalador genera uno aleatorio de 64 caracteres. Con él se firman los tokens para publicar y para suscribirse. - Los canales cifrados de extremo a extremo los dejamos en
No. - Por último, acepta instalar y compilar las dependencias de Node. El instalador añade
laravel-echoy ejecutanpm run build. También añadepusher-js, que Mercure no utiliza.
Como la URL del hub es http, el instalador muestra este aviso:
WARN Set "cookie_name mercure_access_token" on your Mercure hub.
Lo resolveremos al configurar el hub. Además, el instalador publica config/broadcasting.php y routes/channels.php, registra el archivo de canales en bootstrap/app.php, instala symfony/mercure 0.8 y web-token/jwt-library 4.2, y crea resources/js/echo.js:
import Echo from 'laravel-echo';
window.Echo = new Echo({
broadcaster: 'mercure',
host: import.meta.env.VITE_MERCURE_HUB_URL,
});
Ese archivo se importa desde resources/js/app.js, así que Echo está disponible en window.Echo en cualquier página que cargue app.js con @vite. La variable VITE_MERCURE_HUB_URL le dice dónde está el hub.
Las variables de entorno
En tu .env verás estas variables:
BROADCAST_CONNECTION=mercure
VITE_MERCURE_HUB_URL="${MERCURE_PUBLIC_URL}"
MERCURE_URL="http://localhost:8000/.well-known/mercure"
MERCURE_PUBLIC_URL="http://localhost:8000/.well-known/mercure"
MERCURE_COOKIE_NAME="mercure_access_token"
MERCURE_JWT_SECRET=el-secreto-aleatorio-de-64-caracteres
Vamos a hacer 2 cambios. El primero, en APP_URL. Laravel firma los tokens de Mercure con esa URL como emisor y el hub solo acepta los tokens de los emisores que tiene configurados, así que tiene que ser la URL de la aplicación:
APP_URL=http://localhost:8000
El segundo, mover VITE_MERCURE_HUB_URL debajo de MERCURE_PUBLIC_URL. El instalador la escribe antes que la variable a la que apunta, y Docker Compose, que lee el mismo .env, avisa al arrancar con este mensaje:
The "MERCURE_PUBLIC_URL" variable is not set. Defaulting to a blank string.
Vite la resuelve igual, pero así el aviso desaparece. Las variables quedan de la siguiente forma:
APP_URL=http://localhost:8000
BROADCAST_CONNECTION=mercure
MERCURE_URL="http://localhost:8000/.well-known/mercure"
MERCURE_PUBLIC_URL="http://localhost:8000/.well-known/mercure"
MERCURE_COOKIE_NAME="mercure_access_token"
MERCURE_JWT_SECRET=el-secreto-aleatorio-de-64-caracteres
VITE_MERCURE_HUB_URL="${MERCURE_PUBLIC_URL}"
Configurar el hub de FrankenPHP
FrankenPHP está construido sobre el servidor web Caddy y se configura con un Caddyfile. Crea el archivo Caddyfile en la raíz del proyecto con el siguiente contenido:
:8000 {
root public/
mercure {
issuer {$APP_URL} {
publisher {
jwt {env.MERCURE_JWT_SECRET}
}
subscriber {
jwt {env.MERCURE_JWT_SECRET}
}
}
cookie_name mercure_access_token
}
php_server
}
Vamos línea a línea:
:8000escucha en el puerto 8000 por HTTP, yroot public/conphp_serversirven Laravel como cualquier servidor web.- El bloque
mercureactiva el hub en/.well-known/mercure, en el mismo puerto que la aplicación. issuer {$APP_URL}declara la aplicación como emisor de confianza, con 2 claves: la que verifica los tokens de publicación (publisher) y la que verifica los de suscripción (subscriber). Las 2 son elMERCURE_JWT_SECRETde tu.env.cookie_name mercure_access_tokenes lo que pedía el aviso del instalador. Laravel entrega el token de suscripción al navegador en una cookie con ese nombre, y el hub tiene que buscar la misma.
Ahora crea el archivo compose.yaml, también en la raíz del proyecto, con la imagen oficial de FrankenPHP:
services:
app:
image: dunglas/frankenphp:1.13-php8.5
command: frankenphp run --config /app/Caddyfile
ports:
- "8000:8000"
environment:
APP_URL: ${APP_URL}
MERCURE_JWT_SECRET: ${MERCURE_JWT_SECRET}
volumes:
- .:/app
Docker Compose lee el .env de la carpeta para sustituir ${APP_URL} y ${MERCURE_JWT_SECRET}, así que el Caddyfile y Laravel usan los mismos valores. Arranca el contenedor:
docker compose up -d
A partir de ahora tienes la aplicación en http://localhost:8000 y el hub en http://localhost:8000/.well-known/mercure, servidos por el mismo proceso. Si quieres repasar cómo trabajar con Docker en un proyecto de Laravel, tienes el curso Docker y Laravel Sail desde cero.
Emitir un evento
Vamos a crear un evento que avise de cada pedido nuevo:
php artisan make:event OrderPlaced
Abre el archivo app/Events/OrderPlaced.php y cambia su código por el siguiente:
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderPlaced implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public string $number,
) {}
/**
* @return array<int, Channel>
*/
public function broadcastOn(): array
{
return [
new Channel('orders'),
];
}
}
El evento implementa ShouldBroadcast y se emite en el canal público orders. Sus propiedades públicas, aquí number, son los datos que llegan al navegador.
Con ShouldBroadcast, Laravel envía el evento a la cola, así que necesitas un worker. Lánzalo dentro del contenedor:
docker compose exec app php artisan queue:work
Si prefieres emitirlo en la misma petición, sin pasar por la cola, implementa ShouldBroadcastNow en lugar de ShouldBroadcast. Hemos probado las 2 interfaces y el evento llega igual. Tienes más sobre colas y workers en Jobs en Laravel: guía completa para tareas en segundo plano.
Ahora las rutas. En routes/web.php añadimos una página para ver los pedidos y una ruta que crea uno y emite el evento:
<?php
use App\Events\OrderPlaced;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
});
Route::view('/orders', 'orders');
Route::post('/orders', function (Request $request) {
OrderPlaced::dispatch($request->string('number'));
return response()->noContent();
});
Escuchar el evento con Echo
Crea la vista resources/views/orders.blade.php con el siguiente código:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="utf-8">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>Pedidos</title>
@vite('resources/js/app.js')
</head>
<body>
<h1>Pedidos</h1>
<button id="place-order">Crear pedido</button>
<ul id="orders"></ul>
<script type="module">
window.Echo.channel('orders').listen('OrderPlaced', (event) => {
const item = document.createElement('li');
item.textContent = `Nuevo pedido: ${event.number}`;
document.getElementById('orders').prepend(item);
});
document.getElementById('place-order').addEventListener('click', () => {
fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify({ number: 'A-' + Date.now() }),
});
});
</script>
</body>
</html>
Si te fijas, la vista lleva la etiqueta meta con el token CSRF. Con Mercure, Echo pide autorización a /broadcasting/auth para todos los canales, también para los públicos, porque el hub recibe el token de suscripción en una cookie que crea Laravel. Esa petición es un POST a una ruta web, así que necesita el token CSRF, y Echo lo lee de esa etiqueta.
Probar el tiempo real en el navegador
Con el worker en marcha, abre http://localhost:8000/orders en 2 ventanas y pulsa "Crear pedido" en una de ellas. En la otra aparece el pedido nuevo:
Nuevo pedido: A-1791812263785
En la pestaña de red del navegador verás las 2 peticiones que hace Echo al cargar la página:
200 POST http://localhost:8000/broadcasting/auth
200 GET http://localhost:8000/.well-known/mercure?match=https%3A%2F%2Flaravel.alt%2Fecho%2Fchannel%2Forders
La primera devuelve la cookie mercure_access_token, que es httpOnly, así que el JavaScript de la página no puede leerla. La segunda es la conexión de Server-Sent Events, que se queda abierta. En Mercure, cada canal se identifica con una URL, que se llama topic. El del canal orders es https://laravel.alt/echo/channel/orders: Laravel añade ese prefijo a todos los canales para no chocar con otras aplicaciones que compartan el hub, y lo puedes cambiar con la opción topic_prefix de la conexión.
Emitir eventos desde Artisan
Como Laravel publica por HTTP en MERCURE_URL, puedes emitir eventos desde cualquier proceso: una petición, un comando, un job o Tinker. Con la página abierta, ejecuta lo siguiente:
docker compose exec app php artisan tinker --execute "App\Events\OrderPlaced::dispatch('A-2001');"
El worker procesa el evento y en el navegador aparece Nuevo pedido: A-2001. Esto es justo lo que deja de funcionar con el hub integrado de FrankenPHP, como veremos más abajo.
Canales privados
Para un evento que solo debe ver un usuario, Laravel ya trae en routes/channels.php el canal privado de cada usuario:
<?php
use Illuminate\Support\Facades\Broadcast;
Broadcast::channel('App.Models.User.{id}', function ($user, $id) {
return (int) $user->id === (int) $id;
});
Vamos a crear un evento que avise a un usuario de que su pedido ha salido:
php artisan make:event OrderShipped
Abre app/Events/OrderShipped.php y cambia su código por el siguiente:
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class OrderShipped implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(
public int $userId,
public string $number,
) {}
/**
* @return array<int, PrivateChannel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('App.Models.User.'.$this->userId),
];
}
}
En la vista resources/views/orders.blade.php, debajo del listener del canal orders, añadimos la suscripción al canal privado del usuario que ha iniciado sesión:
@auth
window.Echo.private('App.Models.User.{{ auth()->id() }}').listen('OrderShipped', (event) => {
const item = document.createElement('li');
item.textContent = `Tu pedido ${event.number} ha salido`;
document.getElementById('orders').prepend(item);
});
@endauth
Echo autoriza todos los canales de la página en una sola petición a /broadcasting/auth, y Laravel responde con una sola cookie que da acceso a todos los canales concedidos. Esta es la respuesta para el usuario 1:
{"channel_names":[{"name":"orders"},{"name":"private-App.Models.User.1"}],"expires_in":300,"topic_prefix":"https:\/\/laravel.alt\/echo\/","client_events":true}
Si un invitado intenta suscribirse al canal del usuario 1, Laravel marca ese canal como denegado y concede el resto:
{"channel_names":[{"name":"orders"},{"name":"private-App.Models.User.1","denied":true}],"expires_in":300,"topic_prefix":"https:\/\/laravel.alt\/echo\/","client_events":true}
Echo lo notifica en el método error() del canal con el mensaje The broadcasting auth request denied access to [private-App.Models.User.1].
Lo hemos probado con 2 sesiones abiertas, la del usuario 1 y la del usuario 2, emitiendo el evento para el usuario 1:
docker compose exec app php artisan tinker --execute "App\Events\OrderShipped::dispatch(1, 'A-3001');"
El usuario 1 ve Tu pedido A-3001 ha salido y la lista del usuario 2 se queda vacía.
The Mercure broadcasting connection requires a "url" configuration value
Si en el instalador eliges FrankenPHP's built-in hub, tu .env se queda sin MERCURE_URL y cualquier comando de Artisan falla en la terminal, por ejemplo php artisan make:event OrderCancelled:
RuntimeException
Failed to create broadcaster for connection "mercure" with error: The Mercure broadcasting connection requires a "url" configuration value, unless the application is served by FrankenPHP with its built-in Mercure hub enabled..
at vendor/laravel/framework/src/Illuminate/Broadcasting/BroadcastManager.php:320
Sin MERCURE_URL, Laravel publica con mercure_publish(), la función que FrankenPHP define dentro de su servidor, y esa función no existe en la terminal. El fallo no se queda en los comandos que emiten eventos: routes/channels.php se carga en todos los comandos de Artisan, y al registrar los canales Laravel crea la conexión de Mercure y comprueba que la función existe. Por eso falla hasta composer dump-autoload, que ejecuta package:discover al terminar:
Script @php artisan package:discover --ansi handling the post-autoload-dump event returned with error code 1
Tampoco funciona con frankenphp php-cli artisan. Las peticiones web sí, porque llegan al servidor de FrankenPHP: un evento emitido desde una ruta llega al navegador. Pero sin Artisan no puedes lanzar el worker, migrar ni crear archivos, así que la solución es la de esta entrada: que Laravel publique en el hub de FrankenPHP por HTTP. Añade a tu .env MERCURE_URL, MERCURE_PUBLIC_URL y MERCURE_COOKIE_NAME como en la sección de instalación, y la clave publisher al Caddyfile.
The Mercure "__Secure-mercure_access_token" cookie requires an "https" hub "public_url"
Este error sale si quitas MERCURE_COOKIE_NAME del .env con el hub en http:
RuntimeException
Failed to create broadcaster for connection "mercure" with error: The Mercure "__Secure-mercure_access_token" cookie requires an "https" hub "public_url". Use HTTPS, or configure a "cookie_name" without the "__Secure-" or "__Host-" prefix for plain-HTTP development..
Desde Mercure 1.0, la cookie se llama por defecto __Secure-mercure_access_token, y el prefijo __Secure- exige HTTPS. En local con HTTP necesitas otro nombre en los 2 lados: MERCURE_COOKIE_NAME en el .env y cookie_name en el Caddyfile. Es lo que dice el propio mensaje del error: o HTTPS, o un nombre sin ese prefijo.
Mercure o Reverb
Las 2 opciones usan los mismos eventos de Laravel y Laravel Echo en el navegador, así que el código de tus eventos y tus canales no cambia. Lo que cambia es lo que tienes que mantener en el servidor:
- Con Mercure, el hub va en el mismo servidor y el mismo puerto que la aplicación. Con Reverb, el servidor de WebSockets es otro proceso,
php artisan reverb:start, que escucha en el puerto 8080 por defecto y que en producción se suele mantener vivo con un gestor de procesos como Supervisor. - Mercure usa Server-Sent Events, que el navegador abre con
EventSourcesin librerías extra. Reverb usa WebSockets con el protocolo de Pusher, y Echo necesitapusher-jspara conectarse a él. - Server-Sent Events va en un solo sentido, del servidor al navegador. Lo que el navegador envía al servidor sigue yendo por peticiones HTTP normales, como el POST de nuestro botón. Un WebSocket sirve para los 2 sentidos.
- Reverb funciona con cualquier servidor web. El hub integrado necesita que sirvas la aplicación con FrankenPHP; si no, tendrías que levantar un hub de Mercure independiente, que vuelve a ser un proceso aparte.
Si ya sirves tu aplicación con FrankenPHP, Mercure te ahorra un proceso y un puerto. Si no, Reverb no te obliga a cambiar de servidor, y tienes cómo funciona en Laravel Reverb: la solución oficial para aplicaciones en tiempo real.
Espero que te sirva. Si quieres seguir aprendiendo a crear aplicaciones en tiempo real con Laravel, no te pierdas el curso Laravel Reverb Basics, aplicaciones realtime con Laravel.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción