PHPackages                             sandermuller/stopwatch - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Debugging &amp; Profiling](/categories/debugging)
4. /
5. sandermuller/stopwatch

ActiveLibrary[Debugging &amp; Profiling](/categories/debugging)

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.

v0.10.0(1mo ago)944.1k↓60.1%[1 PRs](https://github.com/SanderMuller/Stopwatch/pulls)MITPHPPHP ^8.3CI passing

Since Jun 20Pushed 3w ago1 watchersCompare

[ Source](https://github.com/SanderMuller/Stopwatch)[ Packagist](https://packagist.org/packages/sandermuller/stopwatch)[ Docs](https://github.com/SanderMuller/Stopwatch)[ RSS](/packages/sandermuller-stopwatch/feed)WikiDiscussions main Synced 3d ago

READMEChangelog (10)Dependencies (64)Versions (40)Used By (0)

Stopwatch for PHP &amp; Laravel
===============================

[](#stopwatch-for-php--laravel)

[![Latest Version on Packagist](https://camo.githubusercontent.com/de3fac4ee133797471c8d7f403a6feb274988978cc2cfd1273e12773a5a2ebfe/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f73616e6465726d756c6c65722f73746f7077617463682e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/sandermuller/stopwatch)[![GitHub Tests Action Status](https://camo.githubusercontent.com/b4537ee8fd88d56a094230f58418f761518d56a60f44afa299c9856b89b93efa/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73616e6465726d756c6c65722f73746f7077617463682f72756e2d74657374732e796d6c3f6272616e63683d6d61696e266c6162656c3d7465737473267374796c653d666c61742d737175617265)](https://github.com/sandermuller/stopwatch/actions/workflows/run-tests.yml)[![GitHub PHPStan Action Status](https://camo.githubusercontent.com/a4325b8d60a6de72013a0743d0c2a4bcc6edd4d3434b5deffb7c483f4501a1a9/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f616374696f6e732f776f726b666c6f772f7374617475732f73616e6465726d756c6c65722f73746f7077617463682f7068707374616e2e796d6c3f6272616e63683d6d61696e266c6162656c3d7068707374616e267374796c653d666c61742d737175617265)](https://github.com/sandermuller/stopwatch/actions?query=workflow%3Aphpstan+branch%3Amain)[![Total Downloads](https://camo.githubusercontent.com/0da052e9512bc5d6a4084f767a3dc8ae1cb3d82fc3dd1b040bd8d22d4a1ce9e1/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f64742f73616e6465726d756c6c65722f73746f7077617463682e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/sandermuller/stopwatch)[![License](https://camo.githubusercontent.com/84c2028998d63632b6d8ec92900ce69b684d6e77b8a0ebd42d56ca52dd931db0/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f73616e6465726d756c6c65722f73746f7077617463682e7376673f7374796c653d666c61742d737175617265)](LICENSE)[![Laravel Compatibility](https://camo.githubusercontent.com/8f22d1161dbc8d9a955b4399d36c3da4c17eb58ce6f721cc75d85a3a26001714/68747470733a2f2f62616467652e6c61726176656c2e636c6f75642f62616467652f73616e6465726d756c6c65722f73746f7077617463683f7374796c653d666c6174)](https://packagist.org/packages/sandermuller/stopwatch)

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
-----------------

[](#table-of-contents)

- [Installation](#installation)
- [Quick start](#quick-start)
- [Configuration](#configuration)
- [Usage](#usage)
    - [Checkpoints](#checkpoints)
    - [Default output mode](#default-output-mode)
    - [Measure a closure](#measure-a-closure)
    - [Query tracking](#query-tracking)
    - [Memory tracking](#memory-tracking)
    - [HTTP tracking](#http-tracking)
    - [Write a full report](#write-a-full-report)
    - [Conditional notifications](#conditional-notifications)
- [Output channels](#output-channels)
    - [HTML report](#html-report)
    - [Profiler toolbar](#profiler-toolbar)
    - [Server-Timing header](#server-timing-header)
    - [Laravel Debugbar](#laravel-debugbar)
    - [Run log (persistent profile history)](#run-log-persistent-profile-history)
- [Reference](#reference)
- [AI assistant integration](#ai-assistant-integration)
- [Standalone (without Laravel)](#standalone-without-laravel)
- [Testing](#testing)
- [Changelog](#changelog)
- [Contributing](#contributing)
- [Security vulnerabilities](#security-vulnerabilities)
- [Credits](#credits)
- [License](#license)

Installation
------------

[](#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
-----------

[](#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
-------------

[](#configuration)

Core settings — every feature has its own section below with the env vars it owns:

SettingEnv variableDefaultDescription`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](#query-tracking) (`STOPWATCH_TRACK_*`), [notifications](#conditional-notifications) (`STOPWATCH_NOTIFY_THRESHOLD`, `STOPWATCH_MAIL_*`), [profiler toolbar](#profiler-toolbar) (`STOPWATCH_INJECT*`), [run log](#run-log-persistent-profile-history) (`STOPWATCH_LOG_*`). The full annotated config lives in [`config/stopwatch.php`](config/stopwatch.php).

Usage
-----

[](#usage)

### Checkpoints

[](#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

[](#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:

ModeDescription`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

[](#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

[](#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

[](#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

[](#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-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

[](#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

[](#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

[](#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
---------------

[](#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

[](#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
```

[![rendered-stopwatch.png](rendered-stopwatch.png)](rendered-stopwatch.png)

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

[](#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

[](#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.

#### Print

[](#print)

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

[](#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

[](#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

[](#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

[](#modes)

- **`all`** — inject on every eligible HTML response.
- **`route`** — only when the route's middleware list contains the `stopwatch.inject` alias. 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 the `stopwatch.inject` alias to opt those in.

#### Security: default-deny by environment

[](#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)

[](#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

[](#octane--swoole)

Hard-disabled at runtime. The `Stopwatch` singleton is per-process; under Octane the toolbar would render data from a previous request.

#### CSP

[](#csp)

The toolbar emits a scoped inline `` block (no inline ``, no external assets, no `localStorage`). Strict-CSP setups need `style-src 'unsafe-inline'` for the toolbar to render.

### Laravel Debugbar

[](#laravel-debugbar)

If you have [`fruitcake/laravel-debugbar`](https://github.com/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)

[](#run-log-persistent-profile-history)

Every finished stopwatch run is written to `storage/stopwatch/runs/.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

[](#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

[](#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
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

[](#let-your-ai-read-the-logs)

If you have [`laravel/boost`](https://github.com/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:

1. Verify `STOPWATCH_LOG_RUNS=true` and turn it on if not.
2. Ask you to reproduce the slow request.
3. Run `stopwatch:runs:list --slow` and pick the worst offenders.
4. Run `stopwatch:runs:show ` 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

[](#workflow-debug-a-slow-request)

If you'd rather drive it yourself, here's the loop:

1. Set `STOPWATCH_LOG_RUNS=true` in `.env`. For HTTP requests, register `StopwatchMiddleware::autoStart()` so each run is started and finished automatically. For commands and jobs, call `stopwatch()->start()` at the top of your handler and `stopwatch()->finish()` before it returns. Add `stopwatch()->checkpoint(...)` calls along the suspect path so you can see where time is going, not just that it's slow.
2. Reproduce the slow path. Visit the page, run the command, replay the request — whatever it takes.
3. List the slowest recent runs: ```
    php artisan stopwatch:runs:list --slow --limit=10
    ```
4. Pick the worst offender's id from the table and inspect it: ```
    php artisan stopwatch:runs:show 01HZ8K9X4N5P2Q3R4S5T6U7V8W
    ```
5. Read the per-checkpoint table. Find the row that owns most of the **Share** column. Common shapes:
    - High `q` count on one row: N+1 candidate. Flip to `STOPWATCH_LOG_DETAIL=full` and reproduce again to see the actual SQL.
    - High `h` count: outbound API loop. Same flag adds method/URL/status per call.
    - `queries_total` &gt;&gt; sum of per-checkpoint queries: significant work happens after the last checkpoint. Add a checkpoint near the response return and re-profile.
6. 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

[](#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`

[](#correlate-with-laravellog)

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

[](#configuration-1)

Env knobs (`config/stopwatch.php` under `run_log` for the array-typed ones):

VarDefaultPurpose`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 cellsArray-typed options (config-only; env can't express arrays cleanly):

Config pathPurpose`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_` (sortable from list view).#### Limitations

[](#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
---------

[](#reference)

### Manually stop the stopwatch

[](#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--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

[](#serialization)

Convert the stopwatch data to an array or JSON:

```
$data = stopwatch()->toArray();
$json = stopwatch()->toJson();
```

### Debugging

[](#debugging)

```
stopwatch()->dump(); // dump the stopwatch instance
stopwatch()->dd();   // dump and die
```

AI assistant integration
------------------------

[](#ai-assistant-integration)

This package ships an AI [skill](https://docs.claude.com/en/docs/claude-code/skills) 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](#run-log-persistent-profile-history) commands, and how to wire production tripwires.

If you use [`laravel/boost`](https://github.com/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)
----------------------------

[](#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
-------

[](#testing)

```
composer test
```

Changelog
---------

[](#changelog)

Please see [CHANGELOG](CHANGELOG.md) for a list of recent changes.

Contributing
------------

[](#contributing)

Please see [CONTRIBUTING](CONTRIBUTING.md) for details.

Security vulnerabilities
------------------------

[](#security-vulnerabilities)

Please review [our security policy](../../security/policy) on how to report security vulnerabilities.

Credits
-------

[](#credits)

- [Sander Muller](https://github.com/SanderMuller)
- [All Contributors](../../contributors)

License
-------

[](#license)

The MIT License (MIT). Please see the [License File](LICENSE) for more information.

###  Health Score

53

—

FairBetter than 96% of packages

Maintenance92

Actively maintained with recent releases

Popularity36

Limited adoption so far

Community9

Small or concentrated contributor base

Maturity58

Maturing project, gaining track record

 Bus Factor1

Top contributor holds 97.4% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~20 days

Recently: every ~2 days

Total

34

Last Release

59d ago

PHP version history (2 changes)v0.0.1PHP ^8.3

v0.1.0PHP ^8.2

### Community

Maintainers

![](https://www.gravatar.com/avatar/abeb7bd51fd77656b4133bdcdf4eca99cc8b495b83ca72f63674ff15f2b72a39?d=identicon)[SanderMuller](/maintainers/SanderMuller)

---

Top Contributors

[![SanderMuller](https://avatars.githubusercontent.com/u/9074391?v=4)](https://github.com/SanderMuller "SanderMuller (112 commits)")[![dependabot[bot]](https://avatars.githubusercontent.com/in/29110?v=4)](https://github.com/dependabot[bot] "dependabot[bot] (3 commits)")

---

Tags

phplaravelprofilerdebugbarperformanceprofilingbenchmarkingstopwatchServer-Timingquery-trackingmemory trackingcheckpoints

###  Code Quality

Static AnalysisPHPStan, Rector

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/sandermuller-stopwatch/health.svg)

```
[![Health](https://phpackages.com/badges/sandermuller-stopwatch/health.svg)](https://phpackages.com/packages/sandermuller-stopwatch)
```

###  Alternatives

[craftcms/cms

Craft CMS

3.6k3.6M3.1k](/packages/craftcms-cms)[laravel/framework

The Laravel Framework.

34.8k543.8M20.1k](/packages/laravel-framework)[barryvdh/laravel-debugbar

PHP Debugbar integration for Laravel

19.3k133.0M762](/packages/barryvdh-laravel-debugbar)[spatie/laravel-responsecache

Speed up a Laravel application by caching the entire response

2.8k9.0M69](/packages/spatie-laravel-responsecache)[fruitcake/laravel-debugbar

PHP Debugbar integration for Laravel

19.3k2.3M65](/packages/fruitcake-laravel-debugbar)[laravel/horizon

Dashboard and code-driven configuration for Laravel queues.

4.2k95.4M307](/packages/laravel-horizon)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
