Debugging with probes
A checkpoint records a label, the values you pass, and the time since the previous one. That makes it a structured dump(): put a few on the path you suspect, run the code once, and read the values back in execution order. Debug mode writes each one to disk the moment it happens, so this works in a test, a command, a queued job or a request, and survives an exception, exit() or dd().
STOPWATCH_DEBUG=trueDebug mode only switches on when APP_DEBUG=true or APP_ENV is local or testing, and never under Octane. It forces the run log on with no minimum duration and gives every artisan command and queued job a run of its own.
Add probes
stopwatch()->probe('repeat-customer branch', ['rate' => $rate, 'eligible' => $eligible]);probe() records exactly like checkpoint() and also stores the file:line it was called from. The flag marks the line as temporary, so you can find it later. Always write it as stopwatch()->probe(: that exact text is what you search for at the end, and a bare ->probe( also matches unrelated probe() methods in your app.
Pass scalars or small arrays ($order->id, not the whole $order). Each value is capped at 4096 bytes in the stream (STOPWATCH_DEBUG_VALUE_MAX_BYTES), and a run keeps 1000 records (STOPWATCH_DEBUG_MAX_RECORDS). Metadata is written to disk as you pass it, so keep secrets out of it.
Read the run
Run the test, the command or the request, then:
php artisan stopwatch:runs:show latest# Stopwatch run (shutdown)
- **Run:** 01K6Q2M8D4A9XJ3W7N5T1B6F0R
- **Started:** 2026-10-05T14:11:56.061+00:00
- **Checkpoints:** 3 of 3
- **End:** shutdown after 3.1ms
| # | Checkpoint | Probe | Δ | Cumulative | Queries | HTTP | Location | Metadata |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | cart loaded | yes | 0ms | 0ms | | | tests/Feature/CheckoutTest.php:41 | {"items":3,"customer_orders":4} |
| 2 | repeat-customer branch | yes | 2.4ms | 2.4ms | | | app/Pricing/DiscountCalculator.php:58 | {"rate":0.1,"eligible":false} |
| 3 | total | yes | 0.3ms | 2.7ms | | | app/Pricing/DiscountCalculator.php:73 | {"total":120} |Rows are in execution order. A probe that is missing means its code did not run. --format=json prints the raw records with the values unescaped. Query and HTTP counts per probe need STOPWATCH_TRACK_QUERIES / STOPWATCH_TRACK_HTTP.
The state in the heading, and in the State column of stopwatch:runs:list:
| State | Meaning |
|---|---|
finished | finish() ran; the markdown run file exists too |
shutdown | the process ended without finish(), as in a test, or through exit() / dd() / an uncaught error |
restarted | start() was called while the run was active |
unfinished | no end record: memory or time limit, a killed process, or still running |
If nothing was recorded, stopwatch:runs:show latest says why, also when the blocked run happened in another process, for example a test suite that runs as APP_ENV=staging.
Check the fix
Keep the probes in place, reproduce again, and compare the two runs:
php artisan stopwatch:runs:diff 01K6Q2M8D4A9XJ3W7N5T1B6F0R latestPer probe it shows the time change, the query and HTTP counts, the metadata values that changed (eligible: false -> true), and the probes that ran in one run only. Both runs need debug mode, because the diff reads the stream.
Remove the probes
grep -rn --include='*.php' --exclude-dir=vendor -F 'stopwatch()->probe(' .To make a forgotten probe fail CI, include the shipped PHPStan rule. It reports every probe() call on a Stopwatch, including a nullable one, with the identifier stopwatch.probe:
includes:
- vendor/sandermuller/stopwatch/resources/phpstan/no-probes.neonThen turn STOPWATCH_DEBUG off and run php artisan stopwatch:runs:clear --force.
Notes
- The location is the first frame outside the package. A helper of your own that wraps
stopwatch()shows up as that helper; a checkpoint in a Blade view points at the compiled view. php artisan testruns PHPUnit in a child process. Its own empty command run is dropped, solatestis the test's run.- Inside PHPUnit, Laravel does not fire command events, so a command run through
$this->artisan()is not finished on its own. Its probes still land in the stream and end asshutdown. - An agent with the
stopwatch-debugskill runs this loop on its own, and the same reads are available as MCP tools.