Most Laravel applications go to production with no answer to the only question that matters at 2am: what is actually slow, and who is it slow for? Logs tell you about crashes. They do not tell you that the dashboard query got 400ms worse after last Tuesday's deploy, that one tenant is generating 60% of your queue load, or that a single N+1 is costing you a database server.
Laravel now has three first-party tools for this, and they do different jobs:
- Telescope — a local/staging debug recorder. Every request, query, job, mail, exception, cache hit, with full payloads. Extremely detailed, expensive to run, not something you leave on under load.
- Pulse — a lightweight production dashboard, self-hosted in your app. Slow queries, slow jobs, slow requests, exceptions, usage by user, server CPU/memory. Aggregated and sampled, so it is cheap.
- Nightwatch — Laravel's hosted monitoring product. Request/job/query traces retained over time, deploy-aware comparisons, alerting, and history you can look back through weeks later.
The practical answer for most teams is Telescope locally, Pulse in production for a live dashboard, and Nightwatch when you need retained traces and alerts. This tutorial sets up all three properly, including the parts that bite: sampling, table growth, authorisation, and instrumenting your own business metrics rather than just framework internals.
Assumptions: a Laravel 12 or 13 app on PHP 8.3+, a queue worker running, and Redis available.
Telescope: rich, local, and never wide open
composer require laravel/telescope --dev
php artisan telescope:install
php artisan migrate
Installing with --dev is deliberate. Telescope records everything and writes several rows per request; on a busy production box it will fill your database and slow you down. If you install it as a dev dependency, remove the auto-discovery so composer install --no-dev on your server does not explode:
"extra": {
"laravel": {
"dont-discover": ["laravel/telescope"]
}
}
Then register it only outside production, in bootstrap/providers.php or via AppServiceProvider::register():
public function register(): void
{
if ($this->app->environment('local', 'testing')) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(\App\Providers\TelescopeServiceProvider::class);
}
}
If you do want Telescope on staging, filter aggressively in App\Providers\TelescopeServiceProvider::register() so you only keep the interesting entries:
Telescope::filter(function (IncomingEntry $entry) {
return $entry->isReportableException()
|| $entry->isFailedRequest()
|| $entry->isFailedJob()
|| $entry->isScheduledTask()
|| $entry->hasMonitoredTag()
|| ($entry->type === 'query' && $entry->content['slow'] ?? false);
});
Two more things people skip:
- Lock down the dashboard.
Telescope::auth()in the same provider decides who gets in. Do not rely on the default local-only gate if the app is reachable. - Prune. Telescope tables grow fast. Add to
routes/console.php:
Schedule::command('telescope:prune --hours=48')->daily();
Telescope's real value is the tag. Tag entries with a tenant or order id and you can pull up every query, job, and mail for one customer's failed checkout:
Telescope::tag(fn (IncomingEntry $entry) => match (true) {
$entry->type === 'request' => ['tenant:'.(request()->user()?->tenant_id ?? 'guest')],
default => [],
});
Pulse: a production dashboard that is safe to leave on
composer require laravel/pulse
php artisan vendor:publish --tag=pulse-config
php artisan vendor:publish --tag=pulse-migrations
php artisan migrate
Pulse writes aggregates, not full payloads. Point it at Redis ingest and a separate database connection if you can, so monitoring never competes with application traffic:
// config/pulse.php
'ingest' => [
'driver' => env('PULSE_INGEST_DRIVER', 'redis'),
'trim' => ['lottery' => [1, 1_000], 'keep' => '7 days'],
],
'storage' => [
'driver' => 'database',
'database' => ['connection' => env('PULSE_DB_CONNECTION', 'pulse')],
],
With the Redis ingest driver you must run the worker that drains the stream:
php artisan pulse:work
Add it to Supervisor alongside your queue workers. Forget this and the dashboard silently stops updating — the most common Pulse support ticket we see.
Sampling and thresholds
Every recorder in config/pulse.php takes a sample_rate and usually a threshold. On a high-traffic app, sample:
'recorders' => [
Recorders\SlowQueries::class => [
'threshold' => 500, // ms
'sample_rate' => 1,
'location' => true,
'max_query_length' => 1000,
'ignore' => ['/(?i)^insert into `?jobs`?/'],
],
Recorders\SlowRequests::class => [
'threshold' => 1000,
'sample_rate' => 0.1,
'ignore' => ['#^/pulse$#', '#^/health$#'],
],
Recorders\UserRequests::class => ['sample_rate' => 0.1],
],
sample_rate is honest about what it does: at 0.1 Pulse records one in ten and multiplies the counts back up, so trends stay accurate while write volume drops by 90%. Thresholds should be set to something you would actually act on — a 100ms "slow query" threshold produces a wall of noise.
Also set per-endpoint grouping so /orders/1041 and /orders/1042 are not two separate rows. Pulse groups by route pattern when it can; explicitly ignore high-cardinality paths that slip through.
Authorise the dashboard
Pulse's dashboard exposes user emails and query text. Gate it in AppServiceProvider::boot():
use Laravel\Pulse\Facades\Pulse;
Gate::define('viewPulse', fn ($user) => $user->isAdmin());
Pulse::user(fn ($user) => [
'name' => $user->name,
'extra' => $user->tenant?->name ?? '',
]);
Your own cards are where the value is
Framework metrics tell you the app is healthy. Business metrics tell you the product is healthy. Pulse lets you record either a value or a count from anywhere:
use Laravel\Pulse\Facades\Pulse;
// In an order observer
Pulse::record('order_value', $order->currency, $order->total_cents)->sum()->count();
// In a webhook handler
Pulse::record('webhook_failure', $provider)->count();
// In a checkout controller, timing an external call
$start = hrtime(true);
$response = $gateway->charge($payload);
Pulse::record('gateway_latency', $gateway->name(), (int) ((hrtime(true) - $start) / 1e6))->avg()->max();
Then render them with a Livewire card:
<?php
namespace App\Livewire\Pulse;
use Laravel\Pulse\Facades\Pulse;
use Laravel\Pulse\Livewire\Card;
use Livewire\Attributes\Lazy;
#[Lazy]
class GatewayLatency extends Card
{
public function render()
{
[$aggregates] = Pulse::aggregate('gateway_latency', ['avg', 'max'], $this->periodAsInterval());
return view('livewire.pulse.gateway-latency', ['aggregates' => $aggregates]);
}
}
Publish the dashboard view (php artisan vendor:publish --tag=pulse-dashboard) and drop <livewire:pulse.gateway-latency cols="4" /> into the grid. A dashboard with two or three cards your team actually cares about gets looked at; the default one gets bookmarked and forgotten.
Nightwatch: retained traces, deploys and alerts
Pulse answers "what is happening now". It does not keep a full trace of the request that broke last Thursday. Nightwatch is Laravel's hosted answer to that: per-request and per-job traces with the queries, cache operations, HTTP calls and exceptions inside them, retained and searchable, plus alerting and comparisons across deploys.
Install the agent and point it at your project token:
composer require laravel/nightwatch
NIGHTWATCH_TOKEN=your-project-token
NIGHTWATCH_ENV=production
NIGHTWATCH_DEPLOY=${GIT_SHA}
NIGHTWATCH_SAMPLE_RATE=0.2
Run the agent as its own long-running process (Laravel Cloud and Forge have first-class support; elsewhere it is another Supervisor program) so ingest happens off the request path.
Two practices make it worth the money:
Tag your deploys. Set NIGHTWATCH_DEPLOY to the commit SHA in your deploy script. Being able to say "p95 on /dashboard went from 240ms to 680ms at deploy a1b2c3d" turns a vague performance complaint into a five-minute git diff.
Instrument your own spans and context so traces carry the identifiers you debug by:
use Laravel\Nightwatch\Facades\Nightwatch;
Nightwatch::user(fn ($user) => ['id' => $user->id, 'name' => $user->tenant->name]);
Nightwatch::trace('pdf.render', function () use ($invoice) {
return $this->renderer->render($invoice);
});
Then wire alerts to symptoms with owners, not to every metric that exists: error rate on a route group, p95 on checkout, queue wait time above 60s, failed jobs per hour. An alert nobody is expected to act on trains everyone to ignore the channel.
Do not forget the two cheap things
Observability tooling does not replace either of these:
Health checks. Laravel exposes /up out of the box; make it meaningful by checking the things a load balancer cares about (database reachable, Redis reachable, queue not backed up) and keep it out of your monitoring recorders' ignore lists so it does not pollute metrics.
Structured logs with a request id. Add a middleware that puts a correlation id into the log context and returns it in a header:
public function handle(Request $request, Closure $next): Response
{
$id = $request->header('X-Request-Id') ?? (string) Str::uuid();
Log::withContext(['request_id' => $id, 'tenant_id' => $request->user()?->tenant_id]);
return $next($request)->header('X-Request-Id', $id);
}
With a JSON log formatter, that single field is what lets you jump from a customer's support email to every log line for their request. Queue jobs inherit the context if you pass it through on dispatch.
A sensible baseline
If you want a checklist to hand to a client:
- Telescope as a dev dependency, never registered in production, pruned daily.
- Pulse in production, Redis ingest,
pulse:workin Supervisor, sampled recorders, gated dashboard. - Two to four custom Pulse cards for business metrics (revenue, signups, gateway latency, webhook failures).
- Nightwatch (or equivalent) for retained traces, deploy tagging via commit SHA, and four or five symptom-based alerts.
- A meaningful
/upand structured logs carrying a request id and tenant id. - A monthly review where somebody actually opens the dashboards. Tooling nobody reads is just cost.
Most of the performance problems we are called in to fix were visible for months before anyone noticed. If you want help instrumenting an existing Laravel application, or a review of what your current dashboards are not telling you, see our Laravel performance optimization and support and maintenance services, or get in touch.