Self-hosted WebSocket server for Laravel: RFC 6455 on non-blocking PHP sockets, Redis/Valkey pub/sub fan-out, per-channel sequence numbers with replay, presence and a broadcasting driver.
dskripchenko/laravel-realtime is a Laravel package for self-hosted websocket server for laravel: rfc 6455 on non-blocking php sockets, redis/valkey pub/sub fan-out, per-channel sequence numbers with replay, presence and a broadcasting driver..
It currently has 0 GitHub stars and 30 downloads on Packagist (latest version v0.3.0).
Install it with composer require dskripchenko/laravel-realtime.
Discover more Laravel packages by dskripchenko
or browse all Laravel packages to compare alternatives.
Last updated
Self-hosted WebSockets for Laravel, with no third-party services or runtime packages.
php artisan realtime:serve is a long-running PHP process on non-blocking
sockets (stream_select). It implements RFC 6455 itself.routes/channels.php. Every
channel is keyed by the ticket's scope (your tenant), so tenants cannot cross.realtime broadcasting driver sends ShouldBroadcast events
unchanged. The Realtime facade publishes directly.@dskripchenko/realtime (in js/) is written in TypeScript and has zero
runtime dependencies.Requirements: PHP 8.2–8.5, Laravel 11/12/13, ext-redis (phpredis), Redis ≥ 6.2 or Valkey.
composer require dskripchenko/laravel-realtime
php artisan vendor:publish --tag=realtime-config # optional
BROADCAST_CONNECTION=realtime
REALTIME_PORT=6001
REALTIME_PUBLIC_URL=wss://app.example.com/realtime
REALTIME_ALLOWED_ORIGINS=https://app.example.com
Add the connection to config/broadcasting.php:
'connections' => [
'realtime' => ['driver' => 'realtime'],
],
Register the channels and the tenant hooks in a service provider:
use Dskripchenko\Realtime\Auth\Ticket;
use Dskripchenko\Realtime\Facades\Realtime;
public function boot(): void
{
// Scope for tickets and publications: the current tenant (empty string = single tenant).
Realtime::resolveScopeUsing(fn (): string => (string) tenant()?->id);
// Authorization runs inside the realtime server, so it has to enter the tenant itself.
Realtime::authorizeUsing(fn (Ticket $ticket, Closure $next) => Tenant::run($ticket->scope, $next));
Realtime::channel('board.{id}', fn ($user, string $id) => $user->can('view', Board::find($id)));
Realtime::channel('page.{id}', fn ($user, string $id) => ['name' => $user->name]); // presence
}
Run the server:
php artisan realtime:serve
Broadcast as usual:
class CardMoved implements ShouldBroadcast
{
use InteractsWithSockets;
public function __construct(public int $boardId, public array $card) {}
public function broadcastOn(): PrivateChannel
{
return new PrivateChannel("board.{$this->boardId}");
}
}
broadcast(new CardMoved($board->id, $card))->toOthers();
// or directly:
Realtime::publish("private-board.{$board->id}", 'CardMoved', ['card' => $card]);
Listen in the browser:
import { RealtimeClient } from '@dskripchenko/realtime';
const realtime = new RealtimeClient(); // POST /realtime/ticket with the session cookie
await realtime.connect();
realtime.private('board.42').on('App\\Events\\CardMoved', (data) => moveCard(data.card));
axios.defaults.headers.common['X-Socket-ID'] = realtime.socketId; // for toOthers()
const page = realtime.join('page.7');
page.onMembers((members) => showEditors(members));
A ticket can carry host-defined claims and an expiry, so a connection opened for an API token knows what the token may do, ends when the token expires, and can be closed when the token is revoked:
$token = $request->user()->currentAccessToken();
return Realtime::issueTicket($request->user(), 'sanctum', request: $request,
claims: ['token' => $token->id, 'abilities' => $token->abilities], // array<string, mixed>, JSON-safe
expiresAt: $token->expires_at?->getTimestamp(), // unix seconds, must be in the future
);
// The claims are on the ticket the authorization hook receives:
Realtime::authorizeUsing(fn (Ticket $ticket, Closure $next) => /* check $ticket->claims */ $next());
// Revoked: close every open connection whose claims contain all these pairs (strict comparison).
Realtime::disconnect(['token' => $token->id]); // current scope; empty claims throw
{"type":"error","code":"expired"} and close code 1008.disconnect() reaches every server over a reserved broker control channel
(RealtimeManager::CONTROL_KEY) that clients cannot subscribe to. The affected clients
get {"type":"error","code":"revoked"} and close code 1008. Presence leave and the rest
of the cleanup happen as on any close.Details: Authorization & tenancy.
client → {"type":"subscribe","id":1,"channel":"private-board.42","since":17}
server ← {"type":"ack","id":1,"channel":"private-board.42","seq":19,"gap":false}
server ← {"seq":18,"type":"event","channel":"private-board.42","event":"CardMoved","data":{...}}
server ← {"seq":19,"type":"event","channel":"private-board.42","event":"CardMoved","data":{...}}
The other messages are unsubscribe, presence (join/leave/list), event (client events),
error, ping/pong, welcome and resync. The full spec is in
docs/en/protocol.md.
web / queue workers ──EVAL publish──▶ Redis/Valkey ──pub/sub──▶ realtime server A ─▶ its sockets
│ seq + replay log ──pub/sub──▶ realtime server B ─▶ its sockets
wss://,
and do not expose its port directly.REALTIME_ALLOWED_ORIGINS as defence in depth against cross-site WebSocket hijacking.private-
channels for anything sensitive.Realtime::reauthorize($channel): the servers re-check existing subscriptions and drop
the ones that no longer pass.expiresAt and call Realtime::disconnect($claims) when the token is revoked.request(), the session or auth()->user(). They receive the user from the ticket.Guides are available in English, Русский, Deutsch and 中文:
The current version is 0.1.0, the first public release. permessage-deflate, the ext-event loop backend, Redis Cluster and the UI-kit adapter are not implemented yet. See CHANGELOG.md.
Please report vulnerabilities privately, as described in SECURITY.md.
MIT © Denis Skripchenko
Get paid by clients abroad
Wise gives you local bank details in several currencies and converts at the mid-market rate. Useful when your invoices are not in your own currency.
Try WiseNew Laravel packages worth a look, once a month. No spam, one-click unsubscribe.