Skip to content
All articles
PHPPHP 8.6LaravelUpgrade

PHP 8.6 for Laravel Developers: Partial Function Application, clamp() and What Breaks

PHP 8.6 lands on 19 November 2026: partial function application, clamp(), readonly defaults, what breaks, the deprecations and an upgrade checklist.

Usama Munawar9 min read

The short version

PHP 8.6 is scheduled for general availability on 19 November 2026. The release candidates are out now (RC3 shipped on 8 October, RC4 is planned for 22 October), so the feature list is frozen. This is the right moment to test against it, not to run it in production.

For a Laravel developer the release comes down to three things:

  • Partial function application lets you write Str::limit(?, 120) where you used to write a closure. It is the biggest language change, and it pairs with the pipe operator that arrived in PHP 8.5.
  • Small additions you will use: a native clamp(), default values on readonly properties, a SortDirection enum and a Time\Duration class.
  • Stricter behaviour and a long deprecation list: many functions now throw a ValueError on bad input, the session extension has safer defaults, and old aliases such as is_integer() and spl_object_hash() are deprecated.

None of it forces a rewrite. The work is mostly packages, CI and the deprecation log.

Release timeline

These dates come from the official PHP 8.6 release schedule.

MilestoneDate
Alpha 12 July 2026
Beta 113 August 2026
RC2 (RC1 was skipped)24 September 2026
RC38 October 2026
RC4 and RC5 (planned)22 October and 5 November 2026
General availability19 November 2026

The RC announcements say release candidates are not for production, so put them in CI and on staging.

Partial function application

Partial function application (PFA) means calling a function with some arguments filled in and the rest replaced by placeholders. Instead of running the function, PHP returns a Closure that waits for the missing arguments. ? stands for exactly one argument, and ... stands for "everything else".

use Illuminate\Support\Str;

// Before: a closure that only exists to fix one argument
$excerpts = $posts->pluck('body')->map(fn (string $body) => Str::limit($body, 120));

// PHP 8.6
$excerpts = $posts->pluck('body')->map(Str::limit(?, 120));

The generated closure copies parameter names, types and defaults from the original, so reflection and stack traces see a real signature. It works with functions, static and instance methods, closures and invokable objects. It does not work with new, so partially apply a static factory method instead.

Why ? is safer than ... in Laravel callbacks

This is the detail that matters most in Laravel code. Collection::map() calls your callback with two arguments, the value and the key (under the hood, Arr::map() runs array_map($callback, $array, $keys)). A first-class callable passes both along:

// Bug: the key (0, 1, 2...) lands in Str::limit()'s $limit parameter
$posts->pluck('body')->map(Str::limit(...));

// Fine: one placeholder, so the key is dropped
$posts->pluck('body')->map(Str::limit(?, 120));

The RFC spells this out. Without a ... placeholder, extra arguments passed to the generated closure are ignored. With ..., they are forwarded to the underlying function, which is how an optional parameter ends up receiving a value you never meant to pass. My rule: ? for callbacks, ... only when you deliberately want "fill in these and pass the rest through".

One more difference from an arrow function: the arguments you supply are evaluated when the closure is created, not each time it is called. Str::limit(?, config('blog.excerpt_length')) reads the config once. Usually that is what you want, but it is not the same as fn ($body) => Str::limit($body, config('blog.excerpt_length')).

With the pipe operator

PHP 8.5 added |>, which needs a single-argument callable on its right-hand side. PFA fills the gap that first-class callables left:

$summary = $post->body
    |> strip_tags(...)
    |> Str::squish(?)
    |> Str::limit(?, 160);

According to the RFC, a single-placeholder PFA directly on the right of a pipe is optimised into a direct call, so there is no closure overhead. The UPGRADING notes also say that array_map() with a first-class callable or PFA callback is now compiled into the equivalent foreach loop.

clamp()

// Before
$perPage = min(max($request->integer('per_page', 15), 1), 100);

// PHP 8.6
$perPage = clamp($request->integer('per_page', 15), min: 1, max: 100);

clamp($value, $min, $max) returns the value if it is in range, otherwise the nearest bound. It accepts any comparable values, including strings and DateTimeImmutable, and it throws a ValueError if $min is greater than $max or either bound is NAN.

Laravel already has Number::clamp(), which is min(max($number, $min), $max) and only takes int|float. It does not complain when the bounds are the wrong way round; the native function throws. If your bounds come from config or user input, add a test before you swap one for the other.

The name is now taken, too. A global function clamp() declared without a function_exists() guard, in your code or in a package, will be a fatal redeclaration error on PHP 8.6. Namespaced functions are unaffected.

Readonly properties with defaults

interface PaymentGateway
{
    public string $name { get; }
}

final readonly class StripeGateway implements PaymentGateway
{
    public string $name = 'stripe';

    public function __construct(private StripeClient $client) {}
}

Until now a readonly property could not declare a default value. In 8.6 it can, which suits fixed metadata on classes that implement interface properties (added in PHP 8.4). The default counts as the initialising assignment, so the constructor cannot overwrite it: assigning it again throws "Cannot modify readonly property".

Other additions worth knowing

  • SortDirection enum in the global namespace, with SortDirection::Ascending and SortDirection::Descending. It is deliberately unbacked, so map it to 'asc' or 'desc' with match until the framework accepts it directly. A global class called SortDirection in your own code now clashes; App\Enums\SortDirection does not.
  • Time\Duration, an immutable "stopwatch" duration with nanosecond precision: Time\Duration::fromMilliseconds(500), fromSeconds(), fromIso8601DurationString('PT1M30S'), add(), sub(), multiplyBy(), divideBy(), and comparison with < and >. Existing functions such as sleep() do not accept it (the RFC only wires it into the new polling API), so for now it is a value object for your own timeouts and retry policies.
  • #[\Override] on class constants and enum cases, and __debugInfo() on enums.
  • JSON error locations: json_last_error_msg() and JsonException messages now include where the error is, which helps when a webhook payload fails to decode.
  • Performance work: printf() with only %s and %d compiles to string interpolation, and array_sum(), str_pad() and JSON encoding are faster. I have no benchmark of my own, so measure your app.

What breaks

Stricter argument checks

Across the standard library and extensions, input that used to be accepted, truncated or overflowed now throws. A selection that can reach a web app:

  • file_exists(), is_file(), filesize(), getenv(), parse_str() and friends throw a ValueError when given a string containing NUL bytes. If raw user input reaches a filesystem check, validate it first.
  • number_format() throws a ValueError when $decimals is outside the integer range, and sleep() and usleep() throw instead of overflowing.
  • preg_grep() returns false on a PCRE error instead of a partial array, like the other preg_* functions.
  • fclose(), file_put_contents() and copy() now return false when flushing or closing the stream fails. That is a fix, but code that checks the return value may start reporting failures it used to miss.
  • trim(), ltrim() and rtrim() now strip form feed (\f) by default.

Session defaults

session.use_strict_mode is now 1, session.cookie_httponly is 1 and session.cookie_samesite is Lax. Laravel's session layer does not use PHP's native sessions (there is no session_start() in Illuminate\Session), and the default config/session.php already sets http_only to true and same_site to 'lax'. A standard Laravel app should not notice. Other code on the same runtime that calls session_start() will, especially anything relying on cross-site POST requests carrying the session cookie (the RFC names SP-initiated SAML SSO as an example).

Magic properties and exit codes

  • ?? and empty() on a magic property no longer call __get() when __isset() has already written the property into the object. This only matters for classes whose __isset() does that, so check any custom magic classes.
  • On a hard out-of-memory failure PHP now calls abort() instead of exit(1), so the exit code becomes 134 and a core dump may be written. If your queue worker supervision or alerting matches on exit codes, check it.

Deprecations

Deprecations are not errors, but they fill logs and they are the list of what PHP 9 will remove. The ones I would grep a Laravel codebase and its vendor folder for:

Deprecated in 8.6Use instead
is_integer(), is_long()is_int()
is_double(), doubleval()is_float(), floatval()
spl_object_hash()spl_object_id()
strcoll(), SORT_LOCALE_STRINGCollator::compare(), Collator::sort()
Objects passed to http_build_query()get_object_vars() first
return inside finallyReturn after the try block
Returning a value from __construct()Remove the return value
SplFileObject::fgetcsv(), fputcsv()Plain fgetcsv() and fputcsv(), or a CSV library
Mbregex (mb_ereg* and friends)preg_* with the u modifier

return in finally is the one with a real bug behind it: it silently swallows the exception or overwrites the return value from the try block.

Laravel can collect these: the default config/logging.php sends deprecations to LOG_DEPRECATIONS_CHANNEL, which is null out of the box. Point it at a real channel on staging and run your test suite and a few days of traffic against 8.6.

Does Laravel support PHP 8.6?

Not officially yet, as far as I can verify. Laravel 13's composer.json requires "php": "^8.3" and Laravel 12's requires ^8.2, with no upper bound, so Composer will install either on 8.6. But as I write, the 13.x branch's CI tests PHP 8.3, 8.4 and 8.5 only. Wait for the framework to list 8.6 before you run it in production, and check every package: some pin an upper PHP bound. Composer will tell you which:

composer prohibits php 8.6

If you are still on PHP 8.2, the urgent move is not 8.6. PHP 8.2's security support ends on 31 December 2026, so get to 8.4 or 8.5 first. If you are also behind on the framework, my Laravel 13 guide covers that upgrade.

Upgrade checklist

  1. Know your starting point. On PHP 8.2, move to 8.4 or 8.5 first.
  2. Add an 8.6 RC job to CI, allowed to fail, so you see problems weeks before GA.
  3. Run composer prohibits php 8.6 and list the packages that block you.
  4. Turn on deprecation logging on staging with LOG_DEPRECATIONS_CHANNEL.
  5. Grep for name clashes: global function clamp(, a global SortDirection class, and let, is or _ used as class, function or constant names (now deprecated).
  6. Grep for the deprecated functions in the table above, in app/ and vendor/.
  7. Check non-Laravel session code and anything that relies on cross-site POST requests.
  8. Check worker supervision for assumptions about exit codes.
  9. Update static analysis and code style tools before you use ? placeholders, or they may fail to parse your code.
  10. Ship the PHP bump on its own, after GA and after your framework and packages confirm support, with the previous runtime one deploy away.

New code, such as a Laravel AI SDK feature or a Laravel MCP server, is a good place to try PFA and pipes first.

Need a hand?

Planning a PHP or Laravel upgrade on a production app? See Laravel development, or email devusamaworks@gmail.com or WhatsApp +92 303 8004684 for a free 30-minute call.

A question about this article?Ask me on WhatsApp

Keep reading

Open for new work

Have a system that needs to scale?

Bring your architecture problem, automation bottleneck, or product idea. We will work out the right engineering path before you spend more time or money.

Free, 30 minutes, no pitch. You leave with a next step either way.