rowbot/url
A PHP implementation of the WHATWG URL Standard for parsing and manipulating URLs with spec-compliant accuracy. It handles internationalized domains, percent-encoding, relative resolution, and edge cases browsers agree on, offering a dependable alternative to PHP's parse_url for modern URL work.
composer require rowbot/url
Requires a modern PHP (8.1+ at the time of writing — check composer.json). It pulls in rowbot/idna / rowbot/punycode for IDN handling. There is no Laravel service provider or config: it is a plain library, so you can use it anywhere.
Where to look first
src/URL.php — the main mutable value object. It mirrors the browser URL API almost 1:1.src/URLSearchParams.php — the query-string object; it is a live view of the URL's query.src/URLRecord.php and src/BasicURLParser.php — the low-level parser/record if you ever need spec-level control.new URL(...) in a browser.Mental model: URL is mutable, URLSearchParams is live, and serialization ($url->href, (string) $url) is always the canonical WHATWG form — not the input string.
First use case: normalize a user-supplied URL
use Rowbot\URL\URL;
use Rowbot\URL\Exception\URLException;
try {
$url = new URL('HTTPS://Example.COM:443/a/../b?x=1#frag');
echo $url->href; // https://example.com/b?x=1#frag
} catch (URLException $e) {
// invalid URL
}
// Relative resolution needs a base:
$url = new URL('../c?y=2', 'https://example.com/a/b/');
echo $url->href; // https://example.com/a/c?y=2
The package also exposes static helpers such as URL::parse() and URL::canParse() — useful when you want a boolean check instead of exceptions in hot paths.
1. Parse → inspect → serialize. Read the properties you need and store $url->href, never the object.
$url = new URL($input);
$url->protocol; // "https:"
$url->host; // "example.com:8443" (includes port)
$url->hostname; // "example.com"
$url->port; // 8443
$url->origin; // "https://example.com:8443"
$url->pathname; // "/foo/bar"
$url->search; // "?a=1"
$url->hash; // "#top"
2. Mutate and rebuild. Setters behave like the browser API and re-serialize per the spec.
$url = new URL('https://example.com/old');
$url->pathname = '/new';
$url->search = '?page=2'; // leading "?" is optional
$url->hash = '#section'; // leading "#" is optional
echo $url->href; // https://example.com/new?page=2#section
3. Query-string workflows. searchParams is the authoritative object; mutate it in place.
$url = new URL('https://example.com/search?q=php&tag=a&tag=b');
$params = $url->searchParams;
$params->set('q', 'laravel'); // replaces all "q" values
$params->append('tag', 'c'); // adds another "tag"
$params->delete('tag'); // deletes all "tag"
$params->sort();
echo $url->search; // ?q=laravel
echo $url->href; // includes the updated query
$params->get('q'); // first value
$params->getAll('tag'); // all values
$params->has('q'); // true
count($params); // number of pairs
foreach ($params as $name => $value) {
// iterates every pair, including duplicates
}
URLSearchParams can also be used standalone for query-only work (webhook payloads, cache keys):
$params = new URLSearchParams('a=1&b=2');
$params = new URLSearchParams(['a' => '1', 'b' => '2']);
4. Laravel validation rule.
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Rowbot\URL\URL;
use Rowbot\URL\Exception\URLException;
final class WhatwgUrl implements ValidationRule
{
public function __construct(
private ?string $base = null,
private array $schemes = ['http', 'https'],
) {}
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail("The {$attribute} must be a string.");
return;
}
try {
$url = new URL($value, $this->base);
} catch (URLException) {
$fail("The {$attribute} must be a valid URL.");
return;
}
if (! in_array(rtrim($url->protocol, ':'), $this->schemes, true)) {
$fail("The {$attribute} must use " . implode(' or ', $this->schemes) . '.');
}
}
}
Use it in a FormRequest: 'website' => ['required', new WhatwgUrl()], and canonicalize in prepareForValidation() by replacing the input with (string) new URL($input).
5. Open-redirect protection / origin allow-list.
public function handle(Request $request, Closure $next): Response
{
if ($request->filled('redirect')) {
try {
$target = new URL($request->input('redirect'), $request->fullUrl());
$current = new URL($request->fullUrl());
if ($target->origin !== $current->origin || $target->origin === 'null') {
abort(422, 'Invalid redirect target.');
}
$request->merge(['redirect' => (string) $target]);
} catch (URLException) {
abort(422, 'Invalid redirect target.');
}
}
return $next($request);
}
6. Outbound HTTP calls. Normalize external base URLs and paths before handing them to the HTTP client:
$endpoint = new URL('/api/v1/items', config('services.vendor.base_url'));
Http::withToken($token)->get((string) $endpoint);
This catches typos and IDN/encoding issues early rather than as a 404 from the vendor.
7. Canonical link / cache key generation. Strip tracking params with the live searchParams object:
$canonical = new URL($request->fullUrl());
foreach (['utm_source', 'utm_medium', 'utm_campaign', 'fbclid'] as $p) {
$canonical->searchParams->delete($p);
}
$canonical->searchParams->sort();
$key = (string) $canonical;
parse_url(). Expect different normalization: scheme/host lowercased, IDN converted to punycode, default ports removed, dot segments resolved, trailing / added to empty paths, and percent-encoding applied. Do not mix parse_url() and this package on the same value.new URL('/foo') throws; pass the current request URL or a configured base. The second constructor argument accepts string|URL|null.URLException; others follow spec steps that simply return without changing state (e.g. switching a special URL to an incompatible scheme). Re-read the property after assignment if correctness matters.searchParams is live and authoritative. $url->search is derived from it. Setting $url->search replaces the entire query and therefore resets the URLSearchParams contents. Never assign to searchParams itself.+ vs %20 divergence. URLSearchParams serializes spaces as + and parses + as space, while the URL serializer percent-encodes spaces as %20. Round-tripping between the two representations (or with RFC 3986 tooling) is a classic source of bugs — pick one representation at your boundaries.get() returns the first value, getAll() returns all, set() collapses duplicates, append() preserves them. Iteration yields every pair, so iterator_to_array() loses duplicates unless you collect manually.https://example.com/? has search === '' but serializes with a trailing ?. Same asymmetry for #. Don't assume search === '' means "no query in the input."origin can be the string 'null'. Opaque origins (file:, data:, blob:, etc.) return 'null', not a URL — check for that explicitly in allow-lists.href. username/password are exposed and serializable. Redact them before logging or storing, e.g. build a display URL with $url->username = ''.new URL('https://münchen.de') serializes as https://xn--mnchen-3ya.de/. Use hostname for comparisons; if you need Unicode for display, keep the original input or convert back with rowbot/idna.URL is mutable. If an instance is passed around (e.g. into a service), clone it or re-parse from href. Verify searchParams isolation with a quick test, since it is a live child object.(string) $url / $url->href is the safe boundary for queues, cache, and DB columns; re-parse on the way back in. Avoid PHP-serializing the object graph.parse_url(). Spec compliance costs more. In hot paths (log enrichment, request middleware on every request), normalize once and cache by input string; skip parsing when the input is already canonical.$url->href before and after mutation, catch URLException and log its message, and compare edge cases against a browser's new URL() — the package is tested against the web-platform-tests suite, so browser behavior is the reference.composer.json, and wrap parsing in your own small service so swapping libraries later is a one-file change.URLRecord, BasicURLParser, and ParserState exist for low-level work, but for day-to-day use, stay on URL and URLSearchParams; the internals are not a stable integration surface.How can I help you explore Laravel packages today?