Looking to hire Laravel developers? Try LaraJobs

laravel-query-budget maintained by ericklabs

Description
Detect SQL query regressions in Laravel tests using configurable query budgets.
Author
ErickLabs
Last update
2026/08/02 04:21 (dev-main)
License
Downloads
0

Comments
comments powered by Disqus

Laravel Query Budget

Detect SQL query regressions in Laravel tests using configurable query budgets.

A feature can stay functionally green while quietly growing from 5 to 50 database queries. This package measures the SQL executed inside a scope and fails the test when count, duplication, or duration exceeds the limits you define.

English · Español


Why this package

Most tools help you inspect queries. Query Budget helps you guard them.

Concept Role
Budget Optional limits for queries, duplicates, slow queries, and duration
Regression A change that still passes assertions but spends more SQL than before
Fingerprint Stable identity for a query shape (structural) or shape + bindings (exact)

Focus of the package:

  • Count queries
  • Detect exact duplicates
  • Detect slow queries
  • Measure accumulated SQL time
  • Enforce budgets in tests
  • Produce readable failure output
  • Work with Pest and PHPUnit
  • Support MySQL, PostgreSQL, and SQLite in tests

It is a test-time guard, not a dashboard, Debugbar plugin, AI advisor, or production APM.

Measure queries inside a test. Fail when they exceed a configured budget.


The 10-second pitch

Problem

A feature can remain functionally correct while silently increasing
from 5 to 50 database queries.

Solution

use ErickLabs\QueryBudget\Facades\QueryBudget;

QueryBudget::assert(
    maxQueries: 5,
    maxDuplicates: 0,
    callback: fn () => $this->getJson('/api/orders'),
    scope: 'GET /api/orders',
);

Result

Database query budget exceeded

Scope: GET /api/orders

Queries:
Expected: <= 5
Actual:   17

Duplicate queries:
Expected: <= 0
Actual:   6

Database time:
Expected: <= 120 ms
Actual:   184 ms

Most repeated query:
select * from "customers" where "customers"."id" = ? limit 1
Executed 6 times

That failure message is part of the product.


Requirements

Requirement Version
PHP ^8.2
Laravel ^11.0 or ^12.0
Databases in tests MySQL, PostgreSQL, SQLite
PHP Laravel 11 Laravel 12
8.2 Yes
8.3 Yes Yes
8.4 Yes

Compatible with Pest and PHPUnit.


Installation

composer require ericklabs/laravel-query-budget --dev

Laravel package auto-discovery registers the service provider and facade.

Publish the config if you want local defaults:

php artisan vendor:publish --tag=query-budget-config

Quick start

Facade assertion

use ErickLabs\QueryBudget\Facades\QueryBudget;

public function test_orders_endpoint_respects_query_budget(): void
{
    QueryBudget::assert(
        maxQueries: 8,
        maxDuplicates: 0,
        maxSlowQueries: 1,
        maxTotalDurationMs: 120,
        callback: fn () => $this->getJson('/api/orders'),
        scope: 'GET /api/orders',
    );
}

Every limit is optional. Assert only what matters for that test.

Pest / PHPUnit trait

use ErickLabs\QueryBudget\Data\QueryBudget;
use ErickLabs\QueryBudget\Testing\InteractsWithQueryBudgets;

uses(InteractsWithQueryBudgets::class);

it('lists orders efficiently', function (): void {
    $this->assertQueryBudget(
        budget: new QueryBudget(
            maxQueries: 8,
            maxDuplicates: 0,
        ),
        callback: fn () => $this->getJson('/api/orders'),
        scope: 'GET /api/orders',
    );
});

Measure without failing

Use this to inspect baselines before tightening budgets:

$result = QueryBudget::measure(
    fn () => $this->getJson('/api/orders')
);

$result->metrics()->queryCount;
$result->metrics()->duplicateQueryCount;
$result->metrics()->totalDurationMs;
$result->metrics()->repeatedPatterns;

