windwalker/uri
A PHP URI library for parsing, building, and manipulating URLs and URIs. Create immutable URI objects, resolve relative references, handle query strings and fragments, and normalize paths with a clean, standards-aware API.
windwalker/uri is a small PSR-7 UriInterface implementation whose real value is the string-munging helpers around it (UriHelper, part-level parsing, query building). It is not a router, a request factory, or a URL generator — think of it as "parse_url() with object ergonomics and PSR-7 compatibility."
Minimal steps:
composer require windwalker/uri
First files to read, in order:
src/Uri.php — the PSR-7 implementation; look at __toString(), withQuery(), withPath(), and any static create()/fromString() factory.src/UriHelper.php — static utilities (buildQuery, parseUrl, removeIndex, etc.). In practice you will call these more than the class itself.src/AbstractUri.php / UriData.php (if present) — where the actual parse_url behavior and defaults live.First use case — normalize an inbound URL before storing or redirecting:
use Windwalker\Uri\Uri;
$uri = new Uri('HTTPS://user:[email protected]:443/foo/../bar?b=2&a=1#frag');
(string) $uri; // normalized string form
$uri->getHost(); // 'example.com'
$uri->getPath(); // '/foo/../bar' (no dot-segment resolution!)
$uri->getQuery(); // 'b=2&a=1' (string, not array)
If that output matches what you expect, you're ready. If you expected dot-segment collapsing or query sorting, see the gotchas below — this package deliberately stays close to parse_url semantics.
For a Laravel app, wire it in as a thin adapter rather than a facade: Uri is a plain value object with no container coupling and no service provider.
1. Middleware that rewrites the request URI
Because Uri is PSR-7, you can hand it straight to a request:
use Windwalker\Uri\Uri;
public function handle($request, Closure $next)
{
$original = new Uri((string) $request->getUri());
$normalized = $original
->withScheme('https')
->withHost(strtolower($original->getHost()))
->withPath(rtrim($original->getPath(), '/') ?: '/');
$request->server->set('REQUEST_URI', (string) $normalized);
return $next($request);
}
2. Building URLs from parts in a link/sitemap builder
$url = (new Uri())
->withScheme('https')
->withHost('example.com')
->withPath('/products/' . $product->slug)
->withQuery(UriHelper::buildQuery([
'utm_source' => 'newsletter',
'page' => 2,
'filters' => ['color' => 'red', 'size' => ['m', 'l']],
]));
The UriHelper::buildQuery() helper is the reason to pick this package over rolling your own — it handles nested arrays with the bracket syntax users expect, without you hitting http_build_query()'s encoding quirks mid-pipeline.
3. Safe query mutation (the pattern you'll use most)
Never str_replace on a query string. Parse, mutate, rebuild:
$uri = new Uri($incoming);
$query = UriHelper::parseQuery($uri->getQuery()); // array
unset($query['page']);
$query['utm_campaign'] = 'spring';
$out = $uri->withQuery(UriHelper::buildQuery($query));
This is where the package shines day-to-day: adding tracking params, stripping pagination, appending signed tokens — all without breaking existing keys.
4. Relative URL resolution against a base
$base = new Uri('https://example.com/app/');
$link = new Uri('../docs/intro?x=1');
// Resolve manually using parts from both, or use UriHelper::resolve()
// if your version ships it. Keep the logic in one method — this is the
// spot where hand-rolled resolvers quietly diverge from RFC 3986.
5. Interop with Guzzle / PSR-18 / PSR-7 bridges
Uri satisfies Psr\Http\Message\UriInterface, so Guzzle will accept it directly:
$client->request('GET', new Uri('https://api.example.com/v1/items?limit=50'));
If you're on Laravel's HTTP client, cast to string first — the underlying client re-parses anyway, and an explicit (string) avoids surprises when the object is logged.
6. Comparing URLs without string comparison
For cache keys or dedupe logic, compare a canonical subset rather than the whole string:
$key = implode('|', [
$a->getScheme(), $a->getHost(), $a->getPort(), $a->getPath(),
UriHelper::buildQuery(ksort_recursive(UriHelper::parseQuery($a->getQuery()))),
]);
Immutability bites. Every withX() returns a clone. $uri->withQuery('a=1'); silently does nothing. Assign it back. This is the #1 source of "the package doesn't work" reports.
getQuery() is a string, not an array. There is no getQueryParams() in the PSR-7 contract and this package doesn't add one. You are expected to pair it with UriHelper::parseQuery() / buildQuery(). Forgetting this and calling $uri->getQuery()['key'] will throw a string-offset error.
No dot-segment normalization. parse_url-level behavior means /a/../b survives. If you need RFC 3986 normalization for routing or security-sensitive path checks, add it explicitly.
Encoding is not guaranteed idempotent. Round-tripping (string) (new Uri($s)) can change percent-encoding. Treat it as "safe to send," not "byte-stable." Don't use the string form as a cache key unless you've verified stability for your inputs.
Default ports. Expect :443 and :80 to be dropped by __toString() when the scheme matches. Compare with getPort() (which returns null for defaults) rather than string equality.
parse_str heritage. If UriHelper::parseQuery() delegates to PHP's parser, dots and spaces in keys become underscores, and foo[]=1&foo[]=2 collapses. Validate nested query shapes before trusting them.
Read-only repo, zero dependents. The repository is flagged [READ ONLY], has no stars, and no downstream users. That means: pin an exact version in composer.json, vendor the code if it's load-bearing, and expect no upstream fixes. Do not build a critical URL-signing path on an unmaintained dependency without a fork plan.
No Laravel integration. No service provider, no facade, no config. That's fine — but don't expect Uri to understand APP_URL or URL::to(). Wrap it in a single UrlNormalizer service class in app/Support/ so the rest of the codebase never imports a Windwalker namespace directly. That one indirection makes swapping to guzzlehttp/psr7 or league/uri later a mechanical change.
Extension point. The cleanest way to customize is to extend Uri and override __toString() and the relevant withX() methods, then instantiate your subclass everywhere. Because everything is withX-based, a subclass survives the whole immutable chain — unlike wrapping it in a decorator, which loses the concrete type after the first with...() call.
Debugging tip. When a URL "looks wrong," dump the parts individually:
dump([
'scheme' => $uri->getScheme(), 'host' => $uri->getHost(),
'port' => $uri->getPort(), 'path' => $uri->getPath(),
'query' => $uri->getQuery(), 'fragment' => $uri->getFragment(),
]);
Nine times out of ten the problem is a value that parse_url refused to populate (e.g. a URL with no scheme and a host containing a colon) rather than the package misbehaving.
How can I help you explore Laravel packages today?