Development

PHP 8.5's URI Extension: Because parse_url() Has Been Lying to Us for 20 Years.

8 minutes

Right, let's talk about something that's probably bitten every single one of us at least once. You know that moment when you confidently use parse_url() to validate a user-submitted URL's, only to discover months later that it's been letting absolute nonsense through? Or worse, rejecting perfectly valid URLs that your users are trying to submit?

We've all been there I'm sure, written some code that uses parse_url() and deployed it to only then find issues when users start submitting things and the output from our code looks off. For instance parse_url() was interpreting jump24.co.uk/path/:8080/foo as having port 8080. Yes, you read that right - it thought a path segment was a port number. That's when we decided enough was enough and started looking for alternatives.

Turns out, we're not the only ones who've been quietly cursing parse_url() for the past two decades. The PHP Foundation has finally said "sod it" and built something proper. Enter PHP 8.5's new URI extension.

The parse_url() Lie We've Been Living

Let's be brutally honest here - parse_url() has been gaslighting us since PHP 4. It doesn't follow RFC 3986. It doesn't follow WHATWG. It follows... well, nobody really knows what it follows. It's like that one developer on your team who "has their own way of doing things."

Here's a fun exercise. What do you think this returns when a user types a URL without the scheme?

php
1<?php
2 
3// User input: "jump24.co.uk/example/:8080/foo"
4$url = 'jump24.co.uk/example/:8080/foo';
5$parsed = parse_url($url);
6 
7var_dump($parsed);
8/*
9array(3) {
10 ["host"]=> string(11) "jump24.co.uk"
11 ["port"]=> int(8080)
12 ["path"]=> string(18) "/example/:8080/foo"
13}
14*/

Wait, what? According to RFC 3986, this is just a relative path. According to WHATWG, it's invalid without a base URL. But parse_url() decides it's a host with port 8080 AND includes that :8080 in the path as well. It's literally making stuff up.

Or how about these beauties:

php
1<?php
2 
3// User types: "example.com/path"
4$parsed = parse_url('example.com/path');
5// parse_url thinks: path = "example.com/path", no host!
6 
7// Add the slashes and suddenly...
8$parsed = parse_url('//example.com/path');
9// Now: host = "example.com", path = "/path"

The worst part? This isn't edge case stuff. This is what happens when users type URLs into your forms without the http:// prefix - which, let's face it, happens all the bloody time, I mean I've done this so many times I've lost count.

The Band-Aids We've Been Using

Over the years, we've all developed our own workarounds. Maybe you've been using filter_var() with FILTER_VALIDATE_URL (which has its own quirks). Maybe you've reached for league/uri like we often do as it's a great package:

php
1<?php
2 
3use League\Uri\Uri;
4 
5// The Laravel way (using league/uri under the hood)
6$uri = Uri::createFromString($url);
7$host = $uri->getHost();
8$path = $uri->getPath();
9 
10// Or perhaps some regex monstrosity
11if (preg_match('/^https?:\/\/[^\/]+/', $url, $matches)) {
12 // Please don't do this
13}

But here's the thing - we shouldn't need third-party packages or regex nightmares for something as fundamental as parsing URLs. It's 2025, for crying out loud.

Enter PHP 8.5's URI Extension (Our New Best Friend)

PHP 8.5 is bringing us a proper URI extension, and it's everything parse_url() should have been from the start. The brilliant bit? It gives us two parsers:

  1. RFC 3986 compliant parser - For when you need to follow the actual standard

  2. WHATWG URL parser - For when you need to match browser behaviour

Finally, we can choose our fighter based on what we're actually doing.

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4use Uri\WhatWg\Url;
5 
6// Parse a URL properly with RFC 3986 (strict)
7$uri = new Uri('https://jump24.co.uk:8080/path?query=value#fragment');
8 
9// Or use WHATWG parser (browser-compatible)
10$url = new Url('https://jump24.co.uk:8080/path?query=value#fragment');
11 
12// Getting components (no more array key guessing!)
13echo $uri->getScheme(); // 'https'
14echo $uri->getHost(); // 'example.com'
15echo $uri->getPort(); // 8080
16echo $uri->getPath(); // '/path'
17echo $uri->getQuery(); // 'query=value'
18echo $uri->getFragment(); // 'fragment'
19 
20// Get the full URL back
21echo $uri->toString(); // Normalized version
22echo $uri->toRawString(); // Original input preserved

But here's where it gets properly good...

Real-World Examples That Actually Work