What gets measured

Budget option Meaning
maxQueries Maximum SQL statements in the scope
maxDuplicates Maximum exact duplicate executions
maxRepeatedPatterns Maximum distinct repeated patterns (possible N+1 signals)
maxSlowQueries Maximum queries at/above the slow threshold
maxTotalDurationMs Maximum accumulated SQL time
maxSingleQueryDurationMs Maximum duration for any single query
slowQueryThresholdMs Duration at which a query counts towards maxSlowQueries (defaults to config)
possibleNPlusOneThreshold Threshold used for repeated-pattern signals

maxSingleQueryDurationMs and maxSlowQueries are independent: the first caps the slowest single query, the second counts how many queries cross slowQueryThresholdMs.

Exact duplicates vs repeated patterns

These are not the same metric.

Exact duplicate — same normalized SQL and same bindings:

select * from users where id = 1
select * from users where id = 1

Repeated query pattern — same shape, different bindings (possible N+1 signal):

select * from users where id = 1
select * from users where id = 2

Example signal:

Possible N+1 pattern detected

Query pattern:
select * from comments where post_id = ?

Executions: 25
Distinct bindings: 25

This pattern may indicate a relationship being loaded inside a loop.

The package reports a possible N+1 pattern. It does not claim infallible N+1 detection.

Patterns are enforceable, not just informational:

QueryBudget::assert(
    maxRepeatedPatterns: 0,
    possibleNPlusOneThreshold: 5,
    callback: fn () => $this->getJson('/api/posts'),
);

How it works

QueryRecorder
      ↓
QueryMetricsCalculator
      ↓
QueryBudgetEvaluator
      ↓
QueryBudgetResult
      ↓
ConsoleReportFormatter / QueryBudgetExceeded

Each recorded query keeps:

  • SQL
  • bindings
  • duration (ms)
  • connection name
  • optional scope label

SQL and bindings stay separate so fingerprints stay stable and sensitive values are not forced into public reports.

Fingerprints

Type Built from Used for
Structural Normalized SQL Repeated patterns / possible N+1
Exact Normalized SQL + serialized bindings True duplicates

Normalization collapses whitespace, lowercases SQL, replaces literals with placeholders, and unquotes identifiers so the same query fingerprints identically on MySQL, PostgreSQL, and SQLite. See docs/query-normalization.md.

Test failures

QueryBudget::assert() and assertQueryBudget() register an assertion with PHPUnit, so a test whose entire body is a budget assertion is never flagged as risky. An exceeded budget is reported as a test failure, not an error. See docs/concepts.md.

Isolation rules

  • Only queries inside the active measurement scope are counted
  • Scopes do not leak into later tests
  • If the callback throws, the recorder is cleaned and the original exception is rethrown
  • Measurement scopes are designed for isolated synchronous test runs

Configuration

config/query-budget.php:

return [
    'enabled' => env('QUERY_BUDGET_ENABLED', true),
    'warn_when_disabled' => env('QUERY_BUDGET_WARN_WHEN_DISABLED', true),
    'slow_query_threshold_ms' => (float) env('QUERY_BUDGET_SLOW_QUERY_THRESHOLD_MS', 100),
    'possible_n_plus_one_threshold' => (int) env('QUERY_BUDGET_POSSIBLE_N_PLUS_ONE_THRESHOLD', 5),
    'report_format' => env('QUERY_BUDGET_REPORT_FORMAT', 'console'),
    'connections' => [],
    'ignore' => [],
];
Key Effect
enabled When false, every budget passes without measuring
warn_when_disabled Writes one STDERR notice per process while disabled, so the silent gate is visible without failing the suite
report_format console or json; json is handy as a CI artifact
connections Connection names to measure. Empty measures all of them
ignore Case-insensitive SQL substrings that are never recorded

Per-assertion options override defaults when provided. There is no universal “slow” number: 40 ms may be slow locally and normal in CI.

