blax-software/laravel-webrtc

WebRTC for Laravel on the blax ReactPHP kernel: signaling on the shared loop + a pluggable media engine (Rust/str0m via ext-php-rs) for recording, AI-realtime bridging, and party-to-party calls.

Maintainers

Package info

forgejo.blax.at/blax-software/laravel-webrtc

pkg:composer/blax-software/laravel-webrtc

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

dev-main 2026-07-11 08:53 UTC

This package is auto-updated.

Last update: 2026-07-13 12:06:56 UTC


README

Blax Software OSS

Laravel WebRTC

PHP Version Laravel Built on Tests Assertions License

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 join a 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-left notifications, targeted relay, and room broadcast
  • 🏷️ 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-kernel over the laravel-ws WebSocket transport; one process, one loop, alongside your other realtime servers
  • πŸŽ™οΈ Server-side media, when you need it β€” a pluggable MediaEngine terminates media for recording / intercept, AI-realtime bridging, and SFU group calls
  • πŸ€– AI realtime, provider hidden β€” OpenAiRealtimeBridge relays 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 RoomStore makes membership Cache-backed for fork-per-message hosts
  • πŸ”Œ Pluggable engine β€” NullMediaEngine (signaling-only) now; Str0mMediaEngine (Rust str0m via ext-php-rs, shipped in rust/) 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:

PrefixTypeWho can joinMember list shared
(none)publicanyoneno
private-privatevia RoomAuthorizerno
presence-presencevia RoomAuthorizeryes
open-presence-open presenceanyoneyes

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.

Backend-hosted rooms β€” the Rust SFU (recommended for production)

Mesh is fine for two browsers; for hosted rooms β€” server in the media path, per-peer recording without client cooperation, server-side mute/kick, usage metering, room sizes beyond ~5 β€” switch the media engine to the bundled Rust SFU (Selective Forwarding Unit: it receives each peer's audio once and forwards it to the others without decoding):

WEBRTC_MEDIA_ENGINE="Blax\WebRtc\Media\RustMediaEngine"

That's the whole setup β€” it is plug-and-play. On first use the engine downloads the prebuilt, sha256-verified blax-webrtc-sfu static binary for your platform (the RoadRunner pattern) and spawns it detached, flock-guarded so concurrent workers race exactly one spawn. Explicit control when you want it:

php artisan webrtc:install    # pre-download the binary (deploy step)
php artisan webrtc:sidecar    # run it in the FOREGROUND (docker service /
                              # supervisor; set WEBRTC_SFU_AUTO_SPAWN=false there)

PHP stays the control plane. Sessions look like:

use Blax\WebRtc\Media\RustMediaEngine;

$engine = app(RustMediaEngine::class);

// Browser sends its SDP offer over YOUR signaling (WS/HTTP) β€” answer it:
$answer = $engine->addPeer('lobby', $userId, $sdpOffer, recordPath: "/rec/lobby/$userId.ogg");

$engine->mutePeer('lobby', $userId, true);   // room stops hearing them
$engine->removePeer('lobby', $userId);       // kick
$engine->stats();                            // rooms β†’ peers β†’ connected/muted/seconds
$engine->drainEvents();                      // peer_connected / peer_left (usage seconds)

Recordings are standard Ogg/Opus (ffmpeg/browser playable), written by the sidecar from the decrypted SRTP β€” no browser MediaRecorder upload needed on this path.

Browser contract (one thing beyond a normal WebRTC client): create a data channel before offering. Only the initial offer/answer passes through your signaling; when peers join later, the SFU renegotiates new audio tracks over that data channel directly:

const pc = new RTCPeerConnection()
pc.addTrack(mic.getAudioTracks()[0], mic)
const dc = pc.createDataChannel('signaling')
dc.onmessage = async (e) => {           // SFU offers new tracks as peers join
  await pc.setRemoteDescription(JSON.parse(e.data))
  const answer = await pc.createAnswer()
  await pc.setLocalDescription(answer)
  dc.send(JSON.stringify(answer))
}
pc.ontrack = (e) => playRemoteAudio(e.streams[0])
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
const { answer } = await yourSignaling.addPeer('lobby', offer.sdp) // β†’ $engine->addPeer(...)
await pc.setRemoteDescription({ type: 'answer', sdp: answer })

Ops notes: pin WEBRTC_SFU_UDP_PORT and publish it (UDP) on your container, and set WEBRTC_SFU_PUBLIC_IP to the address browsers can reach β€” it's advertised in the ICE host candidate, which is also why no TURN server is required for the common case. See the sfu block in config/webrtc.php for every knob (socket path, auto_install/auto_spawn, download/checksum URLs, log).

The legacy raw-TCP Signaling\SignalingHandler + Server\SignalingServer path still exists for driving a MediaEngine without a browser relay 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, full per-participant call recording, and backend-hosted rooms on the Rust SFU sidecar (server-terminated ICE/DTLS/SRTP, forwarding without decode, native Ogg/Opus recording, mute/kick/stats/usage events, plug-and-play binary management) β€” all tested, including a real PHP↔Rust round-trip that spawns the sidecar from PHPUnit. Next: the duplex AI bridge inside the SFU data plane (today OpenAiRealtimeBridge covers turn-based AI participants) and browser E2E automation.

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
         └─ RustMediaEngine              backend-hosted rooms on the bundled
              └─ blax-webrtc-sfu         Rust SFU sidecar (str0m) β€” JSON control
                                         socket, plug-and-play binary  (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.

Star History

Star History Chart