The log line was `Redirect allowed: host=trusted.example`, and the redirect went somewhere that was definitely not trusted.example. I spent a long night with parse_url() and a URL that a browser read one way while our allowlist read it another way. So when people ask me what the big thing in PHP 8.5 is, I don't say the pipe operator, even though that's what every conference slide says. My answer is the URI extension, and I'll defend that here.

uri.php
<?php

use Uri\Rfc3986\Uri;

$uri = new Uri('https://php.net/releases/8.5/en.php');
echo $uri->getHost(); // "php.net"

First, where we are. PHP 8.5 came out on November 20, 2025. Laravel 12 and 13 both run on PHP 8.3 through 8.5, hosts offer it, and the next release lands this November. So this is a fair time to stop asking what's new and start asking what actually changed in our codebases. The pipe operator gets almost all the attention. The thing that will matter in your incident reports is `Uri\Rfc3986\Uri` and its WHATWG sibling.

Here's the best case for the other side, and it's a strong one. Pipes change how code reads on every screen you open. Inside-out nested calls are a small tax you pay all day, and `|>` gets rid of it for any plain function, with no collection or `Str::of()` wrapper needed. A feature you see in a hundred files can easily matter more than one you use in three. I accept that. I'd also add that `array_first()` and `array_last()` will remove more ugly `array_key_last()` lookups than any of us wants to admit.

My problem is that readability gains are spread thin, and the pipe has some rough edges that keep them modest. Every step has to be a callable that takes one argument, so the moment you need `str_replace` you're writing an arrow function, and then you have to wrap it in parentheses too. Real pipelines end up as a mix of `trim(...)` and `(fn($s) => ...)`. That's nicer than nesting, but not dramatically nicer. A team that never adopts it loses nothing that a well-named local variable can't give back.

Parsing URLs is different. parse_url() was always lenient: it would give you an array for input that a browser, a proxy and your HTTP client would each read differently. That gap is where open redirects and SSRF allowlist bypasses come from. Now you get an object built to a published standard, either RFC 3986 or WHATWG, and you choose which one matches the system on the other end. That choice is the real upgrade. You have to decide whether you're validating like a server or like a browser, a question parse_url() never asked you.

The call itself is almost too simple to show, which is sort of my point. You don't need a refactor, a style guide debate or a team vote. You find the places where a host or scheme decides who gets in, and you swap the parsing.

To be fair to the rest of 8.5: `#[\NoDiscard]` makes the same kind of argument, catching a `save()` whose false return nobody checked, and fatal errors with backtraces will shorten some memory-exhaustion hunts. Those are where I'd go next. Pipes come after that, in new helpers and data mappers, whenever they make a line clearer. Plenty of us happily write nested calls in Go too, and nobody's career ended over it.

So here's my question for you, since you know your codebase and I don't. If you've already moved to 8.5, what did you change first, and did any of it find a real bug, or was it all about making the code look better? If you've swapped parse_url() for the URI extension, did RFC 3986 or WHATWG turn out to be the right default for your redirects and webhooks, and how did you decide?