Monitoring and Debugging

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05, with laravel/telescope 5.25.0, laravel/pulse 1.8.1 and laravel/pail. The tools divide by environment. Telescope records everything in development, Pulse aggregates the essentials in production, Nightwatch or an error tracker watches production from outside, Pail tails the logs, and the exception page and Xdebug explain a single failure. Logging itself, with channels, context and the exception handler, is on Error Handling and Logging.

Telescope in development

composer require laravel/telescope --dev, php artisan telescope:install and php artisan migrate add a dashboard at /telescope. Its watchers record requests, queries, models, cache operations, Redis commands, views, jobs, mail, notifications, events, logs, exceptions, gates, outgoing HTTP calls and scheduled tasks into telescope_entries. Eight requests to Bookshelf produced:

query 91 | model 10 | cache 8 | request 8 | redis 5 | view 4 | command 1

Entries from one request share a batch_id, so the request list can show what each request cost:

<?php
$requests = DB::table('telescope_entries as r')
    ->where('r.type', 'request')
    ->select('r.content')
    ->selectSub(fn ($q) => $q->from('telescope_entries as q')->whereColumn('q.batch_id', 'r.batch_id')->where('q.type', 'query')->selectRaw('count(*)'), 'queries')
    ->orderBy('r.sequence')
    ->get();
/books                                   200  126 ms  6 queries
/books/1                                 200   30 ms  6 queries
/api/v1/books                            200   49 ms 11 queries
/api/v2/books?include=author%2Creviews   200   41 ms 28 queries
/stats/popular                           200  849 ms  4 queries
/does-not-exist                          404   22 ms  0 queries
/shelf/notes                             200   16 ms  3 queries
/welcome                                 200   20 ms  3 queries

The 28 queries of the JSON:API route are the per-row relationship loading found on Building APIs and API Resources. In the dashboard, the request’s Queries tab lists them, repeated with different ids. /stats/popular is slow because its value is recomputed after the cache entry expires (Cache and Rate Limiting). The counts include the session, cache and rate-limiter queries of the database drivers, about three per request here. The query watcher tags queries slower than 100 ms as slow by default. None was.

php artisan telescope:prune --hours=48 deletes old entries (491 entries pruned. after the experiments) and belongs in the schedule. Telescope::filter() and the watchers array in config/telescope.php limit what is recorded.

The dev-dependency trap

telescope:install registered TelescopeServiceProvider in bootstrap/providers.php, while the package was installed with --dev. The production image from Docker and Sail runs composer install --no-dev. With the generated providers file copied into it, every Artisan command failed:

In TelescopeServiceProvider.php line 11:
  Class "Laravel\Telescope\TelescopeApplicationServiceProvider" not found

The documented local-only setup removes the line from bootstrap/providers.php, registers the providers only in the local environment, and stops auto-discovery:

<?php
// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) {
            $this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);   // dev dependency: never in production
            $this->app->register(TelescopeServiceProvider::class);
        }
    }
}

// composer.json: "extra": { "laravel": { "dont-discover": ["laravel/telescope"] } }

After the change, /telescope still answered 200 locally, and the test below confirms it is absent in the testing environment. Any package in require-dev that registers a provider by hand has the same trap.

Pulse in production

Telescope stores every event, which is too much for production. Pulse (composer require laravel/pulse, then publishing its config and migrations) aggregates instead. Recorders capture slow requests, slow queries, slow jobs, slow outgoing requests, exceptions, cache hits and misses, queue throughput and the users with the most requests, and roll them into time buckets in pulse_entries and pulse_aggregates. After the same traffic:

pulse_entries: cache_hit=3 cache_miss=2 exception=3
pulse_values:  system vm       (written by php artisan pulse:check --once)

The 849 ms request did not appear as slow, because the slow request, query, job and outgoing request thresholds default to 1000 ms (PULSE_SLOW_REQUESTS_THRESHOLD and the others). They should be set to what the application calls slow. php artisan pulse:check, run as a daemon on each server, feeds the Servers card with CPU, memory and disk. The exception entries were the deliberate errors from the next section.

Pulse writes in the same request by default (the storage ingest). Under load, PULSE_INGEST_DRIVER=redis with a php artisan pulse:work process moves the writes off the request path, and sample_rate on busy recorders keeps a fraction of the events. A dedicated PULSE_DB_CONNECTION keeps its tables away from the application database.

Dashboard access

Both dashboards are open in local and closed elsewhere until a gate allows someone:

