inspector-apm/inspector-php
Inspector is a code execution monitoring tool for PHP developers. It tracks transactions and code segments in real time, alerts you when something goes wrong, requires PHP 8.1+, installs via Composer, and supports custom transports.
1. Install
composer require inspector-apm/inspector-php
Requires PHP 8.1+. If you are on Laravel, install inspector-apm/inspector-laravel instead — it pulls this package in, wires up a ServiceProvider, a global middleware for request transactions, and publishes config/inspector.php. The core SDK below is what you reach for in non-Laravel apps, CLI scripts, workers, and custom instrumentation.
2. Get an ingestion key
Create a project at inspector.dev and copy the ingestion key. Put it in .env as INSPECTOR_INGESTION_KEY (Laravel) or export it in your process environment — never commit it.
3. Boot the client
In plain PHP, typically in your front controller or a bootstrap file:
use Inspector\Inspector;
use Inspector\Configuration;
$configuration = new Configuration(getenv('INSPECTOR_INGESTION_KEY'));
$inspector = new Inspector($configuration);
In Laravel you normally resolve Inspector\Inspector from the container instead of constructing it.
4. Open your first transaction
A transaction is one execution cycle (a request, a command, a queue job). Everything else nests inside it:
$inspector->startTransaction($_SERVER['PATH_INFO']);
$result = $inspector->addSegment(function ($segment) {
return (new ReportService)->build();
}, 'report', 'build-report');
$inspector->transaction()->end(); // closes the transaction
Where to look first
Configuration options, segment types, and filtering.Inspector\Configuration class — this is the real config surface (transport, URL, filtering, batching). Read it before assuming a knob exists.First use case to pick
Don't instrument everything. Pick the slowest endpoint or job you already suspect, start a transaction, wrap two or three candidate calls in addSegment(), and look at the timeline. That 30-minute exercise tells you more than a week of reading the API.
The pattern that scales is: exactly one place opens and closes a transaction, and the rest of the codebase only ever adds segments.
startTransaction($request->method().' '.$request->route()?->uri()) before, end() in a finally.Artisan::starting hook that names the transaction command:{$name}.handle() and closes it in finally, named after the job class. This is the highest-value integration in a Laravel app, because a job's failure mode is invisible in HTTP traces.Normalize the transaction name aggressively. GET /users/42 and GET /users/43 must collapse into GET /users/{id}, or your dashboard becomes a list of one-off transactions.
addSegment() is the workhorse — use it as a wrapper, not a loggeraddSegment() returns whatever your closure returns, which makes it safe to wrap production code without restructuring:
$user = $inspector->addSegment(
fn () => User::with('orders')->find($id),
'db',
'User::find with orders'
);
Wrap at the boundary that owns the latency: outbound HTTP calls, cache lookups that miss, a single expensive query, third-party SDK calls, PDF generation. Do not wrap every method in a service class — you'll get 200-segment transactions that are unreadable and cost you ingestion quota.
Segment nesting is positional. Anything started inside an addSegment closure becomes a child of that segment, so you get real parent-child trees for free:
$inspector->addSegment(function () use ($inspector, $order) {
$inspector->addSegment(fn () => $this->chargeCard($order), 'http', 'stripe.charge');
$inspector->addSegment(fn () => $this->notify($order), 'process', 'send-receipt');
}, 'process', 'checkout');
Wrap third-party clients and repositories rather than calling addSegment() at every call site. A Guzzle middleware and a repository base class that add one segment each give you full coverage of outbound HTTP and SQL with two files touched, and keep the instrumentation out of your business logic.
fork()This is the package's genuine differentiator. A single segment stack is wrong the moment two tasks interleave — Fibers, ReactPHP/Amp event loops, pcntl_fork children, or parallel HTTP requests. fork() hands each task its own stack, seeded with the currently open segments as parents:
$inspector->startTransaction('import');
$job = $inspector->startSegment('process', 'import-batch');
$scopeA = $inspector->fork();
$scopeB = $inspector->fork(); // both inherit $job as base parent
$scopeA->startSegment('http', 'fetch-page-1')->end();
$scopeB->startSegment('http', 'fetch-page-2')->end();
Rule of thumb: fork before you branch, not after. Each Scope also supports addSegment(), getOpenSegments(), and nested fork().
TransportInterface has two methods, addEntry(Model $entry) and flush(). The README's example posts straight to https://ingest.inspector.dev from the request — which blocks the user's response. In production you usually want one of:
register_shutdown_function or Laravel's terminating),$inspector->setTransport(fn (Configuration $config) => new QueuedTransport($config));
The factory receives the live Configuration, so your transport can read the URL, key, and batch limits without duplicating config.
$inspector->registerGlobalHandler() installs a PHP global exception handler that reports uncaught throwables and then defers. Use it in plain PHP. In Laravel, let the framework's handler do the work and report explicitly from App\Exceptions\Handler::report() instead — see the gotchas below.
You must close the transaction, or data never ships. Nothing is flushed until transaction()->end() runs (or the object destructs). Any code path that returns early, calls exit, or throws past your finally block drops the trace. Put end() in finally, always.
Long-running processes are the number one operational failure. Queue workers, Octane, RoadRunner, and Swoole daemons keep the Inspector instance alive across requests/jobs. Consequences: memory grows because buffered entries accumulate, parent hashes leak between unrelated jobs, and traces attribute work to the wrong execution. Start a fresh transaction per job/request and flush() at the end of each one; do not rely on process shutdown.
registerGlobalHandler() fights with framework handlers. It is a global set_exception_handler call — in Laravel or Symfony you'll either shadow the framework's handler or silently lose its rendering/logging. Report exceptions explicitly from your existing handler instead, and keep registerGlobalHandler() for standalone scripts and legacy apps.
Segment count is your bill and your readability. Every segment is an ingested model. A segment inside a foreach over 5,000 rows produces 5,000 models — you'll see latency and quota damage. Instrument the loop, not the iteration; if you genuinely need per-item data, aggregate it yourself and attach a count.
Cardinality kills the dashboard. Raw URLs, SQL with interpolated IDs, and labels built from user input all explode into unique entries. Normalize paths and parameterize query labels before they reach addSegment().
Labels are data leaving your network. Segment labels, transaction names, and exception messages are shipped to Inspector's cloud. Don't interpolate emails, tokens, request bodies, or card numbers into them. If your compliance posture forbids third-party egress, use a custom transport pointed at your own proxy — but understand that's a policy decision, not a technical one.
Network egress is silent when blocked. The default cURL transport fails quietly; you will see an empty dashboard with no application error. When traces don't appear, check in this order: (1) is the transaction actually ending, (2) can the host reach ingest.inspector.dev on 443, (3) is the ingestion key correct, (4) is a firewall or corporate proxy stripping headers. Add logging to the top of your custom transport's flush() first — it makes this a two-minute diagnosis instead of a day.
Upgrade to at least 3.16.15 for the cURL deprecation fix, and to 3.17.0+ if you touch Fibers or pcntl_fork at all. Anything older has a single global segment stack.
Disable it in tests and local dev. Traces from PHPUnit runs will pollute your metrics, inflate counts, and slow the suite with network I/O. Gate on an env flag in the service provider and default it off outside production.
Sample deliberately at scale. At high RPS or high job throughput, tracing everything is both slow and expensive. Choose a sampling/filtering strategy in Configuration (and filter out health checks, /up, favicon, and other noise) rather than instrumenting less — you want consistent coverage, not random gaps.
The MCP integration is worth ten minutes. Pointing Claude Code / Cursor at the Inspector docs server (see "Agentic Integration" in the README) genuinely shortens the "which method wraps what" discovery phase, since the README undersells the API surface. Combine it with reading the actual Inspector\Configuration class rather than trusting examples you find in blog posts — this SDK has moved fast (fork scopes and Neuron support both landed in the 3.18–3.19 line), so older snippets are frequently out of date.
How can I help you explore Laravel packages today?