jwage/purl
Purl is a lightweight, dependency-free PHP library for parsing and manipulating URLs. It provides a fluent object-oriented wrapper around parse_url, making it easy to inspect and modify schemes, hosts, paths, query strings, and fragments in your applications.
Purl is a plain PHP library — there is no service provider, config file, or facade to publish. Install it and you are done:
composer require jwage/purl
The only entry point you need is Purl\Url. Read that class first; Purl\Path and Purl\Query are the two sub-objects it delegates to for path and query-string work. The static factory is the cleanest way in:
use Purl\Url;
$url = Url::parse('http://api.example.com/v1/users?page=2');
$url->set('scheme', 'https');
$url->query->set('per_page', '50');
$url->path->set('/v2/users');
echo (string) $url;
// https://api.example.com/v2/users?page=2&per_page=50
A realistic first use case in a Laravel app: you have a third-party base URL in config/services.php and need to build an outbound request URL by swapping the scheme, appending an API key, and keeping whatever query parameters were already configured. Purl lets you do that in three lines instead of string concatenation and parse_str/http_build_query gymnastics.
Wrap it in a tiny value-object helper. Rather than sprinkling Url::parse() calls across controllers, put the manipulation in one place:
namespace App\Support;
use Purl\Url;
class Endpoint
{
public static function for(string $base, array $query = []): string
{
$url = Url::parse($base);
foreach ($query as $key => $value) {
$url->query->set($key, $value);
}
return (string) $url;
}
}
Then Endpoint::for(config('services.billing.base_url'), ['key' => config('services.billing.key')]) from a job or HTTP client call.
Merge query parameters idempotently. This is the strongest day-to-day use. Because query->set() overwrites by key, you can layer defaults, then caller overrides, without duplicate-parameter bugs:
$url = Url::parse($request->input('callback_url'));
$url->query->set('signature', $signature);
$url->query->set('expires', now()->addHour()->timestamp);
$redirect = (string) $url;
Rewrite hosts for tenant/subdomain routing. Read the host, split on ., replace the leftmost label, write it back:
$url = Url::parse($webhookUrl);
$host = $url->get('host'); // e.g. "acme.example.com"
$url->set('host', "{$tenant->slug}.example.com");
Use the same technique to force a scheme (http → https) or to strip a port when you cross environments.
Path versioning for outbound APIs. When a vendor bumps /v1/ to /v2/, $url->path->set(...) (or a targeted string replace on (string) $url->path) is safer than regex against the whole URL, which can also hit the host or a query value.
Canonicalization / dedupe. Before storing URLs, parse and clear what you do not want: $url->set('fragment', null) (or ''), lowercase the host, drop tracking params with $url->query->remove('utm_source'). Cast to string and store that.
Feed it into Laravel's HTTP client. Purl has no HTTP opinion, which is fine — build the URL string, then hand it to Http::withToken(...)->get($url). Keep credential handling in the client, not in the URL, unless the upstream API genuinely requires query-string auth.
Testing. Assert on the string form, not on object internals: $this->assertSame('https://...', (string) $url);. It reads well and catches ordering/encoding surprises.
DI. If you want it injectable, bind a closure that returns a configured Url prototype, or better, bind your own EndpointBuilder interface in a service provider and keep Purl as an implementation detail.
getQuery() and the query/path properties are objects, not strings. (string) $url->query and (string) $url are what you want; implicit concatenation is a common source of "Object of class Purl\Query could not be converted to string" errors or wrong output.&, or = in values survive round-tripping correctly. After building, log (string) $url and, if it matters, run the query values through urlencode() yourself. Test with a value containing a space and an ampersand before trusting it.$url->query->set('tags', ['a','b']) is not tags[]=a&tags[]=b by default. For repeated parameters, either set distinct keys or drop to http_build_query() and $url->set('query', $qs).Url::parse('/foo/bar') yields an object with null scheme/host. Guard get('host') and get('scheme') returns before branching on them — don't assume a URL string from user input is absolute.fragment to '' and to null can render differently (...# vs no #). Pick one convention in your codebase and test it.url()/route() with this. Purl is for manipulating URLs you already have (third-party endpoints, stored callbacks, redirect targets). Laravel's URL generator remains the right tool for producing application routes; mixing the two invites subtle mismatches.Url, Path, and Query are ordinary classes you can subclass if you need behavior like always-lowercase hosts. Check the class hierarchy in vendor/jwage/purl/src before overriding; the base parts do most of the parsing work.dd((string) $url) — inspecting the object graph is noisy and easy to misread. For a quick sanity check in tinker, build the URL, cast it, and diff against the input string.How can I help you explore Laravel packages today?