What's New in PHP 8.5: Pipe Operator, URI Extension, and More

Three years. That’s how long the PHP community has been asking for it. And on November 20, 2025, we finally got it: the pipe operator.

Or: How PHP Learned to Stop Worrying and Love Functional Composition

If you’ve been following PHP’s evolution, you know that each release brings incremental improvements. Constructor property promotion in PHP 8.0 saved us millions of boilerplate lines. Enums in PHP 8.1 made our code safer. But PHP 8.5? This one’s different. According to Larry Garfield (yes, the PHP-FIG Larry Garfield), the pipe operator alone delivers “one of the highest ‘bangs for the buck’ of any feature in recent memory.”

And he’s not wrong. Let me show you why this release matters, not just for the headline features, but for what it signals about PHP’s future.

The Pipe Operator: Finally, Left-to-Right Sanity

The Problem We’ve All Lived With

Pop quiz: What does this code do?

$result = ucfirst(trim(strtolower($input)));

You read it inside-out, right? Your brain parsed it as:

  1. Convert to lowercase
  2. Trim whitespace
  3. Capitalize first letter

But that’s not how you read English. Or German. Or most human languages. We read left-to-right, not inside-out like some deranged LISP interpreter.

I’ve spent years writing code like this, mentally converting nested function calls into a linear narrative. Sometimes I’d break it apart for readability:

$temp1 = strtolower($input);
$temp2 = trim($temp1);
$result = ucfirst($temp2);

Better? Maybe. But now I’m polluting my namespace with $temp1 and $temp2 variables that exist solely to make my code readable. We deserve better.

The Transformation

PHP 8.5 introduces the pipe operator (|>), and suddenly, code reads like a story:

$result = $input
    |> strtolower(...)
    |> trim(...)
    |> ucfirst(...);

Left to right. Top to bottom. The way humans actually think.

The ... syntax is the partial application operator (from PHP 8.1), indicating “put the piped value here.” For callables that accept a single parameter, you can omit it entirely:

$result = $input |> 'strtolower' |> 'trim' |> 'ucfirst';

Real-World Example: Data Processing Pipeline

Here’s where it gets practical. Imagine you’re processing user input for a search query:

// The old way
function prepareSearchQuery(string $input): string {
    return mb_substr(
        trim(
            preg_replace('/\s+/', ' ',
                htmlspecialchars_decode(
                    strtolower($input)
                )
            )
        ),
        0,
        100
    );
}

// The new way
function prepareSearchQuery(string $input): string {
    return $input
        |> strtolower(...)
        |> htmlspecialchars_decode(...)
        |> fn($s) => preg_replace('/\s+/', ' ', $s)
        |> trim(...)
        |> fn($s) => mb_substr($s, 0, 100);
}

Notice something? The new version reads like a recipe. Each step is clear. Each transformation is obvious. And when something breaks (because things always break at 3 AM), you can comment out individual steps to debug:

return $input
    |> strtolower(...)
    |> htmlspecialchars_decode(...)
    // |> fn($s) => preg_replace('/\s+/', ' ', $s)  // Is the regex the problem?
    |> trim(...)
    |> fn($s) => mb_substr($s, 0, 100);

The Gotchas (Because There Are Always Gotchas)

Limitation 1: Single-Parameter Functions Only

Each callable in the pipe must accept exactly one required parameter. This fails:

$result = $value |> str_replace('foo', 'bar', ...);  // Error!

You need to wrap it:

$result = $value |> fn($s) => str_replace('foo', 'bar', $s);

Limitation 2: Arrow Functions Need Parentheses

This is subtle but important:

// Wrong
$result = $value |> fn($x) => $x * 2;

// Right
$result = $value |> (fn($x) => $x * 2)(...);

Coming in PHP 8.6: Function Composition

The pipe operator’s cooler sibling is already on the roadmap. Function composition will let you create new functions from the pipeline:

// PHP 8.6 (proposed)
$normalizer = strtolower(...) |> trim(...) |> ucfirst(...);
$result1 = $normalizer('  HELLO  ');
$result2 = $normalizer('  WORLD  ');

That’s not science fiction. That’s next year.

The URI Extension: URL Parsing That Doesn’t Lie

A Thirty-Year-Old Embarrassment

Here’s a confession: PHP’s parse_url() function is a security nightmare wrapped in convenience.

Try this experiment. Open your terminal and run:

var_dump(parse_url('https://user:pass@example.com:80/path'));

Looks fine, right? Now try this:

var_dump(parse_url('http://foo@evil.com@example.com/'));

What did you get? Did you expect host to be evil.com or example.com? Congratulations, you just discovered why parse_url() shouldn’t be used for security-critical code.

The problem? PHP’s parse_url() doesn’t follow any standard. Not RFC 3986. Not WHATWG. It’s a homebrew parser that makes educated guesses, and sometimes those guesses are wrong.

The New Standard-Compliant Solution

PHP 8.5 introduces an entire URI extension with two different implementations:

RFC 3986 (Traditional Web Standards):

use Uri\Rfc3986\Uri;

