Discover PHP 8.6 Through Its New Error MessagesDiscover PHP 8.6 Through Its New Error Messages

A guided tour of what will bark at your code when you move, and how you should care now

PHP 8.6 lands in November 2025. You have a few weeks left to run your test suite on the release candidate, spot the deprecation notices, and fix them while nobody watches. Migrating now costs you an afternoon, and even if partially applied, it might improve your code anyway.

With the introduction of new features come error messages: after all, without experience yet, you might very well trespass the extend of the features. So, let’s see these new possible error messages, so you can react faster when you encounter them. If you ever do.

No more magic numbers: use the constants

PHP has always shipped named constants for function arguments. It has also always accepted raw integers instead, without a single complaint. That free ride stops in PHP 8.6 for a growing list of functions.

Take pathinfo(). Before 8.6, you could combine PATHINFO_DIRNAME | PATHINFO_EXTENSION and get… one of them. Which one? The answer depended on internal flag values abd black magic, not on logic. Now PHP throws a ValueError:

pathinfo(): Argument #2 ($flags) must be only one of the PATHINFO_* constants

The same treatment applies to array_filter(). Passing 42 as the mode used to default silently to ARRAY_FILTER_USE_VALUE. Now it rages at you:

arrayfilter(): Argument #3 ($mode) must be one of ARRAY_FILTER_USE_VALUE, ARRAY_FILTER_USE_KEY, or ARRAY_FILTER_USE_BOTH

And scandir() follows the same path, refusing invented sorting orders:

scandir(): Argument #2 ($sortingorder) must be one of the SCANDIR_SORT_ASCENDING, SCANDIR_SORT_DESCENDING, or SCANDIR_SORT_NONE constants

This trend deserves attention. PHP keeps replacing silently-accepted literal values with strict constant checks. Each time, the old behavior was to pick a default and hope nobody notices. The new behavior tells you exactly what went wrong and what to use instead. If you grep your codebase for raw integers passed to these functions, fix them now. The constants existed all along; PHP just stopped being polite about ignoring them.

Partial application arrives with guardrails

PHP 8.6 introduces partial function application, one of the most anticipated features in years. You can write add(1, ?) and get a Closure that remembers the 1 and waits for the rest. It is elegant, concise, and is also guarded by two brand-new error messages.

The first fires when you supply too many arguments during the partial call, just like any other function call, but with a reminder that you are using PFA:

Partial application of add() expects at most 2 arguments, 4 given

<?php
function add($a, $b) {
    return $a + $b;
}

$partial = add(1, 2, 3, ?); // too many slots
?>

And a partial application cannot sneak extra arguments past the function signature. The ? placeholder reserves a slot for later; it does not magically create new parameters. What would they be used for? The body of the function is not changed by PFA, so they would be lost in translation.

The second fires when you supply too few:

Partial application of add() expects exactly 3 arguments, 2 given

<?php
function add($a, $b, $c) {
    return $a + $b + $c;
}

$partial = add(1, ?); // one slot short
?>

Every required parameter needs either a value or a ? placeholder. If you want to defer all remaining parameters at once, use ... instead: add(1, ...) captures everything still missing. PHP checks the argument count at partial-creation time, not at call time. Better to fail early than to hand you a Closure that would blow up the moment you try to use it.

Constructors and destructors get their act cleaned up

Two long-standing PHP oddities finally get the deprecation notice they deserved for a decade. Until now, they were mostly reported by static analysis.

First, the generator constructor. Yes, you could put yield inside __construct(). Yes, it compiled. No, it never actually ran as a generator, because a generator only starts executing when iterated, and nothing iterates a constructor. The object was born with its initialization silently skipped.

Making a constructor a Generator is deprecated

<?php
class X {
    public function __construct() {
        yield 1; // this never runs
    }
}
new X; // empty object, no warning... until 8.6
?>

Who thought this was a good idea? Nobody did. The feature emerged as a side effect of how generators work: any function with yield becomes a generator, and __construct() was no exception. PHP 8.6 recognizes this as an elephpant trap. The same deprecation applies to destructors, by the way. Geez…

Second, returning a value from a constructor. The new keyword always returns the fresh object, regardless of what __construct() hands back. Any return $value inside a constructor was dead code wearing a disguise:

Returning a value from a constructor is deprecated

A bare return; to exit the constructor early remains perfectly fine. Only return $something;triggers the deprecation, because that $something vanishes into the void every single time. And $something could have been as nothing as null.

Returning from finally, a silent killer

Here lies one of the most treacherous patterns in PHP. A return inside a finally block silently replaces whatever the try or catch was about to return. Worse, it swallows any exception that was propagating, without a trace.

