laravel-n1-detector maintained by stephen-tembo-dev
Laravel N+1 Detector
Catch N+1 queries before they reach production — right inside your test suite, failing CI the moment a burst of near-identical queries betrays a lazy-loaded relation in a loop.
N+1 query pattern(s) detected:
• 15× select * from "authors" where "id" = ? (3.42ms total, 0.228ms avg)
first seen at /app/Http/Controllers/PostController.php:24
Fix: eager load the offending relation with ->with() or ->load().
Why this one is different
Most N+1 detectors hook Eloquent's lazy-loading event, which only sees relation access and produces false positives on intentional lazy loads. This package works one level lower: it fingerprints every executed SQL query, normalizing away bound values, and flags bursts of structurally-identical single-key lookups.
That distinction matters:
- An eager load emits one
where "id" in (?, ?, ?)— recognized and ignored. - An N+1 emits many
where "id" = ?in quick succession — flagged. - Unrelated repeated queries spread across a request don't accumulate, thanks to a configurable burst time-window.
Because it operates on raw SQL, it catches N+1s from Eloquent relations, the query builder, and raw queries alike.
Installation
composer require stephen-tembo-dev/laravel-n1-detector --dev
php artisan vendor:publish --tag=n1detector-config
Setup
Enable it in the testing environment via phpunit.xml:
<php>
<env name="N1_DETECTOR_ENABLED" value="true"/>
</php>
Scoped assertions
use StephenTembo\N1Detector\Concerns\DetectsN1Queries;
class PostControllerTest extends TestCase
{
use DetectsN1Queries;
public function test_index_has_no_n1(): void
{
$this->assertNoN1Queries(function () {
$this->get('/posts')->assertOk();
});
}
}
Helpers available from the trait:
| Method | Purpose |
|---|---|
assertNoN1Queries(fn) |
Fail if the block triggers an N+1. Returns the block's value. |
assertHasN1Queries(fn) |
Assert the block does trigger one (test your own detector / document tech debt). |
captureN1Queries(fn) |
Return [result, Violation[]] without asserting. |
How detection works
- A listener on
QueryExecutedreceives every query, its bindings, and its timing. - Each query is reduced to a fingerprint: whitespace collapsed,
in (?, ?, …)canonicalized, literals and bindings masked to?. Two queries differing only in values share a fingerprint. - Queries whose shape is a single-key lookup (
where … = ?, one binding) are grouped by fingerprint. - When a group reaches the threshold within the burst window, it's recorded as a violation — complete with count, total/average time, and the first application call site (vendor frames skipped).
Configuration
| Key | Default | Description |
|---|---|---|
enabled |
false |
Master switch. Testing/local only. |
threshold |
3 |
Burst size before a fingerprint is flagged. |
burst_window_ms |
100.0 |
Max gap between same-shape queries to count as one burst. null disables timing. |
throw |
true |
Fail the test on any violation. |
ignore_patterns |
framework noise | Regexes matched against raw SQL to exclude (migrations, sessions, cache, telescope, …). |
Testing
composer install
vendor/bin/phpunit
The suite runs against in-memory SQLite by default. To also verify against a real MySQL server (recommended before releasing, since quoting and timing differ from SQLite):
N1DETECTOR_TEST_DB=mysql vendor/bin/phpunit
Defaults to 127.0.0.1:3306, database n1detector_test, user root with no
password — override with DB_HOST / DB_PORT / DB_DATABASE /
DB_USERNAME / DB_PASSWORD env vars. Create the database first:
CREATE DATABASE n1detector_test;
Roadmap
- Artisan command to profile a route/URL on demand and print a burst report.
- Optional PHPStan/Larastan rule surfacing likely N+1s statically.
- HTML profile output for local (non-test) runs.
License
MIT. See LICENSE.md.