zhora1996 / laravel-performance-analyzer
Detects N+1 queries, duplicate queries, slow queries and other performance problems in Laravel applications — and explains how to fix them.
Package info
github.com/Zhora1996/laravel-performance-analyzer
pkg:composer/zhora1996/laravel-performance-analyzer
Requires
- php: ^8.2
- illuminate/console: ^11.0|^12.0|^13.0
- illuminate/contracts: ^11.0|^12.0|^13.0
- illuminate/database: ^11.0|^12.0|^13.0
- illuminate/events: ^11.0|^12.0|^13.0
- illuminate/http: ^11.0|^12.0|^13.0
- illuminate/queue: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- phpstan/phpstan-phpunit: ^2.0
- phpunit/phpunit: ^11.0|^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-09-25 20:00:10 UTC
README
Finds N+1 queries, duplicate queries, slow queries and slow requests/jobs/commands in a Laravel application — and explains how to fix them.
● N+1 Query [high]
The "organization" relation is lazy loaded 1,247 times, once for every Project model.
Location app/Http/Controllers/ProjectController.php:42
Repeated query select * from "organizations" where "organizations"."id" = ? limit ?
Executed 1,247 times
Possible relation Project → organization (BelongsTo Organization)
Likely source $project->organization
Parent query select * from "projects"
Suggested fix Use eager loading.
Apply at app/Http/Controllers/ProjectController.php:40
Before Project::get();
After Project::with('organization')->get();
Estimated queries 1,249 → 3
Detect → collect context → analyze → explain → recommend.
- Requirements: PHP 8.2+, Laravel 11, 12 or 13
- Status: v0.1. See the roadmap.
Installation
composer require performance-analyzer/laravel-performance-analyzer --dev php artisan vendor:publish --tag=performance-analyzer-config php artisan vendor:publish --tag=performance-analyzer-migrations php artisan migrate
Enable it in .env. It only runs in the environments listed in config/performance-analyzer.php, which by default are local and testing.
PERFORMANCE_ANALYZER_ENABLED=true
When disabled, the package registers no listeners and no middleware, so it adds no runtime overhead.
What it collects
| Context | Started by | Stored as |
|---|---|---|
| Request | global middleware (added automatically) | GET /api/projects |
| Job | JobProcessing / JobProcessed / JobFailed |
job class |
| Command | CommandStarting / CommandFinished |
artisan app:import |
| Manual | Performance::capture() |
not stored |
For every query it records the SQL, the fingerprint, the timing, the application stack frames (vendor frames are skipped, and compiled Blade views are mapped back to their .blade.php file), and the Eloquent relation that triggered it, when there is one.
Collection and detection are separate. Listeners only record data, and detectors analyze a context after it ends. For requests, that happens in terminate(), after the response has been sent.
Detectors
| Detector | Reports |
|---|---|
NPlusOneDetector |
The same query repeated with different values: lazy loading, relation()->count() in a loop, find() in a loop |
DuplicateQueryDetector |
The same query with the same values run more than once |
SlowQueryDetector |
Queries slower than thresholds.query, with index hints |
ExcessiveQueriesDetector |
More than thresholds.queries_per_context queries in one request/job/command |
SlowContextDetector |
Requests, jobs or commands slower than their threshold |
How N+1 detection avoids false positives
Queries are normalized into fingerprints (where id = 1 → where id = ?, in (1, 2, 3) → in (?+)). Lazy-loaded relations are grouped by relation rather than by SQL, so a morphTo that touches several tables counts as one problem.
Each group that runs at least min_occurrences times gets an internal confidence score built from several independent signals:
| Signal | Weight |
|---|---|
| repeated normalized query | base |
| different bindings (same bindings = duplicate, never N+1) | + |
| triggered by Eloquent lazy loading (exact model + relation name) | ++ |
| executed from one source line (a loop) | + |
relation-like key lookup (id, *_id, *_type) |
+ |
| a parent collection query ran earlier | + |
| high repetition count | + |
- A score ≥
confidence.highis reported as N+1 Query. - A score ≥
confidence.potentialis reported as Potential N+1 Query. You can hide these withreport_potential => false. - Anything lower is ignored.
The score is kept internal; users see a severity instead.
When an N+1 also causes duplicate queries (for example, many projects sharing one organization), only the N+1 is reported, because eager loading fixes both.
Nested relations are followed. If $project->organization->owner runs inside a loop, the suggested fix is Project::with('organization.owner').
Artisan commands
php artisan performance:analyze /api/projects # run a request in-process and explain it php artisan performance:analyze /api/projects --user=1 --method=POST --header="Accept: application/json" php artisan performance:analyze # latest stored request php artisan performance:analyze 01KXYZ... # a stored request by id php artisan performance:issues # issues grouped by occurrence php artisan performance:issues --details --type=n_plus_one --hours=24 php artisan performance:stats php artisan performance:clear --days=7
API
use PerformanceAnalyzer\Facades\Performance; // Custom instrumentation (shown in the analysis and stored with the request) $embedding = Performance::measure('embedding-generation', fn () => $this->generateEmbedding()); Performance::start('serialization'); // ... Performance::stop('serialization'); // Ignore rules Performance::ignoreRoute('health'); Performance::ignoreQuery('/from "sessions"/'); // regex Performance::ignoreQuery(fn (string $sql) => str_contains($sql, 'jobs')); // Custom detectors Performance::registerDetector(MyDetector::class); // implements PerformanceDetector // React to every analysis (Slack, logs, ...) Performance::afterAnalyzing(function (\PerformanceAnalyzer\Analyzer\Report $report) { // $report->context, $report->issues });
Writing a detector
use PerformanceAnalyzer\Context\{ContextType, PerformanceContext}; use PerformanceAnalyzer\Detection\Contracts\PerformanceDetector; use PerformanceAnalyzer\Issue\{Issue, Severity}; class LargeResultDetector implements PerformanceDetector { public function supports(PerformanceContext $context): bool { return $context->type() === ContextType::Request; } public function analyze(PerformanceContext $context): array { return [/* new Issue(type: 'large_result', severity: Severity::Medium, ...) */]; } }
Testing your application
use PerformanceAnalyzer\Testing\InteractsWithPerformanceAnalyzer; class ProjectApiTest extends TestCase { use InteractsWithPerformanceAnalyzer; public function test_projects_endpoint_has_no_n_plus_one(): void { $this->assertNoNPlusOneQueries(fn () => $this->getJson('/api/projects')); $this->assertQueryCountLessThan(10, fn () => $this->getJson('/api/projects')); } public function test_something_specific(): void { $report = Performance::capture(fn () => $this->getJson('/api/projects')); $this->assertFalse($report->has(IssueType::NPlusOne)); } }
Privacy
- Query bindings are never stored by default (
sensitive_data.hide_bindings => true). - When bindings are enabled, any query whose SQL matches a sensitive pattern (
password,token,secret,authorization,cookie,api_key,remember) has its bindings replaced with[REDACTED]. - URL query parameters that match those patterns are redacted too:
?token=[REDACTED]. - Headers, cookies and request bodies are not collected.
Overhead
- Disabled: no listeners and no middleware, so no overhead.
- Enabled: each query costs one
debug_backtrace()call, roughly 35 µs per query in the included benchmark (vendor/bin/phpunit --group benchmark). Against real MySQL or PostgreSQL queries that take 0.5–2 ms, this is about 2–7%. Against in-memory SQLite it's higher, because the queries themselves are almost free. - After
max_queries(default 5,000) per context, queries are only counted, with no stack trace. - Analysis and storage run after the response has been sent.
- For shared or production-like environments, enable sampling:
'sampling' => ['enabled' => true, 'rate' => 0.01], // 1% of requests/jobs/commands
Architecture
src/
├── Analyzer/ Analyzer (runs detectors, merges results), Report
├── Collector/ QueryCollector (DB::listen → QueryRecord); collection only
├── Context/ RequestContext, JobContext, CommandContext, ManualContext, lifecycle wiring
├── Detection/ PerformanceDetector contract + detectors
├── Issue/ Issue, Severity, IssueType
├── Recommendation/ Recommendation value object, N+1 and query recommendations
├── Storage/ PerformanceStorage contract, DatabaseStorage, NullStorage
├── Models/ PerformanceRequest, PerformanceQuery, PerformanceIssue
├── Console/ artisan commands + issue printer
├── Http/Middleware/ CollectPerformanceData
├── Testing/ PHPUnit trait
└── Support/ QueryNormalizer, StackTraceResolver, Redactor, ...
The core has no UI dependencies. A dashboard, or a cloud API, can be built on top of PerformanceStorage and the afterAnalyzing() hook as a separate package.
Roadmap
- 0.2: HTTP client analyzer, Redis analyzer, deeper job analysis, memory analyzer, timeline
- 0.3: opt-in
EXPLAIN(never automatic in production;ANALYZEonly on explicit opt-in), MySQL/PostgreSQL plan analysis, index recommendations - 0.4: model hydration analyzer (unused columns, large result sets)
- 1.0: web dashboard (separate package), real-time monitoring, export, API
Development
composer install vendor/bin/phpunit vendor/bin/phpunit --group benchmark
License
MIT