Returning from a finally block is deprecated

<?php
function getConfig(): array {
    try {
        return loadConfig(); // may throw
    } finally {
        return []; // exception? what exception?
    }
}
?>

PHP already forbids break, continue, and goto out of a finally block. A throw inside finally at least chains the discarded exception as $previous. But return got a free pass all these years, quietly eating exceptions and return values with no indication anything happened. That era ends now. Move the return out of the finally block, and make the control flow visible.

Named variadics versus internal functions

Internal functions written in C handle their variadic parameters differently from user-land functions. When you spread an associative array into array_merge(), PHP tries to match each string key to a parameter name. Since internal variadic functions only have one name for their catch-all parameter, every other key gets rejected:

Internal function array_merge() does not accept named variadic arguments

<?php
$parts = ['x' => [1], 'y' => [2]];
array_merge(...$parts); // string keys become named arguments
?>

The fix takes one line: wrap the array in array_values() before spreading it. This strips the string keys and turns them into plain numeric indexes, which the variadic parameter happily accepts. If your data comes from a database query or a JSON decode, string keys might lurk where you expected none. Apply array_values() as a habit, just in case.

NAN walks into clamp

PHP 8.6 ships the new clamp() function, which restricts a value to a given range. Simple, useful, and immediately confronted with the eternal troublemaker of floating-point arithmetic: NAN.

clamp(): Argument #2 ($min) must not be NAN

<?php
clamp(5, NAN, 10); // NAN as a lower bound makes no sense
?>

The fundamental problem with NAN is that any comparison involving it returns false. Ask “is 5 greater than NAN?” and the answer is no. Ask “is 5 less than NAN?” and the answer is also no. A clamping function built on comparisons cannot do anything meaningful with a bound that defeats all comparisons.

Note that NAN is technically a float. It passes type checks, it slips through type declarations, and it arrives in your code disguised as a perfectly normal number. The range() function already rejects it with “must be a finite number, NAN provided”. Now clamp() does the same. Validate your floats with is_nan() when they come from untrusted sources.

Note that range() had a similar problem with NAN, and INF for that matter. You could get that nice error message:

<?php
range(0, NAN); // range(): Argument #2 ($end) must be a finite number, NAN provided
?>

So, PHP is able to handle infinite numbers. That should attract attention of mathematicians, I guess.

Virtual properties resist lazy introspection

PHP 8.4 introduced property hooks and lazy objects. PHP 8.6 discovers that these two features do not always get along.

A virtual property, that is declared with only a get hook and no backing storage, has no memory slot for storage. The lazy objects API, specifically ReflectionProperty::setRawValueWithoutLazyInitialization(), tries to write directly to that memory slot. When the slot does not exist, the method has nothing to write to:

Cannot use setRawValueWithoutLazyInitialization() on virtual property Foo::$bar

<?php
class Foo {
    public int $bar {
        get => 5; // virtual: no storage
    }
}

$prop = new ReflectionProperty(new Foo, 'bar');
$prop->setRawValueWithoutLazyInitialization(new Foo, 2);
?>

Only properties with actual backing storage, those whose hooks reference $this->propName, support raw value manipulation. Virtual properties compute their value on the fly and have nothing to bypass. If your lazy-object initialization code touches property hooks, check which properties are virtual before calling the raw setter.

The readonly function reaches end of life

When PHP 8.1 introduced readonly as a property modifier, the team carved out a compatibility exception: a global function literally named readonly() could still be declared and called, because WordPress shipped one. That exception kept the peace for four versions. In PHP 8.6, the moratorium ends:

Calling a function “readonly” is deprecated

<?php
function readonly() {
    return 'not a property modifier';
}

echo readonly(); // deprecated in 8.6
?>

The deprecation notice fires on every call, not on the declaration. Declaring the function still works, for now: it is only a deprecation. And here is the fun part: readonly as a method name remains perfectly legal. You can call $obj->readonly() all day long without a single warning. The deprecation targets only bare function calls, as part of the broader effort to fully reserve readonly as a language keyword.

If you maintain a library that defines a readonly() function, rename it. Pick isReadonly(), pick asReadonly(), pick anything that will not collide with the language in PHP 9.0.

The takeaway

PHP 8.6 cleans house. It replaces silent fallbacks with explicit errors, deprecates patterns that never worked as intended, and sets boundaries around its newest features. None of these changes will surprise you if you run your test suite against the RC today.

Every error message mentioned in this article links to its full documentation at php-errors.readthedocs.io, with code samples, explanations, and migration advice. When PHP hands you a new error message, look it up there. The answer already exists.

Go test your code. November gets closer every day.