Skip to content

DocsIntegrations

Laravel

A Laravel example that creates jobs from JSON, uploads from the browser, verifies webhooks and redirects downloads.

What you build#

An app that turns an uploaded file into an MP4, or into an MP4, a poster and a GIF preview in one job. The browser uploads straight to Videxo with a one-time URL. The server keeps the API key, creates and reads the job, verifies the webhook and redirects downloads to fresh signed links. Every call to Videxo sits in app/Services/Videxo.php, with plain HTTP.

These files are additions to a fresh Laravel 12 app, not a whole application skeleton.

The whole project: videxo-laravel.zip, or browse it in the examples repository (opens in a new tab).

Before you start#

  • An API key from the dashboard's API keys page, in VIDEXO_API_KEY.
  • The signing secret from the dashboard's Webhooks page, in VIDEXO_WEBHOOK_SECRET.
  • PHP 8.2 or newer, Composer and a fresh Laravel 12 app.

Both secrets stay in the server environment. These demo routes share one API key; add your app's authentication and per-user checks for uploads and jobs before exposing them to users.

Install#

Create an app with composer create-project laravel/laravel ../videxo-laravel '^12.0'. Inside it, run composer require standard-webhooks/standard-webhooks:^1.0.2. Copy the example's app/, requests/ and public/index.html into the app. Merge the routes into routes/web.php, replacing the welcome route. Merge this entry into the existing config/services.php array:

examples/laravel/config/services.php · plain HTTP
'videxo' => [
    'url' => env('VIDEXO_API_URL') ?: 'https://api.videxo.io',
    'key' => env('VIDEXO_API_KEY'),
    'webhook_secret' => env('VIDEXO_WEBHOOK_SECRET'),
    'webhook_url' => env('VIDEXO_WEBHOOK_URL'),
],

Create a job#

The JSON bodies are read from requests/transcode.json and requests/jobs.json at runtime. transcode makes one MP4; jobs makes an MP4, a poster and a GIF preview together. Only a completed upload's videxo://uploads/<id> is accepted. The key is transcode-<upload id> or jobs-<upload id>, so retrying the same upload and kind within 24 hours returns the same job:

examples/laravel/app/Services/Videxo.php · plain HTTP
public function createJob(string $kind, string $input): array
{
    if (!preg_match('~^videxo://uploads/(up_[a-f0-9]{24})$~D', $input, $match)) {
        throw new VidexoProblem([
            'type' => 'about:blank', 'title' => 'The request is not valid.', 'status' => 400,
            'detail' => 'input_url must be the input_url of an upload.', 'code' => 'invalid_request',
        ]);
    }
    $kind = $kind === 'jobs' ? 'jobs' : 'transcode';
    $body = json_decode(file_get_contents(base_path("requests/$kind.json")), true, 512, JSON_THROW_ON_ERROR);
    if ($kind === 'jobs') $body['inputs'] = ['talk' => $input];
    else $body['input'] = $input;
    if ($url = config('services.videxo.webhook_url')) $body['webhook_url'] = $url;
    return $this->request('/v1/'.$kind, 'POST', $body, "$kind-{$match[1]}");
}

The route returns the created Job with 202, so the page can follow it:

examples/laravel/app/Http/Controllers/VidexoController.php · plain HTTP
public function create(Request $request): Response
{
    return $this->respond(function () use ($request) {
        $kind = $request->input('kind') === 'jobs' ? 'jobs' : 'transcode';
        $input = $request->input('input_url');
        // The page needs the Job now. CreateVidexoJob is the background variant for your own code.
        $job = $this->videxo->createJob($kind, is_string($input) ? $input : '');
        return response()->json($job, 202);
    });
}

For a browser request the controller calls the service directly. For your own background work, use CreateVidexoJob::dispatch('transcode', $inputUrl) (or jobs) and run php artisan queue:work. The queued job creates the same Videxo Job on a retry and logs its id; replace that log with saving the id on your order. A queued dispatch does not return the Videxo Job to the page.

examples/laravel/app/Jobs/CreateVidexoJob.php · plain HTTP
final class CreateVidexoJob implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public int $backoff = 30;

    public function __construct(public string $kind, public string $input) {}

    public function handle(Videxo $videxo): void
    {
        // The service derives the same key from the upload on each retry.
        $job = $videxo->createJob($this->kind, $this->input);
        // Your code: store this id on the order so your webhook can update it later.
        Log::info('Videxo job created', ['job_id' => $job['id']]);
    }
}

