Every Laravel codebase has a method that takes a URL it did not generate — a tenant's custom domain, a webhook callback, an OAuth redirect_uri — and bolts something onto it. It is always rtrim($base, '/') . '/' . $path . '?' . http_build_query($params), and it is always subtly wrong. PHP 8.6 ships Uri\Rfc3986\UriBuilder and finishes the job the PHP 8.5 URI extension started.
Confirm the URI extension is available#
PHP 8.6 reaches GA on 19 November 2026. The URI follow-up RFC adds Uri\Rfc3986\UriBuilder, Uri\WhatWg\UrlBuilder, getUriType(), getHostType(), Url::isSpecialScheme() and the uri_percent_encode() family. The extension is bundled, but it can be compiled out, so guard it before you ship anything that depends on it.
if (! extension_loaded('uri')) {
throw new RuntimeException('The uri extension is required.');
}
Better: put it in composer.json so the failure happens at install time rather than at request time.
{
"require": {
"php": "^8.6",
"ext-uri": "*"
}
}
Build a URI from components#
UriBuilder is a mutable builder that produces one immutable Uri when you call build(). Before 8.6 the only way to assemble a URI was to parse a string and then chain wither methods, which allocated a new object per component and could not start from nothing. The builder sets components in place and instantiates once.
use Uri\Rfc3986\UriBuilder;
$uri = new UriBuilder()
->setScheme('https')
->setHost('api.partner.test')
->setPort(8443)
->setPath('/v2/events')
->setQuery('tenant=acme&page=2')
->setFragment('results')
->build();
echo $uri->toRawString();
// https://api.partner.test:8443/v2/events?tenant=acme&page=2#results
That chain relies on calling methods on new without wrapping parentheses, which landed in PHP 8.4.
Validation happens at two levels. Each setter checks the syntax of its own component immediately — setScheme('http%80') throws straight away because a scheme cannot contain percent-encoded octets. Constraints that depend on the whole URI are deferred to build(), so setting a userinfo with no host is only rejected at the end.
$builder = new UriBuilder()
->setScheme('https')
->setUserInfo('user:pass'); // fine so far
$builder->build(); // Uri\InvalidUriException: host is required when userinfo is set
setUserInfo() is marked #[\SensitiveParameter], so credentials passed to it are redacted from stack traces automatically.
One trap: the builder is mutable and reusable. If a builder instance is handed to you by someone else, call reset() before you touch it, and call it again between builds if you are producing several URIs in a loop.
$builder = new UriBuilder();
foreach ($tenants as $tenant) {
$uris[] = $builder->reset()
->setScheme('https')
->setHost($tenant->domain)
->setPath('/callback')
->build();
}
Rewrite a callback URL you were handed#
This is the case Laravel's own helpers do not cover. A tenant gives you a callback URL, you need to append an event id and a signature, and you have no idea what is already in it. Here is the version that ships in most codebases:
final class WebhookDispatcher
{
public function callbackUrl(string $base, string $eventId, string $signature): string
{
return rtrim($base, '/')
. '/?' . http_build_query(['event' => $eventId, 'sig' => $signature]);
}
}
Three inputs break it. https://acme.test/hooks?source=crm loses source=crm because the ? is appended blind. https://acme.test gains a trailing slash it never had. And a base that already ends in /hooks/ quietly becomes /hooks/?..., which is a different route on most servers.
Parse first, then amend. Uri::parse() returns null on a malformed input instead of throwing, which makes it the right entry point for data you did not create — the constructor throws Uri\InvalidUriException instead.
use Uri\Rfc3986\Uri;
final class WebhookDispatcher
{
public function callbackUrl(string $base, string $eventId, string $signature): string
{
$uri = Uri::parse($base)
?? throw new InvalidArgumentException("Not a valid callback URL: {$base}");
// Merge into whatever query the tenant already had, rather than replacing it.
parse_str($uri->getRawQuery() ?? '', $params);
return $uri
->withQuery(http_build_query([...$params, 'event' => $eventId, 'sig' => $signature]))
->toRawString();
}
}
$uri is a readonly value object, so the original is untouched and withQuery() hands back a new instance — the same value-object discipline you already use for domain types, enforced by the engine this time.
build() also takes an optional base URI, which resolves a relative reference against it. That is the clean way to turn a path a tenant configured into an absolute URL:
$base = Uri::parse('https://acme.test/hooks/');
$absolute = new UriBuilder()
->setPath('inbound') // no leading slash, deliberately relative
->build($base);
echo $absolute->toRawString(); // https://acme.test/hooks/inbound
Percent-encode each component for its context#
Percent-encoding is context-sensitive and rawurlencode() is not. It encodes / unconditionally, which is correct inside a single path segment and wrong for a whole path. PHP 8.6 adds uri_percent_encode() with a mode per component.
use Uri\Rfc3986\UriPercentEncodingMode;
use function Uri\Rfc3986\uri_percent_encode;
rawurlencode('/reports/q4 summary');
// %2Freports%2Fq4%20summary — the slashes are gone
uri_percent_encode('/reports/q4 summary', UriPercentEncodingMode::Path);
// /reports/q4%20summary — structure preserved
uri_percent_encode('q4 summary', UriPercentEncodingMode::PathSegment);
// q4%20summary
The RFC 3986 modes are UserInfo, RegisteredNameHost, Path, PathSegment, Query, FormQuery, Fragment, AllReservedCharacters and AllButUnreservedCharacters. FormQuery is the application/x-www-form-urlencoded variant that encodes a space as + — that is the one that matches urlencode(), and the one you want when a server is going to run parse_str() on the result.
Guard a redirect with getUriType() and getHostType()#
The classic open-redirect check is a string comparison, and it loses:
// Passes for https://acme.test.evil.example/steal — do not ship this.
if (str_starts_with($next, 'https://acme.test')) {
return redirect($next);
}
Parse it, confirm it is an absolute URI rather than a relative or network-path reference, confirm the host is a registered name rather than a raw IP literal, then compare the host exactly. getUriType() and getHostType() are what make the middle two checks possible.
use Uri\Rfc3986\Uri;
use Uri\Rfc3986\UriHostType;
use Uri\Rfc3986\UriType;
final class RedirectGuard
{
/** @param list<string> $allowedHosts */
public function __construct(private array $allowedHosts) {}
public function isSafe(string $candidate): bool
{
$uri = Uri::parse($candidate);
if ($uri === null) {
return false;
}
// "//evil.example/x" is a network-path reference: a browser resolves it off-site.
if ($uri->getUriType() !== UriType::Uri) {
return false;
}
if (! in_array($uri->getScheme(), ['http', 'https'], true)) {
return false;
}
// Reject 169.254.169.254 and friends before they reach the host comparison.
if ($uri->getHostType() !== UriHostType::RegisteredName) {
return false;
}
return in_array($uri->getHost(), $this->allowedHosts, true);
}
}
getScheme() returns the normalised, lower-cased scheme, so HTTPS:// needs no special handling. getHostType() returns null when there is no host at all, which the RegisteredName comparison already rejects.
Pin it with a dataset. These are the inputs that have historically got through hand-rolled guards:
use App\Http\RedirectGuard;
it('rejects hostile redirect targets', function (string $candidate) {
expect(new RedirectGuard(['acme.test'])->isSafe($candidate))->toBeFalse();
})->with([
'suffix match' => 'https://acme.test.evil.example/x',
'userinfo trick' => 'https://acme.test@evil.example/x',
'network-path ref' => '//evil.example/x',
'relative path' => 'account/settings',
'raw IPv4' => 'https://169.254.169.254/latest/meta-data/',
'raw IPv6' => 'https://[::1]/admin',
'javascript scheme' => 'javascript:alert(1)',
'malformed' => 'ht!tp://acme.test',
]);
it('accepts the allow-listed host', function () {
expect(new RedirectGuard(['acme.test'])->isSafe('https://acme.test/dashboard'))->toBeTrue();
});
The userinfo trick case is the one worth staring at: https://acme.test@evil.example/x has acme.test as the userinfo, not the host, and a browser sends you to evil.example. A parser gets that right for free. A str_contains() never will. If you write assertions like these often, a custom Pest expectation keeps the dataset readable.
Choose between RFC 3986 and WHATWG#
The two specs disagree on normalisation, on which hosts are valid, and on what a scheme means. Uri\Rfc3986\* is the strict spec parser, and it is the right choice for server-to-server work, signed URLs, and anything you will compare for equality. Uri\WhatWg\Url matches what a browser actually does, including Unicode host handling, and is the right choice for a URL a browser will resolve.
UrlBuilder mirrors UriBuilder with two differences: it splits userinfo into setUsername() and setPassword(), and its setScheme() requires a string because WHATWG mandates a scheme. PHP 8.6 also adds Url::isSpecialScheme(), which tells you whether a URL uses one of the six schemes WHATWG parses specially (http, https, ftp, file, ws, wss).
$url = new Uri\WhatWg\Url('https://example.com');
var_dump($url->isSpecialScheme()); // true
$url = new Uri\WhatWg\Url('custom:example');
var_dump($url->isSpecialScheme()); // false
Pick one per boundary and do not mix them inside a single comparison.
Keep Laravel's helpers for first-party URLs#
None of this replaces the framework. route() and URL::signedRoute() remain the answer for links into your own app, because they know your route table. Http::baseUrl() and Http::withQueryParameters() remain the answer for outbound calls, because the client assembles the URL for you.
Http::baseUrl('https://api.partner.test/v2')
->withQueryParameters(['tenant' => 'acme', 'page' => 2])
->get('/events');
Laravel also ships Illuminate\Support\Uri, a fluent wrapper over the League URI package with Uri::of(), withQuery() and replaceQuery(). It is pleasant, it works on PHP 8.2, and it is a dependency rather than the engine. Use it when you want fluent ergonomics across versions; reach for Uri\Rfc3986\* when you want the strict parser, the type enums and no package in the path — particularly in the security checks above, where "which spec is this implementing" is the whole question. If you are still planning the move, the PHP 8.6 upgrade checklist covers the rest of the version bump.
Watch for these edge cases#
A syntactically valid URI is not a safe one. https://169.254.169.254/ parses perfectly. SSRF filtering — DNS resolution, private range checks, a deny list — is a separate, explicit step after parsing, and getHostType() only removes the literal-IP shortcut.
Decide once whether your inputs are raw or already encoded. Amending a component does not re-encode something you passed in pre-encoded, so a value that went through rawurlencode() on the way in will be double-encoded if you then hand it to uri_percent_encode(). Pick a boundary and normalise there.
Query parameter manipulation and path segment access were split out of the follow-up RFC into their own proposals, so there is no withQueryParam() in 8.6. parse_str() plus http_build_query() is still the way to merge a query string, as in the dispatcher above.
Uri::parse() returning null and new Uri() throwing are not interchangeable. Use parse() for anything that came from a request, a database column or a tenant config; use the constructor for literals you control, where a throw is the correct response to a typo.
Wrapping Up#
Find the one method in your app that concatenates a URL it did not generate, and give it a Uri::parse() and a withQuery(). Then find your redirect guard and replace the str_starts_with() with getUriType() plus an exact host comparison — that one is a security fix, not a tidy-up. If you have not read the 8.5 groundwork yet, the URI extension article covers the parsing side, and partial function application is the other 8.6 feature most likely to change how you write Laravel code.
FAQ#
What is UriBuilder in PHP 8.6?
Uri\Rfc3986\UriBuilder is a mutable builder class added in PHP 8.6 that assembles an RFC 3986 URI component by component and produces one immutable Uri\Rfc3986\Uri when you call build(). Before it, the only way to construct a URI was to parse an existing string and chain wither methods, which allocated a new object per change and could not start from an empty state. Setters validate each component's syntax immediately, and build() validates the URI as a whole.
How do I add a query parameter to a URL in PHP without breaking it?
Parse the URL first with Uri\Rfc3986\Uri::parse(), read the existing query with getRawQuery(), merge your parameters into it with parse_str() and http_build_query(), then call withQuery(). Appending ?foo=bar to a string silently destroys any query the URL already had, which is the single most common URL bug in production code. In a Laravel app making an outbound request, Http::withQueryParameters() does the same job without the parsing step.
What is the difference between Uri\Rfc3986 and Uri\WhatWg in PHP?
Uri\Rfc3986\Uri implements the strict IETF specification: no Unicode in components, an optional scheme, and normalisation that follows RFC 3986. Uri\WhatWg\Url implements the living standard that browsers follow, which accepts Unicode hosts, requires a scheme, and normalises differently. Use RFC 3986 for server-to-server work, signed URLs and equality comparisons; use WHATWG for anything a browser will resolve.
Should I use UriBuilder or Laravel's URL facade?
Use Laravel's route(), URL::signedRoute() and the HTTP client's baseUrl() and withQueryParameters() for URLs your application owns — they know your routes and your configuration. Reach for UriBuilder and Uri\Rfc3986\Uri for URLs that arrived from outside: tenant domains, webhook callbacks, OAuth redirect targets. Laravel's own Illuminate\Support\Uri wrapper sits between the two and works on older PHP versions.
How do I safely validate a redirect URL in PHP 8.6?
Parse the candidate with Uri::parse(), reject null, then check getUriType() === UriType::Uri to rule out relative and network-path references, restrict the scheme to http and https, check getHostType() === UriHostType::RegisteredName to rule out raw IP literals, and finally compare the host exactly against an allow list. A str_starts_with() check passes for https://yourdomain.com.evil.example and for https://yourdomain.com@evil.example, so it is not a guard at all.
Does PHP 8.6 UriBuilder replace parse_url()?
For new code, yes — parse_url() returns an untyped array with optional keys and performs no validation, so a missing host becomes a null you forget to check rather than an error. Uri\Rfc3986\Uri::parse() returns a typed, validated object or null, and UriBuilder covers the construction side parse_url() never had. parse_url() stays for compatibility and for code that must run on PHP 8.4 or earlier.