jakeasmith/http_build_url
Pure-PHP drop-in for the PECL HTTP extension's http_build_url(). Parse URLs into components and reassemble them reliably, handling schemes, hosts, paths, query strings, and fragments. Ideal for merging parameters or rebuilding relative URLs without needing the extension installed.
Install and confirm the function exists. The package is a Composer-only polyfill — there is no service provider, no facade, no config to publish. It registers a single global function http_build_url() through the files autoload rule.
composer require jakeasmith/http-build-url
It is archived and the last tag is from 2017, so let Composer resolve, then pin the exact version in composer.json ("jakeasmith/http-build-url": "1.0.x" or whatever resolves) — no future patch will ever arrive.
Confirm you actually need it before writing code against it:
php -r "var_dump(function_exists('http_build_url'));"
If PHP already has it (pecl_http installed), the polyfill's function_exists guard means you are testing the extension's behaviour, not the package's. On PHP 8.5+ prefer the built-in Uri\Rfc3986\Uri and skip this dependency entirely.
Where to look first. The entire implementation is one file: vendor/jakeasmith/http-build-url/src/HttpBuildUrl.php. Read the flag constants at the top — HTTP_URL_REPLACE, HTTP_URL_JOIN_PATH, HTTP_URL_JOIN_QUERY, and the HTTP_URL_STRIP_* family — because the function's behaviour is entirely driven by the fourth argument, and the defaults are not what most people assume.
Signature.
http_build_url(
string|array $url,
array $parts = [], // scheme, host, port, user, pass, path, query, fragment
int $flags = HTTP_URL_REPLACE,
string|false &$new_url = null
): string|false
First use case: filter/pagination links without string surgery. A product listing where you want to change page but keep sort and filter intact:
$current = 'https://shop.test/products?page=2&sort=price&filter[brand]=acme';
$next = http_build_url($current, [
'query' => ['page' => 3],
], HTTP_URL_REPLACE | HTTP_URL_JOIN_QUERY);
// https://shop.test/products?page=3&sort=price&filter[brand]=acme
That is the 80% case: you have a URL somewhere (request, DB column, API payload, url()->current()) and you need one component changed without regex.
Build a single mutation helper and use it everywhere. The function is global and untestable in isolation, so wrap it once. This also gives you a place to return the base URL on parse failure instead of leaking false into a redirect.
namespace App\Support;
class UrlMutator
{
public static function build(string $url, array $parts, int $flags = HTTP_URL_REPLACE): string
{
$built = http_build_url($url, $parts, $flags);
return is_string($built) ? $built : $url;
}
}
In Blade or controllers use UrlMutator::build(...) rather than the raw function — that indirection is what makes the eventual migration to Illuminate\Support\Uri a one-file change.
Pattern 1 — query deltas for UI state (pagination, sorting, tab switching). Keep HTTP_URL_REPLACE | HTTP_URL_JOIN_QUERY and pass only what changed. Write an assertion test for duplicate-key precedence before you depend on it (see Gotchas).
Pattern 2 — environment/host swapping on stored URLs. Assets, webhook callbacks, and CMS content often store an absolute URL written for another environment. Swap scheme+host while leaving path/query untouched:
$prod = UrlMutator::build($stored, [
'scheme' => 'https',
'host' => config('app.asset_host'),
]);
// -> https://cdn.example.com/media/hero.jpg?w=1200
Unspecified keys are inherited from the base URL, which is exactly the behaviour you want here.
Pattern 3 — relative path resolution with HTTP_URL_JOIN_PATH. Useful when a redirect target or an API Location header is relative and you need to resolve it against the request base:
$absolute = UrlMutator::build(
'https://shop.test/admin/orders/42',
['path' => '../customers/7'],
HTTP_URL_REPLACE | HTTP_URL_JOIN_PATH
);
// https://shop.test/admin/customers/7
Test ../ and ./ handling explicitly — path-join semantics are the least-obvious part of the API and the easiest to get wrong.
Pattern 4 — sanitised URLs for logs, analytics, and error reports. Strip credentials, query, and fragment before a URL hits a log line or an exception trace:
$safe = UrlMutator::build($url, [], HTTP_URL_STRIP_AUTH | HTTP_URL_STRIP_QUERY | HTTP_URL_STRIP_FRAG);
Note this is the one pattern where you pass an empty $parts array — the flags alone do the work.
Pattern 5 — Laravel pagination and redirects. Wrap it in a small FilterLinks/UrlState class that takes the current request and returns ['self' => ..., 'next' => ...]. In a controller, feed it $request->fullUrl(); paginators already produce URLs, so this pairs best with custom filter UIs rather than paginate()->url().
Composition tip. Chain calls instead of building one giant parts array — UrlMutator::build($u, ['host' => $h]) then ->build($u2, ['query' => $q], JOIN_QUERY). It reads better and isolates which step corrupted the URL when something breaks.
Migration pattern. For anything new, prefer the framework: Illuminate\Support\Uri (recent Laravel), Arr::query(), http_build_query(), or league/uri for strict RFC 3986 parsing. Keep this package only inside your wrapper while you port call sites, then delete the dependency.
Default flag is HTTP_URL_REPLACE, not join. If you pass ['query' => ['page' => 3]] with default flags, the entire query string is replaced and sort=price disappears. HTTP_URL_JOIN_QUERY is opt-in and must be combined with HTTP_URL_REPLACE.
Duplicate-key precedence is easy to misread. When JOIN_QUERY merges and both sides contain the same key, which value wins depends on the internal parse_str/merge order. Don't assume "my new value wins" — write a one-line test (page in both base and parts) and pin the behaviour for your resolved version. It can't be fixed by config later.
parse_str will corrupt your query string on round-trip. This is the biggest real-world landmine:
?filter.brand=acme) become underscores (filter_brand).ids[]=1 → ids%5B0%5D=1).+ in a value decodes to a space and re-encodes as %20.?tag=a&tag=b) collapse to one.Any of these silently changes what the upstream server sees. If your URLs carry dotted keys, bracket arrays, or duplicate params, do not route them through JOIN_QUERY — replace the query wholesale with a pre-built string, or use a real URI library.
Never rebuild a signed URL. Laravel's signedRoute/temporarySignedRoute sign the exact final URL string. Re-encoding via parse_str + http_build_query can flip +/%20, reorder params, or re-index arrays — the signature then fails with a 403 and no obvious cause. Same rule for OAuth state, S3 presigned URLs, and webhook HMACs: sign last, mutate never.
Guard against false. http_build_url() runs parse_url() internally; malformed input (unescaped spaces, stray [ in the query, // with no host) makes it return false. false cast into a redirect()->to() produces a redirect to "". Always funnel through a helper that falls back to the original string, as above.
Default ports and fragments survive. A fragment is preserved unless you HTTP_URL_STRIP_FRAG. Port handling around 80/443 is worth a quick var_dump on your PHP version — verify rather than assume the port is normalised away.
$new_url is a by-reference out-param. Passing it gives you the assembled URL while the return value tells you success/failure. Most code ignores it; if you use it, initialise $new = null first.
Not mockable, so keep it at the edge. There's no facade and no container binding; Http::fake()-style testing doesn't apply. That's another reason call sites should go through UrlMutator::build(), which you can swap or mock in a container. Tests assert on plain strings.
Autoload files means it loads on every request unconditionally. Harmless in cost, but worth knowing when debugging "why does this function exist" in a preloaded/opcache setup, or when auditing a dependency tree for an archived package.
Function collision is guarded, not impossible. The package defines the function only if it doesn't exist, which is what lets it coexist with pecl_http — but it also means the extension silently wins in environments where it's installed. If behaviour differs between local and production, check function_exists and extension_loaded('http') first.
No validation, no normalisation, no encoding of path segments. It concatenates what you give it. .. in a non-join path, unicode/IDN hosts, and unencoded characters pass straight through. Treat it as a string assembler, not a URL sanitiser — validate with filter_var($url, FILTER_VALIDATE_URL) or Laravel's url rule if the input is user-supplied.
Debugging order of operations: parse_url($url) first to confirm the base is even parseable, then parse_str(parse_url($url, PHP_URL_QUERY), $q) to see what the merge will actually be working with, then the http_build_url() call. Nearly every "the function mangled my URL" report is one of the first two steps.
Exit plan. It's archived and explicitly deprecated in favour of PHP 8.5's Uri\Rfc3986\Uri. Set a reminder in the ticket that introduced it; when Laravel/PHP minimums allow, delete the wrapper internals and keep the wrapper's public signature.
How can I help you explore Laravel packages today?