Remember those nightmare URLs from earlier? Watch this:

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4use Uri\WhatWg\Url;
5 
6// That URL that broke parse_url()
7$userInput = 'jump24.co.uk/example/:8080/foo';
8 
9// RFC 3986 parser knows it's just a path
10$uri = new Uri($userInput);
11echo $uri->getPath(); // 'example.com/example/:8080/foo' - CORRECT!
12echo $uri->getHost(); // null - NO HOST, IT'S A PATH!
13echo $uri->getPort(); // null - NO PORT EITHER!
14 
15// WHATWG parser for browser compatibility
16try {
17 $url = new Url($userInput);
18} catch (UriException $e) {
19 // Correctly throws: Invalid URL without base
20}
21 
22// With proper scheme, it works perfectly
23$uri = new Uri('http:/jump24.co.uk/example/:8080/foo');
24echo $uri->getPath(); // '/example/:8080/foo'
25echo $uri->getPort(); // null (no port, :8080 is in the path)

The Wither Methods (Because Immutability Matters)

One thing we love about Laravel's approach to URLs is immutability, and the new URI extension gets this right:

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4 
5$uri = new Uri('https://api.example.com/v1/users');
6 
7// Create modified versions without mutating the original
8$v2Api = $uri->withPath('/v2/users');
9$staging = $uri->withHost('staging.example.com');
10$authenticated = $uri->withUserInfo($uri->getUsername() . ":password");
11// Original is unchanged
12echo $uri->toString(); // Still 'https://api.example.com/v1/users'
13 
14// Chain them for complex transformations
15$newUri = $uri
16 ->withScheme('http')
17 ->withHost('localhost')
18 ->withPort(3000)
19 ->withPath('/api/test')
20 ->withQuery('debug=true');
21 
22echo $newUri->toString();
23// 'http://localhost:3000/api/test?debug=true'

WHATWG vs RFC 3986 (When It Actually Matters)

Here's where the two standards diverge, and why having both matter

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4use Uri\WhatWg\Url;
5 
6// User input without scheme (happens ALL THE TIME)
7$userInput = 'example.com/path';
8 
9// RFC 3986: This is a valid relative path
10$rfc = new Uri($userInput);
11echo $rfc->getPath(); // 'example.com/path'
12echo $rfc->getHost(); // null - it's just a path!
13 
14// WHATWG: This needs a base URL to be valid
15try {
16 $whatwg = new Url($userInput);
17} catch (UriException $e) {
18 // Throws: Cannot parse URL without base
19}
20 
21// But with a base URL, WHATWG can resolve it
22$whatwg = new Url($userInput, 'https://default.com');
23echo $whatwg->toString(); // 'https://default.com/example.com/path'
24 
25// Another difference: Default ports
26$uri = new Uri('https://example.com:443/path');
27echo $uri->toString(); // Still shows :443
28 
29$url = new Url('https://example.com:443/path');
30echo $url->toString(); // Removes :443 (default for HTTPS)
31 
32// File URLs - browsers handle these differently
33$fileUrl = 'file:///home/user/document.pdf';
34 
35$rfc = new Uri($fileUrl);
36echo $rfc->getHost(); // '' (empty string)
37 
38$whatwg = new Url($fileUrl);
39echo $whatwg->getUnicodeHost(); // '' (empty string per WHATWG)

Use RFC 3986 when you're dealing with backend systems, APIs, or need strict validation. Use WHATWG when you're handling browser URLs or user input that needs to match what browsers do.

Comparing URLs Safely (Security Actually Matters)

One of the biggest security issues we've seen with parse_url() is comparing URLs to check if they're from the same domain. The new extension makes this bulletproof:

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4 
5class UrlSecurity
6{
7 public static function isSameOrigin(string $url1, string $url2): bool
8 {
9 try {
10 $uri1 = new Uri($url1);
11 $uri2 = new Uri($url2);
12 
13 return $uri1->getScheme() === $uri2->getScheme()
14 && $uri1->getHost() === $uri2->getHost()
15 && $uri1->getPort() === $uri2->getPort();
16 } catch (UriException $e) {
17 return false;
18 }
19 }
20 
21 public static function isAllowedRedirect(string $url, array $allowedHosts): bool
22 {
23 try {
24 $uri = new Uri($url);
25 $host = $uri->getHost();
26 
27 // Handle URLs without hosts (relative paths)
28 if ($host === null) {
29 return true; // Relative URLs are safe
30 }
31 
32 // Normalize for comparison
33 $normalizedHost = mb_strtolower($host);
34 
35 return in_array($normalizedHost, array_map('mb_strtolower', $allowedHosts));
36 } catch (UriException $e) {
37 return false;
38 }
39 }
40}
41 
42// Usage in a Laravel controller
43public function handleRedirect(Request $request)
44{
45 $redirectUrl = $request->input('redirect_to');
46 
47 if (!UrlSecurity::isAllowedRedirect($redirectUrl, config('app.allowed_redirect_hosts'))) {
48 abort(400, 'Invalid redirect URL');
49 }
50 
51 return redirect($redirectUrl);
52}

Query String Handling (Finally, Something Sensible)

