> ## Documentation Index
> Fetch the complete documentation index at: https://bunnynet-cb9733c2-nathan-draft-sep-8.mintlify.site/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](/stream/tus-resumable-uploads) in resumable chunks.

<Card title="Laravel example on GitHub" icon="https://mintcdn.com/bunnynet-cb9733c2-nathan-draft-sep-8/EMGBBzipJnaVqwed/logo/frameworks/laravel.svg?fit=max&auto=format&n=EMGBBzipJnaVqwed&q=85&s=6eec3dd2a088dd005165826d4c7b6e47" 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">
    <Warning>
      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.
    </Warning>

    Register `routes/api.php` in `bootstrap/app.php`. API routes skip the CSRF check, which keeps the browser side simple.

    ```php bootstrap/app.php theme={null}
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ```

    ```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);
    ```

    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
        {
            // Require a signed-in user here. This route is public and creates videos in your library.
            // Before re-signing a videoId, check that the user owns it.
            $data = $request->validate([
                'title' => ['required', 'string'],
                'videoId' => ['nullable', 'string'],
            ]);

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

                return response()->json($this->bunny->signUpload($videoId));
            } catch (RequestException $error) {
                return response()->json(['error' => $error->getMessage()], 502);
            }
        }

        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\Client\RequestException;
    use Illuminate\Http\JsonResponse;

    class VideoController extends Controller
    {
        public function __invoke(BunnyStream $bunny, string $id): JsonResponse
        {
            // Require a signed-in user here, and check that they own this video ID. The route is public.
            try {
                return response()->json($bunny->getVideo($id));
            } catch (RequestException $error) {
                // 404 when Bunny Stream has no such video, 502 for anything else.
                $status = $error->response->status() === 404 ? 404 : 502;

                return response()->json(['error' => $error->getMessage()], $status);
            }
        }
    }
    ```
  </Step>

  <Step title="Upload from the browser">
    Serve the page from `/`, with a file input and somewhere to show the result.

    ```php routes/web.php theme={null}
    <?php

    use Illuminate\Support\Facades\Route;

    Route::view('/', 'upload');
    ```

    ```blade resources/views/upload.blade.php theme={null}
    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="utf-8">
        <title>Upload to Bunny Stream</title>
        @vite('resources/js/app.js')
    </head>
    <body>
        <input type="file" id="video-file" accept="video/*">
        <div id="video-output"></div>
    </body>
    </html>
    ```

    ```js resources/js/app.js theme={null}
    import "./video-uploader";
    ```

    ```bash theme={null}
    bun add 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();
      // Validation errors come back as `message`, Bunny Stream errors as `error`.
      if (!response.ok) throw new Error(body.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. Add this to the same file.

    ```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}`;

    // Aborting `signal` cancels the upload, even while it still waits on your server.
    export async function uploadVideo(file, { signal, onProgress, onSuccess, onError }) {
      const key = videoKey(file);
      const savedVideoId = localStorage.getItem(key);
      const credentials = await requestUpload(file.name, savedVideoId);
      if (signal?.aborted) return null;
      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 (signal?.aborted) return null;
      if (previous && credentials.videoId === savedVideoId) {
        upload.resumeFromPreviousUpload(previous);
      }
      signal?.addEventListener("abort", () => upload.abort());
      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. A 400 means the expiry has already passed. Re-signing keeps the upload's original expiry, as the [TUS FAQ](/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`. This goes in the same file too.

```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](/stream/webhooks) 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.

```js theme={null}
const input = document.querySelector("#video-file");
const output = document.querySelector("#video-output");
let cancel = () => {};

function show(video) {
  if (FAILED.includes(video.status)) {
    output.textContent = "Bunny Stream could not encode the video.";
  } else if (video.status !== FINISHED) {
    output.textContent = `Encoding… ${video.encodeProgress}%`;
  } else {
    const player = Object.assign(document.createElement("iframe"), {
      src: video.embedUrl,
      allow: "autoplay; encrypted-media; picture-in-picture; fullscreen",
      allowFullscreen: true,
    });
    output.replaceChildren(player);
  }
}

input.addEventListener("change", async () => {
  const file = input.files[0];
  if (!file) return;

  cancel();
  const controller = new AbortController();
  let stopWatching = () => {};
  cancel = () => {
    controller.abort();
    stopWatching();
  };

  const onError = (error) => (output.textContent = error.message);
  output.textContent = "Uploading… 0%";
  try {
    await uploadVideo(file, {
      signal: controller.signal,
      onProgress: (percent) => (output.textContent = `Uploading… ${percent}%`),
      onSuccess: (videoId) => {
        stopWatching = watchVideo(videoId, { onChange: show, onError });
      },
      onError,
    });
  } catch (error) {
    if (!controller.signal.aborted) onError(error);
  }
});
```

Start the app with `composer run dev`, open [http://localhost:8000](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

<AccordionGroup>
  <Accordion title="/api/uploads returns 404">
    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.
  </Accordion>
</AccordionGroup>


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