Skip to main content
Video files are large and PHP’s upload limits are small. Here the file never reaches PHP. Laravel creates the video and signs an upload, then the browser sends the file to us over TUS in resumable chunks.

Laravel example on GitHub

A Laravel 13 app with no database.

Quickstart

1

Add your library credentials

Copy both from your library’s API page. The API key can delete videos, and belongs in .env only.
.env
config/services.php
2

Create the Bunny Stream service

The upload signature is a SHA-256 of the library ID, API key, expiry, and video ID.
app/Services/BunnyStream.php
3

Add the API routes

Put the upload routes behind your own authentication before you deploy. The create route makes a video in your library and hands back a signature that lets the caller upload into it. Left open, anyone who finds the URL can fill your library with uploads that you pay to store, encode, and deliver.Server Actions and API routes are public HTTP endpoints, even when nothing in your UI links to them. Check the user on every request, and check that they own a video ID before you re-sign it or return its status.
Register routes/api.php in bootstrap/app.php. API routes skip the CSRF check, which keeps the browser side simple.
bootstrap/app.php
routes/api.php
Pass a videoId from an unfinished upload and the controller re-signs that video. Anything else gets a new one.
app/Http/Controllers/UploadController.php
app/Http/Controllers/VideoController.php
4

Upload from the browser

Serve the page from /, with a file input and somewhere to show the result.
routes/web.php
resources/views/upload.blade.php
resources/js/app.js
resources/js/video-uploader.js
tus-js-client sends the credentials as headers with every request. The video ID goes into localStorage against the file, which is how a reload finds its way back to the same upload. Add this to the same file.
abort() pauses. start() picks up from the last chunk we acknowledged.A 401 from the TUS endpoint means the signature doesn’t match the headers. Check that the library ID and API key belong to the same library. A 400 means the expiry has already passed. Re-signing keeps the upload’s original expiry, as the TUS FAQ explains.

Play it once it’s encoded

We start encoding when the last chunk arrives. Poll your status route until status reaches 4 (finished), 5 or 6 (failed), then embed embedUrl. This goes in the same file too.
encodeProgress gives you a percentage to show in the meantime. A webhook tells your server when encoding finishes. Then wire both to the page’s #video-file input and #video-output element. Picking another file cancels the current upload.
Start the app with composer run dev, open http://localhost:8000, and choose a video.

Before you deploy

Put the routes behind auth:sanctum (php artisan install:api adds Sanctum) or your own middleware, and store which user owns each video ID. As written, anyone can fill your library.

Troubleshooting

Laravel isn’t loading routes/api.php. Check that withRouting in bootstrap/app.php has the api: line. If you changed .env after caching the config, run php artisan config:clear too.
Last modified on October 6, 2026