Laravel SDK
Official videohati/laravel SDK — install, configure, upload video, create playback sessions, verify webhooks, and embed the player from a Laravel app.
videohati/laravel is the official PHP SDK for Laravel. The service provider
and Videohati facade auto-register, TLS certificate verification is always on,
and exceptions never contain your key. PHP 8.2+, Laravel 10 or 11. Version
1.0.0-rc.0.
Install
composer require videohati/laravelSet credentials in .env:
VIDEOHATI_API_KEY=vh_live_...
VIDEOHATI_PROJECT_ID=01JZ9WV3N8GQ5T2M7K4C6XBARFA vh_test_... key runs against test data; the mode always follows the key
prefix.
Configure
Publish the config file to change defaults:
php artisan vendor:publish --tag=videohati-configconfig/videohati.php reads these environment variables:
| Variable | Default | What it sets |
|---|---|---|
VIDEOHATI_API_KEY | — | Project API key. |
VIDEOHATI_BASE_URL | https://api.videohati.com | API origin. |
VIDEOHATI_PROJECT_ID | — | Default project for the @videohatiPlayer directive. |
VIDEOHATI_WEBHOOK_SECRET | — | Endpoint secret for SignatureVerifier. |
VIDEOHATI_PLAYER_URL | https://cdn.videohati.com/player.js | Player bundle URL for the directive. |
VIDEOHATI_TIMEOUT | 30 | HTTP timeout in seconds. |
Facade and dependency injection
Reach the client through the Videohati facade or by injecting
Videohati\Laravel\Client.
use Videohati\Laravel\Facades\Videohati;
$videos = Videohati::videos()->list(state: 'ready');use Videohati\Laravel\Client;
class LessonController
{
public function __construct(private readonly Client $videohati) {}
public function show(string $videoId)
{
$video = $this->videohati->videos()->get($videoId);
// ...
}
}The client exposes videos(), playback(), webhookEndpoints(), and
webhooks() (the signature verifier).
Videos
videos()->list() returns a Laravel Collection<VideoDto>.
| Method | Signature | What it does |
|---|---|---|
list | list(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag) | One page as Collection<VideoDto>. |
listPage | listPage(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag) | The raw page array, including nextCursor. |
get | get(string $videoId) | One VideoDto. |
create | create(string $originalFilename, ?string $idempotencyKey) | Registers a video; returns its id. Step 1. |
update | update(string $videoId, ?string $visibility, ?string $title, ?string $description, ?array $tags) | Partial metadata update; returns the updated VideoDto. |
delete | delete(string $videoId) | Soft-deletes; aborts any active upload. |
bulk | bulk(array $videoIds, string $action, ?string $visibility) | Applies one action to 1–100 ids; per-id results. |
startUpload | startUpload(string $videoId, int $sizeBytes, string $contentType, ?string $checksumSha256, ?string $idempotencyKey) | Opens a multipart upload; returns an UploadStartDto. Step 2. |
completeUpload | completeUpload(string $videoId, ?string $checksumSha256) | Finalizes the upload. Step 3. |
abortUpload | abortUpload(string $videoId) | Cancels an open upload. |
upload | upload(string $filePath, ?string $contentType, ?string $idempotencyKey, ?callable $onProgress, ?GuzzleClient $partHttp) | Runs the full flow in one call. |
use Videohati\Laravel\Facades\Videohati;
$videos = Videohati::videos()->list(state: 'ready', limit: 20);
$videos->each(fn ($video) => logger($video->id . ' ' . $video->state));
$video = Videohati::videos()->get($videoId); // VideoDto
Videohati::videos()->delete($videoId); // soft deleteupload($filePath) runs POST /v1/videos → POST .../upload/start →
sequential part uploads (3 attempts each; 5xx/429 retried, 4xx fail fast) →
POST .../upload/complete. The progress callback receives bytes uploaded and
the total. $partHttp overrides the Guzzle client used for the part uploads
(tests, proxies); the API calls always go through the configured client.
$result = Videohati::videos()->upload(
storage_path('app/lecture-01.mp4'),
onProgress: fn (int $uploaded, int $total) => logger("{$uploaded}/{$total}"),
);VideoDto carries id, state, title, description, tags,
originalFilename, sizeBytes, contentType, checksumSha256, errorCode,
errorMessage, thumbnailUrl, thumbnailSource, visibility,
durationSeconds, readyAt, failedAt, createdAt, updatedAt, and — on
detail responses — upload and encoding. UploadStartDto adds
partUrl($partNumber) to build each part's upload URL.
What a retry does and does not do
Pass $idempotencyKey and both writes that create server state — the video and
the upload session — become safe to repeat. Call upload() again after a failure
and you get the same video id and the same upload session back, even when
the first attempt's response was lost in transit after the server had already
committed it. Without a key, that lost response is indistinguishable from a
request that never arrived, and the retry creates a second video.
Three limits worth reading before you rely on it:
- The key must survive whatever you are recovering from. Retry safety across
a process restart holds only if the value itself comes back the same after the
restart, so derive it from durable state — a queued-job id, a database row id.
A value from
rand(),time(), or a request-scoped counter gives you no protection at all, because the retry sends a different key. - Nothing about the byte transfer resumes. A retry re-sends every part from the beginning. The guarantee is about not duplicating the video, not about saving bandwidth.
- One key belongs to one file. The SDK binds the key to the file's basename and size, so reusing a key for a different file starts a genuinely new upload instead of returning the first file's video. Even so, prefer a fresh key per file.
// The key comes from the job row, so a retried job reuses it.
$result = Videohati::videos()->upload(
storage_path("app/{$this->job->path}"),
idempotencyKey: $this->job->getKey(),
);Retrying a create on its own
create() takes the same $idempotencyKey, if you drive the three upload steps
yourself. Matching is first-write-wins on (project, $idempotencyKey) and
the key is the whole identity — $originalFilename is never part of it. A repeat
of a committed call returns the first call's video id.
Reuse one key with a different file and you get the first call's video back, under its first filename; your second file is never uploaded, and the result is just an id, with nothing in it to reveal the substitution. Mint a fresh key per file — a UUID is the intended shape — including when re-uploading a file you sent before.
$videoId = Videohati::videos()->create('lecture-01.mp4', (string) Str::uuid());Search and filter
$q is a case-insensitive literal substring match over title and
originalFilename — it is not a fuzzy or full-text search, and it does not match
description or tags. $tag returns only videos carrying exactly that tag. Use
listPage() when you need nextCursor for manual paging.
$page = Videohati::videos()->listPage(
state: 'ready',
limit: 50,
q: 'lecture 01',
tag: 'arabic-101',
);Update metadata
update() is a partial update: pass any subset of $visibility, $title,
$description, and $tags, and at least one of them.
| Parameter | What it does |
|---|---|
$title | Renames the video. originalFilename never changes. |
$description | Free text. |
$tags | Replaces the whole list (at most 20 tags). |
$visibility | 'private', 'unlisted', or 'public'. unlisted/public enable the anonymous embed surface — hosted watch page, iframe, oEmbed. |
$video = Videohati::videos()->update(
$videoId,
visibility: 'unlisted',
title: 'Lecture 01 — Introduction',
tags: ['arabic-101', 'semester-1'],
);Nulls are dropped from the request, so this SDK cannot clear the description —
description: null leaves the field untouched rather than emptying it. Send an
empty string instead.
Bulk actions
bulk() applies one action to 1–100 video ids: 'delete' or
'set_visibility' (which needs $visibility). delete behaves exactly like
delete() per id.
The response is 200 even when some ids fail, so inspect each entry's ok flag —
the returned array has one entry per unique requested id, in request order, and a
failed entry carries error.code and error.message.
$results = Videohati::videos()->bulk(
videoIds: [$videoId, $otherVideoId],
action: 'set_visibility',
visibility: 'private',
);
foreach ($results as $result) {
if (! $result['ok']) {
logger()->error($result['videoId'] . ' ' . $result['error']['code']);
}
}Create a playback session
Never ship an API key to a browser. Create the session server-side and emit only the session token into the page.
playback()->createSession() returns a PlaybackSessionDto with sessionId,
manifestUrl, source, sessionToken, watermarkToken, traceId,
watermarkTiled, expiresAt, heartbeatIntervalSeconds, and showFreeBadge.
$session = Videohati::playback()->createSession(
videoId: $video->id,
watermarkText: $user->name,
);| Parameter | Default | What it does |
|---|---|---|
$videoId | required | The video to play. |
$ttlSeconds | server default | Session lifetime. |
$referrerAllowlist | — | Restrict playback to these referrers. |
$watermarkText | — | Watermark opt-in: text drawn onto the video. Also keys the per-viewer concurrency cap when no $viewerId is given. |
$viewerId | — | Your own end-user identifier. Keys the per-viewer concurrency cap without drawing anything on the video. |
The full signature is createSession(string $videoId, ?int $ttlSeconds, ?array $referrerAllowlist, ?string $watermarkText, ?string $viewerId).
Per-viewer concurrency without a watermark
The concurrent-session cap is counted per viewer, and viewer identity is resolved
in this order: $viewerId, then $watermarkText, then a hash of the client IP.
Without either field, every end-user behind one API key falls back to the IP hash
— which collapses a whole shared network into one shared cap.
Pass $viewerId to fix that while keeping playback clean: it identifies the
viewer for the cap only. It is never stored — only its hash reaches the session —
and it renders nothing. The watermark appears if and only if you set
$watermarkText, so $viewerId on its own never turns one on.
// Independent caps per end-user, no overlay on the video.
$session = Videohati::playback()->createSession(
videoId: $video->id,
viewerId: (string) $user->getKey(),
);
// Both, when you want an identified viewer AND a visible watermark.
$identified = Videohati::playback()->createSession(
videoId: $video->id,
watermarkText: $user->name,
viewerId: (string) $user->getKey(),
);Embed the player
The @videohatiPlayer Blade directive emits the embed snippet — the player
<script> plus the data-videohati-* div.
{{-- resources/views/lesson.blade.php --}}
@videohatiPlayer(['videoId' => $video->id, 'sessionToken' => $session->sessionToken])projectId defaults to VIDEOHATI_PROJECT_ID. Optional keys: projectId,
autoplay ('muted' or true), width, height, lang ('ar' / 'en'),
viewerText, apiOrigin, playerUrl. See the Player page
for the attributes and events.
Webhook endpoints
webhookEndpoints() manages endpoints and reads deliveries. Each method returns
the decoded JSON body as an array.
| Method | Signature | What it does |
|---|---|---|
register | register(string $projectId, array $params) | Registers an endpoint. secret is returned once. |
list | list(string $projectId) | Lists endpoints. |
get | get(string $projectId, string $id) | One endpoint. |
update | update(string $projectId, string $id, array $params) | Updates an endpoint. |
delete | delete(string $projectId, string $id) | Soft-deletes an endpoint. |
rotateSecret | rotateSecret(string $projectId, string $id) | Mints a fresh secret (returned once). |
reopen | reopen(string $projectId, string $id) | Clears a tripped circuit breaker. |
sendTest | sendTest(string $projectId, string $id) | Enqueues a synthetic video.encoding.ready delivery. |
listDeliveries | listDeliveries(string $projectId, string $id, array $query = []) | One page of delivery attempts. |
Verify webhooks
webhooks() returns a SignatureVerifier. Pass the raw request body — not
re-serialized JSON. Verify in a controller or route, or wrap it in middleware.
use Illuminate\Http\Request;
use Videohati\Laravel\Webhooks\SignatureVerifier;
Route::post('/webhooks/videohati', function (Request $request) {
$event = SignatureVerifier::verifyAndParse(
$request->getContent(), // the raw body
$request->header(SignatureVerifier::HEADER, ''),
config('videohati.webhook_secret'),
);
if ($event === null) {
abort(400); // invalid signature
}
if ($event['type'] === 'video.encoding.ready') {
// React to the ready video.
}
return response()->noContent(); // 204
});SignatureVerifier::verify(...) returns a bool when you only need the check;
verifyAndParse(...) returns the decoded envelope array or null. The header
is Videohati-Signature: t=<unix_ts>,v1=<hex_sha256> (HMAC-SHA256 over
<unix_ts>.<raw_body>); comparison is constant-time and timestamps more than 300
seconds old are rejected.
Errors
Every non-2xx response throws a typed subclass of
Videohati\Laravel\Exceptions\VideohatiException, which carries status and
apiCode (the API's machine code, for example video_not_found).
| Class | Status | When |
|---|---|---|
ConnectionException | — | The request never reached the API (DNS, TLS, socket, or timeout). |
ValidationException | 400 | The body or query failed validation. |
AuthenticationException | 401 | Missing or invalid credential. |
PermissionException | 403 | Valid credential, insufficient permission. |
NotFoundException | 404 | Resource missing or not visible. |
ConflictException | 409 | Conflicts with the resource's current state. |
RateLimitException | 429 | Rate limit exceeded; read retryAfterSeconds. |
ServerException | 5xx | The API failed to complete the request. |
use Videohati\Laravel\Exceptions\NotFoundException;
use Videohati\Laravel\Exceptions\RateLimitException;
use Videohati\Laravel\Exceptions\VideohatiException;
try {
$video = Videohati::videos()->get($videoId);
} catch (NotFoundException) {
abort(404);
} catch (RateLimitException $e) {
sleep($e->retryAfterSeconds ?? 1);
} catch (VideohatiException $e) {
report($e); // $e->status, $e->apiCode, $e->getMessage()
}React SDK
Official @videohati/react SDK — the VideohatiPlayer component plus upload, metadata, and webhook-delivery hooks for React 18+ and Next.js.
Player
Official @videohati/player — the framework-free web player. Embed it with a script tag or install it from npm, then drive it with a small imperative API.