Search by

raqmeyat / webrtc

loai-K

WebRTC signaling, call-session lifecycle and ICE/TURN credential service for Laravel 12 and 13.

v0.1.0 2026-10-17 13:15 UTC

This package is auto-updated.

Last update: 2026-10-17 13:18:21 UTC


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:expire runs every minute (scheduler, onOneServer) and marks unanswered calls missed. Disable with webrtc.sessions.schedule_expiry.
  • Schedule model:prune to delete finished sessions after webrtc.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_participants at 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.