Go to file
Blax Software 1be40ba59c feat: full package — typed rooms (public/private/presence/open-presence) + call recording
Rooms
- Rooms\RoomType derived from the name prefix (like laravel-websockets channels):
  public, private-* (authorized), presence-* (authorized + member list),
  open-presence-* (public + member list). Room/RoomManager carry per-member info.
- Contracts\RoomAuthorizer + secure DefaultRoomAuthorizer (denies private/presence
  until the host binds one). Presence rooms broadcast the member list + info.

Recording (store the FULL call audio, in PHP, today)
- Pure-P2P audio is DTLS-SRTP end-to-end, so the server can't capture it. Instead
  the browser MediaRecords its mic and streams chunks over the WS as BINARY frames
  (needs the new laravel-ws onBinary); RelaySignalingHandler appends them per
  participant via a RecordingStore. Recording\FileRecordingStore -> <room>/<peer>.webm.
  welcome/joined carry a record flag telling the client to stream.
- Contracts\CallEventListener call-log seam (peer/room/recording lifecycle) so calls
  are auditable even when unrecorded.

Wiring
- RelaySignalingHandler(authorizer, recorder, events); webrtc:serve + provider +
  config/webrtc.php (recording enabled/path/format, authorizer, events).

Tests 35/99 incl on-disk FileRecordingStore, presence member lists, auth gating,
binary recording+finalize+events, RoomType, and the real WS round-trip. README
documents room types + the browser MediaRecorder->WS recording flow.

rel learn-atc #1060 #1055 #1057 #1051

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:01:12 +02:00
config feat: full package — typed rooms (public/private/presence/open-presence) + call recording 2026-07-08 09:01:12 +02:00
rust feat: initial laravel-webrtc scaffold on the reactphp-kernel 2026-07-07 13:07:59 +02:00
src feat: full package — typed rooms (public/private/presence/open-presence) + call recording 2026-07-08 09:01:12 +02:00
tests feat: full package — typed rooms (public/private/presence/open-presence) + call recording 2026-07-08 09:01:12 +02:00
.gitattributes feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding) 2026-07-07 13:28:49 +02:00
.gitignore feat: initial laravel-webrtc scaffold on the reactphp-kernel 2026-07-07 13:07:59 +02:00
CHANGELOG.md feat: full package — typed rooms (public/private/presence/open-presence) + call recording 2026-07-08 09:01:12 +02:00
LICENSE feat: initial laravel-webrtc scaffold on the reactphp-kernel 2026-07-07 13:07:59 +02:00
README.md feat: full package — typed rooms (public/private/presence/open-presence) + call recording 2026-07-08 09:01:12 +02:00
composer.json feat: working browser-to-browser (P2P) MVP via WebSocket signaling relay 2026-07-08 08:35:02 +02:00
phpunit.xml.dist feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding) 2026-07-07 13:28:49 +02:00
pint.json feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding) 2026-07-07 13:28:49 +02:00
test.sh feat: WebRTC MVP to Blax standard (namespace, README, tests, scaffolding) 2026-07-07 13:28:49 +02:00

README.md

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 roomspublic, 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 — bridge a caller to an external model (e.g. OpenAI Realtime) server-side; the browser only ever talks to you, and the model is a config swap
  • 🔌 Pluggable engineNullMediaEngine (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"

It depends on blax-software/reactphp-kernel + blax-software/laravel-ws, resolved from git.blax.at via the repositories entry in composer.json.

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.

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

reactphp-kernel                        shared backbone (loop, IPC, signals)
   ├─ laravel-ws                       WebSocket transport (RFC6455)
   └─ laravel-webrtc  (this)
         ├─ RelaySignalingHandler      browser P2P calls  (no media engine needed)
         └─ MediaEngine (optional)     server-terminated media
               └─ Str0mMediaEngine     Rust/str0m via ext-php-rs  (rust/)

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