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:
- Convert to lowercase
- Trim whitespace
- 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)
neverparameters: 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: