laravel-query-budget maintained by ericklabs
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.
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.
- Durations are noisy — hardware, Docker, CI load, and database engines change timings. Prefer strict count/duplicate budgets; give duration budgets margin.
- Framework queries appear — sessions, auth, Telescope, seeders, and middleware can add SQL. Keep scopes tight around the code under test.
- N+1 is heuristic — repeated patterns are a signal, not a verdict.
- Synchronous scopes — parallel runners and long-lived workers need extra care; isolate each measurement.
Details: docs/limitations.md
Best practices
- Start with
measure()— inspect real counts before locking a budget. - Prefer count budgets in CI —
maxQueriesandmaxDuplicatesare stable gates; keep timing limits looser. - Budget one behavior per test — assert the endpoint or service under review, not the whole bootstrap.
- Use a clear
scope— labels likeGET /api/ordersmake failures easier to read in CI logs. - Treat repeated patterns as investigation hints — confirm with eager loading or batching before changing production code.
- 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