The Advantage of Fixing All PHP Warnings
I have spent a ungodly amount of my life reading other people’s PHP warnings. Scanning codebases for a living does that to you. And after a few million lines, I’ve come to a conclusion that sounds boring until you think about what it actually means: a warning is not decoration. It’s not the code equivalent of a yellow check engine light that’s been on since the car was new and clearly means nothing. A warning is PHP, mid-request, telling you that it just had to take a detour to not answer it. Ignore it, and you don’t just keep the mess: you keep paying for the detour, on every request, forever.
First, let’s be precise about scope, because PHP’s error levels are a genuinely confusing family tree: EALL absorbed ESTRICT in 5.4, then along came E_DEPRECATED: everyone’s mental model is still catching up. So, this post is about warnings, that is E_WARNING specifically. Not deprecation notices, where this will break in the next major version, so please prepare your code, not TypeError/Error, since those already stop you, no persuasion needed here. Warnings are the interesting, dangerous middle ground: annoying enough that everyone wants them gone, harmless-looking enough that gone usually means hidden under the rug rather than fixed.
The cost of handling a warning, instead of fixing it
Projects often miss the actual cost of warnings: raising a warning is not free, even the ones you never look at. Under the hood it goes through zend_error(), and that function has real work to do: the manual is refreshingly blunt about how much control you have over it. In a word: yes, this runs code, every time.
- The warning message gets built. File, line, a
sprintf-flavoured string with your variable’s sins spelled out: all needed even if nobody’s reading the log. - Your handler gets called. Every. Single. Time. If you’ve wired up
set_error_handler()directly, via a framework, or because your APM vendor’s SDK quietly did it for you: that’s a real function call per occurrence, not per bug. - Backtraces are not a courtesy, they’re billed. A lot of logging/monitoring integrations grab a full stack trace on every warning so they can group and report it nicely.
debug_backtrace()walks the entire call stack and allocates a frame for each level. Do that 50,000 times in a loop and you’ve built 50,000 stack traces to tell you the same one thing. - Logging is I/O, and I/O is never free.
error_log, syslog, a network call to your error tracker: they are blocking, they are multiplied by every occurrence, every request, every box in the fleet. This is, incidentally, a wonderful way to accidentally DDoS your own logging pipeline. Ask me how I know. Actually don’t, it’s a long story involving a client, a Black Friday, and a very confused on-call engineer. @doesn’t make any of this go away. The error control operator, aka no scream operator, hides the message, not the work. PHP still executes the slow fallback path first, then throws the report in the bin. You paid full price and kept the receipt out of sight.
None of this matters once. It matters when the same warning fires 50,000 times inside a loop, or once per request across a fleet handling a few million requests a day. Logging the symptom means paying that cost on a subscription. Fixing the cause pays it once, then never again. Which, if you enjoy irony, is the one piece of technical debt advice that’s also literally a performance optimization.
Warnings that are secretly a performance tip
Below are common E_WARNINGs in modern PHP 8.x where fixing them isn’t just tidiness: it moves execution from a defensive check-everything path onto the fast one PHP actually optimizes for. Yes, the fix often looks like more code, like an isset(), a cast, a guard clause, a ??= operator. But the the joke’s on the one not writing them: the lazy version is the one doing more work at runtime. Here are some examples.
1. Undefined array key / undefined variable
<?php // Warning: Undefined array key "user_id" $id = $data['user_id']; ?>
A hash-table miss still has to fall all the way through the warning subsystem before handing you null. Walk a large payload like this and you’re paying the format-and-dispatch tax thousands of times over. Think that finding the key in the list means the execution can stop searching. On the other hand, not finding it means you’ll have to check everything before failing. So, an isset() or ??resolves the key in one cheap opcode and never wakes up zend_error() at all. Manual: array key existence.
2. Accessing an array offset on null/false
This one always draws a big laugh. Then, comes the epiphany that it is usually the fallout of an unchecked function result: $row = $stmt->fetch(); $row['col'] when fetch()handed back false. And, most of the time, it is considered too safe to check. In fact, it belongs to the same family as the first section. Guard the fetch once; don’t let every subsequent offset access rediscover the same problem.
<?php
// admin always exists
$stmt = $db->query("SELECT name FROM users WHERE login='admin'");
$row = $stmt->fetch();
// Warning: Trying to access array offset on null
echo $row['name'];
?>
3. Illegal / uninitialized string offset
<?php // Warning: Uninitialized string offset 3 $char = $str[3]; ?>
Reading past a string’s length triggers the same warn-then-return-empty routine. A strlen()bounds check before the loop is one comparison; letting every out-of-range read hit the warning path is not. And all that applies to clever manipulations with string offsets, so a $position + 3 also needs to be checked.
String manipulations may be handed to regex, and avoid juggling with offsets. substr() also allows to go outside the string interval, returning empty strings without errors.
4. count()/sizeof() on something that isn’t Countable
<?php // Warning: count(): Argument #1 ($value) must be of type Countable|array count($maybeArray); ?>
count() on a genuine array or Iterable is about reading the hash table’s size directly: it is as cheap as PHP gets. Feed it something else and it has to check, fail, and dispatch a warning before falling back, on every single call. Keep the argument honestly array-shaped with is_countable() and it stays on the fast path. If not countable, well, you already know the answer.
5. Array to string conversion
<?php
// Warning: Array to string conversion
$log = "Items: " . $item;
// Warning: Array to string conversion, for each error
$log = "The list: " . implode(', ', $items);
?>
This warning almost always means someone tried to implode() a multi-dimensional array. In a loop, every concatenation pays for the type check, the warning, and building the utterly useless literal string "Array". implode() is a dedicated, optimized function with no per-iteration detour through the error subsystem.
implode() is actually a double layer trap, as the loop is not visible, but hidden inside the function call itself. And there are no native protection, except may be the type trick:
<?php
function myLog(string ...$items) {
$log = "Items: " . $items;
}
// otherwise
$str = implode(', ', array_map(
fn($v) => is_array($v) ? '[' . implode(', ', $v) . ']' : (string) $v,
$array
));
```
Note that static analysis and another preceding loop that checks or filter the array are also valid defense against that situation.
### 6. Non-numeric value in arithmetic
```php
// Warning: A non-numeric value encountered
$total = $price + $quantity; // one of these is "12 units"
```
Arithmetic on a string makes PHP run its own[numeric-string scanner](https://www.php.net/manual/en/language.types.numeric.php), find the trailing garbage, warn, then coerce. This happens every single time the line executes. This got a whole [RFC to itself](https://wiki.php.net/rfc/saner-string-to-number-comparisons) in PHP 8, which tells you it wasn't just me finding it annoying. Cast once at the boundary, `(int) $quantity` the moment it enters your system, and every later arithmetic op in a loop hits the native int/float add opcode instead of re-parsing a string on repeat.
In this case, PHP does not offer a validation system. Either use a cast `(int)`, which always works, but does not tell you what went wrong, and clamp the result to the maximum size of an integer. `is_numeric()` only checks the type of the value, not its content.
### 7. Division by zero
```php
// Warning: Division by zero
$rate = $count / $total; // $total might be 0
```
`if ($total !== 0)` up front is one easy comparison: it is cheaper, categorically, than letting the division fire, warn, and hand you `INF`/`NAN`. This is the rare case where the safe code and the fast code are the exact same expression.
### 8. `include`/`require` failing to find the file
```php
// Warning: include(): Failed opening '...' for inclusion
include $maybeWrongPath;
```
This one surprises people. Before PHP admits defeat, it `stat()`s the path across every directory in [`include_path`](https://www.php.net/manual/en/ini.core.php#ini.include-path). On a network filesystem, or inside certain container setups, that's real wall-clock latency, not just wasted CPU cycles, for a file that was never going to be found. An explicit correct path, or better, autoloading, which resolves through a class map instead of scanning directories, skips the stat storm entirely.
Admittedly, rare are the one that are still using `include()` to do JIT code inclusion, without the `autoload()`. But the autoload may also have to search these path, so it is recommended to make these files easy to find, as much as possible.
### 9. Streams failing to open
```php
// Warning: file_get_contents(https://...): failed to open stream
$content = file_get_contents(https://www.exakat.io/);
```
For network wrappers, the warning only fires after the connection attempt. This means you can sit through a full connection timeout just to arrive at the failure you already suspected. Checking reachability ahead of time, or using a client with an explicit, short timeout, means you fail fast instead of failing slow-and-eventually. See [`file_get_contents()`](https://www.php.net/manual/en/function.file-get-contents.php) and, if you want to do this properly, a real HTTP client with timeout support instead of the streams wrapper.
### 10. Invalid encoding in escaping functions
```php
// Warning: htmlspecialchars(): Argument #1 ($string) must be valid UTF-8
```
When PHP can't confirm the encoding, [`htmlspecialchars()`](https://www.php.net/manual/en/function.htmlspecialchars.php) falls back to a defensive byte-by-byte validation pass before it can attempt the actual fast conversion. Guarantee UTF-8 at the input boundary once, and every escaping call downstream takes the direct route instead of re-litigating your encoding on every single request. For that, PHP offers [mb_check_encoding](https://www.php.net/manual/en/function.mb-check-encoding.php), or, with a detour, `if (preg_match('//u', $string)) {}`, or also `json_encode()`, as this last one needs a valid unicode value, and returns false in case of error.
## Fix them, and it will just come to you
A warning is PHP telling you it had to take the defensive branch to give you an answer. Treat it as noise to suppress or quietly log, and you keep paying for that defensive branch. That, plus pay for your own logging pipeline's overhead, on every occurrence, forever. I always recommend to fix the actual condition, and the cost collapses to zero the moment the fix ships.
I'll also admit there's a selfish reason I like this framing: it's the one argument for cleaning up warnings that doesn't require anyone to believe in code quality as a virtue. You don't have to care about craftsmanship. You just have to care about milliseconds, and it turns out milliseconds were on the side of clean code the whole time. The [engine warnings RFC](https://wiki.php.net/rfc/engine_warnings) that reshuffled a bunch of these levels between PHP 7 and 8 makes for surprisingly good reading if you want the reasons behind which of today's warnings used to be silent notices, or worse, silent nothings. PHP's error levels have a history; it's just one nobody tells you unless you go looking, or unless you're the kind of person who scans other people's warnings for a living.
Zero warnings isn't a tidiness goal you get to feel virtuous about. It's a performance budget, handed to you for free, by an engine that's been trying to tell you this the entire time. And, as usual, along the way, there are some real bugs to confront and fix.
?>

