Looking to hire Laravel developers? Try LaraJobs

laravel-n1-detector maintained by stephen-tembo-dev

Description
Detect N+1 queries in your Laravel tests via SQL fingerprinting, and fail CI when they appear.
Author
Stephen Tembo
Last update
2026/08/03 01:55 (dev-main)
License
Links
Downloads
1

Comments
comments powered by Disqus

Laravel N+1 Detector

tests Latest Version License

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

  1. A listener on QueryExecuted receives every query, its bindings, and its timing.
  2. 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.
  3. Queries whose shape is a single-key lookup (where … = ?, one binding) are grouped by fingerprint.
  4. 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.