{"id":51412973,"url":"https://github.com/mateffy/laraperf","last_synced_at":"2026-07-04T16:02:18.885Z","repository":{"id":351657013,"uuid":"1211921701","full_name":"mateffy/laraperf","owner":"mateffy","description":" Performance analysis toolkit for LLM coding agents. Captures SQL queries, detects N+1 patterns, and runs `EXPLAIN ANALYZE` — all via short-lived Artisan commands that output structured JSON to stdout.","archived":false,"fork":false,"pushed_at":"2026-06-24T06:33:34.000Z","size":8921,"stargazers_count":10,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-27T02:11:09.022Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"http://laraperf.dev","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mateffy.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.md","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"Lukas Mateffy"}},"created_at":"2026-04-15T22:14:43.000Z","updated_at":"2026-04-22T06:36:57.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/mateffy/laraperf","commit_stats":null,"previous_names":["mateffy/laraperf"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/mateffy/laraperf","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mateffy%2Flaraperf","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mateffy%2Flaraperf/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mateffy%2Flaraperf/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mateffy%2Flaraperf/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mateffy","download_url":"https://codeload.github.com/mateffy/laraperf/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mateffy%2Flaraperf/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35127443,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-04T02:00:05.987Z","response_time":113,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-07-04T16:02:18.184Z","updated_at":"2026-07-04T16:02:18.876Z","avatar_url":"https://github.com/mateffy.png","language":"JavaScript","funding_links":["https://github.com/sponsors/Lukas Mateffy"],"categories":[],"sub_categories":[],"readme":"# laraperf\n\nLaravel 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.\n\n\u003cbr\u003e\n    \n\u003cimg src=\"./resources/images/header.webp\" alt=\"Terminal screenshot\" width=\"100%\" align=\"center\" /\u003e\n\n\u003cbr\u003e\n\n## Why this exists\n\nStandard 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.\n\nlaraperf bridges this gap:\n\n- **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.\n- **Analyse** — `perf:query` outputs structured JSON: summaries, slow queries, N+1 candidates with source file/line pointing into `app/` code (vendor frames stripped).\n- **Plan** — `perf:explain` runs `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` against any SQL string, with a runtime database-name override for multi-tenant setups.\n\n\u003cbr\u003e\n\n## Installation\n\n```bash\ncomposer require mateffy/laraperf\n```\n\nRequires PHP 8.3+ and Laravel 11+ (supports 11, 12, 13).\n\nNo publish step needed — config is auto-merged. Environment variables:\n\n```\nPERF_CONNECTION=pgsql    # Default DB connection for perf commands\nPERF_DB=                 # Override database name (for multi-tenant)\n```\n\n\u003cbr\u003e\n\n## Commands\n\n### `perf:watch` — Start a capture session\n\nReturns immediately by default (detached mode). The session stays active for 5 minutes, or until `perf:stop`.\n\n```\n--sync              Run in the foreground. Ctrl+C or timeout ends it.\n--seconds=N         Window duration in seconds. Default: 300.\n--forever           Keep session alive indefinitely (detached only).\n--tag=label         Arbitrary label stored in session metadata.\n```\n\n**Detached mode (default):** Spawns `perf:_worker` as a background process. The parent exits immediately and prints the session ID.\n\n```bash\nphp artisan perf:watch\n# → perf:watch [detached] session=session-20260416-143201-xK9mQp pid=47821 duration=300s\n# → Use `php artisan perf:stop` to stop, or wait for the timeout.\n# → Then run: php artisan perf:query --session=session-20260416-143201-xK9mQp\n```\n\n**Sync mode:** Blocks the terminal. Handles Ctrl+C via SIGINT/SIGTERM.\n\n```bash\nphp artisan perf:watch --sync --seconds=60\n```\n\n### `perf:stop` — Stop detached watchers\n\nSends SIGTERM, waits up to 2 seconds, then SIGKILL if unresponsive. Finalizes sessions and removes PID sentinels.\n\n```bash\nphp artisan perf:stop\nphp artisan perf:stop --session=session-20260416-143201-xK9mQp\n```\n\n### `perf:query` — Analyse captured queries\n\nReads 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.\n\n```\n--session=last      Session ID, or \"last\" for the most recent completed session.\n--summary           Show aggregate session stats.\n--slow=N            Show queries slower than N milliseconds.\n--n1=N              Show N+1 candidates where same query repeats ≥ N times per batch.\n--limit=50          Max records returned.\n--batch=            Filter to a specific request batch ID.\n--connection=       Filter to a specific DB connection name.\n--operation=        Filter to SELECT, INSERT, UPDATE, DELETE, etc.\n--format=json       Output format: json (default) | table\n```\n\n**Default** (summary + slow + n1):\n\n```bash\nphp artisan perf:query\n```\n\n```json\n{\n  \"summary\": { \"type\": \"summary\", \"session_id\": \"...\", \"total_queries\": 183 },\n  \"slow\": { \"type\": \"slow\", \"threshold_ms\": 100, \"count\": 3, \"queries\": [...] },\n  \"n1\": { \"type\": \"n1\", \"threshold\": 3, \"candidate_count\": 2, \"candidates\": [...] }\n}\n```\n\nEach 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`.\n\n**Specific reports:**\n\n```bash\nphp artisan perf:query --n1=3          # N+1 candidates only\nphp artisan perf:query --slow=50       # Queries slower than 50ms\nphp artisan perf:query --summary --slow=50 --n1=3  # Combine flags\nphp artisan perf:query --format=table  # Human-readable table output\n```\n\n### `perf:explain` — Run EXPLAIN ANALYZE\n\nRuns `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.\n\n```\n--sql=              Raw SQL with bindings already interpolated.\n--hash=             12-char hash from perf:query output. Looks up example_raw_sql automatically.\n--session=last      Session to look up --hash from.\n--connection=       Laravel connection name. Default: config('laraperf.connection').\n--db=               Override the database name on the connection at runtime.\n```\n\nThe `--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.\n\n```bash\n# Direct SQL\nphp artisan perf:explain \\\n  --sql \"select * from \\\"estates\\\" where id = '834b7d2a-...'\" \\\n  --connection=tenant --db=tenant_dev\n\n# Reference a query hash from perf:query output\nphp artisan perf:explain --hash=a1b2c3d4e5f6 --db=tenant_dev\n\n# Pipe into jq\nphp artisan perf:explain --hash=a1b2c3d4e5f6 --db=tenant_dev | jq '.[0].Plan'\n```\n\nOutput:\n\n```json\n{\n  \"driver\": \"pgsql\",\n  \"connection\": \"tenant\",\n  \"database\": \"tenant_dev\",\n  \"plan\": [{ \"Plan\": { \"Node Type\": \"Index Scan\", ... } }],\n  \"error\": null\n}\n```\n\n### `perf:clear` — Delete session files\n\nRemoves all session files from `storage/perf/`. Refuses to run if active watchers are detected.\n\n```bash\nphp artisan perf:clear --force\n```\n\n\u003cbr\u003e\n\n## How it works\n\n### PHP-FPM interception\n\nUnder 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.\n\n### Session storage\n\nSessions live in `storage/perf/\u003csession_id\u003e.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.\n\nPID sentinels are written to `storage/perf/.watcher-{pid}` by background workers. `perf:stop` reads these to send SIGTERM.\n\nAdd to `.gitignore`:\n\n```\n/storage/perf/\n```\n\n### Stack trace filtering\n\n`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.\n\n```json\n\"source\": [\n  { \"file\": \"/app/Domains/Deals/Resources/DealResource/Pages/ListDeals.php\", \"line\": 47, \"function\": \"getTableQuery\" }\n]\n```\n\n### N+1 detection\n\n`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 \u003e= 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.\n\n\u003cbr\u003e\n\n## Typical workflow\n\n```bash\n# 1. Start a 2-minute capture window\nphp artisan perf:watch --seconds=120\n# → session=session-20260416-143201-xK9mQp\n\n# 2. Use the application (browser, API calls, etc.)\n#    Queries are automatically captured to the session file\n\n# 3. Get a summary\nphp artisan perf:query\n# → { \"summary\": {...}, \"slow\": {...}, \"n1\": {...} }\n\n# 4. Drill into the worst N+1\nphp artisan perf:query --n1=3 | jq '.n1.candidates[0]'\n# → { \"count\": 47, \"table\": \"contacts\", \"example_source\": {...} }\n\n# 5. Get the EXPLAIN plan\nphp artisan perf:explain --hash=a1b2c3d4e5f6 | jq '.[0].Plan'\n\n# 6. Stop early if needed\nphp artisan perf:stop\n```\n\n\u003cbr\u003e\n\n## Programmatic testing API\n\nlaraperf 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.\n\n### Global functions\n\n```php\nuse function Mateffy\\Laraperf\\Testing\\{measure, capture, is_capturing, timeline_mark};\n\n// Measure a single operation\n$result = measure(fn () =\u003e User::with('posts')-\u003eget());\n\n// Manual start/stop with timeline marks\n$cap = capture();         // starts capture\ntimeline_mark('before-query');\nUser::all();\ntimeline_mark('after-query');\n$result = $cap-\u003estop();   // stops and returns PerformanceResult\n\n// Check if a capture session is active\nif (is_capturing()) { ... }\n```\n\n### PerformanceResult\n\n`measure()` and `stop()` return a `PerformanceResult` with:\n\n| Method | Returns |\n|--------|---------|\n| `durationMs()` | Total execution time in ms |\n| `peakMemoryBytes()` | Peak memory usage |\n| `netMemoryBytes()` | Memory increase during capture |\n| `peakMemoryHuman()` | Human-readable peak memory (e.g. \"2.4 MB\") |\n| `queryCount()` | Number of queries executed |\n| `totalQueryTimeMs()` | Total time spent in queries |\n| `slowQueries($thresholdMs)` | Queries slower than threshold |\n| `n1Candidates($threshold)` | N+1 pattern candidates |\n| `hasN1Patterns($threshold)` | Whether any N+1 patterns were found |\n| `tablesAccessed()` | Array of unique table names |\n| `queriesByTable($table)` | Queries for a specific table |\n| `summary()` | Quick overview array |\n| `toArray()` / `toJson()` | Full serialization |\n\n### Pest integration\n\nlaraperf auto-registers with Pest. Every test gets automatic performance capture, and you can set declarative constraints.\n\n```php\n// Declarative constraints via test() chain\ntest('dashboard does not trigger N+1 queries')\n    -\u003emaxQueryCount(10)\n    -\u003enoN1Patterns()\n    -\u003emaxDuration(500)    // ms\n    -\u003emaxMemory('10M');\n\n// Access results with perf()\ntest('user list is fast', function () {\n    $result = perf();  // PerformanceResult for this test\n    expect($result-\u003equeryCount())-\u003etoBeLessThan(20);\n});\n\n// Fluent expectation API\ntest('user query performance', function () {\n    $result = measure(fn () =\u003e User::with('posts')-\u003epaginate());\n    \n    expect($result)\n        -\u003eperformance()-\u003eduration()-\u003etoBeLessThan(100)\n        -\u003eperformance()-\u003equeries()-\u003ecount()-\u003etoBeLessThan(10)\n        -\u003eperformance()-\u003equeries()-\u003ewhereTable('users')-\u003ecount()-\u003etoBe(1)\n        -\u003eperformance()-\u003en1()-\u003etoBe(0)\n        -\u003eperformance()-\u003etoHaveNoN1()\n        -\u003eperformance()-\u003etoHaveNoSlowQueries(50);\n});\n\n// Manual capture in tests\ntest('specific operation', function () {\n    $this-\u003estartPerformanceCapture();\n    // ... code under test ...\n    $result = $this-\u003estopPerformanceCapture();\n    \n    expect($result-\u003en1Count())-\u003etoBe(0);\n});\n```\n\nConstraint methods available on `test()`:\n\n| Method | Description |\n|--------|-------------|\n| `-\u003emaxQueryCount(int)` | Max allowed queries |\n| `-\u003emaxQueryDuration(float)` | Max single query duration in ms |\n| `-\u003emaxDuration(float)` | Max total test duration in ms |\n| `-\u003emaxDuration(float)` | Alias: `maxTotalDuration()` |\n| `-\u003emaxMemory(string\\|int)` | Max memory usage (\"10M\", \"512KB\", or bytes) |\n| `-\u003emaxN1Candidates(int, int)` | Max N+1 candidate count (with optional threshold) |\n| `-\u003enoN1Patterns(int)` | Require zero N+1 patterns |\n\n\u003cbr\u003e\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmateffy%2Flaraperf","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmateffy%2Flaraperf","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmateffy%2Flaraperf/lists"}