Persistent run log
Every finished run is written to storage/stopwatch/runs/<ULID>.md, so a slow request can be read after the fact instead of reproduced. Off by default.
STOPWATCH_LOG_RUNS=truePair it with StopwatchMiddleware for HTTP runs, or call stopwatch()->finish() yourself in a command or job. Runs faster than STOPWATCH_LOG_MIN_DURATION_MS (default 50) are skipped.
Each file holds the same markdown stopwatch()->toMarkdown() produces, plus ## SQL detail and ## HTTP detail in full mode, ## Exception when something threw, and ## Context when the context collector is on. YAML frontmatter keeps listing cheap.
Inspect runs
php artisan stopwatch:runs:list --slow --limit=10
php artisan stopwatch:runs:show <id>
php artisan stopwatch:runs:clearFiltering and scheduled cleanup
php artisan stopwatch:runs:list --threw
php artisan stopwatch:runs:list --exception-class=ValidationException
php artisan stopwatch:runs:list --ctx tenant_id=acme --ctx user_id=42
php artisan stopwatch:runs:list --format=jsonPruning is probabilistic and in-process (5%). For a predictable schedule:
0 3 * * * php artisan stopwatch:runs:clear --days=7 --force
0 3 * * * php artisan stopwatch:runs:clear --keep=200 --forceDebugging a slow request
- Set
STOPWATCH_LOG_RUNS=true. RegisterStopwatchMiddleware::autoStart()for HTTP; for commands and jobs callstart()andfinish()yourself. Add checkpoints along the suspect path. - Reproduce the slow path.
php artisan stopwatch:runs:list --slow --limit=10php artisan stopwatch:runs:show <id>on the worst offender.- Read the Share column and find the row that owns most of it:
- high
qon one row: an N+1 candidate. SetSTOPWATCH_LOG_DETAIL=fulland reproduce to see the SQL. - high
h: an outbound API loop. Same flag adds method, URL and status per call. queries_totalfar above the sum of per-checkpoint queries: work is happening after your last checkpoint. Add one near the response and re-profile.
- high
- Split the hot row with more checkpoints inside it. Fix, and go back to step 2.
Limitations
Laravel only, and not supported under Octane or Swoole. The Stopwatch singleton keeps per-run state in memory, which is unsafe for concurrent coroutines. Keep STOPWATCH_LOG_RUNS=false under Octane until the lifecycle is per-request.
Stopwatch::dd($exception) does not capture the exception: dd() calls finish() before inspecting its arguments, so the recorder runs first. Use $stopwatch->withTransientContext(Stopwatch::TRANSIENT_EXCEPTION, $e)->dd().
Writes never throw. A disk failure is logged via logger()->warning() and the request completes.