joos/open-tok-bundle
Symfony bundle integrating the OpenTok (TokBox/Vonage Video) PHP SDK, providing services to create video sessions and generate client tokens.
First, calibrate expectations: this is a Symfony2 bundle, not a Laravel package. There is no ServiceProvider, no facade, no config/*.php, no publishable assets. composer require joos/open-tok-bundle in a Laravel app will either fail resolution (it pins the legacy opentok/opentok-php-sdk, which drags Guzzle 3/5 against your Guzzle 7) or pull you into a dependency-downgrade project. Treat the repo as reference material, not a dependency.
What to read in the repo, in order — it's small:
DependencyInjection/JoosOpenTokExtension.php — the config keys (api_key, api_secret) and how the SDK client is constructed. This is the entire "logic" of the package.Resources/config/services.yml — the service id it registers and its constructor arguments. This tells you the exact shape of the object you're injecting.composer.json — the pinned SDK and PHP constraints. This is where you confirm it's unusable on PHP 8+/Laravel 10+.JoosOpenTokBundle.php — boilerplate, skim it.Total reading time: fifteen minutes. That is the honest scope of the package.
What you actually build instead. The bundle's whole value proposition — "credentials from config, client as an injectable service" — is ~100 lines of Laravel glue. The direct successor SDK is vonage/client (TokBox was acquired by Vonage in 2020; OpenTok is now Vonage Video API; opentok/opentok-php-sdk is deprecated).
composer require vonage/client
// config/video.php
return [
'application_id' => env('VONAGE_APPLICATION_ID'),
'private_key' => env('VONAGE_PRIVATE_KEY'), // path or raw JWT key
'default_ttl' => (int) env('VIDEO_TOKEN_TTL', 3600),
];
// app/Providers/VideoServiceProvider.php
public function register(): void
{
$this->app->singleton(VideoSessionProvider::class, fn ($app) => new VonageVideoSessionProvider(
$app['config']->get('video'),
));
}
First use case to get working end-to-end — this is the one the bundle exists to enable, and it is the whole MVP:
POST /api/sessions → server creates a session, returns sessionId.POST /api/sessions/{id}/token → server mints a per-participant token bound to that session, returns it.applicationId + sessionId + token and connects.That round trip proves credentials, networking, and the SDK wiring. Do it in a throwaway branch or a php artisan tinker scratchpad before writing any controllers.
Do not start by writing the Vue/React component. The client SDK is the easy part; the server lifecycle and webhook handling are where time disappears.
The bundle's implicit lesson is that the vendor call surface is tiny. Encode that behind your own contract so a Vonage price change or a Twilio/LiveKit/Daily evaluation is a new class, not a rewrite of every call site.
interface VideoSessionProvider
{
public function createSession(array $opts = []): SessionRef; // mediaMode, archiveMode
public function issueToken(SessionRef $s, array $opts = []): Token; // role, expireTime, data
public function startArchive(SessionRef $s, array $opts = []): ArchiveRef;
public function stopArchive(ArchiveRef $a): void;
}
Bind VonageVideoSessionProvider in the provider above. Feature code (ConsultationController, TutoringSession) depends only on the interface.
For most product flows you don't need two endpoints. A join endpoint that lazily creates the session (first caller) and mints a token (every caller) is the cleanest shape:
public function join(Meeting $meeting, VideoSessionProvider $video): JsonResponse
{
if (! $meeting->video_session_id) {
$session = $video->createSession([
'mediaMode' => 'routed', // 'relayed' for 1:1, 'routed' for 3+
'archiveMode' => 'manual', // 'always' if every call must be recorded
]);
$meeting->update(['video_session_id' => $session->id]);
}
$token = $video->issueToken($meeting->sessionRef(), [
'role' => $this->roleFor($meeting, auth()->user()), // publisher|subscriber|moderator
'expireTime' => now()->addHour()->timestamp,
'data' => json_encode(['user_id' => auth()->id(), 'name' => auth()->user()->name]),
]);
return response()->json([
'applicationId' => config('video.application_id'),
'sessionId' => $meeting->video_session_id,
'token' => (string) $token,
]);
}
Store the session ID on your domain model (meetings.video_session_id) rather than in cache — it's durable state you'll need for archiving, moderation, and support tickets.
role is where authorization lives. Map your domain roles to Vonage roles explicitly, and never let the client request a role:
subscriber — observer / auditor / supervisor (receives streams, cannot publish).publisher — normal participant.moderator — can force-disconnect, kick, and (with archiveMode) trigger recording.Put this in a small VideoRolePolicy class. It's the one piece of "business logic" in the whole integration, and the bundle gives you nothing here.
Session/stream/archive lifecycle events arrive out of band. This is where real operational weight sits.
Route::post('/webhooks/vonage', VonageWebhookController::class)
->withoutMiddleware(['csrf']);
public function __invoke(Request $request)
{
abort_unless($this->verifySignature($request), 403);
$payload = $request->json()->all();
// Dedupe before dispatch — vendor retries aggressively.
if (! Cache::add('vonage:event:'.$payload['id'], true, now()->addDay())) {
return response()->noContent(); // 204 is fine; they stop retrying
}
match ($payload['type']) {
'session.created' => SessionCreated::dispatch($payload),
'archive.updated' => ArchiveUpdated::dispatch($payload),
'connection.created' => ParticipantJoined::dispatch($payload),
default => null,
};
return response()->noContent(); // fast 200/204, queue the work
}
Rules that matter: verify the signature, dedupe by event ID, respond in <1s, do the work in a queued job, and make every handler idempotent. Never call the vendor API synchronously inside a webhook handler — you'll stack timeouts during incident storms.
archiveMode: 'always' is seductive and expensive. Prefer 'manual' plus an explicit start/stop:
$archive = $video->startArchive($session, ['name' => "meeting-{$meeting->id}", 'hasAudio' => true, 'hasVideo' => true]);
$meeting->update(['archive_id' => $archive->id, 'archive_status' => 'started']);
Then reconcile archive_status from the archive.updated webhook (started → stopped → uploaded → available), not from the return value of stopArchive(). The stop call returns before the composite is ready; only the webhook tells you when there's a URL you can hand to a user.
Vonage requires HTTPS and a real network path for media. php artisan serve on http://localhost will connect but you'll fight device permissions and cross-origin issues. Use valet share / ngrok / expose and test from two physical devices or two browser profiles — not two tabs on the same machine with the same mic.
The framework mismatch is fatal, not cosmetic. Don't try to bridge Symfony's container into Laravel to "reuse" the bundle. A Symfony DI container inside Laravel is more code than the glue you'd be avoiding, and you'd own the bridge forever. Port the shape; don't adopt the code.
Guzzle is the landmine. The legacy opentok/opentok-php-sdk pins Guzzle 3/5. If you have any package needing Guzzle 7 (Laravel HTTP client, Flysystem S3, Stripe, AWS SDK, most mail transports), Composer will either refuse or force a downgrade that silently breaks unrelated features. Run composer require vonage/client --dry-run against your real composer.lock before committing to anything.
The vendor was renamed, twice. TokBox → Vonage (2020) → OpenTok is "Vonage Video API." Class names, config keys, and docs all shifted. Search results will mix eras; the opentok SDK examples you find on Stack Overflow mostly won't run against vonage/client. Budget one migration every 18–36 months on this vendor line — the rename is the precedent.
Credentials are asymmetric now. The old bundle used api_key + api_secret. The current API is application_id + a private key used to sign a short-lived JWT. Do not commit the key; inject it from a secrets manager. Rotate it on a schedule and make sure your adapter reads it at resolution time, not at boot, so rotations don't require a deploy.
Never ship the secret to the client. Only applicationId, sessionId, and a per-user token go over the wire. A leaked project secret lets anyone mint moderator tokens for any session in your account. This is the single highest-severity mistake in the integration.
Token expiry vs. session lifetime. Tokens are bearer credentials with an expireTime. Set it to call-duration-plus-buffer (60–90 min is typical), not 24 hours. Expired tokens fail silently client-side — the user sees a spinner, not an error, and your logs show nothing. Log token issuance with user ID, session ID, and expiry server-side so support can diagnose "can't join" tickets.
data is a string, not an array. The token metadata field is opaque and length-limited; json_encode it yourself and parse client-side. Don't put anything in it you wouldn't put in a URL — it's readable by the client.
Session ID ≠ token ≠ archive ID. Three distinct identifiers with different lifetimes and scopes. Name your columns explicitly (video_session_id, video_archive_id) — the inevitable bug is passing a session ID where a token is expected, which fails as an unhelpful generic connection error.
mediaMode: relayed is cheaper and 1:1-only. It falls back or breaks at 3+ participants. Pick routed up front if group calls are on the roadmap — switching later means the session ID changes and in-flight meetings drop.
Webhooks retry; your handler must be idempotent. Vonage retries on non-2xx and even on slow 2xx. Dedupe by event ID in cache/Redis, and make handlers safe to run twice. This is the number-one source of "duplicate participant" and "archive started twice" bugs.
Don't hold the vendor client in a request-scoped binding if you queue. The JWT is time-bound; a long-lived singleton in a queue worker will eventually sign with an expired token. Construct per job, or make the adapter re-sign on each call.
Extension points are yours to define. The bundle offers no event system, no logging, no retry policy. Wrap every vendor call in a small decorator that logs latency and failure to your existing observability stack — vendor API errors are opaque and you will need your own breadcrumbs:
class LoggingVideoProvider implements VideoSessionProvider { /* delegate + measure */ }
Test with fakes, not the network. Because you own the interface, bind a FakeVideoSessionProvider in tests that returns deterministic session/token strings. Your test suite should never touch Vonage. Keep one nightly/smoke test against the real API to catch SDK drift.
Watch the commercial limits, not the code. Concurrent-session caps, participant-minute pricing, and archive storage are plan-level constraints the SDK won't surface until you hit them. Check the account tier before launch, and log session duration server-side so you can reconcile usage against the invoice — that's the number that will surprise you.
How can I help you explore Laravel packages today?