parse_url() follows no standard, so it can disagree with the client that fetches the URL, which defeats SSRF allow-lists. PHP 8.5 adds two immutable parsers: Uri\Rfc3986\Uri (uriparser) and Uri\WhatWg\Url (Lexbor, as in browsers).
<?php
use Uri\Rfc3986\Uri;
use Uri\WhatWg\Url;
foreach (['HTTPS://Shop.Example.COM:443/a/./b/../c', 'https://bücher.example/',
'https://example.com\@evil.test/'] as $in) {
printf("%s rfc3986 %s\n whatwg %s parse_url %s\n", $in,
Uri::parse($in)?->toString() ?? 'invalid', Url::parse($in)?->toAsciiString() ?? 'invalid',
parse_url($in, PHP_URL_HOST));
}
echo (new Uri('https://api.example.com/v1/products/7'))->resolve('../search')
->withQuery(http_build_query(['q' => 'SQL & PHP', 'page' => 2]))->toString(), "\n";Output
HTTPS://Shop.Example.COM:443/a/./b/../c rfc3986 https://shop.example.com:443/a/c whatwg https://shop.example.com/a/c parse_url Shop.Example.COM https://bücher.example/ rfc3986 invalid whatwg https://xn--bcher-kva.example/ parse_url bücher.example https://example.com\@evil.test/ rfc3986 invalid whatwg https://example.com/@evil.test/ parse_url evil.test https://api.example.com/v1/search?q=SQL+%26+PHP&page=2
Only WHATWG drops default ports, converts IDN hosts to Punycode, and rejects relative input. In the third case, parse_url() says evil.test, while a browser reads \ as / and visits example.com. Check a URL and fetch it with the same parser.