You inherited a three-year-old Laravel app. Forty models, sixty controllers, no tests worth the name. It goes public in two weeks and someone has to find the security holes first.
So you point Claude Code at the repo. “Audit every model and controller for missing authorization, mass-assignment, and N+1 queries.”
It reads the models. It starts on the controllers. And somewhere around file 50, it has forgotten what file 5 even did. The summary it hands you is confident, tidy, and covers maybe a tenth of the app. The other 180 files? It never really looked.
That isn’t a prompt problem. You can’t prompt your way out of it.
Why one agent loses the thread
A normal Claude Code session is one agent in one context window, deciding its next move turn by turn. That loop is perfect for “fix this function.” It falls apart at “read these 200 files.”
Your codebase is bigger than the model’s attention. One train of thought, stretched across hundreds of files, degrades as the window fills. The early files fall out of focus. The model starts pattern-matching instead of reading.
Worse, it doesn’t tell you. It writes the confident summary anyway.
You need the opposite of one long session. You need many short ones, each reading a little, none of them carrying the whole codebase at once.
What a dynamic workflow actually inverts
That is what a workflow gives you. A workflow flips the loop on its head.
Instead of the model deciding what to do next, a plain JavaScript file decides. The script holds the control flow: the loops, the fan-out, the order of operations. The agents it spawns only think. Each one gets a fresh context, does one small job, and hands back a result.
The script decides. The agents think. That split is the whole idea.
Now, the name. Anthropic calls these “dynamic workflows,” and the word “dynamic” trips people up. It does not mean the run rewrites itself as it goes. The execution is fixed: the same script with the same inputs runs the same way every time, which is exactly what lets a half-finished run resume from where it stopped.
“Dynamic” means something narrower. It means you describe the job in plain English and Claude writes the orchestration script on the spot, for that job, instead of you hand-coding one in advance. Claude authors the script. The runtime then executes it like fixed code, in the background, and hands you a run ID while it works.
So drop the picture of hundreds of agents improvising in a swarm. The real picture is calmer: one script you can read, running a known set of jobs, in parallel, off to the side.
Not the thing you already built
If you read my post on multi-agent orchestration in Laravel, park that mental model. It does not apply here.
That post was about the Laravel AI SDK: agents inside your app, delegating to each other at runtime, answering your users. This is the other end of the toolchain. These agents run on your codebase, at your desk, to help you ship. Your users never see them.
It is also not a few other things it gets confused with:
- Slash commands and skills are prompts the model expands and reasons over inside your main conversation. The model still decides the flow.
/looprepeats one agent on a timer. It runs the same job again and again. Fanning many jobs out at once is a different tool.- Cron and scheduled agents decide when a run fires. That is orthogonal to how the run is built.
A workflow is the one where code, not the model, holds the steering wheel.
pipeline versus the parallel barrier
There are two ways to run many jobs, and picking the wrong one is the most common mistake. You already know both shapes from Laravel queues.
pipeline() is one queued job per item, each running the same fixed steps. There is no checkpoint between steps. A fast file finishes its whole chain while a slow file is still on step one. Nobody waits at a stage boundary.
// pipeline: one "job" per file, each running the same two steps.
// No barrier between steps — a clean file is done while a messy one is still on step 1.
const fixes = await pipeline(
files,
(path) => agent(`Read ${path} and list its risky queries.`),
(risks, path) => agent(`Given these risks in ${path}, write the fix:\n${risks}`),
)
That second step receives two things: the result of the step before it, and the original file path. So each item carries its own context down the chain.
parallel() is the opposite. It is Bus::batch()->then(). Every job starts at once, and the next line waits for all of them to finish before it runs.
// parallel: every file at once, then a barrier. The next line waits for all of them.
const summaries = await parallel(
files.map((path) => () => agent(`Summarize the model in ${path} in one line.`)),
)
const index = summaries.filter(Boolean).join('\n')
Here is the rule that keeps it straight. Reach for pipeline() by default. Reach for the parallel() barrier only when the very next step needs the complete set: to rank everything, to dedupe across all of it, or to merge it into one report. If you don’t need every result in the same place at the same time, the barrier just makes your fast jobs sit and wait.
One sharp edge worth knowing: in parallel(), a job that fails comes back as null instead of crashing the run, so you filter those out with .filter(Boolean). That holds for the normal case, an agent that errors mid-task. (If you throw synchronously while building the job list itself, that does take the whole call down. Keep your jobs as async functions and you stay on the safe path.)
Solution: the audit that cross-examines itself
Now build the real thing. The audit that one long session never finishes.
Start with the shape. One agent reads each file. Then a second wave of agents tries to disprove every issue. Only the survivors reach the report.
export const meta = {
name: 'laravel-audit',
description: 'Audit models and controllers for mass-assignment, N+1, missing authorization, and missing validation.',
whenToUse: 'Run on a branch before review to surface the judgment calls a linter cannot make.',
phases: [
{ title: 'scan', detail: 'One agent per file flags suspect code.' },
{ title: 'refute', detail: 'Skeptics try to disprove each finding.' },
{ title: 'report', detail: 'Dedupe and rank the survivors into one report.' },
],
}
// File list captured up front and passed in, so the run is repeatable.
const { files = [] } = JSON.parse(args || '{}')
// Each file flows scan -> refute on its own. pipeline() resolves once every file is done,
// which is exactly when you want to rank them.
const perFile = await pipeline(
files,
(path) => agent(scanPrompt(path), { phase: 'scan', label: `scan:${path}`, schema: ISSUES }),
(scan, path) => parallel(
scan.issues.map((f) => () =>
agent(refutePrompt(path, f), { phase: 'refute', label: `refute:${path}`, schema: VERDICT })
.then((v) => ({ ...f, path, keep: v.keep, reason: v.reason })),
),
),
)
// The one place a barrier earns its keep: you need every survivor together. Guard for null
// (a refute agent that errored comes back as null), then dedupe so the same issue is never
// listed twice. This is the pipeline-vs-barrier lesson in practice: dedup is what a barrier is for.
const survivors = perFile.filter(Boolean).flat().filter((f) => f && f.keep)
const seen = new Set()
const deduped = survivors.filter((f) => {
const key = `${f.path}|${f.concern}|${f.snippet.replace(/\s+/g, ' ').trim()}`
if (seen.has(key)) return false
seen.add(key)
return true
})
log(`${deduped.length} issues survived refutation.`)
return agent(reportPrompt(deduped), { phase: 'report', label: 'report' })
Read the pipeline() call closely. Each file scans, then refutes its own issues, on its own timeline. A clean model finishes early. A messy controller takes longer. Neither blocks the other. The barrier only comes at the end, where you genuinely need every survivor in one place to dedupe and rank them.
One ordering note before the pieces. The schemas and prompt helpers below sit above this orchestration in the real file. I show them after it so you read the shape first, but const definitions have to come before the code that uses them.
Here is the scan agent. It looks for the four things that ship silently broken in Laravel apps: mass-assignment, N+1 queries, missing authorization, and missing validation.
const ISSUES = {
type: 'object',
additionalProperties: false,
properties: {
issues: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
properties: {
concern: { type: 'string', enum: ['mass-assignment', 'n+1', 'authorization', 'validation'] },
severity: { type: 'string', enum: ['low', 'medium', 'high'] },
snippet: { type: 'string' },
why: { type: 'string' },
},
required: ['concern', 'severity', 'snippet', 'why'],
},
},
},
required: ['issues'],
}
const scanPrompt = (path) =>
`Read ${path}. You may read its callers for context, but flag ONLY issues whose code lives in ${path} itself. ` +
`Look for these four: mass-assignment (empty or missing $guarded/$fillable), N+1 (a relation used in a loop ` +
`without eager loading), missing authorization (a mutating action with no policy, Gate, or can() check), and ` +
`missing validation (no FormRequest or validate() call). For each, give the snippet, a severity, and why it is ` +
`risky here, not in the abstract. If the file is clean, return an empty array. Do not invent issues, and do not ` +
`report issues that belong to another file.`
Passing a schema does real work. The agent can’t ramble back prose. It has to return an object shaped like ISSUES, validated before your script ever sees it. No parsing, no regex, no hoping. You get back data.
Then the part that makes this worth running. The refute pass.
const VERDICT = {
type: 'object',
additionalProperties: false,
properties: {
keep: { type: 'boolean' },
reason: { type: 'string' },
},
required: ['keep', 'reason'],
}
const refutePrompt = (path, f) =>
`A scan flagged a ${f.concern} issue in ${path}: ${f.snippet} (${f.why}). ` +
`Your job is to refute it. Re-read ${path} and its callers. Could this be deliberate and safe? ` +
`For example: $guarded = [] where the input is built server-side, an N+1 on a route that runs once, ` +
`authorization already enforced by route middleware upstream. Default to skeptical. ` +
`Return keep = true only if the issue clearly survives that scrutiny.`
const reportPrompt = (issues) =>
`Write one Markdown audit report from these verified issues. Group by concern, ` +
`severity first, and give each a file, a snippet, and a concrete Laravel fix ` +
`(a FormRequest, an eager load, a policy). Issues:\n${JSON.stringify(issues)}`
This is the step nobody else builds. Every linter you’ve ignored ignored you back for one reason: false positives. A flagged $guarded = [] that was safe all along. An N+1 on a route that runs once a month. After the tenth false alarm, you stop reading the output.
The refute pass spends a fresh agent on each issue whose entire job is to prove the scan wrong. An issue that can’t be argued away is one you’ll actually act on. You traded raw volume for a list you trust.
I ran this against a real sample app to be sure it holds up. Two models carried the same $guarded = []. The refute pass kept the one a controller actually mass-assigns and threw out the other: a skeptic agent grepped the codebase, found no request-fed write path to it, and called it hardening debt instead of a live hole. Same code, opposite verdict, decided by reachability. That is the call a linter never makes.
A note on feeding each agent the right context. Capture what it needs up front and pass it through args, which arrives as a string you parse:
# Capture the file list once, hand it to the workflow.
find app/Models app/Http/Controllers -name '*.php' > /tmp/audit-files.txt
If you run Laravel Boost, each spawned agent can reach its MCP tools the same way an agent reaches any connected server, so route lists and schema are available without you pasting them. Confirm that in your own setup before you lean on it. The portable move is the one above: gather the context first, pass it in.
One honest line. Enlightn and Larastan already do deterministic versions of some of these checks, and they’re faster and cheaper at it. The workflow is not there to re-run static analysis. It is there for the judgment calls a static tool can’t make (“is this missing authorization deliberate?”) and to read 200 files without one context window melting under them.
Solution: when the workflow alone isn’t enough
The audit is read-only. Nothing on disk changes, so you can run it on anything without fear. The next job is harder, and it teaches the real limit.
A Laravel 11 to 12 upgrade.
Run Laravel Shift first. It does the mechanical 80% deterministically and for a few dollars, and no language model beats it on that. Point the workflow at what Shift leaves behind: the files it flagged “you must do this by hand,” and the call sites of APIs that got removed with no clean replacement.
The shape is a pipeline with a gate at the end: detect, transform, then verify against the test suite.
export const meta = {
name: 'laravel-upgrade-residue',
description: 'Fix the manual residue of a Laravel major-version upgrade, gated by the test suite.',
whenToUse: 'After Laravel Shift, on a dedicated upgrade branch.',
phases: [
{ title: 'detect', detail: 'List the manual TODOs and removed-API call sites.' },
{ title: 'transform', detail: 'One isolated agent per file applies the fix.' },
{ title: 'verify', detail: 'Run the suite; route failures back.' },
],
}
const { workList = [] } = JSON.parse(args || '{}')
const VERIFY = {
type: 'object',
additionalProperties: false,
properties: {
green: { type: 'boolean' },
failingFiles: { type: 'array', items: { type: 'string' } },
summary: { type: 'string' },
},
required: ['green', 'failingFiles', 'summary'],
}
let pending = workList
for (let round = 0; round < 2 && pending.length; round++) {
// Each agent edits ONE file, in its OWN worktree, so parallel writes never collide.
await parallel(
pending.map((w) => () =>
agent(`Apply the Laravel 11->12 fixes to ${w.file}. Items: ${JSON.stringify(w.items)}. ` +
`Edit only this file. Keep behavior identical.`,
{ phase: 'transform', label: `fix:${w.file}`, isolation: 'worktree' }),
),
)
const check = await agent(
`Run composer test and phpstan. Return green plus the files that caused failures.`,
{ phase: 'verify', label: `verify:${round}`, schema: VERIFY },
)
if (check.green) return check.summary
pending = workList.filter((w) => check.failingFiles.includes(w.file))
}
return 'Still red after two rounds. Hand the failing files to a human.'
Two things make this case different from the audit.
First, these agents write files. Run them in one shared directory and two agents editing the same factory will clobber each other. That’s what isolation: 'worktree' is for: each agent gets its own git worktree, so parallel edits never touch the same working tree. The rule that saves you: one file, one agent, never shared. Never let two agents edit the same factory or the same base test class. (I’d treat the worktree flag as the load-bearing piece to confirm against your own runtime before a big sweep. It’s the one part of this post I haven’t stress-tested at scale.)
Second, the test suite is the gate. The verify step runs composer test, and any file whose tests go red gets handed back for another round. The suite, not the model’s confidence, decides what “done” means. That’s the same instinct behind testing AI features in Laravel: trust the assertion, not the vibe.
The honest part: limits, cost, and the wrong tool
A workflow is not free, and it is not always right.
The ceiling is real. You get about 16 agents running at once, and 1000 total across a whole run. “Hundreds in parallel” is marketing, not the number. On a big fan-out, the wallet runs out before the wall clock does.
And the cost is real. I built the research for this post with a workflow: 18 agents reading the web and the docs burned roughly 912,000 tokens. A small probe with 2 light agents cost about 49,000. The lesson in those numbers is simple. Per-agent cost tracks how much each agent reads. An audit agent that opens four Laravel files is cheap. A research agent that crawls the web is not.
So here are the Laravel jobs where a workflow is the wrong tool, and you should feel free to not use it:
- Scaffolding one model with its migration, factory, and policy.
php artisan make:model Order --allalready does this. Spawning agents for it is pure overhead. - Renaming a class or a column across the app. That wants one atomic, coordinated edit. Your IDE or Rector does it correctly. Fan-out invites half-finished renames.
- A big controller-to-action refactor. Too much shared, mutable surface for parallel writers. Often a focused single agent is better.
The same audit shape, by the way, reviews a pull request well: one agent per lens (security, performance, migration safety) against the diff, then one pass to merge them. Read-only, fast, cheap. Worth a try on your next PR.
Save it as a command your team runs
Drop that audit script into .claude/workflows/laravel-audit.js and commit it. Now it isn’t a one-off you retype. It’s a command anyone on the team runs by name before they open a PR.
The whole thing, the sample app and both workflows, is on GitHub at MujahidAbbas/laravel-claude-workflows. Clone it and run the audit against it before you point it at your own code.
Think about what changed.
Before, you ran one session against 200 files, watched it drift, and squinted at a summary you half-trusted. You were the bottleneck and the babysitter.
Now you hand the job to a script. It fans the reading across agents that never lose the thread, sends skeptics to tear down every flag, and returns a ranked list that already survived its own cross-examination. You read the result. You don’t supervise the run.
The first time you point it at that inherited app and watch sixty controllers get read in parallel, the old way stops making sense. You stop asking one agent to hold your whole codebase in its head. You start writing the script that never has to.