> ## Documentation Index
> Fetch the complete documentation index at: https://bunny.net/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Upload videos to Bunny Stream from Laravel

> Upload video from the browser straight to Bunny Stream over TUS, with a Laravel backend that creates each video and signs the upload.

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](/docs/stream/tus-resumable-uploads) in resumable chunks.

<Card title="Laravel example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2/08vlxCwhRdpoHfuV/logo/frameworks/laravel.svg?fit=max&auto=format&n=08vlxCwhRdpoHfuV&q=85&s=42aa00134a48df6adf27e77c58845263" href="https://github.com/BunnyWay/examples/tree/main/stream/upload-tus-laravel" horizontal width="24" height="24" data-path="logo/frameworks/laravel.svg">
  A Laravel 13 app with no database.
</Card>

## Quickstart

<Steps>
  <Step title="Add your library credentials">
    Copy both from your library's **API** page. The API key can delete videos, and belongs in `.env` only.

    ```bash .env theme={null}
    BUNNY_STREAM_LIBRARY_ID=
    BUNNY_STREAM_API_KEY=
    ```

    ```php config/services.php theme={null}
    'bunny_stream' => [
        'library_id' => env('BUNNY_STREAM_LIBRARY_ID'),
        'api_key' => env('BUNNY_STREAM_API_KEY'),
    ],
    ```
  </Step>

  <Step title="Create the Bunny Stream service">
    The upload signature is a SHA-256 of the library ID, API key, expiry, and video ID.

    ```php app/Services/BunnyStream.php theme={null}
    <?php

    namespace App\Services;

    use Illuminate\Container\Attributes\Config;
    use Illuminate\Http\Client\PendingRequest;
    use Illuminate\Support\Facades\Http;

    final readonly class BunnyStream
    {
        // The status of a video still waiting for its file.
        public const int STATUS_CREATED = 0;

        // Bunny checks the expiry on every TUS request, so leave room for slow uploads.
        private const int SIGNATURE_TTL_SECONDS = 24 * 60 * 60;

        public function __construct(
            #[Config('services.bunny_stream.library_id')] private string $libraryId,
            #[Config('services.bunny_stream.api_key')] private string $apiKey,
        ) {}

        /** @return array{status: int, encodeProgress: int, embedUrl: string} */
        public function getVideo(string $videoId): array
        {
            $video = $this->stream()->get('/videos/'.rawurlencode($videoId))->json();

            return [
                'status' => $video['status'],
                'encodeProgress' => $video['encodeProgress'],
                'embedUrl' => "https://player.mediadelivery.net/embed/{$this->libraryId}/{$videoId}",
            ];
        }

        public function createVideo(string $title): string
        {
            return $this->stream()->post('/videos', ['title' => $title])->json('guid');
        }

        /** @return array{videoId: string, libraryId: string, expirationTime: int, signature: string} */
        public function signUpload(string $videoId): array
        {
            $expirationTime = time() + self::SIGNATURE_TTL_SECONDS;

            return [
                'videoId' => $videoId,
                'libraryId' => $this->libraryId,
                'expirationTime' => $expirationTime,
                'signature' => hash('sha256', $this->libraryId.$this->apiKey.$expirationTime.$videoId),
            ];
        }

        private function stream(): PendingRequest
        {
            return Http::baseUrl("https://video.bunnycdn.com/library/{$this->libraryId}")
                ->withHeaders(['AccessKey' => $this->apiKey])
                ->acceptJson()
                ->throw();
        }
    }
    ```
  </Step>

  <Step title="Add the API routes">
    ```php routes/api.php theme={null}
    <?php

    use App\Http\Controllers\UploadController;
    use App\Http\Controllers\VideoController;
    use Illuminate\Support\Facades\Route;

    Route::post('/uploads', UploadController::class);
    Route::get('/videos/{id}', VideoController::class);
    ```

    Run `php artisan install:api` first if the app has no `routes/api.php`. API routes skip the CSRF check, which keeps the browser side simple.

    Pass a `videoId` from an unfinished upload and the controller re-signs that video. Anything else gets a new one.

    ```php app/Http/Controllers/UploadController.php theme={null}
    <?php

    namespace App\Http\Controllers;

    use App\Services\BunnyStream;
    use Illuminate\Http\Client\RequestException;
    use Illuminate\Http\JsonResponse;
    use Illuminate\Http\Request;

    class UploadController extends Controller
    {
        public function __construct(private readonly BunnyStream $bunny) {}

        public function __invoke(Request $request): JsonResponse
        {
            $data = $request->validate([
                'title' => ['required', 'string'],
                'videoId' => ['nullable', 'string'],
            ]);

            $videoId = $data['videoId'] ?? null;
            if (! $videoId || ! $this->canResume($videoId)) {
                $videoId = $this->bunny->createVideo($data['title']);
            }

            return response()->json($this->bunny->signUpload($videoId));
        }

        private function canResume(string $videoId): bool
        {
            try {
                return $this->bunny->getVideo($videoId)['status'] === BunnyStream::STATUS_CREATED;
            } catch (RequestException) {
                return false;
            }
        }
    }
    ```

    ```php app/Http/Controllers/VideoController.php theme={null}
    <?php

    namespace App\Http\Controllers;

    use App\Services\BunnyStream;
    use Illuminate\Http\JsonResponse;

    class VideoController extends Controller
    {
        public function __invoke(BunnyStream $bunny, string $id): JsonResponse
        {
            return response()->json($bunny->getVideo($id));
        }
    }
    ```
  </Step>

  <Step title="Upload from the browser">
    ```bash theme={null}
    npm install tus-js-client
    ```

    ```js resources/js/video-uploader.js theme={null}
    import * as tus from "tus-js-client";

    async function requestUpload(title, videoId) {
      const response = await fetch("/api/uploads", {
        method: "POST",
        headers: { "Content-Type": "application/json", Accept: "application/json" },
        body: JSON.stringify({ title, videoId }),
      });
      const body = await response.json();
      if (!response.ok) throw new Error(body.message ?? "Could not create the upload");

      return body;
    }
    ```

    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.

    ```js theme={null}
    // Remembers which Bunny video a file was going into, so a reload can resume it.
    const videoKey = (file) => `bunny-video:${file.name}:${file.size}:${file.lastModified}`;

    export async function uploadVideo(file, { onProgress, onSuccess, onError }) {
      const key = videoKey(file);
      const savedVideoId = localStorage.getItem(key);
      const credentials = await requestUpload(file.name, savedVideoId);
      localStorage.setItem(key, credentials.videoId);

      const upload = new tus.Upload(file, {
        endpoint: "https://video.bunnycdn.com/tusupload",
        retryDelays: [0, 3000, 5000, 10000, 20000, 60000],
        removeFingerprintOnSuccess: true,
        headers: {
          AuthorizationSignature: credentials.signature,
          AuthorizationExpire: String(credentials.expirationTime),
          VideoId: credentials.videoId,
          LibraryId: credentials.libraryId,
        },
        metadata: { filetype: file.type, title: file.name },
        onProgress: (sent, total) => onProgress(Math.floor((sent / total) * 100)),
        onSuccess: () => {
          localStorage.removeItem(key);
          onSuccess(credentials.videoId);
        },
        onError,
      });

      // A stored upload URL belongs to one video, so only resume into the same one.
      const [previous] = await upload.findPreviousUploads();
      if (previous && credentials.videoId === savedVideoId) {
        upload.resumeFromPreviousUpload(previous);
      }
      upload.start();

      return upload;
    }
    ```

    `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. Re-signing keeps the upload's original expiry, as the [TUS FAQ](/docs/stream/tus-resumable-uploads#resumable-tus-upload-faq) explains.
  </Step>
</Steps>

## 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`.

```js theme={null}
const FINISHED = 4;
const FAILED = [5, 6];

// Returns a function that stops polling.
export function watchVideo(videoId, { onChange, onError }) {
  let timer;
  let active = true;

  async function poll() {
    const response = await fetch(`/api/videos/${videoId}`);
    const video = await response.json();
    if (!active) return;
    if (!response.ok) return onError(new Error(video.error ?? "Could not read the video status"));

    onChange(video);
    if (video.status !== FINISHED && !FAILED.includes(video.status)) {
      timer = setTimeout(() => poll().catch(onError), 3000);
    }
  }

  poll().catch(onError);

  return () => {
    active = false;
    clearTimeout(timer);
  };
}
```

`encodeProgress` gives you a percentage to show in the meantime. A [webhook](/docs/stream/webhooks) tells your server when encoding finishes.

## Before you deploy

Put the routes behind `auth:sanctum` or your own middleware, and store which user owns each video ID. As written, anyone can fill your library.

## Troubleshooting

<AccordionGroup>
  <Accordion title="/api/uploads returns 404">
    Laravel isn't loading `routes/api.php`. Run `php artisan install:api`. If you changed `.env` after caching the config, run `php artisan config:clear` too.
  </Accordion>
</AccordionGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.