Upload progress with Livewire and Ittybit
Livewire gives you reactive server-rendered UI without writing JavaScript. Pair it with Ittybit’s task API and you get a file upload component that shows real-time processing progress — upload the file, kick off a task, poll until it finishes, and update the UI automatically.
Migration and model
Track the upload state and the Ittybit task alongside it.
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('pending');
$table->unsignedTinyInteger('progress')->default(0);
$table->string('output_url')->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',
'progress',
'output_url',
];
}
Livewire component
The component handles three phases: file upload via Livewire’s temporary upload, job creation against the Ittybit API, and polling GET /jobs/:id every 2 seconds until the job reaches a terminal state.
// app/Livewire/VideoUploader.php
namespace App\Livewire;
use App\Models\Video;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Livewire\Component;
use Livewire\WithFileUploads;
class VideoUploader extends Component
{
use WithFileUploads;
public $file;
public ?Video $video = null;
public function upload()
{
$this->validate([
'file' => 'required|file|mimetypes:video/*|max:512000',
]);
// 1. Store to S3
$path = $this->file->store('uploads', 's3');
$s3Url = 's3://' . config('filesystems.disks.s3.bucket') . '/' . $path;
// 2. Save a local record
$this->video = Video::create([
'user_id' => auth()->id(),
'original_path' => $path,
'status' => 'uploading',
]);
// 3. Create an Ittybit task
$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',
],
]);
$task = $response->json();
$this->video->update([
'ittybit_task_id' => $task['id'],
'status' => $task['status'],
]);
}
public function pollTask()
{
if (!$this->video?->ittybit_task_id) {
return;
}
if (in_array($this->video->status, ['succeeded', 'failed'])) {
return;
}
$response = Http::withToken(config('services.ittybit.api_key'))
->get("https://api.ittybit.com/jobs/{$this->video->ittybit_task_id}");
$task = $response->json();
$this->video->update([
'status' => $task['status'],
'progress' => $task['progress'] ?? $this->video->progress,
'output_url' => $task['output']['url'] ?? null,
]);
$this->video->refresh();
}
public function render()
{
return view('livewire.video-uploader');
}
}
pollTask is called on a 2-second interval by the Blade template (see below). Once the status is succeeded or failed, it short-circuits and polling stops.
Blade template
Livewire’s wire:poll directive handles the polling loop. It calls pollTask every 2 seconds, but only while the task is in a non-terminal state.
{{-- resources/views/livewire/video-uploader.blade.php --}}
<div>
@if (!$video)
{{-- Upload form --}}
<form wire:submit="upload">
<input type="file" wire:model="file" accept="video/*">
@error('file')
<p>{{ $message }}</p>
@enderror
<div wire:loading wire:target="file">
Uploading to browser...
</div>
<button type="submit" wire:loading.attr="disabled" wire:target="file">
Upload
</button>
</form>
@elseif ($video->status === 'succeeded')
{{-- Done --}}
<div>
<p>Processing complete.</p>
<video src="{{ $video->output_url }}" controls></video>
</div>
@elseif ($video->status === 'failed')
<p>Processing failed. Please try again.</p>
@else
{{-- Polling state --}}
<div wire:poll.2s="pollTask">
<p>Processing: {{ $video->progress }}%</p>
<div style="width: 100%; background: #e5e7eb; border-radius: 4px; height: 8px;">
<div
style="width: {{ $video->progress }}%; background: #6366f1; height: 8px; border-radius: 4px; transition: width 0.3s;"
></div>
</div>
<p>Status: {{ $video->status }}</p>
</div>
@endif
</div>
The key detail is wire:poll.2s="pollTask" — Livewire fires a request to your server every 2 seconds, which calls pollTask, which hits GET /jobs/:id, and the updated state flows back to the template. When the status becomes succeeded, the @elseif branch renders instead and the polling element is removed from the DOM.
Config
Add your Ittybit credentials to .env:
ITTYBIT_API_KEY=your_ittybit_api_key
ITTYBIT_CONNECTION_ID=conn_xxxxx
Register them in config:
// config/services.php
'ittybit' => [
'api_key' => env('ITTYBIT_API_KEY'),
'connection_id' => env('ITTYBIT_CONNECTION_ID'),
],
S3 connection setup
Ittybit needs read access to your S3 bucket. Create a connection so it can pull files using s3:// URLs.
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_KEYcurl -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.
Using the component
Drop the component into any Blade page:
{{-- resources/views/videos/index.blade.php --}}
<livewire:video-uploader />