Add video upload and playback to Laravel

Use FastPix SDKs to add resumable uploads and adaptive-bitrate playback to a Laravel application with Inertia and React.

Uploading video is different from uploading other files. Large uploads need to resume after interruptions, and media must be processed into streaming formats before it can be played reliably across devices.

FastPix handles uploads, processing, and adaptive streaming. Your application only needs to create a signed upload URL on the server and render a player. Uploads go directly from the browser to FastPix, so video data never passes through your Laravel application.

This guide uses Inertia with React, as provided by Laravel’s React starter kit, because the uploader is a React component. The FastPix Player is a web component and does not require React. If your application uses Blade instead of React, see the note at the end of the playback section.


How it works

The upload workflow is straightforward:

  1. Your Laravel application creates a signed upload URL using the FastPix API.
  2. The uploader uploads the selected file directly to FastPix using the signed URL.
  3. FastPix processes the uploaded media asynchronously.
  4. FastPix sends a webhook event when the media is ready for playback.
  5. Your application retrieves the playback ID and plays the video.

What you’ll build

By the end of this guide, you’ll have:

  • A Laravel application that uploads videos directly to FastPix.
  • Resumable uploads with upload progress and retry support.
  • A FastPix Player that streams the uploaded video.
  • A workflow that waits for media processing before playback.

Before you begin


Install

From your project directory:

$cd your-laravel-project
$composer require fastpix/sdk
$npm install @fastpix/fp-react-uploader @fastpix/fp-player

The PHP SDK talks to the FastPix API from your server, the React Uploader is the browser-side upload UI, and the Web Player plays the result.

Laravel’s React starter kit already ships with Inertia on both sides. If your application was not created from the starter kit, add the Inertia server and client adapters as well:

$composer require inertiajs/inertia-laravel
$npm install @inertiajs/react react react-dom

Follow the Inertia server-side setup once (root template and middleware) if the app has never used Inertia before.

Add your credentials to .env:

$FASTPIX_USERNAME=your-access-token-id
$FASTPIX_PASSWORD=your-secret-key

Read them through config rather than env() directly, so they keep working once you run php artisan config:cache:

1// config/fastpix.php
2<?php
3
4return [
5 'username' => env('FASTPIX_USERNAME'),
6 'password' => env('FASTPIX_PASSWORD'),
7];

Then wrap the client in a small service:

1// app/Services/FastPixService.php
2<?php
3
4namespace App\Services;
5
6use FastPix\Sdk\Fastpixsdk;
7use FastPix\Sdk\Models\Components\Security;
8use GuzzleHttp\Client;
9
10class FastPixService
11{
12 public static function make(): Fastpixsdk
13 {
14 return Fastpixsdk::builder()
15 ->setSecurity(new Security(
16 username: config('fastpix.username'),
17 password: config('fastpix.password'),
18 ))
19 ->setClient(new Client([
20 'headers' => ['X-Client-Type' => 'web-browser'],
21 ]))
22 ->build();
23 }
24}

NOTE: X-Client-Type: web-browser tells FastPix that a browser will perform the upload, so the signed URL is issued for browser use. Send it whenever the file is uploaded from a browser, including this setup, where your server requests the URL and the browser uploads to it. Setting it on the Guzzle client applies it to every SDK call.

Leave it out when PHP itself, a CLI command, or a native Android or iOS app uploads the bytes.


Create an upload URL

Signed upload URLs are created with your Secret Key, so this belongs on the server:

1// app/Http/Controllers/VideoController.php
2<?php
3
4namespace App\Http\Controllers;
5
6use App\Services\FastPixService;
7use FastPix\Sdk\Models\Operations\DirectUploadVideoMediaAccessPolicy;
8use FastPix\Sdk\Models\Operations\DirectUploadVideoMediaRequest;
9use FastPix\Sdk\Models\Operations\PushMediaSettings;
10use Inertia\Inertia;
11
12class VideoController extends Controller
13{
14 public function createUpload()
15 {
16 $upload = FastPixService::make()->inputVideo->directUploadVideoMedia(
17 new DirectUploadVideoMediaRequest(
18 corsOrigin: '*',
19 pushMediaSettings: new PushMediaSettings(
20 accessPolicy: DirectUploadVideoMediaAccessPolicy::Public,
21 ),
22 ),
23 );
24
25 return response()->json([
26 'url' => $upload->object->data->url,
27 'mediaId' => $upload->object->data->uploadId,
28 ]);
29 }
30
31 public function upload()
32 {
33 return Inertia::render('upload');
34 }
35}

Register the routes:

