VideohatiDocs
SDKs

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/laravel

Set credentials in .env:

VIDEOHATI_API_KEY=vh_live_...
VIDEOHATI_PROJECT_ID=01JZ9WV3N8GQ5T2M7K4C6XBARF

A 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-config

config/videohati.php reads these environment variables:

VariableDefaultWhat it sets
VIDEOHATI_API_KEYProject API key.
VIDEOHATI_BASE_URLhttps://api.videohati.comAPI origin.
VIDEOHATI_PROJECT_IDDefault project for the @videohatiPlayer directive.
VIDEOHATI_WEBHOOK_SECRETEndpoint secret for SignatureVerifier.
VIDEOHATI_PLAYER_URLhttps://cdn.videohati.com/player.jsPlayer bundle URL for the directive.
VIDEOHATI_TIMEOUT30HTTP 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>.

MethodSignatureWhat it does
listlist(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag)One page as Collection<VideoDto>.
listPagelistPage(?string $state, int $limit = 20, ?string $cursor, ?string $q, ?string $tag)The raw page array, including nextCursor.
getget(string $videoId)One VideoDto.
createcreate(string $originalFilename, ?string $idempotencyKey)Registers a video; returns its id. Step 1.
updateupdate(string $videoId, ?string $visibility, ?string $title, ?string $description, ?array $tags)Partial metadata update; returns the updated VideoDto.
deletedelete(string $videoId)Soft-deletes; aborts any active upload.
bulkbulk(array $videoIds, string $action, ?string $visibility)Applies one action to 1–100 ids; per-id results.
startUploadstartUpload(string $videoId, int $sizeBytes, string $contentType, ?string $checksumSha256, ?string $idempotencyKey)Opens a multipart upload; returns an UploadStartDto. Step 2.
completeUploadcompleteUpload(string $videoId, ?string $checksumSha256)Finalizes the upload. Step 3.
abortUploadabortUpload(string $videoId)Cancels an open upload.
uploadupload(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 delete

upload($filePath) runs POST /v1/videosPOST .../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.

ParameterWhat it does
$titleRenames the video. originalFilename never changes.
$descriptionFree text.
$tagsReplaces 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,
);
ParameterDefaultWhat it does
$videoIdrequiredThe video to play.
$ttlSecondsserver defaultSession lifetime.
$referrerAllowlistRestrict playback to these referrers.
$watermarkTextWatermark opt-in: text drawn onto the video. Also keys the per-viewer concurrency cap when no $viewerId is given.
$viewerIdYour 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.

MethodSignatureWhat it does
registerregister(string $projectId, array $params)Registers an endpoint. secret is returned once.
listlist(string $projectId)Lists endpoints.
getget(string $projectId, string $id)One endpoint.
updateupdate(string $projectId, string $id, array $params)Updates an endpoint.
deletedelete(string $projectId, string $id)Soft-deletes an endpoint.
rotateSecretrotateSecret(string $projectId, string $id)Mints a fresh secret (returned once).
reopenreopen(string $projectId, string $id)Clears a tripped circuit breaker.
sendTestsendTest(string $projectId, string $id)Enqueues a synthetic video.encoding.ready delivery.
listDeliverieslistDeliveries(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).

ClassStatusWhen
ConnectionExceptionThe request never reached the API (DNS, TLS, socket, or timeout).
ValidationException400The body or query failed validation.
AuthenticationException401Missing or invalid credential.
PermissionException403Valid credential, insufficient permission.
NotFoundException404Resource missing or not visible.
ConflictException409Conflicts with the resource's current state.
RateLimitException429Rate limit exceeded; read retryAfterSeconds.
ServerException5xxThe 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()
}