PHP 8.6 Features & Updates

PHP 8.6.0 RC3 was tagged on 8 October 2026, and general availability is scheduled for 19 November 2026, according to the PHP 8.6 release schedule. RC4 and RC5 are still planned, so details can change. Everything below is checked against the UPGRADING and NEWS files of the RC3 tag, not against RFC target versions. An RFC that says “PHP 8.6” proves nothing until the code is merged.

My short take: PHP 8.6 is a release with one language feature that changes how everyday code reads — partial function application — plus a handful of small, well-designed additions and a long list of tightened edges. The tightened edges are what will cost you time, and they sit where nobody looks until something breaks: session configuration, argument validation and old SPL APIs.

Partial function application is the feature to learn. Session defaults and stricter argument validation are the changes to test. Most deprecations are mechanical and can wait for a quiet sprint — mbregex is the exception.

Research cutoff: 11 October 2026. This article is based on PHP 8.6.0 RC3 and does not cover anything published after that date.

Throughout the article I separate two questions: how useful is an addition (developer value), and how could existing code fail (migration severity). A one-line default change can be useless to you and still break your login.

looking for DMARC as a Service? Look no further, visit dmarcflow.com

Partial function application

Partial function application (PFA) was accepted unanimously and merged in 8.6 alpha3. It lets you call a function with some arguments and placeholders for the rest. Instead of executing, the call returns a Closure that waits for the missing arguments. There are two placeholders:

  • ? stands for exactly one argument at this position.
  • ... stands for zero or more remaining arguments. You already know strlen(...) from first-class callable syntax - conceptually, that is a partial application with no bound arguments.

The classic before/after looks like this:

// PHP 8.5
$slugs = array_map(
fn (string $title): string => str_replace(' ', '-', $title),
$titles,
);

// PHP 8.6
$slugs = array_map(str_replace(' ', '-', ?), $titles);

The real value is that the intent is visible: “this is str_replace() with the first two arguments fixed", without a throwaway parameter name. The engine benefits too: according to UPGRADING, array_map() with a first-class callable or a partial application is now compiled into a loop, without creating intermediate closures.

PFA and the pipe operator

PHP 8.5 introduced the pipe operator, but the right-hand side must be a callable that takes exactly one argument. Everything else needed a parenthesised arrow function. PFA fills that gap, which is why the PHP Foundation called it the perfect complement for the pipe operator:

// PHP 8.5
$slug = $title
|> trim(...)
|> (fn (string $s): string => str_replace(' ', '-', $s))
|> strtolower(...);

// PHP 8.6
$slug = $title
|> trim(...)
|> str_replace(' ', '-', ?)
|> strtolower(...);

There is a detail here that matters for hot paths: when a partial application with a single placeholder sits directly on the right side of |>, the compiler turns it into a direct function call. No closure is created at all. Forms with two or more placeholders, such as foo(?, ?), still create a closure.

Arguments are bound at creation time

This is the semantic difference to arrow functions that will bite people. Bound arguments are evaluated once, when the partial is created, and the values are stored in the closure. An arrow function evaluates its body on every call.

function logLine(string $time, string $message): string
{
return "[$time] $message";
}

$partial = logLine(date('H:i:s'), ?); // date() runs now, once
$arrow = fn (string $m): string => logLine(date('H:i:s'), $m); // date() runs on every call

// Ten seconds later:
echo $partial('cache warmed'); // timestamp from creation time
echo $arrow('cache warmed'); // current timestamp

Usually that is what you want, but when you convert arrow functions into partials, check every bound argument that is not a constant or a plain variable.

Every ? creates a required parameter

The second rule comes from a follow-up RFC, PFA optional placeholders, which tightened the original semantics before release. Parameters created by ? are always required, even if the underlying parameter has a default. Parameters pulled in by ... keep their optionality. Optional parameters that no placeholder covers simply use their defaults.

function connect(string $host, int $port = 5432, int $timeout = 5): Connection
{
// ...
}

$a = connect(?, ?); // closure(string $host, int $port) - $port is now required
$a('db'); // ArgumentCountError: too few arguments

$b = connect(?, ...); // closure(string $host, int $port = 5432, int $timeout = 5)
$b('db'); // fine, defaults apply

Be careful with older blog posts and even the original RFC page: some of their examples show ? inheriting defaults. That is no longer the behaviour in RC3, which ships a dedicated test for the rule. I think this is the right design: a ? says "a value goes here".

Developer value: high. It removes a lot of small closures, works well with the pipe operator and has a clear evaluation model. Migration severity: none — it is new syntax.

Smaller additions worth knowing

Readonly properties with default values

Readonly properties may now declare a default value, including in readonly classes. There is no new syntax. The important rule: the default counts as the initialising assignment. You cannot “override” it in the constructor.

