A synchronous AI call ties up a PHP worker for anywhere between three and thirty seconds while the model thinks. Do that inside a web request and you burn a worker slot, flirt with request timeouts, and leave the user staring at a spinner. The fix is to move the generation off the request cycle. This is where the Laravel AI SDK queue comes in: dispatch the agent run to a worker, track it, and update the UI when the result lands.
Why the Laravel AI SDK queue keeps requests fast#
A PHP-FPM worker handling a request is blocked for the entire duration of the model call. Under any real load, a handful of concurrent AI requests can starve your pool, and a slow provider will trip your 30-second request timeout long before the model finishes. Pushing the work onto a queue frees the request immediately and lets a dedicated worker — with its own generous timeout — do the waiting.
You already reach for the queue when a job is slow or spiky. Long AI generations are both. If you've read my take on choosing between queue chains and batches, the same instinct applies here: anything that blocks for seconds and can fail mid-flight belongs on a worker, not in the request.
Dispatch the agent run with the Laravel AI SDK queue method#
Start with a normal agent. If you don't have one yet:
php artisan make:agent SalesCoach
The synchronous version blocks the request until the model responds:
// Blocks the web worker for the whole generation — avoid in a request.
$response = (new SalesCoach)->prompt($request->input('transcript'));
Swap prompt for queue. It pushes the generation onto your queue connection and returns immediately. The then and catch methods register closures that run on the worker when the response is ready or an exception is thrown:
use Laravel\Ai\Responses\AgentResponse;
use Throwable;
(new SalesCoach)
->queue($request->input('transcript'))
->then(function (AgentResponse $response) {
// Runs on the worker once the model responds.
// $response->text, $response->usage, $response->conversationId
})
->catch(function (Throwable $e) {
// Runs on the worker if the provider call fails.
});
That alone gets the work off the request. But those closures fire on a worker with no view, so you need somewhere to put the result. The same background execution wraps any agent, including ones that call typed function tools mid-generation.
Track the run with your own model#
The SDK doesn't return a run row you can query later — the queue method is fire-and-forget. So persist your own record before you dispatch, and let the closures update it. A small model with a UUID primary key does the job:
Schema::create('ai_runs', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('user_id')->constrained();
$table->string('status')->default('pending'); // pending|running|completed|failed
$table->string('idempotency_key')->unique();
$table->longText('output')->nullable();
$table->text('error')->nullable();
$table->timestamps();
});
Create the run, then capture its id — not the model — in the closures. Capturing the id keeps the serialized job payload tiny and avoids acting on a stale copy of the model:
use App\Models\AiRun;
use Illuminate\Support\Str;
use Laravel\Ai\Responses\AgentResponse;
use Throwable;
$run = AiRun::create([
'id' => (string) Str::uuid(),
'user_id' => $request->user()->id,
'idempotency_key' => $key, // built in the dedupe step below
'status' => 'running',
]);
$runId = $run->id; // the Laravel AI run id you'll poll against
(new SalesCoach)
->queue($request->input('transcript'))
->then(fn (AgentResponse $response) => AiRun::whereKey($runId)->update([
'status' => 'completed',
'output' => $response->text,
]))
->catch(fn (Throwable $e) => AiRun::whereKey($runId)->update([
'status' => 'failed',
'error' => $e->getMessage(),
]));
return back();
Now the run's lifecycle lives in a table you own. If you want the model's own turn-by-turn history alongside this, the RemembersConversations trait covered in persisting AI SDK conversation history stores messages independently of your status row.
Poll the AI run status from Livewire#
The simplest way to surface the result is to poll the run model from a Livewire component. Bind the run and refresh it on a timer:
use App\Models\AiRun;
use Livewire\Component;
class AiRunStatus extends Component
{
public AiRun $run;
public function poll(): void
{
$this->run->refresh();
}
public function render()
{
return view('livewire.ai-run-status');
}
}
Gate wire:poll on the status so it stops hammering the server the moment the run finishes:
<div @if ($run->status === 'running') wire:poll.2s="poll" @endif>
@if ($run->status === 'completed')
<div class="prose">{{ $run->output }}</div>
@elseif ($run->status === 'failed')
<p class="text-red-600">That run failed: {{ $run->error }}</p>
@else
<p>Working on it…</p>
@endif
</div>
That conditional matters — an unconditional wire:poll keeps firing forever. The same self-terminating pattern drives a live dashboard with wire:poll.
If you'd rather stream tokens as they generate than wait for the whole response, skip polling and use broadcastOnQueue, which queues the run and pushes each event over WebSockets:
use Illuminate\Broadcasting\Channel;
(new SalesCoach)->broadcastOnQueue(
$request->input('transcript'),
new Channel("ai-run.{$run->id}"),
);
That path needs Laravel Reverb for the socket layer. Running it under load is its own topic — scaling Reverb in production covers the presence-channel and connection concerns.
Dedupe double-submits with an idempotency key#
A double-clicked button or an accidental resubmit will happily queue the same prompt twice — and bill you for two generations. Derive a key from the user and the input, put a unique constraint on it (already in the migration above), and firstOrCreate the run before dispatching:
$key = hash('sha256', $request->user()->id.'|'.$request->input('transcript'));
$run = AiRun::firstOrCreate(
['idempotency_key' => $key],
[
'id' => (string) Str::uuid(),
'user_id' => $request->user()->id,
'status' => 'running',
],
);
if (! $run->wasRecentlyCreated) {
return back(); // Already queued or finished — reuse it, don't dispatch again.
}
// ... dispatch the queued agent run here, using $run->id
The unique index is doing the real work: even if two requests race in the same millisecond, the database rejects the second insert, so only one run is ever dispatched.
Gotchas and edge cases#
Set a realistic worker timeout. AI jobs routinely run 30–120 seconds, far longer than a typical job. Run a dedicated queue with a generous timeout — php artisan queue:work --queue=ai --timeout=180 — and make sure the connection's retry_after in config/queue.php is larger than that timeout, or the queue will re-dispatch the job while it's still running.
Keep retries from double-billing. If the provider call throws, catch fires — but if the whole job is retried, the prompt runs again. Set AI jobs to a single attempt (--tries=1) or make your completion handler idempotent so a retry can't charge you twice. Pairing that with job middleware for rate limiting and backoff keeps a flaky provider from stampeding your queue.
Only capture serialisable values in the closures. The then/catch closures are serialized with the job. Capture scalars like the run id, not the request or a fat Eloquent model — a model with loaded relations bloats the payload and can deserialize stale.
Watch the cost, not just the latency. Moving work to the background makes it easy to fire off far more generations than you did synchronously. Wire up token usage and cost tracking early so background volume doesn't quietly blow the budget.
Wrapping up#
Reach for queue() the moment an agent call would otherwise block a request. Persist your own run row, update it in then/catch, poll it from Livewire or broadcast it over Reverb, and guard the dispatch with an idempotency key. Next, add token and cost tracking so you can see what all that background work actually costs, and make the whole thing safe to ship by faking agents in your Pest tests.
FAQ#
How do I run a Laravel AI SDK call in the background?
Call the agent's queue method instead of prompt. It pushes the generation onto your queue connection and returns immediately, so the HTTP request finishes without waiting for the model. Register then and catch closures to handle the response or an exception once a worker processes the job.
How do I track a queued AI agent run?
The SDK doesn't give you a run row, so create your own. Persist a small model — I use an AiRun with a UUID and a status column — before you dispatch, capture its id inside the then and catch closures, and set the status to completed or failed there. That UUID becomes the Laravel AI run id you poll or broadcast against.
How do I handle errors from a queued AI job?
The catch closure fires with the Throwable when the provider call fails, so record the message and mark the run failed there. Keep AI jobs at a single attempt, or make the completion handler idempotent, so a retried job doesn't run the prompt — and bill for it — a second time.
How do I show AI results to the user when the job finishes?
You have two options. Poll the run model from a Livewire component with wire:poll and stop polling once the status leaves running, or push the result over WebSockets with broadcastOnQueue and Laravel Reverb. Polling is simpler to stand up; Reverb is the better fit when you want token-by-token streaming.
How do I stop duplicate AI jobs from running twice?
Derive an idempotency key from the user and the prompt, put a unique constraint on it, and firstOrCreate the run before dispatching. If the row already existed you skip the dispatch, so a double-click or accidental resubmit reuses the first run instead of paying for a second generation.