PHP 8.5 Complete Update — Pipe Operator, Clone With, and Everything New

Cover every notable PHP 8.5 addition in one place: the pipe operator |>, the URI extension, clone-with syntax, #[\NoDiscard], array_first()/array_last(), constant-expression changes, closure improvements, static asymmetric visibility, Reflection improvements, error/exception handler inspection changes, and the release's deprecations -- every single feature explicitly labeled PHP 8.5+ since none of it exists on any earlier version.

Every feature in this lesson requires PHP 8.5 or later -- there is no polyfill, no Composer package, no userland trick that brings any of this to PHP 8.4 or earlier, because each one is either new engine syntax or a new built-in class/function shipped directly with the 8.5 release. Unlike the sweeping backward-incompatible changes of PHP 8.0, PHP 8.5 is a comparatively gentle, largely additive minor release along the 8.x line -- existing PHP 8.4 code keeps running essentially unchanged, and the features below are opt-in additions to reach for rather than migration work forced on you. This lesson treats it as a focused reference: what's new, exactly how it behaves, and a runnable example for each.

The Pipe Operator |> (PHP 8.5+)

PHP 8.5+ introduces |>, letting a value be threaded through a sequence of single-argument callables left to right, reading top-to-bottom in the actual order operations happen rather than the inside-out nesting a chain of function calls forces: $result = $value |> strtoupper(...) |> trim(...); is equivalent to trim(strtoupper($value)), but reads as 'take $value, then uppercase it, then trim it' -- the same direction the data actually flows. This mirrors pipe operators already familiar from Elixir, F#, and Hack, and pairs naturally with PHP's existing first-class callable syntax (strtoupper(...)) on the right-hand side of each |>.

The expression to the right of |> must be something callable that accepts the piped value; the expression to the left of |> becomes that callable's argument. Chains can be as long as needed, and because each step is just an ordinary function/method call under the hood, existing named functions, static methods, and closures are all usable as pipe stages with no special preparation.

• PHP 8.5+ only -- there is no equivalent operator in PHP 8.4 or earlier. • $value |> f(...) |> g(...) reads top-to-bottom in execution order, equivalent to g(f($value)). • Works naturally with first-class callable syntax and any single-argument callable -- named functions, static methods, closures.

Clone With Syntax (PHP 8.5+)

PHP 8.5+ adds clone($obj, ['prop' => $value, ...]) -- clone an object AND override specific property values in the SAME expression, without writing a custom __clone() method or a manual 'wither' method (withX()) just to produce a modified copy. This closes a real gap that readonly properties (PHP 8.1+) opened: a plain clone $obj copies a readonly property's value unchanged, and readonly properties cannot be reassigned after construction -- including after a clone -- so before 8.5, changing even one property on a cloned readonly object required a hand-written wither method that rebuilt the whole object via its constructor.

Clone-with works for both readonly and ordinary mutable properties, and only the properties named in the array are overridden -- every other property is copied exactly as a normal clone would copy it. This is a genuine ergonomics and boilerplate win for value objects and DTOs, which very commonly need an 'same object, but with this one field changed' operation.

• PHP 8.5+ only -- clone($obj, [...]) syntax does not exist in earlier versions. • Overrides only the named properties in the array; every other property is copied as a normal clone would. • Removes the need for hand-written withX() wither methods on readonly value objects specifically for this purpose.

#[\NoDiscard] (PHP 8.5+)

PHP 8.5+ adds the built-in #[\NoDiscard] attribute, placed on a function or method to declare that its return value must not be silently thrown away -- calling it as a bare statement, ignoring what it returns, triggers a warning. This targets exactly the class of bug where a function's real job IS its return value (a pure computation building a new immutable object, a validation check returning a boolean success flag, a Result-style wrapper) and calling it purely for a side effect that doesn't actually exist is very likely a mistake, not an intentional choice.

It's most valuable on functions that look like they might have a side effect but don't -- an immutable withX()-style method that returns a NEW modified copy rather than mutating in place is the textbook case, because calling it and discarding the result silently does nothing at all, which is exactly the kind of bug that's easy to write and very hard to notice by reading the code.

