Monday, August 31, 2026
EN

Subscribe in your feed reader — pick your desks:

https://php-net.pro/en/news/feed.atom

php-net.pro · the dev news broadsheet

The Dev Dispatch

The Dev Dispatch · release guide

PHP 8.6

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.

27RFCs implemented
31of 35 deprecation ballots passed
1,765votes cast by core contributors
80days to GA · Nov 19, 2026

Current build: PHP 8.6.0beta2 · Aug 27, 2026 Release notes & discussion → QA builds on qa.php.net ↗

Language

syntax and object model

Partial function application

RFCs: partial_function_application_v2, partial_function_application_optional_placeholder

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}
✓ ran on PHP 8.6.0beta2

Default values for readonly properties

RFC: readonly_property_defaults 24–0 accepted

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
{
    public string $name { get; }
    public array $steps { get; }
}

// Fixed metadata without constructor boilerplate:
// the default IS the initialising assignment
final readonly class ChangelogIngestor implements Ingestor
{
    public string $name = 'changelog';
    public array $steps = [ParseMarkdown::class, ExtractVersions::class];
}

echo new ChangelogIngestor()->name;      // changelog
✓ ran on PHP 8.6.0beta2

Property writes on objects held in constants

RFC: const_object_property_write 17–2 accepted

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)
✓ ran on PHP 8.6.0beta2

Time\Duration

RFC: duration_class 35–1 accepted 30 multiplyBy, divideBy, neg… · 2 mul, divBy, neg, abs decided

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)
✓ ran on PHP 8.6.0beta2

#[\Override] on class constants

RFC: override_constants

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
{
    protected const RETRIES = 3;
}

class Worker extends Base
{
    #[\Override]                 // compile error if Base had no RETRIES
    public const RETRIES = 5;
}

enum Level: int implements HasDefault
{
    #[\Override]                 // enum cases may override interface constants
    case Default = 0;
}
✓ ran on PHP 8.6.0beta2

__debugInfo() on enums

RFC: debugable-enums

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.

enum-debuginfo.php
enum Status: string
{
    case Active = 'active';

    public function __debugInfo(): array
    {
        return [self::class . '::' . $this->name . ' = ' . $this->value];
    }
}

var_dump(Status::Active);
// enum(Status::Active) (1) {
//   [0]=>
//   string(23) "Status::Active = active"
// }
✓ ran on PHP 8.6.0beta2

Doc comments on parameters

RFC: parameter-doccomments

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 */
✓ ran on PHP 8.6.0beta2

Stateless closures are cached

RFC: closure-optimizations

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 => cached
    return static fn (int $x): int => $x * 2;
}

// PHP 8.5: two instances. PHP 8.6: the same cached Closure.
var_dump(handler() === handler());       // bool(true)
✓ ran on PHP 8.6.0beta2

Standard library

new functions, classes and APIs

clamp()

RFC: clamp_v2

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 rules
echo 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
✓ ran on PHP 8.6.0beta2

Structured stream errors

RFC: stream_errors

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)
✓ ran on PHP 8.6.0beta2

Polling API (Io\Poll)

RFC: poll_api

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.

poll-server.php
use Io\Poll\{Context, Event};
use Time\Duration;

$poll = new Context();                              // epoll / kqueue / WSAPoll
$server = stream_socket_server('tcp://127.0.0.1:8080');
stream_set_blocking($server, false);
$poll->add(new StreamPollHandle($server), [Event::Read], 'server');

while ($watchers = $poll->wait(Duration::fromSeconds(1))) {
    foreach ($watchers as $w) {
        if ($w->getData() === 'server') {
            $client = stream_socket_accept($w->getHandle()->getStream(), 0);
            $poll->add(new StreamPollHandle($client), [Event::Read], 'client');
        } elseif ($w->hasTriggered(Event::Read)) {
            $stream = $w->getHandle()->getStream();
            fwrite($stream, 'echo: ' . fread($stream, 8192));
        }
    }
}
✓ ran on PHP 8.6.0beta2

ext/uri: builders and type detection

RFC: uri_followup

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=dispatch
echo $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
✓ ran on PHP 8.6.0beta2

enum SortDirection

RFC: sort_direction_enum

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
✓ ran on PHP 8.6.0beta2

ReflectionProperty::isReadable() and isWritable()

RFC: isreadable-iswriteable

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
final class Money
{
    public function __construct(
        public readonly int $amount,
        public private(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)
✓ ran on PHP 8.6.0beta2

pack()/unpack() endianness modifiers

RFCs: pack-unpack-endianness-signed-integers-support, pack-unpack-float-endianness-modifier

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"
✓ ran on PHP 8.6.0beta2

mysqli::quote_string()

RFC: mysqli_quote_string

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!'

TLS session resumption, PSK and 0-RTT

RFC: tls_session_resumption_api

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);

Intl: grapheme_strrev() and more

RFCs: grapheme_strrev, getdisplaykeyword_and_getdisplaykeywordvalue

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 survive
echo grapheme_strrev("PHP 👨‍👩‍👧 8.6");        // 6.8 👨‍👩‍👧 PHP

// Localised labels for locale keywords
echo Locale::getDisplayKeyword('calendar', 'de');                 // Kalender
echo 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);

Smaller additions worth knowing

