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. For commands and queued jobs, set STOPWATCH_LOG_AUTO_LIFECYCLE=true: the outermost command and each queued job then become a run of their own, and a job on the sync queue inside a request or command joins that run. Without it, call stopwatch()->finish() yourself. Runs faster than STOPWATCH_LOG_MIN_DURATION_MS (default 50) are skipped.
Debug mode (STOPWATCH_DEBUG=true) turns all of this on and also writes each checkpoint to disk as it happens.
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> # or: latest
php artisan stopwatch:runs:clearThe list has a State column: finished, or for a run that only has a debug stream, shutdown, restarted or unfinished (see Debugging with probes).
Filtering 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 --forceDebug mode
STOPWATCH_DEBUG=true also writes each checkpoint to storage/stopwatch/runs/<ULID>.jsonl the moment it happens, beside the run's markdown file, so a run that never reaches finish() can still be read. Debugging with probes covers the loop: probe(), stopwatch:runs:show latest, the run states, stopwatch:runs:diff, and the limits.
Debugging a slow request
- Set
STOPWATCH_LOG_RUNS=true. RegisterStopwatchMiddleware::autoStart()for HTTP; for commands and jobs setSTOPWATCH_LOG_AUTO_LIFECYCLE=true, or 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. Debug mode refuses to start there. 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.