$uri = Uri::fromString('https://user:pass@example.com:443/path?query=value#fragment');

echo $uri->getScheme();    // "https"
echo $uri->getAuthority(); // "user:pass@example.com:443"
echo $uri->getHost();      // "example.com"
echo $uri->getPath();      // "/path"
echo $uri->getQuery();     // "query=value"
echo $uri->getFragment();  // "fragment"

WHATWG (Modern Browser Standard):

use Uri\WhatWg\Url;

$url = Url::fromString('https://example.com/path/../other');

echo $url->getPathname();  // "/other" (automatically normalized!)
echo $url->getOrigin();    // "https://example.com"

Why This Matters: Security and Consistency

The new URI extension uses battle-tested libraries:

  • uriparser for RFC 3986 compliance
  • Lexbor for WHATWG compliance

These libraries power browsers and servers. They’ve been fuzzed, audited, and stressed-tested by security researchers. They don’t guess; they follow specs.

Here’s the security win:

// Old way: ambiguous parsing
$parts = parse_url('http://foo@evil.com@example.com/');
// What's the host? Depends on PHP version and mood.

// New way: unambiguous
$uri = Uri\Rfc3986\Uri::fromString('http://foo@evil.com@example.com/');
// Throws an exception or parses according to spec. No guessing.

The Development Story

This wasn’t a quick feature. The PHP Foundation spent nearly a year on this, with over 150 emails on PHP Internals debating the design. Should it be procedural or object-oriented? Which standard to follow? How to handle edge cases?

The result? Both standards, bundled with PHP. No external packages. No dependency hell. Just install PHP 8.5 and you’re ready to parse URLs correctly.

Clone With: Readonly Properties Finally Work

The Readonly Clone Problem

Remember when PHP 8.1 introduced readonly properties? We all celebrated. Immutability! Type safety! No more accidental mutations!

Then we tried to actually use them:

readonly class User {
    public function __construct(
        public string $name,
        public string $email,
        public int $loginCount = 0,
    ) {}
}

$user = new User('Alice', 'alice@example.com');

// How do I increment loginCount?
// Can't modify readonly property directly...
$newUser = new User(
    $user->name,
    $user->email,
    $user->loginCount + 1
);

That’s… verbose. And it gets worse with 10 properties. Libraries like symfony/serializer implemented “wither” patterns:

$newUser = $user
    ->withLoginCount($user->loginCount + 1);

But you had to write those methods manually. For every property. For every class.

The Fix: Clone With Syntax

PHP 8.5 introduces clone with:

readonly class User {
    public function __construct(
        public string $name,
        public string $email,
        public int $loginCount = 0,
    ) {}
}

$user = new User('Alice', 'alice@example.com');
$updated = clone($user, ['loginCount' => $user->loginCount + 1]);

One line. Clean. Obvious. And it respects all your property hooks and type checks:

readonly class User {
    public function __construct(
        public string $name,
        public string $email,
        public int $loginCount = 0,
    ) {}

    public string $normalizedName {
        get => strtolower($this->name);
        set($value) => strtolower(trim($value));
    }
}

// Property hooks still fire on clone
$updated = clone($user, [
    'normalizedName' => '  BOB  '  // Trimmed and lowercased automatically
]);

Production Tip: Validation Still Applies

Don’t assume clone with bypasses your business logic. Type checks, property hooks, and constructor logic all still apply:

readonly class User {
    public function __construct(
        public string $email,
    ) {
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            throw new InvalidArgumentException('Invalid email');
        }
    }
}

$user = new User('valid@example.com');

// This throws InvalidArgumentException
$invalid = clone($user, ['email' => 'not-an-email']);

Your data integrity remains intact.

#[\NoDiscard]: Catching Silent Failures

The Partial Success Problem

Here’s a scenario that’s bitten me more times than I’d like to admit:

function writeToCache(string $key, mixed $value): bool {
    $success = $this->redis->set($key, $value);

    if (!$success) {
        $this->logger->warning("Cache write failed for key: {$key}");
    }

    return $success;
}

// Later in code...
writeToCache('user:123', $userData);  // Did this work? Who knows!

The function returns a boolean indicating success or failure. But if you ignore the return value, you’ll never know that your cache write failed. The warning gets logged, but your application continues blissfully unaware.

Exceptions don’t help here because this isn’t exceptional—cache failures happen. They’re expected. But silently ignoring them? That’s a bug waiting to surface in production.

Enter #[\NoDiscard]

PHP 8.5 borrows from C++'s [[nodiscard]] and Rust’s #[must_use]:

#[\NoDiscard]
function writeToCache(string $key, mixed $value): bool {
    $success = $this->redis->set($key, $value);

    if (!$success) {
        $this->logger->warning("Cache write failed for key: {$key}");
    }

    return $success;
}

// This now triggers a warning
writeToCache('user:123', $userData);

// Warning: Return value of writeToCache() should not be discarded

// This is fine
if (!writeToCache('user:123', $userData)) {
    // Handle cache failure
}

When to Use It

