spacespell/elastic-apm-bundle
Symfony bundle for integrating Elastic APM into PHP apps, with request tracing, transactions, spans, error capture, and performance monitoring. Sends APM data to Elasticsearch/Kibana with minimal configuration for better observability.
Read this first: spacespell/elastic-apm-bundle is a Symfony bundle, not a Laravel package. There is no service provider, no config file in config/ you can publish, and no facade. If you live in Laravel, the honest first step is deciding whether you're:
elastic/apm-agent-php (the official, framework-agnostic PHP agent) plus a small middleware, and treat this bundle as a reference for wiring conventions rather than a runtime dependency.Assuming you're on the Symfony side or prototyping the patterns:
composer require spacespell/elastic-apm-bundleconfig/bundles.php (or AppKernel) — there is no auto-discovery-era guarantee here given the 2020 release date; verify the class name from the README rather than trusting Flex recipes.# config/packages/elastic_apm.yaml
elastic_apm:
enabled: true
app_name: "%env(APP_NAME)%"
server_url: "%env(APM_SERVER_URL)%" # e.g. http://apm:8200
environment: "%kernel.environment%"
transactions:
sample_rate: 0.1
docker run -p 8200:8200 -p 5601:5601 docker.elastic.co/apm/apm-server:7.x alongside Kibana, or use the Elastic APM integration in a 8.x stack.transaction document lands in the apm-* index in Kibana's APM UI with a sane name (e.g. GET /orders/{id}), then confirm a Doctrine query and a Twig render appear as child spans under it.Rely on the built-in subscribers first. The bundle's value is that it hooks the kernel and common libraries via event subscribers/listeners, so you get HTTP transactions, console command transactions, Doctrine DBAL query spans, Twig render spans, and Guzzle calls without writing code. Verify each by clicking into a transaction in Kibana — if the Doctrine tab is empty, the DBAL listener isn't matching your DBAL version (see Gotchas).
Name transactions deliberately. Default naming tends to either use the route/route name or fall back to the raw path. Raw paths with IDs (/orders/48213) explode your transaction cardinality and make the UI useless. Add a small kernel listener with high priority that renames the current transaction to CONTROLLER::action or the route name. For a Laravel port, this is the equivalent of a TerminateMiddleware that calls \Apm\ElasticApm::getCurrentTransaction()->setName(...).
Wrap business-critical work in custom spans. Inject the tracer/client service and wrap anything you want to see broken out — external API calls the bundle doesn't auto-instrument, heavy report generation, third-party SDK calls:
$span = $this->tracer->beginSpan('report.generate', 'custom');
try {
$report = $this->generate();
} finally {
$span->end(); // always end, even on throw
}
The pattern you'd copy into Laravel is roughly a startSpan/endSpan helper plus a middleware that opens a transaction per request and closes it in terminate().
Add context and labels, not just names. Attach user ID, tenant ID, and feature flags as labels on the transaction. Keep label values low-cardinality — tenant_id is fine if you have tens of tenants, not thousands. High-cardinality values belong as free-form context, not labels.
Sample per environment. sample_rate: 1.0 locally, 0.1 or lower in prod. If you're doing queue workers or high-QPS endpoints, sample below the global rate — you want traces, not bills. Sample rates are per-transaction in this style of bundle, so workers can be dialed down independently.
Instrument async work explicitly. HTTP transactions get closed automatically at kernel terminate; queue/messenger handlers do not. Wrap handlers in their own transaction (or span if you propagate a parent trace ID) and make sure the transaction ends before the worker goes back to sleep(). This is the single biggest source of "we have no APM data from our queue" tickets.
Propagate traceparent downstream. If the bundle's Guzzle middleware is active, it injects the traceparent/tracestate headers for you. If you use a non-Guzzle HTTP client (Symfony HttpClient, cURL), add the header yourself from the current transaction — otherwise every downstream service starts a fresh trace and the service map is broken.
For a Laravel app, mirror the subscriber model: a ServiceProvider that binds a singleton tracer, a global middleware starting/ending transactions, an Event::listen(QueryExecuted::class) for query spans, and JobProcessing/JobProcessed for queue spans. The bundle's structure is a decent blueprint; its code isn't portable.
elastic/apm-agent-php is the strategic answer for any new work.enabled: false still costs you. Compile-time wiring and service instantiation often happen regardless. Disable the bundle entirely in config/bundles.php for local/test when you don't need it, rather than relying on the config flag to make it free.sample_rate: 0 silently ships nothing. It's a valid value and produces no error — you'll stare at an empty Kibana for an hour. Check this first when "APM isn't working" in a new environment.capture_body and capture_headers can serialize request payloads, cookies, and auth headers into APM docs. Turn both off in production unless you have a redaction story; they also cost CPU on every request.begin must be ended in a finally block. Any custom transaction you open (CLI, worker) must be end()ed before shutdown; if the process is SIGKILLed the transaction is lost entirely — no partial data.SQLLogger/middleware.curl $APM_SERVER_URL (is the server up?), check APM Server logs for intake rejections (usually a version or secret-token mismatch), then verify the agent's own log level is bumped (logging.level: debug or equivalent) to see what it's actually sending.server_url (where to ship), secret_token (older APM Server 6.x/7.x auth), and api_key (APM Server 7.6+ / Elastic Cloud). Use one, not both; a 401 at intake is almost always this.How can I help you explore Laravel packages today?