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
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Catch N+1 queries and performance regressions in your Laravel app — in your terminal and CI, before they hit production.
You refactored a controller. Tests pass, the page renders fine — but query count quietly went from 2 to 51. No test failed, nothing threw. That is the N+1 problem: invisible until production traffic makes it expensive.
Pinpoint records every query per request, flags the repeats, and fails your build when a PR introduces one:
For: Laravel developers who want to catch silent performance regressions before production. Not for: production APM monitoring (use Blackfire/New Relic for that).
Explore the Documentation & Features ↗
Install
composer require asimali/pinpoint php artisan vendor:publish --tag=pinpoint-migrations php artisan migrate
Optional: php artisan vendor:publish --tag=pinpoint-config for full control.
Zero config needed after that — recording starts automatically when APP_ENV is local, development, dev, or testing. PINPOINT_ENABLED=false hard-disables it everywhere; PINPOINT_ENABLED=true forces it on (e.g. staging).
30-second demo
# 1. Start clean, then exercise the app — browser, curl, or your test suite php artisan pinpoint:reset --force curl http://localhost:8000/api/orders # 2. See what was recorded php artisan pinpoint:report # 3. Drill into a flagged route (exact queries + caller file:line + suggested fix) php artisan pinpoint:report --route=api.orders # 4. Made a fix? Verify it instantly — the report aggregates history, # so scope it to samples recorded after the fix: php artisan pinpoint:report --since=5m
--since accepts any natural duration — 5 (minutes), 5m, 5min, 1h, 2d. Or wipe history entirely with pinpoint:reset --force.
What you get
1. A per-route report — p50/p95/p99, tier, peak memory, and N+1 flags, with a Locate block pointing at the worst offenders and their caller file:line (⌘-clickable, VS Code by default, PhpStorm via config):
15 route(s) · 2 critical · 5 with N+1 · 2 with duplicate queries
| Route | p95 | Avg | Samples | Memory (peak) | Tier (p95) | N+1? |
|------------------------|------|------|---------|---------------|------------|----------|
| api.user.families.tree | 258 | 224 | 2 | 6 MB | ACCEPTABLE | Yes (x11)|
Repeats are classified, not just counted: N+1 (varying bindings → eager-load with Model::with()), CACHE (identical bindings → Cache::remember()), REPEAT (no binding data, e.g. raw SQL).
2. A CI gate — fail the merge when a PR introduces an N+1 or blows a budget:
php artisan pinpoint:check --fail-on-n1 --max-queries=20 --max-duration-ms=1000
3. A regression diff — snapshot main, diff after your change:
php artisan pinpoint:snapshot --tag=main # on main, after exercising the app php artisan pinpoint:diff --baseline=main --fail-on-regression # on the PR branch
Why Pinpoint?
| Feature | Pinpoint | Debugbar | Telescope |
|---|---|---|---|
| N+1 detection + fix classification | ✅ auto (N+1 / CACHE / REPEAT) | ⚠️ marks duplicates, you diagnose | ❌ manual inspection |
| CI gate (fail builds on N+1) | ✅ | ❌ | ❌ |
| Baseline snapshot diffing | ✅ | ❌ | ❌ |
| Terminal-first workflow | ✅ | ❌ browser toolbar | ❌ web dashboard |
| Per-route peak-memory budget | ✅ | ⚠️ app-level readout | ❌ |
| Staging-friendly (sampling, prune) | ✅ | ❌ local only | ⚠️ prune-managed |
Scope & limitations
- Is: a local/dev + limited-staging diagnostics tool for request time, query count, query time, per-route peak memory, and repeated-query analysis.
- Is not: an APM replacement, a production-wide trace collector, or a general memory/CPU profiler (use Blackfire for those).
Requirements
- PHP
^8.2 - Laravel
^12.0|^13.0
Full reference
Command reference
| Command | What it does | Key options | Exit codes |
|---|---|---|---|
pinpoint:report |
Per-route summary + Locate block for worst offenders | --tier=, --route=, --since=, --limit= (default 20), --interactive, --json, --json-to= |
0 normal, 1 invalid input / DB error |
pinpoint:check |
CI gate: fail on N+1s or budget violations | --fail-on-n1, --fail-on-duplicates, --max-queries=, --max-duration-ms=, --since= (default 30m), --allow-empty, --json, --json-to=, --limit= |
0 pass, 1 fail |
pinpoint:snapshot |
Capture per-route metrics as a baseline tag or JSON file | --tag=main, --file=, --since=, --no-overwrite |
0 success, 1 failure |
pinpoint:diff |
Compare current metrics against a baseline (REGRESSION / IMPROVEMENT / STABLE / NEW / REMOVED) | --baseline=main, --since=, --fail-on-regression, --show-stable, --json, --json-to= |
0 clean, 1 regression/invalid input |
pinpoint:aggregate |
Roll recent raw requests into pinpoint_summaries (offline percentiles, all-or-nothing per run) |
— | 0 success, 1 failure |
pinpoint:prune |
Delete data older than the retention window (pinpoint.retention_days, default 30) |
--days= |
0 success, 1 failure |
pinpoint:reset |
Wipe ALL recorded data | --force (skip confirmation) |
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) |
--fail-on-n1 covers true N+1s: same SQL with varying bindings, Eloquent lazy-load violations, and repeats with no binding data (cannot be proven safe). Exact duplicates fail only under --fail-on-duplicates. The check reads the last 30 minutes by default (--since), requires sample_rate = 1.0 for deterministic gates, and fails closed on empty windows (a gate that checked nothing is a false green — --allow-empty opts out). Callers are captured in testing/local, so run CI tests with APP_ENV=testing for exact file:line.
Configuration
Publish with php artisan vendor:publish --tag=pinpoint-config:
| Env | Config key | Default | What it does |
|---|---|---|---|
PINPOINT_ENABLED |
enabled |
auto — true when APP_ENV is local/development/dev/testing |
master switch; false disables everything, true forces on |
PINPOINT_CAPTURE_CALLER |
capture_caller |
auto — same environments | debug_backtrace file:line capture |
PINPOINT_MEMORY_BUDGET_KB |
memory_budget_kb |
20480 (20 MB) |
routes exceeding this show bold red; null/-1 disables |
PINPOINT_EDITOR |
editor |
vscode |
URI scheme for clickable file:line links (phpstorm, cursor, windsurf, devin, …) |
PINPOINT_COMPOSITE_TIER |
composite_tier |
false |
replace the p95-only tier column with a Health verdict (HEALTHY / NEEDS WORK · <reasons> over N+1 + memory too) |
PINPOINT_DIFF_DURATION_PCT / PINPOINT_DIFF_QUERY_COUNT / PINPOINT_DIFF_MEMORY_PCT |
diff.regression_* |
20 / 3 / 50 |
pinpoint:diff regression thresholds; an introduced N+1 always flags |
PINPOINT_DIFF_MIN_SAMPLES |
diff.min_samples |
1 |
minimum samples per side before a route is judged |
| — | sample_rate |
1.0 |
fraction of requests recorded (0.1–0.2 in staging, 1.0 local/CI) |
| — | n_plus_one_repeat_threshold |
3 |
repeats of a query shape before flagging |
| — | thresholds_ms |
good 150 / acceptable 400 / needs-improvement 1000 | tier boundaries (ms) |
| — | route_threshold_overrides |
— | per-route tier boundaries for naturally slow/fast endpoints |
| — | retention_days |
30 |
window for pinpoint:prune |
Prune on a schedule: $schedule->command('pinpoint:prune')->daily(); — raw tables grow otherwise.
How N+1 detection works (and its limits)
Two signals:
- Lazy-loading violations (semantic): Pinpoint registers a violation handler recording model + relation, chains nested relations (
stages.photos), and chains to any handler registered before Pinpoint boots. If your app registers its ownhandleLazyLoadingViolationUsing()in a provider booting after Pinpoint, it overwrites Pinpoint's handler — callPinpoint::observeLazyLoad($model, $relation)inside yours to keep the signal. Disable 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. Catches query-builder N+1s, but a legitimate loop running the same query 3+ times will flag — treat as likely N+1, not proof.
Repeats are classified by bound values (normalized MD5; 1 and '1' match; empty bindings → null):
| Classification | Meaning | Fix |
|---|---|---|
| CACHE (cyan) | Same SQL, identical bindings every time | Cache::remember(...) / memoize |
| N+1 (red) | Same shape, different bindings per iteration | Model::with(...) |
| unknown | No binding data (e.g. raw DB::statement) |
drill in and inspect |
Memory column, performance & production guidance
Memory is the peak RAM the PHP process allocated while serving the request (memory_get_peak_usage(true)) — not the response size. The report shows the max across a route's samples; over-budget routes render bold red. A bare Laravel request sits at 2–4 MB baseline — compare routes against each other, not zero. One route spiking 20 MB+ usually means hydrating too many rows: paginate() / limit(), select() needed columns, chunkById().
Overhead (composer benchmark: in-memory SQLite, 10 queries/request, 200 requests): disabled ~0.84 ms → enabled ~1.13 ms (~0.29 ms). DB writes defer to terminating callbacks (after the response); caller capture via debug_backtrace only runs in local environments. Re-run on your hardware: composer benchmark.
Production guidance: local dev + staging at sample_rate 0.1–0.2; never 1.0 at scale (every request inserts rows). Caller capture only runs locally — disable everywhere with pinpoint.capture_caller = false. Schedule pinpoint:prune. Grouping is by route_name, falling back to METHOD path — name routes for useful grouping. Stored SQL is parameterized (bindings never persisted, only hashes); unparameterized interpolated SQL is stored verbatim, so don't interpolate secrets into raw queries. At large volumes use pinpoint:aggregate on a schedule — on-demand percentile computation is instant locally but not meant for staging/prod datasets.
Troubleshooting
"No requests recorded yet" — but I've been hitting endpoints.
Almost certainly disabled: recording needs APP_ENV of local/development/dev/testing (check php artisan about). Custom env names silently disable it — set PINPOINT_ENABLED=true. Also verify migrations ran (php artisan migrate:status | grep pinpoint); a missing table only logs a warning, never throws.
"I fixed the N+1 but the report still shows it."
Expected — the report aggregates all recorded samples. Verify with pinpoint:report --since=5m (summary or --route drill-down), or pinpoint:reset --force.
"My route shows GOOD but has an N+1 / high memory — is the tier wrong?"
No — the tier measures latency alone (Tier (p95)). N+1s and memory are separate columns because a fast route can still be wasteful. A route is only clean when all three are unremarkable.
"Memory shows 4 MB on a route returning tiny JSON." Correct — it's peak process RAM, not response size. Baseline is 2–4 MB; compare routes against each other.
"Tests ran but pinpoint:check finds nothing."
(1) Only HTTP feature tests (get()/post()) record — pure unit tests don't. (2) The window defaults to 30 minutes — use --since=2h for earlier runs. Use sample_rate = 1.0 in CI.
"Is my database even connected?"
Pinpoint records into your app's default connection. A fresh Laravel app uses SQLite (database/database.sqlite) — Pinpoint's tables appear there after publishing + migrating.
"Links don't open my editor."
OSC 8 hyperlinks need a supporting terminal (iTerm2, VS Code integrated terminal, Windows Terminal, kitty…). Scheme comes from pinpoint.editor (vscode default, PINPOINT_EDITOR=phpstorm, any custom scheme passes through). Works from Docker/Sail/WSL — the host terminal resolves the link.
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.
