kirschbaum-development/monitor
Monitor is a Laravel package from Kirschbaum Development that helps you keep tabs on your application's health and performance. It offers a lightweight, configurable way to add monitoring and catch issues before they affect your users or services. Designed for simple setup and integration into exist...
Install and check your footing (10 minutes). This is a Laravel 12/13 package and requires PHP 8.3–8.5; it will not install on Laravel 11 and there is a hard dependency on kirschbaum-development/redactor 1.x. After composer require kirschbaum-development/monitor, publish config and (only if you plan to persist outcomes) the migration — the provider deliberately does nothing at boot, so there is no service to "turn on":
php artisan vendor:publish --tag=monitor-config
php artisan vendor:publish --tag=monitor-migrations # optional, store is off by default
Two environment switches matter: MONITOR_STORE_ENABLED (persist outcomes to monitor_outcomes) and MONITOR_MCP_ENABLED (agent server). Both default off.
Where to look first, in this order:
vendor/kirschbaum-development/monitor/docs/control-points.md, then risks-and-corrections.md, then testing.md. The README is thin; the docs are the product.php artisan monitor:points — this inventories every point already in the codebase without running anything. On day one on an existing app it doubles as a "where is the risky stuff" map.php artisan about — the package registers its settings there, which is faster than reading config/monitor.php..ai/guidelines/core.blade.php and resources/boost/skills/monitor-development/SKILL.md — if you use Laravel Boost, these are already composed into your agent guidelines. If you use an agent daily, read what it read, so you two agree on the vocabulary.The first use case to ship. Do not start by wrapping everything. Pick exactly one operation that moves money or mutates a third party — a card charge, a filing, a webhook post — and make the failure story explicit:
php artisan make:control-point Payments/ChargeCard
The generator produces the class form and a test that already uses the fake, which is the main reason to prefer it over hand-rolling. The shape you get:
#[Point('payment.charge', profile: 'external')]
final class ChargeCard extends ControlPoint
{
public function __construct(private readonly Invoice $invoice) {}
protected function control(Control $control): void
{
$control
->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code))
->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled')
->escalate(PagePayments::class);
}
public function context(): array
{
return ['invoice' => $this->invoice->id];
}
public function handle(StripeClient $stripe): ChargeResult
{
return $stripe->charge($this->invoice);
}
}
Then call it from your existing controller or action: ChargeCard::run($invoice) where you want the value or the thrown exception, ChargeCard::attempt($invoice) where you want to branch on an Outcome. That is a complete, reviewable change: a declined card is a typed result, an outage escapes to whoever handles failures today, and monitor:points --check now knows the point exists.
First CI step. Add php artisan monitor:points --check to the pipeline before you have many points. It fails on duplicate names, names off the configured pattern, a point with neither escalation nor catch-all, an inline control() that cannot be read statically, and a class in a critical namespace that is not a point. Turning it on early means the gate shapes new code rather than retrofitting fifty files later.
Honest framing before you commit. Three stars, zero dependents, and a 1.0 that explicitly ships no compatibility layer with 0.1. Treat this as adopting a pattern plus a library, pin the exact version, and budget for reading docs/upgrading.md at every minor. The idea is worth adopting; the maintenance burden is yours to keep an eye on.
Two forms, one contract — pick by audience. The inline form (Monitor::control('payment.charge', $this)->run(...)) is for a single call site you want bounded today, in place, without a new class. The class form above is for anything you want in the inventory, in monitor:explain output, generatable by the stub, dispatchable to a queue, and readable by an agent. Rule of thumb: if it has a domain and a failure story, it is a class; if it is a one-line guard inside a larger flow, inline is fine — accepting that --check will flag it as not statically readable, which is the intended pressure to promote it.
Declare the failure story, not just the try/catch. The four declarations are the whole design:
$control
->recover(RateLimited::class, fn ($e) => CachedRate::fallback()) // expected, typed
->within(seconds: 5) // records a breach
->attempts(3) // hard cap
->ensure(fn ($r) => $r->reference !== null, 'reference required') // correctness
->escalate(PagePayments::class); // uncaught
recover() handlers may legitimately return null, false, or 0 — that is the point: "show a placeholder avatar" is a decision someone can review in a diff. First declared match wins, so order handlers from most specific to least. Use a class implementing Contracts\Correction instead of a closure when the handler needs dependencies or deserves a name in the inventory.
Reach for profiles instead of tuning policies per point. external, database, messaging, internal each bundle policies and limits from config; a point starts from its profile and overrides only what it says. Keeping the number of tuning knobs low is what makes the pipeline auditable. A database point gets Transaction (deadlock retries as whole transactions); an external point gets Retry with backoff/multiplier/jitter and a Breaker. Only write a custom policy when the bundled ones cannot express the rule — it is one interface, but every custom policy is one more thing a reviewer has to reason about.
Use attempt() at boundaries, run() inside them. run() is the happy path and is typed by template, so PHPStan knows what the callback returns. attempt() is for the places that must not throw — a controller that renders a degraded page, a job that decides whether to release or fail, a webhook handler that must always 200. Log the Outcome there; it carries status, attempts, duration, breached limits, policies, timeline, and the run stack.
Let nesting do the composition work. Points nest to any depth, and every record carries parent_run_id plus the stack. A child's escalation reaches the parent's corrections; a child refused by an open breaker surfaces as BreakerOpen in the parent rather than as an unrelated exception. Retries deliberately do not compose across the stack, so an inner retry cannot multiply into an outer retry — one operation, one retry budget. This is the pattern for "charge → post ledger entry → send receipt": three points, one trace, honest per-step outcomes.
Idempotency where the side effect lives. ->once('invoice:'.$id, ttl: 3600) belongs on the outermost point that owns the irreversible act, not on a helper. The key is released when the run fails inside the policies and kept when it completed, on the grounds that the side effect already happened; a second run inside the window is refused with a Duplicate risk before anything executes. Because it is outermost, a refused run never claims a key.
Wire the infrastructure once and reuse it everywhere. Http::traced() adds W3C traceparent (plus the legacy header) to outbound calls, and Http::breaker('stripe') puts the same circuit in front of the HTTP client that your control point uses — open circuit refuses without sending, connection failures and 5xx count. Queue\Middleware\WaitForBreaker releases jobs for the breaker's retry-after instead of failing them. CheckBreakers middleware plus Monitor::breaker() gives you a real Retry-After for edge routes. Making a point dispatchable (ChargeCard::dispatch($invoice)) runs it through Queue\RunControlPoint, named and tagged for Horizon, with the breaker-release behaviour for free.
Testing is the day-to-day win. Monitor::fake() records every outcome while still running the point, and is idempotent:
Monitor::fake();
ChargeCard::run($invoice);
Monitor::assertRan('payment.charge');
Monitor::assertRecovered(CardDeclined::class);
Monitor::assertNothingEscalated();
Monitor::outcomes()->first()->duration; // a Collection, mirroring the framework fake
Every positive assertion has a negative and assertRanOnce exists; assertRecovered/assertEscalated accept a class or a closure. Add the Pest expectation (toBeControlled(), toHaveCompleteControlPoints()) and the monitor.uncontrolled PHPStan rule alongside --check — three ways to say the same thing, at three different granularities.
CI and review surfaces. monitor:points emits table, JSON, and SARIF — SARIF means findings land inline on the PR, which is what makes "every money-moving path has a failure story" a reviewable claim instead of a retro argument. monitor:explain <point> renders one contract as prose, useful in design review and genuinely useful pasted into an incident channel.
Observability you get without writing any. Point names, domain (derived from the origin namespace via a configurable map), run_id, parent_run_id, trace_id, origin are fields on every record, so "escalations per domain" is a query, not a regex over log lines. Eleven events (PointStarted, PointRetried, PointLimitBreached, PointRecovered, PointEscalated, PointRefused, PointEnded, EscalationFailed, BreakerOpened/HalfOpen/Closed) are the extension point: log lines and the store are just listeners, so shipping records to a new sink is a listener, not a fork. JsonTap/RecordFormatter give you NDJSON with fields at the top level for whatever collects it.
The store, when you need history. Off by default; rows are written after the response or job, never on the request path, and a failing write is logged once and never reaches a control point. monitor:outcomes to query, monitor:prune to keep it bounded. Turn it on when you want trend data or post-hoc "how often did this escalate last week", not for real-time gating.
Agent workflow, if you use one. The MCP server is read-only, off by default, and redacted JSON-aware. list_points + explain_point before changing an operation is the highest-value use; outcomes and escalations turn an incident into a prompt. Register from config or by hand in routes/ai.php.
ensure() and within() look symmetric and are not. ensure() runs after every policy, so a wrong result is never retried — correct for "it succeeded but the answer is wrong". within() records a duration breach and never fails a completed run; it is a metric, not a timeout. If you want a breach to actually do something, add escalateLimits() (hand a completed-but-breached run to escalation) or throttleEscalation(seconds) (at most one escalation per point per window, the rest announced as EscalationThrottled). Teams routinely assume within(5) cancels the call and get burned by it.
attempts() is a ceiling, not a promise. It caps whatever the policies ask for. If you set attempts(2) and a Retry policy wants five, you get two — which is the desired safety property, but it means a retry policy silently doing nothing looks like a bug. Check the Outcome's attempts and policies before debugging the retry config.
Pipeline order is fixed: breaker → retry → transaction. This is intentional so a breaker sees one failure per run rather than burning its threshold on retries of a single user action. Do not try to reorder it; if you need different semantics, that is a different point, not a different pipeline.
A throwing escalation does not swallow anything. It is recorded as EscalationFailed and the original exception still propagates. Likewise a throwing recover() handler escalates rather than falling through to the next handler. And a Redactor failure inside the recorder or the MCP server never reaches a control point — redaction degrades rather than blocking. Know all three before you write a handler whose job is "never let this page anyone".
Nesting: what propagates and what does not. A child's escalation reaches the parent's corrections; a child's refusal surfaces as BreakerOpen; retries do not compose. So a parent attempts(3) does not multiply a child's three retries into nine — but it also means an inner point failing three times consumes one parent attempt, which surprises people building retry ladders.
once() releases on failure, keeps on success. That is the right default (the side effect happened), but it means a run that completed while breaching a limit keeps the key. If you want a completed-but-wrong run to be replayable, you cannot rely on once() alone — pair it with ensure() so the wrong result fails inside the policies and the key is released.
Jobs start clean, deliberately. A job hands off the control stack it inherited through Context and records the dispatching run as dispatched_from_run, so a point inside a job has no parent from another process — no phantom parents across process boundaries. If you were expecting parent/child to survive dispatch, that is the dispatched_from_run field, not parent_run_id.
Monitor::fake() still runs the point. It records outcomes; it does not stub your Stripe client. A test asserting a recovery path will make the real call unless you use returning() (can the return value) or failing() (can the failure). Copy the generated test from make:control-point rather than writing one from scratch — the stub already gets this right.
The store is written after the response or job. So in the same request, and often in the same test run, monitor_outcomes is empty. Query it in a later assertion or after the flush point; do not write a feature test that reads it mid-request. Flushes happen after requests and commands via the kernels, after each job, on Queue::looping and worker stop, and at a 100-row buffer — which is what makes it Octane-safe, and also what makes its timing non-obvious.
Inline control() is second-class in static analysis. --check fails on control() it cannot read statically and warns on catch-alls without escalation and on computed point names. If you build point names dynamically, expect warnings; if you centralise names as a backed enum (supported everywhere a name is accepted — do this), you get the type safety and the warning goes away.
Config moved and shrank in 1.0. The enabled, exception_trace, console_auto_trace, trace_header, circuit_breaker, and redactor keys are gone; MONITOR_STORE_ENABLED and MONITOR_MCP_ENABLED are the only environment variables. There is no compatibility layer with 0.1 — Monitor::controlled(), catching(), withCircuitBreaker(), withDatabaseTransaction(), addContext(), Monitor::time() and the origin wrapper/prefix/separator settings are all removed. Read docs/upgrading.md before touching any 0.1-era snippet you find in a blog post or an old PR.
Extension points, so you do not fork. Control is Conditionable and Macroable (StructuredLogger is Conditionable), so a declaration can branch with when()/unless() and your app can add methods. Custom Limit, Policy, Rule, Escalation, Correction, Runner all live under Contracts\. Value objects are final readonly; services are open for extension. MonitorException is an interface, and exceptions keep natural SPL parents (the Invalid* ones extend InvalidArgumentException, the risks extend RuntimeException) — so catch (RuntimeException) still catches a risk, which is either convenient or a footgun depending on how coarse your catches are.
Debugging a point that "did nothing". monitor:explain <point> first (it prints the contract in prose, including what is not declared), then the Outcome's timeline and stack via attempt(), then monitor:points to confirm the class form was actually discovered. If the store is enabled, monitor:outcomes after the flush. Nine times out of ten the surprise is a limit that only records, or a recover() earlier in the chain matching first.
Adoption risk is a real gotcha. Three stars, zero dependents, single-maintainer-shaped surface, and a 1.0 rewrite that broke 0.1 on purpose. Pin the version in composer.json (not a caret range you do not watch), keep monitor:points --check green so the contract is legible to the next developer regardless of the package's fate, and note that the pattern — declared operation, typed failure story, fixed pipeline, CI gate — is portable even if the library is not. The pieces you would miss most are the fake and the inventory; both are things you could rebuild, badly, in a week.
How can I help you explore Laravel packages today?