league/uri-query-parser
A standalone PHP library for parsing and building RFC 3986 query strings. It preserves key order and duplicate parameters, handles configurable separators and encoding, and provides the query-string parsing foundation used by league/uri.
Install with Composer:
composer require league/uri-query-parser
Two classes are all you need. Look at these first:
League\Uri\QueryParser\Parser — turns a raw query string into an ordered list of [name, value] pairs.League\Uri\QueryParser\Builder — turns that pair list back into a query string.The core idea is that a query string is not an associative array. It is an ordered sequence of pairs where keys may repeat. This package models that faithfully, unlike parse_str(), which flattens duplicates, reorders nothing but loses information, and mangles keys with dots/spaces.
Your first use case is the one that breaks parse_str():
use League\Uri\QueryParser\Parser;
use League\Uri\QueryParser\Builder;
$parser = new Parser();
$builder = new Builder();
$pairs = $parser->parse('filter=active&filter=archived&sort=name');
// [ ['filter', 'active'], ['filter', 'archived'], ['sort', 'name'] ]
echo $builder->build($pairs);
// filter=active&filter=archived&sort=name
That round-trip is the whole value proposition: order and duplicate keys survive.
Parse → mutate → rebuild. Treat the pair list as an ordered log. To add, filter, or reorder parameters, manipulate the array of pairs directly, then hand it back to the builder. This is the most common pattern in middleware, signed-URL handlers, and API clients.
$pairs = $parser->parse($request->getUri()->getQuery());
// Remove every occurrence of a key
$pairs = array_values(array_filter($pairs, fn ($p) => $p[0] !== 'signature'));
// Append a new parameter
$pairs[] = ['page', '2'];
$query = $builder->build($pairs);
Reading single values. Build a small helper that returns the first value for a key, or a list of all values when duplicates matter. Do not convert the pair list into an associative array at the boundary — that reintroduces the bug you chose this package to avoid. Provide first($pairs, 'key') and all($pairs, 'key') and call them explicitly.
Signing and canonicalization. Because the package preserves order, you can canonicalize deterministically: sort pairs by name (stable, preserving relative order of duplicates), drop known-volatile keys, rebuild, then hash. Keep the canonicalization in one place so verification and generation stay in sync.
Query merging. When combining queries from two URIs, concatenate the pair lists and let duplicate keys remain, rather than array_merge-ing associative arrays. Downstream consumers that read "all values for key x" will see both.
Integration with PSR-7. The pair list works well as an intermediate representation: parse($uri->getQuery()) on the way in, set the rebuilt string via $uri->withQuery(...) on the way out. Because the builder handles encoding, you avoid the double-encoding that happens when you manually urlencode() then pass through a URI library.
Validation layer. The parser performs syntax work, not semantic work. Wrap it in a value object (e.g. QueryParameters) that applies your app's rules — allow-lists of keys, integer coercion, rejection of nested bracket notation — and expose typed getters. This keeps the third-party call surface to two methods and makes a future migration mechanical.
It is archived (last release 2019). No bug fixes, no PHP 8.2+ deprecation patches, no security response. Pin it exactly, or better, plan the exit. The functionality lives on in league/uri v6+:
use League\Uri\QueryString;
QueryString::parse('a=1&a=2'); // pairs
QueryString::build([['a', '1']]); // string
QueryString::extract('a=1&a=2'); // assoc, if you want the old parse_str behaviour
The API is close enough that a migration is mostly a use statement change plus swapping the parser/builder instances for static calls. Do it before you accumulate call sites.
Do not pipe the pairs through http_build_query(). It will collapse duplicates and re-encode with application/x-www-form-urlencoded rules (+ for space) instead of RFC 3986 (%20). Always rebuild with the package's Builder.
Space encoding differs between query strings and form bodies. In a URI query, a space is %20; in application/x-www-form-urlencoded bodies it is +. If you parse an HTML form POST body with this package you may mis-handle +. Restrict it to URIs, and let the framework handle request bodies.
Encoding round-trips can surprise you. Percent-encoding normalization (%7e vs ~), uppercase vs lowercase hex, and already-encoded input all affect the output string byte-for-byte. If you sign or cache-key on the rebuilt query, normalize consistently (e.g. through League\Uri itself) before comparing, or your signature check will fail intermittently.
No max_input_vars truncation. Because it does not go through parse_str(), it is not subject to the max_input_vars / max_input_nesting_level ini limits — a genuine advantage for large machine-generated queries. But it also does no size limiting, so apply your own cap on query length if the input is untrusted.
Bracket notation is not a data structure. a[]=1&a[]=2 is, to this parser, just two pairs whose names contain brackets. It will not produce nested arrays. Decide explicitly whether brackets are significant in your API; if they are, do the interpretation yourself after parsing.
Empty key vs empty value is distinguishable. foo, foo=, and =bar are three different things here and all three are preserved. Good for protocol correctness, but it means you must validate; downstream code that assumes $value !== '' will break on foo.
No extension points worth using. There is no pluggable encoder or custom pair class. If you need non-standard behaviour (semicolon separators, case-insensitive key folding), wrap the package rather than subclass it. Keep the wrapper thin so the eventual move to league/uri touches one file.
Composer hygiene. With zero dependents and no activity, expect no upstream support. Add it to your own composer.json with an explicit constraint, and record the migration target in a TODO — the cheapest time to remove it is now, while it is only wrapped in one class.
How can I help you explore Laravel packages today?