error_reporting, display_errors, and log_errors

Configure PHP's error_reporting level and the display_errors/log_errors/error_log directives correctly for development versus production, and return safe error responses that never leak sensitive detail to end users.

PHP's error output is controlled by a small set of php.ini directives (and their runtime equivalents) that are frequently misconfigured in exactly the way that turns a minor bug into a security incident: full stack traces, file paths, and database credentials printed straight into a production HTTP response. This lesson covers the directives themselves, why development and production need opposite settings, and how to build safe, generic error responses on top of proper logging.

error_reporting: which severities are reported at all

error_reporting is a bitmask that determines which error severities PHP considers 'reportable' in the first place — it does not, by itself, decide whether they're shown on screen or written to a log; display_errors and log_errors control that separately. The constant E_ALL reports everything, including deprecations and strict-standards notices; a narrower mask like E_ALL & ~E_DEPRECATED reports everything except deprecation notices. It can be set in php.ini, overridden per-request with ini_set('error_reporting', ...), or set directly in code with the error_reporting() function, which also returns the previous level (useful for temporarily suppressing a specific known-noisy call and restoring the old level afterward).

In development, the strong recommendation is error_reporting(E_ALL) — see everything, including deprecations, because deprecations are early warnings about code that will break on a future PHP upgrade, and it is far cheaper to fix them one at a time during development than to discover a dozen of them at once during a production upgrade.

display_errors: never true in production

display_errors controls whether reported errors are written directly into the HTTP response body (or CLI stdout/stderr). In development this is invaluable — the error appears right where you're looking, in the browser or terminal, with a file and line number. In production it is a serious information-disclosure risk: an uncaught TypeError can print the full absolute filesystem path of your source tree, function arguments (which may include user passwords or tokens mid-processing), and enough of your internal structure to help an attacker map the application. Production php.ini should always set display_errors = Off (and, ideally, the related html_errors = Off), no exceptions.

A common, safer alternative for genuinely wanting to see the message during a production incident is display_errors = stderr, which sends output to the web server's error stream (visible to operators via server logs) rather than into the response body seen by the end user — but log_errors combined with a proper error_log destination is the more standard, deliberate mechanism for this, covered next.

log_errors and error_log: where errors actually go

log_errors (independent of display_errors) controls whether PHP writes reported errors to a log destination instead of, or in addition to, the response. This should always be On, in both development and production — it is the mechanism that lets you find out about a problem without it ever having to be shown to a user. The error_log directive names that destination: a file path (error_log = /var/log/php/error.log), or the special value syslog to send entries to the system logger, which is common in containerized deployments where a sidecar or log-collector agent tails syslog rather than an application-specific file.

The combination that every production PHP deployment should use is display_errors = Off, log_errors = On, with error_log pointed at a real, monitored destination (and error_reporting still set to E_ALL, or close to it, so nothing important is silently dropped from the log even though nothing is shown to the user).

• Development: error_reporting(E_ALL), display_errors = On, log_errors = On • Production: error_reporting(E_ALL), display_errors = Off, log_errors = On, error_log set to a real file or syslog • log_errors should be On in every environment -- it's the one directive that's never a security tradeoff

Safe error responses

Beyond the ini directives, the application itself should never let an uncaught Throwable's message or stack trace reach the client. The standard pattern is a top-level handler — set_exception_handler() for anything that escapes every try/catch, and often a framework-level exception listener for HTTP responses — that logs the full Throwable (message, trace, previous chain) somewhere safe, generates a correlation/request ID, and returns a deliberately generic response to the caller: a fixed 'Something went wrong, reference: 8f2c1a90' message and a 500 status, never the raw exception text.

This separation — rich detail in the log, minimal detail in the response — is what lets developers debug effectively (by looking up the correlation ID in the log) while giving an attacker nothing to work with from the response itself. It applies equally to JSON APIs (never serialize an exception object or its message directly into an error response body) and traditional HTML error pages.

Development vs. production configuration

Runtime ini_set() calls only take effect for settings marked PHP_INI_ALL or PHP_INI_USER in the manual -- some directives can only be set in php.ini or a vhost config, not at runtime. Setting display_errors at runtime after a fatal error has already begun to be handled is sometimes too late -- prefer setting it in php.ini/php-fpm pool config for guarantees, and treat ini_set() as a supplement for app-specific overrides.

; php.ini -- development
error_reporting = E_ALL
display_errors = On
display_startup_errors = On
log_errors = On
error_log = /var/log/php/dev-error.log

; php.ini -- production
error_reporting = E_ALL
display_errors = Off
display_startup_errors = Off
log_errors = On
error_log = /var/log/php/error.log
; or: error_log = syslog

// Runtime equivalents inside a bootstrap file (useful when ini can't be edited directly)
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/error.log');
error_reporting(E_ALL);

Code Examples

In a local development environment, showing errors directly is desirable -- this configuration makes even a minor issue like an undefined variable access immediately visible with a file and line number, right where you're working. Expected result: Warning: Undefined variable $undefinedVariable in ... on line 6

