goksagun/elastic-apm-bundle
A PHP bundle that adds Elastic APM instrumentation to your application, capturing transactions, spans, errors, and performance metrics for request tracing and bottleneck diagnosis. Simple configuration and lightweight integration.
The bundle is a thin Symfony integration around an Elastic APM PHP agent, so getting value out of it is really two jobs: get an APM Server + agent reachable, then let the bundle turn Symfony's request/console lifecycle into transactions.
Install and register
composer require goksagun/elastic-apm-bundle
// config/bundles.php
return [
// ...
Goksagun\ElasticApmBundle\ElasticApmBundle::class => ['all' => true],
];
Point it at your APM Server (config/packages/elastic_apm.yaml). Keep secrets in env vars so they survive config caching:
elastic_apm:
enabled: '%env(bool:ELASTIC_APM_ENABLED)%'
service_name: 'my-app'
server_url: '%env(ELASTIC_APM_SERVER_URL)%'
secret_token: '%env(ELASTIC_APM_SECRET_TOKEN)%'
environment: '%kernel.environment%'
Where to look first, in this order:
DependencyInjection/Configuration.php — that is the authoritative list of options for this version. With 1 star and 0 dependents, treat the README as the contract and the code as the fallback.DependencyInjection/ extension class: it tells you which services are registered and therefore what you can inject/decorate.elastic_apm PHP extension (elastic/apm-agent-php) or the HTTP intake-API library. This single fact changes everything about how you instrument (extension = mostly automatic; library = you call transaction APIs yourself). Confirm it before writing any code.First use case (15 minutes): instrument one controller, open Kibana → APM → Services → my-app, and confirm a transaction appears with the route as its name. If you see the service but no spans, the transaction is working and the deeper instrumentation isn't — that's a useful signal to separate the two concerns early.
Let the lifecycle do the work, then name transactions deliberately. The most common pattern is: bundle/kernel subscriber opens a transaction per request, you control only the name. Route-based names (GET /orders/{id}) keep cardinality low; raw URIs or user IDs explode your transaction breakdown and make the UI useless. Most bundles expose a namer or a transaction object you can adjust — prefer GET /orders/{id} over GET /orders/12345.
Instrument the boundaries, not the internals. The high-value spans are: Doctrine queries, outbound HTTP calls, cache/Redis calls, and queue publishes. If the bundle ships Doctrine/HttpClient subscribers, enable them; otherwise wrap the boundary in your own adapter:
$span = $this->apm->beginSpan('external', 'GET payment-gateway');
try {
$response = $this->httpClient->request(...);
} finally {
$span?->end();
}
Keep spans around I/O, never around pure computation — you'll pay overhead for noise.
Capture exceptions where you already handle them. Don't hook every throwable; hook the place where you decide an error is "real". A kernel.exception listener or a try/catch in the top-level handler is the right seam:
try {
$this->handler->handle($command);
} catch (\Throwable $e) {
$this->apm->captureThrowable($e); // then rethrow
throw $e;
}
If the extension is installed, uncaught exceptions are captured automatically — adding manual capture creates duplicates. Check which mode you're in before adding this.
Long-running processes need explicit transaction lifecycles. For Messenger workers and console consumers, a "boot transaction" model is wrong: one transaction per message, ended per message, and a flush at sensible intervals (or on WorkerStoppedEvent). This is the single most common place developers get value and the place they most often leak memory.
Console commands: separate transactions. If the bundle doesn't already trace commands, wrap execute() in a transaction named after the command (console app:sync-orders) so cron-driven work is visible next to web traffic. Without this, your slowest batch jobs are invisible in APM.
Distributed tracing across services. If service A calls B, propagate the trace header (traceparent) on the outbound HTTP client. Symfony's HttpClient supports decorating with a header-setting middleware; do it once in an event subscriber rather than per-call.
Environment and sampling discipline. Disable in test, lower transaction_sample_rate in staging, full sampling only in dev. In practice:
when@test:
elastic_apm:
enabled: false
This prevents test noise and keeps CI from failing on outbound network calls to the APM server.
elastic/apm-agent-php directly. Don't build a critical observability path on an unmaintained integration without a fallback.elastic_apm_* functions from the native extension, your Docker image needs elastic_apm.so installed and ELASTIC_APM_ENABLED set before PHP starts. Calling function_exists('elastic_apm_begin_transaction') in a health check is a fast way to find out. Missing extension usually fails silently, not loudly.end()ed transaction accumulates spans until the process dies. Wrap message handling in try/finally with end() in the finally, and cap the worker's lifetime (--time-limit, --memory-limit).ELASTIC_APM_SERVER_URL at the container level requires bin/console cache:clear. In Docker, set env vars before the app boots, not via a runtime override.enabled: true for a day before enabling on your hottest endpoint.ELASTIC_APM_LOG_LEVEL=debug) and check the agent's log file / stderr first, then curl the APM Server's intake endpoint from the app container. The failure mode is usually network/secret token, not the bundle.EventSubscriber for kernel.request/kernel.terminate, and if you need custom spans, wrap them in small services rather than sprinkling agent calls through domain code. That keeps the vendor dependency in one or two files — which is how you survive swapping this bundle out later.How can I help you explore Laravel packages today?