Keeping infrastructure noise out

Sessions, Telescope, and migrations run SQL you did not write. Instead of padding maxQueries, drop them:

'ignore' => ['telescope_entries', 'migrations', 'sessions'],
'connections' => ['mysql'],

Ignored queries never enter the scope, so the count a budget sees is the one you asked about.

Custom report format

QueryReportFormatter is resolved from the container, so binding your own changes the failure message:

$this->app->bind(QueryReportFormatter::class, MyFormatter::class);

Real scenario

Eager-loaded listing:

Post::query()
    ->with('author')
    ->get();

Typically 2 queries.

Lazy-loaded listing:

$posts = Post::all();

foreach ($posts as $post) {
    $post->author;
}

With 20 posts: about 21 queries.

The package suite reproduces this in tests/Feature/EloquentPatternTest.php.


Architecture overview

src/
├── Contracts/          # QueryRecorder, QueryNormalizer, QueryReportFormatter
├── Data/               # RecordedQuery, QueryBudget, QueryMetrics, Result, Violation
├── Recording/          # LaravelQueryRecorder, QueryCollection
├── Normalization/      # DefaultQueryNormalizer, QueryFingerprint
├── Analysis/           # Metrics, duplicates, slow queries
├── Budget/             # Evaluator + QueryBudgetExceeded
├── Testing/            # Trait + assertion helper
├── Reporting/          # Console + JSON formatters
├── Facades/QueryBudget.php
├── QueryBudgetManager.php
└── QueryBudgetServiceProvider.php

Public API prefers value objects, named arguments, and small methods over loosely typed config arrays.


Limitations

Read these before relying on timing budgets in CI.

  1. Durations are noisy — hardware, Docker, CI load, and database engines change timings. Prefer strict count/duplicate budgets; give duration budgets margin.
  2. Framework queries appear — sessions, auth, Telescope, seeders, and middleware can add SQL. Keep scopes tight around the code under test.
  3. N+1 is heuristic — repeated patterns are a signal, not a verdict.
  4. Synchronous scopes — parallel runners and long-lived workers need extra care; isolate each measurement.

Details: docs/limitations.md


Best practices

  1. Start with measure() — inspect real counts before locking a budget.
  2. Prefer count budgets in CImaxQueries and maxDuplicates are stable gates; keep timing limits looser.
  3. Budget one behavior per test — assert the endpoint or service under review, not the whole bootstrap.
  4. Use a clear scope — labels like GET /api/orders make failures easier to read in CI logs.
  5. Treat repeated patterns as investigation hints — confirm with eager loading or batching before changing production code.
  6. Keep sensitive data out of reports — public output uses normalized SQL; avoid dumping raw bindings in shared logs.

Good first budgets

Scenario Suggested starting point
Simple show endpoint maxQueries: 3, maxDuplicates: 0
Index/list with relations maxQueries near the eager-load baseline
Write action Count statements after setup/seed work is outside the scope
Suspected N+1 Inspect repeatedPatterns, then lock maxQueries

Development

composer install
composer test
composer analyse
composer format

CI matrix:

  • PHP 8.2 + Laravel 11 (SQLite)
  • PHP 8.3 + Laravel 11 (SQLite)
  • PHP 8.3 + Laravel 12 (SQLite)
  • PHP 8.4 + Laravel 12 (SQLite)
  • PHP 8.3 + Laravel 12 (MySQL 8 and PostgreSQL 16)

Run the suite against another driver locally with DB_DRIVER=pgsql vendor/bin/pest.


Documentation

Doc Contents
concepts.md Budgets, scopes, duplicates, exceptions
query-normalization.md Fingerprints and reporting safety
limitations.md Timing, framework queries, parallelism
README.es.md Spanish documentation
examples/demo-app Eager vs lazy scenario
CHANGELOG.md Release notes
CONTRIBUTING.md Contribution guide
SECURITY.md Vulnerability reporting

License

MIT © ErickLabs