asimali / pinpoint
Pinpoint: local-first Laravel request performance profiler with N+1 detection, tiering, CLI report and CI regression gate
Requires
- php: ^8.2
- laravel/framework: ^12.0|^13.0
- spatie/laravel-package-tools: ^1.93
Requires (Dev)
- larastan/larastan: ^3.10
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.8|^4.0
- pestphp/pest-plugin-arch: ^3.1|^4.0
- pestphp/pest-plugin-laravel: ^3.2|^4.1
README
Explore the Documentation & Features ↗
Pinpoint is a local-first Laravel request performance profiler. It captures every DB query during a request, detects N+1 patterns, tiers each route (good → critical), and gives you a CLI report that drills from a slow endpoint straight to the offending query and its caller file:line.
Terminal output is rendered with Termwind (ships with Laravel): tier pills are color-coded (green / yellow / red), numbers are right-aligned for quick scanning, units are dimmed, and N+1 flags are red and bold. The design is defined once in Internal\CliRenderer and shared by every Pinpoint command.
Scope & Limitations
- Is: a local/dev + limited-staging diagnostics tool for request time, query count, query time, and N+1 detection.
- Is not: an APM replacement, a production-wide trace collector, or a memory/CPU profiler (use Blackfire for those).
Installation
composer require asimali/pinpoint
Publish and run the migrations:
php artisan vendor:publish --tag=pinpoint-migrations php artisan migrate
Optionally publish the config to customize it:
php artisan vendor:publish --tag=pinpoint-config
Usage
Pinpoint starts recording automatically when enabled. It's enabled by default when APP_ENV is local, development, dev, or testing — zero config needed. PINPOINT_ENABLED=false hard-disables it everywhere (e.g. production); PINPOINT_ENABLED=true is only needed for non-standard environments like staging. Caller file:line capture (debug_backtrace) follows the same default in local/dev/testing and can be turned on/off with PINPOINT_CAPTURE_CALLER.
Command reference
| Command | What it does | Options | Exit codes |
|---|---|---|---|
pinpoint:report |
Per-route summary (p50/p95/p99/avg, tier, N+1) + a "Locate" block for the worst offenders | --tier=, --route=, --since=, --limit=, --json, --json-to= |
0 normal, 1 invalid input / DB error |
pinpoint:check |
CI gate: fail the build on N+1s or query/duration budget violations | --fail-on-n1, --max-queries=, --max-duration-ms=, --since=, --allow-empty, --json, --json-to=, --limit= |
0 pass, 1 fail |
pinpoint:aggregate |
Roll recent raw requests into the pinpoint_summaries table (offline percentiles, all-or-nothing per run) |
— | 0 success, 1 failure |
pinpoint:prune |
Delete recorded data older than the retention window (pinpoint.retention_days, default 30) |
--days= |
0 success, 1 failure |
pinpoint:reset |
Wipe ALL recorded data (requests, queries, lazy loads, summaries) | --force (skip the confirmation prompt) |
0 success, 1 failure |
Local read API (local/debug environments only — blocked by the LocalOnly middleware otherwise):
| Endpoint | Returns |
|---|---|
GET /_pinpoint/api/v1/summaries |
Per-route tiers as JSON |
GET /_pinpoint/api/v1/summaries/{route}/queries |
Top offending queries for one route (URL-encode the route name;METHOD path labels work too) |
Try it on your project
# 1. Start clean php artisan pinpoint:reset --force # 2. Exercise the app — hit endpoints in the browser/curl, or run your test suite curl http://localhost:8000/api/orders # 3. Inspect what was recorded php artisan pinpoint:report # summary table php artisan pinpoint:report --since=1h # only the last hour php artisan pinpoint:report --tier=critical # only critical routes php artisan pinpoint:report --route=api.orders # drill into a route php artisan pinpoint:report --limit=5 # top 5 routes only php artisan pinpoint:report --json | jq . # machine-readable summary php artisan pinpoint:report --json-to=storage/pinpoint/report.json # same JSON to a file, prints its path # 4. Run the CI gate locally (exit 1 = violation) php artisan pinpoint:check --fail-on-n1 --max-queries=20 --max-duration-ms=1000 php artisan pinpoint:check --json # JSON for scripts # 5. Roll raw rows into summaries (schedule this) and prune old data php artisan pinpoint:aggregate php artisan pinpoint:prune --days=7
The report
php artisan pinpoint:report # summary table for all routes php artisan pinpoint:report --tier=critical # only critical routes php artisan pinpoint:report --route=api.orders # drill into a route: top queries + caller file:line php artisan pinpoint:report --since=1h # only consider recent samples php artisan pinpoint:report --since=5m # ...or just the last 5 minutes php artisan pinpoint:report --limit=10 # cap the table (default 20) php artisan pinpoint:report --json # machine-readable summary (scripts / webhooks) php artisan pinpoint:report --json-to=storage/pinpoint/report.json # same JSON written to a file, path printed
Iterating on a fix? The report reads historical samples — after you fix an N+1 or a slow route, the old pre-fix rows still skew the tiers until they age out of the window or are pruned. --since accepts any natural duration (5, 5m, 5min, 1h, 2d; bare number = minutes), so you see your fix's effect immediately:
php artisan pinpoint:report --since=5m # post-fix verification, minutes later php artisan pinpoint:reset # or: clear all recorded data entirely
pinpoint:reset wipes every recorded request/query/lazy-load/summary (asks for confirmation; use --force in scripts).
Aggregation (staging/production)
At scale, compute percentiles offline instead of per-request:
// app/Console/Kernel.php $schedule->command('pinpoint:aggregate')->hourly();
php artisan pinpoint:report --route=api.packages
Drilling into a route with lazy-load violations also prints actionable fixes, not just warnings:
N+1 detected — suggested eager loads:
App\Models\CloseoutPackage -> stages.photos at app/Services/ApprovalReadinessService.php:124
Suggested fix: App\Models\CloseoutPackage::with('stages.photos')
Click to jump to the exact line: every file:line in the report is an OSC 8 terminal hyperlink. Route names in the summary table and the Locate block are links too — Pinpoint resolves each route to its controller action via reflection, so ⌘-clicking a route name jumps straight to the handler method. ⌘-click (macOS) / Ctrl-click (Windows/Linux) works from inside Docker/Sail/WSL because the host terminal resolves the URI scheme, not the container. Default is VS Code (vscode://file/path:line); switch to PhpStorm via config:
// config/pinpoint.php 'editor' => 'phpstorm', // or env PINPOINT_EDITOR=phpstorm
Any other editor that registers its own URI scheme works too — set PINPOINT_EDITOR to its scheme and Pinpoint emits <scheme>://file/path:line (e.g. devin, cursor, windsurf). VS Code-compatible forks (Cursor, Windsurf/Devin Desktop) typically register their scheme (or vscode://) as the handler; pick whichever your OS opens by default.
The summary table is followed by a Locate block showing the top 5 worst offenders (N+1 or critical routes) with their caller line; the rest are listed with a hint to drill in.
Pinpoint persists the model + relation of every lazy-loading violation, chains nested relations (stages.photos when stages itself is lazily loaded), and shows the exact caller — so the fix is copy-paste ready.
CI / GitHub Actions — fail the merge on N+1s and query bloat
Pinpoint doubles as a regression gate: run your test suite (requests get recorded), then pinpoint:check fails the job when a PR introduces an N+1 or blows a query/duration budget — with the exact offending SQL and file:line in the output.
php artisan pinpoint:check --fail-on-n1 --max-queries=20 --max-duration-ms=1000
Exit code is 0 (pass) or 1 (fail) — drop it straight into a workflow:
steps: - run: php artisan test - run: php artisan pinpoint:check --fail-on-n1 --max-queries=20 --json > pinpoint-report.json - if: failure() uses: actions/github-script@v7 with: script: | const fs = require('fs'); const report = JSON.parse(fs.readFileSync('pinpoint-report.json', 'utf8')); core.setFailed(report.violations.map(v => `N+1 in ${v.route}: ${v.sql} at ${v.caller_file}:${v.caller_line} (x${v.repeat_count})` ).join('\n'));
Options:
--fail-on-n1— fail when any request repeats the same SQL ≥pinpoint.n_plus_one_repeat_thresholdtimes (Eloquent lazy loads are included via the violation callback).--max-queries=N— fail when any request exceeds N queries.--max-duration-ms=N— fail when any request exceeds N ms.--since=MINUTES(default 30) — only inspect recent requests; stale rows from previous runs can't false-fail a PR.--allow-empty— pass (with a warning) when the window contains no requests.--json— machine-readable{passed, meta, violations[]}for PR-comment automation.
Notes for CI:
- Callers are captured in
testingandlocalenvironments — in CI, run tests withAPP_ENV=testingso the report includes the exact file:line. - The check reads raw tables, so it's meant for the same job that just ran the tests (fresh data,
--sinceguards the window). Usesample_rate = 1.0in the CI environment so the gate is deterministic — the command warns if sampling is on. - No data in the window → the gate fails closed (a check that evaluated nothing is a false green). If an empty run is legitimate for your pipeline, add
--allow-empty.
Retention
Raw tables grow. Prune old data on a schedule (default retention: 30 days, configurable via pinpoint.retention_days):
$schedule->command('pinpoint:prune')->daily();
N+1 detection — how it works and its limits
Two signals:
- Lazy-loading violations (semantic): Pinpoint registers a violation handler that records the model + relation. This is precise but only covers Eloquent relations, and it chains to any handler already registered before Pinpoint boots. If your app registers its own
handleLazyLoadingViolationUsing()in a provider that boots after Pinpoint, it will overwrite Pinpoint's handler — in that case, callPinpoint::observeLazyLoad($model, $relation)inside your own handler to keep the signal working. You can disable this signal entirely withpinpoint.capture_lazy_loading_violations = false. - Fingerprint repeat count (heuristic): the same normalized SQL appearing 3+ times (
pinpoint.n_plus_one_repeat_threshold) in one request is flagged. This catches N+1s done via the raw query builder, but it's a heuristic — a legitimate loop that intentionally runs the same query 3+ times will be flagged. Treat this signal as likely N+1, not proof.
Both signals set has_n_plus_one; the report shows the repeat count as Yes (xN).
Performance
Measured with composer benchmark (in-memory SQLite, 10 queries/request, 200 requests, Testbench skeleton app). DB writes are deferred to the application's terminating callbacks — after the response is sent — so the request path only pays for in-memory capture:
| Scenario | Mean request time | Overhead |
|---|---|---|
| Pinpoint disabled | ~0.84 ms | — |
| Enabled, no caller capture | ~1.13 ms | ~0.29 ms |
| Enabled, local + caller capture | ~1.13 ms | ~0.29 ms |
The worst case (caller capture via debug_backtrace) only runs in local environments — production never pays it. The remaining overhead is one fingerprint hash per query plus the deferred request/query row inserts. Re-run on your own hardware: composer benchmark.
Production guidance
- Recommended use: local development, and staging at
sample_rate0.1–0.2. - Do not run
sample_rate = 1.0at high scale — every request inserts rows. - Caller capture (
debug_backtrace, the most expensive part) only runs in local environments. Disable it everywhere withpinpoint.capture_caller = false. - Retention: schedule
pinpoint:prune(default 30 days) or raw tables grow unbounded. - Route grouping: requests are grouped by
route_name; requests without a route name fall back toMETHOD path. If many endpoints share a route name, the summary flattens them — name your routes for useful grouping. - Stored SQL is parameterized — bound values are never persisted (Laravel sends them as
?placeholders, and Pinpoint stores only the SQL string). The exception is unparameterized SQL (DB::select("... where x = '$value'"),whereRawwith interpolated values): those literals are stored verbatim, so don't pass secrets through string interpolation into raw queries. - Local summary computation is in-memory —
pinpoint:reportand the API read all raw rows to compute percentiles on demand. This is instant at local-dev volumes; for large staging/prod datasets usepinpoint:aggregateon a schedule and keep retention tight.
Testing
composer test
Changelog
See CHANGELOG.md for recent changes.
Contributing
Please see CONTRIBUTING.md for details.
Security Vulnerabilities
Report security vulnerabilities privately to asimalipeerzada@gmail.com. Please do not open a public issue.
Credits
License
The MIT License (MIT). Please see LICENSE for more information.
