# Upload progress with Livewire and Ittybit

Build a real-time upload and processing UI with Laravel Livewire and Ittybit task polling

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.

```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('pending');
    $table->unsignedTinyInteger('progress')->default(0);
    $table->string('output_url')->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',
        '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.

```php
// 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.

```php
{{-- 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`:

```bash
ITTYBIT_API_KEY=your_ittybit_api_key
ITTYBIT_CONNECTION_ID=conn_xxxxx
```

Register them in config:

```php
// 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.

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

## Using the component

Drop the component into any Blade page:

```php
{{-- resources/views/videos/index.blade.php --}}
<livewire:video-uploader />
```

## See also

- [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)