final class RetryPolicy
{
public readonly int $maxAttempts = 3;

public function __construct(int $maxAttempts)
{
$this->maxAttempts = $maxAttempts; // Error: Cannot modify readonly property
}
}

If a value should be configurable, use a promoted constructor parameter with a default instead. __clone() and clone-with may reinitialise the property once. Useful, but niche. For the bigger picture, see whether readonly properties replace getters.

clamp()

clamp(mixed $value, mixed $min, mixed $max): mixed returns the value if it lies within the bounds, otherwise the nearest bound (RFC). It uses standard PHP comparison, so it works for strings and DateTimeInterface objects as well as numbers.

// Before
$page = max(1, min($lastPage, $requestedPage));

// PHP 8.6
$page = clamp($requestedPage, 1, $lastPage);

The win is that clamp() throws a ValueError when $min exceeds $max or a bound is NAN. The nested version silently returns 1 when $lastPage is 0 for an empty result set; clamp() throws. Check that edge case before replacing every max(min()).

Time\Duration

Time\Duration is a final readonly class for exact, nanosecond-precise durations. Instances come from named factories instead of a constructor:

use Time\Duration;

$timeout = Duration::fromSeconds(2, 500_000_000); // 2.5 seconds
$budget = Duration::fromMinutes(1)->sub($timeout); // 57.5 seconds
$sla = Duration::fromIso8601DurationString('PT1H30M');

if ($elapsed > $budget) {
// comparison operators work; + and - do not, use add() and sub()
}

Two limitations to know: there is no integration with DateTime or DateInterval, and existing functions such as sleep() do not accept a Duration. Today the main benefit is typed configuration - no more int $timeout of unknown unit - and the polling API, which uses it for timeouts.

Behaviour changes, ranked by migration severity

Secure session defaults

I would test this first: it can break authentication without any code change. Three INI defaults change (RFC):

  • session.use_strict_mode: 0 to 1. PHP rejects session IDs it did not create. If you share sessions across subdomains or services by passing an externally chosen ID, that stops working.
  • session.cookie_httponly: 0 to 1. JavaScript can no longer read the session cookie via document.cookie.
  • session.cookie_samesite: unset to "Lax". The cookie is no longer sent on cross-site POST requests. Some SSO flows, for example SAML POST bindings, and payment provider callbacks that post back into a session are affected.

These are the settings most applications should have had anyway, and frameworks usually configure cookie options explicitly. The risk sits in custom session handling and legacy code. If you depend on an old value, set it explicitly before upgrading.

Stricter argument validation

Many functions now throw ValueError where they used to ignore, truncate or warn about invalid input. Examples from UPGRADING:

  • Invalid mode or flag values: array_filter() with an invalid $mode, array_change_key_case(), pathinfo() flags, scandir() sorting order.
  • Out-of-range numbers: number_format() decimals, sleep() and usleep() beyond the platform limits.
  • NUL bytes: getenv(), putenv(), parse_str(), setlocale() (previously truncated), and the whole family of filename functions such as file_exists(), is_file(), filesize() and stat().

The NUL-byte rule deserves attention. Code that passes user input to file_exists() used to get false for a path containing \0. Now it gets an uncaught exception, which turns a quiet "not found" into a 500 error. Better, but a behaviour change in a request path.

Smaller edges in the same category: trim(), ltrim() and rtrim() now also strip form feed (\f) by default. base_convert(), bindec(), hexdec() and octdec() raise a notice when they cannot convert precisely. preg_grep() returns false on an execution error, and GMP, Intl, GD, Sodium and other extensions tighten validation similarly.

Failure reporting

A new INI option, error_include_args, adds function arguments to error messages, using the same formatting as stack traces (RFC). It is off by default, so nothing changes unless you enable it. If you do, check what ends up in your logs - parameters marked with #[\SensitiveParameter] are redacted, everything else is not.

On a hard out-of-memory error, PHP now calls abort() instead of exit(1). The exit code changes to 134, and a core dump may be written. Adjust supervisors and alerting that match on exit codes.

Engine edge cases

A by-reference foreach may visit elements again after array compaction, and ?? and empty() on magic properties no longer call __get() if __isset() already wrote the property. Rare, but relevant for ORMs and proxy libraries.

Deprecations, grouped by effort

A deprecation is not a breaking change. Your code keeps running and emits E_DEPRECATED. Treat these as a PHP 9 backlog, not as upgrade blockers. The PHP 8.6 deprecations RFC lists each vote individually.

Mechanical — a search-and-replace or a Rector rule:

  • The non-canonical scalar function aliases is_integer(), is_long(), is_double() and doubleval(). Use is_int(), is_float() and floatval(). The (integer) and (boolean) casts are not part of this deprecation.
  • spl_object_hash() - use spl_object_id(). If you store the hash as a string key, the replacement is an integer, so check serialised data and cache keys.
  • The third argument of define(), spl_classes() and mysqli_get_charset().

