stephen-tembo-dev / laravel-n1-detector
Detect N+1 queries in your Laravel tests via SQL fingerprinting, and fail CI when they appear.
Package info
github.com/stephen-tembo-dev/laravel-n1-detector
pkg:composer/stephen-tembo-dev/laravel-n1-detector
Requires
- php: ^8.2
- illuminate/database: ^10.0|^11.0|^12.0|^13.0
- illuminate/events: ^10.0|^11.0|^12.0|^13.0
- illuminate/support: ^10.0|^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^8.0|^9.0|^10.0|^11.0
- phpunit/phpunit: ^10.0|^11.0|^12.0
This package is not auto-updated.
Last update: 2026-08-31 22:29:03 UTC
README
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.