• PHP 8.5+ only -- #[\NoDiscard] does not exist as a built-in attribute before this release. • Warns when a marked function/method's return value is discarded by calling it as a bare statement. • Best fit: pure/immutable-returning methods where ignoring the return value means the call did nothing useful at all.

array_first() and array_last() (PHP 8.5+)

PHP 8.5+ adds array_first($array) and array_last($array), returning the VALUE at the first/last position of an array without mutating the array's internal pointer (unlike reset()/end(), which move that pointer as a side effect) and without the two-step $array[array_key_first($array)] dance previously needed to get the first value by key. Both return null for an empty array, mirroring array_key_first()/array_key_last()'s existing null-on-empty behavior.

Because they don't touch the internal pointer, they're safe to call in the middle of code that's also using current()/next()-style pointer-based iteration elsewhere on the same array -- something reset()/end() are NOT safe to do, since those functions' whole job is to move that same shared pointer.

• PHP 8.5+ only. • array_first($arr) / array_last($arr) -- return the value at that position, null if the array is empty. • Do not mutate the array's internal pointer, unlike reset()/end().

The URI Extension (PHP 8.5+)

PHP 8.5+ bundles a new URI extension providing a spec-compliant way to parse, build, and manipulate URIs directly, addressing the well-known rough edges of parse_url() (which silently returns partial or misleading results for many malformed or edge-case URLs rather than a clear parse failure, and offers no structured way to rebuild a modified URL back into a string). Because the WHATWG URL Standard and RFC 3986 disagree on some parsing edge cases (particularly around what counts as valid and how relative references resolve), the extension exposes this as distinct, explicit parsing modes rather than picking one specification silently.

For application code, this replaces a common pattern of reaching for a third-party URI-manipulation package (or hand-rolling careful parse_url()/http_build_query() combinations) for anything beyond the most trivial URL handling, with a built-in, spec-aware class directly in the engine.

• PHP 8.5+ only -- a newly bundled extension, not available in 8.4 or earlier. • Addresses parse_url()'s well-known edge-case and malformed-input weaknesses with spec-compliant parsing. • Exposes both WHATWG-URL-style and RFC 3986-style parsing explicitly, since the two specs disagree on some cases.

Constant Expressions, Closures, and Static Asymmetric Visibility (PHP 8.5+)

PHP 8.5+ expands what counts as a valid constant expression -- the kind of expression allowed in property defaults, class constant declarations, and attribute arguments, which historically had to be a simple literal or a narrow set of compile-time-evaluable forms. A broader set of operators and expressions being usable in these contexts reduces how often code has to fall back to constructor-time initialization purely because a property default couldn't express what was needed directly.

PHP 8.4 introduced asymmetric visibility for INSTANCE properties (public private(set) string $id; -- publicly readable, only privately writable). PHP 8.5+ extends the same idea to STATIC properties, letting a static property be declared publicly readable but writable only from within the class itself -- closing a gap where 8.4's asymmetric visibility applied only at the instance level, and static properties needing 'publicly readable, privately writable' semantics still had no direct syntax for it and needed a getter method as a workaround.

• PHP 8.5+ only for both changes described here. • Constant expressions now permit a broader set of operations in property defaults, class constants, and attribute arguments. • Static asymmetric visibility extends PHP 8.4's public private(set) idea to static properties specifically.

Reflection Improvements and Error/Exception Handler Inspection (PHP 8.5+)

PHP 8.5+ adds Reflection APIs for introspecting the CURRENTLY registered error handler and exception handler -- what set_error_handler()/set_exception_handler() currently point to -- which had no clean, direct introspection path before this release. Previously, code that needed to know 'what handler is currently active' without disturbing it had to call set_error_handler() with a throwaway sentinel handler and immediately call it again to restore the original, purely to read back the value the first call returned -- a workaround that briefly (if only for a moment) actually swaps the real handler out.

