# Show a queued Laravel job in Vue

> Dispatch the job, store pending, ready, or failed, and reload that row in the Vue page until the file can be downloaded.

- URL: https://laravel-vuejs.space/laravel-vue-queued-job
- Author: Anass Ez-zouaine (https://laravel-vuejs.space/author/anass-ez-zouaine)
- Published: 2026-10-10
- Topics: Laravel, VueJs, Inertia

The user clicks Export, the page says “Done!”, and the file doesn’t exist yet. A queued job is a promise, not a result. Here is a status row Vue can read, a poll that stops itself, and a private download only the owner can open.

> **TL;DR**
>
> -   Create an `exports` row (`pending`), dispatch `BuildExport`, redirect to its page.
> -   The job writes to the private `local` disk and sets `ready`, or `failed` in `failed()`.
> -   Vue polls `router.reload({ only: ['export'] })` every 2s from `onMounted` while `pending`.
> -   Download through a controller that checks the owner; use a plain `<a>`.
>
> **What you'll learn**
>
> -   How to track a queued job with your own status row
> -   How to poll from Vue with a partial reload
> -   How to keep the result private and test it
>
> **Who it's for:** Laravel + Vue developers running slow work in the background
>
> **Applies to:** Laravel 13 · Inertia 3 · Vue 3 · Pest

The page lies if it says “done” at the moment you dispatch. The job is a row in `jobs` until a worker runs it. Store your own status, return that row to Vue, and read it again until it leaves `pending`.

> **Gotcha: stuck on pending**
>
> If the status stays `pending`, nothing is processing the `jobs` table. `composer run dev` starts a queue listener with the app server. `php artisan serve` alone does not. Run `php artisan queue:work` in that case. The production worker, and `queue:restart` on deploy, stay in the [deploy guide](https://laravel-vuejs.space/deploy-laravel-vue).

A new Laravel app already ships the jobs migration and `QUEUE_CONNECTION=database`. Run `php artisan migrate` if that table is not in the database yet. Do not create a second jobs migration. This page is also not the posts CRUD. The row is an export. The stack is [Laravel and Vue in 2026](https://laravel-vuejs.space/laravel-vue-in-2026).

## The row and the job

```bash
php artisan make:model Export -m
php artisan make:job BuildExport
```

`database/migrations/xxxx_create_exports_table.php`
```php
Schema::create('exports', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->string('status')->default('pending');
    $table->string('path')->nullable();
    $table->timestamps();
});
```

`status` is `pending`, `ready`, or `failed`. `path` stays null until the file exists. Add `user_id`, `status`, and `path` to the model’s `$fillable` array.

`app/Jobs/BuildExport.php`
```php
use App\Models\Export;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

class BuildExport implements ShouldQueue
{
    use Queueable;

    public function __construct(public Export $export) {}

    public function handle(): void
    {
        $path = 'exports/'.$this->export->id.'.csv';
        $stored = Storage::disk('local')->put(
            $path,
            "id,status\n{$this->export->id},ready\n",
        );

        if ($stored !== true) {
            throw new RuntimeException('Export file was not written.');
        }

        $this->export->update([
            'status' => 'ready',
            'path' => $path,
        ]);
    }

    public function failed(?Throwable $exception): void
    {
        $this->export->update(['status' => 'failed']);
    }
}
```

> **Gotcha: private disk only**
>
> `local` is the private disk. Do not write this file onto the `public` disk, and do not copy it under `storage/app/public`. The download below checks the owner. A URL that anyone can guess is not a check.

## Dispatch, then show the row

`app/Http/Controllers/ExportController.php`
```php
public function store(Request $request): RedirectResponse
{
    $export = Export::query()->create([
        'user_id' => $request->user()->id,
        'status' => 'pending',
    ]);

    BuildExport::dispatch($export);

    return to_route('exports.show', $export);
}

public function show(Request $request, Export $export): Response
{
    abort_unless($request->user()->id === $export->user_id, 403);

    return Inertia::render('exports/Show', [
        'export' => $export->only(['id', 'status', 'path']),
    ]);
}

public function download(Request $request, Export $export)
{
    abort_unless(
        $request->user()->id === $export->user_id && $export->status === 'ready',
        403,
    );

    return Storage::disk('local')->download($export->path);
}
```

Register those three routes behind `auth`. Name them `exports.store`, `exports.show`, and `exports.download`. `only` keeps the page props to the fields Vue prints. The path is not a public URL. The download action is the only way out of the private disk.

## The Vue page

Poll while the row is `pending`. `router.reload({ only: ['export'] })` is the same partial reload as the [partial reloads guide](https://laravel-vuejs.space/inertia-partial-reloads). Two seconds is enough to see the change without a websocket. Start the timer in `onMounted` and clear it in `onUnmounted`. Do not read `window` in setup. This page can be server-rendered.

`resources/js/pages/exports/Show.vue`
```vue
<script setup lang="ts">
import { onMounted, onUnmounted } from "vue";
import { router } from "@inertiajs/vue3";

const props = defineProps<{
  export: { id: number; status: string; path: string | null };
}>();

let timer: ReturnType<typeof setInterval> | undefined;

onMounted(() => {
  if (props.export.status !== "pending") {
    return;
  }

  timer = setInterval(() => {
    if (props.export.status !== "pending") {
      clearInterval(timer);
      return;
    }

    router.reload({ only: ["export"] });
  }, 2000);
});

onUnmounted(() => {
  if (timer) {
    clearInterval(timer);
  }
});
</script>

<template>
  <p v-if="export.status === 'pending'">Preparing the file.</p>
  <p v-else-if="export.status === 'failed'">The export failed.</p>
  <a v-else :href="`/exports/${export.id}/download`">Download</a>
</template>
```

**Treated as a page visit:**

`resources/js/pages/exports/Show.vue`
```vue
<Link :href="`/exports/${export.id}/download`">Download</Link>
```

**A real download:**

`resources/js/pages/exports/Show.vue`
```vue
<a :href="`/exports/${export.id}/download`">Download</a>
```

The download link is a normal anchor. An Inertia `<Link>` would try to treat the file as a page visit.

When the status should appear without a timer, replace the interval with the broadcast in the [Reverb guide](https://laravel-vuejs.space/laravel-reverb-vue). This page does not install Echo. The row you stored is still the source of truth. The broadcast only tells the browser to reload it.

## A test

`tests/Feature/ExportTest.php`
```php
use App\Jobs\BuildExport;
use App\Models\Export;
use App\Models\User;
use Illuminate\Support\Facades\Queue;

it('queues an export', function () {
    Queue::fake();

    $this->actingAs(User::factory()->create())
        ->post('/exports')
        ->assertRedirect();

    Queue::assertPushed(BuildExport::class);
});

it('marks the export ready', function () {
    $export = Export::query()->create([
        'user_id' => User::factory()->create()->id,
        'status' => 'pending',
    ]);

    (new BuildExport($export))->handle();

    expect($export->fresh()->status)->toBe('ready');
});
```

> **Run it with and without a worker**
>
> Start an export with no worker running and watch it sit on “Preparing the file.” Then run `php artisan queue:work` in another terminal; within two seconds the page shows **Download**.

`php artisan test --filter=ExportTest`. `Queue::fake()` proves the dispatch. Calling `handle()` proves the file write and the status change without a worker. The download test is the same ownership check as the controller: another user, and a row that is still `pending`, both receive 403.

## Recap

- Store your own status; the `jobs` table is not a UI.
- Poll with a partial reload and stop when the status changes.
- Private disk + owner check for downloads.
- `Queue::fake()` for the dispatch, `handle()` for the work.

## FAQ

**Q: Why is my Laravel job stuck on pending?**

No worker is processing the queue. `composer run dev` starts one; with `php artisan serve` alone, run `php artisan queue:work`.

**Q: How do I show queued job progress in Vue?**

Store a status row, pass it as a prop, and poll with `router.reload({ only: ['export'] })` until it leaves `pending`, or broadcast with Reverb.

**Q: Why does an Inertia Link not download my file?**

Inertia treats a Link as a page visit. Use a plain `<a href>` for downloads.

## Next steps

- [Laravel Reverb with Vue](https://laravel-vuejs.space/laravel-reverb-vue): replace the poll with a push.
- [Inertia partial reloads](https://laravel-vuejs.space/inertia-partial-reloads)
- [Deploy a Laravel + Vue app](https://laravel-vuejs.space/deploy-laravel-vue): workers in production.

## Sources

- [Laravel 13: Queues](https://laravel.com/docs/13.x/queues)
- [Laravel 13: File storage, downloads](https://laravel.com/docs/13.x/filesystem#downloading-files)
- [Inertia v3: Partial reloads](https://inertiajs.com/docs/v3/data-props/partial-reloads)