The changelog is long; these are the entries most likely to show up in application code.

  • json_last_error_msg() and JsonException now name the offset of the failing token.
  • curl_getinfo() reports size_delivered, and CURLOPT_SEEKFUNCTION lets libcurl rewind a streamed request body on redirects.
  • PDO_PGSQL: Pdo\Pgsql::ATTR_CHUNK_SIZE fetches rows in chunks with libpq 17.
  • Stream sockets accept so_keepalive, tcp_keepidle, tcp_keepintvl, tcp_keepcnt, so_linger and so_reuseaddr context options.
  • openssl_sign()/openssl_verify() take a $salt_length for RSA-PSS; openssl_x509_parse() lists critical extensions.
  • ZipArchive::openString() and closeString() work on in-memory archives.
  • gmp_prevprime() and the side-channel resistant gmp_powm_sec().
  • Reference assignment into a WeakMap no longer requires the key to exist.
  • ini_get_all() exposes builtin_default_value per directive.
  • The CLI development server answers the HTTP QUERY method.
  • array_filter() gains the explicit ARRAY_FILTER_USE_VALUE constant; finfo_file() works on remote streams.

Security & operations

defaults that change what production does

Secure session defaults

RFC: session_security_defaults

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)
✓ ran on PHP 8.6.0beta2

Function arguments in error messages

RFC: display_error_function_args

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)
✓ ran on PHP 8.6.0beta2

php://filter chains capped at 16

RFC: limit-maximum-number-of-filter-chains

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]]);

Returning from __construct()/__destruct() is deprecated

RFC: deprecate-return-value-from-construct

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
{
    public function __construct()
    {
        return $this;   // Deprecated: Returning a value from a constructor is deprecated
    }

    public function __destruct()
    {
        if (!$this->open) {
            return;     // bare "return;" stays legal
        }
    }
}
✓ ran on PHP 8.6.0beta2

mb_ereg*() deprecated: Oniguruma is end-of-life

RFC: eol-oniguruma

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);

Minimum versions and platforms

RFC: min_supported_versions_php_8_6 27–2 accepted 26–0 accepted

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
  • PHP_OS_FAMILY gains the value AIX

Engine & performance

what got faster without touching your code

Performance

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);

SNMP

RFC: snmp_improvements_2026

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 →

Deprecated in 8.6Use insteadBallot
is_double() is_float() 40–2 accepted
is_integer() is_int() 39–2 accepted
is_long() is_int() 39–2 accepted
doubleval() floatval() 38–2 accepted
strcoll() Collator::compare() 39–2 accepted
SORT_LOCALE_STRING Collator::sort() / asort() 38–2 accepted
metaphone() userland phonetic library 20–8 accepted
spl_classes() ReflectionExtension::getClassNames() 43–0 accepted
spl_object_hash() spl_object_id() 26–9 accepted
ArrayIterator::asort() / ksort() / setFlags() … ArrayObject or native arrays 42–1 accepted
SplFileObject::fgetcsv() / fputcsv() fgetcsv() / fputcsv() on a stream 25–5 accepted
mysqli::stmt_init() mysqli::prepare() 40–0 accepted
mysqli_get_charset() mysqli_character_set_name() 43–0 accepted
define($n, $v, $case_insensitive) define($n, $v) 41–0 accepted
is_subclass_of("Cls", …, allow_string: false) class_exists() + allow_string: true 37–0 accepted
is_a("Cls", …, allow_string: false) class_exists() + allow_string: true 38–0 accepted
ReflectionProperty::setValue($foreignObject, …) pass an instance of the declaring class 43–1 accepted
ReflectionMethod::invoke($obj) on static methods invoke(null, …) 39–3 accepted
array_walk($object, …) array_walk(get_object_vars($object), …) 41–3 accepted
deflate_init($enc, $optionsObject) get_object_vars($optionsObject) 39–4 accepted
zlib.* filter with object params get_object_vars() 38–4 accepted
bzip2.* filter with object params get_object_vars() 38–4 accepted
mb_convert_variables(…, $object) get_object_vars() 39–2 accepted
http_build_query($object) http_build_query(get_object_vars($object)) 21–10 accepted
session handler without create_sid() / validateId() implement both methods 26–2 accepted
return inside finally {} return after the try/finally 39–3 accepted
function readonly() rename the function 39–1 accepted
const namespace = … rename the constant 29–8 accepted
let as class / function / constant name rename (reserved for a future keyword) 24–11 accepted
is as class / function / constant name rename (reserved for pattern matching) 29–10 accepted
const _ / use Foo as _ rename (reserved as wildcard) 34–4 accepted

Voted down: these stay four proposals missed the 2/3 threshold and remain untouched

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.

php-src UPGRADING ↗

Not in PHP 8.6

what people expect to find here and will not

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.

RFC ↗ 14–12 declined

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.

RFC ↗

Deprecating list() ended in a 23 to 23 tie. A tie is not a 2/3 majority, so list($a, $b) = … remains fully supported.

RFC ↗ 23–23 declined

Reserving in, out and inout as identifiers failed 8 to 21; code using them as names is unaffected.

RFC ↗ 8–21 declined

Upgrade guide

the order that finds problems before your users do
  1. 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 failures
    docker 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
  2. 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 on
    grep -rnE '\b(function|class|interface|enum|const)\s+(let|is|_|readonly)\b' --include='*.php' src
  3. 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.

    ↑ Secure session defaults

  4. Check extensions and platform requirements

    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.

  5. 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.

    .github/workflows/ci.yml
    jobs:
      test:
        strategy:
          fail-fast: false
          matrix:
            php: ['8.4', '8.5', '8.6']
        continue-on-error: ${{ matrix.php == '8.6' }}   # required after GA
        steps:
          - uses: shivammathur/setup-php@v2
            with:
              php-version: ${{ matrix.php }}
          - run: composer install --ignore-platform-req=php+
          - run: vendor/bin/phpunit --fail-on-deprecation

Our PHP 8.6 coverage

the story as it happened: RFC votes, betas and the deprecation debate

The 8.6 Vote Watch: all 42 ballots on a timeline →

Frequently asked

When is PHP 8.6 released?
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.

back to the front page