Search by

asimali / pinpoint

asim-ali-peerzada

Pinpoint: local-first Laravel request performance profiler with N+1 detection, tiering, CLI report and CI regression gate

v1.7.0 2026-09-04 14:10 UTC

This package is auto-updated.

Last update: 2026-09-07 09:01:14 UTC


README

Latest Version on Packagist Total Downloads GitHub Stars License

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:

Pinpoint CLI performance report

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.10.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:

  1. 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 own handleLazyLoadingViolationUsing() in a provider booting after Pinpoint, it overwrites Pinpoint's handler — call Pinpoint::observeLazyLoad($model, $relation) inside yours to keep the signal. Disable with pinpoint.capture_lazy_loading_violations = false.
  2. 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.