The full exception -- class, message, file, line, and stack trace -- is written to the log with a correlation ID, but the HTTP response the client actually receives contains only a generic message and that same ID. An operator can grep the log for the ID to see exactly what happened; the client (or an attacker probing the API) learns nothing about internals. Expected result: HTTP 500, body: {"error":"Internal server error","reference":"3f9a1c2e8b7d4f10"} http_response_code(500);... echo json_encode(['error' => 'Internal server error', 'reference' => $correlationId]);

error_reporting() returns the previous bitmask, which lets you scope a suppression narrowly and restore it deterministically via finally -- far safer than a blanket @ operator (error suppression operator) or a permanent ini change, because it only affects the one call and always reverts even if that call throws.

Common mistake: Using the @ operator (e.g. @legacyLibraryCall()) instead would silently discard the error entirely -- it would not be logged even with log_errors On for most error types, making it much harder to notice a real regression hiding behind the suppressed call.

<?php
// Top of a development bootstrap file
error_reporting(E_ALL);
ini_set('display_errors', '1');

echo $undefinedVariable; // triggers a Warning: Undefined variable, shown on screen
<?php
// Top of a production bootstrap file
error_reporting(E_ALL);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
ini_set('error_log', '/var/log/php/app-error.log');

set_exception_handler(function (Throwable $e): void {
    $correlationId = bin2hex(random_bytes(8));
    error_log(sprintf(
        '[%s] Uncaught %s: %s in %s:%d\n%s',
        $correlationId,
        get_class($e),
        $e->getMessage(),
        $e->getFile(),
        $e->getLine(),
        $e->getTraceAsString()
    ));

    http_response_code(500);
    header('Content-Type: application/json');
    echo json_encode([
        'error' => 'Internal server error',
        'reference' => $correlationId,
    ]);
});

throw new RuntimeException('Payment gateway timeout after 30s, order #4471');
<?php
function withVerboseLoggingSuppressed(callable $fn): mixed
{
    $previousLevel = error_reporting(E_ALL & ~E_DEPRECATED & ~E_NOTICE);
    try {
        return $fn();
    } finally {
        error_reporting($previousLevel); // always restore, even if $fn() throws
    }
}

// Suppress noisy deprecation warnings from a legacy library call, without
// silencing them application-wide for the rest of the request.
$result = withVerboseLoggingSuppressed(fn() => legacyLibraryCall());

Important Rules

display_errors must be Off in production, with no exceptions -- it is a direct information-disclosure risk.

log_errors should be On everywhere, always -- it costs nothing and is the only way to learn about problems that aren't shown to users.

error_reporting(E_ALL) in development surfaces deprecations early, when they're cheap to fix, rather than as a pile of upgrade-day surprises.

Never serialize a raw exception message or trace into a client-facing HTTP response -- return a generic message plus a correlation ID, and log the detail server-side.

Avoid the @ error-suppression operator for anything you actually care about -- it discards the error rather than logging it, unlike a scoped error_reporting() change.

Where this shows up

Security scanners and bug bounty reports routinely flag PHP applications where display_errors was accidentally left On in production -- it's one of the most common, embarrassing, and easily-avoided misconfigurations in the PHP ecosystem, and frameworks like Laravel and Symfony ship with an explicit APP_ENV/APP_DEBUG (or similar) flag specifically to make the development/production distinction a single, obvious environment variable rather than something buried in php.ini.

// production php.ini display_errors = On ; "just for now, to debug this one issue"

// production php.ini display_errors = Off log_errors = On error_log = /var/log/php/error.log // debug via: tail -f /var/log/php/error.log

Temporarily flipping display_errors on in production -- even briefly, even 'just to debug one issue' -- exposes every request that hits an error (from any user, not just you) to full stack traces and file paths until it's flipped back. Tail the log instead; it gives the same detail without exposing anything to end users.

[multiple-choice] In a production environment, which combination of settings is correct? A. display_errors = On, log_errors = Off B. display_errors = Off, log_errors = On C. display_errors = Off, log_errors = Off D. display_errors = On, log_errors = On

B. display_errors = Off, log_errors = On — Production should never display errors to end users (information disclosure risk) but should always log them (log_errors = On) so operators can diagnose issues via the error_log destination without exposing anything to the client.

[true-false] error_reporting() alone determines whether an error is shown on screen. A. True B. False

B. False — False. error_reporting() only determines which severities are considered reportable at all; display_errors separately controls whether reported errors are written into the response, and log_errors controls whether they're written to a log. All three work together.

Write a small bootstrap snippet that configures PHP for a production environment: report all errors, never display them, log them to /var/log/php/app.log, and install a set_exception_handler that logs the exception's class and message and returns a generic JSON 500 response with a random correlation ID.

error_reporting(E_ALL); ini_set('display_errors', '0'); ini_set('log_errors', '1'); ini_set('error_log', '/var/log/php/app.log'); set_exception_handler(function (Throwable $e) { $id = bin2hex(random_bytes(8)); error_log("[$id] " . get_class($e) . ': ' . $e->getMessage()); http_response_code(500); header('Content-Type: application/json'); echo json_encode(['error' => 'Internal server error', 'reference' => $id]); });

Combine error_reporting(E_ALL), ini_set('display_errors','0'), ini_set('log_errors','1'), ini_set('error_log', ...), and set_exception_handler().