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
exportsrow (pending), dispatchBuildExport, redirect to its page. - The job writes to the private
localdisk and setsready, orfailedinfailed(). - Vue polls
router.reload({ only: ['export'] })every 2s fromonMountedwhilepending. - Download through a controller that checks the owner; use a plain
<a>.
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#
php artisan make:model Export -m
php artisan make:job BuildExportSchema::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.
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#
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.
<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:
<Link :href="`/exports/${export.id}/download`">Download</Link><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#
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
jobstable 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#
- Laravel Reverb with Vue: replace the poll with a push.
- Inertia partial reloads
- Deploy a Laravel + Vue app: workers in production.



