PHP 8.6: every feature, before and after
PHP 8.6 is the release that deletes code. Not features, not syntax you rely on — code you wrote only because the language made you: the closure that exists to name an argument once, the constructor whose whole body is four assignments of constants, the foreach that reimplements array_sum because you were not sure it was safe with integers. This article walks through every user-visible change in the release, and each paragraph puts the old approach next to the new one, because that is the only comparison that matters when you are deciding whether to upgrade.
Everything below was measured on PHP 8.6.0RC3, the newest tag that exists — 8.6 is still a release candidate, and there is no final version to pin to. Where a claim is about behaviour, there is a runnable example or a test behind it in the companion repository at michalwolinski/php-8-6-new-features: 117 tests, 272 assertions, plus a directory of seven compile errors you will meet while trying the new features, each one run in its own process by the test suite so the quoted messages cannot rot. Where a claim is about performance, there are two runs of the same benchmark on 8.5 and on 8.6 — including three optimisations from the release notes that did not reproduce, which are reported as they came out.
One thing to keep in mind throughout: this release is not a bag of unrelated additions. Three themes run through it, and the rest of the changes follow from them. The language removes ceremony around callables and immutability. It grows a small layer of value types for time, URIs, watching streams and stream failures. And it takes a long, deliberate pass through names and functions that should not have survived this long, marking twenty-odd of them deprecated with the replacement named in the message.
Partial application changes how you write callbacks
The headline feature is partial function application. A ? in an argument list marks an argument you will supply later; the expression evaluates to a Closure that fills it in. What it replaces is the arrow function you wrote to hand one argument through:
// PHP 8.5
$clean = array_map(fn (string $t): string => strtolower(trim($t)), $titles);
$failures = array_filter($lines, fn (string $l): bool => str_contains($l, 'ERROR'));
usort($names, fn (string $a, string $b): int => strcmp($a, $b));
// PHP 8.6
$clean = array_map(strtolower(?), array_map(trim(?), $titles));
$failures = array_filter($lines, str_contains(?, 'ERROR'));
usort($names, strcmp(?, ?));
The arrow function said "call trim with whatever the caller passes". The placeholder says the same thing without naming a parameter, and the difference compounds inside a pipeline. In the demo repository the slug pipeline is five steps, and the 8.5 version needed a lambda per step, each with its own parameter name and its own types:
// src/Text/Slugger.php
$this->steps = [
trim(...),
strtolower(...),
str_replace('_', '-', ?),
self::normalize(...),
trim(?, '-'),
];
The fourth step is a wrapper rather than an inline preg_replace('/[^a-z0-9-]+/', '-', ?), and it is worth knowing why: preg_replace() returns null when PCRE fails, and a typed pipeline of Closure(string): string would turn that into a TypeError one step later, with no clue which step produced it. A pipeline is only as typed as its weakest step.
Note that the first two entries are f(…), not f(?). That syntax is not part of this feature: a first-class callable has been available since PHP 8.1 and means "this function, as a closure, unchanged". The 8.6 addition is ? — an argument you bind later — and a trailing … that takes everything you did not name. The two are not interchangeable, and the difference is arity rather than style: f(?) takes the arguments it was asked for and ignores the rest, while f(…) passes everything the caller supplies.
// PHP 8.6
$one = trim(?);
$all = trim(...);
$one('--x--', '-'); // '--x--' - the second argument never reaches trim()
$all('--x--', '-'); // 'x' - it does
array_map(trim(?), [' a '], ['-']); // ['a'] - one argument per element, from the first array
That difference matters wherever the caller decides how many arguments to pass: array_map() with several arrays, array_filter() with ARRAY_FILTER_USE_BOTH, a callback handed a key as well as a value, a comparator that receives two. A partial built with ? is the narrow choice, and it is usually the right one — but it is a narrow choice, and reaching for ... because it looks more general quietly changes what the callback receives.
The mechanics are consistent across call shapes. Placeholders are filled positionally; several in one expression are filled in the order they appear; a placeholder may sit after named arguments; instance methods and static methods work the same way as plain functions.
// PHP 8.6
$replace = str_replace(?, ?, 'aaa'); // $replace('a', 'b') === 'bbb'
$padded = str_pad(string: ?, length: 6); // named arguments on either side of a placeholder
$formats = array_map($clock->format(?), ['H:i', 'Y-m-d']); // instance method
$parse = DateTimeImmutable::createFromFormat(?, '2026-01-02'); // static method
$mapped = array_map(?, ...); // a placeholder, then whatever the caller supplies
The comparison with the arrow function is not a style question, and it is where the first real trap sits. A placeholder binds where it appears — which means a placeholder nested inside another call is not composition:
// PHP 8.6
$f = strtolower(trim(?));
// TypeError: strtolower(): Argument #1 ($string) must be of type string, Closure given
Reading strtolower(trim(?)) as a pipeline is natural and wrong. The placeholder belongs to trim, so trim(?) produces a closure, and strtolower is then called immediately with that closure as its argument. Composition still needs two steps: array_map(strtolower(?), array_map(trim(?), $titles)).
The syntax has two hard edges, and both are compile-time errors rather than runtime surprises, which is the kinder of the two ways to learn them. Partial application does not reach new: new ArrayObject(?, 0) fails with Cannot create Closure for new expression. The variadic placeholder must be last: array_map(..., ?) fails with Variadic placeholder must be last. A third limitation is not an error at all: a partial is a closure, not a reference to the function it wraps, so anything that needs the callable name — a string callback, ReflectionFunction over the original — still needs the original.
Performance deserves its own sentence, because the release notes mention a stateless closure cache and it is easy to over-read. When a partial expression has no captured variables, repeated evaluation can hand back a cached Closure; when it captures something, it cannot. Measured, that is worth roughly 2× on closure creation in a loop: 54.3 against 26.2 ns/op without OPcache and 50.4 against 24.5 with it, while a closure that captures a variable is unchanged. A partial with no captured variables is one of the closures that gets cached — but building one still costs about twice a plain closure, and that cost is measured in the performance section below.
Readonly properties may declare defaults
Readonly properties have been unable to carry a default value since they landed, and the RFC that allows them now is candid about why the rule existed: a readonly property with a default is essentially a constant, and constants were not considered interesting. What changed is interface properties, which arrived in PHP 8.4 — because now there is something to satisfy:
// src/Report/ReportBlueprint.php
interface ReportBlueprint
{
public string $name { get; }
public string $extension { get; }
public string $template { get; }
public array $columns { get; }
}
An implementation has to provide those four values immutably. On 8.5 that meant a constructor whose body is four assignments of constants, or four getters returning constants. On 8.6 it is the property declarations themselves:
// PHP 8.5
final readonly class CsvReport implements ReportBlueprint
{
public string $name;
public string $extension;
public string $template;
public array $columns;
public function __construct()
{
$this->name = 'CSV';
$this->extension = 'csv';
$this->template = 'reports/%s.csv';
$this->columns = ['id', 'name', 'total'];
}
}
// PHP 8.6 — src/Report/CsvReport.php
final readonly class CsvReport implements ReportBlueprint
{
public string $name = 'CSV';
public string $extension = 'csv';
public string $template = 'reports/%s.csv';
public array $columns = ['id', 'name', 'total'];
}
The rule that governs everything else about this feature is one sentence in the RFC: a default value counts as the initialising assignment. The property is set before the constructor body runs, so any later assignment — including one from the constructor — is a modification of a readonly property and fails. That is not a detail you can discover by reading the declaration; it is the first thing that will happen to you:
// PHP 8.6
final readonly class Rule
{
public string $name = 'default';
public function __construct(string $name)
{
$this->name = $name; // Error: Cannot modify readonly property Rule::$name
}
}
Once you accept that a defaulted readonly property is an instance constant, the rest of the feature is consistent. It cannot be unset — Cannot unset readonly property — so the lazy-initialisation pattern that uses unset() and __get() does not apply here. It survives serialisation, where the default is part of the payload because the property was already initialised when the object was created, and __unserialize() may restore a different value. It may be reinitialised once from __clone(), and by clone-with from inside the class — and from inside is not a style note but a rule. No explicit asymmetric visibility is needed for it: readonly alone makes the property protected(set), which reflection confirms (ReflectionProperty reports the modifiers public|protected(set)|readonly for a plain public readonly property). So clone($object, ['n' => 5]) from outside is Cannot modify protected(set) readonly property … from global scope, while the same call inside the class works. Reflection reports it as an ordinary default, with hasDefaultValue() true and getDefaultValue() returning what the declaration said. A child class inherits it, or redeclares it under the normal compatibility rules for properties.
There are four limits, and three of them are compile errors that live in the before/ directory of the repository precisely because a demo that hits one never reaches the next example. The default must be a constant expression, so new DateTimeImmutable() is refused — even though 8.1 permits new in a parameter default, which is a different feature with a confusingly similar look. A readonly property never satisfies a { get; set; } interface property, with or without a default. Two traits that declare the same readonly property must agree on the default. And a readonly class may only be extended by another readonly class — a rule from 8.2, but one you meet while experimenting with defaults, because it is the obvious next thing to try.
#[\Override] reaches constants, and enums describe themselves
#[\Override] has been available for methods since PHP 8.3, and it is a rare kind of attribute: it does nothing at runtime, and it is worth adding everywhere, because it turns a rename in a parent class into an error in the child instead of into a second constant that nobody reconciles.
// PHP 8.5
class Child extends Base
{
public const int LIMIT = 2; // rename it in Base and nothing tells you
}
// PHP 8.6
class Child extends Base
{
#[\Override]
public const int LIMIT = 2; // verified at compile time
}
The check is real, not decorative: an attribute with no matching parent constant is NoParent::LIMIT has #[\Override] attribute, but no matching parent constant exists, and the process stops. Enum cases are included, so a case that satisfies an interface constant can declare the relationship. That is worth looking at closely, because the combination has a sharp edge — and the demo keeps it out of src/ for exactly that reason, showing it in examples/03 and before/07 instead:
// PHP 8.6 - compiles, because the interface constant has no type
interface Labelled { public const High = 'high'; }
enum Severity: int implements Labelled
{
#[\Override]
case High = 3; // an enum case over an untyped constant: allowed
}
// PHP 8.6 - and with a typed constant it does not
interface Typed { public const string High = 'high'; }
enum Severity2: int implements Typed
{
#[\Override]
case High = 3;
// Fatal error: Type of Severity2::High must be compatible with Typed::High of type string
}
A case is an instance of its enum, never a string, so an enum case can only ever satisfy an untyped interface constant. That is worth stating because it inverts the usual advice: the attribute is a name check, and an untyped constant is what lets a case through — which means an enum that overrides an untyped constant is not satisfying the contract so much as borrowing its name.
Enums also become readable when dumped, which the RFC calls debuggable enums. Before 8.6 an enum could not declare __debugInfo() at all — the declaration was a fatal error, Enum S cannot include magic method __debugInfo — which meant every dump of a case printed the case. Now a case can explain itself, and the explanation can be shaped for a human:
// src/Ops/Severity.php
public function __debugInfo(): array
{
return ['severity' => $this->label(), 'value' => $this->value];
}
// var_dump() and print_r() now show the array above instead of the bare case
Two smaller Core changes belong to the same area. Objects held in constants are now writable through the constant. A global constant has been able to hold an object since 8.1, and 8.6 is what makes it mutable:
// PHP 8.6
const OBJ = new Holder(); // allowed since 8.1, at the global scope
OBJ->name = 'after'; // PHP 8.6; on 8.5 this was the fatal error below
// PHP 8.5
// Fatal error: Cannot use temporary expression in write context
What remains forbidden is narrower than that: new is still not allowed inside a class constant expression, so class Registry { public const OBJ = new Holder(); } is a fatal error (New expressions are not supported in this context) — a global const, a define() or a static property is where an object actually lives. And WeakMap accepts a reference to a key that is not in the map yet, creating the entry: $slot = &$map[$key]; $slot = 42; used to fail with Object stdClass#1 not contained in WeakMap — the entry came into existence only if the key was already there. The entry still disappears with its key, which is the whole point of the structure.
A value type for durations
Time has never had a unit in PHP. You carried an integer and remembered what it counted, or you carried a DateInterval and did arithmetic on h, i and s by hand. Time\Duration is a small immutable value object with a factory per unit, and the arithmetic reads the way the domain does:
// PHP 8.5
$seconds = 5400; // an int with a unit in its name
$interval = new DateInterval('PT1H30M');
$total = $interval->h * 3600 + $interval->i * 60 + $interval->s;
// PHP 8.6
$duration = Time\Duration::fromIso8601DurationString('PT1H30M'); // ->seconds is 5400
$later = Time\Duration::fromSeconds(90)->add(Time\Duration::fromMinutes(1)); // 150s
$half = Time\Duration::fromSeconds(90)->divideBy(4); // 22s + 500_000_000ns
The object keeps seconds and nanoseconds as separate fields and a negative flag rather than a signed integer, which is why negate() returns a duration with the same magnitude and the flag set, and absolute() simply clears it. compare() is static and takes two durations and returns a sign, so it plugs straight into a comparator. The factories cover nanoseconds through hours, and ISO 8601 durations parse.
The interesting part is where it plugs in: the new polling API takes a Time\Duration as its timeout, not an integer number of milliseconds. That is the difference between a language that accumulates useful types and a language that accumulates flags — the new value type and the new I/O API were designed against each other.
// PHP 8.5
stream_select($read, $write, $except, 0, 100_000); // seconds and microseconds
usleep(100_000); // or this, if you only need to wait
// PHP 8.6
$context->wait(Time\Duration::fromMilliseconds(100));
What it is not matters too, because both are easy to assume. There is no method that formats a duration back to an ISO 8601 string — fromIso8601DurationString() has no counterpart. And it is not a DateInterval: DateTimeImmutable::add() still refuses it with a TypeError. It is a duration as a value, not a calendar arithmetic operand, and 8.6 makes no attempt to blur that.
URI answers questions you used to answer with a regex
The Uri classes arrived in 8.5; 8.6 adds the two questions that made people keep a hand-rolled parser around anyway, and a builder. The first question is what kind of reference a string is — a full URI, an absolute path, a relative path, or a network path — and the second is what kind of host it has:
// PHP 8.5
$parts = parse_url($input); // an array, or false
$isAbsolute = isset($parts['scheme']); // a guess about intent
$isIp = (bool) filter_var($parts['host'], FILTER_VALIDATE_IP); // another guess
// PHP 8.6
$uri = Uri\Rfc3986\Uri::parse($input);
$isAbsolute = Uri\Rfc3986\UriType::Uri === $uri->getUriType();
$isIp = in_array($uri->getHostType(), [Uri\Rfc3986\UriHostType::IpV4, Uri\Rfc3986\UriHostType::IpV6], true);
Measured, the answers are exact: https://example.com/a is a Uri, /a/b is an AbsolutePathReference, ../a/b is a RelativePathReference, //example.com/a is a NetworkPathReference; and a host is a RegisteredName, an IpV4, an IpV6 or an IpVFuture. Those are enums, so a match over a reference kind is exhaustive rather than a chain of string comparisons.
Two details have to change with them. The classes are namespaced — Uri\Rfc3986\Uri, Uri\Rfc3986\UriHostType, Uri\Rfc3986\UriType — and Uri::parse() returns null for input it cannot read, where parse_url() returned false. So the check is not optional: Uri::parse('https://a b.com/x') is null, and calling getUriType() on it is Call to a member function getUriType() on null. An empty string parses, and so does a relative path: the function refuses what is malformed, not what is relative.
The builder closes the other half of the problem, which is not parsing but assembling a URL out of parts that each need encoding:
// PHP 8.5
$url = 'https://'.$host.$path.'?'.http_build_query($query); // hope every part is already safe
// PHP 8.6
$url = (new Uri\Rfc3986\UriBuilder())
->setScheme('https')
->setHost($host)
->setPath($path)
->setQuery('page=2')
->build()
->toString();
A malformed path does not silently produce a URL that 404s in production: setPath('/a b') throws Uri\InvalidUriException with The specified path is malformed. And percent-encoding has a new function that asks you where the value is going, which is the question urlencode() versus rawurlencode() never asked. It lives on the WHATWG side of the API, so it is namespaced rather than global: Uri\WhatWg\url_percent_encode('a b/c?d', Uri\WhatWg\UrlPercentEncodingMode::Path) gives a%20b/c%3Fd while the same value as a query gives a%20b/c?d. Two calls, two correct answers, one function — instead of two memorised functions and one wrong answer, and no way to pass a value that was encoded for the wrong context.

Polling and stream errors grow up
Waiting on several streams used to mean passing three arrays by reference and reading the survivors back out, with the answer to "which one was it" encoded in array membership. The new polling API hands you objects you already hold:
// PHP 8.5
$read = [$socketA, $socketB];
$write = $except = [];
$ready = stream_select($read, $write, $except, 1, 0); // what changed, and which stream was it?
// PHP 8.6
$context = new Io\Poll\Context();
$watcherA = $context->add(new StreamPollHandle($socketA), [Io\Poll\Event::Read]);
$watcherB = $context->add(new StreamPollHandle($socketB), [Io\Poll\Event::Read]);
$ready = $context->wait(Time\Duration::fromSeconds(1)); // the Watcher objects that fired
A watcher is inspectable and changeable while it is registered, which is what makes the API usable for anything longer-lived than a single call: getHandle(), getWatchedEvents(), getTriggeredEvents(), hasTriggered(Event::Read), getData(), isActive(), modify(), modifyEvents(), modifyData(), remove(). The vocabulary is enums, so both halves of a poll are type-checked: events are Read, Write, Error, HangUp, ReadHangUp, OneShot and EdgeTriggered, and backends are Auto, Poll, Epoll, Kqueue, EventPorts and WSAPoll.
One measured surprise is worth committing to memory before you use it. Subscribing to Write does not mean "tell me when this socket changes" — it means "tell me when I may write", and a socket with room in its buffer may be written to right now. Registering a fresh socket pair for [Event::Read, Event::Write] makes it ready immediately; in the demo, adding a Read-only watcher and waiting 50 ms gives nothing, while a Write watcher returns on the first wait. That is not a bug, but it is the first thing that looks like one.
Stream failures got the same treatment. The old pattern was @ plus error_get_last(), which is one global slot that any other warning in the same request can overwrite, and which gives you a string to parse. Now the behaviour is configured per stream context, and the reason is a value:
// PHP 8.5
$data = @file_get_contents($url);
if (false === $data) {
$error = error_get_last(); // one global slot, clobbered by anything else
echo $error['message'] ?? 'unknown';
}
// PHP 8.6
$context = stream_context_create(['stream' => [
'error_mode' => StreamErrorMode::Silent, // or Error, or Exception
'error_store' => StreamErrorStore::All, // or Auto, None, NonTerminating, Terminating
]]);
$data = @file_get_contents($url, false, $context);
$errors = stream_last_errors(); // a list of StreamError objects
Measured on a host that does not resolve: the call returns false, and stream_last_errors() returns one object whose code is StreamErrorCode::OpenFailed — one of 78 cases — and whose message carries the resolver's own words. Switching error_mode to Exception turns the same failure into a StreamException. stream_clear_errors() resets the store, and the options live under the stream wrapper of the context, which is a detail worth knowing: stream_context_create(['error_mode' => …]) is rejected with Options should have the form ["wrappername"]["optionname"] = $value.
What Silent does not do is replace the @. That is measured, and it is not what the mode's name suggests. For a path that does not exist locally, Silent is genuinely silent: no warning, and the failure is in the store. For a host that does not resolve, the warning still comes out of the network layer before the stream knows it has failed — Warning: file_get_contents(): php_network_getaddresses: getaddrinfo for … failed, with Silent set and the error stored as well. The resolver reports through the ordinary error path, which the stream-error mechanism does not own. So the honest summary is: this API replaces error_get_last(), the single global slot you cannot attribute to anything, and it gives you a structured reason; it does not replace your suppression strategy for network calls. The repository has a test that asserts exactly this, with no @, so the behaviour is pinned rather than described.
The same release adds a long list of socket options that used to require socket_* functions or an ini setting: so_reuseaddr (with SO_EXCLUSIVEADDRUSE on Windows), so_keepalive with tcp_keepidle/tcp_keepintvl/tcp_keepcnt, so_linger, and the buffer sizes so_rcvbuf/so_sndbuf. Measured against the socket itself rather than against the context, because a stream context ignores an option it does not know without a word: on 8.5 so_keepalive is silently dropped (SO_KEEPALIVE reads back 0, exactly as without it) and so_rcvbuf leaves the 131072-byte default untouched, while on 8.6 the keepalive reads back 1 and so_rcvbuf => 8192 gives a 16384-byte buffer. Not the requested 8192: the kernel rounds it, which is why the documentation says it may adjust the value. A test that only asserts the socket came up would pass on both versions — the same trap as a misspelled option name, which is also accepted in silence.
Small additions that delete a helper
clamp() is the smallest, and it replaces a shape everyone has written and nobody enjoys reading:
// PHP 8.5
$level = min(max($value, $min), $max); // read it inside out
$level = $value < $min ? $min : ($value > $max ? $max : $value); // or this
// PHP 8.6
$level = clamp($value, $min, $max); // value, min, max
Two behaviours are worth measuring rather than assuming. Inverted bounds are refused rather than sorted out — clamp(5, 10, 1) throws ValueError: clamp(): Argument #2 ($min) must be smaller than or equal to argument #3 ($max). And the comparison is a comparison, not a numeric coercion: clamp('x', 1, 10) returns 10, because a non-numeric string compared with an integer is compared as a string and lands above the upper bound. A numeric string is fine (clamp('5', 1, 3) gives 3), and floats stay floats (clamp(2.5, 1.0, 2.0) gives 2.0), so the trap is narrow — but it is exactly the kind of value a query parameter arrives as.
trim() and its one-sided siblings now treat the form feed as whitespace. The difference is one byte you cannot see:
// PHP 8.5
echo bin2hex(trim("\x0cfoo\x0c")); // 0c666f6f0c - the form feeds survived
// PHP 8.6
echo bin2hex(trim("\x0cfoo\x0c")); // 666f6f
grapheme_strrev() does what strrev() and a UTF-8 split never did, because a grapheme is not a code point — though it comes from ext-intl rather than from the core string functions, so a deployment that does not load intl does not have it (the demo's composer.json requires it, and the image builds it in). Reversing "A\u{0301}BC" — an A, a combining acute accent, a B and a C — puts the C first and keeps the accent attached to the A, which the preg_split version gets wrong and strrev() destroys outright.
Reflection grew the questions you actually ask. ReflectionProperty::isReadable() and isWritable() replace the combination of isPublic()/isReadOnly() that was always an approximation — it now accounts for asymmetric visibility, which is how the DOM's properties report false for writing. ReflectionParameter::getDocComment() exposes a parameter's own doc comment, so an attribute-free annotation on a parameter is finally readable. ReflectionConstant::inNamespace() and the attribute trio ReflectionAttribute::inNamespace()/getNamespaceName()/getShortName() finish the set.
ZipArchive::openString() and closeString() remove the last reason a zip needed a file on disk. Building an archive in memory is now: open with an empty string, addFromString(), and take the bytes back from closeString() — the demo's archive starts with the PK signature and reads back correctly.
pack() and unpack() learned to state the byte order on the codes that never had it. Before, l was whatever the machine was, and portable binary formats came with a manual strrev() and a comment about endianness. Now:
// PHP 8.5
$bytes = pack('l', 1); // machine order: "which machine?" is the question
if (unexpectedOrder()) {
$bytes = strrev($bytes);
}
// PHP 8.6
pack('l<', 1) === "\x01\x00\x00\x00"; // little endian, stated
pack('l>', 1) === "\x00\x00\x00\x01"; // big endian, stated
pack('f<', 1.0); // and the float and double codes take them too
unpack('l<', pack('l<', 1))[1] === 1; // round trip
The modifiers are refused where the order is already inherent, which is the design being honest rather than permissive: pack('v<', 1) throws ValueError: Endianness modifier '<' cannot be applied to format code 'v' which already has inherent endianness — and the same for e, N and g.
The Intl additions are a mixed bag, and deliberately so, because several of them are gated by the ICU version rather than by PHP. Verified running on ICU 76.1: SpoofChecker::getSkeleton('paypal') returns paypal while areConfusable('paypal', 'paypa1') returns true — the homograph pair that phishing filters are built on — and IntlNumberRangeFormatter exists for formatting an interval rather than two endpoints, from ICU 63 onwards. Locale::getDisplayKeyword() and IntlDatePatternGenerator::getSkeleton() join them. The SpoofChecker::getBidiSkeleton()/areBidiConfusable() pair needs ICU 74+, so on an older ICU the class exists and the methods do not, which is why the example checks before it calls. The same gating applies outside Intl: the new Sodium KEM functions (X-Wing, ML-KEM768, and the IPcrypt and SHAKE-XOF batches) need libsodium 1.0.22+, and gmp_powm_sec() and gmp_prevprime() need GNU MP — official Windows builds using MPIR do not have them at all.
The extension-level additions that did not fit into a paragraph of their own, listed because a release note that hides them is useless: OpenSSL streams gained TLS session resumption, external PSK and TLS 1.3 early data (0-RTT), with Openssl\Session and Openssl\Psk as classes; Curl gained CURLOPT_SEEKFUNCTION and a size_delivered figure in curl_getinfo(); PDO_PGSQL gained Pdo\Pgsql::ATTR_CHUNK_SIZE for lazy fetching; SNMP gained AES-192/256, a MIB reset, and output-control options; Phar lets a SplFileInfo subclass override the mtime and pathname used by the buildFrom family.
What 8.6 deprecates
The deprecation pass is the largest part of this release by count, and it is the part that will cost you time, because deprecations are the only changes here that you can defer and then meet all at once three versions later. Each one names its replacement in the message, which is the pattern PHP has settled into:
Function is_long() is deprecated since 8.6, use is_int() instead
Function is_integer() is deprecated since 8.6, use is_int() instead
Function is_double() is deprecated since 8.6, use is_float() instead
Function doubleval() is deprecated since 8.6, use floatval() instead
Function spl_object_hash() is deprecated since 8.6, consider using spl_object_id() instead
Function spl_classes() is deprecated since 8.6, use ReflectionExtension::getClassNames() instead
Function metaphone() is deprecated since 8.6, use a userland phonetic matching library instead
Function strcoll() is deprecated since 8.6, use Collator::compare() instead
Constant SORT_LOCALE_STRING is deprecated since 8.6, use one of the Collator::*sort*() methods instead
Method ArrayIterator::ksort() is deprecated since 8.6
Method SplFileObject::fgetcsv() is deprecated since 8.6
Two of those deserve a sentence more. metaphone() is deprecated without a replacement in core, which is unusual and honest: phonetic matching moved to userland libraries, and core is not going to compete with them. SplFileObject::fgetcsv() arrives with a second, older notice about its $escape parameter — the default is going to change, so pass it explicitly or set it through setCsvControl(), and the deprecation is the deadline rather than the change itself.
The outright mistakes of the past are here too, and they are worth grepping for because every one of them is a name a codebase can use by accident. let and is may no longer be used as the name of a class, interface, trait, enum, function or constant, nor as an alias in class_alias() or a use declaration — the language is keeping them for itself. _ may no longer name a constant. namespace may no longer name a class constant, and readonly may no longer name a function. All four are the same bug in four spellings.
Calling a function "let" is deprecated since 8.6
Using "is" as a class name is deprecated since 8.6
Calling a constant "_" is deprecated since 8.6
Declaring class constant called 'namespace' is deprecated
Calling a function "readonly" is deprecated since 8.6
Then there is a group about control flow and object lifecycle, where the deprecation is the language closing a hole rather than renaming something. A return statement inside a finally block is deprecated — it silently discards an exception or a return value from the try, and no amount of documentation ever made that a good idea. Returning a value from __construct() or __destruct() is deprecated, and making either a generator is deprecated too. __debugInfo() declared as ?array is deprecated in favour of array. Two messages are easy to confuse here, because returning null from that method was already deprecated in 8.5: the older notice is Returning null from E::__debugInfo() is deprecated, return an empty array instead, and the new one belongs to the declaration — …is deprecated, make the return type non-nullable and return an empty array instead — and it is emitted even when the method returns []. Measured: a ?array declaration returning an array gets exactly one notice, the new one; returning null gets both, new first. So the 8.6 fix is not "stop returning null", it is to declare array and return an empty array. And define()'s third argument — the case-insensitive flag — has carried a notice since PHP 8.0 that it is ignored; 8.6 turns that into a deprecation and adds a date: this will be an error in PHP 9.0. Two entries on this list are therefore older than the release that made them loud, which is worth knowing when you go looking for when a warning started.
The rest of the list, by area, since a complete inventory is the point of this article: passing an object to http_build_query() or to array_walk()/array_walk_recursive() (use get_object_vars()); is_a() and is_subclass_of() with a string first argument when $allow_string is false; ReflectionProperty::setValue()/setRawValue() with an object that is not an instance of the declaring class; ReflectionMethod::invoke() with an object for a static method; more than 16 filters in a php://filter URL without configuring filter.max_filter_count; a SessionHandlerInterface implementation that cannot create_sid() or validateId(); the ArrayIterator methods inherited from ArrayObject (getFlags, setFlags, asort, ksort, uasort, uksort, natsort, natcasesort, serialize, unserialize); the SplFileObject CSV methods; objects passed to the bz2 and zlib stream filters. And, for the extension you may still be loading for one regex call: mbregex is deprecated (Oniguruma reached end of life). The repository's image builds mbstring with --disable-mbregex for exactly that reason, and a codebase that uses mb_ereg_* has a real migration in front of it rather than a rename.
Behaviour changes that do not announce themselves
Deprecations are polite. The changes in this section are not: they alter what the same code does, with no notice at the call site, and they are the reason to read the release notes rather than the changelog's headlines.
The sessions one is a security fix, and it is also the one that will break a login flow. Three ini defaults moved at once:
; PHP 8.5 - measured defaults
session.use_strict_mode = 0
session.cookie_httponly = 0
session.cookie_samesite = (unset)
; PHP 8.6 - measured defaults
session.use_strict_mode = 1 ; rejects session ids the server never issued
session.cookie_httponly = 1 ; document.cookie cannot read it
session.cookie_samesite = "Lax" ; not sent on cross-site requests, except top-level navigations
Strict mode closes session fixation by refusing an identifier that does not correspond to a stored session, which is the right default and a behaviour change for any custom save handler that used to accept an externally supplied id. httponly breaks JavaScript that reads the session cookie. SameSite=Lax is the subtle one: a cookie with Lax is not sent on a cross-site POST, so an application that accepts form posts from another origin — or a payment or OAuth flow that posts back — has to set cookie_samesite=None and cookie_secure=1 explicitly. Those three lines are the entire migration, and skipping them means debugging a login that worked before the upgrade. The framework-side counterpart — proving that a session is fresh rather than merely authenticated — is in Symfony 8.2 sudo mode, which is the same problem one layer up.
The DOM change is subtler than a changed message. Properties previously documented as @readonly — DOMNode::$nodeType and friends — are now declared with asymmetric visibility, so the error you get when you write to one is Cannot modify private(set) property DOMNode::$nodeType from global scope instead of the old readonly complaint. The words matter less than what they imply: ReflectionProperty::isWritable() now returns false for them, so code that decided whether to attempt a write by asking reflection gets a different answer than it used to.
JSON errors became useful in the way you always wanted when you were staring at a 400: the message says where.
// PHP 8.5
json_decode('{"a":}'); // "Syntax error"
// PHP 8.6
json_decode('{"a":}'); // "Syntax error near location 1:6"
The position is in both places you might look — json_last_error_msg() and the JsonException message when you decode with JSON_THROW_ON_ERROR — and an offset into a 4 KB payload is the difference between a guess and a fix. Two more small ones: session_encode() now returns an empty string for an empty session instead of false, which means false finally means "could not encode" and nothing else; and array_intersect() converts values while scanning rather than while comparing, which changes how often __toString() is called on the way. The count depends on the shape, so both of the shapes measured here say which is which: two values on the left compared against one on the right, where 8.5 converted the first value three times and 8.6 converts it once, for the same result; and an empty array anywhere in the inputs, where 8.6 converts nothing at all while 8.5 still walked the other arrays and converted twice.
The last group is the type police, and it is large enough that the release notes list it per extension: GMP's shift and exponentiation operators now throw instead of silently truncating, and gmp_fact(), gmp_pow() and friends throw ValueError for arguments that do not fit an unsigned long; Intl rejects non-stringable objects as time zones with a TypeError, and the Intl getters that used to return false now throw ValueError; PCNTL validates its alarm range and requires a list for pcntl_exec(); Sodium's password-hashing functions throw ValueError instead of SodiumException for out-of-range arguments; SOAP rejects integer keys in a classmap; sockets require an int and a matching level for the reuseport filter; preg_grep() returns false on a PCRE execution error instead of a partial array; session settings containing NUL bytes now warn and leave the value unchanged; FTP's non-blocking functions throw instead of warning. None of these are hard to fix, and all of them are impossible to guess from a stack trace unless you know the release changed them.
What 8.6 does not make faster
Performance claims deserve measurements, and they deserve the ones that fail. The table below is the same benchmark script run on two pinned patch versions — php:8.5.11-cli and php:8.6.0RC3-cli — on one machine, 300 000 iterations per figure, two runs each, in both OPcache states, because that turned out to matter more than anything else in this section.
The first version of this benchmark measured nothing useful, and the reason is worth stating before the numbers. With OPcache enabled, a call whose arguments are constants is evaluated during compilation: str_split('abcdefghij') became a constant, and the loop around it measured 3.4 ns/op — an empty loop. PHP 8.5 does not fold the same call (149 ns/op), so a benchmark written with literals measures the optimiser of one version against the interpreter of the other and calls the difference a speedup. Every figure below is built from inputs that only exist at runtime, which is the only way the comparison means anything.
| Shape (ns/op) | 8.5 no OPcache | 8.6 no OPcache | 8.5 OPcache | 8.6 OPcache | |
|---|---|---|---|---|---|
printf('%s-%d') into a buffer | 462.2 | 99.7 | 100.4 | 39.5 | faster in both: 4.6× / 2.5× |
| stateless closure, per iteration | 54.3 | 26.2 | 50.4 | 24.5 | ~2× faster in both |
array_sum() on ints | 48.4 | 31.6 | 47.3 | 28.4 | ~1.6× faster in both |
str_split() | 127.1 | 40.6 | 127.6 | 37.8 | ~3.3× faster in both |
pathinfo(), one component | 62.6 | 41.6 | 87.0 | 39.2 | ~1.5–2× faster in both |
array_map(), first-class callable | 453.1 | 396.0 | 379.7 | 360.9 | small, consistent |
sprintf('%s-%d') | 47.0 | 65.8 | 38.3 | 25.4 | only faster with OPcache |
array_map(), closure | 508.2 | 607.8 | 606.2 | 1077.7 | slower, worst with OPcache |
json_encode(), 50 records | 4971.4 | 5151.3 | 5226.8 | 5255.3 | unchanged |
json_encode(), pretty-printed | 5788.6 | 6109.2 | 5487.5 | 6125.5 | unchanged (noisy) |
str_pad() | 27.5 | 26.0 | 26.9 | 25.5 | unchanged |
array_fill_keys() | 93.6 | 99.9 | 109.0 | 94.2 | unchanged |
array_unshift() | 46.7 | 45.3 | 49.2 | 44.6 | unchanged |
| closure over a variable, per iteration | 78.7 | 76.7 | 69.3 | 74.6 | unchanged (it captures) |
foreach (the control) | 208.4 | 155.2 | 151.6 | 150.4 | unchanged, as it should be |

Three things fall out of that table. The first is that the optimisations which reproduce are narrower than the release notes read: printf() compiled into interpolation, the stateless closure cache, and integer fast paths in array_sum(), str_split() and pathinfo(). The second is that sprintf() is the odd one: it is the only row where the two OPcache states disagree about the direction — 25.4 against 38.3 with OPcache, 65.8 against 47.0 without — because the compile-time path is exactly what makes it fast, and without OPcache there is no compile-time path.
The third is a correction to my own earlier run of this benchmark, which used literal arguments and reported str_pad() and pretty-printed json_encode() as slower on 8.6 and array_fill_keys() as improved. Measured properly, all three are unchanged in every configuration: that was noise, and the OPcache matrix is what made it obvious. What is not noise is array_map() with a closure — 508 to 608 ns without OPcache, and 606 to 1078 with it, holding in both runs of both versions. If you have a hot loop that passes a closure to array_map(), that row is worth a benchmark of your own, and a first-class callable or a partial is the cheaper form.
And one finding that goes the other way, because it is a feature people will adopt for the wrong reason. Partial application is not a speedup, and the benchmark in the repository measures it from both sides: creating a partial costs about twice what creating a stateless closure costs (169 against 68 ns/op with OPcache off, 80 against 40 with it on), while calling one is close to calling a closure (60 against 52, 39 against 35). The stateless closure cache does apply to a partial with no captured variables — but an expression that captures something is outside it, and a partial is a closure with an extra argument frame to fill in either way. Adopt ? because it deletes a lambda and a parameter name; the numbers here are a reason not to adopt it for speed.
When not to upgrade yet
The strongest argument against moving now is the calendar: 8.6 is a release candidate, RC3 is the newest tag that exists, and the repository pins it in the image tag and the tool versions in a committed lock file. Nothing measured here is speculative, but every measurement is a measurement of an RC. If you are on 8.3 or 8.4 and your pain is somewhere else, this release is mostly ergonomics and cleanup — a good upgrade, not an urgent one.
The work of the upgrade is the deprecation list, and it is not evenly distributed. If your codebase touches sessions with a custom handler, CSV through SplFileObject, ArrayIterator's array-object methods, or mb_ereg_*, you have real migrations; if it touches is_long(), doubleval() and spl_object_hash(), you have a find-and-replace. One item carries a deadline in its own message — define()'s third argument will be an error in PHP 9.0 — and the __debugInfo() pair is the other worth doing early, because the return value was already deprecated a version ago.
There are also four changes that break silently enough to deserve a test before you deploy. The session SameSite=Lax default breaks a cross-site POST login flow. array_intersect()'s conversion order changes results for values with a stateful __toString(). The DOM properties that changed from @readonly to private(set) answer reflection differently. And the SplFileObject::fgetcsv() escape default is on its way to changing, which is why it warns now rather than later.
Some of the new API is gated by the platform rather than by PHP, so "we are on 8.6" is not the same as "we have all of it": the Intl additions need ICU 63 for the number-range formatter and ICU 74 for the spoof-checker pair, the Sodium KEM work needs libsodium 1.0.22+, GMP's new functions need GNU MP and are absent from MPIR builds, and the polling backend is whichever one your operating system provides. A feature you cannot exercise is a feature you cannot rely on, which is why each of those is checked at runtime in the example rather than assumed.
Finally, three of the new features are easy to adopt where they do not fit. Partial application is not yet understood by every static analyser — the RFC itself lists tooling updates as a consequence — so a codebase with a strict analysis gate should add it where the analysis already handles it. A readonly property with a default is an instance constant, so it is the wrong tool the moment you need a per-instance value: that is what a promoted constructor parameter is for, and the two look similar enough to be confused. And Io\Poll is a large hammer for the common case of one stream that you want to read with a timeout.
What to check in your own code
- Search for the renamed and removed names.
is_long,is_integer,is_double,doubleval,spl_object_hash,spl_classes,metaphone,strcoll,SORT_LOCALE_STRING, andlet/is/_/namespaceused as identifiers. - Run your suite with deprecations as failures —
failOnDeprecation="true"in PHPUnit is a one-line change that turns this entire section into a checklist you cannot forget. - Check your session configuration before you upgrade. If you need cross-site POST cookies, set
cookie_samesite=Noneandcookie_secure=1explicitly, and give your custom save handler avalidateId(). - Look for constructors that only assign constants — they are candidates for readonly defaults, and the class often becomes
readonlywith no constructor at all. - Look for closures that only pass an argument through —
fn ($x) => f($x)and its multi-argument cousins are what?replaces. - Do not reach for a readonly default where you need to customise. A default is the initialising assignment; a value that varies per instance belongs in a promoted constructor parameter.
- Replace the hand-rolled helpers:
min(max())withclamp(), the integer-seconds duration withTime\Duration, theparse_url()classification withgetUriType()/getHostType(),error_get_last()with a stream context andstream_last_errors()— but keep your suppression for network calls, because a resolver failure still warns (see the stream section). - Check the arity of every partial you write.
f(?)takes one argument and ignores the rest;f(...)passes everything. In a callback handed a key as well as a value, that is the difference between working and quietly wrong. - Then read your own benchmarks before believing any of them, including the ones in this article. Three of the optimisations above did not reproduce in these shapes, and the only way to know what your code does is to measure your code.
None of the changes here is a rewrite. The release deletes ceremony — a lambda, a constructor, a guess about a URL, a hand-built byte swap — and in exchange it asks for two things: a session configuration review and a deprecation sweep. That is a good trade, and it is a better one a version before it becomes mandatory.