Skip to content
intermediate Phase 6 · Error Handling and Exceptions

Logging Errors for Production

Record failures with error_log and structured loggers.

40m
0 problems
Topic Progress 0%

error_log, ini Destinations, and What Gets Recorded

PHP has logging built in before any library enters the picture, and the first layer is entirely ini-driven:

log_errors = On
error_log = /var/log/php/app-error.log   ; a file path — or syslog:/udp for the daemon
error_reporting = E_ALL
display_errors = Off                     ; users never see diagnostics
log_errors_max_len = 0                   ; 0 = no truncation (default in modern PHP)
ignore_repeated_errors = On              ; identical same-location errors logged once

With log_errors = On, every diagnostic that survives error_reporting — notices, warnings, and, crucially, fatal E_ERROR messages too — is appended to the error_log destination with timestamp, severity, message, file, and line. That fatal coverage is the layer no handler reaches: the errors-to-exceptions handler from Registering Custom Error Handlers improves what your code catches; log_errors records what the engine kills the process with. On a server, pointing error_log at syslog (the value syslog, or syslog:local0 for a facility) hands aggregation to the platform's log shipper; per-vhost or per-pool overrides are standard in php-fpm pool configs so each application's stream stays separate.

The imperative counterpart is the error_log() function, whose second argument selects the destination:

error_log('Import finished: 1204 rows');              // type 0 → the error_log ini destination
error_log($dump, 3, '/var/log/imports/dry-run.log'); // type 3 → append to this file
error_log($alert, 1, 'ops@example.com');             // type 1 → email (the old alert path)
error_log($json, 4);                                 // type 4 → SAPI-specific (e.g. stderr)

Type 0 is the workhorse: one line into the configured stream, which in a containerized deployment is usually stderr (error_log = /dev/stderr or php-fpm's catch_workers_output), so docker logs and the orchestrator collect everything without PHP knowing about the shipping. Type 3 writes a specific file — legitimate for an app-level audit trail that operators read separately from engine diagnostics, provided the directory is writable by the PHP user and rotated (logrotate with copytruncate, or the platform's rotation). Type 1, email-per-error, is a historical footnote: it blocks the request while mail is sent and floods inboxes during an incident; alerting belongs to the monitoring system reading your logs, not to the logging call itself.

Two qualities separate a log line that resolves incidents from one that merely exists: context and identity. error_log('failed') tells a future on-call engineer nothing; error_log(sprintf('[order:%s] charge failed: %s (ref %s)', $orderId, $e->getMessage(), $ref)) answers which order, what happened, and which gateway reference to check — in one grep. The structured version of this discipline — fields, levels, channels — is exactly what the engine-level layer cannot provide, and what Monolog supplies next.

Rotation deserves one concrete note because unrotated logs are a classic outage: a busy application appends megabytes a day, and a full disk stops both logging and writing. logrotate's copytruncate directive suits files PHP holds open; the platform's shipping agent usually owns rotation entirely when logs go to stdout. For an app-level audit file written with error_log type 3, the same rules apply one level down: dedicated directory, dedicated rotation, a size alarm. One formatting habit also pays off at the raw layer: prefix every line with a stable tag — [order], [import], [cron] — so grep can partition the stream before any platform is involved, and keep lines single-line even when dumping structures, since multi-line entries shatter into unreadable fragments in every aggregator.

Cleanup from Cleanup with finally Blocks guarantees that release happens; this topic is the other half of the error story — leaving a record precise enough that the next incident starts with facts instead of archaeology.

Monolog: Channels, Levels, and Handlers for Production

Once logs feed an aggregation service, the raw text line becomes a liability: no fields to query, no level to filter on, no channel to route. Monolog — the PSR-3 reference implementation used by Symfony and Laravel — structures the record at the source:

use Monolog\Logger;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
use Monolog\Formatter\JsonFormatter;
use Monolog\Level;

$shipping = new StreamHandler('/var/log/app.log', Level::Warning);
$shipping->setFormatter(new JsonFormatter());

$remote  = new SyslogUdpHandler('logs.example.com:514');

$log = new Logger('payments', [$shipping, $remote]);

$log->error('Charge declined', [
    'order_id' => 512,
    'gateway'  => 'stripe',
    'reason'   => 'card_declined',
]);

