Real-time web / Comparison
Laravel Reverb vs SSE: Do You Need a WebSocket Server for a Live Dashboard?
We built the same live order board twice: once on Laravel with Livewire and Reverb, once on Semitexa with Server-Sent Events. Both passed the same browser test. Speed was not the difference, and neither was the amount of code.
Most comparisons of WebSockets and SSE argue from protocol features. This one starts from a ticket. One order management app, one feature request, two implementations, and one acceptance test that drives a real browser and does not know which framework it is looking at. We are the authors of Semitexa, so read the numbers with that in mind; the section on where our build was worse is as long as we could honestly make it.
The short version
- Both builds work and both are fast. A change reached every open board in 14 to 108 ms on either stack. Nobody can see that difference.
- Both took about the same amount of code. Around 240 lines of application PHP on Laravel, around 270 on Semitexa.
- Reverb adds moving parts. Two Composer packages, two npm packages, a second long-running process on its own port, thirteen environment values and an asset build that bakes four of them into the bundle.
- The SSE build added none of those. The stream is served by the same server that renders the page. It did need Redis, which the Semitexa stack already runs.
- The real difference is who announces a change. In Laravel you write events and dispatch them. In Semitexa the feed names the tables it reads, and every ORM write to them wakes it.
- If you need presence, typing indicators or client-to-client messages, use Reverb. SSE only goes one way.
The task and the test
The starting point is a small order management app that exists in both frameworks: customers, products, and orders that move through new, paid, shipped and cancelled. The ticket is written without framework words:
- A board at
/boardwith four columns, one per status, each with a count. - One card per order, showing the number, the customer and the total.
- Anything that changes an order, from anywhere, shows on every open board within 2 seconds, without a page reload.
- A board that loses its connection catches up by itself once it is back, including what happened meanwhile.
- Customer names are shown as plain text, whatever they contain.
The acceptance test is a Playwright script with 14 checks. It opens two boards and a third browser that creates orders, marks them paid, ships and cancels them, and times how long each board takes to follow. It also plants a customer name with an <img onerror> in it. Both builds passed 14 of 14, three runs in a row.
Build one: Livewire, Reverb and Echo
This is the stack the Laravel documentation leads you to: a Livewire component for the board, broadcast events for the changes, Reverb as the WebSocket server and Echo in the browser. Versions: Laravel 13, Livewire 4.4, Reverb 1.13.
First, something has to say that an order changed. That is an event class per kind of change, three in this build:
class OrderStatusChanged implements
ShouldBroadcastNow, ShouldDispatchAfterCommit, ShouldRescue
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): Channel
{
return new Channel('orders');
}
public function broadcastWith(): array
{
return ['id' => $this->order->id, 'status' => $this->order->status];
}
}
The three interfaces each earn their place. ShouldBroadcastNow skips the queue, because the database queue's idle worker sleeps longer than the ticket allows. ShouldDispatchAfterCommit keeps a board from reading an order before its transaction is visible. ShouldRescue means a Reverb outage is logged and does not turn a saved order into a 500.
The model dispatches the events:
protected static function booted(): void
{
static::created(fn (Order $order) => OrderCreated::dispatch($order));
static::updated(function (Order $order): void {
if ($order->wasChanged('status')) {
OrderStatusChanged::dispatch($order);
}
});
}
The board itself is pleasantly small. The broadcast carries no data the board uses. It is only a signal, and the listener is an empty method whose one job is to make Livewire render again from the database:
#[On('echo:orders,OrderCreated')]
#[On('echo:orders,OrderStatusChanged')]
#[On('echo:orders,OrderLinesChanged')]
public function refreshBoard(): void {}
That design is worth copying whatever stack you use. A missed, repeated or reordered message cannot leave the board wrong, and no customer data crosses a public channel.
Catching up after a lost connection is one line of JavaScript in the component. Reverb speaks the Pusher protocol and does not replay what was broadcast while a browser was away, so every confirmed subscription simply renders the board again:
Echo.channel('orders').subscribed(() => $wire.$refresh());
What it took besides the code
- Packages.
livewire/livewireandlaravel/reverb, which pulled in twelve more Composer packages (ReactPHP, a Pusher server library, a Redis client), pluslaravel-echoandpusher-jsfrom npm. - Processes. The application server, and
php artisan reverb:starton its own port. - Configuration.
BROADCAST_CONNECTION, three application credentials, two pairs of host and port (where Reverb listens, and where Laravel sends broadcasts), a scheme, and fourVITE_REVERB_*values that are baked into the JavaScript bundle at build time. Change the public port and you rebuild the assets. - For production, the Reverb documentation adds a process manager such as Supervisor, a reverse proxy that forwards the WebSocket upgrade, raised open-file limits,
reverb:restarton every deploy, and theext-uvextension beyond about 1,000 connections.
None of this is hard, and Laravel Forge and Laravel Cloud do most of it for you. It is simply a second service to run.
Build two: one SSE feed
Semitexa runs on Swoole, so the process that renders the page can also hold a stream open. Every page already has one SSE connection for live components. The board becomes a feed on that stream: a named endpoint that the browser subscribes to, and that the server runs again when its data changes. Version: Semitexa 2026.11.03.
The feed declares what it depends on:
#[AsPublicPayload(
path: '/board/feed',
name: 'orders.board.feed',
transport: TransportType::Sse,
sseGateModel: SseGateModel::BearerSession,
)]
#[WatchScopes('orders', 'customers')]
final class BoardFeedPayload implements SseFeedPayloadInterface
{
// stream id, request and view parameters required by the interface
}
Its handler renders the board component on the server and returns the HTML:
protected function buildResponse(
SseFeedPayloadInterface $payload,
JsonResourceResponse $response,
): JsonResourceResponse {
$html = ComponentRenderer::render(
OrderBoardComponent::NAME,
['instanceId' => $payload->getInstance()],
);
return $this->jsonResponse($response, 200, ['data' => ['html' => $html]]);
}
In the browser, the component's script subscribes the feed and morphs each frame into the board that is already on the page:
import { openFeedChannel } from 'platform-ui/core';
import { morph } from 'platform-ui/events';
window.SemitexaComponent.register('orders.board', function (board, info) {
var channel = openFeedChannel({
feed: 'orders.board.feed',
params: { instance: info.componentId },
dataEvent: 'ui.collection.data',
onData: function (frame) {
var template = document.createElement('template');
template.innerHTML = frame.data.html;
morph(board, template.content.firstElementChild);
}
});
return { destroy: function () { channel.close(); } };
});
What is missing is the interesting part. There is no event class and no dispatch call. The handlers that create an order or change its status were not touched. #[WatchScopes('orders', 'customers')] names two tables, and the ORM publishes a table's scope after every committed write to it. That is the whole announcement.
What it took besides the code
- Packages. None.
- Processes. None that were not already running. The stream is served by the application server. The signal travels between Swoole workers through Redis, which is part of the default Semitexa stack.
- Configuration. No environment values. One asset manifest of 15 lines for the script.
That last point about Redis deserves a plain statement: the Laravel app in this comparison started from SQLite and one process, and the Semitexa app started from a Docker stack with MySQL, Redis and NATS. Semitexa added nothing for the board because its baseline was already heavier.
The numbers side by side
| Measure | Laravel, Livewire, Reverb | Semitexa, SSE |
|---|---|---|
| Acceptance test | 14 of 14, three runs | 14 of 14, three runs |
| Update reaches a board, median per run | 14, 18 and 54 ms | 5, 70 and 108 ms |
| Slowest single update | 59 ms | 165 ms |
| Catch-up after a 5 s and an 8 s cut | 9.1 s and 6.1 s | 2.8 s and 2.8 s |
| Application PHP added | 240 lines | 266 lines |
| Template lines | 42 (Blade) | 29 (Twig) |
| JavaScript written | 22 lines | 25 lines |
| Config files and lines | 219, almost all published by the installer | 15 |
| Packages added | 2 Composer (14 with dependencies), 2 npm | 0 |
| Environment values added or changed | 13 | 0 |
| New long-running processes | 1 (Reverb) | 0 |
| Code that announces a change | 3 event classes and model hooks, 174 lines | one attribute |
Read the latency rows as "equal". Both are far inside the 2 second limit, the runs vary more than the stacks do, and all of it happened on one laptop. The catch-up row is real but is a default, not a limit: Echo's client waited about 16 seconds after the cut before it reconnected, while the browser's own EventSource retries every 3 seconds.
What actually differs
Who announces that something changed
In the Laravel build the application says so explicitly. You decide which changes matter, write an event for each, and dispatch it from the right place. That is precise, and it is also a list you maintain. A bulk Order::where(...)->update() skips model events, so that change never reaches the board unless someone remembers to broadcast it. A new order in this build fired two broadcasts, because creating its lines is also a change.
In the Semitexa build the data layer says so. The feed names tables, and any committed write to them wakes it. Nothing can be forgotten, including writes from code that did not exist when the board was built. The price is that it is coarse: editing a customer's phone number redraws the board for every viewer, because the feed watches the whole customers table.
How many things have to be running
A WebSocket server is a separate service in the PHP world, because classic PHP ends when the response is sent. Reverb is a good one, and it is still a second process with its own port, its own proxy rule, its own restart on deploy and its own credentials that the browser bundle must know at build time. A long-running runtime folds that into the application server. This is not a Semitexa invention; it is what Swoole, RoadRunner and FrankenPHP make possible, as we described in SSE vs WebSockets for live dashboards.
What happens after a disconnect
Neither stack replays missed messages for this board, and neither needs to, because both redraw from the database. What differs is who reconnects. With SSE it is the browser, on a fixed short timer you did not write. With Echo it is a client library with a back-off you can tune. The details of what EventSource does and does not do for you are in EventSource reconnects.
Where the SSE build was worse
The Semitexa build passed on the first run, and it still had more rough edges than the Laravel one.
- The simple mechanism did not apply. Semitexa has a ten-line, no-JavaScript way to make a region live, called an island. It only works for signed-in users. The ticket's board is public, so the build had to write its own feed, and learned how by reading the framework's source for the island.
- The feed payload is mostly boilerplate. Most of its 78 lines exist to satisfy an interface: a stream id, a request, view parameters.
- The documentation was wrong in two places. It said an anonymous stream needs an environment flag that it does not need, and showed an asset manifest format the code does not use.
- A guest page needs one template call to keep its stream open, and nothing tells you so until the board quietly stops updating.
- Every write redraws the whole board for every viewer. Fine for a team board, wasteful for thousands of orders. The Laravel build at least caps each column at 50 cards.
- There is a default limit of five streams per address per worker. Twelve boards from one machine worked in our test; a large office behind one address may not.
- One direction only. The board never needs to talk back, so it did not matter here. Presence channels, typing indicators and client events have no equivalent in this build.
Outside this ticket, the gap is wider. Laravel has an ecosystem, hosting that configures Reverb for you, and years of answers to every question. In Semitexa a template edit needs a server restart of a few seconds, and the first start of a new project builds a runtime image for several minutes. Those are ours to fix, and they are real today.
SSE inside Laravel itself
Laravel can send Server-Sent Events without Reverb. response()->eventStream() takes a generator and streams whatever it yields, and the documentation shows it for streaming AI responses.
For a live board it is a harder fit. Under PHP-FPM every open stream holds a worker for as long as the tab stays open, so a board with fifty viewers occupies fifty workers. Under Octane the stream is cheaper to hold, but you still need something that wakes the generator when an order changes, which means a pub/sub channel of your own. At that point you have rebuilt part of what Reverb gives you. For a server-rendered Laravel app, Reverb with Livewire is the straightforward choice, and this comparison is not an argument against it.
Which one to pick
- You have a Laravel app. Use Reverb. Nothing measured here is a reason to change frameworks, and the build above is a sound template: broadcast signals, render from the database, refresh on every subscription.
- You need the browser to talk back in real time. Presence, typing, cursors, chat between clients. Use WebSockets, on any stack.
- The page only receives updates. Dashboards, boards, tables, counters, notifications. SSE is enough, and it removes a service if your runtime can hold the connections.
- You are choosing a PHP stack for a product that is mostly live, server-rendered screens. This is the case Semitexa is built for. Weigh one process and no announcement code against a small ecosystem and the rough edges listed above.
Inside Semitexa
The pieces the board relied on, by name:
- One stream per page. The page opens a single
EventSourceto/__semitexa_kiss. Every live component on the page shares it, and each frame carries the id of the subscription it belongs to. Subscribing and leaving are small POSTs on a control channel. - Feeds. A payload with
transport: TransportType::Ssewhose handler extendsAbstractSseFeedHandler. A plain GET answers once; a subscription keeps it and runs it again. - Scopes.
#[WatchScopes]on the feed, and a scope published by the ORM after each committed write to a table. A handler can publish a scope by hand for data that does not live in a table. - Across workers. Swoole runs several workers, and the tab that changes an order is rarely on the worker that holds your stream. The invalidation signal crosses them through Redis.
- Morphing.
morphfromplatform-ui/eventspatches the server's new HTML into the existing DOM. Children are matched byid, which is why each card has one. - The check that caught a mistake.
bin/semitexa ai:verifyfailed the first attempt: a model named in#[WatchScopes]must declare whether it is tenant-scoped, so a live feed cannot leak one tenant's rows to another by omission.
A table that needs paging, sorting and filters has a ready component built on the same stream. It is described in the SSE-driven data grid, with a working grid on the page.
How we measured
- Both builds started from the same commit of the same app in each framework, with an empty database, and were written to the same ticket text.
- Laravel ran on
php artisan servewith 16 workers and SQLite, plus Reverb. Semitexa ran its default Docker stack with Swoole and MySQL. These are development setups, not production ones, and they are not the same database. - Latency is the time from a click in one browser to the card appearing in another, taken from three runs of the acceptance test on each stack, alternating between them.
- Playwright's offline mode does not close a socket that is already open, on either stack. For the catch-up row we put a TCP proxy in front of each app, cut every connection for 5 and for 8 seconds, changed two orders meanwhile, and timed how long the board took to show both after the link returned. Two runs per stack.
- Line counts come from
git diff --statagainst the commit before the board, split by file type, without lock files and tests. - One machine, one day, one feature. Treat every number as an illustration of the shape of the difference, not as a benchmark.
Common questions
Is Laravel Reverb faster than SSE?
Not in a way this test could show. A change reached an open board in tens of milliseconds on both. For a page that only receives updates, choose on infrastructure and direction, not speed.
Do I need Laravel Reverb for a live dashboard?
In a Laravel app it is the supported way to push changes to the browser, and with Livewire it is little code. You need a WebSocket server specifically when the browser must also send messages in real time. A dashboard that only receives updates can run on SSE if your runtime can hold the open connections.
Does Reverb replay events missed while a browser was offline?
No. It delivers to the connections that are open. Design the page so that a reconnect fetches current state, as both builds here do.
Can Livewire poll instead?
Yes, wire:poll works and needs no WebSocket server. Every open board then asks the server on a timer whether anything changed. For a few users and slow data that is the simplest answer of all.
Why is everyone talking about SSE now?
Two things brought it back. AI model APIs stream their answers token by token over SSE, so every chat interface built in the last few years reads an event stream. And the Model Context Protocol first shipped with an SSE transport, later replaced by Streamable HTTP, which still uses SSE when a server streams. That story is in Streamable HTTP vs SSE for MCP servers. A live dashboard is the older and plainer use of the same idea.
Does SSE work without Swoole?
It works under PHP-FPM, at one worker per open connection, so only for low traffic. Any long-running runtime, or a hub such as Mercure, removes that limit.
The protocol choice in general, for any backend: SSE vs WebSockets: how to choose for live dashboards and tables.