alex-frolov / query-guard
Detects N+1, duplicate and otherwise problematic SQL queries during a regular PHPUnit run
Requires
- php: >=8.2
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- doctrine/dbal: ^3.9 || ^4.0
- doctrine/orm: ^2.20 || ^3.0
- friendsofphp/php-cs-fixer: ^3.64
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/events: ^11.0 || ^12.0 || ^13.0
- phpstan/phpstan: ^2.1
- symfony/cache: ^6.4 || ^7.0 || ^8.0
- symfony/var-exporter: ^6.4 || ^7.0 || ^8.0
Suggests
- doctrine/dbal: Doctrine adapter: query interception via a DBAL middleware (^3.0 || ^4.0)
- doctrine/orm: Doctrine adapter: without it findings cannot name the lazy-loaded association, and n-plus-one stays a warning instead of an error (^2.20 || ^3.0)
- illuminate/database: Eloquent adapter: query interception via DB::listen
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-12 23:01:26 UTC
README
A PHPUnit extension that watches the queries your tests already make and reports the performance problems hiding behind them — N+1 above all.
Install it, add six lines to phpunit.xml, and run your suite as usual. No assertions
in your tests, no separate command, no code changes.
Status: 1.0.0. Everything documented below works and is covered by tests, run on PHPUnit 10.5–13, Doctrine ORM 2–3, DBAL 3–4, MySQL and PostgreSQL. The public API is listed in UPGRADING.md and does not break before 2.0; the same file names the two things a minor release may still change.
What it looks like
query-guard
tests traced: 404, queries: 25736 (in setUp: 0)
findings: 207
* [error] n-plus-one — App\Tests\Controller\TimesheetControllerTest::testExportAction
App\Entity\Timesheet::$tags — lazy-loaded association, 10 queries
src/Entity/Timesheet.php:418
* [warning] n-plus-one — App\Tests\Controller\TimesheetControllerTest::testSaveRates
50 queries of the same shape from one place, different values: SELECT r0_.id AS id_0, ...
src/Repository/TimesheetRepository.php:810
from src/Timesheet/RateService.php:96 App\Repository\TimesheetRepository::findRates
from src/Controller/TimesheetController.php:212 App\Timesheet\RateService::calculate
Those two are real: the numbers above come from running query-guard over the controller suite of an open-source Symfony application. 207 findings in 21 distinct places, including three rate lookups per timesheet on every flush.
The line under a finding is where the query left from; the from lines above it are who
asked for it. The two are rarely the same place — a lazy association is touched inside a
getter that every caller shares, and on Eloquent inside the one magic accessor every
association goes through — and the fix belongs to the caller. call-chain-depth sets how
many of them are shown, 0 turns them off.
Why not a static analyser
N+1 is not a property of a query. It is a property of a sequence of queries.
This is not a philosophical point — it is measurable. On MySQL, where plan analysis works
completely, the plan of every single query in a textbook N+1 is flawless: access_type: const, key: PRIMARY, one row examined, zero issues. The problem is that there are fifty
of them. No amount of EXPLAIN will tell you that.
Nor will an AST-based analyser: lazy loading happens when you touch a property, dynamic query builders resolve at runtime, and neither is visible in source code.
| Tool | Zone | Overlap with query-guard |
|---|---|---|
| phpstan-dba | Static SQL: result types, syntax, placeholders, statically resolvable query strings | None. It reads code, we watch a live run. Use both |
| phpstan-doctrine | DQL correctness without a database | None |
| phpunit-query-count-assertions | Query counters, duplicates, EXPLAIN — via a trait and manual assertions | Closest neighbour. See below |
| query-guard | Runtime trace: what actually ran, in what order, from where | — |
About the closest neighbour
Tested on real projects rather than read from its README:
- its duplicate detection compares SQL and bound values, so a real N+1 — where the values differ by definition — produces zero duplicates and a green test;
- lazy-loading detection requires Laravel; on Doctrine
assertNoLazyLoading()returns green without checking anything; - query timing is unavailable on Doctrine (it reuses Doctrine's logging middleware, which
records queries before execution), so
assertMaxQueryTime(0.001)passes on six real queries; - on PostgreSQL, plan analysis silently switches itself off and reports green with zero queries analysed.
query-guard writes its own DBAL middleware (so timings exist), fingerprints SQL with the values stripped (so N+1 is visible), and says out loud when a rule cannot judge instead of showing green.
Install
composer require --dev alex-frolov/query-guard
<!-- phpunit.xml --> <extensions> <bootstrap class="QueryGuard\Extension"> <parameter name="mode" value="report"/> </bootstrap> </extensions>
Then wire the adapter for your ORM — one line for Doctrine, nothing at all for Eloquent.
Under Pest it is the same two steps. Pest runs on PHPUnit, the <extensions> node in
phpunit.xml is read as usual, and the summary is printed after Collision's. There is
one thing worth knowing: pest --parallel is ParaTest, so give report-json a %token%
— see "When query-guard stays silent".
Doctrine
The middleware has to be in the connection configuration before the connection is created, which is why the extension cannot install it for you:
# config/services_test.yaml services: QueryGuard\Adapter\Doctrine\Middleware: tags: ['doctrine.middleware']
Works with Doctrine ORM 2 and 3, DBAL 3 and 4.
Eloquent
Nothing to do: Laravel discovers QueryGuardServiceProvider automatically. It subscribes
to the event dispatcher, so every connection is covered — including ones created later,
and including queries made in setUp().
Anything else
The collector is the seam. Feed it from a PDO decorator, another ORM, wherever:
use QueryGuard\Query\QueryEvent; use QueryGuard\QueryGuard; QueryGuard::collector()->record(new QueryEvent( sql: 'SELECT * FROM users WHERE id = ?', params: [42], durationMs: 0.4, stack: debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS), ));
Outside a PHPUnit run the collector is a null object: this code does nothing and costs nothing.
If you already know where the query came from, pass callsite: instead of stack: and
skip the stack walk entirely — that is what both built-in adapters do.
QueryGuard::collector(), QueryCollector::record(), QueryEvent and Callsite are
the public API for this, and the release notes treat them as such. Everything else under
QueryGuard\ is marked @internal — the adapter interfaces included — and can change in
any release; UPGRADING.md lists what is public and why the rest is not.
Rules
Tier 1 — works on day one, no reference database needed
| Rule | What it means | Default |
|---|---|---|
n-plus-one |
One query shape, one place in the code, differing values | on, threshold 3 |
duplicate-query |
Same query, same values, repeated | on, threshold 5 |
query-in-loop |
One place costing many queries of different shapes | on, threshold 5 |
no-limit |
SELECT without LIMIT against a table you flagged as large |
off until large-tables is set |
select-star |
SELECT * |
off |
query-count |
Query budget per test | off until max-queries is set |
Three rules stay silent until configured, on purpose. select-star would fire on every
Eloquent query (it is Eloquent's default); "a large table" cannot be determined from a
three-row fixture database; and a query budget is a project policy, not a constant.
n-plus-one only looks at reads, requires the repeated values to differ, and ignores
batch fetches (IN (?, ?, ?)) — that pattern is the cure for N+1, not the disease.
Tier 2 — plan rules, needs a database with real data
Enable with tier2="true". Runs EXPLAIN once per distinct query shape, on the same
connection and inside the same transaction.
Several connections are handled separately. Each is explained by itself, with its own platform driver — a query that ran on the analytics database is never explained against the primary one. A connection on a platform tier 2 does not support is named in the summary and skipped; the others keep working.
| Rule | MySQL / MariaDB | PostgreSQL |
|---|---|---|
no-possible-index |
✅ | — the platform does not report candidate indexes |
table-scan |
✅ | ✅ |
filesort |
✅ | ✅ |
temporary-table |
✅ | — no equivalent worth flagging |
Where a rule cannot work, the summary says so. A green report and "we did not look" must never be the same output.
Tier 2 needs volume. Plan rules stay quiet until a table has at least min-rows
(default 1000) rows, because on a small table the optimiser's own estimate lies — we have
watched a competitor report error: Full table scan on a five-row table. The one
exception is no-possible-index: a missing index is a fact about the schema, true even
when the table is empty.
When query-guard stays silent
A tool that shows green when it did not look is worse than no tool. Everything on this list is printed as a notice in the summary — except the last three, which are silent by design:
| Situation | What you see |
|---|---|
| No ORM found in the project | neither Doctrine nor Eloquent was found |
| ORM found, interception never took | the adapter is named, with the specific fix |
| Interception worked, zero queries all suite | not "all clear", it is "nothing to look at" |
| Queries outside a test (bootstrap, data providers) | counted, and the count is reported |
| A configuration value it could not read | the parameter, the value, and what was used instead |
tier2 on, no connection ever appeared |
no plans were looked at |
tier2 on an unsupported platform |
the platform is named; supported ones listed |
no-possible-index on PostgreSQL |
the rule says it cannot judge — not that all is well |
temporary-table on PostgreSQL |
same |
--no-output with no report-json and no baseline to write |
nothing: there is nowhere to say anything, so the extension does not load |
A table below min-rows |
nothing: plan rules deliberately do not judge small tables |
| Eloquent listener that reached one connection | the summary says which, and how to cover the rest |
| Two Doctrine connections sharing a database name | both are kept; the second is renamed and the rename is announced |
| Baseline entries that silenced nothing | counted, with what that means after a filtered run |
A third or more of the findings pointing inside vendor/ |
the share, the packages behind it, and skip-paths named |
| A third or more of the findings pointing at fixture directories | the share, the directories behind it, and fixture-paths named |
| Findings whose every stack frame was skipped | the count, and that query-in-loop cannot group them at all |
| A baseline generated on another database platform | both platforms are named, with what dialect differences do to a signature |
#[IgnoreRule] / a baseline entry |
the baseline count is shown; a per-test ignore is not |
A few more worth knowing:
- Tier 2 on Eloquent explains eagerly, right where a query fires — not lazily, when a
rule asks.
RefreshDatabase/DatabaseTransactionsroll a test's transaction back in its owntearDown(), before any rule gets to run, so waiting would mean explaining against data that is already gone. This also means anEXPLAINbriefly shows up as another query on the same connection — other listeners on the same dispatcher (Telescope, Debugbar, an application's own query logger) will see it too. - Queries made in
setUp()are not analysed. They are counted and shown separately (in setUp: N). That is the decision the whole false-positive story rests on — a factory creating 50 rows in a loop is 50 identical INSERTs from one callsite. Themax-queriesbudget is charged against the test body for the same reason. - The trace describes the program your tests run, which is not always the program you
deploy. An application that swaps an implementation under test — a cache replaced by a
no-op, a queue driver set to
sync, a service faked in a provider guarded byrunningUnitTests()— is traced honestly, and the finding is a true statement about a program nobody ships. This is a step beyond "coverage equals your tests' coverage": here the coverage is there and the trace is real, it is the subject that differs. Seen on a music streamer whose scanner memoises artist lookups in production and deliberately does not under test, which turned an N+1 that cannot happen in production into the largest cluster in the report. Worth one question when a finding looks too easy: is this class the one that runs in production? - The trace describes the program your tests run, which is not always the program you
deploy. An application that swaps an implementation under test — a cache replaced by a
no-op, a queue driver set to
sync, a service faked in a provider guarded byrunningUnitTests()— is traced honestly, and the finding is a true statement about a program nobody ships. This is a step beyond "the tool sees as much as the tests cover": here the coverage is there and the trace is real, it is the subject that differs. Seen on a music streamer whose scanner memoises artist lookups in production and deliberately does not under test, which turned an N+1 that cannot happen in production into the largest cluster in the report. Worth one question when a finding looks too easy: is this class the one that runs in production? - Under a parallel runner every worker is its own run. ParaTest starts a process per
worker, and query-guard has no way to see across them: each prints its own summary,
keeps its own counters, and in
strictmode fails its own process. Findings are still correct — a trace never spans workers — but "findings: 3" printed four times is four partial reports, not twelve findings. Put%token%in thereport-jsonpath so every worker writes its own file, or run query-guard in a sequential job of its own. Regenerating a baseline under a parallel runner is refused outright and said so in the summary: a baseline is one committed file, twelve workers writing it would leave whichever share finished last, and the run after that would fail on findings nobody added.
What it costs
Worth knowing before putting it in CI, and measured rather than estimated:
| Cost | |
|---|---|
| Outside a PHPUnit run | nothing. The collector is a null object and no stack is captured |
Between tests, in setUp() |
a query is recorded; no rule runs |
| Per traced query | one debug_backtrace (200 frames) and one callsite resolution — ~0.006 ms, i.e. ~0.15 s per 25 000 queries |
| Memory per traced query | SQL, bound values and one callsite — ~0.5 MB per 1000 queries, freed when the test ends |
| The call chain | nothing measurable. Identical chains are shared, and in an N+1 every query has the same one: 10 000 queries cost 7.63 MB with call-chain-depth="0" and 7.63 MB at the default 4; over 1000 distinct call paths, 7.89 MB |
| Tier 2 | one EXPLAIN per distinct query shape per run, not per query; on PostgreSQL one extra size lookup per table |
| End of run | rules run over each trace as its test finishes; the summary is printed once |
The stack is captured only while a test is being traced, and only the resolved callsite is kept — 200 raw frames per query cost about 106 MB per 1000 queries and used to be the one way this tool could hurt a suite.
Tier 2 is the part with a real price: it talks to the database. Its own queries are excluded from the trace, so the tool never counts its own traffic.
On an ordinary suite ~0.5 MB per 1000 queries is nothing, but a test-data import running
100k queries in one test is ~50 MB held until that one test ends. max-trace-queries is
the valve for that case — see the configuration table above.
Baseline
Point query-guard at an existing project and you will get hundreds of findings. That is what a baseline is for:
QUERY_GUARD_GENERATE_BASELINE=1 vendor/bin/phpunit
<parameter name="baseline" value="query-guard-baseline.json"/>
Everything known stays quiet; anything new shows up. Commit the file.
A finding is keyed by rule|file|fingerprint — deliberately without the line number
and without the test name, since both move for harmless reasons and would reset your
baseline for nothing. Paths in the file are relative, so it survives the trip to CI.
The summary always reports how many findings the baseline silenced.
What is in the file
{
"format-version": 1,
"generated-at": "2026-09-03T10:12:00+00:00",
"platform": "mysql",
"comment": "query-guard baseline: the findings listed here do not fail the run...",
"findings": {
"n-plus-one|src/Entity/Timesheet.php|select t0.id from tags t0 where t0.id = ?": {
"rule": "n-plus-one",
"place": "src/Entity/Timesheet.php:418",
"sample": "App\\Entity\\Timesheet::$tags — lazy-loaded association, 10 queries"
}
}
}
platform is the database the file was generated on. A signature holds the fingerprint
of the statement, and the same DQL becomes different SQL per dialect — Doctrine emits
CONCAT(...) and CAST(... AS CHAR) on MySQL against ... || ... and
CAST(... AS VARCHAR) on PostgreSQL. Measured on one schema across all three: 2 of 114
findings differed that way while the other 112 matched byte for byte. So a run against a database
the baseline did not come from says so, rather than leaving you to work out why known
findings arrived as new ones.
format-version is the shape of the file. It goes up only when a field is removed or
starts to mean something else — never for a new one — and a release that meets a number
it does not know silences nothing and says so in the summary, rather than guessing what
the entries mean. A file without the field is format 1.
The key is what silences a finding; everything under it is there so the file can be
read in a pull request. place carries the line number the key deliberately leaves out,
and sample is the message as it was when the entry was written — neither is matched
against, and neither is refreshed. Delete a key by hand to start failing on it again;
editing place or sample changes nothing.
Entries that outlive their findings
Fix a finding and its baseline entry stays behind, silencing something that no longer exists — including, in time, a fresh regression that lands on the same rule in the same file. So a run reports how many entries matched nothing:
! 4 baseline entries silenced nothing in this run.
After a full-suite run they are obsolete: regenerate with QUERY_GUARD_GENERATE_BASELINE=1 to drop them.
After a filtered run it only means those tests did not execute.
The distinction is the whole point of the wording. --filter, --exclude-group, a
sharded CI job — all of them leave entries unmatched without anything being stale.
Regenerate from a full run or not at all.
Machine-readable report
The summary above is written to be read by a person, which means it will be reworded. Anything automated should read this instead:
<parameter name="report-json" value="var/query-guard.json"/>
{
"format-version": 1,
"generated-at": "2026-09-03T10:12:00+00:00",
"worker": null,
"mode": "strict",
"fail-on": "warning",
"failing": true,
"summary": {"tests": 404, "queries": 25736, "fixture-queries": 0, "suppressed": 0, "findings": 207},
"notices": ["tier 2: 118 plans parsed, 0 failed."],
"findings": [
{
"rule": "n-plus-one",
"severity": "error",
"test": "App\\Tests\\Controller\\TimesheetControllerTest::testExportAction",
"class": "App\\Tests\\Controller\\TimesheetControllerTest",
"method": "testExportAction",
"message": "App\\Entity\\Timesheet::$tags — lazy-loaded association, 10 queries",
"file": "src/Entity/Timesheet.php",
"line": 418,
"chain": [
"src/Api/MediaController.php:31 App\\Entity\\Timesheet::getTags",
"src/EventListener/ExportListener.php:12 App\\Api\\MediaController::export"
],
"count": 10,
"signature": "n-plus-one|src/Entity/Timesheet.php|select t0.id from tags t0 where t0.id = ?"
}
]
}
Paths are relative to the working directory, so the file survives the trip to CI.
format-version goes up only when a field is removed or starts to mean something else,
never for a new one: a script that checks it keeps working across releases until the
report actually changes under it.
failing answers the only question a CI script usually has — whether this run is about to
exit non-zero — without it having to reimplement the fail-on comparison. worker is
null in an ordinary run and carries the ParaTest token when the file holds one worker's
share of a parallel one, so that merged reports stay attributable.
The directory is created if it is not there yet — var/ is in .gitignore on most
projects and missing from a fresh checkout, and a report that was not written because of
that used to be a notice easily lost in a screenful of findings.
The console summary is not replaced by the file: a run that writes a report and prints
nothing is a run whose findings nobody sees. The one exception is the run that asked for
silence — --no-output — where the file is still written and the summary is not printed.
Per-test overrides
use QueryGuard\Attribute\AllowQueries; use QueryGuard\Attribute\IgnoreRule; #[AllowQueries(50)] #[IgnoreRule('n-plus-one')] public function testImportsLargeFile(): void
Both work on the class as well as the method. #[IgnoreRule] is repeatable, and a method
adds to whatever its class already ignores; #[AllowQueries] on a method replaces the
class-level budget rather than adding to it.
Where a callsite lands inside a framework your project happens to vendor, and no
#[IgnoreRule] will help because the rule is right and the file is not yours, add the
package to skip-paths:
<parameter name="skip-paths" value="vendor/api-platform,vendor/sonata-project"/>
Those are path fragments, not regular expressions, and they are added to the built-in list (PHPUnit, Pest, Doctrine, Symfony, Laravel, Illuminate, Composer) rather than replacing it. The first frame outside every listed path is what a finding points at.
Some plumbing cannot be recognised by its path, because the path is genuinely yours. A
frame's file is the caller's, so return $next($request); in your own Laravel middleware
shows up as an application frame — on every request, telling you nothing about which query
fired. On one Laravel project those frames held 28% of all chain slots and were the whole
chain for a quarter of the findings. skip-functions matches what a frame called
instead:
<parameter name="skip-functions" value="App\Support\Instrumentation::"/>
Same rules as skip-paths — fragments rather than patterns, added to the built-in list
(currently Illuminate\Pipeline\Pipeline::) rather than replacing it. Reach for it only
for transit code: it hides every frame that called the named method, wherever from.
Both of those are about naming a finding. fixture-paths is about not making one:
<parameter name="fixture-paths" value="database/factories,database/migrations,database/seeders"/>
Queries made in setUp() are counted and shown separately (in setUp: N) and never
analysed — that decision is what keeps a factory creating fifty rows in a loop from being
reported as an N+1. Laravel suites call their factories on the first line of the test body
instead, where the setUp() boundary does not reach: on one 1 617-test suite that put 64%
of the findings on factory and test files. fixture-paths moves a query into the same
bucket because of where it came from, wherever in the test it happened.
Reaching for skip-paths here does not work, and it is worth knowing why before trying:
that list steps over a frame so the callsite lands on the next one, so pointing it at a
factory directory brings the same findings back against the test line that called the
factory, and pointing it at the tests as well leaves 41% of them with no callsite at all.
fixture-paths does not change how anything is named.
The same list is the answer to migrations showing up in the report. LazilyRefreshDatabase
builds the schema on first use — inside the body of whichever test ran first — so the
schema build is traced as that test's work, and under a parallel runner that happens once
per worker.
Everything moved this way is still counted in in setUp: N, so the summary keeps saying
how much of the run nobody looked at. Nothing is skipped silently: when a third or more of
the findings land in the conventional fixture directories of either supported ORM, the
summary says so and names them, ready to paste in.
It matches the callsite, and only the callsite. A migration that issues its query
through a package gets that package's file as its callsite, not the migration's, and stays
in the report. Measured on the suite above: 24 findings out of 1 869 (1.3%) came back that
way. Matching anywhere in the call chain would catch them, and is deliberately not done —
the chain is only call-chain-depth frames deep, so what counted as a fixture would start
depending on a parameter that has nothing to do with it.
Configuration
| Parameter | Meaning | Default |
|---|---|---|
mode |
report — print a summary; strict — fail the run |
report |
fail-on |
Lowest severity strict fails on: error, warning, info |
warning |
baseline |
Path to the baseline file | not set |
n-plus-one-threshold |
Repeats before it counts as N+1 | 3 |
duplicate-query-threshold |
Repeats before it counts as a duplicate. Until 2.0 the old name duplicate-threshold is read too, with a notice asking for the rename |
5 |
query-in-loop-threshold |
Queries from one place before it is reported | 5 |
max-queries |
Query budget per test body — setUp() is counted separately and never charged to it |
not set, rule silent |
max-trace-queries |
Safety valve for a huge test: past this many queries a test's trace stops holding events, so the rules see only the head of it — a truncated test says so in the summary. query-count is unaffected: it keeps the real total regardless |
not set, no limit |
large-tables |
Comma-separated tables for no-limit; a bare name matches the table in any schema, a qualified one (public.orders) only in that schema |
not set, rule silent |
select-star |
Enable select-star |
false |
tier2 |
Enable plan rules | false |
min-rows |
Table size below which plan rules do not judge | 1000 |
skip-paths |
Comma-separated path fragments a callsite is never blamed on — your own framework packages, on top of the built-in list | not set |
skip-functions |
Comma-separated Class::method fragments a frame is never blamed on — plumbing that lives in your own files, on top of the built-in list |
not set |
fixture-paths |
Comma-separated path fragments whose queries count as building the stand rather than exercising the application: they go where setUp()'s queries go — counted in in setUp: N, shown to no rule |
not set |
call-chain-depth |
How many application frames above the callsite a finding carries, so that "who called this" is read rather than guessed. 0 turns it off |
4 |
report-json |
Where to write the machine-readable report; the console summary is printed either way. A missing directory is created. %token% in the path becomes the ParaTest worker's token, and disappears — with the separator before it — outside a parallel run |
not set, nothing written |
fail-on reads the severity scale. [error] means the adapter recognised lazy
loading and named the association; [warning] that only the shape heuristic fired;
[info] is a style note. Everything found is always printed — fail-on only decides
what strict is willing to fail the run over. The default of warning deliberately
leaves info out: the only info rule is select-star, and select * is Eloquent's
default mode, so failing on it would turn a whole Laravel suite red the moment the rule
is enabled. Set fail-on="error" to fail only on what was proved rather than guessed.
On Eloquent, fail-on="error" currently fails on nothing. [error] comes from the
adapter recognising lazy initialisation in the stack — an uninitialised Doctrine proxy or
PersistentCollection is visible there as an object. The Eloquent adapter does not
enrich its events yet, so every n-plus-one it reports is a warning from the query's
shape. Until that changes, fail-on="error" on a Laravel project is a run that cannot
fail: use the baseline to draw the line instead. When it does change, those findings
become error in a minor release — UPGRADING.md
says what that means for a run with fail-on="error".
A value that cannot be read — mode="strickt", max-queries="lots" — produces a warning
in the summary naming the parameter, the value and what was used instead. It never falls
back in silence. A misspelled parameter name is the one thing that cannot be caught:
PHPUnit's ParameterCollection offers no way to enumerate what was actually written.
strict mode fails the whole run (exit code 1), not an individual test: PHPUnit's event
system gives an extension no way to mark a test as failed. When PHPUnit is already
failing the run for its own reasons, its exit code is left alone — it is the more
specific one.
Recipes
Catching an N+1 in five minutes
No baseline, no strict mode, no tier 2 — just point it at one test and read the output.
1. Install the extension and wire the adapter (Install section above). Defaults are
fine: n-plus-one, duplicate-query and query-in-loop are on out of the box, the
noisy rules are off.
2. Run the one test you suspect:
vendor/bin/phpunit --filter testExportAction
3. Read the summary. A finding names the rule, the test, the diagnosis and the line:
* [error] n-plus-one — App\Tests\Controller\TimesheetControllerTest::testExportAction
App\Entity\Timesheet::$tags — lazy-loaded association, 10 queries
src/Entity/Timesheet.php:418
[error] means the adapter recognised lazy loading and there is nothing left to guess.
[warning] means only the shape heuristic fired — same query, same place, different
values — and it is worth a look but not a certainty.
Nothing found? Check the top of the summary before concluding there is no problem:
tests traced: 1, queries: 0 means interception never took, and the notice below it says
what to fix. See When query-guard stays silent.
When you are ready to hold the whole suite to this, read on.
Reading the three grouping rules
n-plus-one, duplicate-query and query-in-loop all say "the same place ran a lot of
queries", and they are three different diagnoses with three different fixes. The
distinction is what the finding turns on:
| What the trace looks like | Rule | What it means | The fix |
|---|---|---|---|
| One shape, one place, values differ | n-plus-one |
A row at a time out of a loop or a lazy association | Fetch the set in one query: a JOIN, an IN (...), eager loading |
| One shape, one place, values identical | duplicate-query |
The first answer was thrown away | Keep the result, or put it behind a cache. Nothing about the query itself is wrong |
| Several shapes, one place | query-in-loop |
That line costs that many queries — a loop doing different work per iteration, or a single call that fans out (an entity, then its settings, then its owner) | Read what the line does before assuming a loop: it is often one call, and then the fix is upstream of it |
Two rules never fire on the same queries: n-plus-one requires the bound values to
differ, duplicate-query requires them to match, and query-in-loop stands down when a
group has only one shape. If you are looking at all three on one line of code, they are
describing three different sets of queries that happen to leave from it.
Severity is the second half of the message and means certainty, not cost. [error] on
n-plus-one is the adapter having recognised Doctrine lazy initialisation and named the
association — there is nothing left to guess. [warning] is the shape heuristic alone.
Fixing an N+1 once you have found one
The finding gives you a file, a line and a shape. What it cannot tell you is which of three fixes applies, because that depends on what the code is doing with the rows.
Doctrine — a lazy association walked in a loop. The usual case, and the one that gets
reported as [error] with the association named:
// before: one query for the timesheets, then one per timesheet for its tags foreach ($repository->findBy(['user' => $user]) as $timesheet) { $names[] = implode(', ', $timesheet->getTags()->map(fn (Tag $t) => $t->getName())->toArray()); } // after: the association is fetched with the parent, in one query $timesheets = $repository->createQueryBuilder('t') ->addSelect('tags') // without addSelect the join filters but does not hydrate ->leftJoin('t.tags', 'tags') ->where('t.user = :user')->setParameter('user', $user) ->getQuery()->getResult();
addSelect is the whole trick, and leaving it out is the most common way this "fix"
changes nothing: a leftJoin alone still leaves the collection lazy. Do not reach for
fetch: 'EAGER' on the mapping instead — it makes every read of that entity anywhere in
the application pay for the association.
Doctrine — many entities by id, not through an association:
// before: findOneBy in a loop foreach ($ids as $id) { $orders[] = $repository->find($id); } // after: one query, indexed for lookup $orders = $repository->createQueryBuilder('o') ->where('o.id IN (:ids)')->setParameter('ids', $ids) ->indexBy('o', 'o.id') ->getQuery()->getResult();
query-guard skips IN (?, ?, ?) on purpose — a batch fetch is the cure, so the rule must
not report the fix as the disease.
Eloquent:
// before foreach (Order::all() as $order) { $total += $order->items->sum('price'); } // after — eager load with the parent query foreach (Order::with('items')->get() as $order) { $total += $order->items->sum('price'); } // or, when the collection already exists $orders->load('items');
Then check it. Re-run the one test and read the summary, rather than assuming:
vendor/bin/phpunit --filter testExportAction
The finding disappears and the query count drops. If the count did not drop, the fix did
not fire — an unhydrated join, or a lazy load that moved rather than went away. If a
duplicate-query finding appeared where the n-plus-one was, the loop is now asking one
query repeatedly instead of one per row: nearly there, and the remaining fix is to keep
the result rather than to change the query.
Troubleshooting false positives
A finding you do not believe is not a reason to raise the global budget or drop a rule project-wide. Work through these, in order, before deciding it is wrong:
1. Is it happening in setUp()? It should not be flagged at all — queries made there
are counted separately (in setUp: N) and never analysed (see "When query-guard stays
silent" above). If a factory loop in a test body is producing the finding, moving that
fixture creation into setUp() is usually the actual fix, not an exception.
2. Is it a batch fetch that got misread? n-plus-one already skips IN (?, ?, ?) —
that pattern is the cure, not the disease. It does not skip NOT IN (?, ?, ?): an
exclude-list is a different pattern from "fetch a page of rows by key", and a per-row
lookup that happens to also exclude a couple of ids is still N+1.
3. Is the test doing genuinely heavy, one-off work? Bulk import, a report export, a migration test — give it its own budget rather than raising the number every other test is measured against:
#[AllowQueries(120)] public function testBulkImport(): void
4. Is the rule right about the pattern but wrong for this test? Narrow the exception
to exactly the rule that misjudges it, on exactly the test, with a comment saying why —
see "Per-test overrides" above. A class-wide #[IgnoreRule] is the last resort, not the
first.
5. Does the callsite land inside vendored framework code? No #[IgnoreRule] fixes a
finding whose file is not yours — add the package to skip-paths instead, so the finding
points at the first frame in your own code. When this is not the odd finding but a third
of the run — a project whose repositories all extend one from a wrapper package, say —
the summary says so and names the packages, ready to paste in.
6. Are you running under a parallel runner? Each ParaTest worker prints its own
summary; "findings: 3" from four workers is four partial reports, not twelve findings —
see "When query-guard stays silent" above. Give the report-json path a %token% so the
workers stop overwriting each other's file; the summary says so too when they do.
7. Is the class in the trace the one that runs in production? Applications routinely
swap implementations under test, and a service provider branching on
runningUnitTests() is the usual way. The finding is then a true statement about code
that is never deployed — a memoising cache replaced by a pass-through, a driver faked, a
client stubbed. Follow the chain to the class that issued the queries and check how it is
bound, before treating the finding as real.
If none of the above explains it, it is a bug report rather than a configuration change: the rule genuinely cannot tell this pattern apart from the one it exists to catch.
Adopting on a legacy project
You cannot fix hundreds of old tests at once, and you do not need to. The goal is to draw
a line: everything existing goes into the baseline, everything new is checked. The steps
below go from install to strict.
1. Install and enable every check. Extension and adapter as in the Install section
above. Keep mode="report" while you are still measuring the damage, and set the
baseline path right away — step 5 writes to it:
<!-- phpunit.xml --> <extensions> <bootstrap class="QueryGuard\Extension"> <parameter name="mode" value="report"/> <parameter name="baseline" value="tests/query-guard-baseline.json"/> <parameter name="n-plus-one-threshold" value="3"/> <parameter name="duplicate-query-threshold" value="5"/> <parameter name="query-in-loop-threshold" value="5"/> <parameter name="max-queries" value="50"/> <parameter name="select-star" value="true"/> <parameter name="large-tables" value="users,orders"/> </bootstrap> </extensions>
max-queries here is a probe, not the budget you intend to keep — see steps 3 and 4.
2. Run the suite and save the log. Findings and query counts go to stdout:
vendor/bin/phpunit 2>&1 | tee query-guard.log
3. Pick the real max-queries from the log. Only breaches are printed, as
N queries against a budget of M — so one run shows you the tests above the probe and
nothing about the rest. To see the shape of the distribution, run it twice: once with a
low probe (say 20) and once with a high one (say 100). Between the two you have every
test's exact count.
grep 'against a budget' query-guard.log
Recommendations:
- set the global budget near the 90th–95th percentile of ordinary business tests — a number chosen "with headroom" protects nothing;
- genuinely heavy tests (bulk import, export, reporting) get their own
#[AllowQueries(N)]instead of an inflated global budget; - legacy code you cannot touch: leave its breaches to be absorbed by the baseline
(step 4) —
query-countfindings are keyed per test, so new tests are still checked against the budget.
4. Write the budget you picked back into phpunit.xml — the probe from step 1 is
still in there, and step 5 is about to freeze whatever it produces:
<parameter name="max-queries" value="35"/>
Do this before generating the baseline, not after. A baseline generated against the probe
records query-count findings at the wrong threshold, and lowering the budget later then
looks like a wave of regressions.
5. Generate the baseline and commit it:
QUERY_GUARD_GENERATE_BASELINE=1 vendor/bin/phpunit
Everything currently found lands in tests/query-guard-baseline.json; the file goes into
the repository.
Set that variable for the one command, never in CI. With it on, the run always passes and always rewrites the baseline — the quietest possible way to switch the tool off for good. If the variable is set but no
baselineparameter is configured, the summary says so and nothing is written.
6. Decide on tier 2 — only if the tests run on a database with real volume. Plan rules need rows and statistics; on a three-row fixture database the optimiser's estimate lies and the rules do more harm than good (see the Tier 2 section above). If your test database is a production copy or is seeded at scale, add:
<parameter name="tier2" value="true"/>
and regenerate the baseline (step 5), so existing plan findings are recorded as well.
7. Re-run and verify:
vendor/bin/phpunit 2>&1 | tee query-guard-after.log
The summary must show silenced by baseline: N and stay otherwise empty — anything that
still appears is either a flaky callsite or a regression between the two runs, and the
baseline has nothing to do with it.
8. Switch the mode to strict:
<parameter name="mode" value="strict"/>
From now on every finding outside the baseline fails the run: a new N+1 in a new test, a moved callsite of an old one, a budget breach in a test whose name did not exist when the baseline was written.
One habit worth adopting together with the tool: prepare test data in setUp(), not
in the test body. The trace opens after setUp() finishes, so factory INSERTs there are
kept out of the analysis; the same creation loop inside a test body is a textbook
n-plus-one/duplicate-query false positive — on your own fixtures.
Starting a new project
No legacy, no baseline: every finding is either a bug you fix or an exception you
justify in writing. Enable everything that works on day one and keep report while
triaging:
<!-- phpunit.xml --> <extensions> <bootstrap class="QueryGuard\Extension"> <parameter name="mode" value="report"/> <parameter name="n-plus-one-threshold" value="3"/> <parameter name="duplicate-query-threshold" value="5"/> <parameter name="query-in-loop-threshold" value="5"/> <parameter name="max-queries" value="30"/> <parameter name="select-star" value="true"/> </bootstrap> </extensions>
Two more parameters are worth adding, but only once each condition holds — neither has a sensible value in the abstract:
<!-- your own large tables, by name; the rule is silent without this list. A bare name matches the table however the query spells it — `orders`, `public.orders`, `"public"."orders"`. Write `public.orders` to mean that schema and no other --> <parameter name="large-tables" value="users,orders"/> <!-- only when the test database carries real volume: on a fixture-sized one the optimiser's estimate lies and the plan rules do more harm than good --> <parameter name="tier2" value="true"/>
1. Run and save the log:
vendor/bin/phpunit 2>&1 | tee query-guard.log
2. Triage every finding. Three possible verdicts:
- True positive — fix the code, not the config: eager fetch (
JOIN/IN) instead of lazy loading, one batched query instead of one per row, the missing index behind atable-scanorno-possible-index. - Deliberately heavy test — bulk import, report export: an exception on the test (step 3), not a raised global budget.
- False positive — a rule judged a pattern it cannot see through: an exception on the test with a comment saying why.
3. Exceptions — how to set them correctly:
use QueryGuard\Attribute\AllowQueries; use QueryGuard\Attribute\IgnoreRule; // a budget sized to the test's real work — its actual count is in the log #[AllowQueries(120)] public function testBulkImport(): void // switch off exactly the rule that misjudges this test; the argument is the rule id // verbatim from the finding header: [warning] n-plus-one — ... #[IgnoreRule('n-plus-one')] public function testLegacyPdfExport(): void // a whole class may carry the attributes instead of repeating them per test #[IgnoreRule('select-star')] final class ReportQueryTest extends TestCase
A method-level #[AllowQueries] overrides a class-level one; #[IgnoreRule] is
repeatable and adds to whatever the class ignores. Keep exceptions narrow: one rule on
one method beats a class-wide ignore of three. An exception without a reason comment is
where the rot starts.
4. When the log is clean, switch to strict:
<parameter name="mode" value="strict"/>
On a new project the baseline is optional — every finding is fresh enough to fix. If the
suite ever outgrows that, the baseline parameter is right there.
Migrating from phpunit-query-count-assertions
The differences are covered in "About the closest neighbour" above; this is the mechanical part of moving over.
1. Keep both installed while you migrate. Nothing about query-guard conflicts with the trait-based assertions — they watch the same queries independently, so there is no need to rip the old one out before the new one is in place.
2. Map the assertions you have to query-guard's equivalents:
| phpunit-query-count-assertions | query-guard |
|---|---|
assertQueryCount(N) / assertMaxQueryCount(N) |
max-queries, globally, or #[AllowQueries(N)] per test |
assertNoDuplicateQueries() |
duplicate-query, on by default |
assertNoLazyLoading() (Laravel only) |
n-plus-one, on by default — and it also works on Doctrine |
assertMaxQueryTime($seconds) |
not covered: query-guard reports counts and plans, not wall-clock time |
3. Remove the trait and its assertions from each test as you confirm query-guard
catches the same thing. Run with mode="report" first (see "Starting a new project"
above) so a query-guard finding shows up next to the assertion it is replacing, before
you delete the assertion.
4. Expect query-guard to find more, not less. It is not a stricter version of the
same check: assertNoDuplicateQueries() compares SQL and bound values, so a real N+1 —
where the values differ by definition — produces zero duplicates there, and
query-guard's n-plus-one is what catches it instead. A suite that was green under the
old assertions is not evidence the new ones will start green too; see "Adopting on a
legacy project" above for baselining whatever surfaces.
5. Once every test has been converted, remove the dependency:
composer remove --dev mattiasgeniar/phpunit-query-count-assertions
Running it in CI
The extension needs no CI job of its own — it rides along with the suite you already run. What CI adds is somewhere to put the report and a rule about the baseline.
1. Write the machine-readable report and keep it:
- name: Tests run: vendor/bin/phpunit - name: Keep the query-guard report if: always() # a `strict` failure is exactly when you want the file uses: actions/upload-artifact@v4 with: name: query-guard path: var/query-guard.json
with <parameter name="report-json" value="var/query-guard.json"/> in phpunit.xml. The
if: always() is the point of the step: without it the artifact is uploaded only on the
runs where nobody needs it.
2. Never set QUERY_GUARD_GENERATE_BASELINE in CI. With it on, the run always passes
and always rewrites the baseline. It is the quietest possible way to switch the tool off
for good — quieter than removing it, because the job stays green and the file keeps
changing. Regeneration is a local command, and its output is a reviewed commit.
3. Review the baseline diff like code. A pull request that adds entries is a pull request that ships known problems, and the diff says exactly which:
+ "n-plus-one|src/Repository/OrderRepository.php|select t0.id from items t0 where t0.order_id = ?": { + "rule": "n-plus-one", + "place": "src/Repository/OrderRepository.php:88",
Sometimes that is the right call — a deadline, a refactor already scheduled. The value is that it was a call, made by a named reviewer, rather than a rule quietly not firing.
4. Read the counts, not just the exit code. tests traced: 0 or queries: 0 is a
green build that checked nothing, and it looks identical to a clean one unless somebody
reads the two numbers at the top of the summary. The JSON carries them under summary,
which is the easier thing to assert on.
A project with several databases
Tier 2 handles connections separately by design: each is explained by itself, through the connection that ran the query, with its own platform driver. What that needs from you is mostly a way to check that it happened.
Which connections were seen. The summary names every one it had to skip, and why:
! the "analytics" connection runs on "clickhouse", which tier 2 does not support — its
queries were not explained.
Supported: MySQL/MariaDB and PostgreSQL. Other connections are unaffected.
! tier 2: 118 plans parsed, 0 failed.
0 failed next to a plausible number of plans is what a working tier 2 looks like. A
large failed count usually means EXPLAIN is being refused rather than misparsed —
permissions, or a statement the platform will not explain.
Connections are named after their database. Two entries pointing at the same dbname
— a primary and a read replica, most often — are told apart by host, port and user, and
the second is reported under a suffixed name:
! two connections answer to the database name "shop", so the second one is reported as "shop#2".
They are separate databases — different host, port or user — and each is explained against itself.
Nothing is lost when that happens, but the name in the summary is one you never configured. If that matters, give the replica its own database name, or read the notice as the mapping it is.
See "With Eloquent, and RefreshDatabase/DatabaseTransactions" below for the Eloquent
side of the same per-connection design.
Keeping a baseline healthy through a refactor
A baseline entry is keyed by rule|file|fingerprint. The line number is deliberately not
in there, so ordinary edits cost nothing — but the file is, and the fingerprint is the
normalised SQL. Two refactors therefore move entries:
- moving or renaming a file — every entry pointing into it stops matching, and its findings come back as new;
- changing the query itself — a new column in the
SELECTis a new fingerprint, even though the N+1 is the same one.
Both look identical from the outside: findings reappear in code you did not think you had touched, and the same number of entries starts reporting that it silenced nothing.
An upgrade can do the same without any refactor: a minor release may improve how SQL is normalised, which changes fingerprints — the SQL dialect is the first candidate. Its UPGRADING.md entry says when, and the fix below is the same.
The fix is the same in both cases and it is deliberately blunt — regenerate from a full run and read the diff:
QUERY_GUARD_GENERATE_BASELINE=1 vendor/bin/phpunit git diff --stat tests/query-guard-baseline.json
What the diff should show is entries moving: the same count out and back, under new keys. A regeneration that adds more than it removes has recorded something new as pre-existing, which is the one thing a baseline must not do — check what appeared before committing.
Do not hand-edit keys to follow a moved file. A key is derived, and a hand-written one that is subtly wrong silences nothing while looking like it does; deleting entries by hand is fine, because deleting can only make the tool stricter.
With dama/doctrine-test-bundle and transactional tests
Nothing to configure — and the combination is worth naming, because tier 2 was built
around it. That bundle wraps each test in a transaction and rolls it back, so the rows a
test created exist on that connection and nowhere else. This is why EXPLAIN is run
through the connection that issued the query rather than a fresh one: a separate
connection would explain an empty database and report plans for data that is not there.
Two things follow that are worth knowing:
- The connection is reused across tests, the trace is not. The bundle keeps one connection open for the suite; query-guard opens and closes a trace per test, so findings stay per-test regardless.
- Plans are cached per shape per connection, not per test. A query explained in the
first test is not explained again in the four-hundredth — which is what keeps tier 2
affordable, and also means the plan reflects the data as it was when first seen. On a
fixture-sized database that would matter; tier 2 is not for fixture-sized databases
anyway, and
min-rowsis what keeps it quiet there.
With Eloquent, and RefreshDatabase/DatabaseTransactions
Nothing to configure — and the combination is worth naming, because it decided how tier 2
had to work here. Both traits roll a test's transaction back in that test's own
tearDown(), and Laravel rebuilds the whole application between tests: unlike
dama/doctrine-test-bundle's one connection for the whole suite, there is no point after
the query runs where the connection is still guaranteed to hold that test's data. So the
EXPLAIN runs immediately, inside the QueryExecuted listener, before the test can tear
anything down — not lazily when a rule asks, the way Doctrine's tier 2 does.
Two things follow that are worth knowing:
- The
EXPLAINis itself a query on the same connection, so it briefly shows up to every other listener on the same dispatcher — Telescope, Debugbar, an application's own query logger. query-guard's own trace is unaffected; theirs may not be. - Plans are cached by query shape and connection name, not by test. A shape explained
in the first test is not explained again in the four-hundredth — the connection itself
is re-resolved on every attempt (Laravel hands out a fresh one every test), but the
Planis not, so it reflects the data as it was the first time this shape was seen. Same trade-off as dama/doctrine-test-bundle above, and the same answer: not a fixture-sized database's friend, andmin-rowsis what keeps tier 2 quiet there.
Keeping smoke/load tests out of query-guard
Performance smoke tests should run without the extension: the DBAL middleware and the
per-shape EXPLAIN of tier 2 distort the very timings those tests exist to measure, and
strict fails the run on a query budget that is the subject of the test, not a bug.
The setup is a group and a flag. A second phpunit.xml is not needed — PHPUnit's
--no-extensions switches every registered extension off for one run, so there is no
copy of the config to keep in sync.
1. Put the tests in their own group:
use PHPUnit\Framework\Attributes\Group; #[Group('smoke')] final class AuctionBidLoadSmokeTest extends TestCase
2. Exclude the group from normal runs — either in the config, as a top-level
<groups> block (an <exclude> inside <testsuite> is a path, not a group):
<groups> <exclude> <group>smoke</group> </exclude> </groups>
or on the command line: vendor/bin/phpunit --exclude-group=smoke. Both leave
--group=smoke working: an explicit group on the command line overrides the config.
3. Wire both commands into composer:
{
"scripts": {
"test": "phpunit --exclude-group=smoke",
"test:smoke": "phpunit --no-extensions --group=smoke"
}
}
or into a Makefile:
test: vendor/bin/phpunit --exclude-group=smoke test-smoke: vendor/bin/phpunit --no-extensions --group=smoke
--no-extensions turns off every extension registered in phpunit.xml, not only this
one. If the smoke suite depends on another extension, that is the case — and the only
case — for a second configuration file; then porting every change of phpunit.xml into
it becomes a standing obligation, and a config that has silently drifted is worse than no
config at all.
One habit keeps the rest honest: run the smoke suite sequentially. Parallel workers race each other over CPU and the database, and the measurements are worthless.
Requirements
PHP 8.2+, PHPUnit 10.5 / 11 / 12 / 13, Pest 4. Doctrine ORM 2 / 3 with DBAL 3 / 4, or Laravel 11+. Tier 2: MySQL 8 / MariaDB or PostgreSQL.
Development
No local PHP needed — everything runs in containers. composer update and not
install: this is a library, so composer.lock is deliberately not in the repository
and CI resolves dependencies afresh on every run.
docker run --rm -v "$PWD":/app -w /app -e COMPOSER_CACHE_DIR=/tmp/composer-cache composer:2 composer update docker run --rm -v "$PWD":/app -w /app php:8.5-cli php vendor/bin/phpunit docker run --rm -v "$PWD":/app -w /app php:8.5-cli php vendor/bin/phpstan analyse --memory-limit=1G docker run --rm -v "$PWD":/app -w /app php:8.5-cli php vendor/bin/php-cs-fixer fix
Tier 2 is verified against a synthetic stand with 100 000 rows:
docker compose -f tools/stand/docker-compose.yml up -d tools/stand/capture.sh # refresh reference plans in tests/Fixture/Explain tools/stand/run-tier2-tests.sh # run tier 2 against live MySQL and PostgreSQL docker compose -f tools/stand/docker-compose.yml down -v
The official php images ship neither pdo_mysql nor pdo_pgsql, so
run-tier2-tests.sh builds a throwaway image per platform on first use. Set
QG_MYSQL_IMAGE / QG_PG_IMAGE to reuse images your project already has.
The expected plan flags in those tests were written by hand from reading real EXPLAIN
output, never generated from our own parser — otherwise a misunderstanding of a plan would
land in the code and in its test at the same time.
Pest has a stand of its own, for the same reason: it installs the package the way a user
does, and it caught a way of breaking the extension that no test inside this repository
could see (see tools/pest/README.md).
docker run --rm -v "$PWD":/app -w /app composer:2 composer update --working-dir=tools/pest docker run --rm -v "$PWD/tools/pest":/app -w /app php:8.5-cli php vendor/bin/pest docker run --rm -v "$PWD/tools/pest":/app -w /app php:8.5-cli php vendor/bin/pest --parallel
License
MIT. See LICENSE.