Three concepts do all the work. Levels are the shared severity scale — Debug, Info, Notice, Warning, Error, Critical, Alert, Emergency (Monolog 3 uses the native Level enum, and the numbers line up with PHP's E_* scale, so the error-handler conversion from the previous topic can pass $errno straight through). Each handler has a minimum level; records below it are dropped at the source, which is how production runs Level::Info while CI and development run Level::Debug. Channels — the constructor's first argument — namespace records by origin ('payments', 'mail', 'php'), so an alert rule can fire on payments.error while php.notice merely archives. Handlers fan out: the same record can hit a local file, stderr, and a remote syslog endpoint, each with its own level and formatter.

The formatter is where the operational payoff lands. The default line formatter produces prose; JsonFormatter emits one object per record with message, level, channel, datetime, context, and extra — fields a log platform can index. The context array you pass at the call site becomes queryable data: order_id:512 is a filter, not a regex over text. The discipline that makes it work is context, not interpolation: build the human-readable message for the first line, and put every value you might need to filter or group on in the context array, with stable snake_case keys that never rename (renaming a key breaks every saved dashboard).

Wiring it all together: install Monolog through Composer (composer require monolog/monolog — it supports PHP 8 without dependencies beyond psr/log), construct one logger in bootstrap and inject it (never a static Logger::get() call from business logic — the testability rules from Static Properties and Methods apply to loggers like everything else), and keep the handler stack environment-specific: file + stderr for containers, syslog for VMs. If you want a logger-shaped object in tests, a PSR-3 TestLogger or a tiny in-memory handler asserts on records without touching disks. What to put in those records — especially when the record is an exception — is a security-sensitive design problem of its own.

The PSR-3 angle is worth an interview paragraph of its own. Monolog implements Psr\Log\LoggerInterface, so any PSR-3 consumer — your services, third-party libraries — accepts it unchanged, and a library demanding a PSR-3 logger can be handed a NullLogger in tests or a flow-specific channel in production without adaptation. That interchangeability is why the ecosystem standardized on the interface rather than the class: your type hints should say Psr\Log\LoggerInterface, keeping the implementation a wiring detail exactly like the interface-first discipline from earlier phases. And beware the PSR-3 cardinal sin while you are here: never interpolate user data into the message for machine consumption — a logger that receives 'User 42 failed at 10:03' as prose is a query waiting to become impossible; the id belongs in context.

Logging Exceptions So Reports Are Actionable (Without Leaking Secrets)

An exception log line is the artifact incident response actually starts from, and the default — error_log($e) — produces Object of class RuntimeException could not be converted to string. The report that resolves incidents captures five things: class, message, origin, cause chain, and correlation:

function logThrowable(Logger $log, \Throwable $e, array $context = []): void
{
    $log->error($e->getMessage(), $context + [
        'exception'  => $e::class,                       // the taxonomy from custom exceptions
        'code'       => $e->getCode(),
        'file'       => $e->getFile(),
        'line'       => $e->getLine(),
        'trace'      => $e->getTraceAsString(),
        'previous'   => $e->getPrevious()?->getMessage(),
        'request_id' => $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(6)),
    ]);
}

Each field answers a question an responder will ask. $e::class tells which domain category failed — which is why Custom Exception Hierarchies paid off: App\Checkout\PaymentDeclinedException routes to the payments dashboard by name. file:line locates the throw, not the catch. previous surfaces the chained root cause (ValueError from the HTTP client beneath the domain wrapper) without walking the whole chain. The correlation id — read from a trusted request header or generated per request and attached to every record that request produces — is what stitches this exception to the request's other lines and to the downstream service's logs; generate it once at bootstrap, stash it in the logger's processor, and it appears on every record without threading it through call sites.

Trace handling deserves a specific policy. getTraceAsString() includes argument snippets — and arguments include passwords, tokens, and card data. Production defaults in most serious teams are: log the trace for Error-rooted bugs (engine failures, where arguments are usually safe scalars), log only file:line plus the top frames for exceptions that may carry request data, and let a debug-mode flag widen it. The same reasoning governs redaction: strip or hash Authorization headers, cookies, password, card_number, and whatever your domain adds, at the logging boundary — never log the whole request body speculatively. Remember too that exception messages can contain PII too ("User ada@example.com not found"); the constructor-composed messages from the custom-exceptions topic help, because you controlled exactly what went in.

Finally, the destination question: PHP's error_log for engine diagnostics, Monolog channels for application records, and never echo/print_r/var_dump left in a request path — those go to the response, which is both a data leak and useless to responders. Log rotation, retention, and access control are platform concerns, but one ownership rule belongs in code review: every ->log( call ships the fields the on-call runbook will grep, and nothing that would embarrass you on a public incident report. That concludes the error-handling phase — the roadmap now moves from failures back to ordinary I/O with File Operations.

The last mile is alerting, and it is where logging earns its budget. An alert rule firing on raw volume pages you for a deploy spike; one firing on exception-class rate — fifty PaymentDeclinedException in five minutes — pages you for the thing users feel. Build those rules on the taxonomy and fields the last two chapters established, and the on-call runbook shrinks accordingly: the alert names the class, the dashboard groups by the context key, and the trace with its correlation id leads straight to the failing request. Logging done this way is not verbose debugging — it is the product's nervous system, and its quality is measurable in mean time to resolution, which is the number a postmortem will ask for first.

Quiz

1. What is the key difference in coverage between a set_error_handler converter and PHP's log_errors mechanism?

