====== PHP RFC: Query Parameter Manipulation Support ======
* Version: 1.0
* Date: 2026-02-13
* Author: Máté Kocsis, kocsismate@php.net
* Status: Under Discussion
* Implementation: https://github.com/php/php-src/pull/22551
* First published at: https://wiki.php.net/rfc/uri_followup
===== Introduction =====
Despite being the web's most prominent server-side language for over 30 years, PHP still lacks a proper built-in API for working with query parameters. The existing tools — the ''$_GET'' superglobal, ''parse_str()'', and ''http_build_query()'' — cover only the basics, and are tied exclusively to [[https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1|RFC 1866's application/x-www-form-urlencoded]]'s semantics, which is the format used when populating ''$_GET'' from the current request's query string. Support for RFC 3986 query strings is entirely absent, and there is no way to parse or compose query parameters outside of the current request context without reaching for userland libraries.
This gap becomes even more apparent in modern PHP applications, which increasingly rely on application server models (e.g., FrankenPHP or Swoole), where a single PHP process handles multiple requests over its lifetime. In such environments, traditional superglobals like ''$_GET'' represent mutable global state that must be carefully reset between requests — a footgun that is easy to overlook and hard to debug. Beyond the app-server concern, superglobals also make it impossible to represent query parameters in an immutable, value-object style consistent with modern PSR-7-based request abstractions. This RFC introduces Uri\QueryParams as a first step toward giving PHP a proper, immutable representation of query string data that is independent of the request lifecycle.
===== Proposal =====
The following classes and methods are proposed for addition:
namespace Uri {
final readonly class QueryParamParsingOptions
{
public int $maxQueryStringLength;
public int $maxParamCount;
public function __construct(
int $parsingMaxQueryStringLength = 10000,
int $parsingMaxParamCount = 1000,
) {}
public function __debugInfo(): array {}
public function __serialize(): array {}
public function __unserialize(array $data): void {}
}
final readonly class QueryParamBuildingOptions
{
public string $trueValue;
public string $falseValue;
public bool $useNullAsEmptyString;
public function __construct(
int $maxNestingLevel = 64,
string $trueValue = "0",
string $falseValue = "0",
bool $useNullAsEmptyString = false
) {}
public function __debugInfo(): array {}
public function __serialize(): array {}
public function __unserialize(array $data): void {}
}
}
namespace Uri {
final readonly class QueryParams implements \IteratorAggregate, \Countable
{
private \Uri\QueryParamBuildingOptions $buildingOptions;
public static function parseRfc1866(
string $queryString,
?\Uri\QueryParamParsingOptions $parsingOptions = null,
?\Uri\QueryParamBuildingOptions $buildingOptions = null
): \Uri\QueryParams {}
public static function parseRfc3986(
string $queryString,
?\Uri\QueryParamParsingOptions $parsingOptions = null,
?\Uri\QueryParamBuildingOptions $buildingOptions = null
): \Uri\QueryParams {}
public static function parseWhatWg(
string $queryString,
?\Uri\QueryParamParsingOptions $parsingOptions = null,
?\Uri\QueryParamBuildingOptions $buildingOptions = null
): \Uri\QueryParams {}
public static function fromArray(
array $queryParams,
?\Uri\QueryParamBuildingOptions $buildingOptions = null
): \Uri\QueryParams {}
public function __construct(?\Uri\QueryParamBuildingOptions $buildingOptions = null) {}
public function has(string $name): bool {}
public function hasValue(string $name, mixed $value): bool {}
public function getFirst(string $name): ?string {}
public function getLast(string $name): ?string {}
public function getAll(string $name): array {}
public function list(): array {}
public function getIterator(): \Traversable {}
public function count(): int {}
public function append(string $name, mixed $value): static {}
public function set(string $name, mixed $value): static {}
public function delete(string $name): static {}
public function deleteValue(string $name, ?string $value): static {}
public function sort(): static {}
public function withArray(array $queryParams): static {}
public function toArray(int $maxNestingLevel = 64): array {}
public function toRfc1866String(): string {}
public function toRfc3986String(): string {}
public function toWhatWgString(): string {}
public function __serialize(): array {}
public function __unserialize(array $data): void {}
public function __debugInfo(): array {}
}
namespace Uri\Rfc3986 {
final readonly class Uri
{
...
public function getQueryParams(): ?\Uri\QueryParams {}
...
}
}
namespace Uri\WhatWg {
final readonly class Url
{
...
public function getQueryParams(): ?\Uri\QueryParams {}
...
}
}
=== Construction ===
''QueryParams'' supports the following methods for instantiation:
* **''parseRfc1866()''**: It parses a query string into a list of query parameters according to the processing and percent-decoding rules of the ''application/x-www-form-urlencoded'' media type, as defined by [[https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1|RFC 1866]]. This specification regards query parameters as a list of name-value pairs, where the two parts are separated by a "='" character, and the individual parameters are separated from each other by a "&" character (e.g. ''name1=value1&name2=value2'').
* **''parseRfc3986()''**: It parses a query string into a list of query parameters according to the percent-decoding rules of RFC 3986, with the caveat that this specification in fact does not specify exactly how query parameters are composed. That's why the implementation defines query parameters based on the definition of [[https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1|RFC 1866]].
* **''parseWhatWg()''**: It parses a query string into a list of query parameters according to the percent-decoding rules of the ''application/x-www-form-urlencoded'' media type, as defined by the WHATWG URL specification.
* **''fromArray()''**: It takes an array of query parameters and directly composes the query parameter list object based on it. Besides scalar values, it can also accept complex types such as arrays according to the rules discussed in the [[https://wiki.php.net/rfc/uri_followup#supported_types|"Supported types" section]].
* **''%%__construct()%%''**: It accepts an empty parameter list, and results in an empty query parameter list. This method allows building query parameters by starting from scratch.
$params = Uri\QueryParams::parseRfc1866("a=foo&b=bar"); // Successful instantiation
$params = Uri\QueryParams::parseRfc3986("a=foo&b=bar"); // Successful instantiation
$params = Uri\QueryParams::parseWhatWg("a=foo&b=bar"); // Successful instantiation
$params = Uri\QueryParams::fromArray(
[
"a" => "foo",
"b" => "bar",
]
); // Successful instantiation - same result as above
$params = new Uri\QueryParams(); // Successful instantiation - creates an empty query parameter list
It's important to note that ''QueryParams'' doesn't validate query parameters appropriately during construction. This behavior is by design, because the idea of WHATWG URL's ''URLSearchParams'' class is that it's tolerant for reading, and ''QueryParams'' follow the same principle. Validation happens anyway when the recomposed query parameters are attempted to be written to a URI (via ''Uri\Rfc3986\Uri::withQuery()'' and ''Uri\WhatWg\Url::withQuery()''). Although, as we'll see, invalid characters are [[https://wiki.php.net/rfc/url_parsing_api#percent-encoding_decoding|automatically percent-encoded]] during query parameter recomposition, so the ''withQuery()'' calls won't fail in practice either.
$params = Uri\QueryParams::parseRfc3986("#foo=bar"); // Parses an invalid parameter name "#foo"
$uri = new Uri\Rfc3986\Uri("https://example.com/");
$uri = $uri->withQuery($params->toRfc3986String()); // Success: the query is automatically percent-encoded to "%23foo=bar"
The same example for WHATWG URL:
$params = Uri\QueryParams::parseWhatWg("#foo=bar"); // Parses an invalid parameter name "#foo"
$url = new Uri\WhatWg\Url("https://example.com/");
$url = $url->withQuery($params->toWhatWgString()); // Success: the query is automatically percent-encoded to "%23foo=bar"
Neither the ''parse*()'', nor the ''fromArray()'' factory methods can fail in practice: they only have memory-related failure cases which are handled by the PHP engine as a fatal error.
According to the WHATWG URL algorithm, the leading "?" character is removed during parsing. As opposed to this behavior, the leading "?" becomes part of the first query parameter name for RFC 3986 query params.
$params = Uri\QueryParams::parseRfc3986("?abc=foo");
// $params internally contains the ["?abc" => "foo"] key-value pair
$params = Uri\QueryParams::parseWhatWg("?abc=foo");
// $params internally contains the ["abc" => "foo"] key-value pair
All ''parse*()'' variants percent-decode the input automatically when constructing the ''QueryParams'' instance. This is necessary so that the classes can work with the unencoded query parameters.
$params = Uri\QueryParams::parseRfc1866("foo%5B%5D=b%61r"); // Percent-encoded form of "foo[]=bar"
// $params internally contains the ["foo[]" => "bar"] key-value pair
$params = Uri\QueryParams::parseRfc3986("foo%5B%5D=b%61r"); // Percent-encoded form of "foo[]=bar"
// $params internally contains the ["foo[]" => "bar"] key-value pair
$params = Uri\QueryParams::parseWhatWg("foo%5B%5D=b%61r"); // Percent-encoded form of "foo[]=bar"
// $params internally contains the ["foo[]" => "bar"] key-value pair
=== Parameter Retrieval ===
The ''has()'' and ''hasValue()'' methods can be used to find out if a parameter exists:
$params = Uri\QueryParams::parseRfc3986("foo=bar&baz=qux&baz=quux");
echo $params->has("baz"); // true
echo $params->has("non-existent"); // false
echo $params->hasValue("foo", "bar"); // true
echo $params->hasValue("foo", "baz"); // false
The ''has()'' method returns ''true'' if there is at least one parameter in the parameter list with the given name, ''false'' otherwise. On the other hand, ''hasValue()'' returns ''true'' if the given name and value both matches at least one parameter, otherwise it returns ''false''.
The number of query parameters can be retrieved by calling the ''count()'' method:
$params = Uri\QueryParams::parseRfc3986("foo=bar&baz=qux&baz=quux");
echo $params->count(); // 3
There are also a number of methods that can return a query parameter or an array of query parameters:
* ''getFirst()'': Retrieves the first parameter with the given name. This actually implements the [[https://url.spec.whatwg.org/#dom-urlsearchparams-get|get() method]] from the WHATWG URL specification.
* ''getLast()'': Retrieves the last parameter with the given name. It's a custom, PHP-specific method which doesn't have a WHATWG URL equivalent.
* ''getAll()'': Retrieves all parameters with the given name. This actually implements the [[https://url.spec.whatwg.org/#dom-urlsearchparams-getall|getAll() method]] from the WHATWG URL specification.
* ''list()'': Retrieves all query parameters. It's also a custom, PHP-specific method which doesn't have a WHATWG URL equivalent.
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz&qux=quux");
echo $params->getFirst("foo"); // bar
echo $params->getFirst("non-existent"); // null
echo $params->getLast("foo"); // baz
echo $params->getLast("non-existent"); // null
echo $params->getAll("foo"); // ["bar", "baz"]
echo $params->getAll("non-existent"); // []
echo $params->list(); // [["foo", "bar"], ["foo", "baz"], ["qux", "quux"]]
All these methods return the natively stored values without applying any transformations. That is, percent-encoding or decoding neither happens in the input, nor in the output.
$params = Uri\QueryParams::parseRfc3986("foo%5B%5D=b%61r"); // Internally stored as "foo[]=bar"
echo $params->getFirst("foo%5B%5D"); // null
echo $params->getFirst("foo[]"); // bar
echo $params->getLast("foo%5B%5D"); // null
echo $params->getLast("foo[]"); // bar
echo $params->getAll("foo%5B%5D"); // []
echo $params->getAll("foo[]"); // ["bar"]
echo $params->list(); // [["foo[]", "bar"]]
=== Percent-Encoding and Decoding ===
''QueryParams'' only performs percent-encoding when query parameters are recomposed to a query string (via ''to*String()'' methods), and they only perform percent-decoding when a query string is parsed into a query parameter list (via ''parse*()'' methods). The rest of the functionalities don't use percent-encoding or decoding.
''QueryParams'' supports percent-encoding and decoding according to three specifications:
* [[https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1|RFC 1866]] which specifies the percent-encoding and decoding rules of the ''application/x-www-form-urlencoded'' media type
* [[https://datatracker.ietf.org/doc/html/rfc3986|RFC 3986]] which defines the generic query string syntax.
* [[https://url.spec.whatwg.org/#urlencoded-parsing|URLSearchParams]] class specified by WHATG URL, which yet again builds upon the ''application/x-www-form-urlencoded'' media type for historic reasons, albeit slightly differently than how RFC 1866 specifies it.
The current section is going to have an overview about the percent-encoding and decoding details, as well as the differences between the aforementioned specifications.
According to RFC 1866, space characters are replaced by the plus character (''+'') during percent-encoding, and any characters that fall outside of the unreserved character set are percent-encoded. Percent-decoding inverts these operations.
This behavior clearly deviates from the percent-encoding rules of the query component of RFC 3986 which allows quite a few reserved characters to be present in the query component without percent-encoding (a few examples: ":", "@", "?", "/"), not to mention the difference in how the space character is handled.
Regarding WHATWG URL's ''URLSearchParams'' class, as usually, a [[https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-set|dedicated percent-encoding set]] is defined:
The application/x-www-form-urlencoded percent-encode set contains all code points, except the ASCII alphanumeric, U+002A (*), U+002D (-), U+002E (.), and U+005F (_).WHATWG URL also defines a [[https://url.spec.whatwg.org/#urlencoded-serializing|dedicated algorithm]] for "serialization" (in this context, serialization means recomposition - converting the list to a query string): the space code point is percent-encoded as the plus code point (''+''), and the rest of the code points in the percent-encoding set are encoded how WHATWG URL normally does so. This behavior deviates from the percent-encoding rules of the query component of WHATWG URL, as the [[https://url.spec.whatwg.org/#query-percent-encode-set|query percent-encode set]] contains much less characters, and the space code point is handled differently again. It's also important to compare how the percent encoding rules of RFC 1866's as well as WHATWG URL's application/x-www-form-urlencoded media type differ: they handle the asterisk (''*'') and the tilde (''~'') symbols differently: RFC 1866 percent-encodes the first one, but WHATWG URL doesn't, however RFC 1866 doesn't percent-encode the latter one, but WHATWG URL does so. === Recomposition === In order to be consistent with the design of ''Uri\Rfc3986\Uri'' and the ''Uri\WhatWg\Url'' classes, ''QueryParams'' doesn't have a ''%%__toString()%%'' magic method, especially because it offers multiple possibilities for recomposition. Instead, it contains custom ''to*String()'' methods to recompose the query string from the parsed query parameters according to the supported formats.
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz");
echo $params->toRfc1866String(); // foo=bar&foo=baz
echo $params->toRfc3986String(); // foo=bar&foo=baz
echo $params->toWhatWgString(); // foo=bar&foo=baz
All ''to*String()'' methods (''Uri\QueryParams::toRfc1866String()'', ''Uri\QueryParams::toRfc3986String()'', ''Uri\QueryParams::toWhatWgString()'') automatically percent-encode their output according to the rules outlined in the [[https://wiki.php.net/rfc/uri_followup#percent-encoding_and_decoding|previous section]], otherwise it would be possible that an invalid output is returned.
$params = Uri\QueryParams::fromArray([["foo[]" => "bar baz"]]);
echo $params->toRfc1866String(); // foo%5B%5D=bar+baz
echo $params->toRfc3986String(); // foo%5B%5D=bar%20baz
echo $params->toWhatWgString(); // foo%5B%5D=bar+baz
Unlike ''Uri\Rfc3986\Uri'', the ''Uri\QueryParams'' class doesn't have a ''toRawString()'' method because it could be misleading what it exactly does: ''toRawString()'' cannot really provide a "raw" representation of the query string, since automatic percent-encoding must happen any way to make the produced query string valid.
=== Relation to the query component ===
After learning about the details of the percent-encoding and decoding behavior of ''QueryParams'', it should be clarified how the new class can interoperate with the query component of the existing ''Uri\Rfc3986\Uri'' and ''Uri\WhatWg\Url'' classes.
''QueryParams'' has full compatibility with ''Uri\Rfc3986\Uri'' via the ''fromRfc3986()'' and ''toRfc3986String()'' methods:
$uri = new Uri\Rfc3986\Uri("https://example.com?foo=a%20b"); // "%20b" is the percent-encoded form of "a b"
$params = Uri\QueryParams::fromRfc3986($uri->getQuery());
$uri = $uri->withQuery($params->toRfc3986String());
echo $uri->getQuery(); // foo=a%20b
As it can be seen in the example above, the behavior is round-trippable: parsing a query string to a ''QueryParams'' instance and then modifying the original query string to the parsed one will result in the original query string.
''Uri\WhatWg\UrlQueryParams'' and ''Uri\WhatWg\Url'' are not completely compatible due to the different percent-encoding and decoding algorithm they use, and this incompatibility is even encoded in the WHATWG URL specification itself, so it's not possible to work around on PHP's side:
$url = new Uri\WhatWg\Url("https://example.com?foo=a%20b"); // "%20b" is the percent-encoded form of "a b"
$params = $url->getQueryParams();
$url = $url->withQuery($params->toWhatWgString());
echo $url->getQuery(); // foo=a+b
=== Modification ===
The ''append()'' method can be used to append a parameter to the end of the list. As normally, the same query parameter can be added multiple times:
$params = Uri\QueryParams::parseRfc3986("foo=bar");
$params = $params->append("baz", "qux");
$params = $params->append("baz", "qaz"); // Appends "baz" twice
echo $params->toRfc3986String(); // foo=bar&baz=qux&baz=qaz
Updating a parameter is possible via the ''set()'' method:
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz");
$params = $params->set("foo", "baz"); // Overwrites the first item "foo", and removes the second one
$params = $params->set("qux", "qaz"); // Appends a new item "qux"
echo $params->toRfc3986String(); // foo=bar&baz=qux&baz=qaz
Actually, the ''set()'' method has a hybrid behavior: if a parameter is not present in the list, then it adds it just like ''append()'' does. Otherwise, it overwrites the first item, and removes the rest of the occurrences.
Neither ''append()'', nor ''set()'' do any percent-encoding or decoding of their arguments.
$params = new Uri\QueryParams::parseRfc3986();
$params = $params->append("foo%5B%5D", "ab%63"); // Percent-encoded form of "foo[]=abc"
$params = $params->set("bar%5B%5D", "de%66"); // Percent-encoded form of "bar[]=def"
echo $params->getFirst("foo%5B%5D"); // ab%63
echo $params->getFirst("bar%5B%5D"); // de%66
Removing parameters is possible via either the ''delete()'' or the ''deleteValue()'' method: the former one removes all occurrences of the given parameter name, while the latter one removes all occurrences of a parameter if the given name and value both matches it, as demonstrated below:
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz&foo=qux");
$params = $params->deleteValue("foo", "baz"); // Deletes the "foo=baz" parameter
$params = $params->delete("foo"); // Deletes the rest of the occurrences: "foo=bar" and "foo=qux"
$params = $params->delete("non-existent"); // The parameter is not present: nothing happens
Finally, ''sort()'' sorts the query parameter list alphabetically:
$params = Uri\QueryParams::parseRfc3986("foo=bar&baz=qux&baz=quux");
$params = $params->sort();
echo $params->toRfc3986String(); // baz=qux&baz=quux&foo=bar
Parameters with the same name (''baz'' in the above example) keep their original order rather than being ordered by value. This is necessary because parameters with the same name usually refer to lists, and modifying the order of list items would definitely change the original meaning.
The main purpose of ''sort()'' is to provide a consistent order of the key-value pairs (e.g. to increase cache hits), therefore more advanced features such as sorting in descending order, or user-provided comparison methods are not proposed.
=== Supported types ===
What's also important to clarify is how non-string values are mapped to query parameters which inherently have string type? PHP's [[http_build_query()|https://www.php.net/manual/en/function.http-build-query.php]] and functions can basically map any type to query parameters, however, this is purely a PHP-specific behavior, and as such, type mapping rules are out of scope of both RFC 3986 and WHATWG URL: RFC 3986 completely omits any information how query parameters should be built, while WHATWG URL's ''URLSearchParams'' only accepts and returns string data.
The position of this RFC is that it's important to follow the road that ''http_build_query()'' has already paved because of better developer experience and better interoperability with the existing ecosystem. That's why the following type mapping behavior is proposed **when a query parameter is added/updated**:
* **bool:** Becomes string "0" (in case of ''false'') or string "1" (in case of ''true'')
* **int:** Becomes a numeric string (123 -> "123")
* **float:** Becomes a decimal string (3.14 -> "3.14")
* **resource:** Invalid mapping, an exception is thrown
* **array:** Invalid mapping, an exception is thrown
* **enum:**
* **backed enums** are converted to their backing value
* **enums without backing type** are invalid, and an exception is thrown
* **object:** invalid mapping, an exception is thrown
A few examples demonstrating how ''QueryParams'' handles non-string scalar types:
$params = new Uri\QueryParams();
$params = $params->append("null", null);
$params = $params->append("bool", true);
$params = $params->append("int", 123);
$params = $params->append("float", 3.14);
var_dump($params->getFirst("null")); // NULL
var_dump($params->getFirst("bool")); // string(1) "1"
var_dump($params->getFirst("int")); // string(3) "123"
var_dump($params->getFirst("float")); // string(4) "3.14"
echo $params->toRfc3986String(); // null&bool=1&int=123&float=3.14
...
For reference, ''http_build_query()'' omits parameters with ''null'' values.
=== Array API ===
The original proposal included a dedicated set of methods for working with PHP-style bracket-syntax arrays directly on the ''QueryParams'' object (''getArray()'', ''appendArray()'', ''setArray()''). After implementation experience and discussion, this approach was abandoned in favor of a significantly simpler design.
The core problem with the original approach was that it required ''QueryParams'' to reimplement PHP's native array operations internally. For example, ''QueryParams::getArray("foo[bar][baz]")'' would have needed to parse the array dimensions at runtime and navigate a nested structure, and ''appendArray()'' / ''setArray()'' would have required equivalent complexity for writes. Beyond the implementation burden, the resulting API surface was too large, the method names were confusable (e.g. ''getArray()''
vs. ''getAll()''), and the behavior around bracket parsing in method arguments was difficult to define precisely and consistently.
The simplified Array API consists of three methods:
* **''QueryParams::fromArray()'':** Creates a new ''QueryParams'' instance from a PHP array, using the bracket-syntax encoding rules described in the "Supported types" section to convert nested arrays into flat key-value pairs.
* **''QueryParams::toArray()'':** Converts the ''QueryParams'' instance back into a PHP array, inverting the encoding done by ''fromArray()''. The bracket-syntax in stored key names is interpreted to reconstruct nested arrays, following similar rules as ''$_GET''.
* **''QueryParams::withArray()'':** Similar to ''fromArray()'', but clones and then updates the current instance rather than creating a completely new one.
The intended workflow for array-based modifications is:
$params = Uri\QueryParams::parseRfc3986("page=1&debug=false");
// Convert to a native PHP array, modify using native PHP operations
// then convert back to a QueryParams instance.
$array = $params->toArray();
$array["page"] = 2;
unset($array["debug"]);
$params = $params->withArray($array);
// Or
$params = Uri\QueryParams::fromArray($array);
This design deliberately delegates array manipulation to PHP's native array functions rather than reimplementing them inside ''QueryParams''. The result is a smaller API surface, predictable behavior, and better composability with existing PHP code.
It is important to note that ''toArray()'' is the only method that interprets bracket-syntax in parameter names. All other methods (''getFirst()'', ''getAll()'', ''has()'', etc.) treat parameter names as opaque strings and do not parse brackets:
$params = Uri\QueryParams::parseRfc3986("foo%5B%5D=1&foo%5B%5D=2");
// Internally: [["foo[]", "1"], ["foo[]", "2"]]
echo $params->getAll("foo[]"); // ["1", "2"]
echo $params->getAll("foo"); // []
$array = $params->toArray();
// ["foo" => ["1", "2"]] — brackets interpreted here
The ''toArray()'' method interprets bracket-syntax in parameter names to reconstruct nested arrays. Several edge cases require explicit behavior decision, since ''$_GET'' either handles them silently in surprising ways or treats them inconsistently:
* **Mixed scalar and array keys:** If the same root key appears both as a plain parameter and with bracket notation (e.g. ''k=1&k[]=2''), ''$_GET'' silently discards the scalar value and retains only the array: the scalar ''k=1'' is stored first, then ''k[]=2'' converts ''k'' to an array, losing the original ''1''. The intended behavior of ''toArray()'' in this case is TBD — the options are to follow ''$_GET'''s silent last-write-wins behavior, or to throw an exception.
* **Unclosed bracket (e.g. ''k[=1''):** ''$_GET'' replaces the ''['' with ''_'' (the same mangling applied to spaces and dots), storing the value under ''k_'' as a plain (non-array) key. Since ''toArray()'' does not apply key mangling, the recommended behavior is to treat the entire key as a literal string and not attempt bracket interpretation.
* **Extra closing bracket (e.g. ''foo[]]''):** ''$_GET'' silently ignores the trailing '']'', treating the key as ''foo[]''. ''toArray()'' could do the same, or treat the key as a literal string without bracket interpretation.
* **Unbalanced opening bracket (e.g. ''%%foo[[a]%%''):** ''$_GET'' includes the extra ''['' as part of the subkey, producing ''foo["[a"]''. This behavior is clearly accidental and ''toArray()'' should instead treat such keys as literal strings.
How the different array types are handled:
* **empty array**: An empty array has zero items, therefore empty arrays are omitted from the query parameter list.
* **list**: An array is a list if its keys are consecutive integers starting from 0. Lists are converted to query parameters by repeating the given query parameter name appended by a bracket pair (''[]'') along with each value in the list mapped recursively according to the currently described type mapping rules. E.g. adding a query parameter with the ''array'' name and the ''[1, false, "foo"]'' value will result in an ''array[]=1&array[]=0&array[]=foo'' query string.
* **map**: An array is a map if it is not a list. Maps are converted to query parameters by appending the array keys contained within brackets (''[]'') to the given query parameter name along with each value in the map mapped recursively according to the currently described type mapping rules. E.g. adding a query parameter with an ''array'' name and the ''[1 => 1, 2 => true, 3 => "foo"]'' value will result in an ''array[1]=1&array[2]=1&array[3]=foo'' query string.
=== Class signature ===
The ''QueryParams'' class is ''final'' [[https://wiki.php.net/rfc/url_parsing_api#why_should_the_uri_rfc3986_uri_and_the_uri_whatwg_url_classes_be_final|for the same reason]] as all the other URI classes are final: mainly, in order to make followup changes possible without breaking backward compatibility.
Additionally, ''QueryParams'' is a ''readonly'' class, meaning that instances are immutable value objects: modification methods such as ''append()'', ''set()'', and ''delete()'' return a new ''QueryParams'' instance rather than mutating the existing one. This design is consistent with the immutable value-object semantics of ''Uri\Rfc3986\Uri'' and ''Uri\WhatWg\Url'', prevents different layers of an application from accidentally observing each other's modifications when sharing a reference to the same instance, and makes modification explicit at the call site.
The ''QueryParams'' class implements the ''IteratorAggregate'' and the ''Countable'' interfaces. Implementing ''IteratorAggregate'' seems straightforward at the first sight (query parameter names could be returned as iterator keys, while query parameter values could be returned as iterator values), unfortunately, it's more tricky than that due to query components that share the same name, e.g.: ''param=foo¶m=bar¶m=baz''. In this case, the same key (''param'') would be repeated 3 times - which would actually be very unwieldy behavior.
That's why the iterator returns each query parameter name and value as a list of pairs. Similarly to the ''get*()'' methods, the iterator returns the "raw" parameter names and values without percent-encoding. Let's see an example:
$params = Uri\QueryParams::parseRfc3986("param=foo¶m=bar¶m=baz");
foreach ($params as $key => $value) {
echo "$key => $value[0], $value[1]";
}
/*
0 => param, foo
1 => param, bar
2 => param, baz
*/
=== Cloning ===
Cloning of ''QueryParams'' is supported.
$params1 = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz");
$params2 = clone $params1;
$params2 = $params2->append("foo", "qux");
echo $params1->toRfc3986String(); // foo=bar&foo=baz
echo $params2->toRfc3986String(); // foo=bar&foo=baz&foo=qux
=== Serialization ===
''QueryParams'' supports serialization and deserialization via the the [[https://wiki.php.net/rfc/custom_object_serialization|new serialization API]]. The only implementation gotcha is that the serialized format is slightly unexpected: instead of recomposing the query parameters into a query string, the individual query parameter name and value pairs are serialized as an array of key-value pairs, similarly to the output of the ''list()'' method. During deserialization, the query parameter list is directly created from this array without any transformation (the same way how the ''fromArray()'' method works).
The main advantage of this choice is that the query parameters can be serialized and deserialized as-is, without any modifications (remember, the recomposition algorithms must percent-encode their output, and percent-decoding is needed during parsing, both of which processes modify the original data). Additionally, this behavior is more efficient than the former one, because it eliminates the overhead of parsing, including percent-encoding and decoding.
=== Debugging ===
''QueryParams'' contains a ''%%__debugInfo()%%'' method that returns all items in the query parameter list in order to make debugging easier. Effectively, this has a similar output to the ''list()'' method.
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz&foo=qux");
var_dump($params);
/*
object(Uri\QueryParams)#1 (1) {
["params"]=> array(3) {
[0]=>
array(2) {
[0]=>
string(3) "foo",
[1]=>
string(3) "bar"
}
[1]=>
array(2) {
[0]=>
string(3) "foo",
[1]=>
string(3) "baz"
}
[2]=>
array(2) {
[0]=>
string(3) "foo",
[1]=>
string(3) "qux"
}
}
}
*/
=== Relation to $_GET ===
The ''$_GET'' superglobal stores the query parameters of the current request, percent decoded according to RFC 1866. That's why the proposed ''QueryParams'' class is its direct alternative when it comes to processing the current request. The current RFC lays the foundations for populating ''$_GET'' according to the other relevant specifications besides RFC 1866 (RFC 3986 and WHATWG URL), for example, by adding support for a new php.ini configuration option.
The position of this RFC though is that ''$_GET'' (and superglobals in general) shouldn't be changed in any way, but rather gradually phased out on the long term by offering better alternatives. In this case, ''QueryParams'' can be used directly instead of ''$_GET'', so migrating away from the superglobal usage should be straightforward in most cases.
Given the following piece of code:
$order = isset($_GET["order"]) ? (string) $_GET["order"] : null;
$limit = isset($_GET["limit"]) ? (int) $_GET["limit"] : null;
It becomes possible to migrate to the new API roughly the following way:
$queryParams = Uri\QueryParams::parseRfc3986($_SERVER["QUERY_STRING"]);
$order = $queryParams->getFirst("order");
$limit = $queryParams->has("limit") ? (int) $queryParams->getFirst("limit") : null;
There are a few behavioral incompatibilities between ''$_GET'' and ''Uri\QueryParams'' though that developers should be aware of when migrating:
* **Key mangling**: ''$_GET'' silently replaces dots (''.'') and spaces in parameter names with underscores, because PHP variable names cannot contain these characters. This means that ''my.key=1'' and ''my_key=1'' are indistinguishable in ''$_GET'', and data is silently corrupted. ''QueryParams'' preserves parameter names exactly as they appear in the query string.
* **Duplicate key loss**: ''$_GET'' only retains the last value of a parameter when the same name appears multiple times (e.g. ''a=1&a=2'' results in ''["a" => "2"]''). ''QueryParams'' preserves all occurrences and provides ''getFirst()'', ''getLast()'', and ''getAll()'' to retrieve them individually.
* **Silent truncation**: When the number of input variables exceeds the ''max_input_vars'' ini directive (default: 1000), ''$_GET'' silently discards the remaining parameters, potentially causing data loss that is difficult to detect. ''QueryParams'' also enforces a limit, but it throws an exception.
===== Backward Incompatible Changes =====
All the proposed features are backward compatible with existing code.
===== Proposed PHP Version(s) =====
Next minor version after PHP 8.6 (likely PHP 8.7).
===== RFC Impact =====
==== To the Ecosystem ====
What effect will the RFC have on IDEs, Language Servers (LSPs), Static Analyzers, Auto-Formatters, Linters and commonly used userland PHP libraries?
==== To Existing Extensions ====
Existing extensions that manipulate or access query parameters can continue to use the ''$_GET'' superglobal without any changes, however, they are encouraged to migrate to the newly added ''Uri\QueryParams'' class and its own API.
==== To SAPIs ====
None. SAPIs should continue to fill in the raw query string to the ''sapi_globals.request_info.query_string'' global variable.
===== Open Issues =====
None.
===== Future Scope =====
* Adding support for passing objects to ''QueryParams'': if a class implements a new interface (''QueryStringable''?), then it could become possible to serialize the object when appending/setting it to ''QueryParams''.
===== Voting Choices =====
The vote starts on 2026-xx-xx, ends on 2026-xx-xx, and requires 2/3 majority to be accepted.