Every API call uses one helper. API errors become an exception carrying the original problem details:

examples/laravel/app/Services/Videxo.php · plain HTTP
private function request(string $path, string $method = 'GET', ?array $body = null, ?string $key = null): array|string
{
    $apiKey = config('services.videxo.key') ?: throw new RuntimeException('Set VIDEXO_API_KEY on the server.');
    $http = Http::baseUrl(rtrim(config('services.videxo.url'), '/'))
        ->withToken($apiKey)->acceptJson()->withoutRedirecting()->connectTimeout(5)->timeout(30);
    if ($key !== null) $http = $http->withHeaders(['Idempotency-Key' => $key]);
    $response = $http->send($method, $path, $body === null ? [] : ['json' => $body]);
    if ($response->failed()) {
        $problem = $response->json();
        throw new VidexoProblem(is_array($problem) && isset($problem['code']) ? $problem : [
            'type' => 'about:blank', 'title' => 'The API answered with an error.',
            'status' => $response->status(), 'detail' => 'HTTP '.$response->status(), 'code' => 'http_error',
        ]);
    }
    if ($response->redirect()) return $response->header('Location') ?: throw new RuntimeException('The API returned no download link.');
    return $response->json();
}

The error handler keeps the status and application/problem+json, including field errors, so the shared page can show them:

examples/laravel/app/Http/Controllers/VidexoController.php · plain HTTP
private function respond(callable $route): Response
{
    try {
        return $route();
    } catch (VidexoProblem $error) {
        return response()->json($error->problem, $error->problem['status'], ['Content-Type' => 'application/problem+json']);
    } catch (Throwable $error) {
        report($error);
        return response()->json(['title' => 'Something went wrong.', 'status' => 500], 500, ['Content-Type' => 'application/problem+json']);
    }
}

Receive the webhook#

Videxo sends a signed POST when the job ends (Webhooks). Verify the raw body exactly as it arrived. The library checks the signature and rejects timestamps more than five minutes away:

examples/laravel/app/Services/Videxo.php · plain HTTP
public function verifyWebhook(string $raw, array $headers): array
{
    $secret = config('services.videxo.webhook_secret') ?: throw new RuntimeException('Set VIDEXO_WEBHOOK_SECRET on the server.');
    // Composer 1.0.2 can throw a runtime error for a signature entry without a comma.
    // Accept the space-separated signatures used during secret rotation too.
    $signatures = $headers['webhook-signature'] ?? '';
    if (!is_string($signatures) || !preg_match('~\Av[0-9]+,[A-Za-z0-9+/]+={0,2}(?: v[0-9]+,[A-Za-z0-9+/]+={0,2})*\z~', $signatures)) {
        throw new WebhookVerificationException('Invalid signature header.');
    }
    return (new Webhook($secret))->verify($raw, $headers);
}
examples/laravel/app/Http/Controllers/VidexoController.php · plain HTTP
public function webhook(Request $request): Response
{
    return $this->respond(function () use ($request) {
        $max = 2_097_152;
        if ((float) $request->header('Content-Length', '0') > $max) return response('', 413);
        // Bootstrap skips JSON transformations here so nothing eagerly reads the full body.
        $stream = $request->getContent(true);
        if (!is_resource($stream)) throw new \RuntimeException('Could not read the webhook.');
        try {
            $raw = stream_get_contents($stream, $max + 1);
        } finally {
            fclose($stream);
        }
        if ($raw === false) throw new \RuntimeException('Could not read the webhook.');
        if (strlen($raw) > $max) return response('', 413);
        try {
            $event = $this->videxo->verifyWebhook($raw, [
                'webhook-id' => $request->header('webhook-id'),
                'webhook-timestamp' => $request->header('webhook-timestamp'),
                'webhook-signature' => $request->header('webhook-signature'),
            ]);
        } catch (WebhookVerificationException) {
            return response('', 400);
        }
        // Explicit file store: works on a fresh app, even without a cache database migration.
        // In your app use a unique webhook-id column in the business update's transaction.
        $cache = Cache::store('file');
        $key = 'videxo-webhook:'.$request->header('webhook-id');
        $cache->lock($key.':lock', 30)->block(5, function () use ($cache, $key, $event) {
            if ($cache->has($key)) return;
            // Your code: mark the order done or enqueue slow work. Log only ids.
            Log::info('Videxo job ended', ['type' => $event['type'], 'job_id' => $event['data']['id']]);
            $cache->forever($key, true);
        });
        return response('', 204);
    });
}

