dskripchenko / laravel-realtime
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.
Requires
- php: ^8.2
- ext-redis: *
- illuminate/auth: ^11.0 || ^12.0 || ^13.0
- illuminate/broadcasting: ^11.0 || ^12.0 || ^13.0
- illuminate/console: ^11.0 || ^12.0 || ^13.0
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/http: ^11.0 || ^12.0 || ^13.0
- illuminate/redis: ^11.0 || ^12.0 || ^13.0
- illuminate/routing: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
- ext-pcntl: Graceful shutdown on SIGTERM/SIGINT
Provides
None
Conflicts
None
Replaces
None
README
Self-hosted WebSockets for Laravel, with no third-party services or runtime packages.
- Server:
php artisan realtime:serveis a long-running PHP process on non-blocking sockets (stream_select). It implements RFC 6455 itself. - Scaling: run as many server processes as you like. They do not know about each other. Publications go through Redis/Valkey pub/sub, and each server delivers to its own sockets.
- Protocol: a small JSON protocol. Every channel has sequence numbers and a short replay buffer, so a client that reconnects picks up where it stopped.
- Auth: a connection needs a one-time ticket issued for the logged-in user. Private and
presence channels are checked by callbacks you register, as in
routes/channels.php. Every channel is keyed by the ticket's scope (your tenant), so tenants cannot cross. - Laravel integration: the
realtimebroadcasting driver sendsShouldBroadcastevents unchanged. TheRealtimefacade publishes directly. - Client:
@dskripchenko/realtime(injs/) 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.
Install
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'], ],
Quick start
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));
Protocol at a glance
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.
Scaling
web / queue workers ──EVAL publish──▶ Redis/Valkey ──pub/sub──▶ realtime server A ─▶ its sockets
│ seq + replay log ──pub/sub──▶ realtime server B ─▶ its sockets
- Each server subscribes only to the channels its clients use.
- Sequence numbers and the replay log are assigned atomically in Redis, so every server sees the same order.
- Put any number of servers behind a TCP or HTTP load balancer. No sticky sessions are needed: a reconnecting client replays from its last sequence number on whichever server it reaches.
- Presence is counted in Redis per user. Entries left by a crashed server are removed by the surviving servers once its heartbeat expires.
Security notes
- Tickets are single-use and short-lived (30 s by default). Redis stores only their SHA-256 hash.
- The server does not terminate TLS. Put it behind nginx, Caddy or Traefik using
wss://, and do not expose its port directly. - Set
REALTIME_ALLOWED_ORIGINSas defence in depth against cross-site WebSocket hijacking. - Public channels are readable by every connection in the same scope. Use
private-channels for anything sensitive. - Channel callbacks run in a long-lived process. They must not use request state such as
request(), the session orauth()->user(). They receive the user from the ticket.
Documentation
Guides are available in English, Русский, Deutsch and 中文:
- Server: the loop, limits, keepalive, health
- Protocol: every message, with examples
- Authorization & tenancy
- Broadcasting driver
- JS client
- Operations: supervisor, Docker, proxies, monitoring, capacity
Status
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.
Security
Please report vulnerabilities privately, as described in SECURITY.md.
License
MIT © Denis Skripchenko