local /telescope 200      local /pulse 200
production /telescope 403 production /pulse 403
<?php
// tests/Feature/MonitoringTest.php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Gate;

uses(RefreshDatabase::class);

test('telescope is not even registered outside local', function () {
    expect(app()->environment())->toBe('testing');

    $this->get('/telescope')->assertNotFound();
});

test('the pulse dashboard is closed to everyone by default', function () {
    $this->get('/pulse')->assertForbidden();
    $this->actingAs(User::factory()->create())->get('/pulse')->assertForbidden();
});

test('operators can open pulse once the gate allows them', function () {
    Gate::define('viewPulse', fn (User $user) => $user->email === 'ops@bookshelf.test');

    $this->actingAs(User::factory()->create(['email' => 'ops@bookshelf.test']))->get('/pulse')->assertOk();
});

All three pass. The viewPulse gate belongs in AppServiceProvider::boot(). The viewTelescope gate in the generated provider starts with an empty e-mail list, and viewHorizon works the same way (Queues and Jobs).

Outside the application: Nightwatch and error trackers

Laravel Nightwatch is a hosted monitoring service: an agent sends requests, queries, jobs, exceptions and logs to nightwatch.laravel.com, where they can be searched with traces across requests and jobs. Sentry and Flare collect exceptions with their context in the same way. The exception handler reports to them (Error Handling and Logging). An outside service keeps working when the application, its database or its server is down, which a dashboard inside the application cannot do. Nightwatch was not run for this page, because it needs an account and outbound access.

Pail

php artisan pail tails the application log in the terminal, with filters (--filter, --level, --user) and without having to know which file or channel is in use:

$ php artisan pail --timeout=10
   INFO  Tailing application logs.                        Press Ctrl+C to exit
┌ 23:10:09 INFO ───────────────────────────────────────────────────────────────┐
│ loan created                                                                 │
└ GET: /log/demo • Auth ID: guest • isbn: 978-0-446-67697-7 • trace_id: 01a10e54-ed34-7355-be47-1d03970fd3d1 ┘
┌ 23:10:09 WARNING ────────────────────────────────────────────────────────────┐
│ shelf nearly full                                                            │
└ GET: /log/demo • Auth ID: guest • shelf: A3 • trace_id: 01a10e54-ed34-7355-be47-1d03970fd3d1 ┘

Each entry shows the route, the user and the Context values, here the trace_id. Pail needs the pcntl extension, and it is started with composer run dev alongside the server, the queue and Vite.

The exception page, dump and dd

With APP_DEBUG=true, an exception renders Laravel’s exception page. With false, it renders the generic error page. The same route on the two servers:

local      500  916 440 bytes   stack trace with code, request headers and body, routing, the queries run, "Copy as Markdown"
local JSON 500  {"message":"catalogue index is corrupt","exception":"RuntimeException","file":…,"line":…,"trace":[…57 frames]}
production 500  <title>Server Error</title>, no message

The "Copy as Markdown" button copies the exception, the trace and the request into the clipboard for an issue or an AI assistant. The local page includes request headers and the queries, which is why APP_DEBUG must never be on in production.

dump($value) prints a value with Symfony’s VarDumper and continues; the response contained three sf-dump blocks. dd($value) dumps and stops. ->dd() and ->dump() exist on query builders, collections and responses, and $query->dumpRawSql() prints the SQL with bindings. A dump() left in the code reaches users if APP_DEBUG is on, so a static-analysis rule against them in CI is worth having.

Xdebug

Step debugging needs Xdebug in the PHP that serves the request and an IDE listening on port 9003. Herd includes Xdebug and switches it on per site. Sail starts with SAIL_XDEBUG_MODE=develop,debug (Docker and Sail). On a host, xdebug.mode=debug and xdebug.start_with_request=trigger in php.ini make it start only when the browser extension or XDEBUG_TRIGGER asks. The extension slows every request even when idle, so it stays out of production. Installing and configuring Xdebug, including its profiler, is on Debugging and Profiling.

Where older books and tutorials are outdated

  • barryvdh/laravel-debugbar as the only insight tool (common in Laravel 5 tutorials) still works for HTML pages. Telescope also covers API requests, jobs, mail and the queue.

  • Filp/Whoops and then Ignition were the error pages of earlier versions. Current applications use Laravel’s own exception page, and Ignition is no longer in the skeleton.

  • tail -f storage/logs/laravel.log is replaced by php artisan pail, which works with any channel and filters by user.

  • Telescope in production was common, and it fills the database. Pulse and Nightwatch are designed for production.