skip to content

In PHP 8, what does the @ error-control operator suppress, and how does it interact with fatal errors and custom error handlers?

level: middleimportance: should knowfreq 36%

answer

  1. hides diagnostics, not exceptions
  2. 8.0: fatal errors are shown again
  3. a custom handler still runs
  4. error_reporting() returns 4437 under @
  5. error_get_last() keeps the message

basics

~20 s

@ hides the warnings, notices and deprecations one expression raises. Since PHP 8.0 it no longer hides fatal errors; it never stops exceptions; and a custom error handler still runs, seeing error_reporting() return 4437 rather than 0.

solid answer

~40 s

Prefixing an expression with `@` — `@file_get_contents($path)`, `@$cache[$key]` — suppresses the diagnostics that expression raises, so PHP's standard handler neither displays nor logs them. It is not a `try`/`catch`: exceptions such as `TypeError` or `ValueError` still propagate. PHP 8.0 changed two things. `@` no longer silences fatal errors — `E_ERROR`, `E_CORE_ERROR`, `E_COMPILE_ERROR`, `E_USER_ERROR`, `E_RECOVERABLE_ERROR` and `E_PARSE` — so a silenced call can no longer end the script with no explanation. And although a handler registered with `set_error_handler()` was always called for suppressed errors, `error_reporting()` inside it used to return `0`; it now returns those fatal levels OR-ed together, 4437. Handlers that tested `error_reporting() === 0` broke in 8.0; the robust test is `!(error_reporting() & $errno)`. The suppressed message stays readable through `error_get_last()`.

code

php · 8 lines
php
<?php
$lock = '/tmp/booking-sync.lock';

if (@unlink($lock) === false) {
    $error = error_get_last();
    // e.g. "unlink(/tmp/booking-sync.lock): No such file or directory"
    error_log('Lock not removed: ' . ($error['message'] ?? 'unknown'));
}

go deeper

for a junior

Recall that @ hides warnings and notices from one expression, and that it does not stop exceptions.

for a middle

Explain the PHP 8.0 changes: fatal errors are no longer silenced, and error_reporting() returns 4437 inside a handler, so handlers must test error_reporting() & $errno.

for a senior

Audit legacy code for @ hiding real failures and for handlers still testing error_reporting() === 0, and replace @ with ??, explicit checks or scoped handlers.

for a principal

Set a policy that treats @ as an exception needing justification in review, since it silently removes signal from production logs.

## What @ does `@` is PHP's **error-control operator**. Placed before an expression, it lowers the reporting level while that expression is evaluated, so the diagnostics it raises — warnings, notices, deprecations — are not displayed or logged by PHP's standard handler. It works on anything that produces a value: - a function call: `@unlink($tmpFile)`; - an array read: `@$cache[$key]`; - an `include`: `@include $optionalFile`. It cannot be applied to statements such as `if`, `foreach` or a function definition. The message is not destroyed. `error_get_last()` still returns an array with `type`, `message`, `file` and `line` for the last diagnostic, so code can call a function under `@`, check its return value, and read the reason when it failed. ## What @ never did `@` affects **diagnostics**, not **exceptions**. Anything thrown — a `TypeError` from a wrong argument type, a `ValueError` from an invalid argument value, a `JsonException`, your own exceptions — propagates through `@` untouched. Since PHP 8.0 turned many former warnings into `TypeError` and `ValueError`, code that relied on `@` to hide a bad call now sees an exception instead. ## What changed in PHP 8.0 | Behaviour | Before 8.0 | Since 8.0 | |---|---|---| | Fatal errors under `@` | Silenced — the script could die with no message | Reported as usual | | `error_reporting()` inside a user handler, for an `@`-suppressed error | `0` | `4437` (the fatal-error mask) | | User error handler called for suppressed errors | Yes | Yes | The fatal levels that `@` no longer hides are `E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE`, whose bits add up to **4437**. The PHP manual warns that, before 8.0, prefixing `@` to a call of a mistyped or unavailable function could terminate the script "with no indication as to why" — the classic silent blank page. ## The handler check that broke Error handlers written for PHP 7 often began like this: ```php if (error_reporting() === 0) { return false; // @-suppressed } ``` Under PHP 8, `error_reporting()` is 4437 during an `@` expression, so this test never matches, and a handler that converts diagnostics into `ErrorException` starts throwing for code that was deliberately silenced. The robust form works on every version and also respects the configured level: ```php if (!(error_reporting() & $errno)) { return false; } ``` A warning's bit (`E_WARNING`, 2) is not part of 4437, so the expression is zero and the handler steps aside. ## When @ is acceptable - File-system calls that can race with other processes, where you check the return value anyway: `if (@unlink($lock) === false) { ... error_get_last() ... }`. - Legacy APIs with no side-effect-free way to test first. Usually there is a clearer alternative: 1. `$cache[$key] ?? null` instead of `@$cache[$key]` for a possibly missing key; 2. `is_file()` or `is_readable()` before opening a file when a race does not matter; 3. a scoped `set_error_handler()` that converts the one call's warning into an exception. ## Costs to keep in mind `@` silences **everything** in the expression, including diagnostics from nested calls and argument expressions you did not mean to hide, such as an undefined variable passed as an argument. It also makes debugging slower, because the log no longer records what went wrong. Reserve it for the narrow cases above and always pair it with a return-value check. ## Reading the failure after @ `error_get_last()` returns the **most recent** diagnostic of the whole request, not necessarily the one from the call you just made. If the call succeeded, the array can still describe an older, unrelated warning. Two habits keep it reliable: 1. Call `error_clear_last()` immediately before the silenced call, so a stale entry cannot be mistaken for the current failure. 2. Read `error_get_last()` only after the return value says the call failed, and read it at once — the manual notes that it changes on each error. The array holds `type`, `message`, `file` and `line`; for internal functions the message starts with the function name, which makes log lines self-explanatory.

  • Does @ stop a TypeError thrown by an internal function in PHP 8?
    No. `@` only lowers the reporting level for diagnostics such as warnings and notices. A `TypeError` or `ValueError` is an exception and propagates through `@` exactly as it would without it; only a `catch` block handles it.
  • Why does 4437 appear inside error handlers under PHP 8?
    It is `E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR | E_USER_ERROR | E_RECOVERABLE_ERROR | E_PARSE`, the fatal levels that `@` no longer suppresses. While an `@` expression runs, PHP 8 sets the reporting level to that mask instead of 0, which is what `error_reporting()` returns inside a user handler.

saying these in an interview costs you the question

  • @ turns a thrown exception into a silent false return
  • @ still hides fatal errors, so a silenced call can die invisibly
  • A custom error handler is not called for @-suppressed errors
  • error_reporting() === 0 still detects @ inside a PHP 8 handler
  • The suppressed message is lost and cannot be read afterwards