sabre/uri
sabre/uri is a lightweight PHP library for working with URIs per RFC3986. It offers resolve, normalize, parse, build, and split functions for handling relative URLs, comparing URLs, and Windows-style paths. Fully unit tested, with many tests drawn from RFC3986 examples.
composer require sabre/uri
Version choice matters more than usual:
| PHP | Pin |
|---|---|
| 8.2+ (Laravel 11/12) | ^3.1 |
| 7.4 – 8.1 | ^3.0 |
| ≤ 7.3 | ^2.3 |
There is nothing to register. No service provider, no facade, no config file. Composer autoloads the functions via files, and you call them by namespace:
use function Sabre\Uri\parse;
use function Sabre\Uri\build;
use function Sabre\Uri\resolve;
use function Sabre\Uri\normalize;
use function Sabre\Uri\split;
use Sabre\Uri\InvalidUriException;
Look first at src/functions.php — the whole library is five functions plus one exception. Then skim RFC 3986 §5.2 if you care about why resolve() behaves the way it does.
First real use case: you scrape/consume an HTML page, feed, or API and get href="/docs/v2" or href="../images/a.png". PHP has no built-in for this.
$absolute = resolve('https://example.com/api/v1/items?page=2', '../images/a.png');
// https://example.com/api/images/a.png
That one call is usually the entire justification for the package. Everything else is a bonus.
This is the highest-frequency pattern in a Laravel app: pulling paginated data and following a Link: <...>; rel="next" header.
final class FollowLinks
{
public function next(Illuminate\Http\Client\Response $response): ?string
{
$link = $response->header('Link');
if ($link === '' || !preg_match('/<([^>]+)>\s*;\s*rel="?next"?/', $link, $m)) {
return null;
}
return resolve($response->effectiveUri(), $m[1]);
}
}
effectiveUri() is important — after redirects, the base must be the final URL, not the one you requested.
Two spellings of the same URL should not spawn two queued jobs or two cache entries.
public function canonical(string $url): string
{
$parts = parse($url);
// normalize() does NOT sort the query string or strip the fragment.
unset($parts['fragment']);
if (isset($parts['query'])) {
parse_str($parts['query'], $q);
ksort($q);
$parts['query'] = http_build_query($q);
}
return normalize(build($parts));
}
Then Cache::lock('og:'.hash('xxh128', $canonical)) or a ShouldBeUnique job keyed on the canonical form.
filter_var($url, FILTER_VALIDATE_URL) accepts things you don't want and rejects things you do. parse() gives you the components to make policy decisions yourself.
final class SafeCallbackUrl implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
try {
$parts = parse((string) $value);
} catch (InvalidUriException) {
$fail("The {$attribute} is not a valid URI.");
return;
}
if (($parts['scheme'] ?? null) !== 'https') {
$fail("The {$attribute} must use https.");
}
if (isset($parts['user']) || isset($parts['pass'])) {
$fail("The {$attribute} must not contain credentials.");
}
$host = strtolower((string) ($parts['host'] ?? ''));
if (! in_array($host, config('services.callbacks.allowed_hosts', []), true)) {
$fail("The {$attribute} host is not allowed.");
}
}
}
resolve() and normalize() make a relative Location absolute, but neither decides whether it's safe. That's your job:
function safe_redirect(string $location, Request $request): ?string
{
try {
$target = normalize(resolve($request->fullUrl(), $location));
$targetParts = parse($target);
} catch (InvalidUriException) {
return null;
}
$own = parse((string) config('app.url'));
if (($targetParts['host'] ?? '') !== ($own['host'] ?? '')
|| ($targetParts['scheme'] ?? '') !== ($own['scheme'] ?? '')) {
return null;
}
return $target;
}
normalize() lowercases scheme and host, so the comparison is stable without extra strtolower() calls.
Useful when a third-party gives you components rather than a string (S3 keys, signed URL reconstruction, mailto: links):
$mailto = build(['scheme' => 'mailto', 'path' => '[email protected]']);
// mailto:[email protected]
build() is the inverse of parse() and expects exactly that array shape: scheme, host, port, user, pass, path, query, fragment.
split() for storage pathsdirname()/basename() are locale- and separator-fragile. split() is a predictable last-slash split:
[$dir, $file] = split('https://cdn.example.com/uploads/2024/report.pdf');
// ['https://cdn.example.com/uploads/2024', 'report.pdf']
Comparing URLs textually is a classic flaky test. Normalize both sides:
$this->assertSame(normalize($expected), normalize($actual));
resolve() has file-vs-directory semantics. This is the #1 bug:
resolve('https://example.com/api/v1', 'users'); // https://example.com/api/users ← surprising
resolve('https://example.com/api/v1/', 'users'); // https://example.com/api/v1/users ← intended
If your base came from a config value or a route without a trailing slash, append one.
parse() throws; parse_url() returns false. Wrap every call in try/catch (InvalidUriException). Laravel's exception handler will happily 500 your API otherwise.
normalize() is not a canonicalizer for your purposes. It does not sort query parameters, does not strip fragments, and does not merge https://example.com with https://example.com/ beyond the RFC's empty-path rule. Do the extra work yourself (see the canonical-key pattern above).
build() performs zero percent-encoding. Whatever you put in path or query goes out verbatim. Run rawurlencode() / http_build_query() before handing values to it, or you'll ship URLs with raw & and spaces.
parse() percent-encodes non-ASCII before parsing. That's the fix for parse_url() corrupting Hebrew/Chinese input, but it means the path you get back is already percent-encoded. Don't rawurlencode() it a second time when round-tripping through build().
Windows paths changed behavior in 3.0.0. file:///C:/path/file.ext now yields an empty-string host and a leading-slash path. If you upgraded from 2.3.x and your file:// handling used host === null, re-test — this was a deliberate BC break (PR #82/#71, issue #81).
split() is a plain string split. Query strings and fragments stay glued to the basename ('a/b?x=1' → ['a', 'b?x=1']), and a trailing slash gives you an empty basename ('a/b/' → ['a/b', '']). Parse first if you need real components.
It will not protect you from hostile URLs. //evil.com is protocol-relative (host, no scheme), https://[email protected] has host = evil.com, and browsers treat \ like /. Always compare the parsed host against an allowlist after normalize(), never the raw string, and reject user/pass.
Function-based API is not mockable. You can't swap Sabre\Uri\resolve in a test. If you need fakes (or a seam for a future league/uri migration), wrap the five functions in a small injectable service class and type-hint that interface.
No PSR-7 interop. If you already use guzzlehttp/psr7, you have UriResolver available and may not need this. Conversely, converting parse() output into a UriInterface is trivial: new GuzzleHttp\Psr7\Uri(build($parts)).
Debugging: php artisan tinker → dump(Sabre\Uri\parse($url)) is faster than reasoning about regex. Print parse() on both the base and the relative reference before blaming resolve(); nine times out of ten the base is missing its trailing slash or carries a query string you didn't expect.
Performance: these are small pure-PHP functions, safe inside loops over thousands of feed entries. There's no caching layer, so if you canonicalize the same URL repeatedly in a request, memoize it in a local array.
Extension points are deliberately absent. No config, no events, no interfaces. When you need extra policy (IDN → punycode, default port handling, scheme allowlists), write it around the calls, in a Support\Uri helper or a dedicated service — not by forking the package.
How can I help you explore Laravel packages today?