qruto/laravel-wave
Laravel Wave adds real-time features to Laravel apps without WebSockets. It broadcasts your events to the frontend over Server-Sent Events, with a simple JavaScript client and seamless integration with Laravel's existing event system—no external services required.
Install the package and publish its config:
composer require qruto/laravel-wave
php artisan vendor:publish --tag=wave-config
Open config/wave.php first. That file is the map for the package: the SSE route path, middleware stack, retry/heartbeat behaviour, and whichever storage/driver it uses to persist in-flight events. If the package ships migrations for its event store, run php artisan migrate; if it uses the cache or a Redis stream, confirm the store is shared by every app server.
Wire broadcasting to Wave. Add (or confirm) a wave connection in config/broadcasting.php and point the app at it:
BROADCAST_CONNECTION=wave
Then use ordinary Laravel broadcasting. A first useful case is a per-user private channel for notifications:
class OrderShipped implements ShouldBroadcast
{
use SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [new PrivateChannel('users.'.$this->order->user_id)];
}
}
broadcast(new OrderShipped($order));
On the front end, open one SSE connection via the package's JS client and subscribe to the channel:
import { Wave } from 'laravel-wave';
const wave = new Wave({ endpoint: '/wave' });
wave.private(`users.${userId}`)
.listen('OrderShipped', (payload) => console.log(payload));
Check the browser devtools Network tab for the /wave request. You should see Content-Type: text/event-stream, an open connection, and data: frames as events are broadcast.
Treat SSE as a one-way pipe. Server-to-client is the SSE channel; client-to-server is still normal HTTP (POST/PUT), then you broadcast the result. Do not try to build request/response or chat "typing" semantics over the event stream itself.
Keep event classes thin. Broadcast an ID and a small payload (['id' => $order->id, 'status' => 'shipped']) and let the client fetch the full resource over HTTP. Large serialized models bloat every frame and every stored event.
Use private channels by default. Define authorization in routes/channels.php with Broadcast::channel('users.{id}', fn ($user, $id) => (int) $user->id === (int) $id). The JS client authenticates against Laravel's /broadcasting/auth endpoint, so the SSE route and auth route must share the same session/CSRF setup. For multi-tenant apps, put tenant ID in the channel name and re-check membership in the callback.
Centralise the connection. In Vue/React/Svelte, create the Wave instance once (module singleton or provider) and expose a composable/hook that subscribes on mount and calls leave/stopListening on unmount. Multiple EventSource instances per tab waste the browser's small per-origin connection budget.
Broadcast from jobs and model events. Standard ShouldBroadcast events work from HTTP controllers, queued jobs, observers, and php artisan commands. Prefer queued broadcasts (ShouldBroadcast) over ShouldBroadcastNow for user-facing requests so the response is not blocked by storage writes.
Use it alongside, not necessarily instead of, websockets. If you already run Reverb/Pusher for bidirectional features, Wave can cover high-volume, fire-and-forget notification streams while websockets handle presence and client events. Presence and "whisper" style client-to-client messaging are awkward over SSE because there is no persistent client-to-server socket; implement those with HTTP endpoints plus broadcasts.
Testing. Fake the dispatch side with Event::fake([OrderShipped::class]) and assert the event and its channels; test channel authorization callbacks directly with Broadcast::channel resolution or HTTP tests against /broadcasting/auth. Avoid hitting the streaming endpoint in a normal feature test without a timeout — a streaming response can hold the test open. If you must, use a short client timeout and assert headers/status rather than the full stream.
Local development. php artisan serve is single-threaded, so one open SSE connection can block every other request. Use Sail, Valet, Herd, or Octane for day-to-day work, and a separate browser tab/profile when debugging streams.
max_execution_time, fastcgi_read_timeout, and any upstream idle timeout.X-Accel-Buffering: no (the package usually does) and set proxy_buffering off. Also disable gzip for text/event-stream; compression can delay frames indefinitely.php artisan wave:prune or whatever your version exposes) in routes/console.php/Kernel::schedule, and size the retention window to your longest acceptable reconnect gap.beforeunload/SPA route teardown.EventSource to reconnect automatically; use the package's retry config plus server-side backoff/jitter to avoid reconnect storms after a deploy. If the stream emits event IDs, honour Last-Event-ID on the server so clients can resume without gaps; if it does not, design for at-least-once and idempotent client handlers.SameSite, cross-domain SPA), the CSRF token is stale, or the SSE route uses different middleware from /broadcasting/auth. Put both behind the same web middleware group.broadcastAs(). Namespaced class names, queued events, and broadcastWith() payload shape are the usual "nothing arrives" causes. Log on the server with Log::info() before broadcast() and confirm the frame in the Network tab.ShouldBroadcast events sit in the queue until queue:work/Horizon is running; in local dev this is the most common reason events never appear. ShouldBroadcastNow bypasses the queue but slows the request.dd() or echo debug output mid-stream. Any output before the SSE headers breaks the response. Use logs, and debug with curl -N -H "Accept: text/event-stream" http://app.test/wave to watch raw frames.config/wave.php, run php artisan config:clear; a cached config will silently ignore route path, middleware, and retry changes.config/wave.php is the right place for tenant resolution or extra auth. Channel authorization belongs in routes/channels.php. If you need custom event shaping or storage, wrap broadcasting in your own service rather than forking the package; upgrade cost is low because events remain standard Laravel broadcast events.composer.json, skim the changelog on upgrade, and keep a fallback path (polling or an existing websocket driver) for critical notification flows.How can I help you explore Laravel packages today?