This matters most to frameworks, debuggers, and testing tools that need to WRAP or temporarily augment the active handler (adding logging around it, say) without losing track of what it originally was, or that need to assert in a test that a specific handler is currently registered without needing the fragile set-then-restore workaround to check.

• PHP 8.5+ only. • New Reflection-based APIs to inspect the currently active error/exception handler directly. • Removes the need for the old 'set a sentinel handler, read the return value, restore the original' workaround just to introspect.

Deprecations and Migration Notes (PHP 8.5+)

PHP 8.5 ships as a source-compatible minor release for the vast majority of existing PHP 8.4 code -- it is additive along the 8.x line, not a sweeping backward-incompatible jump the way PHP 8.0 was, so migrating an 8.4 application to 8.5 is generally a much lighter lift than any of the transitions covered in the Migration Across Versions lesson. The main practical migration task for most applications is auditing COMPILED extension compatibility (any extension not yet rebuilt/released for 8.5 will not load, exactly as with any other version bump), not rewriting application code.

As with every PHP release, 8.5 also carries a handful of deprecations flagged for likely removal in a future major version -- certain implicit type-coercion behaviors in specific contexts are marked deprecated in 8.5 rather than left silent, giving codebases advance warning before any future removal. The practical migration note is to run the existing E_DEPRECATED-logging approach from the version-migration lesson against an 8.5 target before adopting it widely, and to treat the new syntax in this lesson (pipe operator, clone-with, static asymmetric visibility) as OPTIONAL modernization to adopt opportunistically -- none of it is required just to run correctly on 8.5.

• PHP 8.5+ is source-compatible with the vast majority of PHP 8.4 code -- a light migration relative to 8.0's jump. • The main migration task is compiled-extension compatibility, not application-code rewriting. • New 8.5 deprecations exist (flagged for possible future removal) -- inventory them the same way as any other version migration, via logged E_DEPRECATED. • Every feature in this lesson is opt-in modernization, not something forced on existing 8.4-compatible code.

Code Examples

Each |> stage receives the previous stage's result as its argument and passes its own result to the next: $title is lowercased, then trimmed via slugify(), then run through an inline arrow function that replaces runs of non-alphanumeric characters with a hyphen -- reading top-to-bottom in exactly the order the transformations happen, instead of the inside-out nesting preg_replace(..., slugify(strtolower($title))) would require. Expected result: -hello-php-8-5-world-

$title |> strtolower(...) |> slugify(...) |> fn (string $s): string => ...;

clone($price, ['cents' => 1499]) produces a new Money instance with cents overridden to 1499 while currency is copied unchanged from $price -- exactly the 'same object, one field different' operation that readonly properties made impossible with a plain clone, and that previously required a dedicated withCents() wither method just to express. Expected result: 1999 USD 1499 USD clone($price, ['cents' => 1499]);

ValidationResult::check() is a pure function -- it has no side effect of its own, so calling it and discarding the result (the last line) accomplishes literally nothing, which is almost certainly a bug rather than intent. #[\NoDiscard] makes PHP itself flag that mistake with a warning instead of the code silently doing nothing and the actual bug (an unvalidated email going on to be used elsewhere) surfacing somewhere else entirely, much harder to trace back here.

array_first()/array_last() read a value by position without the side effect reset()/end() have on the array's internal pointer -- calling current() afterward still reflects the pointer's position from BEFORE array_first()/array_last() were called, proving they left it untouched. Both simply return null for an empty array rather than emitting a warning the way end()/reset() historically could. Expected result: 10 50 10 NULL array_first($scores); // value, not key -- and the pointer is untouched public private(set) static int $total is readable from anywhere ($total can be read from outside the class) but only assignable from inside RequestCounter itself -- external code can observe the counter but cannot tamper with it directly, only through the class's own increment() method, which is exactly the guarantee asymmetric visibility exists to express without resorting to a private property plus a separate public getter method. Expected result: 2 public private(set) static int $total = 0;

<?php
declare(strict_types=1);

// PHP 8.5+ only -- the |> operator does not exist in PHP 8.4 or earlier.

function slugify(string $s): string
{
    return trim($s);
}

