August 4, 2026

PHP 8.6: Every New Feature, the Release Date, and What Breaks When You Upgrade

Photo of Marco Orta Marco Orta | 14 mins read
Compartir
3D illustration of the PHP elephant next to a question mark floating as a placeholder inside a function call
Table of Contents

    PHP 8.6 ships on November 19, 2026, and August 13 marked the soft feature freeze: once beta 1 lands, anything not already in barely makes it. Which means the list below is, in practice, the final list.

    The headline is Partial Function Application, a syntax that spent years in limbo and finally passed unanimously. But what will actually cost you time isn’t the new functions — it’s three quiet changes: sessions become secure by default, mb_ereg and friends get deprecated, and return inside a constructor starts warning.

    Let’s go through it.

    Timeline: when PHP 8.6 arrives

    flowchart TB
        A["🧪 Alpha 1 · Jul 2, 2026"] --> B["🧪 Alpha 2 · Jul 16"]
        B --> C["🧪 Alpha 3 · Jul 30"]
        C --> D["🧊 Beta 1 + soft feature freeze<br/>Aug 13, 2026"]
        D --> E["🧊 Beta 2 · Aug 27"]
        E --> F["🧊 Beta 3 · Sep 10"]
        F --> G["🔒 Hard feature freeze<br/>Sep 22, 2026"]
        G --> H["📦 RC 1 to RC 4<br/>Sep 24 – Nov 5"]
        H --> I["🚀 GA · Nov 19, 2026"]

    In plain terms: from August 13 a new feature needs justification to get in, and from September 22 it needs explicit approval from the release managers. From September 24 onward it’s bug fixes only.

    This release is managed by Daniel Scherzer (veteran), Matteo Beccati, and Joe Ferguson (rookies), elected by single transferable vote in April.

    If you want to try it today without touching your machine, the cleanest route is a container running php:8.6-rc-cli once the RCs are out — the complete Docker guide covers the setup.

    Partial Function Application: the big one

    This is the feature that changes how you write code. The Partial Function Application v2 RFC passed 33 votes to 0, and it has been merged since alpha 3.

    The idea: you can call a function leaving gaps, and instead of executing it, PHP hands you back a closure with those gaps as parameters.

    function add(int $a, int $b, int $c): int {
        return $a + $b + $c;
    }
    
    $add10AndX = add(10, ?, 5);  // Closure: fn(int $b): int
    echo $add10AndX(3);          // 18
    

    The ? is a placeholder. It isn’t a value, it’s a gap.

    Before and after

    The classic case is passing a native function to array_map when the argument that varies isn’t the first one. Today you have to wrap it:

    // PHP 8.5 and earlier
    $strings = ['hello world', 'hello php'];
    $result = array_map(fn ($s) => str_replace('hello', 'hi', $s), $strings);
    

    In 8.6 the wrapper is gone:

    // PHP 8.6
    $replaceHello = str_replace('hello', 'hi', ?);
    $result = array_map($replaceHello, $strings);
    

    Or inline, with no intermediate variable:

    $result = array_map(str_replace('hello', 'hi', ?), $strings);
    

    The two placeholders: ? and ...

    • ? consumes exactly one argument at that position.
    • ... consumes zero or more remaining arguments.
    $replaceWhitespace = str_replace(' ', ?, ?);   // 2 parameters
    $replaceWhitespace('=', $input);
    
    $replaceWhitespace = str_replace(' ', ...);    // whatever's left, as-is
    $replaceWhitespace('=', $input);
    

    If you were already using PHP 8.1’s first-class callable syntax (strlen(...)), good news: it’s exactly the degenerate case of PFA. foo(...) still means the same thing — it’s just part of a bigger family now.

    With named arguments

    Named placeholders adopt the order you write them in, not the order of the original signature:

    function stuff(int $i1, string $s2, float $f3, Point $p4) {}
    
    $c = stuff(s2: ?, i1: ?, p4: ?);
    // Resulting signature: fn(string $s2, int $i1, Point $p4)
    

    With one hard rule: you cannot put a positional placeholder after a named one. That’s a fatal error.

    Combined with the pipe operator

    This is where it really shines. The |> operator arrived in PHP 8.5 (November 2025) and until now forced you to wrap any function taking more than one argument. With PFA:

    $slug = $input
        |> trim(...)
        |> str_replace(' ', '-', ?)
        |> str_replace(['.', '/', '…'], '', ?)
        |> strtolower(...);
    

    And it isn’t just cosmetic: the RFC includes a compile-time optimization that removes the intermediate closure for the simple cases (foo(?), foo(1, ?), foo(1, a: ?), foo(1, ...)) when used with the pipe. You don’t pay for the abstraction.

    The detail that will bite you

    The arguments you do fill in are evaluated when you create the partial, not when you call it. This is the opposite of an arrow function:

    $partial = speak(?, getArg());        // getArg() runs HERE
    $arrow   = fn ($who) => speak($who, getArg());  // getArg() runs on call
    

    If getArg() reads the clock, hits the database, or depends on state, the result is frozen at the moment the partial is created. It’s consistent behavior (you’re applying arguments, not deferring them), but it’s the easiest mistake to make in the first few weeks.

    What you cannot do

    • Constructors: new Foo(?) doesn’t work. Error: Cannot create Closure for new expression.
    • compact(), extract(), func_get_arg(), get_defined_vars(): disallowed, because they depend on the calling scope.
    • __get and __set: don’t apply, since they aren’t invoked as methods. __call and __callStatic do work.
    • Static methods produce static closures (not rebindable); instance methods produce rebindable ones, but only to the same type.

    The optional-parameter patch

    A second RFC (25 for, 0 against) fixed an important detail before this reached production. The final rule:

    • A parameter generated by ? is always required, even if the original function gave it a default value.
    • Parameters pulled in by ... keep their optionality.
    function example(mixed $a, string $b = 'default', string $c = 'also optional') {}
    
    example(?, ?);        // fn(mixed $a, string $b)         ← $b is now required
    example('foo', ...);  // fn(string $b = 'default', string $c = 'also optional')
    

    Without this fix, a child class changing a default value could alter the signature of a partial created on the parent. Now ? always means the same thing.

    The small, useful additions

    clamp()

    Constrains a value between a minimum and a maximum. We’ve all written max($min, min($max, $v)) at some point, and we’ve all gotten it wrong at some point.

    clamp(2, min: 1, max: 3)   // 2
    clamp(0, min: 1, max: 3)   // 1
    clamp(6, min: 1, max: 3)   // 3
    clamp("a", "c", "g")       // "c"
    

    The signature is clamp(mixed $value, mixed $min, mixed $max): mixed and it follows PHP’s normal comparison rules, so it works with dates too:

    clamp(
        new \DateTimeImmutable('2025-08-01'),
        new \DateTimeImmutable('2025-08-15'),
        new \DateTimeImmutable('2025-09-15')
    )->format('Y-m-d');  // 2025-08-15
    

    Two edge cases worth knowing: if $min > $max it throws ValueError, and so does passing NAN as a bound. NAN as the value is returned as-is. Passed 23 to 3.

    enum SortDirection

    An enum in the global namespace with two cases, Ascending and Descending:

    enum SortDirection {
        case Ascending;
        case Descending;
    }
    
    $query->orderBy('created_at', SortDirection::Descending);
    

    Let’s be honest about what this is and isn’t: no core function consumes it yet. What it provides is a shared vocabulary so frameworks, ORMs, and extensions stop inventing their own 'ASC' / 'DESC' / SORT_ASC constants. The RFC mentions scandir() as a future candidate. Passed 22 to 2.

    #[\Override] on class constants

    PHP 8.3’s #[\Override] attribute now works on constants too. If you declare that you’re overriding something and you aren’t, PHP tells you:

    class Demo {
        #[\Override]
        public const C = 'C';  // Error: nothing to override
    }
    
    class Base {
        protected const C = 'C';
    }
    
    class Child extends Base {
        #[\Override]
        public const C = 'Changed';  // Fine
    }
    

    Public and protected constants from parent classes and interfaces satisfy it; private ones don’t. Works on interfaces, enums, and anonymous classes. Passed 15 to 0.

    Debuggable enums

    Until now __debugInfo() was forbidden on enums. In 8.6 the restriction is lifted:

    enum Foo: string {
        case Bar = "Baz";
    
        public function __debugInfo() {
            return [__CLASS__ . '::' . $this->name . ' = ' . $this->value];
        }
    }
    
    var_dump(Foo::Bar);
    

    Small change, no breakage, and handy if you have enums carrying logic you want to inspect. Passed 16 to 2.

    Errors that show their arguments

    This is one of the bigger time-savers when debugging. Today, a warning from a native function tells you what failed but not what with:

    Warning: chmod(): Operation not permitted in /app/chmod.php on line 5
    

    With the new error_include_args directive enabled:

    Warning: chmod('/', 511): Operation not permitted in /app/chmod.php on line 5
    

    One critical detail: it’s off by default (error_include_args=0). The feature passed 24 to 2, but the secondary vote on the default value went to 0 by 18 to 7. If you want it, you turn it on.

    It respects the protections that already existed: it honors the #[\SensitiveParameter] attribute to mask passwords, respects zend.exception_string_param_max_len so logs don’t bloat, and only applies to PHP’s internal functions, not to errors you trigger yourself.

    And also

    • grapheme_strrev() — reverse a string respecting grapheme clusters. In other words, strrev() that doesn’t destroy emoji or composed characters.
    • Polling API (Io\Poll) — an I/O multiplexing API with Context, Watcher, and StreamPollHandle classes, backed by epoll on Linux and kqueue on BSD/macOS. It fixes the two historical limits of stream_select(): the file-descriptor ceiling and its O(n) complexity. If you write async servers in PHP, this is your feature. Passed 33 to 1.
    • mysqli_quote_string() — string escaping without needing an open connection.
    • Locale::getDisplayKeyword() and getDisplayKeywordValue() — human-readable names for locale keywords.
    • Reflection isReadable / isWriteable methods — check whether a property can be read or written without triggering the access.
    • TLS session resumption for streams — fewer full handshakes on repeated connections.
    • DocComments on function parameters — doc blocks can now attach to individual parameters and be read via Reflection.
    • json_decode() reports the error position — the message tells you where the malformed JSON is, not just that it is.

    What breaks: check this before upgrading

    This is where your effort should go. None of these changes is dramatic, but all of them are quiet.

    1. Secure session defaults (the one that will hurt most)

    The secure session defaults RFC changes three directives:

    DirectiveBeforeIn 8.6
    session.use_strict_mode01
    session.cookie_httponly01
    session.cookie_samesite(empty)Lax

    This is a good change — these are the values every audit has asked for for years — but changing defaults breaks applications that relied on the old behavior without knowing it:

    • use_strict_mode = 1: if you share session IDs across subdomains without calling session_write_close() first, the IDs get rejected. Custom handlers that don’t implement validateId() are unaffected.
    • cookie_httponly = 1: any code reading the session cookie from JavaScript via document.cookie stops working. The RFC’s recommendation is the right one: use a separate CSRF token for that.
    • cookie_samesite = Lax: cross-site POSTs stop carrying the session cookie. Chrome and Firefox already applied Lax implicitly, so in practice this only makes explicit what was already happening in the major browsers — but it does affect payment gateways with POST callbacks and older SSO integrations.

    All three votes were clean: 27-0, 26-0, and 26-0.

    If you’re touching this anyway, audit the site’s security headers while you’re at it:

    2. Goodbye to mb_ereg and all of mbregex

    Oniguruma, the multibyte regex library behind mb_ereg, is no longer maintained. PHP’s answer is to deprecate 14 functions in 8.6 and remove them in PHP 9.0:

    mb_ereg, mb_ereg_match, mb_ereg_replace, mb_ereg_replace_callback, mb_ereg_search, mb_ereg_search_getpos, mb_ereg_search_getregs, mb_ereg_search_init, mb_ereg_search_pos, mb_ereg_search_regs, mb_ereg_search_setpos, mb_eregi, mb_eregi_replace, and mb_split.

    The MB_ONIGURUMA_VERSION constant and the mbstring.regex_retry_limit / mbstring.regex_stack_limit options go away too.

    You have two paths: migrate to PCRE with the u modifier (preg_match('/…/u', …)), which is what you should do in 95% of cases, or install the mb_onig PECL extension the RFC author published on Packagist, if you have patterns using Oniguruma-specific syntax you’d rather not rewrite.

    Start by searching your codebase:

    grep -rn "mb_ereg\|mb_split" --include="*.php" .
    

    And if you’re rewriting patterns, test them before you deploy:

    Passed 24 to 0, with no opposition.

    3. Returning a value from constructors

    From 8.6, returning a value from __construct() or __destruct() — or turning them into generators — emits a deprecation at compile time:

    class Foo {
        public function __construct() {
            return 123;  // Deprecated: Returning a value from a constructor is deprecated
        }
    }
    
    class Baz {
        public function __construct() {
            yield 123;   // Deprecated: Making a constructor a Generator is deprecated
        }
    }
    

    A bare return; to exit early is still perfectly legal:

    class Bar {
        public function __construct() {
            if (random_int(0, 1)) {
                return;  // Fine
            }
        }
    }
    

    Because it’s compile-time, you’ll see it even if the code never runs. In the first major after 8.6 it becomes an error. Passed 39 to 0.

    4. Minor changes that might still hit you

    • trim(), ltrim(), rtrim(), and chop() now strip the form feed (\f) by default. If your code relied on \f surviving a trim(), the behavior changes. Rare, but real.
    • array_filter() throws ValueError on an invalid $mode instead of silently ignoring it. That turns a latent bug into an exception, which is better, but it can take down production code that had been passing a wrong value unnoticed for years.
    • A cap on the number of chained filters in php://filter. Security hardening against a known filter-chain exploitation technique.

    Migration checklist

    A sensible order so nothing surprises you:

    1. Spin up 8.6 in a container as soon as RC1 lands (September 24). Leave your main environment alone.
    2. Run your test suite with error_reporting(E_ALL) and capture the deprecations. The constructor ones surface at compile time on their own.
    3. grep for mb_ereg and mb_split. Decide between PCRE and the mb_onig extension and plan it: you have until PHP 9.0, but the sooner it leaves your backlog, the better.
    4. Review your sessions. If your php.ini already set all three directives explicitly, nothing changes for you. If it relied on defaults, test login, logout, SSO, and any external POST callback.
    5. Update your static analysis tools (PHPStan, Psalm, Rector) before writing new syntax. PFA is new syntax; until your analyzer understands it, you’ll get false positives.
    6. Save PFA for new code. There’s no reason to rewrite closures that already work.

    Should you upgrade?

    If you’re on PHP 8.3 or older, yes, and soon: 8.3 leaves security support at the end of 2026. Jump straight to 8.6 as soon as your stack supports it.

    If you’re on 8.4 or 8.5, this is one of the easy jumps: no mandatory syntax changes, no removals, just deprecations and three safer defaults. The question isn’t whether to upgrade but whether your dependencies support it yet — and that’s usually the real bottleneck. Laravel, Symfony, and the rest typically take anywhere from a few weeks to a couple of months to declare formal compatibility.

    My practical recommendation: test on September’s RC1, migrate mb_ereg in the meantime, and ship to production when 8.6.1 or 8.6.2 lands. There’s never a prize for being first on a .0.

    Conclusion

    PHP 8.6 isn’t a revolutionary release, and that’s fine. It’s a release that closes gaps: PFA completes what first-class callables started in 8.1 and the pipe operator continued in 8.5; clamp() and SortDirection remove boilerplate that was scattered everywhere; and the session defaults have been everyone’s recommendation for a decade — everyone’s except PHP’s own.

    The only item that demands real work is mbregex, and you have until PHP 9.0 to do it.

    Keep reading:

    FAQ

    When is PHP 8.6 released? November 19, 2026. The soft feature freeze was August 13 and the hard feature freeze is September 22.

    What is Partial Function Application? Calling a function while leaving gaps (? or ...) so it returns a closure instead of executing. It’s the general case of which PHP 8.1’s foo(...) was a specific instance.

    Does it break compatibility? It removes nothing, but it changes three session defaults (use_strict_mode, cookie_httponly, cookie_samesite) and deprecates mbregex, return in constructors, and the form-feed trim.

    Why is mb_ereg deprecated? Oniguruma is no longer maintained. 14 functions are deprecated in 8.6 and removed in 9.0. Migrate to PCRE with /u or the mb_onig PECL extension.

    What is clamp() for? Constraining a value between a minimum and a maximum, replacing max($min, min($max, $v)). Works with numbers, strings, and comparable objects.

    Can I use it with Laravel? You will, but not on day one. Test on September’s RC and ship to production on 8.6.1 or 8.6.2.

    Compartir

    Search

    Tags

    Tutorial PHP Laravel Web Development JavaScript AI Best Practices Laravel 13 Security Tools Claude SEO Regular Expressions Text Manipulation Frontend