Monitor gives a Laravel application control points: the operations where a failure matters, declared in code as a contract. A control point says what it is called, which domain it belongs to, which failures it expects and what to return instead, which policies bound it, which limits it should stay within, and who is told when something it did not expect gets out. Every run ends in one outcome, succeeded, recovered, escalated or refused, and every transition is written as a record with the same fields.
Because the declaration is data, the rest of the package can read it: monitor:points lists every point and checks the declarations in CI, Monitor::fake() asserts on outcomes by name, an optional store keeps outcomes queryable, and an MCP server lets an agent ask the application what its control points are and what happened at them. The same convention ships as guidelines for Laravel Boost, so an agent adding a critical operation is told once, by the package.
// A critical operation as a try/catch: no name, no attempt count, no trace, null means declined.
try {
DB::beginTransaction();
$charge = $this->stripe->charge($amount);
DB::commit();
} catch (CardDeclined $e) {
DB::rollBack();
Log::warning('card declined: '.$e->getMessage());
return null;
} catch (\Throwable $e) {
DB::rollBack();
Log::error($e);
throw $e;
}
// The same operation as a control point.
return Monitor::control('payment.charge', $this)
->with(['invoice' => $invoice->id, 'amount' => $amount])
->profile('external') // retry, breaker and duration limit from config
->transaction(retries: 2)
->ensure(fn (ChargeResult $r): bool => $r->settled, 'charge must be settled')
->recover(CardDeclined::class, fn (CardDeclined $e) => ChargeResult::declined($e->code))
->escalate(PagePayments::class)
->run(fn () => $this->stripe->charge($amount));The declaration says what the operation tolerates, what it tries again, when it stops calling the gateway, what counts as success, and who is paged. It produces one log record per transition with point, domain, status, run_id and trace_id as fields.
composer require kirschbaum-development/monitor
php artisan vendor:publish --tag=monitor-configAdd the trace middleware so every request, and every job it dispatches, shares one trace id:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\Kirschbaum\Monitor\Http\Middleware\StartTrace::class);
})Declare a critical operation as a class:
php artisan make:control-point Payments/ChargeCard --profile=external#[Point('payments.charge_card', 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);
}
}
ChargeCard::run($invoice); // the value, or throws what escaped
ChargeCard::attempt($invoice); // an Outcome: ->status, ->value, ->exception, ->attempts, ->durationMsTest it by name:
Monitor::fake()->failing('payments.charge_card', new CardDeclined('insufficient_funds'));
ChargeCard::run($invoice);
Monitor::assertRecovered('payments.charge_card', from: CardDeclined::class);
Monitor::assertNothingEscalated();And prove in CI that every critical operation is declared and complete:
php artisan monitor:points --checkA control point has a name (payment.charge), a domain derived from its class namespace, and a contract. The contract declares risks with their corrections (recover(): the handler's return value is the result), policies (Retry, Transaction, Breaker, composed in a fixed order), limits (within() records a slow run without failing it, attempts() caps retries, ensure() fails a run whose result is wrong), and an escalation for anything no correction covers.
Every run ends in exactly one outcome: succeeded, recovered, escalated, or refused by an open breaker. The outcome is what attempt() returns, what the events carry, what the fake records, what the log receives as one record per transition, and what the optional store keeps in a table.
Control points nest. A child carries its parent's run id, a child's escalation reaches the parent's corrections, and retries never compose across the stack. The trace id and the current point ride on Laravel's Context, so they reach queued jobs and every log line the application writes.
An inventory reads the codebase without running it: monitor:points lists every point with its contract, --check fails the build when a point has no escalation, a name is duplicated, or a class in a critical namespace is not a control point. The same rules are available as a Pest expectation and a PHPStan rule.
- Critical operations through
Monitor::control()inline orControlPointclasses, with typed outcomes. - Resilience through retry with backoff, whole-transaction retry on deadlock,
once()for idempotent runs, and a closed/open/half-open circuit breaker shared across processes, also usable standalone, on the HTTP client asHttp::breaker(), and as theCheckBreakersroute middleware. - Queues through
ChargeCard::dispatch(), which runs a control point as a job tagged for Horizon and released for the breaker's retry-after when refused, and theWaitForBreakerjob middleware. - Records as one schema for every transition, redacted through Redactor, with a tap that writes NDJSON with the fields at the top level.
- Tracing with W3C
traceparentand a legacy header,Http::traced()for outgoing calls, and automatic propagation to queued jobs. - A store of outcomes, written after the response, for
monitor:outcomesand the MCP tools when there is no log backend. - Verification through
Monitor::fake()and its assertions,monitor:points --checkwith table, JSON and SARIF output, a Pest expectation and a PHPStan rule. - Agents through a guideline and a skill that Laravel Boost composes into every consuming app, a read-only MCP server (
list_points,explain_point,outcomes,escalations, awrap_operationprompt), and amake:control-pointstub whose test already uses the fake.
The full documentation lives in docs/:
| Page | What it covers |
|---|---|
| Getting Started | Installation, the inline form, the Outcome, a first class-form point, the trace middleware. |
| Control Points | Naming, domains, the builder, the class form, nesting, Laravel Context. |
| Risks and Corrections | recover() semantics, the catch-all, escalate(), the risks Monitor raises. |
| Policies and Limits | Retry, Transaction, Breaker, pipeline order, the three limits, profiles. |
| Records | The record schema, levels, redaction, NDJSON, Monitor::log(), events. |
| Tracing | Trace ids, the middleware, Http::traced(), jobs, console. |
| Jobs | Dispatching a point as a job, Horizon tags, WaitForBreaker, what a job inherits. |
| Breakers | The state machine, the standalone API, the route middleware. |
| Store | Enabling the outcome store, what is written and when, monitor:outcomes, pruning. |
| Inventory | monitor:points, every rule, --check in CI, monitor:explain, make:control-point. |
| Testing | Monitor::fake() and its assertions, the Pest expectations, the PHPStan rule, and the package's own tests. |
| Agents | The Boost guideline and skill, the MCP server, its tools, resources and prompt. |
| Configuration | Every key in config/monitor.php with its type, default and environment variable. |
| Extending | Custom policies, escalations, inventory rules, event listeners. |
| Upgrading | Every 0.1 surface and its 1.0 replacement. |
- PHP 8.3, 8.4 or 8.5
- Laravel 12 or 13
- kirschbaum-development/redactor 1.x, installed automatically
- laravel/mcp 1.x, only for the MCP server
composer test
composer preflight # pint, rector, phpstan and pest, as the pre-commit hook runs them
composer setup-hooks # point git at .githooksSee CHANGELOG.md for what changed in each release.
The MIT License (MIT). See LICENSE.md.