league/url
League\Url is a PHP library for parsing, inspecting, and manipulating URLs. It provides an object-oriented API for working with schemes, hosts, paths, ports, and query strings, making URL generation and modification safer and more predictable in PHP applications.
Install and pin. The package is effectively in maintenance mode, but the "frozen since 2015" assumption no longer holds exactly: a 3.3.5 patch was published after a very long gap (3.3.4 was the last tag before it). Treat the version constraint as part of your dependency policy and pin to the patched tag rather than floating:
composer require "league/url:^3.3.5"
Commit the lock file and add a note in your composer.json README explaining that this is a legacy dependency. If you are starting fresh, stop here and use league/uri instead — it is the maintained successor from the same organisation and covers the same ground (plus PSR-7/PSR-17 integration).
What 3.3.5 changes. One line only: a fix to relative path handling ("Bug fix path get relative", PR #81). No new features, no deprecations, no API changes. If you are on 3.3.x already, this is a safe patch bump — but read the "Relative URLs" gotcha below, because the behaviour you get now depends on whether you are above or below 3.3.5.
Where to look first.
README.md — the usage examples are the most accurate documentation; the API changed between 2.x and 3.x (e.g. the factory was renamed), so always check which version you actually installed.src/Url.php — the entry point and the only class most applications need.src/Components/ — Scheme, User, Pass, Host, Port, Path, Query, Fragment. Every accessor on Url returns one of these value objects, not a string.CHANGELOG.md / the GitHub releases page — with a package this quiet, the changelog is short enough to read end to end, and it is the only reliable signal about which behaviours have been patched.First real use case: normalising a user-supplied URL before storing it.
use League\Url\Url;
$url = Url::createFromUrl('HTTPS://Example.COM:443/a/b/../c?utm_source=news#top');
(string) $url->getScheme(); // 'https' (normalised case)
(string) $url->getHost(); // 'example.com'
(string) $url->getPath(); // '/a/c' if dot segments are removed, else '/a/b/../c'
(string) $url->getFragment(); // 'top'
// Persist a canonical form
$canonical = (string) $url;
Two things to internalise immediately:
getQuery() returns a Query object, not a string. Cast it ((string)) or call toArray()/get() to read individual parameters.Url has setters (setScheme, setHost, setPort, setPath, setQuery, setFragment), but the components themselves are largely immutable. $url->getPath()->append('x') builds a new Path that you must feed back into setPath().Laravel wiring (optional). There is no Laravel integration package and there doesn't need to be. A thin wrapper is enough:
// app/Support/UrlNormalizer.php
namespace App\Support;
use League\Url\Url;
class UrlNormalizer
{
public function normalize(string $raw): string
{
$url = Url::createFromUrl($raw);
$url->setFragment(null);
$url->setQuery($url->getQuery()->remove(['utm_source', 'utm_medium']));
return (string) $url;
}
}
Bind it in a service provider only if you plan to inject it in more than a couple of places; otherwise a static helper or a plain app(UrlNormalizer::class) call is fine.
The value-object model makes it easy to enforce a consistent shape (scheme + host + path, no query noise):
public function canonical(string $raw): string
{
$url = Url::createFromUrl($raw);
$url->setScheme('https');
$url->setPort(null); // drop explicit :443 / :80
$url->setFragment(null);
$url->setQuery($url->getQuery()->remove(['utm_*']));
return (string) $url;
}
The most common day-to-day need in a Laravel app is "take the current URL and change one query parameter". Because the Query component is a collection-like object, use it as the single source of truth:
use League\Url\Url;
public function pageLink(string $currentUrl, int $page): string
{
$url = Url::createFromUrl($currentUrl);
$query = $url->getQuery();
$query->modify(['page' => $page]);
$url->setQuery($query);
return (string) $url;
}
modify() merges into the existing query without dropping unrelated filters. If your installed version lacks modify(), fall back to toArray() + array_merge + Query::createFromParams($params) (or construct a Query from the array) and re-assign.
Staging/production link rewriting is a classic use for a URL manipulation library rather than a URL router:
public function toProduction(string $stagingUrl): string
{
$url = Url::createFromUrl($stagingUrl);
$url->setHost('www.example.com');
$url->setScheme('https');
$url->setPort(null);
return (string) $url;
}
Url::createFromServer($serverArray) builds a Url from a $_SERVER-shaped array. In Laravel, prefer passing an explicit array over the superglobal so it is testable:
$url = Url::createFromUrl(request()->fullUrl());
// or, if you need the raw pieces:
$url = Url::createFromServer([
'HTTPS' => request()->isSecure() ? 'on' : 'off',
'HTTP_HOST' => request()->getHost(),
'REQUEST_URI' => request()->getRequestUri(),
]);
Because setters accept either a string or the matching component object, you can move parts between URLs safely:
$a = Url::createFromUrl('https://api.example.com/v1/users?limit=10');
$b = Url::createFromUrl('http://localhost:8000/health');
$b->setScheme($a->getScheme());
$b->setHost($a->getHost());
$b->setPath($a->getPath());
echo $b; // https://api.example.com/v1/users
This is the single most useful property of the package: you never concatenate strings yourself.
3.3.5 fixes relative path retrieval, which makes the Path component usable for things like breadcrumb or asset-path derivation, not just full URLs:
$url = Url::createFromUrl('/docs/guide/../intro?x=1');
$path = $url->getPath();
(string) $path; // normalised path
$rel = $path->getRelative(); // relative form — behaviour differs on < 3.3.5
If you were pinned below 3.3.5 and had a workaround that manually stripped a leading slash or re-parsed the path string, remove it after upgrading and assert the result in a test.
Url object equality is by value, so assertions are clean:
$this->assertSame(
'https://example.com/search?q=php',
(string) Url::createFromUrl('https://example.com/search?q=php')
);
Use a data provider with edge cases: uppercase schemes, default ports, trailing slashes, empty query ?, fragments, IPv6 literals, already-encoded paths, and relative paths (the last one is exactly what 3.3.5 touched — pin your expectations there so a future patch cannot silently shift them).
Minimally maintained — plan the exit. Stars and downloads are irrelevant now; the signals are the multi-year gaps between tags, a change history measured in single-commit releases, and no CI coverage against modern runtimes. The 3.3.5 patch shows someone can still cut a release, but it is a bug-fix-only channel: do not expect features, PHP-version guarantees, or deprecation windows. Do not add it to a greenfield project. If you already depend on it, wrap it behind a single class (as in UrlNormalizer) so migrating to league/uri is a one-file change. league/uri uses Uri::new($string) / Uri::fromString, and its components are immutable throughout, so the migration is mostly mechanical but not a drop-in.
Pin, then verify after any bump. 3.3.5 is a one-line behavioural change to relative path handling. That is exactly the class of fix that can silently alter stored strings if you normalise-and-persist. On upgrade, run your URL fixtures through the new version and diff the output before deploying; if you cannot diff, keep the old pin.
PHP 8 compatibility is unverified. The package predates PHP 7.4 and PHP 8, and a single relative-path patch does not change that. Test on your target PHP version before relying on it; the most likely breakage points are string/array type coercion in Query and Path and any each()-era constructs in the deeper component code. If you hit fatal errors, that is a sign to migrate rather than patch.
The component accessors are objects. $url->getQuery() === '' will never be true. Use (string) $url->getQuery() === '' or count($url->getQuery()) === 0. Same for getPath(), getPort(), and getFragment(). This trips up everyone on the first day.
Immutability mismatch. Url setters mutate the Url instance; components do not. This is the number-one source of silent bugs:
// WRONG — result discarded
$url->getPath()->append('extra');
// RIGHT
$url->setPath($url->getPath()->append('extra'));
Check the return value of each component method you call; if it returns a new instance, you must assign it.
parse_str quirks in Query. The query component ultimately goes through PHP's parse_str-style parsing, so parameter names with dots or spaces are mangled (?a.b=1 becomes a_b), and %2B is decoded to + and then possibly re-decoded on re-serialisation. If you need exact round-tripping of a third-party query string you do not control, parse it yourself with parse_url + explode('&') and only use Url for the non-query parts.
Double-encoding. Component constructors expect decoded values in most cases but will happily accept already-encoded strings, which then get encoded again. If your path already contains %2F, either leave it alone (build the Url with createFromUrl() and don't touch the path) or decode first. There is no setRawPath() escape hatch. Note that the 3.3.5 path fix does not add one — it changes relative retrieval, not raw handling.
Default ports. $url->getPort() returns a Port object whose string cast is empty when the port equals the scheme default. getPort()->get() returns an int|null. Do not compare to 80 expecting true for http://example.com — there is no port there at all. To force a port into the output, setPort(8080) explicitly.
Relative URLs are only partly supported. Url is designed for absolute URLs. If you pass /foo/bar?x=1, you get a Url with only path and query; (string) gives back a relative form, but setHost() on it produces surprising output. The 3.3.5 patch improves relative path retrieval specifically — it does not add RFC 3986 reference resolution. For resolving a relative reference against a base, use league/uri (which implements it) or resolve against request()->root() yourself before constructing the Url.
IDN is not handled for you. Host will accept a Unicode hostname, but it will not convert to Punycode. If you accept user-supplied internationalised domains, run idn_to_ascii() (requires ext-intl) before Url::createFromUrl(), then store the ASCII form.
Public-suffix logic is stale. $url->getHost()->getRegisterableDomain(), getPublicSuffix(), and getSubdomain() rely on a bundled public suffix snapshot from around 2015. 3.3.5 does not touch it. New TLDs added since then (and several ccTLD rule changes) will resolve incorrectly. If you depend on eTLD+1 extraction — for cookies, multi-tenant subdomains, or analytics — use a maintained PSL library instead and treat league/url as the URL parser only.
Extension points are limited and undocumented. The main hooks are (a) injecting your own component instances via the Url setters (e.g. a custom Path subclass), and (b) subclassing Url if you need a richer factory. There is no plugin registry, no config file, and no service provider. Anything more ambitious should be a wrapper class around the library, not a fork — and a patch release arriving nine-plus years later is a reminder that you do not control this package's schedule.
Debugging tip. (string) $url is the fastest way to inspect state, but if you get an unexpected result, dump each component individually — var_dump((string) $url->getScheme(), (string) $url->getHost(), ...) — because __toString() will happily concatenate a partially-populated URL and hide which component is wrong. If __toString() throws, it is almost always a component that received a value it could not validate (e.g. setHost('not a host')). For path questions specifically, compare (string) $url->getPath() against $url->getPath()->getRelative() to see which of the two moved.
Do not serialise Url objects directly. They are not designed for json_encode or queue payloads; the object graph is deep and version-specific — and it just changed under you at 3.3.5. Serialise the string and re-parse on the other side.
How can I help you explore Laravel packages today?