sandermuller/stopwatch
A simple Laravel/PHP stopwatch package for measuring elapsed time, starting and stopping timers, and tracking laps or durations in your application.
Stopwatch is a run/checkpoint profiler for Laravel: you open a run, drop named checkpoints along a code path, and close it. Each run is persisted (optionally) as a run log and surfaced through Artisan, Debugbar, and an MCP endpoint so both you and your AI tooling can inspect where time went.
Minimal setup:
composer require sandermuller/stopwatch --dev
php artisan vendor:publish --tag=stopwatch-config # optional but recommended
php artisan stopwatch:runs:list
Wrap your first suspect code path:
use SanderMuller\Stopwatch\Facades\Stopwatch;
$run = Stopwatch::start('checkout');
$run->checkpoint('cart.loaded');
$cart = $this->loadCart($user);
$run->checkpoint('shipping.quoted');
$quote = $this->quoteShipping($cart);
$run->stop();
Then run the command again — you should see a row with the run name, duration, and checkpoint count. That feedback loop (instrument → hit the endpoint → read stopwatch:runs:list) is the whole first use case; there is no dashboard to learn and no migration to run unless you enable the run log.
Where to look first:
config/stopwatch.php — especially the run_log block and min_duration_ms.php artisan stopwatch:runs:list --help — the filters (--threw, name, duration) are the real interface.started() / timeSinceLastCheckpoint() methods on the run object — these are what you'll assert against in tests.Ambient instrumentation over manual plumbing. The package can inject itself via middleware (STOPWATCH_INJECT), so request-scoped timing exists without touching controllers. Use this as the baseline, then add explicit runs only where the automatic span is too coarse:
// config/stopwatch.php
'inject' => env('STOPWATCH_INJECT', false),
Keep explicit injection off in production and let the env var opt in locally — that is what the flag is for, and v0.16.0 finally makes it behave.
Checkpoint granularity, not run granularity. A run with one checkpoint tells you nothing. The useful unit is 3–8 checkpoints bracketing I/O: cache fetch, external HTTP call, DB write, serialization. Runs with zero checkpoints are treated as noise by the auto lifecycle (see Gotchas), so always drop at least one.
Assert on timing in tests via started() and timeSinceLastCheckpoint(). These are the stable, cheap primitives:
$run = Stopwatch::start('report');
$this->assertTrue($run->started());
$run->checkpoint('query.done');
$elapsed = $run->timeSinceLastCheckpoint();
$run->stop();
$this->assertFalse($run->started());
Debugbar for the sub-millisecond cases. The v0.16.0 fix means checkpoints under 1ms render at true width instead of collapsing to zero. If you're chasing a hot loop where checkpoints are microseconds apart, the Debugbar timeline is now the right view — stopwatch:runs:list will not distinguish them by duration alone.
Filter by failure to triage. Triage workflow: stopwatch:runs:list --threw to find runs that ended in TRANSIENT_EXCEPTION, then re-run locally with Debugbar on. Before v0.16.0 these runs were silently absent from both the CLI filter and the MCP threw filter, so any triage runbook you built on that flag was incomplete.
Queue and deferred work. Runs survive job boundaries, and deferred jobs carry their own run. If you batch instrumentation across a chain, keep one run per job rather than one per chain — the per-job run is what the deferred-job fix in this release actually preserves.
MCP for agent-assisted profiling. The MCP endpoint exposes the same filters as the CLI (threw, etc.). Point your assistant at it instead of pasting log files; the filter parity with stopwatch:runs:list means a query you validate in the terminal translates directly.
recorded_at semantics changed in v0.16.0. It is now the run's start time, and stopwatch:runs:list labels the column Started. If you were sorting by that column to find slow completions, you are now sorting by start — usually fine, occasionally misleading for long-running overlapping runs. More importantly: run files written before this release still carry end times until pruning deletes them. During the transition window, the column mixes two meanings across old and new rows. Either prune aggressively after upgrading or filter by run age when comparing.
started() returns false after an auto-dropped run. The auto lifecycle discards runs with no checkpoints, and v0.16.0 makes started() report that honestly. If your code polls started() as a liveness flag inside a long block that never checks in, it will now flip to false mid-flight. Add a checkpoint early if you depend on it.
min_duration_ms is load-bearing and easy to lose. If you publish run_log config without that key, pre-0.16.0 logged every run — a fast way to fill a table. v0.16.0 falls back to skipping runs under 50ms, but do not rely on the fallback: set it explicitly, and set it per-environment. Note the interaction with started(): a sub-50ms run still executes and still drops checkpoints, it just doesn't get logged.
STOPWATCH_INJECT is stricter now. false, null, true as literals, and typos previously registered the middleware and force-enabled tracking — meaning an innocent STOPWATCH_INJECT=flase turned profiling on in an environment where you thought it was off. That is fixed, but audit your .env files: any environment that was relying on the accidental-on behaviour will go quiet after upgrading. Also, STOPWATCH_INJECT=true is a literal string comparison now, so quote your values consistently.
Do not put timeSinceLastCheckpoint() after stop(). Pre-0.16.0 it kept counting with no checkpoint recorded; now it behaves. Code that read a growing number as a "total time" proxy was already wrong and will now read differently — check any assertion that snapshots this value in teardown.
Extension points. The run log writer and the lifecycle are the seams to customise: swap the writer to ship runs to object storage or a dedicated table, and hook the lifecycle if you want a different drop policy than "no checkpoints = noise." Keep the drop policy conservative — an over-eager lifecycle that discards short runs will silently hide exactly the regressions you bought the package to find.
How can I help you explore Laravel packages today?