1// routes/web.php
2use App\Http\Controllers\VideoController;
3
4Route::get('/upload', [VideoController::class, 'upload']);
5Route::post('/api/upload-url', [VideoController::class, 'createUpload']);

Keep the mediaId. It identifies the media everywhere else in the API, and you’ll use it later to play the video. Full options are in Upload media from device, or use create media from a URL to import video you already host.


Add the uploader

Give <FastPixUploader> a function instead of a URL. It runs when a file is picked, so a URL is minted per upload rather than per page load.

Routes in routes/web.php are CSRF-protected, so the request needs the token Laravel set in the XSRF-TOKEN cookie:

resources/js/pages/upload.tsx

1import { FastPixUploader } from '@fastpix/fp-react-uploader';
2import '@fastpix/fp-react-uploader/styles.css';
3
4function csrfToken(): string {
5 const cookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
6 return cookie ? decodeURIComponent(cookie.split('=')[1]) : '';
7}
8
9async function createUpload() {
10 const response = await fetch('/api/upload-url', {
11 method: 'POST',
12 headers: { 'X-XSRF-TOKEN': csrfToken() },
13 });
14 const { url } = await response.json();
15 return url as string;
16}
17
18export default function Upload() {
19 return <FastPixUploader endpoint={createUpload} accept="video/*" />;
20}

resources/js/pages/upload.jsx

1import { FastPixUploader } from '@fastpix/fp-react-uploader';
2import '@fastpix/fp-react-uploader/styles.css';
3
4function csrfToken() {
5 const cookie = document.cookie.split('; ').find((c) => c.startsWith('XSRF-TOKEN='));
6 return cookie ? decodeURIComponent(cookie.split('=')[1]) : '';
7}
8
9async function createUpload() {
10 const response = await fetch('/api/upload-url', {
11 method: 'POST',
12 headers: { 'X-XSRF-TOKEN': csrfToken() },
13 });
14 const { url } = await response.json();
15 return url;
16}
17
18export default function Upload() {
19 return <FastPixUploader endpoint={createUpload} accept="video/*" />;
20}

That’s a working uploader: drag and drop, a file picker, progress, and pause, resume, and cancel controls.


Build your own layout instead

Pass the sub-components as children and arrange them yourself. Each reads upload state from the parent through context, so placement and order are yours:

1import {
2 FastPixUploader,
3 FastPixDropZone,
4 FastPixStatus,
5 FastPixTrack,
6 FastPixStartButton,
7 FastPixPauseButton,
8 FastPixResumeButton,
9 FastPixAbortButton,
10} from '@fastpix/fp-react-uploader';
11
12<FastPixUploader endpoint={createUpload} autoStart={false} size="lg" appearance={{ accentColor: '#00d1ff' }}>
13 <FastPixDropZone overlay>
14 <p>Drag a video here, or click to browse</p>
15 </FastPixDropZone>
16
17 <FastPixStatus />
18 <FastPixTrack showLabel />
19
20 <FastPixStartButton />
21 <FastPixPauseButton />
22 <FastPixResumeButton />
23 <FastPixAbortButton />
24</FastPixUploader>

autoStart={false} holds the file until someone presses start, which is why a start button appears here and not in the default layout.

Tracking progress yourself, driving the uploader from a ref, reading live state with useUploaderContext(), and building a fully headless UI are covered in the uploader README.


Respond to upload events

The uploader reports its lifecycle through callback props: onProgress, onSuccess, onError, and more. All are optional:

1<FastPixUploader
2 endpoint={createUpload}
3 accept="video/*"
4 onProgress={(percent) => console.log(percent)}
5 onSuccess={() => {
6 /* the bytes are delivered; encoding continues */
7 }}
8 onError={(error) => console.error(error.message)}
9/>

onSuccess fires when the bytes finish uploading. The media still has to be encoded before it can play, which is what the next section covers. The full callback list is in the uploader README.


Know when the video is ready

A finished upload isn’t a playable video yet. It still has to be encoded. FastPix sends a video.media.ready webhook once playback is available.

Register your endpoint under Org Settings > Webhooks, see Set up webhooks, then handle the event:

1// app/Http/Controllers/VideoController.php
2public function webhook(Request $request)
3{
4 $payload = $request->json()->all();
5
6 if (($payload['type'] ?? null) === 'video.media.ready') {
7 // $payload['data']['id'] is the mediaId,
8 // $payload['data']['playbackIds'][0]['id'] is the playback ID.
9 // Store them against your own record here.
10 $this->markVideoReady(
11 $payload['data']['id'],
12 $payload['data']['playbackIds'][0]['id'],
13 );
14 }
15
16 return response()->json(['message' => 'ok']);
17}
1// routes/web.php
2Route::post('/api/fastpix/webhook', [VideoController::class, 'webhook']);

