nunomaduro/pao
Laravel PAO detects when PHPUnit, Pest, Paratest, PHPStan, Rector, or Artisan run inside an AI agent and swaps verbose human output for compact structured JSON. Zero config, installs as a dev dependency, and leaves normal terminal output untouched.
Install into the repo, dev-only:
composer require laravel/pao --dev
There is nothing to publish. No config file, no service provider registration (Laravel 12+ auto-discovers it), no artisan install command, no CI change. PAO registers itself through Composer's autoloader and hooks PHPUnit, Pest, Paratest, PHPStan, Rector, and Artisan from there.
Check you are eligible before you spend time on it. The version floor is hard and narrow: PHP 8.3+, PHPUnit 12–13, Pest 4–5, Laravel 12+. A repo on PHP 8.2 or PHPUnit 11 is out of scope — do not force it, and do not let it drag your test toolchain forward as a side effect.
Where to look first: the README's Before & After section is essentially the whole spec. There is no API surface — the contract is the JSON shape it emits. Read the changelog next; the release notes are the closest thing to documentation of behaviour changes.
First use case — let the agent read a test run. Open Claude Code (or Cursor, Devin, Gemini CLI) in the project and run your suite normally:
php artisan test
# or
vendor/bin/pest
vendor/bin/phpunit
You should get one compact object instead of a wall of dots:
{"tool":"pest","result":"passed","tests":1002,"passed":1002,"duration_ms":1520}
Then open a plain terminal and run the exact same command. The output must be byte-identical to what you had before PAO. Confirm both halves of that before you rely on it — the second half is the whole reason the package is safe to install.
The first genuinely useful moment is a failing run in an agent session: the agent gets file paths, line numbers, and messages in one read instead of grepping through progress dots.
Treat the JSON as the agent's test harness. In agent sessions, the useful loop is: run tests → parse result → if failed, read the failure details → patch → rerun. The output is constant-size, so a 1,000-test suite and a 10-test suite cost the same context. That is the point — it makes the rerun cheap, which is what actually drives multi-turn fixing.
Lean on the detail arrays rather than the summary. Test output carries skipped, incomplete, deprecations, warnings, notices, each with a matching *_details array of {file, line, message}. Day-to-day this means an agent can act on a deprecation without a human pasting anything. When you write prompts or agent instructions, tell it to read *_details and not to re-run with -v to "get more info" — flags like that are the fastest way to defeat the whole package.
Coverage and profiling still work — they land in raw. Pest plugins that print extra text (--coverage, --profile) are captured, stripped of ANSI and decoration, and appended as a raw array:
{"tool":"pest","result":"passed","tests":1002,"passed":1002,"duration_ms":1520,
"raw":["Http/Controllers/Controller 100.0%","Models/User 0.0%","Total: 33.3 %"]}
Ask for coverage only when you actually need it. raw is unbounded by design, so --coverage on a large app is where token savings quietly evaporate.
Static analysis is a two-stage loop. PHPStan output is grouped by file with line, message, and identifier, capped at 30 errors. The identifier field is the useful part — it maps to PHPStan's error identifiers, so an agent can fix a whole class of problem (return.type, method.notFound) rather than 30 individual lines. A practical workflow: let the agent fix a batch, rerun, and keep going until errors is 0. Do not expect one pass on a legacy codebase.
Rector for upgrade/migration work. PAO runs Rector with its native JSON format, giving totals.changed_files, file_diffs with applied_rectors, and changed_files. Use this when driving a framework upgrade or a mass refactor: the agent can read the actual diff and the rule names that produced it, which is far better grounding than parsing console diff output. Run a dry run first, review changed_files, then apply.
Artisan commands get cleaned, not JSON-ified. For about, db:show, migrate:status, route:list and friends, PAO strips ANSI colours, box-drawing characters, dot leaders, and excess whitespace. The output stays human-shaped — it is just dense:
Environment ..
Application Name .. Laravel
Laravel Version .. 13.3.0
This is the cheapest win in the package and the one developers notice first, because exploratory commands are what agents run constantly during debugging.
Parallel runs are supported — keep using them. pest --parallel and Paratest both produce JSON. Historically this was the rockiest area (raw dots leaking, --profile interaction), so if you run parallel in the agent loop, pin a recent version and sanity-check the output once.
Do not build CI around it. PAO is an agent-environment formatter, not a reporting tool. Your CI pipeline, coverage uploaders, and log parsers should consume the normal tool output — that is exactly what they get, since PAO deliberately stays out of the way outside agent sessions. If you want to pilot it in CI, do so in a separate throwaway job and treat it as an experiment, not a contract.
Composer scripts are a reasonable seam. Point your agent at the same script names humans use (composer test, composer analyse). PAO's behaviour is decided by the environment, not by which script invoked it, so you get one set of commands that works for both audiences.
Agent detection is a heuristic, and false positives are the dangerous direction. PAO keys off environment signals set by Claude Code, Cursor, Devin, Gemini CLI, and similar. A false negative is harmless — you get normal output. A false positive means JSON appears where a human or a parser expects text, possibly inside a CI container that happens to set similar variables. Test your pipeline containers explicitly after installing.
Verify exit codes before you trust the quality gate. The changelog shows repeated fixes here (exit code 1 on phpunit, test results being displayed on runtime exits). A formatter that ever returns success on a failing suite silently breaks your gate. Add one test to your own suite: deliberately fail a test, run through PAO, assert a non-zero exit. Do this on failure, on PHPStan errors, and on a runtime error — not just the happy path.
PHPStan errors are capped at 30. Deliberate token economics, but it's a judgement call you inherit. On a large legacy codebase the agent may never see the full error set, so it can look "done" while errors remain. Always read errors (the count) alongside error_details (the truncated list) — if the count exceeds the length of the details, keep iterating.
Don't pass output-format flags yourself. As of v1.1.3 PAO avoids repeating flags the caller already passed. Passing --teamcity, --log-junit, or a Rector --output-format manually can conflict with the interception. Let PAO choose the format.
Expect to upgrade in lockstep, forever. Most releases are compatibility patches for upstream printer and plugin internals — PHPUnit 13.2.x with Paratest, PHPUnit 13.3.0, Pest fatal errors, beforeEach/afterEach failures. This is structural, not incidental: PAO hooks internals that move. Budget for bumping it whenever you bump PHPUnit or Pest, and prefer pinning a known-good combination over floating.
Pin the canonical package name. The package moved from nunomaduro/pao to laravel/pao in v1.0.6. Anything you write — docs, onboarding notes, agent instruction files, allowlists — should say laravel/pao.
No published schema, almost no external validation. Zero dependents means the JSON shape has been field-tested mostly by the author. Don't write strict validators against it; parse defensively and tolerate new keys. Pin versions if you do parse programmatically.
Debugging "it isn't working": in order — (1) are you actually in a detected agent environment, (2) does the repo meet PHP 8.3+ and the tool version floors, (3) did you run composer dump-autoload after install (the hook is autoloader-based), (4) is a Pest/PHPUnit plugin writing to a stream PAO doesn't capture — check raw, since v1.1.5 bypasses the formatter for raw output specifically.
Extension points are effectively nil. No config, no published events, no output hooks. Treat it as a black box: if the emitted shape doesn't suit you, post-process the JSON rather than trying to fork the formatter. Forking means owning the upstream-internals churn yourself, which is the one cost this package exists to absorb.
Keep it --dev. It has no business in a production install; PAO is a dev-loop observability shim and never touches application code, HTTP paths, queues, or the database. The one real risk of a dev dependency is lockfile resolution — isolate any pilot to repos that already have a clean dependency graph.
How can I help you explore Laravel packages today?