raqmeyat / webrtc
WebRTC signaling, call-session lifecycle and ICE/TURN credential service for Laravel 12 and 13.
Requires
- php: ^8.3|^8.4|^8.5
- ext-hash: *
- ext-json: *
- illuminate/auth: ^12.0|^13.0
- illuminate/broadcasting: ^12.0|^13.0
- illuminate/cache: ^12.0|^13.0
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/events: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/validation: ^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- pestphp/pest-plugin-laravel: ^3.0|^4.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
WebRTC signaling, call-session lifecycle and ICE/TURN credentials for Laravel 12 & 13 (PHP 8.4 & 8.5).
Laravel never touches media. This package does the server half of a call:
| Concern | What it does |
|---|---|
| ICE / TURN | Per-user, time-limited coturn credentials (TURN REST API), or a static STUN list |
| Call sessions | ringing → active → ended / declined / missed / cancelled, row-locked transitions, busy detection, ring timeout |
| Signaling | Validates and relays SDP offers/answers and ICE candidates to exactly one peer over a private broadcast channel (Reverb, Pusher, Ably...) |
| Late joiners | Short-lived signal buffer + pull endpoint, so a callee woken by a push notification still gets the offer |
| Mobile safety | X-Idempotency-Key replay on session mutations, per-call rate limit on signals, JSON:API shaped responses |
Install
composer require raqmeyat/webrtc # Publish configuration php artisan vendor:publish --tag=webrtc-config # Publish migrations (optional - package auto-loads them) php artisan vendor:publish --tag=webrtc-migrations # Publish routes (optional) php artisan vendor:publish --tag=webrtc-routes # Or publish all assets at once php artisan vendor:publish --tag=webrtc php artisan migrate
Requirements: a broadcaster (php artisan install:broadcasting for Reverb), a cache store with atomic locks (Redis recommended), model:prune scheduled if you want old calls removed.
Map the models that can take part in calls in config/webrtc.php:
'participant_types' => [ 'user' => App\Models\User::class, 'staff' => App\Models\Staff::class, ],
Keys must be strings (ULID/UUID, up to 36 chars). The alias (user, staff) is what clients send and what is stored.
Environment
WEBRTC_ROUTE_PREFIX=webrtc WEBRTC_ICE_DRIVER=coturn WEBRTC_STUN_URLS=stun:turn.example.com:3478 WEBRTC_TURN_URLS=turn:turn.example.com:3478?transport=udp,turns:turn.example.com:5349?transport=tcp WEBRTC_TURN_SECRET=<same value as coturn static-auth-secret> WEBRTC_TURN_TTL=3600 WEBRTC_RING_TIMEOUT=45
coturn needs use-auth-secret, static-auth-secret=<secret> and a realm. Never ship the secret to clients; they only receive derived, expiring credentials.
HTTP API
Default prefix webrtc, middleware ['api', 'auth:sanctum'] (configurable via config/webrtc.php or WEBRTC_ROUTE_PREFIX). Mutating session routes require X-Idempotency-Key.
| Method | Path | Purpose |
|---|---|---|
| GET | /webrtc/ice-servers |
RTCConfiguration.iceServers + ice_transport_policy, ttl, expires_at |
| POST | /webrtc/sessions |
{ "call_type": 1|2|3, "invitees": [{"type":"user","id":"..."}], "subject": {"type":"trip","id":"..."}? } |
| GET | /webrtc/sessions/{id} |
Session with participants |
| POST | /webrtc/sessions/{id}/accept · /decline · /end |
Lifecycle transitions |
| POST | /webrtc/sessions/{id}/signals |
{ "type": "offer|answer|candidate|rollback", "payload": {...}, "target"?: {...} } → 202 |
| GET | /webrtc/sessions/{id}/signals |
Buffered signals addressed to the caller |
call_type: 1 audio, 2 video, 3 data. Session status: 1 ringing, 2 active, 3 ended, 4 declined, 5 missed, 6 cancelled.
Errors: { "errors": [{ "status": "409", "code": "participant_busy", "detail": "..." }] }.
Real-time events
Each participant subscribes to one private channel, webrtc.inbox.{participantId}, and receives:
.webrtc.session→{ event: ringing|accepted|declined|left|ended|cancelled|missed, actor, session }.webrtc.signal→{ id, session_id, sender, target, type, payload, sent_at }
Authorize the channel through your normal /broadcasting/auth route (the package registers the channel callback; disable with webrtc.broadcasting.register_channels).
Client flow (browser)
const { data: ice } = await api.get('/webrtc/ice-servers'); const pc = new RTCPeerConnection({ iceServers: ice.data.ice_servers, iceTransportPolicy: ice.data.ice_transport_policy, }); const seen = new Set(); // de-dupe socket + pull deliveries by message id Echo.private(`webrtc.inbox.${me.id}`) .listen('.webrtc.signal', async (m) => { if (seen.has(m.id)) return; seen.add(m.id); if (m.type === 'offer') { await pc.setRemoteDescription(m.payload); /* createAnswer, setLocal, POST answer */ } if (m.type === 'answer') { await pc.setRemoteDescription(m.payload); } if (m.type === 'candidate') { await pc.addIceCandidate(m.payload); } }) .listen('.webrtc.session', (e) => updateCallUi(e.event, e.session)); // Caller const { data: s } = await api.post('/webrtc/sessions', { call_type: 2, invitees: [{ type: 'user', id: calleeId }] }, { headers: { 'X-Idempotency-Key': crypto.randomUUID() } }); pc.onicecandidate = (e) => send(s.data.id, 'candidate', e.candidate?.toJSON() ?? { candidate: '' }); await pc.setLocalDescription(await pc.createOffer()); await send(s.data.id, 'offer', { sdp: pc.localDescription.sdp });
Rules the server enforces: a callee must accept (HTTP) before it can send answer/candidate; signals only flow while the session is ringing/active; only whitelisted payload keys are relayed.
Extending
Who may call whom - implement Raqmeyat\WebRtc\Contracts\InvitationPolicy and set webrtc.invitation_policy:
use Raqmeyat\WebRtc\Contracts\InvitationPolicy; use Raqmeyat\WebRtc\DTOs\CreateSessionData; use Raqmeyat\WebRtc\DTOs\ParticipantRef; final class TripCallPolicy implements InvitationPolicy { public function allows(ParticipantRef $initiator, ParticipantRef $invitee, CreateSessionData $data): bool { return $data->subject?->type === 'trip' && Trip::query()->whereKey($data->subject->id)->involving($initiator, $invitee)->exists(); } }
Push notifications - listen to SessionStateChanged and send FCM/APNs when $event->event === SessionEvent::Ringing. The event fires after the DB commit.
Another ICE provider (Twilio, Cloudflare...):
use Raqmeyat\WebRtc\IceProviders\IceServerManager; app(IceServerManager::class)->extend('twilio', fn ($app) => new TwilioIceProvider(...));
Custom Models & Database Connection:
You can swap the session and participant models or point them to a dedicated database connection via config/webrtc.php:
'database' => [ 'connection' => env('WEBRTC_DB_CONNECTION'), ], 'models' => [ 'session' => App\Models\CustomWebRtcSession::class, 'participant' => App\Models\CustomWebRtcParticipant::class, ],
Architecture: Actions, DTOs, Services & Enums
The package adheres to strict layering: FormRequest → DTO → Action → Service → Model.
Actions
Single-purpose, invocable action classes that authorize operations and orchestrate business logic:
| Action | Description |
|---|---|
CreateSessionAction |
Initiates a new call session from CreateSessionData |
ShowSessionAction |
Authorizes and loads details for a session |
AcceptSessionAction |
Authorizes and accepts an incoming invitation |
DeclineSessionAction |
Authorizes and declines an incoming invitation |
EndSessionAction |
Authorizes and hangs up or cancels a session |
RelaySignalAction |
Authorizes and relays SDP/ICE signals using RelaySignalData |
PullSignalsAction |
Authorizes and retrieves buffered signals for a participant |
GetIceServersAction |
Resolves time-limited ICE/TURN configuration |
ExpireSessionsAction |
Expires ringing sessions past timeout (used by webrtc:expire) |
Example: Invoking Actions from your own code
use Raqmeyat\WebRtc\Actions\CreateSessionAction; use Raqmeyat\WebRtc\DTOs\CreateSessionData; use Raqmeyat\WebRtc\DTOs\ParticipantRef; use Raqmeyat\WebRtc\Enums\SessionType; $session = app(CreateSessionAction::class)->handle(new CreateSessionData( initiator: new ParticipantRef('user', (string) $authUserId), invitees: [new ParticipantRef('user', (string) $calleeId)], type: SessionType::Video, ));
Operations
webrtc:expireruns every minute (scheduler,onOneServer) and marks unanswered callsmissed. Disable withwebrtc.sessions.schedule_expiry.- Schedule
model:pruneto delete finished sessions afterwebrtc.sessions.retention_days. - The migrations only add two tables, safe for rolling deploys.
- Busy-checks use cache locks; with several app servers use Redis, not
file/array. - Group calls are a mesh (every pair negotiates). Keep
max_participantsat 4 or lower; use an SFU (LiveKit, mediasoup) beyond that.
Testing
composer install vendor/bin/pest
License
The MIT License (MIT). Please see LICENSE for more information.