Laravel and PHP error tracking with the Sentry SDK.
PHP applications fail in more places than most developers expect: the synchronous request cycle, queue workers running in isolation, Horizon supervisor restarts, Octane long-lived processes with shared state, and fatal errors that fire before the framework boots. This guide covers how to wire up sentry/sentry-laravel so every failure surface reaches your error tracker, what the Laravel integration captures without extra code, and the gotchas that catch teams shipping to production for the first time.
20 seconds. Run composer require sentry/sentry-laravel. Add SENTRY_LARAVEL_DSN to .env. In Laravel 11, register the integration in bootstrap/app.php with ->withExceptions. Swap the DSN to your urgentry DSN and nothing else changes.
60 seconds. The integration hooks Laravel's exception handler, Horizon job supervisor, and the HTTP kernel automatically. Unhandled controller exceptions, failed queue jobs, and Horizon worker crashes all flow to your error tracker without extra code. What it does not capture: exceptions you add to $dontReport, handled exceptions you convert to HTTP responses, and exceptions inside long-running Octane workers unless you flush between requests. Plain PHP (non-Laravel) apps use \Sentry\init directly and bridge error_reporting for non-exception errors.
Three surfaces will cause silent failures if you miss them. First, queue worker exceptions vanish unless the worker process has the SDK registered and your failed job handler calls through. Second, Octane long-lived request scopes accumulate stale context from prior requests. Third, PHP fatal errors fire outside the exception handler entirely; a register_shutdown_function bridges that gap, but only if it registers before any opcode-cached configuration runs. All three are covered below.
Where PHP errors come from in 2026
PHP 8.3 changed what surfaces errors. The engine now emits deprecation notices for dynamic property creation, implicit nullable parameters, and class constant type mismatches. Under older versions these were either silent or only visible in error logs. Under 8.3, they generate E_DEPRECATED and E_STRICT notices that bubble through your error handler.
A traditional PHP request error originates in one of four ways: an uncaught exception propagates through the call stack until it reaches a handler or terminates the script; a fatal error from memory_limit exhaustion, a missing function, or a type coercion failure terminates the script immediately; a non-exception PHP error at the E_ERROR level fires the registered shutdown function; or a deprecation notice at E_DEPRECATED passes through your custom error handler if you have one.
Laravel itself adds two more surfaces that vanilla PHP does not have. Queue workers run in a separate process under a different lifecycle. The worker boots the framework once and then processes jobs in a loop. An exception inside a job does not propagate to the HTTP kernel; it propagates to Laravel's job pipeline, which catches it, logs it, and either retries or fails the job. Without explicit instrumentation, that exception is visible in your log file and the failed_jobs table, but invisible to your error tracker.
Session-state poisoning is a subtler failure mode. If a serialized PHP object in a session becomes unserializable after a deploy (because the class no longer exists, or its constructor signature changed), the session read throws an exception before your controller runs. Laravel's session middleware catches it and clears the session, but the exception itself often goes unreported because it occurs in middleware before the Sentry middleware or handler hook sees it.
Plain PHP install
For PHP applications without Laravel, the SDK is sentry/sentry. Install it with Composer:
composer require sentry/sentry
Initialize the SDK at the top of your entry point, before any application code that could throw:
<?php
\Sentry\init([
'dsn' => $_ENV['SENTRY_DSN'] ?? '',
'environment' => $_ENV['APP_ENV'] ?? 'production',
'release' => $_ENV['APP_VERSION'] ?? null,
'traces_sample_rate' => 0.1,
'send_default_pii' => false,
]);
The SDK registers its own set_exception_handler and register_shutdown_function on init. Unhandled exceptions and fatal errors both route through the SDK's capture path automatically. You do not need to add a try/catch at the top level.
PHP's error reporting system predates exceptions. Non-fatal errors (E_WARNING, E_NOTICE, E_DEPRECATED) do not throw exceptions by default. The SDK converts these to breadcrumbs, but if you want them to appear as events, install a custom set_error_handler that converts them:
<?php
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline): bool {
// Only handle errors that match the current error_reporting level
if (!(error_reporting() & $errno)) {
return false;
}
// Convert E_DEPRECATED and E_WARNING to exceptions so the SDK captures them
if ($errno === E_DEPRECATED || $errno === E_USER_DEPRECATED) {
\Sentry\captureMessage($errstr, \Sentry\Severity::warning());
return true;
}
// Let the SDK's shutdown handler deal with E_ERROR; throw the rest
throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
Call this after \Sentry\init. The SDK's own error handler runs first and registers the shutdown function; your custom handler runs for non-fatal errors and routes them to the SDK. Keep this ordering or the SDK's shutdown handler may not be registered when a fatal error fires.
Laravel install
The Laravel-specific package is sentry/sentry-laravel. It wraps the core PHP SDK and adds integrations for Laravel's HTTP kernel, queue system, Horizon, Octane, and log channel:
composer require sentry/sentry-laravel
Publish the configuration file:
php artisan vendor:publish --provider="Sentry\Laravel\ServiceProvider"
This creates config/sentry.php. Open it and confirm the DSN reads from the environment:
<?php
return [
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
'environment' => app()->environment(),
'release' => env('SENTRY_RELEASE'),
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.0),
'send_default_pii' => false,
'breadcrumbs' => [
'logs' => true, // capture log messages as breadcrumbs
'queue_info' => true, // capture queue job context as breadcrumbs
'cache' => true, // capture cache hits and misses
'livewire' => true, // capture Livewire events
],
];
Set the DSN in your .env file:
SENTRY_LARAVEL_DSN=https://<public_key>@your.urgentry.host/<project_id>
SENTRY_TRACES_SAMPLE_RATE=0.1
SENTRY_RELEASE=1.4.2
In Laravel 11+, the service provider auto-discovers, but exception reporting hooks through bootstrap/app.php. Open that file and add the Sentry reporting call inside ->withExceptions:
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Sentry\Laravel\Integration;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// your middleware configuration
})
->withExceptions(function (Exceptions $exceptions) {
Integration::handles($exceptions);
})->create();
In Laravel 10 and earlier, register the integration in app/Exceptions/Handler.php instead. The register method is the place to wire it up:
<?php
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Sentry\Laravel\Integration;
class Handler extends ExceptionHandler
{
public function register(): void
{
$this->reportable(function (\Throwable $e) {
Integration::captureUnhandledException($e);
});
}
}
The Handler::report exception path
Laravel's exception handler is the central routing point for all errors. When an exception propagates out of a controller, middleware, or job, Laravel calls Handler::report. The Integration::handles call (or the reportable callback in Laravel 10) hooks into this path so the SDK receives every exception the handler processes.
For exceptions you want to enrich with custom context before they reach the SDK, use Sentry::configureScope inside a reportable callback:
<?php
->withExceptions(function (Exceptions $exceptions) {
Integration::handles($exceptions);
$exceptions->reportable(function (\Throwable $e) {
\Sentry\configureScope(function (\Sentry\State\Scope $scope) {
$scope->setTag('app.subsystem', 'billing');
$scope->setExtra('server_load', sys_getloadavg()[0]);
});
});
})
The dontReport list controls which exception classes the handler skips entirely. Laravel ships with a default list that includes AuthenticationException, AuthorizationException, and HttpException. Add ValidationException to this list immediately; it represents expected user input failure, not application error, and will produce high-volume noise with no actionable signal:
<?php
// app/Exceptions/Handler.php (Laravel 10) or inside withExceptions (Laravel 11)
protected $dontReport = [
\Illuminate\Auth\AuthenticationException::class,
\Illuminate\Auth\Access\AuthorizationException::class,
\Illuminate\Database\Eloquent\ModelNotFoundException::class,
\Illuminate\Validation\ValidationException::class, // add this
\Symfony\Component\HttpKernel\Exception\HttpException::class,
];
For exceptions you catch explicitly in a controller and convert to a JSON response, call Sentry::captureException yourself for the ones that represent real application failures:
<?php
public function checkout(Request $request): JsonResponse
{
try {
$result = $this->paymentService->charge($request->validated());
} catch (CardDeclinedException $e) {
// Expected user-facing outcome, not a bug
return response()->json(['error' => 'Card declined'], 402);
} catch (PaymentProviderException $e) {
// Our integration is failing — capture it
\Sentry\withScope(function (\Sentry\State\Scope $scope) use ($e, $request) {
$scope->setTag('payment.provider', $request->input('provider'));
$scope->setExtra('amount_cents', $request->input('amount'));
\Sentry\captureException($e);
});
return response()->json(['error' => 'Payment service unavailable'], 503);
}
return response()->json(['transaction_id' => $result->id]);
}
Queue workers and Horizon
Queue workers run outside the HTTP request cycle. The queue:work command boots the framework once, then enters a loop where it pulls jobs off the queue and executes them. An exception thrown inside a job's handle method does not propagate to the HTTP kernel; it propagates to Laravel's job dispatcher, which catches it and decides whether to retry or fail the job.
This is why queue exceptions disappear without explicit instrumentation. The job dispatcher catches the exception before any Sentry middleware sees it. The exception appears in your log channel and in the failed_jobs table, but never reaches the SDK unless you wire it in.
The sentry/sentry-laravel package includes a queue integration that hooks into the job lifecycle. It fires automatically when the service provider loads, which happens in the worker process if your config/app.php lists the provider (or if auto-discovery is enabled). Confirm the service provider loads in your worker by checking the worker startup log for the Sentry provider.
Laravel's job model uses a "foreach and die" memory management strategy for long-running workers: after a configurable number of jobs or after reaching a memory threshold, the worker process exits and the supervisor (Horizon, a system service, or a simple shell loop) restarts it. This means a worker process is not truly long-lived; it restarts regularly. The SDK state resets on each restart, which is the correct behavior.
For jobs you want to capture on every attempt, not just at final failure, implement the failed method on your job class and call the SDK there:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Throwable;
class ProcessInvoice implements ShouldQueue
{
use InteractsWithQueue, Queueable;
public int $tries = 3;
public int $backoff = 60;
public function __construct(public readonly int $invoiceId) {}
public function handle(): void
{
// job logic here
}
public function failed(Throwable $exception): void
{
\Sentry\withScope(function (\Sentry\State\Scope $scope) use ($exception) {
$scope->setTag('job.class', static::class);
$scope->setTag('job.queue', $this->queue ?? 'default');
$scope->setExtra('invoice_id', $this->invoiceId);
$scope->setExtra('attempts', $this->attempts());
\Sentry\captureException($exception);
});
}
}
The failed method fires only when the job exhausts its retry count. If you want to observe intermediate failures, add a try/catch inside handle that calls Sentry::captureException and then re-throws:
<?php
public function handle(): void
{
try {
$this->processInvoice($this->invoiceId);
} catch (Throwable $e) {
\Sentry\withScope(function (\Sentry\State\Scope $scope) use ($e) {
$scope->setExtra('invoice_id', $this->invoiceId);
$scope->setExtra('attempt', $this->attempts());
\Sentry\captureException($e);
});
throw $e; // re-throw so Laravel still handles retries
}
}
Horizon adds supervisor-level monitoring on top of queue:work. The sentry/sentry-laravel package includes a Horizon integration that captures supervisor failures and worker termination events. It activates automatically when Horizon is detected. Horizon's dashboard shows failed jobs independently of your error tracker; treat them as complementary, not redundant.
Octane and Swoole gotchas
Laravel Octane runs the framework in a long-lived process using Swoole or RoadRunner. The framework boots once and handles thousands of requests without restarting. This eliminates the per-request bootstrapping cost and is the source of the performance gains Octane advertises. It is also the source of a class of bugs that do not exist in traditional PHP-FPM deployments.
The problem with the Sentry SDK under Octane is static state. The SDK maintains a global hub with a current scope. In PHP-FPM, each request spawns a fresh process and the hub starts empty. In Octane, the hub persists across requests in the same worker. If request A sets a user context on the hub's scope and request A completes, request B starts with that user context still attached.
The sentry-laravel package ships an Octane integration that registers a request lifecycle listener to reset the scope between requests. Verify it is active by checking the Octane boot output for the Sentry integration listener registration. If you use a custom Octane boot configuration that bypasses the default service provider initialization, you may need to register it manually:
<?php
// In a custom Octane task or lifecycle hook
use Laravel\Octane\Facades\Octane;
use Sentry\Laravel\Integration;
Octane::tick('sentry-flush', function () {
\Sentry\withScope(function () {
// Scope is discarded when this closure exits
});
})->seconds(0); // Run between requests
The safer pattern under Octane is to always use Sentry::withScope for any context you set inside a request. Never call Sentry::configureScope at the top level inside a controller or middleware under Octane; that call modifies the persistent hub scope, not a temporary one.
<?php
// Safe under Octane
public function show(Request $request, int $id): JsonResponse
{
try {
$resource = Resource::findOrFail($id);
} catch (ModelNotFoundException $e) {
\Sentry\withScope(function (\Sentry\State\Scope $scope) use ($e, $request, $id) {
$scope->setUser(['id' => $request->user()?->id]);
$scope->setExtra('resource_id', $id);
\Sentry\captureException($e);
});
abort(404);
}
return response()->json($resource);
}
The withScope closure creates a temporary scope that is discarded when the closure exits, regardless of how long the Octane worker lives. Context set inside it does not leak to subsequent requests.
Call Sentry::flushEvents() at the end of a Swoole coroutine if you use coroutine-based concurrency. The SDK batches events in memory before flushing; under normal FPM the process exits and forces a flush. Under Swoole, the process stays alive and the flush happens on a timer. In high-throughput scenarios, events may sit in the buffer longer than expected. Most deployments are fine with the default flush behavior, but high-reliability applications should call flushEvents explicitly after each request.
Source maps for Laravel Mix and Vite
PHP applications that ship JavaScript frontends can use source maps to map minified JavaScript stack traces back to their original source lines. This is a client-side concern, not a PHP concern, but the workflow integrates with the same urgentry project that receives your PHP errors.
For Vite (the default in Laravel 10+), install the Sentry Vite plugin:
npm install --save-dev @sentry/vite-plugin
Configure it in vite.config.js:
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import { sentryVitePlugin } from '@sentry/vite-plugin';
export default defineConfig({
plugins: [
laravel({ input: ['resources/js/app.js'], refresh: true }),
sentryVitePlugin({
org: 'your-org',
project: 'your-project',
url: 'https://your.urgentry.host', // point at urgentry
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
assets: './public/build/**',
},
}),
],
build: {
sourcemap: true,
},
});
For Laravel Mix (Laravel 9 and earlier), use sentry-cli directly after the build step. Install the CLI and upload artifacts:
npm install --save-dev @sentry/cli
# After mix builds
npx sentry-cli releases \
--url https://your.urgentry.host \
--auth-token $SENTRY_AUTH_TOKEN \
files $SENTRY_RELEASE upload-sourcemaps \
./public/js \
--url-prefix '~/js'
The --url flag overrides the default Sentry endpoint and points sentry-cli at your urgentry instance. Every source map operation, release artifact upload, and deploy notification works through the same override.
Pointing all of this at urgentry
The DSN is the only thing that changes. In your urgentry instance, create a project and copy the DSN from the project settings. It follows the same format as a Sentry DSN:
https://<public_key>@your.urgentry.host/<project_id>
Set it in .env:
SENTRY_LARAVEL_DSN=https://<public_key>@your.urgentry.host/<project_id>
The config/sentry.php published by the package reads from SENTRY_LARAVEL_DSN first, then falls back to SENTRY_DSN. No code change is needed. The SDK sends the same Sentry envelope protocol it always has. urgentry implements all 218 Sentry REST API operations the SDK targets, including the envelope ingest endpoint, the store endpoint, the release API, and the source map artifact upload endpoint. See the full compatibility matrix at /sentry-alternative/.
For sentry-cli commands (source map upload, release tracking, deploy notifications), pass the --url flag or set the SENTRY_URL environment variable:
SENTRY_URL=https://your.urgentry.host
SENTRY_AUTH_TOKEN=your_token_here
SENTRY_ORG=your_org
SENTRY_PROJECT=your_project
Every sentry-cli subcommand reads these variables, so the same .env or CI environment configuration works for both the SDK integration and the CLI tooling.
urgentry runs as a single Go binary. On a $5 1 vCPU/1 GB VPS it sits at around 52 MB resident memory while handling 400 events per second. SQLite is the default datastore; PostgreSQL is available for higher write throughput or shared multi-instance deployments. A Laravel application generating tens of thousands of events per day runs without issue on the smallest available VM tier.
Performance monitoring traces
The Sentry SDK's performance monitoring in Laravel captures distributed traces through the HTTP lifecycle. When traces_sample_rate is greater than zero, the SDK creates a transaction for each sampled HTTP request and attaches spans for the operations that occur within it.
Set the sample rate in .env:
SENTRY_TRACES_SAMPLE_RATE=0.1
At 0.1, one in ten requests generates a performance transaction. Start here and adjust based on event volume and your retention needs. At 1.0, every request creates a transaction and your event storage fills quickly.
The spans the SDK captures by default in a Laravel request include the route matching time, controller execution, Eloquent query execution (each query is a span with the SQL attached), Redis operations, and the response rendering time. You get query-level visibility into slow requests without adding any instrumentation to your Eloquent models.
For custom spans around operations the SDK does not instrument automatically, use the tracing API directly:
<?php
use Sentry\Tracing\SpanContext;
$parentSpan = \Sentry\SentrySdk::getCurrentHub()->getSpan();
if ($parentSpan !== null) {
$context = new SpanContext();
$context->setOp('external.api');
$context->setDescription('Stripe charge');
$span = $parentSpan->startChild($context);
\Sentry\SentrySdk::getCurrentHub()->setSpan($span);
try {
$result = $this->stripeClient->charges->create([...]);
} finally {
$span->finish();
\Sentry\SentrySdk::getCurrentHub()->setSpan($parentSpan);
}
}
The sampling decision propagates downstream via the sentry-trace header. If your Laravel application calls another service that also uses the Sentry SDK (or any OpenTelemetry-compatible SDK), the trace continues across the service boundary. urgentry receives all trace segments and assembles them into a single distributed trace view.
Route-level transactions group by the route pattern, not the specific URL. A request to /users/42/orders groups with /users/99/orders under the pattern /users/{user}/orders. This is the correct behavior for performance aggregation: you care about how fast the route is, not how fast a specific user's route is.
Three PHP gotchas
1. Fatal errors before the SDK initializes
PHP fatal errors (E_ERROR, E_PARSE, E_COMPILE_ERROR) terminate the script immediately and can fire before your Composer autoloader runs. A missing class, a syntax error in a required file, or a memory_limit exhaustion during autoloading all fall into this category. The SDK's register_shutdown_function only captures fatals if the SDK initialized before the fatal occurred.
The practical consequence: an error in bootstrap/app.php itself, or in a service provider that loads during boot, fires before the Sentry service provider registers. That error reaches your error log but not your error tracker.
The mitigation is minimal: in your server's PHP error log configuration (or in a thin front-controller file before Composer autoloads), you can initialize a bare \Sentry\init call using a direct DSN string. This pre-boot capture is separate from the full Laravel integration and captures only fatal errors that occur before the application bootstraps. Most teams accept this gap; the errors that occur before Composer loads are rare and visible in server logs.
2. register_shutdown_function ordering
PHP calls shutdown functions in LIFO (last in, first out) order. If a third-party package registers a shutdown function after the Sentry SDK does, that package's shutdown function runs first. If that package's shutdown function itself causes a fatal error (uncommon but possible), the Sentry shutdown function never runs and the original fatal is not captured.
Verify your shutdown function order with a quick debug snippet in non-production:
<?php
register_shutdown_function(function () {
error_log('Shutdown functions running');
$error = error_get_last();
if ($error !== null) {
error_log('Last error: ' . print_r($error, true));
}
});
Add this at the very end of your initialization sequence (after all service providers register). If it logs before the Sentry shutdown function, your shutdown order has the problem described above.
3. Opcache-cached configuration staleness on deploy
PHP's opcache caches the compiled bytecode of PHP files, including your config/sentry.php file. On deploy, if you update the DSN or change a configuration value but do not reset opcache, the running worker processes may continue using the old configuration. SENTRY_LARAVEL_DSN reads from the environment at runtime, so a DSN change in the environment takes effect without an opcache reset. But any configuration value that is evaluated at compile time or hardcoded in config/sentry.php does not.
The standard fix is php artisan config:cache on deploy, which serializes all configuration to a single cached file and invalidates the opcache for that file. If you do not run config:cache (common in development and some staging setups), changes to config/sentry.php take effect only after PHP-FPM or Octane restarts.
In production, always run php artisan config:cache and php artisan optimize as part of your deploy pipeline. These clear the configuration cache and rebuild it from the current environment. Failing to do so is the most common cause of "I updated the config but nothing changed" reports.
FAQ
Does sentry-laravel capture all unhandled exceptions automatically?
Yes. The package registers a reportable callback in Laravel's exception handler. Any exception that propagates to the handler without being suppressed generates an event with the full stack trace and request context. Exceptions in $dontReport, or exceptions matched by a reportable callback that returns false, are skipped.
How do I stop ValidationException from flooding my error tracker?
Add ValidationException::class to the $dontReport array in your exception handler, or call $this->dontReport([ValidationException::class]) in the register method. ValidationException represents expected user input failure, not application error, so capturing it produces noise with no actionable signal.
Why do queue worker exceptions disappear?
Queue workers run as a separate process. An exception inside a job is caught by Laravel's job processing pipeline, logged to your configured log channel, and then the job is retried or moved to the failed jobs table. The SDK only captures the exception if sentry-laravel is registered in the worker process. Implement the failed method on your job and call \Sentry\captureException there to guarantee capture.
How do I handle Octane request scope leaking?
Use Sentry::withScope for all per-request context in Octane applications. The SDK's static state persists across requests in a long-lived Octane worker. Context set outside withScope on one request attaches to the next request's events. The sentry-laravel Octane integration resets the hub between requests automatically for most setups, but always prefer withScope over top-level scope mutation.
Does urgentry work with sentry/sentry-laravel without code changes?
The only change is the DSN. Set SENTRY_LARAVEL_DSN in your .env file to your urgentry project DSN. Every SDK feature, every integration, every captureException call, and every Horizon and Octane hook continues to work without modification. urgentry implements the same Sentry envelope ingest endpoints the SDK targets.
Sources
- getsentry/sentry-laravel — the Laravel integration package covering exception handler registration, queue integration, Horizon, and Octane support.
- Sentry PHP Laravel SDK documentation — official setup guide covering Bootstrap/app.php registration, config publishing, and Laravel 11 migration notes.
- Sentry PHP SDK documentation — core
\Sentry\initoptions, error handler bridging, and manualcaptureExceptionreference for plain PHP. - Laravel exception handling documentation — the
Handler::reportpath,$dontReportlist,reportablecallbacks, and the Laravel 11withExceptionsAPI inbootstrap/app.php. - Laravel Octane documentation — the Octane request lifecycle, worker memory model, Swoole coroutine concurrency, and the implications for static PHP state.
- urgentry compatibility matrix — the full list of Sentry REST API operations confirmed compatible with
sentry/sentry-laravel. - FSL-1.1-Apache-2.0 license text — the FSL license under which urgentry is distributed, converting to Apache 2.0 after two years.
Ready to wire up your Laravel app?
urgentry runs as a single Go binary, accepts sentry/sentry-laravel out of the box, and takes under ten minutes to start receiving events. Add the package, publish the config, set the DSN. That is the full migration.