mantas-done/laravel-apm
Lightweight, self-hosted APM for Laravel. Capture requests, database queries, jobs, and exceptions, then review performance data in a simple dashboard to find slow endpoints and bottlenecks—no external monitoring service required.
1. Install and publish
composer require mantas-done/laravel-apm
php artisan vendor:publish --provider="MantasDone\LaravelApm\ApmServiceProvider"
php artisan migrate
The publish step gives you config/apm.php plus the migration that creates the storage tables. Read config/apm.php before running migrations — that file is the single source of truth for what gets recorded, how long it's kept, and which route/table names the package uses.
2. Decide on a database connection first
This is the most consequential early decision. APM writes per-request rows. If you have any meaningful traffic, point the package at a separate connection (or a separate database on the same server) rather than your primary OLTP connection:
// config/apm.php
'connection' => env('APM_DB_CONNECTION', 'mysql_apm'),
If you skip this, you'll be benchmarking your app against tables that its own monitoring is competing with for I/O and row locks.
3. Enable selectively via env
APM_ENABLED=true
APM_SAMPLE_RATE=1.0 # 1.0 in local/staging, 0.1–0.25 in prod
APM_RETENTION_DAYS=14
Ship with APM_ENABLED=false in .env.example and turn it on per-environment. A monitoring package that silently activates itself in production on first deploy is the classic footgun.
4. First use case: staging performance triage
The fastest payoff is running it on staging and hitting your slowest, heaviest endpoints. Open the dashboard route (published in routes/ or registered by the service provider — check php artisan route:list | grep -i apm), sort requests by duration, then drill into the queries attached to the slow ones. That single loop — slow request → its query list → the N+1 or missing index — is 80% of the package's day-to-day value.
Global middleware, but scoped down quickly
The package hooks in as middleware. Register it globally so nothing is missed, then immediately build an ignore list so you're not storing health checks, Horizon/Octane heartbeats, and asset 404s:
// config/apm.php
'ignore' => [
'paths' => ['up', 'health', 'horizon/*', 'telescope/*', '_debugbar/*'],
'status_codes' => [404],
],
Noise control is the difference between a dashboard people read and one they abandon in a week.
Per-route and per-environment tuning
Keep the sample rate high (1.0) on staging so you get statistically useful data from a handful of manual clicks, and low in production. If the package exposes per-route overrides in config, use them for your 2–3 money paths (checkout, login, search) so those stay at 1.0 while background traffic is sampled at 10%.
Instrument background work explicitly
Request monitoring gets you the web tier; jobs and scheduled commands are usually where the surprises are. Wrap queue jobs so their duration and failure land alongside the request that dispatched them:
public function handle()
{
// if the package ships a facade/helper, wrap the body
return Apm::measure('reconcile-invoices', function () {
// ...
});
}
The payoff is causal tracing: a 900ms checkout request that dispatched three jobs is far easier to reason about when the job rows carry the same trace/request identifier.
Separate error tracking from performance tracking
Do not use this as a replacement for Sentry/Flare/Bugsnag. Use APM for performance and trends (p95 latency, slow queries, throughput), and keep a dedicated error tracker for stack-trace-grade detail. Run both; the overlap is cheap and the failure modes are different.
Retention as a first-class concern
Put pruning in the scheduler from day one, not after the table hits 20M rows:
// app/Console/Kernel.php
$schedule->command('apm:prune')->dailyAt('03:15');
Align APM_RETENTION_DAYS with your team's actual debugging horizon. Two weeks is usually plenty for performance data; anything longer is cheaper to keep in a warehouse.
Integrate with your alerting, not just your dashboard
A dashboard nobody opens is dead weight. If the package exposes aggregates you can query, wire a scheduled job to check p95 latency and slow-query count against thresholds and push to Slack. Even a crude "p95 > 800ms for 10 min" check converts this from a museum into an alerting tool.
Write amplification is real. Every tracked request costs at least one INSERT, plus one per captured query if query logging is on. At 500 req/s with 10 queries each you're looking at 5,500 writes/sec on top of your application's. Always pair this with sampling and a dedicated connection. Benchmark the overhead on staging before enabling in prod — measure with and without, don't guess.
Query logging is the expensive switch. It's also the most useful feature, which is an awkward combination. If you only need latency trends, consider capturing query counts and total time per request rather than every statement, and only store full query lists for requests over a duration threshold. Check whether config/apm.php exposes a slow-query threshold; if it does, use it.
Lock the dashboard down. The published dashboard route is typically registered without middleware beyond web. Before this ever reaches a non-local environment, wrap it in an authorization gate:
// AuthServiceProvider
Gate::define('viewApm', fn ($user) => $user->isAdmin());
and confirm the package's middleware config actually applies it. A public endpoint that lists your slowest queries and their bindings is a data-exposure incident waiting to happen — bindings can contain emails, tokens, and PII.
Query bindings are a PII surface. Even behind auth, storing raw bindings means customer data lands in a table with different access controls and different retention than your main data. Check whether bindings are stored, and if so, whether there's a redaction option. If not, consider trimming retention specifically for the query table.
Config caching will bite you. php artisan config:cache in deploy means changes to config/apm.php require a re-cache. If you flip APM_ENABLED via env in a deploy and nothing changes, run php artisan config:clear before you start debugging the package itself.
Long-running workers and Octane. Under Octane or a long-lived queue worker, anything the package holds in memory (buffered events, accumulated timers) can leak or double-flush across requests. Test explicitly under whatever runtime you deploy — behavior under php artisan serve tells you nothing.
Middleware ordering matters. If APM middleware sits after StartSession/Auth, you'll miss authentication time in the total. If it sits before your exception handler, thrown exceptions may not be attributed correctly. When durations look systematically lower than your load balancer's numbers, middleware position is the first thing to check.
Missing data usually means three things: (1) the scheduler isn't running so nothing is pruned and eventually writes fail, (2) the sample rate is lower than you think, or (3) the ignore list is broader than intended — a trailing-slash or wildcard mistake in paths will silently drop whole route groups. Verify by hitting a known route and querying the table directly.
Version and maintenance risk. With a low star count and a last release in early 2024, treat this as a package you may end up owning. Pin the version in composer.json, don't rely on it for anything you can't replace, and keep the data model simple enough that a fork or a swap to OpenTelemetry is a weekend, not a quarter. If you need long-term stability, evaluate whether the data it stores is genuinely proprietary or whether you'd be better served by an OTel-compatible stack from the start.
Extension points worth knowing: the published config, the published migrations (you own those tables now — add your own indexes if you query them), the dashboard views if published, and the service provider bindings. If the package ships a facade or Apm helper, that's your cleanest custom-instrumentation hook; avoid editing vendor files, since a composer update erases them.
How can I help you explore Laravel packages today?