Lightspeed: build real-time apps the Laravel way
I've been meaning to release Lightspeed for a while now. I actually built it a while ago. It's an open-source websocket server for Laravel. A browser sends a message up a websocket and your auth runs. Then your Laravel code handles it and the answer comes back down the same socket.
Why did I make it?
I built Lightwave which is a real-time collaborative editor. It needed a duplex websocket system that could authorize every keystroke without touching the database. It also needed each client's messages to arrive in the order that client sent them, and HTTP/2 multiplexing explicitly doesn't guarantee that. I couldn't find anything that did that so I wrote Lightspeed for it.
The gap I hope it fills
Laravel broadcasting is good at pushing events from your app out to browsers. Reverb and Pusher do that.
Lightspeed covers the other direction. It's for apps where browsers also send authenticated application messages into Laravel over the socket.
So basically if your server mostly pushes events out to browsers use Reverb (it's first-party and simpler).
What Lightspeed is
Lightspeed is a Pusher-compatible websocket server built on Swoole. It works with Laravel Echo. It's also your web server. One server runs your whole app, with HTTP and websockets on the same port. It boots once and stays in memory so there's no framework bootstrap on each request.
A message from the browser lands in a handler that runs inside the full Laravel container. Here's one from the README:
namespace App\Realtime;
use Illuminate\Support\Facades\Broadcast;
use Lightspeed\ClientEvents\ClientEvent;
use Lightspeed\ClientEvents\ClientEventResult;
use Lightspeed\Contracts\ClientEventHandler;
class EchoHandler implements ClientEventHandler
{
public function handle(ClientEvent $event): ?ClientEventResult
{
// Not mine: hand it to the next handler, then to normal peer relay.
if ($event->event !== 'client-say') {
return null;
}
$said = $event->data['message'];
// Everyone on the channel hears it.
Broadcast::connection()->broadcast(['presence-lobby'], 'said', [
'message' => $said,
'from' => $event->userId,
]);
// The sender alone gets this, on the socket it asked from.
return ClientEventResult::response(
requestId: $event->data['requestId'],
response: ['saved' => true],
);
}
}
$event->userId is the identity your channel authorization approved. It doesn't come from anything the browser claimed.
Auth on every message, without the database
Websocket authorization normally runs once when a connection subscribes. After that an open socket has no idea if you've changed your mind.
Lightspeed checks the messages that come after. You tag the connection in routes/channels.php, where you're already hitting the database:
Lightspeed::tag(["user:{$user->id}", "project:{$doc->project_id}"])
->with(['can_edit' => $user->can('update', $doc)]);
Then when access changes you call this from anywhere in your app:
Lightspeed::revoke('project:7');
When the user sends their next message, Lightspeed checks Redis and refuses it as stale. The client then re-subscribes and that runs your rule again. The check is one Redis read. It takes about 0.03ms on loopback and stays flat from one tag to eight because it's a single MGET. If your Redis is across a network it'll cost about one Redis round trip instead. It never queries the database and it's never cached. If Redis can't answer, the message is refused.
The grant is signed into the auth string your channel-auth endpoint already returns, so there's nothing for the server to store. Strip it or edit a tag or paste in someone else's and the signature stops matching.
Lightspeed guarantees that a revoked user's messages are refused. Dropping that user from the fan-out so they stop receiving is best effort. The fallback is the grant lifetime, which defaults to 300 seconds. Revoking also isn't a logout. The socket stays open and if your channel rule still says yes, the user is straight back in.
All of this is opt-in. If you never tag a connection it has no grant and skips the Redis read.
Some tests I ran
For round trips I did four runs. Each run timed 500 sequential round trips on one connection. That was on an Apple silicon laptop running 2 workers with real Redis on loopback. The p50 was 0.27 to 0.28ms every run and the p95 ranged from 0.37 to 0.60ms. The check fails if any reply is wrong, including the identity the handler saw.
For fan-out I published one event once and it reached every subscribed socket across two instances on one laptop. That held at every size up to 10,000 connections. The same laptop ran both servers and faked every client, so it was fighting itself for CPU. The latency figures from that run are in the docs, but that's why I only go by the delivery column.
The live demo at https://innerloop.works/lightspeed is an asteroids-style multiplayer arena running in production. It exists to prove the two-way path under load. Each player's keypresses go up the socket as intents thirty times a second. They land in a Laravel handler that carries the identity the channel authorization approved. The browser never sends an identity or a position. The server decides where every ship is and its state ticks at 30Hz inside Lightspeed. I load tested it on the box that serves it. That's an 8 vCPU Hetzner machine in Oregon behind Cloudflare running 6 workers. The bots ran from one Mac in Los Angeles.
1,000 of 1,000 sockets connected. That was 200 players and 800 watchers spread across 35 arenas. Players got snapshots at 30Hz and watchers got them at 5Hz. The server held a median of 30.2 ticks a second. Input made the round trip from LA to Oregon and back in 123ms at p50 and 256ms at p95. Peak load average was 3.84 of 8 cores and there were no errors.
Give it a try!
The example gets you a server running on a live Redis in about a minute:
git clone https://github.com/innerloop-dev/lightspeed.git
cd lightspeed/example && ./install.sh
Open two tabs and type in one. It shows up in both with your round-trip time.
To add it to your own app you'll need PHP 8.2+ with the Swoole extension, Redis, a PHP Redis client and Laravel 11 or 12.
composer require innerloop-dev/lightspeed
php artisan lightspeed:install
php artisan lightspeed:doctor
If you're coming from Reverb your REVERB_APP_* values get picked up as fallbacks. Point Echo at the new port and your clients don't change.
Justin Vincent. Follow me on X.