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.

For your AI agent

Copies a prompt. Paste it into the AI agent you already have open.

Read https://laravel-vuejs.space/laravel-vue-queued-job.md
Dispatch the job, store pending, ready, or failed, and reload that row in the Vue page until the file can be downloaded.
Do not create a second jobs migration.
Do not write this file onto the public disk, and do not copy it under storage/app/public.
Do not read window in setup.
Show the diff. Do not commit.

Next: Laravel database notifications in Vue

On this page · 8 sections
  1. The row and the job
  2. Dispatch, then show the row
  3. The Vue page
  4. A test
  5. Recap
  6. FAQ
  7. Next steps
  8. Sources

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.

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.

The row and the job#

bash
php artisan make:model Export -m
php artisan make:job BuildExport
database/migrations/xxxx_create_exports_table.phpphp
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.phpphp
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.phpphp
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. 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.vuevue
<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, then A real download:

resources/js/pages/exports/Show.vuevue
<Link :href="`/exports/${export.id}/download`">Download</Link>
resources/js/pages/exports/Show.vuevue
<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. 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.phpphp
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#

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.

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.

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#

Sources#

Found this useful?

Ask questions, share what you built.

Join thousands of Laravel and Vue developers in our Facebook group, or get new posts in your Telegram feed. Sharing this article helps too.

Keep reading

Newsletter

Get the best of Laravel & Vue, once a month.

One short email with new guides, release notes worth reading and hand-picked community links. No spam, unsubscribe any time.