$title = '  Hello, PHP 8.5 World!  ';

$slug = $title
    |> strtolower(...)
    |> slugify(...)
    |> fn (string $s): string => preg_replace('/[^a-z0-9]+/', '-', $s);

echo $slug . PHP_EOL;
<?php
declare(strict_types=1);

// PHP 8.5+ only -- clone($obj, [...]) syntax does not exist in PHP 8.4 or earlier.

final readonly class Money
{
    public function __construct(
        public int $cents,
        public string $currency,
    ) {}
}

$price = new Money(1999, 'USD');

// Before PHP 8.5, changing 'cents' on a readonly object required a hand-written
// wither method that rebuilt the object via the constructor:
//   public function withCents(int $cents): self { return new self($cents, $this->currency); }
//
// PHP 8.5+: clone-with does it directly, no wither method needed.
$discounted = clone($price, ['cents' => 1499]);

echo $price->cents . ' ' . $price->currency . PHP_EOL;
echo $discounted->cents . ' ' . $discounted->currency . PHP_EOL;
<?php
declare(strict_types=1);

// PHP 8.5+ only -- #[\NoDiscard] does not exist as a built-in attribute before this release.

final readonly class ValidationResult
{
    private function __construct(
        public bool $isValid,
        public string $message,
    ) {}

    #[\NoDiscard]
    public static function check(string $email): self
    {
        $valid = (bool) filter_var($email, FILTER_VALIDATE_EMAIL);

        return new self($valid, $valid ? 'ok' : 'invalid email format');
    }
}

// Correct usage -- the return value is actually used:
$result = ValidationResult::check('[email protected]');
echo $result->message . PHP_EOL;

// Bug: calling check() purely as a statement does nothing at all -- it has no
// side effect, its entire job is the return value -- and PHP 8.5+ warns here
// because check() is marked #[\NoDiscard]:
ValidationResult::check('not-an-email');
<?php
declare(strict_types=1);

// PHP 8.5+ only -- array_first()/array_last() do not exist in PHP 8.4 or earlier.

$scores = ['bronze' => 10, 'silver' => 25, 'gold' => 50];

echo array_first($scores) . PHP_EOL; // 10 -- the VALUE at the first position
echo array_last($scores) . PHP_EOL;  // 50 -- the VALUE at the last position

// Unlike reset()/end(), these do not move the array's internal pointer --
// current() still points at wherever it already was:
echo current($scores) . PHP_EOL;     // still 'bronze's value: 10, unaffected

var_dump(array_first([])); // NULL -- empty array, no first element
<?php
declare(strict_types=1);

// PHP 8.5+ only -- static asymmetric visibility does not exist in PHP 8.4 or earlier.

final class RequestCounter
{
    // Publicly readable, but only writable from inside this class -- extends
    // PHP 8.4's instance-level asymmetric visibility to a static property.
    public private(set) static int $total = 0;

    public static function increment(): void
    {
        self::$total++;
    }
}

RequestCounter::increment();
RequestCounter::increment();

echo RequestCounter::$total . PHP_EOL; // 2 -- reading from outside is fine

// RequestCounter::$total = 999; // Fatal error: cannot modify private(set) property from outside

Important Rules

Every feature in this lesson requires PHP 8.5.0 or later at runtime -- there is no polyfill or Composer-package equivalent for engine syntax like |> or clone-with.

|> threads a value through single-argument callables left to right; the callable receives the piped value as its argument.

clone($obj, [...]) only overrides the named properties; everything else copies exactly as a plain clone would.

array_first()/array_last() return values (not keys) and never move the array's internal pointer, unlike reset()/end().

PHP 8.5 is source-compatible with the vast majority of PHP 8.4 code -- adopt these features opportunistically, not as forced migration work.

Why This Matters in Production