FastPix can’t send a CSRF token, so exclude the webhook route. Without this every delivery fails with a 419:

1// bootstrap/app.php
2->withMiddleware(function (Middleware $middleware) {
3 $middleware->validateCsrfTokens(except: [
4 'api/fastpix/webhook',
5 ]);
6})

WARNING:
Verify the signature before trusting a payload. Excluding the route from CSRF means anything can post to it. Check the FastPix-Signature header, an HMAC-SHA256 of the raw body, as described in Set up webhooks. Use $request->getContent() and verify those exact bytes. A decoded and re-encoded payload produces different bytes and never matches.

Other events, including video.media.failed, are in the webhook event reference

Webhooks can’t reach localhost, so while developing either expose your app through a tunnel or check the status directly:

1$media = FastPixService::make()->manageVideos->getMedia($mediaId);
2// $media->object->data->status === 'Ready'

Play the video

Fetch the media in the controller. That runs on the server, so your credentials stay there and only the playback ID reaches the browser:

1// app/Http/Controllers/VideoController.php
2public function play(string $mediaId)
3{
4 // Read the playback ID from your own database, or from FastPix like below.
5 $media = FastPixService::make()->manageVideos->getMedia($mediaId);
6
7 return Inertia::render('play', [
8 'playbackId' => $media->object->data->playbackIds[0]->id,
9 ]);
10}
1// routes/web.php
2Route::get('/play/{mediaId}', [VideoController::class, 'play']);

<fastpix-player> is a web component, so it registers itself against customElements, a browser-only API. Inertia server-renders pages when SSR is enabled, so import the player after mount. The element upgrades itself as soon as the definition arrives:

resources/js/components/player.tsx

1import { useEffect } from 'react';
2
3export default function Player({ playbackId }: { playbackId: string }) {
4 useEffect(() => {
5 import('@fastpix/fp-player');
6 }, []);
7
8 return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
9}

resources/js/components/player.jsx

1import { useEffect } from 'react';
2
3export default function Player({ playbackId }) {
4 useEffect(() => {
5 import('@fastpix/fp-player');
6 }, []);
7
8 return <fastpix-player playback-id={playbackId} stream-type="on-demand" />;
9}

resources/js/pages/play.tsx

1import Player from '../../../components/player';
2
3export default function Play({ playbackId }: { playbackId: string }) {
4 return <Player playbackId={playbackId} />;
5}

resources/js/pages/play.jsx

1import Player from '../../../components/player';
2
3export default function Play({ playbackId }) {
4 return <Player playbackId={playbackId} />;
5}

Give it a size, since it fills whatever container it sits in:

1fastpix-player { width: 100%; aspect-ratio: 16 / 9; }

Playing video in a Blade view, without React

The player needs no React. Load it from your bundle and use the tag directly:

1<fastpix-player playback-id="{{ $playbackId }}" stream-type="on-demand"></fastpix-player>

with import '@fastpix/fp-player'; in resources/js/app.js. Only the upload UI requires React.

A playback ID isn’t the same as a media ID. One media asset can carry several playback IDs with different access policies. This guide uses accessPolicy: 'public', so the ID alone is enough to play the video. Private and DRM playback need a signed token, covered in play uploaded videos along with autoplay, captions, and the player’s full attribute and event surface.


Troubleshooting

composer require fastpix/sdk fails with a dependency version conflict.

The SDK’s dependencies (such as Guzzle) can conflict with versions your project has locked. Let Composer update the locked transitive dependencies alongside it:

$composer require fastpix/sdk --with-all-dependencies

419 on /api/upload-url.

The request is missing Laravel’s CSRF token. Send it as the X-XSRF-TOKEN header, read from the XSRF-TOKEN cookie, as above.

419 on the webhook route.

FastPix can’t send a CSRF token. Add the route to validateCsrfTokens(except: [...]) in bootstrap/app.php, and verify the signature instead.

Uploads fail with “error code 0” after 5 attempts, but the video still processes.

The signed URL was created without the X-Client-Type: web-browser header, so it isn’t valid for uploads started from a browser. Set it on the Guzzle client as shown. The rule is about who uploads the bytes, not who requests the URL. A browser doing the upload needs the header even though your server is the one asking for the URL.

Credentials work until you cache config.

php artisan config:cache stops env() from reading .env outside config files. Read credentials through config('fastpix.username') and keep env() calls inside config/fastpix.php.

ReferenceError: window is not defined with Inertia SSR.

The player is being imported at module scope. Import it inside useEffect, as above.

Chunk size is rejected.

chunkSize is in KB, between 5120 and 512000, in multiples of 256.


What’s next