# Adaptive streaming in Laravel with Ittybit

Convert uploaded videos to HLS adaptive streams and serve them in Blade views

Users upload a video through a Laravel form. Ittybit converts it to an HLS adaptive stream with multiple quality variants. A webhook delivers the playlist URL back to your app, and an HLS.js player in a Blade view handles playback -- adjusting quality automatically based on the viewer's bandwidth.

## Migration and model

Create a `videos` table to track uploads and their HLS output.

```bash
php artisan make:model Video -m
```

```php
// database/migrations/xxxx_create_videos_table.php
Schema::create('videos', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained();
    $table->string('original_path');
    $table->string('ittybit_task_id')->nullable();
    $table->string('status')->default('uploading');
    $table->string('playlist_url')->nullable();
    $table->json('metadata')->nullable();
    $table->timestamps();
});
```

```php
// app/Models/Video.php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Video extends Model
{
    protected $fillable = [
        'user_id',
        'original_path',
        'ittybit_task_id',
        'status',
        'playlist_url',
        'metadata',
    ];

    protected $casts = [
        'metadata' => 'array',
    ];
}
```

## Upload controller

Accept the file, store it to S3, and dispatch an `adaptive_video` task to Ittybit with HLS output.

```php
// app/Http/Controllers/VideoController.php
namespace App\Http\Controllers;

use App\Models\Video;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

class VideoController extends Controller
{
    public function store(Request $request)
    {
        $request->validate([
            'video' => 'required|file|mimetypes:video/*|max:512000',
        ]);

        $path = $request->file('video')->store('uploads', 's3');

        $video = Video::create([
            'user_id' => $request->user()->id,
            'original_path' => $path,
            'status' => 'processing',
        ]);

        $s3Url = 's3://' . config('filesystems.disks.s3.bucket') . '/' . $path;

        $response = Http::withToken(config('services.ittybit.api_key'))
            ->post('https://api.ittybit.com/jobs', [
                'input' => $s3Url,
                'connection_id' => config('services.ittybit.connection_id'),
                'kind' => 'adaptive_video',
                'options' => [
                    'format' => 'hls',
                ],
            ]);

        $video->update([
            'ittybit_task_id' => $response->json('id'),
        ]);

        return back()->with('status', 'Video uploaded. Generating adaptive stream.');
    }

    public function show(Video $video)
    {
        return view('videos.show', compact('video'));
    }
}
```

Add credentials to your `.env`:

```bash
ITTYBIT_API_KEY=your_ittybit_api_key
ITTYBIT_CONNECTION_ID=conn_xxxxx
ITTYBIT_WEBHOOK_SECRET=your_webhook_secret
```

And register them in config:

```php
// config/services.php
'ittybit' => [
    'api_key' => env('ITTYBIT_API_KEY'),
    'connection_id' => env('ITTYBIT_CONNECTION_ID'),
    'webhook_secret' => env('ITTYBIT_WEBHOOK_SECRET'),
],
```

## Webhook handler

Ittybit POSTs to your app when the adaptive stream is ready. Verify the signature, then store the playlist URL.

```php
// routes/api.php
use App\Http\Controllers\WebhookController;

Route::post('/webhooks/ittybit', [WebhookController::class, 'handle']);
```

```php
// app/Http/Controllers/WebhookController.php
namespace App\Http\Controllers;

use App\Models\Video;
use Illuminate\Http\Request;

class WebhookController extends Controller
{
    public function handle(Request $request)
    {
        $secret = config('services.ittybit.webhook_secret');
        $signature = $request->header('Ittybit-Signature');

        if (!hash_equals(hash_hmac('sha256', $request->getContent(), $secret), $signature)) {
            abort(401);
        }

        $payload = $request->all();

        $video = Video::where('ittybit_task_id', $payload['id'])->first();

        if (!$video) {
            return response()->json(['status' => 'ignored'], 200);
        }

        if ($payload['status'] === 'completed') {
            $video->update([
                'status' => 'completed',
                'playlist_url' => $payload['output']['url'],
                'metadata' => [
                    'duration' => $payload['output']['duration'],
                    'width' => $payload['output']['width'],
                    'height' => $payload['output']['height'],
                    'format' => $payload['output']['format'],
                ],
            ]);
        } else {
            $video->update(['status' => 'failed']);
        }

        return response()->json(['status' => 'ok'], 200);
    }
}
```

## Blade view with HLS.js player

Serve the adaptive stream using HLS.js. It handles manifest parsing, quality switching, and buffering. Safari supports HLS natively, so the player falls back to the native `<video>` element there.

```php
{{-- resources/views/videos/show.blade.php --}}
@extends('layouts.app')

@section('content')
<div class="max-w-3xl mx-auto py-8">
    @if ($video->status === 'completed' && $video->playlist_url)
        <video id="player" controls playsinline style="width:100%; max-width:100%;"></video>

        <script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
        <script>
            const video = document.getElementById('player');
            const src = @json($video->playlist_url);

            if (Hls.isSupported()) {
                const hls = new Hls();
                hls.loadSource(src);
                hls.attachMedia(video);
            } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
                video.src = src;
            }
        </script>
    @elseif ($video->status === 'processing')
        <p>Your video is being converted to an adaptive stream. This page will update when it's ready.</p>
    @else
        <p>Something went wrong processing this video.</p>
    @endif
</div>
@endsection
```

## Upload form

A minimal Blade template with the file input.

```php
{{-- resources/views/videos/upload.blade.php --}}
<form action="{{ route('videos.store') }}" method="POST" enctype="multipart/form-data">
    @csrf
    <input type="file" name="video" accept="video/*" required>
    <button type="submit">Upload</button>
</form>
```

## Routes

```php
// routes/web.php
use App\Http\Controllers\VideoController;

Route::middleware('auth')->group(function () {
    Route::get('/videos/upload', fn () => view('videos.upload'))->name('videos.upload');
    Route::post('/videos', [VideoController::class, 'store'])->name('videos.store');
    Route::get('/videos/{video}', [VideoController::class, 'show'])->name('videos.show');
});
```

## S3 connection setup

Before dispatching tasks with `s3://` URLs, create a connection in Ittybit so it can read from your bucket.

<CodeGroup labels={["CLI", "curl"]}>
```bash
ittybit connections add s3 \
  --name my-uploads \
  --endpoint https://s3.us-east-1.amazonaws.com \
  --region us-east-1 \
  --access-key-id $AWS_ACCESS_KEY_ID \
  --secret-access-key $AWS_SECRET_ACCESS_KEY
```

```bash
curl -X POST https://api.ittybit.com/connections \
  -H "Authorization: Bearer $ITTYBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "kind": "s3",
    "name": "my-uploads",
    "endpoint": "https://s3.us-east-1.amazonaws.com",
    "region": "us-east-1",
    "access_key_id": "'$AWS_ACCESS_KEY_ID'",
    "secret_access_key": "'$AWS_SECRET_ACCESS_KEY'"
  }'
```

</CodeGroup>

The `connection_id` from the response goes into your `.env` as `ITTYBIT_CONNECTION_ID`.

## See also

- [Create HLS streams](/guides/create-hls-streams)
- [Video upload pipeline with Laravel](/guides/video-upload-pipeline-with-laravel)
- [Build a user upload pipeline](/guides/build-a-user-upload-pipeline)
- [Process files from S3](/guides/process-files-from-s3)