Clone-with syntax removes a specific, very common source of boilerplate in modern PHP codebases built around immutable value objects and DTOs -- codebases using readonly properties (widespread since PHP 8.1) had been accumulating dozens of near-identical hand-written withX() methods purely to work around the fact that a plain clone couldn't touch a readonly property; PHP 8.5+ deletes that entire category of method. #[\NoDiscard] is the kind of feature whose value shows up mostly in what DOESN'T happen -- a silently-ignored validation check or a discarded immutable transformation that used to become a confusing bug report three layers away from its actual cause now gets caught as a warning at the exact call site where the mistake was made.

$first = reset($scores); // works, but silently rewinds the array's internal pointer as a side effect

$first = array_first($scores); // PHP 8.5+: reads the first value without touching the pointer reset() both returns the first value AND moves the array's internal pointer back to the start -- a side effect that can silently break other code elsewhere relying on current()/next() pointer state for the SAME array. array_first() (PHP 8.5+) gets the same value with no such side effect.

final readonly class Money { public function __construct(public int $cents, public string $currency) {} public function withCents(int $cents): self { return new self($cents, $this->currency); } // hand-written wither } final readonly class Money { public function __construct(public int $cents, public string $currency) {} } $discounted = clone($price, ['cents' => 1499]); // PHP 8.5+: no wither method needed

Before PHP 8.5, changing one field on a readonly object required a hand-written withX() method per property (or per combination of properties) purely to express 'same object, this field different.' Clone-with syntax expresses the same operation directly at the call site, with no boilerplate method needed on the class at all.

[multiple-choice] What does the PHP 8.5+ pipe operator |> require on its right-hand side? A. A string literal only B. A callable that accepts the piped value as its argument C. Another pipe expression only D. An array of callables

B. A callable that accepts the piped value as its argument — The right-hand side of |> must be something callable (a named function, static method, or closure/arrow function via first-class callable syntax) that accepts the left-hand side's value as its argument; the whole |> expression evaluates to that callable's return value.

[multiple-choice] Why did clone-with syntax (PHP 8.5+) specifically matter for readonly objects? A. Readonly properties could always be changed after a plain clone, so clone-with is just a convenience B. A plain clone copies a readonly property's value unchanged, and readonly properties can't be reassigned afterward -- so changing even one field on a cloned readonly object previously required a hand-written wither method C. Readonly properties are removed entirely in PHP 8.5 D. Clone-with only works on non-readonly properties

B. A plain clone copies a readonly property's value unchanged, and readonly properties can't be reassigned afterward -- so changing even one field on a cloned readonly object previously required a hand-written wither method — Because readonly properties cannot be reassigned after construction (including after a plain clone), producing a modified copy of a readonly object previously required rebuilding it via the constructor inside a hand-written withX() method. clone($obj, [...]) does the same thing directly, for both readonly and ordinary properties.

[multiple-choice] What is the key difference between array_first($arr) and reset($arr)? A. They are functionally identical in every way B. array_first() returns the first value without moving the array's internal pointer; reset() returns it AND moves the pointer as a side effect C. array_first() returns a key; reset() returns a value D. reset() is only available in PHP 8.5+

B. array_first() returns the first value without moving the array's internal pointer; reset() returns it AND moves the pointer as a side effect — array_first() (PHP 8.5+) reads the first value with no side effect on the array's internal pointer, while reset() both returns that value and rewinds the pointer -- a side effect that can interfere with other pointer-based iteration (current()/next()) happening elsewhere on the same array.

A codebase has a class final readonly class Address { public function __construct(public string $street, public string $city, public string $zip) {} public function withZip(string $zip): self { return new self($this->street, $this->city, $zip); } }. Rewrite the usage $updated = $address->withZip('90210'); to use a PHP 8.5+ feature instead, and explain what can now be deleted from the class.

Replace the call with clone-with: $updated = clone($address, ['zip' => '90210']);, which produces a new Address with zip overridden and street/city copied unchanged -- functionally identical to what withZip() did. The withZip() method itself (and any other single-purpose wither methods on Address, or on any other readonly value object in the codebase) can be deleted entirely, since clone($obj, [...]) expresses the same 'same object, this field different' operation directly at the call site for any property, not just zip.

Which PHP 8.5+ feature was specifically designed to replace exactly this kind of wither method?