PHPackages                             mateffy/laraperf - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Debugging &amp; Profiling](/categories/debugging)
4. /
5. mateffy/laraperf

ActiveLibrary[Debugging &amp; Profiling](/categories/debugging)

mateffy/laraperf
================

Performance analysis toolkit for AI coding agents — SQL query profiling, N+1 detection, EXPLAIN ANALYZE, via Artisan commands outputting structured JSON.

1.3.1(1mo ago)10997↓50%MITJavaScriptPHP ^8.3CI passing

Since Apr 16Pushed 1mo agoCompare

[ Source](https://github.com/mateffy/laraperf)[ Packagist](https://packagist.org/packages/mateffy/laraperf)[ Docs](https://github.com/mateffy/laraperf)[ GitHub Sponsors]()[ RSS](/packages/mateffy-laraperf/feed)WikiDiscussions main Synced 1w ago

READMEChangelog (4)Dependencies (15)Versions (5)Used By (0)

laraperf
========

[](#laraperf)

Laravel performance analysis CLI tool for AI coding agents. Captures SQL queries, detects N+1 patterns, and runs `EXPLAIN ANALYZE` — all through short-lived Artisan commands that output structured JSON to stdout. No browser or GUI required.

[![Terminal screenshot](./resources/images/header.webp)](./resources/images/header.webp)

Why this exists
---------------

[](#why-this-exists)

Standard profiling tools (Debugbar, Clockwork, Telescope) are browser-first. LLM agents work via commands and stdout, not GUIs. Eloquent and Filament generate queries that are invisible at the source level — the agent never sees the PHP that triggers them.

laraperf bridges this gap:

- **Capture** — `DB::listen` attaches to every PHP-FPM request while a session is active. Each request appends its queries to a shared JSON file. The agent reads the file after the fact.
- **Analyse** — `perf:query` outputs structured JSON: summaries, slow queries, N+1 candidates with source file/line pointing into `app/` code (vendor frames stripped).
- **Plan** — `perf:explain` runs `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` against any SQL string, with a runtime database-name override for multi-tenant setups.

Installation
------------

[](#installation)

```
composer require mateffy/laraperf
```

Requires PHP 8.3+ and Laravel 11+ (supports 11, 12, 13).

No publish step needed — config is auto-merged. Environment variables:

```
PERF_CONNECTION=pgsql    # Default DB connection for perf commands
PERF_DB=                 # Override database name (for multi-tenant)

```

Commands
--------

[](#commands)

### `perf:watch` — Start a capture session

[](#perfwatch--start-a-capture-session)

Returns immediately by default (detached mode). The session stays active for 5 minutes, or until `perf:stop`.

```
--sync              Run in the foreground. Ctrl+C or timeout ends it.
--seconds=N         Window duration in seconds. Default: 300.
--forever           Keep session alive indefinitely (detached only).
--tag=label         Arbitrary label stored in session metadata.

```

**Detached mode (default):** Spawns `perf:_worker` as a background process. The parent exits immediately and prints the session ID.

```
php artisan perf:watch
# → perf:watch [detached] session=session-20260416-143201-xK9mQp pid=47821 duration=300s
# → Use `php artisan perf:stop` to stop, or wait for the timeout.
# → Then run: php artisan perf:query --session=session-20260416-143201-xK9mQp
```

**Sync mode:** Blocks the terminal. Handles Ctrl+C via SIGINT/SIGTERM.

```
php artisan perf:watch --sync --seconds=60
```

### `perf:stop` — Stop detached watchers

[](#perfstop--stop-detached-watchers)

Sends SIGTERM, waits up to 2 seconds, then SIGKILL if unresponsive. Finalizes sessions and removes PID sentinels.

```
php artisan perf:stop
php artisan perf:stop --session=session-20260416-143201-xK9mQp
```

### `perf:query` — Analyse captured queries

[](#perfquery--analyse-captured-queries)

Reads a completed session and outputs analysis as JSON (status lines go to stderr). When no output flags are given, all three reports are included (summary, slow≥100ms, n1≥3). Flags can be combined freely.

```
--session=last      Session ID, or "last" for the most recent completed session.
--summary           Show aggregate session stats.
--slow=N            Show queries slower than N milliseconds.
--n1=N              Show N+1 candidates where same query repeats ≥ N times per batch.
--limit=50          Max records returned.
--batch=            Filter to a specific request batch ID.
--connection=       Filter to a specific DB connection name.
--operation=        Filter to SELECT, INSERT, UPDATE, DELETE, etc.
--format=json       Output format: json (default) | table

```

**Default** (summary + slow + n1):

```
php artisan perf:query
```

```
{
  "summary": { "type": "summary", "session_id": "...", "total_queries": 183 },
  "slow": { "type": "slow", "threshold_ms": 100, "count": 3, "queries": [...] },
  "n1": { "type": "n1", "threshold": 3, "candidate_count": 2, "candidates": [...] }
}
```

Each N+1 candidate includes: `count`, `total_time_ms`, `avg_time_ms`, `normalized_sql`, `table`, `batch_id`, `example_raw_sql`, `example_source` (app-frame stack trace), and up to 5 `example_instances`.

**Specific reports:**

```
php artisan perf:query --n1=3          # N+1 candidates only
php artisan perf:query --slow=50       # Queries slower than 50ms
php artisan perf:query --summary --slow=50 --n1=3  # Combine flags
php artisan perf:query --format=table  # Human-readable table output
```

### `perf:explain` — Run EXPLAIN ANALYZE

[](#perfexplain--run-explain-analyze)

Runs `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for PostgreSQL, falls back to plain `EXPLAIN` for other drivers. For non-SELECT statements, wraps in `BEGIN`/`ROLLBACK` to avoid data mutation.

```
--sql=              Raw SQL with bindings already interpolated.
--hash=             12-char hash from perf:query output. Looks up example_raw_sql automatically.
--session=last      Session to look up --hash from.
--connection=       Laravel connection name. Default: config('laraperf.connection').
--db=               Override the database name on the connection at runtime.

```

The `--db` flag patches `database.connections.{name}.database` at runtime and calls `DB::purge()` to force a fresh connection. No changes to `config/database.php` and no tenancy package dependency.

```
# Direct SQL
php artisan perf:explain \
  --sql "select * from \"estates\" where id = '834b7d2a-...'" \
  --connection=tenant --db=tenant_dev

# Reference a query hash from perf:query output
php artisan perf:explain --hash=a1b2c3d4e5f6 --db=tenant_dev

# Pipe into jq
php artisan perf:explain --hash=a1b2c3d4e5f6 --db=tenant_dev | jq '.[0].Plan'
```

Output:

```
{
  "driver": "pgsql",
  "connection": "tenant",
  "database": "tenant_dev",
  "plan": [{ "Plan": { "Node Type": "Index Scan", ... } }],
  "error": null
}
```

### `perf:clear` — Delete session files

[](#perfclear--delete-session-files)

Removes all session files from `storage/perf/`. Refuses to run if active watchers are detected.

```
php artisan perf:clear --force
```

How it works
------------

[](#how-it-works)

### PHP-FPM interception

[](#php-fpm-interception)

Under PHP-FPM, each web request is a separate process. The background worker can't intercept those requests' queries directly. Instead, `LaraperfServiceProvider::packageBooted()` checks on every boot whether an active session exists on disk. When found, `DB::listen` is attached to that request's process. Every request made while the watcher is alive automatically appends its queries to the session JSON. When no session is active, overhead is ~1 `glob` call.

### Session storage

[](#session-storage)

Sessions live in `storage/perf/.json`. Each session is a JSON object with a `queries` array. Writes are atomic (write to `.tmp.{pid}`, then `rename`). Up to 10 completed sessions are retained; older ones are pruned automatically.

PID sentinels are written to `storage/perf/.watcher-{pid}` by background workers. `perf:stop` reads these to send SIGTERM.

Add to `.gitignore`:

```
/storage/perf/

```

### Stack trace filtering

[](#stack-trace-filtering)

`QueryLogger` captures up to 5 frames from each query's call stack, filtered to `app/` and `packages/` frames (excluding vendor and framework). Filament/Eloquent queries report the specific Resource, Page, Action, or RelationManager that triggered them — not an anonymous closure inside the framework.

```
"source": [
  { "file": "/app/Domains/Deals/Resources/DealResource/Pages/ListDeals.php", "line": 47, "function": "getTableQuery" }
]
```

### N+1 detection

[](#n1-detection)

`N1Detector` groups queries by `(batch_id, normalized_sql_hash)`. Two queries match when their SQL is structurally identical after stripping all literal values. Groups with `count >= 3` (default threshold) are reported as N+1 candidates. Each PHP-FPM request gets a unique `batch_id`, so N+1s are detected per-request, not across requests.

Typical workflow
----------------

[](#typical-workflow)

```
# 1. Start a 2-minute capture window
php artisan perf:watch --seconds=120
# → session=session-20260416-143201-xK9mQp

# 2. Use the application (browser, API calls, etc.)
#    Queries are automatically captured to the session file

# 3. Get a summary
php artisan perf:query
# → { "summary": {...}, "slow": {...}, "n1": {...} }

# 4. Drill into the worst N+1
php artisan perf:query --n1=3 | jq '.n1.candidates[0]'
# → { "count": 47, "table": "contacts", "example_source": {...} }

# 5. Get the EXPLAIN plan
php artisan perf:explain --hash=a1b2c3d4e5f6 | jq '.[0].Plan'

# 6. Stop early if needed
php artisan perf:stop
```

Programmatic testing API
------------------------

[](#programmatic-testing-api)

laraperf provides a testing API for use in PHPUnit/Pest tests, tinker, or any PHP context. It captures queries, detects N+1 patterns, and measures timing and memory — all in-process, no CLI required.

### Global functions

[](#global-functions)

```
use function Mateffy\Laraperf\Testing\{measure, capture, is_capturing, timeline_mark};

// Measure a single operation
$result = measure(fn () => User::with('posts')->get());

// Manual start/stop with timeline marks
$cap = capture();         // starts capture
timeline_mark('before-query');
User::all();
timeline_mark('after-query');
$result = $cap->stop();   // stops and returns PerformanceResult

// Check if a capture session is active
if (is_capturing()) { ... }
```

### PerformanceResult

[](#performanceresult)

`measure()` and `stop()` return a `PerformanceResult` with:

MethodReturns`durationMs()`Total execution time in ms`peakMemoryBytes()`Peak memory usage`netMemoryBytes()`Memory increase during capture`peakMemoryHuman()`Human-readable peak memory (e.g. "2.4 MB")`queryCount()`Number of queries executed`totalQueryTimeMs()`Total time spent in queries`slowQueries($thresholdMs)`Queries slower than threshold`n1Candidates($threshold)`N+1 pattern candidates`hasN1Patterns($threshold)`Whether any N+1 patterns were found`tablesAccessed()`Array of unique table names`queriesByTable($table)`Queries for a specific table`summary()`Quick overview array`toArray()` / `toJson()`Full serialization### Pest integration

[](#pest-integration)

laraperf auto-registers with Pest. Every test gets automatic performance capture, and you can set declarative constraints.

```
// Declarative constraints via test() chain
test('dashboard does not trigger N+1 queries')
    ->maxQueryCount(10)
    ->noN1Patterns()
    ->maxDuration(500)    // ms
    ->maxMemory('10M');

// Access results with perf()
test('user list is fast', function () {
    $result = perf();  // PerformanceResult for this test
    expect($result->queryCount())->toBeLessThan(20);
});

// Fluent expectation API
test('user query performance', function () {
    $result = measure(fn () => User::with('posts')->paginate());

    expect($result)
        ->performance()->duration()->toBeLessThan(100)
        ->performance()->queries()->count()->toBeLessThan(10)
        ->performance()->queries()->whereTable('users')->count()->toBe(1)
        ->performance()->n1()->toBe(0)
        ->performance()->toHaveNoN1()
        ->performance()->toHaveNoSlowQueries(50);
});

// Manual capture in tests
test('specific operation', function () {
    $this->startPerformanceCapture();
    // ... code under test ...
    $result = $this->stopPerformanceCapture();

    expect($result->n1Count())->toBe(0);
});
```

Constraint methods available on `test()`:

MethodDescription`->maxQueryCount(int)`Max allowed queries`->maxQueryDuration(float)`Max single query duration in ms`->maxDuration(float)`Max total test duration in ms`->maxDuration(float)`Alias: `maxTotalDuration()``->maxMemory(string|int)`Max memory usage ("10M", "512KB", or bytes)`->maxN1Candidates(int, int)`Max N+1 candidate count (with optional threshold)`->noN1Patterns(int)`Require zero N+1 patterns
License
-------

[](#license)

MIT

###  Health Score

47

—

FairBetter than 93% of packages

Maintenance90

Actively maintained with recent releases

Popularity26

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity52

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 95.3% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

53d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/e572442281b0c1b6e8e8f966cc908161a66c4c822e950ff278f8f90dc4f2d2cd?d=identicon)[mateffy](/maintainers/mateffy)

---

Top Contributors

[![Capevace](https://avatars.githubusercontent.com/u/10093858?v=4)](https://github.com/Capevace "Capevace (41 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (1 commits)")[![github-actions[bot]](https://avatars.githubusercontent.com/in/15368?v=4)](https://github.com/github-actions[bot] "github-actions[bot] (1 commits)")

---

Tags

laraveldebugperformanceprofilingexplainLukas Mateffyn plus 1laraperf

###  Code Quality

TestsPest

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/mateffy-laraperf/health.svg)

```
[![Health](https://phpackages.com/badges/mateffy-laraperf/health.svg)](https://phpackages.com/packages/mateffy-laraperf)
```

###  Alternatives

[spatie/laravel-health

Monitor the health of a Laravel application

88011.3M149](/packages/spatie-laravel-health)[larastan/larastan

Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel

6.4k51.0M7.4k](/packages/larastan-larastan)[laravel/ai

The official AI SDK for Laravel.

9782.1M153](/packages/laravel-ai)[psalm/plugin-laravel

Psalm plugin for Laravel

3325.1M337](/packages/psalm-plugin-laravel)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k8.7M64](/packages/spatie-laravel-responsecache)[illuminate/queue

The Illuminate Queue package.

20432.2M1.5k](/packages/illuminate-queue)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