Mark functions with #[\NoDiscard] when:

  • Return values indicate partial success/failure
  • Ignoring the return value leads to silent bugs
  • The operation isn’t important enough for exceptions but too important to ignore

Don’t use it for:

  • Void functions (obviously)
  • Functions where return values are genuinely optional
  • Getter methods

Production Example: Database Transactions

class DatabaseTransaction {
    #[\NoDiscard]
    public function commit(): bool {
        try {
            $this->pdo->commit();
            return true;
        } catch (PDOException $e) {
            $this->logger->error('Commit failed', ['error' => $e->getMessage()]);
            return false;
        }
    }
}

// Bad: silently fails
$transaction->commit();

// Good: explicitly handled
if (!$transaction->commit()) {
    throw new RuntimeException('Failed to commit transaction');
}

Preparing for PHP 9.0: The Deprecations

Out with the Old (Really Old)

Some of these deprecations are overdue. Like, two-decades overdue.

Alternate Semicolon Syntax for Switch/Case

This syntax dates back to PHP/FI 2.0 (1997):

// Deprecated in PHP 8.5
switch ($value):
    case 1:
        echo "One";
        break;
    case 2:
        echo "Two";
        break;
endswitch;

How many of you have even seen this in production? Anyone? Exactly.

The disable_classes INI Setting

; Deprecated - don't do this
disable_classes = PDO,mysqli

This was supposed to prevent users from instantiating certain classes. In practice, it broke autoloading, confused static analyzers, and provided zero security benefit.

Constant Redeclaration

define('FOO', 'bar');
define('FOO', 'baz');  // Deprecated: redefining constant

Yes, this was allowed. No, you should never have done it.

What Didn’t Make the Cut

Several RFCs were proposed but declined:

  • Readonly hooks: Hooks for readonly properties (deemed too complex)
  • Nested classes: Inner classes like Java (not enough use cases)
  • never parameters: Type hint for parameter that must never be used (too niche)
  • Optional interfaces: Interfaces that may or may not exist (confusing)

PHP’s evolution is deliberate. Features need strong justification, clear use cases, and minimal gotchas.

The Smaller Wins That Add Up

array_first() and array_last()

Finally, we don’t need reset() and end():

// Old way
$first = reset($array);
$last = end($array);

// New way
$first = array_first($array);
$last = array_last($array);

Both return null for empty arrays instead of false. One less === check to remember.

Fatal Error Backtraces by Default

; Now enabled by default
fatal_error_backtraces = 1

When PHP crashes at 3 AM, you’ll actually know why. This should have been default years ago.

OPcache Always Compiled In

OPcache is no longer optional. It’s compiled into every PHP 8.5 build. Because honestly, who runs PHP without OPcache in 2025?

CHIPS/Partitioned Cookies

Privacy-preserving cookies for Chrome and Safari:

setcookie('session', $value, [
    'partitioned' => true,  // Prevents cross-site tracking
    'secure' => true,
    'httponly' => true,
    'samesite' => 'None',
]);

Your users’ privacy just got better by default.

The Timeline: Support and Upgrades

  • Released: November 20, 2025
  • Active Support: Until December 31, 2027 (2 years of active development)
  • Security Updates: Until December 31, 2029 (4 years total)

If you’re still on PHP 7.4 (end of life: November 28, 2022), you’re nearly three years out of support. Plan your upgrades. The pipe operator alone is worth it.

What This Means for Your Code

Short Term: Start Using Pipes

The pipe operator will transform how you write data processing code. Start small:

// Replace this
$result = ucfirst(trim($input));

// With this
$result = $input |> trim(...) |> ucfirst(...);

Then expand to more complex pipelines. Your code will thank you.

Medium Term: Adopt URI Parsing

If you’re doing URL validation, switching, or security checks, migrate to the new URI extension:

// Security-critical URL parsing
try {
    $uri = Uri\Rfc3986\Uri::fromString($userInput);

    // Guaranteed to follow RFC 3986
    if ($uri->getHost() === 'trusted-domain.com') {
        // Safe to proceed
    }
} catch (Exception $e) {
    // Invalid URI - reject
}

Long Term: Address Deprecations

Run your test suite with error reporting cranked up:

error_reporting(E_ALL | E_DEPRECATED);

Fix deprecation warnings now, before they become errors in PHP 9.0.

The Bigger Picture

PHP 8.5 isn’t just about features. It’s about philosophy.

The pipe operator shows that PHP is willing to learn from functional languages without becoming one. The URI extension demonstrates a commitment to standards and security over backwards compatibility. The #[\NoDiscard] attribute proves that PHP cares about preventing bugs, not just fixing them.

And the declined RFCs? They show restraint. Not every idea makes the cut. Features need to earn their place.

This is a mature language, confidently evolving.

Try It Yourself

PHP 8.5 is available now. Install it, play with the pipe operator, break your URL parsing assumptions, and start preparing for PHP 9.0.

And when you write your first data pipeline that reads left-to-right like actual human language? Share it. Write about it. Show the world that PHP isn’t the language it was in 2005—it’s the language it will be in 2035.

What’s your first use case for the pipe operator? I’d love to hear what you build with it.


Resources: