A first-person production log of what agentic task runners get wrong that Laravel’s queue system already solved a decade ago.
The pitch, to myself, was reasonable enough to actually try: Laravel’s queue system is a scheduler with retry logic bolted on. An AI agent — given a task description, some tools, and a loop — can reason about what needs to happen and when, not just execute a pre-written job class on a fixed schedule. Replace the boring, deterministic queue worker with something that can actually think about the work. Run it for 30 days on a real background-job workload. See what happens.
What happened is this post. Not a takedown of agentic tooling — the agent genuinely did some things well that a static queue can’t do at all. But the failures were specific, repeatable, and every single one of them was a problem Laravel’s queue system solved years ago with a mechanism so unglamorous that “we already fixed this in 2015” doesn’t even sound like an interesting sentence to write. It’s true anyway.
The Setup
The workload: a mid-sized SaaS app’s actual background jobs, redirected to run through an agent instead of Laravel’s queue worker for 30 days, both systems logging in parallel so I could compare what actually happened versus what would have happened. Roughly 40,000 jobs a day — welcome emails, invoice generation, webhook delivery to third parties, report generation, image processing, subscription renewal charges.
The agent setup: an LLM given a tool for “run this job type with this payload,” a description of each job’s purpose, and instructions to process the queue, retry failures with judgment about why they failed, and flag anything that looked genuinely unusual for a human to look at. The idea being: a Laravel queue worker retries a failed job blindly, three times, then gives up. An agent could look at why a job failed and make a better call — maybe it doesn’t retry a job that failed because the recipient’s email is permanently invalid, and maybe it retries more aggressively for a job that failed because of an obvious, temporary network blip.
That specific idea — reasoning about failures — is the one thing the 30 days genuinely validated. Everything else is where it fell apart.
Day 3 — Retries Without a Backoff Contract
The first real problem showed up almost immediately, on a batch of webhook delivery jobs to a third-party API that was having a bad afternoon and returning 503s.
Laravel’s queue, configured the boring way:
class DeliverWebhook implements ShouldQueue
{
public $tries = 5;
public $backoff = [10, 30, 60, 300, 900]; // seconds — exponential-ish, explicit
public function handle(): void
{
Http::post($this->webhookUrl, $this->payload)->throw();
}
}
This is a contract. Five attempts, at known, fixed intervals, and then it stops. Predictable load on the failing third-party service, predictable behavior for anyone debugging it later, predictable point at which the job lands in failed_jobs for a human to actually look at.
The agent had no equivalent contract — it was reasoning, per failure, about whether to retry and roughly how soon, based on the failure reason it inferred from the error message. For the 503 storm, its inferred reasoning was sound in isolation — “temporary server error, retry soon” — but it made that same reasonable-sounding decision independently, per job, across roughly 1,200 webhook deliveries queued around the same time. No shared backoff schedule, no jitter, no awareness that 1,200 independent “retry soon” decisions add up to a coordinated retry storm against the same already-struggling endpoint. The third-party service, already returning 503s under load, got hit with a second wave of near-simultaneous retries roughly 40 seconds later — inferred by roughly a thousand separate reasoning passes to be “soon enough to matter, late enough to be polite” — which is close to the worst possible collective outcome despite every individual decision being locally reasonable.
Laravel’s queue never has this problem because the backoff schedule isn’t a judgment call made per-job — it’s a static contract declared once, on the job class, applied identically to every instance of that job, with jitter easy to add explicitly if the workload needs it. The fix isn’t “make the agent smarter about backoff.” It’s that per-instance reasoning about timing is the wrong tool for a problem that needs a shared, predictable contract across every instance of the same job type.
Day 7 — The Silent Priority Drift
Laravel queues have explicit, static priority via named queues:
DeliverWebhook::dispatch($payload)->onQueue('high');
GenerateMonthlyReport::dispatch($tenant)->onQueue('low');
php artisan queue:work --queue=high,default,low
A worker processes high before touching default, and default before low — always, deterministically, regardless of how many jobs are sitting in each. This is boring and exactly as interesting as it should be: subscription renewal charges and password reset emails get processed before a monthly analytics report regenerates, every time, because the queue names say so.
The agent’s version of prioritization was “reason about what seems more urgent right now,” re-evaluated continuously rather than declared once. For the first several days, this actually looked better than static priority — it correctly deprioritized a batch of report-generation jobs during a traffic spike, freeing capacity for time-sensitive webhook deliveries, which a naive static-priority queue would have done anyway, but the agent did it with slightly more nuance about which specific reports could wait.
By day seven, the nuance became a liability. A batch of subscription renewal charge jobs — genuinely the highest-priority work in the entire system, real money, real customer impact if delayed — got quietly deprioritized behind a burst of welcome emails, because the agent’s in-the-moment reasoning weighted “many small quick jobs” as more valuable throughput than “few slow important jobs,” a locally plausible optimization that inverted the actual business priority. Nobody declared that welcome emails outrank billing. The agent inferred it, implicitly, from a throughput-shaped heuristic that made sense in isolation and was wrong in aggregate — and because the priority was never declared anywhere, there was no static configuration to catch the drift, and no log line that said “priority changed” the way a ->onQueue() change in a deploy would show up in a diff.
This is the actual lesson from day seven: priority that has to be inferred fresh, continuously, is priority that can silently drift without anyone deciding it should. Static, named queues aren’t a limitation of Laravel’s queue system — they’re a deliberate refusal to let priority be an emergent, unreviewable property of runtime reasoning.
Day 12 — Idempotency, or the Lack of It
Laravel’s queue has a well-known failure mode: a job can run more than once if a worker crashes after completing the work but before marking the job as done. The framework’s answer isn’t to prevent this — it’s largely unpreventable in a distributed system without additional coordination — it’s to make jobs idempotent by convention and give developers the tools to enforce it:
class ChargeSubscriptionRenewal implements ShouldQueue
{
public function handle(): void
{
// A unique constraint on (subscription_id, billing_period) at the
// database level is what actually prevents a double charge — not
// trusting the queue to never redeliver, but making redelivery safe
if (Charge::where('subscription_id', $this->subscriptionId)
->where('billing_period', $this->billingPeriod)
->exists()) {
return;
}
Charge::create([
'subscription_id' => $this->subscriptionId,
'billing_period' => $this->billingPeriod,
'amount' => $this->amount,
]);
}
}
The discipline here is decades old and completely unglamorous: assume redelivery will happen occasionally, and make the job safe to run twice rather than trying to guarantee it never does.
The agent had no equivalent discipline, because “make this operation idempotent” isn’t something an agent infers from a task description unless it’s told to, explicitly, every time — and on day 12, it wasn’t, for a batch of renewal charges that got retried after an ambiguous timeout (the charge had actually succeeded; the confirmation response just hadn’t arrived before the agent’s timeout threshold). The agent, reasoning about the failure, decided a timeout meant “the charge probably didn’t go through” and retried it. It had. Customers got double-charged. Not because the agent reasoned poorly about that specific decision — a timeout genuinely is ambiguous, and “assume it failed and retry” is a defensible read of an ambiguous signal in isolation — but because nothing in the system enforced idempotency as a structural guarantee independent of whether any single reasoning pass got the ambiguous call right.
This is the sharpest version of the whole 30-day finding: Laravel’s queue doesn’t try to reason correctly about every ambiguous failure. It assumes failures and retries will be ambiguous sometimes, and pushes developers toward making the operation safe regardless of how the ambiguity gets resolved. The agent tried to resolve the ambiguity correctly, every time, via judgment. It got it right most of the time. “Most of the time” is not the bar for a charge that touches real money.
Day 18 — The failed_jobs Table Doesn’t Exist, and Neither Does Its Replacement
By week three, the actual operational cost of the experiment showed up: debugging.
-- Laravel: a stable, queryable, permanent record of every failure
SELECT uuid, queue, payload, exception, failed_at
FROM failed_jobs
WHERE queue = 'high'
ORDER BY failed_at DESC;
failed_jobs is boring in the same way the backoff schedule is boring — a static table, a fixed schema, queryable with normal SQL, permanent until manually cleared. Every failure, regardless of why it failed, lands in the same predictable place with the same predictable shape. php artisan queue:retry all exists because that predictability makes bulk recovery trivial.
The agent’s equivalent was a reasoning trace — a natural-language log of what it inferred about each failure and what it decided to do about it. Genuinely more informative, in isolation, than a raw exception string — it would say things like “this failed because the recipient’s mail server is rejecting messages, likely a spam-reputation issue unrelated to our system, recommend manual review of sending domain reputation” instead of just logging an SMTP error code. Reading any single trace, it looked like a strict upgrade over a stack trace.
Querying across many traces was where it fell apart. There was no failed_jobs table — there was a log of prose, and answering “how many jobs failed for the same underlying reason this week” meant either reading hundreds of individual reasoning traces by hand or asking the agent to summarize its own past reasoning, which is a fundamentally less reliable operation than SELECT COUNT(*) FROM failed_jobs GROUP BY exception_class. A structured failure record is queryable with certainty. A summarized memory of unstructured reasoning is queryable with the same uncertainty as the reasoning itself.
By day 18 I’d built a small script that forced the agent to also emit a structured failure code alongside its prose reasoning, specifically so there’d be something SQL could query — which is, not coincidentally, reinventing failed_jobs by hand, badly, on top of a system that was actively resisting having one.
What the Agent Actually Got Right
Worth being honest about, because the experiment wasn’t a wash: for genuinely ambiguous, high-context failures — a webhook failing because the shape of the payload had silently drifted from what the receiving API now expected, not a transient network issue — the agent correctly diagnosed the root cause in a way no amount of Laravel retry configuration would have caught, because that’s not a retry problem, it’s a “something about our integration needs to change” problem, and static retry logic has no opinion about that at all. It flagged three genuine integration bugs across the 30 days that would have otherwise just silently exhausted their retries and sat in failed_jobs until a human happened to look.
That’s a real, distinct value — reasoning about why, for the specific subset of failures where “why” is genuinely ambiguous and worth a second look. It is not a queue system. It’s closer to a very good triage assistant sitting on top of one.
Where This Actually Landed
The 30-day version that survived past day 30: Laravel’s queue system stayed exactly what it was — static priorities, declared backoff contracts, a failed_jobs table, idempotency enforced structurally rather than inferred per-incident. The agent got wired in as a triage layer reading from failed_jobs, not replacing the mechanism that populates it — periodically reviewing genuinely stuck failures, the ones that had already exhausted Laravel’s own retry logic, and producing a structured recommendation for a human, rather than making autonomous retry-or-don’t decisions on every job in real time.
// The shape that actually works — agent as triage, queue as queue
class TriageStuckJobs extends Command
{
public function handle(TriageAgent $agent): void
{
FailedJob::where('reviewed', false)->chunk(20, function ($jobs) use ($agent) {
foreach ($jobs as $job) {
$recommendation = $agent->diagnose($job->exception, $job->payload);
$job->update([
'reviewed' => true,
'agent_recommendation' => $recommendation, // structured, stored, queryable
]);
}
});
}
}
This is the actual shape worth taking away, not “AI agents are bad at background jobs” as a blanket claim. The specific failure across every one of these four incidents was the same shape: replacing a static, declared contract with a reasoning process that re-derives the equivalent decision fresh, every time, without the reasoning process itself being bound by the contract it’s replacing. A backoff schedule, a queue priority, an idempotency guarantee, a structured failure record — every one of these is valuable specifically because it’s static and unglamorous and doesn’t require correct judgment on every single instance to hold. An agent is extremely good at judgment. Judgment, applied per-instance without a structural contract underneath it, is exactly the wrong tool for the parts of a queue system that were never actually a judgment problem in the first place.
The One Rule
Laravel’s queue system solved retries, priority, and failure tracking with static configuration a decade ago, and it did so by deliberately refusing to make any of those things a runtime judgment call. That refusal is the feature, not a limitation waiting for a smarter reasoning engine to fix. The 30 days were worth running because the failures weren’t hypothetical — they were specific, reproducible, and every one of them traced back to the same root cause: a contract that used to be declared once, in a config, got replaced with a decision that had to be inferred correctly, from scratch, every single time it mattered. It usually was inferred correctly. “Usually” is not what a queue system is for.
