Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|---|---|---|
| config | ||
| rust | ||
| src | ||
| tests | ||
| .gitattributes | ||
| .gitignore | ||
| CHANGELOG.md | ||
| LICENSE | ||
| README.md | ||
| composer.json | ||
| phpunit.xml.dist | ||
| pint.json | ||
| test.sh | ||
README.md
Laravel WebRTC
WebRTC for Laravel on the shared reactphp-kernel backbone. A WebSocket signaling relay connects browsers for peer-to-peer calls today (no server-side media needed); when you need the server in the media path — recording, AI bridging, an SFU — a pluggable media engine (a Rust str0m core via ext-php-rs) takes over.
Features
- 📞 Browser-to-browser calls, working today — two browsers
joina room and the server relays their SDP/ICE so they connect peer-to-peer; the audio never touches the server, so no media engine is required - 👥 Rooms & mesh signaling — peer discovery on join,
peer-joined/peer-leftnotifications, targetedrelay, and roombroadcast - 🏷️ Typed rooms —
public,private-*(authorized),presence-*(authorized + member list),open-presence-*— derived from the room name, like laravel-websockets channels - ⏺️ Record the full call — the browser streams its mic over the WebSocket and the server stores each participant's audio (
FileRecordingStore→<room>/<peer>.webm); + a call-event log seam for auditing even unrecorded calls - 🧩 On the shared kernel — runs on
reactphp-kernelover thelaravel-wsWebSocket transport; one process, one loop, alongside your other realtime servers - 🎙️ Server-side media, when you need it — a pluggable
MediaEngineterminates media for recording / intercept, AI-realtime bridging, and SFU group calls - 🤖 AI realtime, provider hidden —
OpenAiRealtimeBridgerelays a caller to OpenAI's Realtime API server-side (PCM in → spoken reply out, recordable), so the browser only ever talks to you and the model is a config swap - 🔀 Use it on any transport — the domain layer (rooms, recording, the turn-based realtime bridge) is transport-agnostic: run it on the bundled ReactPHP server or drive it from a WebSocket you already have; a swappable
RoomStoremakes membership Cache-backed for fork-per-message hosts - 🔌 Pluggable engine —
NullMediaEngine(signaling-only) now;Str0mMediaEngine(Ruststr0mviaext-php-rs, shipped inrust/) for real server-terminated media
Installation
composer require blax-software/laravel-webrtc
php artisan vendor:publish --tag="webrtc-config"
The domain layer (rooms, recording, the realtime bridge) needs only illuminate/* + textalk/websocket. The bundled ReactPHP server (webrtc:serve) additionally uses reactphp-kernel + laravel-ws — these are require-dev, so a host that runs the relay on its own WebSocket doesn't pull them (see Use it on your own WebSocket).
Quick Start
Run the signaling relay (browsers connect here):
php artisan webrtc:serve # ws://127.0.0.1:8090 by default
That's all a peer-to-peer call needs — the server only relays signaling; the browsers exchange audio directly:
const ws = new WebSocket('ws://127.0.0.1:8090')
const pc = new RTCPeerConnection()
let peer
ws.onmessage = async ({ data }) => {
const m = JSON.parse(data)
if (m.type === 'welcome') ws.send(JSON.stringify({ type: 'join', room: 'lobby' }))
if (m.type === 'joined') m.peers.forEach(p => (peer = p, call(p))) // someone's already here → call them
if (m.type === 'peer-joined') peer = m.peer // they'll send us an offer
if (m.type === 'relay') { // SDP / ICE from the other browser
peer = m.from
if (m.data.sdp) { await pc.setRemoteDescription(m.data); if (m.data.type === 'offer') answer() }
if (m.data.candidate) await pc.addIceCandidate(m.data)
}
}
pc.onicecandidate = e => e.candidate && ws.send(JSON.stringify({ type: 'relay', to: peer, data: e.candidate }))
pc.ontrack = e => audioEl.srcObject = e.streams[0]
const send = (data) => ws.send(JSON.stringify({ type: 'relay', to: peer, data }))
async function call() { const o = await pc.createOffer(); await pc.setLocalDescription(o); send(o) }
async function answer() { const a = await pc.createAnswer(); await pc.setLocalDescription(a); send(a) }
Relay protocol (JSON over WebSocket)
← { "type": "welcome", "peer": "<id>", "record": false } // record:true => also stream your mic
→ { "type": "join", "room": "lobby", "auth": {}, "info": {} }
← { "type": "joined", "room", "roomType", "record", "peers", "members"? } // members: presence rooms only
← { "type": "peer-joined", "room", "peer", "info"? } // sent to the others
→ { "type": "relay", "to": "<peer>", "data": { ...sdp | candidate... } }
← { "type": "relay", "from": "<peer>", "data": { ... } } // delivered to the target
→ { "type": "broadcast", "data": { ... } } // to the rest of your room
→ { "type": "leave" } ← { "type": "left" } / others get { "type": "peer-left", "peer" }
(binary frames) → appended to YOUR recording (when record=true)
Room types
The type is derived from the room name prefix, like laravel-websockets channels:
| Prefix | Type | Who can join | Member list shared |
|---|---|---|---|
| (none) | public | anyone | no |
private- |
private | via RoomAuthorizer |
no |
presence- |
presence | via RoomAuthorizer |
yes |
open-presence- |
open presence | anyone | yes |
private-* / presence-* joins are denied by default — bind an authorizer that validates the join and returns the member's presence info:
WEBRTC_AUTHORIZER="App\WebRtc\MyRoomAuthorizer"
final class MyRoomAuthorizer implements Blax\WebRtc\Contracts\RoomAuthorizer
{
public function authorize(string $peerId, string $roomId, array $auth): ?array
{
// validate $auth['token'] for $roomId; return presence info, or null to deny
return ['name' => /* the authorized user's name */];
}
}
Recording the full call
A pure-P2P call's audio is DTLS-SRTP end-to-end, so the server can't capture it. To store the full call, the browser MediaRecords its mic and streams the chunks to the server as binary WebSocket frames when record is set; the server appends them per participant.
WEBRTC_RECORDING=true
WEBRTC_RECORDING_PATH=/var/recordings # writes <room>/<peer>.webm
// after `welcome` / `joined` reports record: true
const rec = new MediaRecorder(micStream, { mimeType: 'audio/webm;codecs=opus' })
rec.ondataavailable = (e) => e.data.size && e.data.arrayBuffer().then((b) => ws.send(b)) // binary frame
rec.start(250)
Recordings finalize on leave/disconnect. Swap FileRecordingStore for your own RecordingStore (S3, DB blob) and bind a CallEventListener (WEBRTC_EVENTS) to persist call records — participants, duration, and the finalized recording locator — even for calls you don't record.
Server-terminated media (AI bridge / SFU)
When the server must be in the media path, configure a MediaEngine. NullMediaEngine (default) is signaling-only; Str0mMediaEngine is backed by a Rust str0m core via ext-php-rs (see rust/README.md):
WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\Str0mMediaEngine"
The engine-backed path uses Signaling\SignalingHandler (offer/ice/record/connect/bridge/close) + Server\SignalingServer; it's independent of the browser relay above and shares the same RoomManager.
Use it on your own WebSocket (no bundled server)
The domain layer is transport-agnostic, so a host that already runs a WebSocket — including a classic fork-per-message Laravel WS — can drive the relay itself and skip this package's ReactPHP server entirely. Resolve the pieces and relay with your own transport:
use Blax\WebRtc\Rooms\RoomManager;
use Blax\WebRtc\Contracts\RecordingStore;
use Blax\WebRtc\Realtime\OpenAiRealtimeBridge;
// Typed rooms + membership — join() returns the peers already there to notify:
$others = app(RoomManager::class)->join($room, $peerId);
foreach ($others as $peer) $yourWs->sendTo($peer, ['type' => 'peer-joined', 'peer' => $peerId]);
// Store the call (your transport streams the mic to you however it likes):
app(RecordingStore::class)->append($room, $peerId, $chunk);
// An AI joins the call — provider hidden, turn-based (one round-trip per push-to-talk):
$turn = app(OpenAiRealtimeBridge::class)->exchange($pcm, [
'api_key' => config('services.openai.key'), 'voice' => 'alloy', 'instructions' => '…',
]);
// $turn->pcm = PCM16 24kHz reply, $turn->transcript = text — play + record it.
Membership lives in a swappable RoomStore. The default ArrayRoomStore keeps it in process memory (right for the long-lived bundled server); a fork-per-message host, which has no shared memory across frames, binds a Cache/Redis-backed one so state survives across workers:
// config/webrtc.php
'room_store' => \App\Support\CacheRoomStore::class, // implements Blax\WebRtc\Contracts\RoomStore
OpenAiRealtimeBridge talks to a RealtimeTransport (textalk/websocket by default), so it's unit-testable with a scripted fake. This is exactly how learn-atc runs the relay + a recorded OpenAI ATC participant on its existing WebSocket, without laravel-ws/reactphp-kernel installed.
Configuration
config/webrtc.php covers the signaling bind (host/port/tls), recording (enabled/path/format), the authorizer (room join gate) and events (call log) bindings, the media_engine, advertised ice_servers, and the external bridge (e.g. OpenAI model + key).
Status
Working package. Browser-to-browser (mesh) calls, typed rooms (public / private / presence / open-presence) with authorization + presence, and full per-participant call recording (mic streamed over the WS, stored to disk) all work today and are tested (incl. a real WebSocket round-trip). The only remaining piece is server-terminated SRTP media (an in-path SFU/recorder for AI bridging or huge groups) — that's the Str0mMediaEngine + rust/ core, the next build.
Architecture
laravel-webrtc (this)
├─ Domain layer (transport-agnostic — needs no bundled server)
│ ├─ RoomManager + RoomStore typed rooms, membership, presence
│ ├─ RecordingStore per-participant call recording
│ └─ OpenAiRealtimeBridge AI joins the call, provider hidden
│
├─ Bundled server (require-dev) run it for you…
│ reactphp-kernel shared backbone (loop, IPC, signals)
│ └─ laravel-ws WebSocket transport (RFC6455)
│ └─ RelaySignalingHandler browser P2P calls over the loop
│
└─ MediaEngine (optional) …or terminate media server-side
└─ Str0mMediaEngine Rust/str0m via ext-php-rs (rust/)
The domain layer runs on any transport (the bundled server, or a host WS like learn-atc's fork-per-message WebSocket). The bundled server + media engine are opt-in.
Testing
composer install
composer test
The relay + signaling routers and engines are unit-tested with fakes; a real WebSocket round-trip proves the relay over laravel-ws, and a raw-socket test drives the engine signaling server — no browser or external services required.
License
MIT. See LICENSE.