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 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 theapplication/x-www-form-urlencodedmedia type, as defined by 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 RFC 1866.parseWhatWg(): It parses a query string into a list of query parameters according to the percent-decoding rules of theapplication/x-www-form-urlencodedmedia 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 "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 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 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 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:
- RFC 1866 which specifies the percent-encoding and decoding rules of the
application/x-www-form-urlencodedmedia type - RFC 3986 which defines the generic query string syntax.
- URLSearchParams class specified by WHATG URL, which yet again builds upon the
application/x-www-form-urlencodedmedia 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 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 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 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 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 roundtripable: 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->append("baz", "qux"); $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->set("foo", "baz"); // Overwrites the first item "foo", and removes the second one $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->append("foo%5B%5D", "ab%63"); // Percent-encoded form of "foo[]=abc" $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->deleteValue("foo", "baz"); // Deletes the "foo=baz" parameter $params->delete("foo"); // Deletes the rest of the occurrences: "foo=bar" and "foo=qux" $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->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 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 oftrue) - int: Becomes a numeric string (123 -> “123”)
- float: Becomes a decimal string (3.14 -> “3.14”)
- resource: Invalid mapping, an exception is thrown
- array:
- 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 thearrayname and the[1, false, “foo”]value will result in anarray[]=1&array[]=0&array[]=fooquery 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 anarrayname and the[1 => 1, 2 => true, 3 => “foo”]value will result in anarray[1]=1&array[2]=1&array[3]=fooquery string.
- 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
The above conversion rules work for both UriQueryParams and UrlQueryParams. However, Uri\Rfc3986\UriQueryParams can additionally properly handle null values: a null input is mapped to a query component so that only the parameter name is present — the “=” and the parameter value is omitted. On the other hand, Uri\WhatWg\UrlQueryParams converts null values to an empty string. For reference, http_build_query() omits parameters with null values.
A few examples demonstrating how UriQueryParams handles scalar types:
$params = new Uri\Rfc3986\UriQueryParams(); $params->append("null", null); $params->append("bool", true); $params->append("int", 123); $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
Let's also see a few examples about how UrlQueryParams handles scalar types. Note how null is represented differently than in case of UriQueryParams:
$params = new Uri\WhatWg\UrlQueryParams(); $params->append("null", null); $params->append("bool", true); $params->append("int", 123); $params->append("float", 3.14); var_dump($params->getFirst("null")); // string(0) "" 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
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 newQueryParamsinstance 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 theQueryParamsinstance back into a PHP array, inverting the encoding done byfromArray(). The bracket-syntax in stored key names is interpreted to reconstruct nested arrays, following similar rules as$_GET.QueryParams::withArray(): Similar tofromArray(), 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 = 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. foofor 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 could be a readonly class, but it's still TBD.
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:
<code php>
$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
*/
</code>
=== Cloning ===
Cloning of QueryParams is supported.
<code php>
$params1 = Uri\QueryParams::parseRfc3986(“foo=bar&foo=baz”);
$params2 = clone $params1;
$params2->append(“foo”, “qux”);
echo $params1->toRfc3986String(); foo=bar&foo=baz
echo $params2->toRfc3986String(); foo=bar&foo=baz&foo=qux
</code>
=== Serialization ===
QueryParams supports serialization and deserialization via the the 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.
<code php>
$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”
}
}
}
*/
</code>
=== 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:
<code php>
$order = isset($_GET[“order”]) ? (string) $_GET[“order”] : null;
$limit = isset($_GET[“limit”]) ? (int) $_GET[“limit”] : null;
</code>
It becomes possible to migrate to the new API roughly the following way:
<code php>
$queryParams = Uri\QueryParams::parseRfc3986($_SERVER[“QUERY_STRING”]);
$order = $queryParams->getFirst(“order”);
$limit = $queryParams->has(“limit”) ? (int) $queryParams->getFirst(“limit”) : null;
</code>
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 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.
===== Patches and Tests ===== https://github.com/kocsismate/php-src/pull/9 ===== Implementation ===== After the RFC is implemented, this section should contain: - the version(s) it was merged into - a link to the git commit(s) - a link to the PHP manual entry for the feature ===== References ===== * RFC 1866: https://datatracker.ietf.org/doc/html/rfc1866 * RFC 3986: https://datatracker.ietf.org/doc/html/rfc3986 * WHATWG URL specification: https://url.spec.whatwg.org/ * Followup Improvements for ext/uri RFC * Followup Improvements for ext/uri RFC discussion thread: https://externals.io/message/129486 * Query Parameter Manipulation Support RFC discussion thread: ===== Rejected Features ===== Keep this updated with features that were discussed on the mail lists. ===== Changelog ===== If there are major changes to the initial proposal, please include a short summary with a date or a link to the mailing list announcement here, as not everyone has access to the wikis' version history.