Working with query strings has always been a faff with parse_url(). The new extension makes it actually pleasant:

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4 
5$uri = new Uri('https://example.com/search?category=books&sort=price&order=asc');
6 
7// Get the entire query string
8$query = $uri->getQuery(); // 'category=books&sort=price&order=asc'
9 
10// Parse it if needed (combines nicely with parse_str)
11parse_str($query, $params);
12// ['category' => 'books', 'sort' => 'price', 'order' => 'asc']
13 
14// Or build a new query
15$newUri = $uri->withQuery(http_build_query([
16 'category' => 'electronics',
17 'sort' => 'rating',
18 'order' => 'desc',
19 'page' => 2
20]));
21 
22// Perfect for API clients
23class ApiClient
24{
25 private Uri $baseUri;
26 
27 public function __construct(string $baseUrl)
28 {
29 $this->baseUri = new Uri($baseUrl);
30 }
31 
32 public function get(string $endpoint, array $params = []): Response
33 {
34 $uri = $this->baseUri
35 ->withPath($endpoint)
36 ->withQuery(http_build_query($params));
37 
38 return Http::get($uri->toString());
39 }
40}
Looking for help with a PHP Project?

Talk to us today about our Team Augmentation service to see how we can help you with your current PHP Project.

Get in touch

The Reality Check

Now, before you get too excited and start refactoring everything, let's talk about the elephants in the room.

The Adoption Timeline

PHP 8.5 lands on November 20, 2025. But let's be realistic about when we'll actually be using this:

  • November 2025: PHP 8.5 releases

  • Early 2026: Brave souls use it in production

  • Mid 2026: Laravel officially supports it

  • Late 2026: Hosting providers catch up

  • 2027: Most of us can actually use it

If your minimum PHP version is currently 8.1 (like most of our Laravel projects), you're looking at a long wait.

The Migration Path

Here's the thing - parse_url() isn't going anywhere. It's been in PHP for 20+ years, and removing it would break approximately 97% of all PHP applications ever written. So we're entering this awkward period where we have two ways to parse URLs:

php
1<?php
2 
3use Uri\Rfc3986\Uri;
4 
5// The old way (still works, still broken)
6$parts = parse_url($url);
7if ($parts === false) {
8 // Handle error
9}
10$host = $parts['host'] ?? null;
11 
12// The new way (requires PHP 8.5+)
13try {
14 $uri = new Uri($url);
15 $host = $uri->getHost();
16} catch (\Uri\UriException $e) {
17 // Handle error properly
18}
19 
20// Or handle user input that might not have a scheme
21$userInput = trim($_POST['website']);
22 
23// Add scheme if missing (common pattern)
24if (!preg_match('~^https?://~i', $userInput)) {
25 $userInput = 'https://' . $userInput;
26}
27 
28try {
29 $uri = new Uri($userInput);
30 // Now we can safely work with it
31} catch (\Uri\UriException $e) {
32 // Invalid URL even after our fix
33 return back()->withErrors(['website' => 'Please enter a valid URL']);
34}

What About Laravel?

Laravel currently uses league/uri for most of its URL handling, which is already excellent. The interesting question is whether Laravel will switch to the native extension once PHP 8.5 becomes the minimum version (probably Laravel 14 or 15?).

For now, if you're in Laravel, you're already in good hands:

php
1<?php
2 
3use Illuminate\Support\Facades\URL;
4 
5// This already works brilliantly
6$url = URL::to('/path', ['query' => 'value']);
7$current = URL::current();
8$previous = URL::previous();

The Polyfill Situation

Unlike some PHP 8.5 features, you can't really polyfill this one properly. The whole point is that it's a better parser than what PHP currently offers. There's talk of a userland implementation, but honestly, if you need this functionality now, just use league/uri.

Performance Considerations

We ran some quick benchmarks (because of course we did) please take these with a pinch of salt as its not super indepth. Here's exactly how we tested it, so you can verify these numbers yourself:

php
1<?php
2 
3// Our benchmark script (PHP 8.5 RC)
4use Uri\Rfc3986\Uri;
5use League\Uri\Uri as LeagueUri;
6 
7// Test data: Mix of simple and complex URLs
8$testUrls = [
9 'https://example.com/path',
10 'https://user:pass@api.example.com:8080/v1/users?page=1&limit=20#section',
11 'http://subdomain.example.co.uk/path/to/resource',
12 'ftp://files.example.com/downloads/file.zip',
13 'https://münchen.de/über-uns',
14 // ... generate more test URLs
15];
16 
17// Generate 10,000 URLs for testing
18$urls = [];
19for ($i = 0; $i < 2000; $i++) {
20 foreach ($testUrls as $url) {
21 $urls[] = $url;
22 }
23}
24 
25// Benchmark parse_url()
26$start = microtime(true);
27foreach ($urls as $url) {
28 $parts = parse_url($url);
29 $host = $parts['host'] ?? null;
30}
31$parseUrlTime = (microtime(true) - $start) * 1000;
32 
33// Benchmark league/uri
34$start = microtime(true);
35foreach ($urls as $url) {
36 try {
37 $uri = LeagueUri::createFromString($url);
38 $host = $uri->getHost();
39 } catch (Exception $e) {
40 // Handle error
41 }
42}
43$leagueTime = (microtime(true) - $start) * 1000;
44 
45// Benchmark new Uri extension
46$start = microtime(true);
47foreach ($urls as $url) {
48 try {
49 $uri = new Uri($url);
50 $host = $uri->getHost();
51 } catch (\Uri\UriException $e) {
52 // Handle error
53 }
54}
55$uriExtTime = (microtime(true) - $start) * 1000;
56 
57printf("parse_url(): %6.2fms\n", $parseUrlTime);
58printf("league/uri: %6.2fms\n", $leagueTime);
59printf("Uri extension: %6.2fms\n", $uriExtTime);

Our results across multiple runs:

shell
1parse_url(): 42.18ms (but wrong results for edge cases!)
2league/uri: 156.43ms (correct, but userland overhead)
3Uri extension: 38.91ms (correct AND fast)

We also tested with more complex operations (parsing + modification):

php
1<?php
2 
3// Complex operation benchmark
4$start = microtime(true);
5foreach ($urls as $url) {
6 $parts = parse_url($url);
7 if ($parts !== false) {
8 $parts['scheme'] = 'https';
9 $parts['port'] = 443;
10 // Manually reconstruct (ugh)
11 $newUrl = $parts['scheme'] . '://' .
12 ($parts['user'] ?? '') .
13 ($parts['pass'] ? ':' . $parts['pass'] : '') .
14 ($parts['user'] ?? '' ? '@' : '') .
15 $parts['host'] .
16 ':' . $parts['port'] .
17 ($parts['path'] ?? '') .
18 ($parts['query'] ? '?' . $parts['query'] : '') .
19 ($parts['fragment'] ? '#' . $parts['fragment'] : '');
20 }
21}
22$parseUrlModifyTime = (microtime(true) - $start) * 1000;
23 
24// With Uri extension
25$start = microtime(true);
26foreach ($urls as $url) {
27 try {
28 $uri = new Uri($url);
29 $newUri = $uri->withScheme('https')->withPort(443);
30 $result = $newUri->toString();
31 } catch (\Uri\UriException $e) {
32 // Handle error
33 }
34}
35$uriModifyTime = (microtime(true) - $start) * 1000;
36 
37printf("parse_url() + rebuild: %6.2fms\n", $parseUrlModifyTime);
38printf("Uri + withers: %6.2fms\n", $uriModifyTime);

Results:

shell
1parse_url() + rebuild: 128.34ms (and error-prone!)
2Uri + withers: 71.22ms (clean and safe)

The native extension is faster because it's implemented in C, avoiding the overhead of userland PHP function calls. But honestly, unless you're parsing thousands of URLs per request (and if you are, we need to talk about your architecture), the performance difference won't matter. The correctness is what matters.

The Verdict

Is the new URI extension going to revolutionise how we build PHP applications? No. Is it going to fix a two-decade-old problem that's caused countless security vulnerabilities and weird bugs? Absolutely.

For us at Jump24, this is exactly the kind of improvement PHP needs. Not flashy, not revolutionary, just fixing the fundamentals that have been broken for far too long. It's like finally fixing that squeaky door hinge - you don't realise how annoying it was until it's gone.

When PHP 8.5 lands and we can finally use this in production, we'll be switching over gradually. New projects will use the URI extension. Existing projects will migrate when we're touching URL-handling code anyway. And parse_url()? It'll join mysql_* functions in the "we don't talk about that" pile.

Your Turn

What's your worst parse_url() horror story? We shared ours at the beginning (that :8080 in the path incident still haunts us). Have you had URLs that broke your application in production? International domains that wouldn't parse? Security vulnerabilities from incorrect URL validation?

Drop us a line or share your tales of URL parsing woe. We're collecting stories for a future "PHP parsing nightmares" post, and parse_url() deserves its own chapter.

And if you're dealing with complex URL handling in your Laravel applications - whether it's API integrations, OAuth flows, or just trying to properly validate user input - get in touch. We've been wrestling with PHP's URL quirks for 11 years, and we've got the battle scars (and solutions) to prove it.

Want more PHP 8.5 deep dives? We're covering all the interesting bits as we test them in our development environment. Follow us to make sure you don't miss the next one!

Laravel Partner

Since 2014, we’ve built, managed and upgraded Laravel applications of all types and scales with clients from across the globe. We are one of Europe’s leading Laravel development agencies and proud to support the framework as an official Laravel Partner.