Video upload pipeline with Laravel and Ittybit

View Markdown

Users upload a video through a Laravel form, you store it in S3 via the Storage facade, then hand it off to Ittybit for processing. When the task finishes, a webhook hits your app and you update the media record with the output URL.

Migration and model

Create a videos table to track uploads and their processed output.

php artisan make:model Video -m
// 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('output_url')->nullable();
    $table->json('metadata')->nullable();
    $table->timestamps();
});
// 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',
        'output_url',
        'metadata',
    ];

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

Upload controller

Accept the file, store it to S3, and dispatch the Ittybit task.

// 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' => 'video',
                'options' => [
                    'width' => 1920,
                    'format' => 'mp4',
                    'quality' => 'high',
                ],
            ]);

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

        return back()->with('status', 'Video uploaded and processing.');
    }
}

Add credentials to your .env:

ITTYBIT_API_KEY=your_ittybit_api_key
ITTYBIT_CONNECTION_ID=conn_xxxxx
ITTYBIT_WEBHOOK_SECRET=your_webhook_secret

And register them in config:

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

Webhook route

Ittybit POSTs to your app when the task completes. Register a route that skips CSRF verification and updates the video record.

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

Route::post('/webhooks/ittybit', [WebhookController::class, 'handle']);
// 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',
                'output_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);
    }
}

Upload form

A minimal Blade template with the file input.

{{-- 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>

S3 connection setup

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

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
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'"
  }'

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

Multiple outputs

Need a thumbnail alongside the transcoded video? Fire a second task after the upload.

// In VideoController::store(), after the first Http::post()
Http::withToken(config('services.ittybit.api_key'))
    ->post('https://api.ittybit.com/jobs', [
        'input' => $s3Url,
        'connection_id' => config('services.ittybit.connection_id'),
        'kind' => 'image',
        'options' => [
            'start' => 2,
            'width' => 640,
            'format' => 'webp',
        ],
    ]);

Both tasks run in parallel. Handle each webhook independently by matching on ittybit_task_id.

See also