sandermuller / stopwatch
Lightweight profiler for PHP and Laravel. Add checkpoints, measure closures, track queries and memory, and surface results as HTML, Server-Timing headers, log entries, or Debugbar timelines.
Requires
- php: ^8.3
- illuminate/collections: ^11.0|^12.0|^13.0
- illuminate/support: ^11.0|^12.0|^13.0
- nesbot/carbon: ^2|^3.10.3
- spatie/laravel-package-tools: ^1.92.7
- symfony/var-dumper: ^7.3.3|^8.0
Requires (Dev)
- fakerphp/faker: ^1.24
- fruitcake/laravel-debugbar: ^4.2
- illuminate/database: ^11.0|^12.0|^13.0
- laravel/pint: ^1.24
- nunomaduro/collision: ^8.8.2
- orchestra/testbench: ^9.6|^10.6|^11.0
- phpstan/extension-installer: ^1.4.3
- phpstan/phpstan: ^2.1.25
- phpstan/phpstan-deprecation-rules: ^2.0.3
- phpstan/phpstan-strict-rules: ^2.0.6
- rector/rector: ^2.1.7
- rector/type-perfect: ^2.1
- sandermuller/package-boost: ^0.11.0
- spaze/phpstan-disallowed-calls: ^4.6
- stolt/lean-package-validator: ^5.7.1
- symplify/phpstan-extensions: ^12.0.1
- tomasvotruba/cognitive-complexity: ^1.0
- tomasvotruba/type-coverage: ^2.0.2
Suggests
- fruitcake/laravel-debugbar: Display stopwatch checkpoints as a timeline in Debugbar
- dev-main
- v0.10.0
- v0.9.1
- v0.9.0
- v0.8.0
- v0.7.0
- v0.6.1
- v0.6.0
- v0.5.2
- 0.5.1
- v0.5.0
- v0.4.2
- v0.4.1
- v0.4.0
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.3.0-rc1
- v0.2.0
- v0.1.8
- v0.1.7
- v0.1.6
- v0.1.5
- v0.1.4
- v0.1.3
- v0.1.2
- v0.1.1
- v0.1.0
- v0.0.4
- v0.0.3
- v0.0.2
- v0.0.1
- dev-dependabot/composer/sandermuller/package-boost-tw-0.11.0or-tw-0.15.0
This package is auto-updated.
Last update: 2026-05-13 15:09:49 UTC
README
A lightweight profiler for PHP and Laravel. Add checkpoints to your code, measure closures, track queries and memory, and see where time is spent.
Reach for it when a request, command, or job feels slow and you want to know where time is going — without standing up a full APM. Output as an HTML report, an injected request-profiler toolbar, Server-Timing headers, persistent run-log markdown, log entries, or Debugbar timelines. Works in tests, CI, and production.
Compatibility: PHP 8.3+ · Laravel 11.x / 12.x / 13.x
Table of contents
- Installation
- Quick start
- Configuration
- Usage
- Output channels
- Reference
- AI assistant integration
- Standalone (without Laravel)
- Testing
- Changelog
- Contributing
- Security vulnerabilities
- Credits
- License
Installation
You can install the package via composer:
composer require sandermuller/stopwatch
Optionally publish the config file:
php artisan vendor:publish --tag=stopwatch-config
Quick start
Drop a few checkpoints around suspect code and dump the profile:
stopwatch()->withQueryTracking()->start(); $users = User::all(); stopwatch()->checkpoint('Load users'); $orders = Order::where('status', 'pending')->get(); stopwatch()->checkpoint('Load orders'); stopwatch()->toLog('Profile:'); // Profile: // [3ms / 3ms] Load users (queries=1) // [12ms / 15ms] Load orders (queries=1) // Total: 15ms
That's the whole loop: start, checkpoint, render. Read on for HTML / toolbar / Server-Timing / persistent run-log outputs, query / memory / HTTP tracking, and conditional notifications.
Configuration
Core settings — every feature has its own section below with the env vars it owns:
| Setting | Env variable | Default | Description |
|---|---|---|---|
enabled |
STOPWATCH_ENABLED |
true |
Disable to make all calls no-ops with near-zero overhead |
output |
STOPWATCH_OUTPUT |
silent |
Default output mode (silent, log, stderr, dump) |
log_level |
STOPWATCH_LOG_LEVEL |
debug |
Log level when output is log |
slow_threshold |
STOPWATCH_SLOW_THRESHOLD |
50 |
Highlight checkpoints slower than this (ms) |
Per-feature env vars: tracking (STOPWATCH_TRACK_*), notifications (STOPWATCH_NOTIFY_THRESHOLD, STOPWATCH_MAIL_*), profiler toolbar (STOPWATCH_INJECT*), run log (STOPWATCH_LOG_*). The full annotated config lives in config/stopwatch.php.
Usage
Checkpoints
stopwatch()->checkpoint('First checkpoint'); stopwatch()->checkpoint('Second checkpoint'); stopwatch()->lap('Third checkpoint'); // alias for checkpoint()
Calling checkpoint() auto-starts the stopwatch if it hasn't been started yet. You can also start it explicitly with stopwatch()->start(). Note that start() resets any existing checkpoints, use it to begin a fresh measurement.
You can attach metadata to any checkpoint:
stopwatch()->checkpoint('Query executed', ['table' => 'users', 'rows' => 42]);
Default output mode
Configure where each checkpoint is emitted using outputTo():
use SanderMuller\Stopwatch\StopwatchOutput; stopwatch()->outputTo(StopwatchOutput::Log)->start(); stopwatch()->checkpoint('First checkpoint'); // Automatically logged stopwatch()->checkpoint('Second checkpoint'); // Automatically logged
Available output modes:
| Mode | Description |
|---|---|
StopwatchOutput::Silent |
Collect only, render later (default) |
StopwatchOutput::Log |
Send to Laravel log |
StopwatchOutput::Stderr |
Write to stderr |
StopwatchOutput::Dump |
Use Laravel's dump() |
You can override the output for a single checkpoint:
stopwatch()->checkpoint('Debug this', output: StopwatchOutput::Dump);
Or use the log() shortcut to send a single checkpoint to the log:
stopwatch()->log('Query executed'); stopwatch()->log('Query executed', level: 'warning');
Measure a closure
Wrap a closure to automatically create a checkpoint after execution. Auto-starts the stopwatch if needed.
$result = stopwatch()->measure('Heavy computation', function () { return doExpensiveWork(); });
Query tracking
Automatically track the number of database queries and their total duration between each checkpoint. Requires illuminate/database.
stopwatch()->withQueryTracking()->start(); User::all(); stopwatch()->checkpoint('Load users'); // Checkpoint includes: 1q / 2.3ms Order::where('status', 'pending')->get(); stopwatch()->checkpoint('Load orders'); // Checkpoint includes: 1q / 1.5ms
Can also be enabled via config (STOPWATCH_TRACK_QUERIES=true). Up to 50 SQL statements + bindings + per-query duration are stored per checkpoint and shown when you click a row to expand its detail modal — handy when you need to inspect which query was slow, not just the count.
Memory tracking
Track memory usage changes between each checkpoint:
stopwatch()->withMemoryTracking()->start(); $data = loadLargeDataset(); stopwatch()->checkpoint('Load data'); // Checkpoint includes: +2.4MB
In the HTML output, memory is shown as a compact delta badge with full details on hover (current usage, delta, peak). In plain-text output (toStderr, toLog), the delta is included inline. Can also be enabled via config (STOPWATCH_TRACK_MEMORY=true).
HTTP tracking
Track outbound HTTP requests sent through Laravel's Http:: facade between each checkpoint. Per-checkpoint count + total time appear as a chip; the hover tooltip shows the first three calls (method · URL · status · duration) with an +N more line if there were more, plus a footer total across the whole profile.
stopwatch()->withHttpTracking()->start(); Http::get('https://api.example.com/users'); Http::post('https://api.example.com/orders', $payload); stopwatch()->checkpoint('Sync order'); // Checkpoint includes: 2h / 156ms
Status codes are color-coded in the tooltip (green 2xx, amber 4xx, red 5xx + connection failures). Up to 50 call detail rows are stored per checkpoint to bound memory; the count + total time still reflect every call beyond that. Can also be enabled via config (STOPWATCH_TRACK_HTTP=true).
Limitation: only requests through Laravel's Http:: facade are captured. Direct new GuzzleHttp\Client instances bypass Laravel's event dispatcher and won't be tracked — same limitation as Laravel Telescope. If you need direct-Guzzle tracking, wrap calls in stopwatch()->measure() manually.
All tracking methods can be combined:
stopwatch()->withQueryTracking()->withMemoryTracking()->withHttpTracking()->start();
Use when() / unless() to toggle parts of the chain conditionally without breaking the fluent flow:
stopwatch() ->withMemoryTracking() ->when($trackQueries, fn ($sw) => $sw->withQueryTracking()) ->unless(app()->runningUnitTests(), fn ($sw) => $sw->withHttpTracking()) ->start();
Write a full report
Write all checkpoints and the total duration to stderr or your log:
stopwatch()->checkpoint('Validation'); stopwatch()->checkpoint('DB inserts'); // Write to stderr stopwatch()->toStderr('Profile:'); // Or write to the log stopwatch()->toLog('Profile:', level: 'info');
Conditional notifications
Get notified when a request or operation exceeds a time threshold. Notifications are dispatched when the stopwatch finishes:
stopwatch()->notifyIfSlowerThan(500); stopwatch()->checkpoint('Fetch order'); stopwatch()->checkpoint('Generate PDF'); stopwatch()->checkpoint('Upload to S3'); stopwatch()->finish(); // notifications dispatch here if total >= 500ms
The threshold is also checked on implicit finishes (render(), toArray(), toLog(), toStderr()), and also accepts CarbonInterval:
stopwatch()->notifyIfSlowerThan(CarbonInterval::seconds(2));
The threshold and channels can be configured entirely via config/env:
STOPWATCH_NOTIFY_THRESHOLD=500
This pairs well with the middleware. Every request that exceeds the threshold will trigger a notification automatically.
Or set it programmatically in a service provider:
// AppServiceProvider::boot() stopwatch()->notifyIfSlowerThan(500);
Configure which channels are used in config/stopwatch.php:
'notification_channels' => [ \SanderMuller\Stopwatch\Notifications\LogChannel::class, ],
Email notifications
Add MailChannel to receive an email with the stopwatch's HTML report when a threshold is exceeded:
'notification_channels' => [ \SanderMuller\Stopwatch\Notifications\LogChannel::class, \SanderMuller\Stopwatch\Notifications\MailChannel::class, ],
Configure the recipient in your .env:
STOPWATCH_MAIL_TO=dev-team@example.com STOPWATCH_MAIL_SUBJECT="Slow request detected" # optional
Or bind the channel with constructor arguments:
$this->app->bind(MailChannel::class, fn () => new MailChannel( to: 'dev-team@example.com', subject: 'Slow request', ));
Custom notification channels
Create your own channel by implementing StopwatchNotificationChannel:
use SanderMuller\Stopwatch\Notifications\StopwatchNotificationChannel; use SanderMuller\Stopwatch\Stopwatch; class SlackChannel implements StopwatchNotificationChannel { public function notify(Stopwatch $stopwatch): void { Slack::message("Slow request: {$stopwatch->totalRunDurationReadable()}"); } }
Register it in your config:
'notification_channels' => [ \SanderMuller\Stopwatch\Notifications\LogChannel::class, \App\Stopwatch\SlackChannel::class, ],
Or set channels at runtime:
stopwatch()->notifyUsing([new SlackChannel()]);
Output channels
Pick the surface that matches how you want to read the profile — they share one underlying recorder and you can use several at once.
HTML report
Render an HTML report with the total execution time, each checkpoint, and the time between them. Slow checkpoints are highlighted.
stopwatch()->checkpoint('First checkpoint'); stopwatch()->checkpoint('Second checkpoint'); // Render the output {{ stopwatch()->render() }}
Or use the Blade directive:
@stopwatch
The card is self-contained — all styles are inline so it drops into any host page (or email body) without picking up surrounding CSS. It includes:
- Smart duration formatting that scales the unit so long profiles read clearly:
3.4ms,143ms,1.25s,1m 5s. Available as a public helper too:Stopwatch::formatDuration(1247). - Slow severity tiers. Checkpoints over the slow threshold get a tiered red signal — light (1×–2×), medium (2×–5×), heavy (5×+) — so you can tell a barely-slow row from a way-too-slow one at a glance.
- Overview bar at the top with one colored segment per checkpoint, sized by share of total. Hovering a row cross-highlights its segment, and vice versa.
- Hover tooltip per row with the full label, timestamp, delta vs cumulative, share, query and memory metrics.
- Click any row to expand into a centered modal showing the full label, all metadata, memory current/delta/peak, every captured query (with SQL + bindings + per-query duration), and every captured HTTP call (method/URL/status/duration). Backdrop click, ESC, or × button closes; only one row open at a time.
- Footer totals showing the cumulative query count, query time, HTTP count, HTTP time, and memory delta when the corresponding tracking is enabled.
- Copy as Markdown button (clipboard icon, header) that copies a Markdown summary table to the clipboard — paste it into a chat with an AI assistant or a bug report. Available programmatically too:
stopwatch()->toMarkdown(). - Empty state when no checkpoints have been recorded.
Light + dark mode
The card respects prefers-color-scheme automatically, and includes a built-in toggle button (sun/moon, in the header) that lets users override the theme. The choice persists in localStorage under the sw-theme key. Pages that disallow JavaScript fall back to the system preference and the toggle is hidden.
Custom CSS overrides
The card root is .sw-stopwatch. All themable surfaces are exposed as CSS variables (e.g. --sw-bg, --sw-text, --sw-border, --sw-hover-bg, --sw-tip-bg). To re-skin without forking the renderer, override these on .sw-stopwatch (or its [data-theme="dark"] variant) in your application stylesheet.
A @media print rule strips shadows, drops the toggle button and tooltips, expands the card to full width, and disables the bar grow-in animation, so PDF exports of an HTML profile look clean.
Server-Timing header
Add a Server-Timing HTTP header to your responses so you can inspect checkpoint timings in the browser's DevTools Network tab.
Register the middleware to automatically add the header whenever the stopwatch has been started:
// bootstrap/app.php use SanderMuller\Stopwatch\StopwatchMiddleware; return Application::configure(basePath: dirname(__DIR__)) ->withMiddleware(function (Middleware $middleware) { $middleware->append(StopwatchMiddleware::class); }) // ...
By default the middleware is passive, it only adds the Server-Timing header if the stopwatch was started somewhere in your code (e.g. via stopwatch()->start() or stopwatch()->checkpoint()). Requests where the stopwatch is never started will not have the header.
To auto-start the stopwatch on every request, use StopwatchMiddleware::autoStart():
$middleware->append(StopwatchMiddleware::autoStart());
Or add the header manually without the middleware:
return response('OK') ->header('Server-Timing', stopwatch()->toServerTiming());
Profiler toolbar
Inject a Debugbar-style toolbar into eligible HTML responses with per-request totals (duration, memory delta, query / HTTP counts) and a JS-free expanded panel of per-checkpoint deltas. Three opt-in tiers share one injector.
STOPWATCH_INJECT=all # off | all | route | attribute STOPWATCH_INJECT_ENVIRONMENTS=local # CSV — default-deny by environment name STOPWATCH_INJECT_POSITION=bottom-right # bottom-right | bottom-left | top-right | top-left STOPWATCH_INJECT_SLOW_REQUEST_MS=500 # duration pill turns red at/above this many ms
Required middleware order
The injector reads aggregates after $next returns, so it must wrap autostart (which finishes the stopwatch in its own post-$next block):
use SanderMuller\Stopwatch\StopwatchInjectMiddleware; use SanderMuller\Stopwatch\StopwatchMiddleware; $middleware->append(StopwatchInjectMiddleware::class); // outer — runs after() $middleware->append(StopwatchMiddleware::autoStart()); // inner — finishes the stopwatch
If the order is reversed, injection silently no-ops (the middleware logs a one-shot debug message in non-production environments to flag the misconfiguration).
Modes
all— inject on every eligible HTML response.route— only when the route's middleware list contains thestopwatch.injectalias. Add the alias to opted-in routes:Route::middleware('stopwatch.inject')->get('/dashboard', /* ... */);
attribute— only when the resolved controller class or method carries#[ProfileViaStopwatch]:use SanderMuller\Stopwatch\ProfileViaStopwatch; #[ProfileViaStopwatch] final class OrdersController { /* ... */ }
Closure routes have no class — add thestopwatch.injectalias to opt those in.
Security: default-deny by environment
STOPWATCH_INJECT_ENVIRONMENTS defaults to local only. The expanded panel exposes raw SQL with bound values via the existing query renderer; staging / dev / preview environments are commonly reachable from the internet, so not-production allow-rules would leak query bindings. Opt environments in explicitly:
STOPWATCH_INJECT_ENVIRONMENTS=local,docker
Treat any environment with this enabled as "trusted viewer only".
Eligibility guards (auto-skipped)
Non-2xx responses; non-text/html Content-Type (or charset present and not UTF-8); Content-Encoding set and not identity; StreamedResponse / BinaryFileResponse; ajax / wantsJson / pjax / HX-Request / X-Livewire / X-Inertia headers; stopwatch never started or never finished. XHTML (application/xhtml+xml) is not supported in v1.
Octane / Swoole
Hard-disabled at runtime. The Stopwatch singleton is per-process; under Octane the toolbar would render data from a previous request.
CSP
The toolbar emits a scoped inline <style> block (no inline <script>, no external assets, no localStorage). Strict-CSP setups need style-src 'unsafe-inline' for the toolbar to render.
Laravel Debugbar
If you have fruitcake/laravel-debugbar installed, checkpoint timings automatically appear as a timeline tab in Debugbar with a duration badge — no extra wiring required.
Run log (persistent profile history)
Every finished stopwatch run is written to storage/stopwatch/runs/<ULID>.md so you (or an AI assistant) can come back to slow runs later, without re-reproducing them. Crashed requests are captured too, with an ## Exception section and a stack trace.
Enabling the run log
One env var. Off by default.
STOPWATCH_LOG_RUNS=true
Pair with StopwatchMiddleware for HTTP runs, or call stopwatch()->finish() yourself from a command or job. Runs faster than STOPWATCH_LOG_MIN_DURATION_MS (default 50ms) are skipped.
Each file's body starts with the same markdown stopwatch()->toMarkdown() already produces, then appends extra sections when relevant: ## SQL detail and ## HTTP detail in full mode, ## Exception when something threw, and ## Context when the Context collector is enabled. YAML frontmatter on top keeps listing cheap.
Inspect runs
Three artisan commands. The full markdown of show is what an AI assistant or a human reads to debug.
php artisan stopwatch:runs:list --slow --limit=10 php artisan stopwatch:runs:show <id> php artisan stopwatch:runs:clear # cleanup when done
Filter the list:
php artisan stopwatch:runs:list --threw # only crashed runs 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=json # for scripts / jq
Want a predictable cron job instead of the 5%-probabilistic in-process prune?
0 3 * * * php artisan stopwatch:runs:clear --days=7 --force 0 3 * * * php artisan stopwatch:runs:clear --keep=200 --force
Let your AI read the logs
If you have laravel/boost installed and the bundled stopwatch-profile skill synced to your editor, you can skip the artisan commands and just ask. Something like "the /admin/users page feels slow, can you figure out why?" is enough. The skill will:
- Verify
STOPWATCH_LOG_RUNS=trueand turn it on if not. - Ask you to reproduce the slow request.
- Run
stopwatch:runs:list --slowand pick the worst offenders. - Run
stopwatch:runs:show <id>on each, read the per-checkpoint table, and point at the segment that owns most of the share.
Same loop a human would run, just automated. Works with any agent that supports Laravel Boost (Claude Code, Cursor, Copilot, etc.).
Workflow: debug a slow request
If you'd rather drive it yourself, here's the loop:
- Set
STOPWATCH_LOG_RUNS=truein.env. For HTTP requests, registerStopwatchMiddleware::autoStart()so each run is started and finished automatically. For commands and jobs, callstopwatch()->start()at the top of your handler andstopwatch()->finish()before it returns. Addstopwatch()->checkpoint(...)calls along the suspect path so you can see where time is going, not just that it's slow. - Reproduce the slow path. Visit the page, run the command, replay the request — whatever it takes.
- List the slowest recent runs:
php artisan stopwatch:runs:list --slow --limit=10
- Pick the worst offender's id from the table and inspect it:
php artisan stopwatch:runs:show 01HZ8K9X4N5P2Q3R4S5T6U7V8W
- Read the per-checkpoint table. Find the row that owns most of the Share column. Common shapes:
- High
qcount on one row: N+1 candidate. Flip toSTOPWATCH_LOG_DETAIL=fulland reproduce again to see the actual SQL. - High
hcount: outbound API loop. Same flag adds method/URL/status per call. queries_total>> sum of per-checkpoint queries: significant work happens after the last checkpoint. Add a checkpoint near the response return and re-profile.
- High
- Split the hot row by dropping more
stopwatch()->checkpoint(...)calls inside that section of code. Fix what you find. Go back to step 2.
Crash diagnostics
When a request throws, the middleware catches it, persists a run-log file with threw: true, then re-throws. Frontmatter gets the exception class / file / line; the body gets a ## Exception section with a top-N stack trace and (one level of) ### Previous for wrapped exceptions.
--- id: 01HZ8K9X4N5P2Q3R4S5T6U7V8W url: /admin/users threw: true exception_class: Illuminate\Validation\ValidationException exception_file: app/Http/Controllers/OrderController.php exception_line: 142 ctx_trace_id: 01HZULID0000000000000000A ---
Note
Trace args are never persisted. Only file, line, class, function, type from each frame. The exception message itself is also off by default (set STOPWATCH_LOG_EXCEPTIONS_MESSAGE=true to opt in; many app messages quote validation or user input). When enabled, messages are capped via mb_substr and can be redacted via options.exceptions.mask_message_matching.
For queued jobs / commands that catch their own exceptions, capture them yourself before finish():
use SanderMuller\Stopwatch\Stopwatch; use Throwable; try { // ... } catch (Throwable $e) { stopwatch() ->withTransientContext(Stopwatch::TRANSIENT_EXCEPTION, $e) ->finish(); throw $e; }
Correlate with laravel.log
Set STOPWATCH_LOG_COLLECT_CONTEXT=true to capture Illuminate\Support\Facades\Context::all() (Laravel 11+) into a ## Context body section. Hidden context (Context::addHidden()) is never read.
If your app already does:
Context::add('trace_id', (string) Str::ulid()); Context::add('tenant_id', $tenant->slug);
…promote those keys via config/stopwatch.php so they land in frontmatter and stopwatch:runs:list --ctx key=value can filter on them:
// config/stopwatch.php → run_log.options.context 'options' => [ 'context' => [ 'frontmatter_keys' => ['trace_id', 'tenant_id'], ], ],
Promoted scalar values land in frontmatter as ctx_trace_id / ctx_tenant_id, round-trip-safe (string "01" stays "01", not int 1). Then pivot from run log to log line:
# Slowest crashed runs of one exception type for one tenant; pull their trace ids TRACE_IDS=$(php artisan stopwatch:runs:list --threw --exception-class=ValidationException \ --ctx tenant_id=acme --format=json | jq -r '.[].frontmatter.ctx_trace_id') # Then grep laravel.log for any of them (Laravel auto-includes Context in structured logs) for id in $TRACE_IDS; do grep "$id" storage/logs/laravel.log; done
Configuration
Env knobs (config/stopwatch.php under run_log for the array-typed ones):
| Var | Default | Purpose |
|---|---|---|
STOPWATCH_LOG_RUNS |
false |
Master toggle |
STOPWATCH_LOG_DIR |
storage/stopwatch/runs |
Override the storage path |
STOPWATCH_LOG_MIN_DURATION_MS |
50 |
Skip runs faster than this; 0 to log everything |
STOPWATCH_LOG_MAX_FILES |
200 |
Cap on retained files (oldest pruned automatically) |
STOPWATCH_LOG_MAX_AGE_DAYS |
7 |
Soft age cap (probabilistic prune) |
STOPWATCH_LOG_DETAIL |
summary |
full appends per-call SQL/HTTP detail tables |
STOPWATCH_LOG_INCLUDE_BINDINGS |
false |
Persist SQL bindings in full mode (PII opt-in) |
STOPWATCH_LOG_SKIP_EMPTY |
true |
Skip runs that finished with zero checkpoints |
STOPWATCH_LOG_COLLECT_EXCEPTIONS |
true |
Capture Throwable class/file/line + trace |
STOPWATCH_LOG_EXCEPTIONS_MESSAGE |
false |
Persist $e->getMessage() (PII opt-in) |
STOPWATCH_LOG_EXCEPTIONS_MESSAGE_MAX_CHARS |
500 |
Codepoint cap before … is appended |
STOPWATCH_LOG_EXCEPTIONS_TRACE_FRAMES |
10 |
Trace frame cap (0 omits the trace section) |
STOPWATCH_LOG_COLLECT_CONTEXT |
false |
Capture Context::all() (visible only) |
STOPWATCH_LOG_CONTEXT_VALUE_MAX_BYTES |
4096 |
Per-value byte cap for context body cells |
Array-typed options (config-only; env can't express arrays cleanly):
| Config path | Purpose |
|---|---|
options.exceptions.mask_message_matching |
Patterns. Leading / = preg, otherwise substring; matches replaced with ***. Applied AFTER cap. |
options.exceptions.trace_exclude_paths |
Substring matches against frame.file. Use to hide vendor noise. |
options.context.allow |
Allowlist. Empty = all visible scalar keys; rich objects need explicit allowlisting. |
options.context.deny |
Denylist applied after allow. |
options.context.mask |
Replace value with *** while preserving the key. |
options.context.frontmatter_keys |
Promote scalar values to frontmatter as ctx_<key> (sortable from list view). |
Limitations
Note
The run log is Laravel-only and not supported under Laravel Octane or Swoole. The Stopwatch singleton keeps per-run state in memory, which is not safe for concurrent coroutines. Making the lifecycle per-request is a separate refactor. Until that lands, keep STOPWATCH_LOG_RUNS=false under Octane.
Note
Stopwatch::dd($exception) does not capture the exception in this version. dd() calls finish() before it inspects its dump arguments, so the recorder runs first and the throwable never reaches it. Workaround: $stopwatch->withTransientContext(Stopwatch::TRANSIENT_EXCEPTION, $e)->dd().
Run-log writes never throw. Disk failures are logged via logger()->warning() and the request completes normally. Crashed runs do a bit of extra work (build the trace, render the ## Exception section), but the overhead is bounded by STOPWATCH_LOG_EXCEPTIONS_TRACE_FRAMES and amortised across the file write.
Reference
Manually stop the stopwatch
You can manually stop the stopwatch to freeze the timing. It will also stop automatically when output is rendered (e.g. render(), toArray(), toStderr()).
stopwatch()->checkpoint('First checkpoint'); // Stop the stopwatch stopwatch()->stop(); // Do something else you don't want to measure // Finally render the output {{ stopwatch()->render() }}
You can get the total duration as a string with stopwatch()->toString() (e.g. "116ms").
Enable / disable at runtime
Enable or disable the stopwatch at runtime. When disabled, all calls become no-ops:
stopwatch()->disable(); stopwatch()->checkpoint('Skipped'); // no-op stopwatch()->enable();
Serialization
Convert the stopwatch data to an array or JSON:
$data = stopwatch()->toArray(); $json = stopwatch()->toJson();
Debugging
stopwatch()->dump(); // dump the stopwatch instance stopwatch()->dd(); // dump and die
AI assistant integration
This package ships an AI skill that teaches an assistant how and when to reach for stopwatch() to investigate a slow request, command, or code path: checkpoint placement, when to enable query / memory / HTTP tracking, how to read the rendered card, how to drive the run-log commands, and how to wire production tripwires.
If you use laravel/boost, the skill is auto-discovered from vendor/sandermuller/stopwatch/resources/boost/skills/ — just run php artisan boost:install. Works with any agent that supports Laravel Boost (Claude Code, Cursor, Copilot, etc.).
Standalone (without Laravel)
You can use the stopwatch without the Laravel helper by creating instances directly:
$stopwatch = \SanderMuller\Stopwatch\Stopwatch::new(); $stopwatch->start(); $stopwatch->checkpoint('Done'); echo $stopwatch->toString();
The stopwatch() helper is not available outside Laravel. Query tracking requires illuminate/database and a Laravel application. Config-based setup and notification channel resolution from class strings also require the Laravel container.
Testing
composer test
Changelog
Please see CHANGELOG for a list of recent changes.
Contributing
Please see CONTRIBUTING for details.
Security vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
The MIT License (MIT). Please see the License File for more information.
