Error handling in production — log vs display, Sentry integration
Concept
Error handling strategy in production is fundamentally different from development. In development you want maximum information: display all errors, full stack traces, query logs. In production you want security (never expose internals to users), reliability (errors logged reliably), and observability (errors are discoverable and actionable).
The golden rule: In production, NEVER display errors to the end user. Log them. Display a user-friendly message.
display_errors = Off: The php.ini setting that controls whether errors are output to the browser/CLI. Off in production. On in development (but never in production — it leaks file paths, class names, and database structure to attackers).
log_errors = On: Always on. Combined with error_log path or syslog, all errors go to logs. In Docker/Kubernetes, log_errors = On + error_log = /dev/stderr sends PHP errors to the container's stderr stream, which is collected by the log aggregator.
Sentry integration: Sentry (and alternatives like Bugsnag, Rollbar, Flare) capture exceptions, aggregate them, track frequency, show stack traces, and alert on new errors. In Laravel: composer require sentry/sentry-laravel, configure SENTRY_LARAVEL_DSN, and exceptions are automatically reported.
Structured logging: Use Monolog (Laravel's default) with JSON formatters for structured log lines. ELK Stack (Elasticsearch, Logstash, Kibana) or Grafana Loki can then query and visualize them. Context should include: exception class, message, user ID, request ID, trace ID.
Code Example
<?php
// Development php.ini
// display_errors = On
// error_reporting = E_ALL
// log_errors = On
// Production php.ini
// display_errors = Off
// error_reporting = E_ALL (still report everything to log)
// log_errors = On
// error_log = /var/log/php/errors.log
// log_errors_max_len = 1024
// Production error handling setup (bootstrap)
if (app()->isProduction()) {
ini_set('display_errors', '0');
error_reporting(E_ALL); // still capture, just don't display
}
// Structured exception logging (Laravel Log)
try {
processPayment($orderId);
} catch (\Exception $e) {
Log::error('Payment processing failed', [
'exception' => $e::class,
'message' => $e->getMessage(),
'order_id' => $orderId,
'user_id' => auth()->id(),
'trace' => $e->getTraceAsString(),
]);
throw $e; // re-throw for exception handler to render
}
// Sentry integration (after composer require sentry/sentry-laravel)
// config/sentry.php:
// 'dsn' => env('SENTRY_LARAVEL_DSN'),
// 'traces_sample_rate' => 0.1,
// In Handler.php:
// use Sentry\Laravel\Integration;
// $this->reportable(function (\Throwable $e): void {
// Integration::captureUnhandledException($e);
// });
// Docker stderr logging
// error_log = /dev/stderr
// log_errors = On
// Then: docker logs container_name | grep ERROR
// Manual error reporting to Sentry
\Sentry\captureException(new \Exception("Something went wrong"));
\Sentry\captureMessage("Custom message", \Sentry\Severity::warning());
// User-facing error response
function handleApiError(\Throwable $e): array
{
$isDev = app()->environment('local', 'testing');
return [
'error' => $isDev ? $e->getMessage() : 'An error occurred',
'code' => $isDev ? get_class($e) : 'INTERNAL_ERROR',
'trace' => $isDev ? $e->getTrace() : null,
];
}