PHP 8.5: the pipe operator, a URI library, and a lot of cleanup
PHP 8.5 shipped November 20th. Two features define this release: the pipe operator and the URI extension. They solve different problems, but both share the same motivation: making common operations less awkward to express.
The pipe operator
Functional pipelines in PHP have always been a mess. Chaining transformations meant either nesting function calls inside out, or breaking them into intermediate variables:
// before — read right to left
$result = array_sum(array_map('strlen', array_filter($strings, 'strlen')));
// or verbose but readable
$filtered = array_filter($strings, 'strlen');
$lengths = array_map('strlen', $filtered);
$result = array_sum($lengths);
// after — read left to right
$result = $strings
|> array_filter(?, 'strlen')
|> array_map('strlen', ?)
|> array_sum(?);
The |> operator passes the left-hand value into the right-hand expression. The ? placeholder marks where it goes. Pipelines now read in the order operations happen: left to right, top to bottom.
This pairs well with first-class callables from PHP 8.1. The two features compose nicely:
$result = $input |> trim(...) |> strtolower(...) |> $this->normalize(...);
The URI extension
Handling URIs in PHP has always meant either reaching for a third-party library or cobbling together parse_url() (returns an array, not an object), http_build_query(), and manual string concatenation.
The new Uri extension gives you a proper object-oriented API:
$uri = Uri\Uri::parse('https://example.com/path?query=value#fragment');
$modified = $uri->withPath('/new-path')->withQuery('key=val');
echo $modified; // https://example.com/new-path?key=val#fragment
Immutable value objects, RFC-compliant parsing, modify individual components without parsing and reconstructing the whole string. Long overdue.
#[\NoDiscard]
A new attribute that generates a warning when the return value is ignored:
#[\NoDiscard("Use the returned collection, the original is unchanged")]
public function filter(callable $fn): static { ... }
Useful for immutable methods where ignoring the return value is almost certainly a bug. Common in other languages for years, now in PHP where it belongs.
clone with
Cloning an object with modified properties without using property hooks or a custom with() method:
$updated = clone($point) with { x: 10, y: 20 };
Clean syntax for a pattern readonly objects needed: you clone to “modify” since direct mutation isn’t allowed.
PHP 8.5 has a functional streak. The pipe operator and URI extension together make data transformation code meaningfully easier to read. The language keeps moving in a consistent direction.
Closures in constant expressions
A constraint that’s been baked in since PHP 5: constant expressions (attribute arguments, property defaults, parameter defaults, const declarations) couldn’t contain closures or first-class callables. 8.5 removes that.
#[Validate(fn($v) => $v > 0)]
public int $count = 0;
const NORMALIZER = strtolower(...);
class Config {
public function __construct(
public readonly Closure $transform = trim(...),
) {}
}
This is the missing piece that makes attributes genuinely expressive for validation and transformation rules. Before 8.5, you had to pass class names or string references to attributes and let the framework look them up. Now the callable lives directly in the attribute.
Attributes on constants
The #[\Deprecated] attribute from 8.4 couldn’t be applied to const declarations. 8.5 adds attribute support for constants generally:
const OLD_LIMIT = 100;
#[\Deprecated('Use RATE_LIMIT instead', since: '3.0')]
const API_TIMEOUT = 30;
const RATE_LIMIT = 60;
ReflectionConstant, a new reflection class in 8.5, exposes getAttributes() so tools can read them. Combined with closures in constant expressions, attributes on constants become a real metadata layer for compile-time values.
#[\Override] extends to properties
PHP 8.3 brought #[\Override] for methods. 8.5 extends it to properties:
class Base {
public string $name = 'default';
}
class Derived extends Base {
#[\Override]
public string $name = 'derived';
}
If the property doesn’t exist in the parent, PHP throws an error. Particularly useful with property hooks from 8.4: you can now signal that a hooked property is intentionally overriding a parent’s.
Static asymmetric visibility
8.4 introduced asymmetric visibility (public private(set)) for instance properties. 8.5 brings that to static properties too:
class Registry {
public static private(set) array $items = [];
public static function register(string $key, mixed $value): void {
self::$items[$key] = $value;
}
}
echo Registry::$items['foo']; // readable
Registry::$items['bar'] = 1; // Error: cannot write outside class
Straightforward pattern: expose a static collection for reading, block external mutation.
Constructor promotion for final properties
Property promotion in constructors has existed since PHP 8.0. The final modifier on promoted properties was the missing piece, 8.5 adds it:
class ValueObject {
public function __construct(
public final readonly string $id,
public final readonly string $name,
) {}
}
A subclass can’t override $id or $name with a property of the same name. The final readonly combination on promoted properties makes value objects as locked down as possible without sealing the whole class.
Casts in constant expressions
Another gap in constant expressions: no type casts. 8.5 allows them:
const PRECISION = (int) 3.7; // 3
const THRESHOLD = (float) '1.5'; // 1.5
const FLAG = (bool) 1; // true
Sounds minor until you have configuration constants derived from environment variables that need type coercion right at the declaration.
Fatal errors include backtraces
Before 8.5, a fatal error (out-of-memory, stack overflow, type error in certain contexts) produced a message with no context about where in the code it happened. Finding the cause meant inserting debug logging and reproducing.
8.5 adds stack backtraces to fatal error messages, in the same format as exception backtraces. A new INI directive, fatal_error_backtraces, controls the behavior. It’s on by default.
array_first() and array_last()
PHP has had reset() and end() for accessing the first and last elements of an array since PHP 3. Both mutate the array’s internal pointer (not safe to call on a reference), and they return false for empty arrays in a way that’s indistinguishable from a stored false value.
$values = [10, 20, 30];
$first = array_first($values); // 10
$last = array_last($values); // 30
$first = array_first([]); // null
The new functions return null for empty arrays, don’t touch the internal pointer, and work on any array expression without needing a variable. reset($this->getItems()) was a deprecation warning waiting to happen.
get_error_handler() and get_exception_handler()
PHP has set_error_handler() and set_exception_handler(). Getting the current handler meant either storing it yourself before setting it, or calling set_error_handler(null) and capturing what came back, which also cleared the handler in the process.
8.5 adds:
$current = get_error_handler();
$current = get_exception_handler();
Handy in middleware chains where you want to wrap the existing handler without losing it, or in tests where you want to verify a handler was actually installed.
IntlListFormatter
Formatting a list with locale-appropriate conjunctions has always needed manual string assembly. 8.5 adds IntlListFormatter:
$formatter = new IntlListFormatter('en_US', IntlListFormatter::TYPE_AND);
echo $formatter->format(['apples', 'oranges', 'pears']);
// "apples, oranges, and pears"
$formatter = new IntlListFormatter('fr_FR', IntlListFormatter::TYPE_OR);
echo $formatter->format(['rouge', 'bleu', 'vert']);
// "rouge, bleu ou vert"
The class wraps ICU’s ListFormatter. Three types: TYPE_AND, TYPE_OR, TYPE_UNITS. Width constants control whether you get “and” or “&”. Oxford comma handling, locale-specific conjunction placement, all handled by ICU.
FILTER_THROW_ON_FAILURE for filter_var()
filter_var() returns false on validation failure, which produces the classic false vs null vs 0 ambiguity when you’re filtering untrusted input. A new flag changes that:
try {
$email = filter_var($input, FILTER_VALIDATE_EMAIL, FILTER_THROW_ON_FAILURE);
} catch (Filter\FilterFailedException $e) {
// explicitly invalid, not ambiguously false
}
The Filter\FilterFailedException and Filter\FilterException classes are new in 8.5. The flag can’t be combined with FILTER_NULL_ON_FAILURE: the behaviors are mutually exclusive.
Deprecations that clean up years of technical debt
The backtick operator (`command` as an alias for shell_exec()) is deprecated. It’s an obscure syntax that surprises anyone reading the code and is inconsistent with every other PHP function call.
Non-canonical cast names ((boolean), (integer), (double), (binary)) are deprecated in favor of their short forms: (bool), (int), (float), (string). The long forms have been undocumented for years; 8.5 starts the formal removal.
Semicolon-terminated case statements are deprecated:
// deprecated
switch ($x) {
case 1;
break;
}
// correct
switch ($x) {
case 1:
break;
}
The semicolon form has been syntactically valid since PHP 4 but nobody uses it on purpose. It’s a typo PHP happened to accept.
__sleep() and __wakeup() are deprecated in favor of __serialize() and __unserialize(), which return and receive arrays and compose correctly with inheritance. The old methods had messy semantics around property visibility.
max_memory_limit caps runaway allocations
A new startup-only INI directive: max_memory_limit. It sets a ceiling that memory_limit can’t exceed at runtime. If a script calls ini_set('memory_limit', '10G') and max_memory_limit is 512M, PHP warns and caps the value.
Useful in shared hosting environments, or anywhere you want to make sure a bug or a malicious payload can’t convince PHP to raise its own limit and eat the whole machine’s RAM.
Opcache is always present
In 8.5, Opcache is always compiled into the PHP binary and always loaded. The old situation (Opcache as a loadable extension that might or might not be present depending on build configuration) is gone.
You can still disable it: opcache.enable=0 works fine. What changes is the guarantee that the Opcache API (opcache_get_status(), opcache_invalidate(), etc.) is always available, regardless of how PHP was compiled. Any code that checks extension_loaded('opcache') before calling Opcache functions can drop the check.