every feature, every deprecation, the upgrade path · kept current from the RFC ballots we mirror live · updated Aug 31, 2026
PHP 8.6 is the last minor release before PHP 9. It lands on November 19, 2026 with 27 accepted RFCs: partial function application, default values for readonly properties, a real Duration type, clamp(), a scalable polling API, structured stream errors and secure session cookies out of the box. The other half of the story is cleanup: 35 separate deprecation ballots, of which 31 passed, prepare the removals PHP 9 will make. This page follows the release from beta to GA; every vote count below is mirrored from the public ballots on wiki.php.net, and every code example marked with a check mark was executed on the current 8.6 build.
Call a function with ? in place of some arguments and PHP hands back a Closure for the rest. Named placeholders reorder parameters, ... collects everything not yet given, and the resulting Closure keeps the original types, defaults and by-reference flags for reflection.
This is the missing half of first-class callables from 8.1 and the natural partner of the 8.5 pipe operator. array_map(str_replace('a', 'b', ?), $list) replaces a line of arrow-function boilerplate, and static analysers see the real signature. A follow-up RFC made every ? placeholder a required parameter, so the arity you write is the arity you get.
partial-application.php
// Fix some arguments now, supply the rest later$greet = str_replace('hello', 'hi', ?);
echo$greet('hello world'); // hi world// Placeholders compose with the 8.5 pipe operator$slugs = ['Hello World', 'PHP 8.6']
|> array_map(strtolower(...), ?)
|> array_map(str_replace(' ', '-', ?), ?);
print_r($slugs); // hello-world, php-8.6// "..." keeps the remaining parameters, defaults included$encode = json_encode(?, JSON_THROW_ON_ERROR, ...);
echo$encode(['ok' => true]); // {"ok":true}
Readonly properties may now declare a default value; the default counts as the one and only initialisation. Combined with interface properties from 8.4 this gives you contractual constants per class without constructor assignments.
The original readonly RFC called defaults “not particularly useful”. Interface properties changed that: a readonly default satisfies a { get; } contract with zero boilerplate. Careful: an assignment in the constructor is now a modification and throws, and unset() on such a property is an error.
readonly-defaults.php
interface Ingestor
{
publicstring$name { get; }
publicarray$steps { get; }
}
// Fixed metadata without constructor boilerplate:// the default IS the initialising assignmentfinalreadonlyclass ChangelogIngestor implements Ingestor
{
publicstring$name = 'changelog';
publicarray$steps = [ParseMarkdown::class, ExtractVersions::class];
}
echonew ChangelogIngestor()->name; // changelog
Objects have been allowed in constants since 8.1, but any write such as CONST->prop = 1 died with “Cannot use temporary expression in write context”. PHP 8.6 lifts that restriction for property writes, compound assignments, increments, isset(), unset() and by-reference passing.
The constant binding stays immutable; only the object’s state changes, which is how objects always behaved elsewhere. Array constants are unchanged (copy-on-write). One side effect: writing to an enum case’s property now throws the regular readonly error instead of a compile-time fatal.
const-object.php
const REGISTRY = new stdClass();
// PHP 8.5: Fatal error: Cannot use temporary expression in write context// PHP 8.6: the constant still binds the same object, its state may change
REGISTRY->hits = 0;
REGISTRY->hits++;
REGISTRY->hits += 10;
class Config
{
const SETTINGS = REGISTRY;
}
Config::SETTINGS->debug = true;
var_dump(REGISTRY->hits, isset(Config::SETTINGS->debug)); // int(11) bool(true)
A final, readonly stop-watch duration with nanosecond precision: factories from seconds down to nanoseconds and from ISO 8601 strings, add/sub/multiplyBy/divideBy, comparison operators, and an overflow-safe range of about 292 years.
Timeouts, back-off and scheduling were int-in-some-unit or fragile floats. DateInterval carries calendar units that cannot be converted to seconds reliably. Duration is unit-safe by construction and is already the timeout type of the new polling API; expect more internal APIs to accept it. sleep() and friends still take int.
duration.php
use Time\Duration;
// Stop-watch time with nanosecond precision, no calendar semantics$timeout = Duration::fromMilliseconds(1_500);
$backoff = $timeout->multiplyBy(2)->add(Duration::fromSeconds(1));
var_dump($backoff->seconds, $backoff->nanoseconds); // int(4) int(0)
var_dump($timeout < $backoff); // bool(true)// ISO 8601 without date parts: hours are the largest unit$limit = Duration::fromIso8601DurationString('PT1H30M');
var_dump(Duration::compare($backoff, $limit)); // int(-1)
The attribute introduced for methods in 8.3 and extended to properties in 8.5 now covers class constants and enum cases. The compiler errors if nothing is actually being overridden.
Overriding a parent constant by accident is a classic silent bug. Marking the intent makes refactorings safe: rename the parent constant and the child fails to compile instead of quietly defining a new one.
override-constant.php
class Base
{
protectedconst RETRIES = 3;
}
class Worker extends Base
{
#[\Override] // compile error if Base had no RETRIESpublicconst RETRIES = 5;
}
enum Level: intimplements HasDefault
{
#[\Override] // enum cases may override interface constantscaseDefault = 0;
}
Enums may now implement __debugInfo(); var_dump() keeps the enum(Foo::Bar) header and appends whatever the method returns.
Enum output in dumps and debuggers was fixed and terse. Backed enums with meaningful values, or cases that map to configuration, can now explain themselves. __toString() on enums was discussed and rejected in favour of this narrower change.
A /** … */ comment placed before or after a parameter is preserved by the engine and returned by the new ReflectionParameter::getDocComment().
Parameter documentation no longer has to repeat name and type in a @param line above the function, where it silently drifts out of sync. Attribute-driven frameworks and API generators get the text straight from reflection.
param-doccomment.php
/** Search for entries matching a query */function search(
/** Terms to look for in the index */string$query,
/** Maximum number of entries returned */int$limit = 10,
): array {
return [];
}
foreach (new ReflectionFunction('search')->getParameters() as$p) {
echo$p->name, ': ', $p->getDocComment(), PHP_EOL;
}
// query: /** Terms to look for in the index */// limit: /** Maximum number of entries returned */
A static closure that captures no variables and declares no static variables is created once and reused. Two such closures from the same source location are now identical objects.
Hot paths that create the same closure per iteration stop allocating. The second part of the RFC, inferring static for closures that never use $this, was pulled after the vote when an edge case with callable strings surfaced; mark closures static yourself to get the full benefit.
closure-cache.php
function handler(): Closure
{
// static, captures nothing, no static variables => cachedreturnstaticfn (int$x): int => $x * 2;
}
// PHP 8.5: two instances. PHP 8.6: the same cached Closure.
var_dump(handler() === handler()); // bool(true)
clamp($value, $min, $max) returns the value if it lies within the bounds, otherwise the nearest bound. It accepts anything comparable, including strings and DateTime objects, and throws a ValueError when $min is greater than $max or either bound is NAN.
The usual max($min, min($max, $v)) idiom hides two bugs: swapped bounds and NAN passing through silently. The native version validates both and is slightly faster than the userland ternary.
clamp.php
var_dump(clamp(150, min: 0, max: 100)); // int(100)
var_dump(clamp(42, 0, 100)); // int(42)
var_dump(clamp(2.5, 1, 3)); // float(2.5)// Any comparable type follows the usual comparison rulesecho clamp(
new DateTimeImmutable('2026-12-24'),
new DateTimeImmutable('2026-11-19'),
new DateTimeImmutable('2026-12-01'),
)->format('Y-m-d'); // 2026-12-01
clamp(4, 8, 6); // ValueError: $min must be smaller than or equal to $max
A stream context can now switch error handling to exceptions or to silent collection: error_mode (Error, Exception, Silent), error_store and an error_handler callback. Errors arrive as StreamError value objects with a StreamErrorCode enum case, wrapper name, severity and the affected path.
Until now file_get_contents() and friends reported failure through warnings you had to trap with an error handler and parse as text. With StreamErrorMode::Exception a failed open is a StreamException with machine-readable codes; the defaults are untouched, so nothing changes unless you ask.
stream-errors.php
$ctx = stream_context_create([
'stream' => ['error_mode' => StreamErrorMode::Exception],
]);
try {
$config = file_get_contents('/etc/app/missing.ini', false, $ctx);
} catch (StreamException $e) {
foreach ($e->getErrors() as$error) {
echo$error->code->name, ': ', $error->message, PHP_EOL;
// OpenFailed: Failed to open stream: No such file or directory
}
}
// Or stay silent and inspect afterwards$ctx = stream_context_create(['stream' => ['error_mode' => StreamErrorMode::Silent]]);
@file_get_contents('/nope', false, $ctx);
var_dump(stream_last_errors()[0]->code === StreamErrorCode::OpenFailed); // bool(true)
A native multiplexing API on top of epoll, kqueue, event ports, WSAPoll or poll(): Io\Poll\Context watches handles for Read, Write, Error and HangUp events, supports one-shot and edge-triggered modes and returns the triggered watchers from wait().
stream_select() is O(n), capped at 1024 descriptors on many systems and knows nothing about modern kernels. Event loops such as Revolt, ReactPHP or AMPHP can now run on a built-in backend instead of ext-uv or ext-event, and FrankenPHP-style runtimes get a shared internal API. Only stream resources are pollable in this first version.
Uri\Rfc3986\UriBuilder and Uri\WhatWg\UrlBuilder assemble a URI in one object and validate on build(); getUriType() distinguishes absolute URIs from path and network references, getHostType() reports IPv4, IPv6, domain or opaque hosts, and isSpecialScheme() tells you whether WHATWG special-scheme rules apply.
The 8.5 URI classes were immutable withers only, so building a URI from scratch allocated an object per step and depended on call order. The builder removes both problems. The percent-encoding helper from the same RFC has not landed in the beta yet.
uri-builder.php
// One object instead of a wither chain that allocates per step$uri = new Uri\Rfc3986\UriBuilder()
->setScheme('https')
->setHost('php-net.pro')
->setPath('/en/php-8.6')
->setQuery('utm_source=dispatch')
->build();
echo$uri->toRawString(); // https://php-net.pro/en/php-8.6?utm_source=dispatchecho$uri->getUriType()->name; // Uri (vs. RelativePathReference ...)$url = Uri\WhatWg\Url::parse('https://[2001:db8::1]/');
echo$url->getHostType()->name; // IPv6
var_dump($url->isSpecialScheme()); // bool(true) for http(s), ws(s), ftp, file
A global unbacked enum with the cases Ascending and Descending, meant as the one sort-direction type PHP, frameworks and libraries converge on.
Doctrine, query builders and every ORM ship their own Order enum. A shared type makes signatures interoperable; a union with the old string or int flags keeps existing APIs compatible while they migrate.
sort-direction.php
// One shared type instead of SORT_ASC, 'DESC', bool $ascending ...function orderBy(string$column, SortDirection $direction = SortDirection::Ascending): string
{
return sprintf('ORDER BY %s %s', $column, match ($direction) {
SortDirection::Ascending => 'ASC',
SortDirection::Descending => 'DESC',
});
}
echo orderBy('created_at', SortDirection::Descending); // ORDER BY created_at DESC
Both methods answer whether a read or write would succeed from a given scope, optionally for a concrete object. They understand readonly, asymmetric visibility, hooks and magic accessors, which isPublic() never did.
Since readonly (8.1) and private(set) (8.4) a public flag no longer means writable. Serializers, hydrators and mappers had to reimplement the engine’s rules; now they ask the engine.
is-writable.php
finalclass Money
{
publicfunction __construct(
publicreadonlyint$amount,
publicprivate(set) string$currency = 'EUR',
) {}
}
$amount = new ReflectionProperty(Money::class, 'amount');
$currency = new ReflectionProperty(Money::class, 'currency');
// isPublic() says "true" for both; the new methods answer the real question
var_dump($amount->isWritable(null)); // bool(false) from global scope
var_dump($currency->isWritable(Money::class)); // bool(true) from inside
var_dump($currency->isReadable(null)); // bool(true)
The Perl-style modifiers < and > now work on the signed and unsigned integer codes s, l, q, S, L, Q and on the float codes f and d. Applying them to codes with inherent endianness throws a ValueError.
Reading a signed little-endian int32 used to take three unpack() calls and bit shifts. Binary protocol and file-format parsers become one format string. Watch the naming rule: unpack('s<value') now yields the key value, not <value.
pack-endianness.php
// Signed little-endian int32 used to need a manual bit dance$bytes = pack('l<', -16909060);
print_r(unpack('l<value', $bytes)); // [value] => -16909060// Modifiers work on s/S, l/L, q/Q and (new) f/d$frame = pack('s>l<d>', -258, 1024, 3.14159);
[$id, $len, $ratio] = array_values(unpack('s>id/l<len/d>ratio', $frame));
pack('a<', 'x'); // ValueError: modifier not allowed on "a"
Escapes and wraps a string in single quotes in one step, the mysqli counterpart of PDO::quote(). Available as mysqli::quote_string() and mysqli_quote_string().
real_escape_string() only ever worked correctly inside single quotes; combined with double quotes and NO_BACKSLASH_ESCAPES it was an injection hole. The new function removes the manual quoting step where prepared statements are not an option, as in query builders that must render full SQL.
mysqli-quote.php
// Before: quotes by hand, injection possible under NO_BACKSLASH_ESCAPES$sql = sprintf("SELECT id FROM users WHERE name = '%s'", $mysqli->real_escape_string($name));
// PHP 8.6: escaped AND quoted, always single quotes (like PDO::quote())$sql = sprintf('SELECT id FROM users WHERE name = %s', $mysqli->quote_string($name));
// SELECT id FROM users WHERE name = 'Don\'t!'
Stream contexts gain session_data, session_new_cb, session_cache and related options with an Openssl\Session object that can be exported and imported; psk_client_cb/psk_server_cb enable external pre-shared keys, and early_data adds TLS 1.3 0-RTT.
Every PHP request used to start a fresh TLS handshake. Persisting the session in Redis or APCu saves a round trip per outgoing HTTPS call, and servers written in PHP can back their session cache with any store.
tls-resume.php
// Skip the full handshake on the next request by persisting the session$stored = $cache->get('tls:api.example.com');
$ctx = stream_context_create(['ssl' => [
'peer_name' => 'api.example.com',
'session_data' => $stored ? Openssl\Session::import($stored) : null,
'session_new_cb' => function ($stream, Openssl\Session $session) use ($cache): void {
$cache->set('tls:api.example.com', $session->export(), $session->getTimeout());
},
]]);
$fp = stream_socket_client('tls://api.example.com:443', context: $ctx);
grapheme_strrev() reverses by grapheme cluster, so emoji sequences and combining marks stay intact. Locale::getDisplayKeyword() and getDisplayKeywordValue() localise locale keywords, IntlNumberRangeFormatter formats numeric ranges, SpoofChecker learns bidirectional confusables and IntlDatePatternGenerator can return skeletons.
The intl extension keeps closing gaps against ICU. Most of these are small, but the display-keyword pair completes a family that has been incomplete since PHP 5.3.
intl.php
// Reverses grapheme clusters, so family emoji and combining marks surviveecho grapheme_strrev("PHP 👨👩👧 8.6"); // 6.8 👨👩👧 PHP// Localised labels for locale keywordsecho Locale::getDisplayKeyword('calendar', 'de'); // Kalenderecho Locale::getDisplayKeywordValue('ja@calendar=japanese', 'calendar', 'en'); // Japanese Calendar// Number ranges with ICU collapsing rules ("1–5 km")$f = new IntlNumberRangeFormatter('measure-unit/length-kilometer', 'en');
echo$f->format(1, 5);
Three INI defaults change: session.use_strict_mode=1 rejects session IDs the server did not issue, session.cookie_httponly=1 hides the cookie from JavaScript and session.cookie_samesite=Lax stops it travelling with cross-site POSTs.
This is the one 8.6 change most likely to reach production unnoticed. Frameworks set these years ago, plain PHP applications often did not. Cross-subdomain hand-offs of a session ID, JavaScript reading PHPSESSID and cross-site form posts break; each has an explicit opt-out in php.ini.
php.ini (new built-in defaults)
; PHP 8.6 ships these as built-in defaults (were 0 / 0 / unset)
session.use_strict_mode = 1 ; unknown session IDs are rejected (fixation)
session.cookie_httponly = 1 ; document.cookie can no longer read it
session.cookie_samesite = Lax ; not sent on cross-site POSTs
; Only if you really depend on the old behaviour:; session.cookie_samesite = None (requires session.cookie_secure = 1)
With error_include_args=1 warnings raised by built-in functions include the arguments they were called with, using the same machinery as exception backtraces: #[\SensitiveParameter] values stay masked and zend.exception_string_param_max_len trims long strings.
chmod(): Operation not permitted tells you nothing; chmod('/var/app/cache', 511) does. The setting ships off by default in the beta because of log size and PII concerns, so enable it deliberately in staging first.
terminal
$ php-d error_include_args=1 -r'chmod("/var/app/cache", 0777);'Warning: chmod('/var/app/cache', 511): Operation not permitted in ...
# #[\SensitiveParameter] values stay masked, long strings are cut# by zend.exception_string_param_max_len; the INI defaults to 0 (off)
A php://filter URL with more than 16 filters now emits E_DEPRECATED; the limit can be raised per context via filter.max_filter_count and stream_filter_append() remains unlimited.
Chains of convert.iconv and base64 filters are the standard tool to turn a local file inclusion into remote code execution. Legitimate code rarely stacks more than three filters, so the cap removes the exploit class without an INI switch to forget.
filter-chains.php
// Still fine: two filters
file_get_contents('php://filter/string.toupper|string.rot13/resource=data://text/plain,hello');
// 17+ filters in one URL (the LFI-to-RCE gadget) now emits E_DEPRECATED,// a later version turns it into an error. Raise the cap only on purpose:$ctx = stream_context_create(['php' => ['filter.max_filter_count' => 32]]);
return with a value in a constructor or destructor emits a compile-time deprecation, as does turning either into a generator with yield. A bare return; to skip the rest of the body remains legal.
Constructors cannot declare a return type, so nothing stopped return $this; from creeping in; an audit of the top 4000 Composer packages found 77 such statements. PHP 9 turns the deprecation into an error.
constructor-return.php
class Connection
{
publicfunction __construct()
{
return$this; // Deprecated: Returning a value from a constructor is deprecated
}
publicfunction __destruct()
{
if (!$this->open) {
return; // bare "return;" stays legal
}
}
}
All mbregex functions (mb_ereg, mb_ereg_replace, mb_split, mb_eregi, …), the mbstring.regex_* INI settings and MB_ONIGURUMA_VERSION are deprecated in 8.6 and scheduled for removal in 9.0 because the Oniguruma library stopped being maintained in April 2025.
PCRE with the u modifier covers UTF-8 use cases. Code that relies on non-UTF-8 multibyte regex will need the external mb_onig package the RFC author maintains. Grep for mb_ereg and mb_split now; 147 occurrences were found across the top Composer packages.
mbregex.php
// Deprecated in 8.6, removed in 9.0 (Oniguruma is unmaintained since 2025-04)$parts = mb_split('\s+', $text);
$clean = mb_ereg_replace('[^\w]', '', $text);
// PCRE with the /u modifier covers the same ground$parts = preg_split('/\s+/u', $text);
$clean = preg_replace('/[^\w]/u', '', $text);
Building from git needs autoconf 2.71, persistent MySQL connections require MySQL 5.7.3 or MariaDB 10.2.4 for COM_RESET_CONNECTION, and the official Windows builds move to Visual Studio 2026, OpenSSL 4 and libxml2 2.15.
autoconf 2.71 (only for builds from git; release tarballs ship configure)
MySQL ≥ 5.7.3 / MariaDB ≥ 10.2.4 for persistent connections in mysqlnd and PDO
Windows: VS 2026 (VS18) toolchain, OpenSSL 4, libxml2 2.15.3
Several patterns now compile to cheaper opcodes, ZTS builds got faster across the board and the JIT reaches Apple Silicon in threaded builds.
printf() with only %s and %d compiles to string interpolation.
array_map() with a first-class callable or partial application compiles to a foreach loop without intermediate Closures.
Arguments to known constructors (new self()) are passed more efficiently.
The TAILCALL VM is faster and now available on Windows with Clang 19+.
JIT for ZTS builds on Apple Silicon; general ZTS speed-ups.
Faster str_ends_with(), str_split(), array_fill_keys(), array_intersect(), array_sum()/array_product() on int arrays, array_unshift(), array_walk(), json_encode() with pretty print.
compiled-away.php
// Both lines compile to the same string interpolation, no function call
printf("%s has %d items\n", $name, $count);
echo"$name has $count items\n";
// First-class callables and partials in array_map() become a plain loop:// no intermediate Closure, no userland-callback overhead, JIT-friendly$upper = array_map(strtoupper(...), $names);
$prefixed = array_map(str_replace('_', '-', ?), $keys);
AES192/AES256 security protocols for SNMPv3 where net-snmp supports them, snmp_init_mib() to reset the MIB tree, and new MIB parsing and output controls (snmp_set_mib_option(), snmp_set_output_option(), OID and string output formats).
Deprecations
35 separate ballots in one RFC, each decided on its own with a 2/3 majority of yes/no votes
Every row links to the ballot page with all named votes. Deprecated code keeps working in 8.6 and emits E_DEPRECATED; the removal is planned for PHP 9.0. RFC ↗ · The 8.6 Vote Watch →
Also deprecated, via separate RFCs and changelog entries
Returning a value from __construct() or __destruct(), and making either a generator. RFC ↗
The whole mb_ereg*() / mb_split() family and the mbstring.regex_* INI settings (Oniguruma end-of-life). RFC ↗
More than 16 filters in a single php://filter URL without setting filter.max_filter_count. RFC ↗
Declaring __debugInfo() with an array|null or ?array return type; use array.
GMP shift and ** operators with a float right operand that loses precision when converted to int.
Breaking changes
behaviour that changes without a deprecation period
session.use_strict_mode, session.cookie_httponly and session.cookie_samesite default to 1, 1 and Lax. Cross-site form posts and JavaScript access to the session cookie stop working unless you opt out.
trim(), ltrim() and rtrim() strip the form feed character \f by default, like Python, JavaScript, Rust and Go.
unpack() reads a < or > right after a format code as an endianness modifier, so "s<value" yields the key value instead of <value.
?? and empty() on a magic property no longer call __get() once __isset() has written the property into the object; the written value is returned directly.
preg_grep() returns false instead of a partial array on a PCRE execution error, consistent with the other preg_* functions.
array_intersect() converts values to strings while scanning instead of during sorting, which changes the number and order of conversion warnings and __toString() calls.
Dozens of functions now throw a ValueError for NUL bytes in path, locale, environment and similar arguments instead of silently truncating: file_exists(), is_file(), stat(), setlocale(), getenv(), putenv(), parse_str(), proc_open() and more.
sodium_crypto_pwhash*() throw ValueError instead of SodiumException for out-of-range opslimit/memlimit arguments.
Two stateless closures from the same source location are identical (===) because of the new closure cache; objects that used to sit in a reference cycle with a closure may be destructed earlier.
DOM properties documented as @readonly (DOMNode::$nodeType and others) are declared public private(set); writes fail with a different error message.
A hard out-of-memory now calls abort() instead of exit(1): exit code 134 and possibly a core dump.
The pipe assignment operator |>= was declined with 14 yes against 12 no, 54 percent and well short of the 2/3 majority. $x = $x |> f(...) stays the way to write it; the pattern is the closest result of the whole 8.6 season.
Pattern matching with an is keyword is still under discussion and did not go to a vote for 8.6. The is and _ identifier deprecations in this release reserve the syntax for a later version.
the order that finds problems before your users do
Run your test suite on the beta today
The official php:8.6-rc image tracks the latest pre-release. Lint the code base first, then run the tests with deprecations turned into failures; the 8.6 deprecation wave is mostly in library code, so vendor/ matters as much as src/.
terminal
# Lint every file with the 8.6 parser, then run the suite with deprecations as failuresdocker run --rm-v"$PWD":/app -w /app php:8.6-rc-cli \
sh -c'find src tests -name "*.php" -print0 | xargs -0 -n1 -P4 php -l | grep -v "No syntax errors"'docker run --rm-v"$PWD":/app -w /app php:8.6-rc-cli \
php -d error_reporting=E_ALL vendor/bin/phpunit --fail-on-deprecation
Grep for the mechanical replacements
Most 8.6 deprecations have a one-to-one replacement that a search and replace fixes in minutes: is_double(), is_integer(), is_long(), doubleval(), spl_object_hash(), strcoll(), mysqli::stmt_init(). Rector and PHP_CodeSniffer rule sets for 8.6 are on their way; the list on this page is the spec.
terminal
# The one-to-one replacements, including vendor/grep-rnE'\b(is_double|is_integer|is_long|doubleval|strcoll|metaphone|spl_classes|spl_object_hash|mysqli_get_charset|mysqli_stmt_init|mb_ereg[a-z_]*|mb_split|mb_eregi)\s*\(' \
--include='*.php' src vendor
# Identifiers that are reserved from 8.6 ongrep-rnE'\b(function|class|interface|enum|const)\s+(let|is|_|readonly)\b'--include='*.php' src
Decide on the session cookie defaults
If your application reads the session cookie from JavaScript, hands session IDs across subdomains or receives cross-site POSTs into a session, set the three session.* directives explicitly before upgrading. Everything else gains the protection for free.
PECL extensions need rebuilding for every minor; the polling API, stream errors and Duration are new internal APIs that extension authors may adopt. Persistent MySQL connections need MySQL 5.7.3+ or MariaDB 10.2.4+; mbregex users need a plan for PHP 9.
Add 8.6 to the CI matrix, keep 8.5 green
Run 8.6 as an allowed-to-fail job until GA on November 19, 2026, then flip it to required. Composer’s platform check will refuse packages whose constraints stop at 8.5, which is your list of upstream issues to file.
General availability is scheduled for November 19, 2026. Before that: beta 3 on 10 September, the hard feature freeze on 22 September, and four release candidates from 24 September to 5 November 2026.
What are the headline features of PHP 8.6?
Partial function application, default values on readonly properties, property writes on objects in constants, the Time\Duration class, clamp(), a native polling API, structured stream errors, #[\Override] for constants, doc comments on parameters and secure session cookie defaults.
Does PHP 8.6 have pattern matching?
No. The pattern matching RFC is still under discussion. PHP 8.6 only reserves the is keyword and the _ wildcard by deprecating them as identifiers.
Was the pipe assignment operator |>= accepted?
No. It received 14 yes and 12 no votes, a majority but not the required two thirds. The pipe operator |> from PHP 8.5 is unaffected.
Is list() deprecated in PHP 8.6?
No. The ballot ended 23 to 23, so the construct stays. Short array destructuring [$a, $b] = … remains the recommended style.
What breaks when I upgrade to PHP 8.6?
The most visible change is the set of secure session defaults: strict mode, HttpOnly and SameSite=Lax. Beyond that, trim() strips form feeds, unpack() treats < and > after a format code as modifiers, and many functions throw ValueError on NUL bytes. Deprecations only emit notices in 8.6.
Which MySQL or MariaDB version does PHP 8.6 need?
Persistent connections require MySQL 5.7.3 or MariaDB 10.2.4 because mysqlnd now resets connections with COM_RESET_CONNECTION. Older servers still work with regular connections.
How can I try PHP 8.6 before the release?
Use the official Docker image php:8.6-rc-cli, which follows the latest beta or release candidate, or download the source from qa.php.net. Every example on this page marked with a check mark was run on the current build.
Sources and method. Feature list from the php-src UPGRADING file and the RFC index on wiki.php.net; vote counts mirrored from the public ballots (2/3 of yes/no votes required, abstentions do not count); release dates from the 8.6 release schedule. Examples marked “ran on PHP 8.6.0beta2” were executed on the official Docker image; their comments show the actual output. Reviewed by a human before publication and updated with every beta.