Question 1 options

2. What does error_log($msg, 3, '/var/log/imports/run.log') do?

Question 2 options

3. In Monolog, what do channels and handlers each control?

Question 3 options

4. Why should getTraceAsString() be logged with a policy rather than by default in production?

Question 4 options

Flashcards

Question

PHP's engine-level logging controls, and the coverage only they provide

Answer

log_errors = On routes engine diagnostics to the error_log destination (file path, syslog, /dev/stderr in containers); error_reporting filters severities; display_errors Off keeps diagnostics off responses. Uniquely, this layer records fatal E_ERROR/E_PARSE messages that no userland handler ever sees.

Question

The four destination types of the error_log() function

Answer

Type 0 → the error_log ini destination (the workhorse); type 1 → email (blocking, flood-prone — avoid for alerts); type 3 → append to a specified file (separate audit trail, needs rotation); type 4 → SAPI-specific sink such as stderr.

Question

Monolog's three core concepts

Answer

Levels: the shared severity scale (Debug…Emergency, aligned with E_* numbers) with a per-handler minimum that drops records at the source. Channels: origin namespaces (payments, php) for routing and alerting. Handlers: shipping targets (file, stderr, syslog) with their own level and formatter — one record fans out to several.

Question

Structured logging discipline: context vs interpolation, and the formatter's role

Answer

Stable message for humans; snake_case context array for everything you'll filter on — it becomes queryable JSON via JsonFormatter (order_id:512 is a filter, not a regex). Keys never rename: renaming one breaks saved dashboards. Build the logger once in bootstrap and inject it — no static Logger::get() from business logic.

Question

The five elements of an actionable exception report, plus its redaction policy

Answer

Class (routes via your exception taxonomy), message, file:line (the throw, not the catch), previous message (chained root cause), and a request correlation id tying the record to the request's other lines. Policy: trace strings embed argument values — full traces for engine Errors, top frames for data-bearing exceptions, redact auth/PII at the logging boundary, never log whole request bodies.

Revision Notes

Key Takeaways

  • 1. Engine-level log_errors is the coverage floor — it records fatal errors no handler can intercept; display_errors Off keeps diagnostics off responses
  • 2. error_log() type-selects its destination: ini stream (0), file (3), email (1), SAPI (4); containers route everything to stderr
  • 3. Monolog's levels drop at the source, channels namespace by origin, and handlers fan out with per-target formatters — JsonFormatter turns context into queryable fields
  • 4. An actionable exception report carries class, message, file:line, previous, and a per-request correlation id — the taxonomy from custom exceptions makes routing work
  • 5. Traces leak arguments; redact secrets and PII at the logging boundary, keep trace policy per exception kind, and never ship debug output to the response

Interview Tips

  • • "How do you log errors in production PHP?" — the two-layer answer: log_errors for engine-level coverage including fatals, plus a structured logger (Monolog/PSR-3) for application records with levels, channels, and JSON formatting
  • • "What makes a good exception log line?" — class, message, file:line of the throw, previous cause, and a correlation id; note $e::class routes via your custom hierarchy and getTraceAsString needs a redaction policy
  • • "How would you correlate logs across services?" — one request id generated at bootstrap, attached via a logger processor to every record, propagated to downstream calls as a header; responders can then join the traces
  • • "Console.log-style habits in PHP — what's the review response?" — echo/var_dump goes to the response (leak + useless to ops); error_log type 0 goes to the configured stream; context arrays plus JsonFormatter make records queryable instead of grepped

Cheat Sheet

Logging Errors for Production

Engine layer (covers fatals no handler sees):

log_errors = On
error_log = /var/log/php/app-error.log   ; or syslog / /dev/stderr in containers
display_errors = Off
error_reporting = E_ALL

error_log() destinations: error_log($m) → ini destination; ,3,$path → file; ,1,$email → email (avoid); ,4 → SAPI/stderr.

Monolog skeleton:

$ship = (new StreamHandler('/var/log/app.log', Level::Warning))
    ->setFormatter(new JsonFormatter());
$log = new Logger('payments', [$ship]);   // channel = origin

$log->error('Charge declined', ['order_id' => 512, 'gateway' => 'stripe']);
// stable snake_case keys; context becomes queryable JSON fields

Inject the logger; never static calls from business logic.

Exception report:

$log->error($e->getMessage(), [
    'exception'  => $e::class,
    'file'       => $e->getFile(), 'line' => $e->getLine(),
    'trace'      => $e->getTraceAsString(),   // policy-guarded
    'previous'   => $e->getPrevious()?->getMessage(),
    'request_id' => $requestId,               // one per request, all records
]);

Redaction: traces embed arguments (secrets included) — full trace for Errors, top frames for data-bearing exceptions; strip auth/PII at the logging boundary; never echo/var_dump in a request path.

Continue Learning

Before You Start

Related Reading