Search by

zhora1996 / laravel-performance-analyzer

Zhora1996

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

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-09-25 19:54 UTC

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.high is reported as N+1 Query.
  • A score ≥ confidence.potential is reported as Potential N+1 Query. You can hide these with report_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; ANALYZE only 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