Needs review — the fix depends on intent:

  • return inside a finally block. It silently discards exceptions, so every occurrence needs a decision about what the code was meant to do.
  • SPL APIs: the sort, flag and serialisation methods of ArrayIterator (asort(), ksort(), uasort(), setFlags(), serialize() and others), and the CSV methods of SplFileObject (fgetcsv(), fputcsv(), setCsvControl(), getCsvControl()).
  • Returning values from __construct() or __destruct(), calling is_a() or is_subclass_of() with a string while $allow_string is false, and passing objects to array_walk() or http_build_query().
  • Custom session handlers without create_sid() and validateId().

Strategic — plan it now: mbregex (mb_ereg(), mb_eregi(), mb_ereg_replace(), mb_split(), mb_regex_encoding() and friends) is deprecated because the underlying Oniguruma library reached end of maintenance (RFC). Removal is planned for PHP 9.0. Migrating to PCRE with the u modifier is usually straightforward, but the dialects differ, so write tests first.

Worth noting what did not pass: list() and the _() gettext alias were voted down and stay.

Runtime and infrastructure

Io\Poll. The new polling API provides an object-oriented event loop primitive: a Context with automatic backend selection (epoll, kqueue, poll, event ports, WSAPoll), watchers for stream handles, typed events and a wait() that takes a Time\Duration timeout. Note that the RFC page still shows an older integer-based wait() signature. This is for library authors - async runtimes, socket servers, process managers. Application developers benefit indirectly, once those libraries drop stream_select() workarounds.

Stream errors. Stream error handling can be switched from warnings to exceptions per stream context. The default stays warnings, so nothing changes unless you opt in:

$context = stream_context_create([
'stream' => ['error_mode' => StreamErrorMode::Exception],
]);

try {
$body = file_get_contents($url, false, $context);
} catch (StreamException $e) {
foreach ($e->getErrors() as $error) {
// StreamError with code, message, wrapperName, ...
}
}

No more set_error_handler() wrappers just to get a catchable failure. HTTP clients, storage adapters and import jobs benefit most.

URI extension. The URI extension from PHP 8.5 gets follow-up improvements: fluent builders (Uri\Rfc3986\UriBuilder, Uri\WhatWg\UrlBuilder), host type and URI type inspection, and a WHATWG percent-encoding function. Useful for redirects, signed URLs and SSRF checks, where string concatenation has historically produced bugs.

Performance. UPGRADING lists a stateless closure cache, compiled printf() and array_map() calls, a faster TAILCALL VM (now also on Windows with Clang), faster JSON encoding and faster array and string functions. Expect incremental gains, not a step change, and measure your own workload.

What is not in PHP 8.6

  • True Async is not included. The RFC was cancelled.
  • Automatic static closure inference was not merged. From the closure optimisation RFC, only stateless closure caching landed. Closures you explicitly mark as static get the full benefit.
  • SortDirection is a new enum with Ascending and Descending, but no existing sort function accepts it. SORT_ASC, SORT_DESC and the scandir() constants stay as they are.
  • Reflection spelling: the new method is ReflectionProperty::isWritable() next to isReadable(), even though the RFC URL says "iswriteable".

Verdict

PHP 8.6 is an upgrade I would plan for the first quarter after release, not one I would rush or postpone. Partial function application is the most useful language addition since property hooks for day-to-day code, and together with the pipe operator it finally makes a functional style practical without closure noise. The smaller additions are sensible, and none of it forces you to change anything.

The migration risk is concentrated and testable. Session defaults can break production without failing a unit test, and the stricter validation mostly surfaces existing bugs. The deprecations are a PHP 9 backlog, with mbregex as the item to schedule early.

The test order I would follow:

  1. Run the full test suite on 8.6 RC in CI, with E_DEPRECATED visible in the logs but not failing the build.
  2. Run static analysis and a dependency check: make sure your framework and libraries declare 8.6 support.
  3. Diff the effective session configuration and test login, SSO and payment callbacks end to end.
  4. Search for user input that reaches filesystem, environment and locale functions, and test the error paths.
  5. Inventory mbregex, return in finally and the deprecated SPL methods, then schedule the work.
  6. Deploy to staging with production-like traffic and watch error logs and exit codes before rolling out.

If your codebase is still several versions behind, the legacy PHP migration checklist is a better starting point than this article.

Sources

Planning the move to PHP 8.6?

I help teams upgrade PHP applications without surprises in production — from version jumps within PHP 8 to legacy codebases several major versions behind, in plain PHP, Symfony and Laravel.