The Composer library 1.0.2 can throw for a malformed signature header, so the helper checks the signature-list format first and responds with 400, including for v1 or garbage.

A delivery can arrive more than once with the same webhook-id. The single-server demo remembers handled ids under a lock and responds with 204. In your app, use a unique column in the same transaction as the business update. Keep slow work in your own queue so the response arrives within seconds.

The handler uses the file cache explicitly. It needs no cache table on a fresh app, but clearing the cache forgets those ids. Merge this call into the existing withMiddleware callback in bootstrap/app.php, preserving the rest of that file:

examples/laravel/bootstrap/app.php · plain HTTP
// The identical static demo page sends no session CSRF token. Exempt only these demo POSTs
// and the signed webhook. In a signed-in app protect the browser routes with auth and CSRF.
$middleware->validateCsrfTokens(except: ['api/uploads', 'api/jobs', 'webhooks/videxo']);
// Avoid eager JSON reads before the webhook controller applies its bounded raw-body limit.
$middleware->trimStrings(except: [fn ($request) => $request->is('webhooks/videxo')]);
$middleware->convertEmptyStringsToNull(except: [fn ($request) => $request->is('webhooks/videxo')]);

The webhook also skips JSON transformations so they cannot read its body before the size check. Only the signed webhook and the two demo POST routes skip CSRF. The static demo page sends no session token. In a signed-in application, use authentication and CSRF on the browser routes.

The route rejects bodies over 2 MiB (2,097,152 bytes) with 413, checking Content-Length first and otherwise reading at most 2 MiB plus one byte before verification. PHP's post_max_size and any reverse proxy limit also apply; keep them at least 2M (PHP defaults to 8M).

Upload a file#

The server creates a one-time upload URL and gives the browser only that URL and the input URL:

examples/laravel/app/Services/Videxo.php · plain HTTP
public function createUpload(array $file): array
{
    return $this->request('/v1/uploads', 'POST', $file);
}
examples/laravel/app/Http/Controllers/VidexoController.php · plain HTTP
public function upload(Request $request): Response
{
    return $this->respond(function () use ($request) {
        $upload = $this->videxo->createUpload([
            'filename' => $request->input('filename'),
            'size_bytes' => $request->input('size_bytes'),
            'content_type' => $request->input('content_type'),
        ]);
        return response()->json(['input_url' => $upload['input_url'], 'upload_url' => $upload['upload_url']], 201);
    });
}

The browser then sends the file with PUT to upload_url, with no API key, and creates the job with input_url. See Files for limits and expiry.

Download the outputs#

For each click, ask GET /v1/jobs/{id}/outputs/{alias} for a fresh link without following the redirect in the HTTP client. Then redirect the browser to its Location:

examples/laravel/app/Services/Videxo.php · plain HTTP
public function outputUrl(string $id, string $alias): string
{
    return $this->request('/v1/jobs/'.rawurlencode($id).'/outputs/'.rawurlencode($alias));
}
examples/laravel/app/Http/Controllers/VidexoController.php · plain HTTP
public function download(string $id, string $alias): Response
{
    return $this->respond(fn () => redirect()->away($this->videxo->outputUrl($id, $alias), 302));
}

A job's outputs[].download_url is also a signed link. It expires at download_url_expires_at, within an hour.

Run it#

Append the names from .env.example to the Laravel app's existing .env, preserving its other values. Fill in the secrets. Set PORT to a free local port and run php artisan serve --host=127.0.0.1 --port="$PORT". Open http://127.0.0.1:$PORT. No frontend build or queue worker is needed for the demo page; the README gives the exact copy and merge steps.

Open the page, pick a file and choose the job. The page follows the job every 2 seconds and lists its outputs as download links. VIDEXO_API_URL defaults to https://api.videxo.io; set it to your local API's origin when using the local stack.

Webhooks need a public https URL. Tunnel this app's port, set VIDEXO_WEBHOOK_URL to the tunnel address plus /webhooks/videxo, and restart the app. Without it the page still follows the job by polling. Or set your account's default endpoint on the Webhooks page.

Next steps#

  • Operations and Presets: more JSON job options.
  • Webhooks: events, retries and delivery logs.
  • Errors: problem codes and field errors.
  • app/Services/Videxo.php is the one file to change when replacing the HTTP client.