# RFC: Query Parameter Manipulation Support

- Status: Under discussion
- Authors: Máté Kocsis, kocsismate@php.net
- Date: 2026-02-13
- Version: 1.0
- Implementation: https://github.com/php/php-src/pull/22551
- Original: https://wiki.php.net/rfc/query_params
- Page: https://externals.io/rfc/query_params
- More for agents: search with https://externals.io/search.md?q={query} · RFCs at https://externals.io/rfc.md · JSON API at https://externals.io/api · documentation at https://externals.io/llms.txt

## Votes

### Add support for query parameter manipulation as outlined in the RFC? (closed)

Yes: 0 · No: 0 · Abstain: 0

## Discussions on the mailing list

- [\[RFC\] \[Discussion\] Followup Improvements for ext/uri](https://externals.io/message/129486.md) — started on 2025-12-01, 42 messages
- [\[RFC\] \[Discussion\] Query Parameter Manipulation Support](https://externals.io/message/130217.md) — started on 2026-03-01, 15 messages

---

## Introduction

Despite being the web's most prominent server-side language for over 30 years, PHP still lacks a dedicated object-oriented API for working with query parameters. The existing tools — the `$_GET` superglobal, `parse_str()`, and `http_build_query()` — cover common parsing and building tasks, but do not expose query parameters as an ordered list of name-value pairs. Their behavior follows [RFC 1866's application/x-www-form-urlencoded](https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1), while only `http_build_query()` supports RFC 3986 percent-encoding.

A request-independent, immutable query parameter list representation is particularly useful in modern PHP applications, including application server models such as FrankenPHP or Swoole, where a single PHP process handles multiple requests over its lifetime. Superglobals expose mutable state associated with the current request. A dedicated value object allows query parameters to be passed explicitly between application components and used independently of that global state, consistently with modern PSR-7-based request abstractions.

This RFC introduces `Uri\QueryParams` — a generic object-oriented abstraction for parsing, inspecting, modifying, and recomposing query parameters.

## Proposal

The following classes and methods are proposed for addition:

```php
namespace Uri {
    final readonly class QueryParamParsingOptions
    {
        public int $maxQueryStringLength;
        public int $maxParamCount;
 
        /**
         * @throws \ValueError
         */
        public function __construct(
            int $maxQueryStringLength = 10000,
            int $maxParamCount = 1000,
        ) {}
    }
 
    enum QueryParamNullPolicy
    {
        case SkipTuple;
        case KeyOnly;
        case EmptyString;
    }
 
    final readonly class QueryParamBuildingOptions
    {
        public string $trueValue;
        public string $falseValue;
        public \Uri\QueryParamNullPolicy $nullPolicy;
 
        public function __construct(
            string $trueValue = "1",
            string $falseValue = "0",
            \Uri\QueryParamNullPolicy $nullPolicy = \Uri\QueryParamNullPolicy::KeyOnly
        ) {}
    }
 
    class InvalidQueryParamsException extends \Uri\UriException
    {
    }
}
```

```php
namespace Uri {
    final readonly class QueryParams implements \IteratorAggregate, \Countable
    {
        private \Uri\QueryParamBuildingOptions $buildingOptions;
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         */  
        public static function parseRfc1866(
            string $queryString,
            ?\Uri\QueryParamParsingOptions $parsingOptions = null,
            ?\Uri\QueryParamBuildingOptions $buildingOptions = null
        ): \Uri\QueryParams {}
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         */
        public static function parseRfc3986(
            string $queryString,
            ?\Uri\QueryParamParsingOptions $parsingOptions = null,
            ?\Uri\QueryParamBuildingOptions $buildingOptions = null
        ): \Uri\QueryParams {}
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         */
        public static function parseWhatWg(
            string $queryString,
            ?\Uri\QueryParamParsingOptions $parsingOptions = null,
            ?\Uri\QueryParamBuildingOptions $buildingOptions = null
        ): \Uri\QueryParams {}
 
        /**
         * @throws \ValueError
         */
        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 {}
 
        /**
         * @throws \TypeError
         */
        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 {}
 
        /**
         * @throws \TypeError
         */
        #[\NoDiscard]
        public function append(string $name, mixed $value): static {}
 
        /**
         * @throws \TypeError
         */
        #[\NoDiscard]
        public function set(string $name, mixed $value): static {}
 
        #[\NoDiscard]
        public function delete(string $name): static {}
 
        /**
         * @throws \TypeError
         */
        #[\NoDiscard]
        public function deleteValue(string $name, mixed $value): static {}
 
        #[\NoDiscard]
        public function sort(): static {}
 
        /**
         * @throws \TypeError
         */
        public function withArray(array $queryParams): static {}
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         * @throws \ValueError
         */
        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 {}
    }
}
```

```php
namespace Uri\Rfc3986 {
    final readonly class Uri
    {
        // ...
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         */
        public function getQueryParams(
            ?\Uri\QueryParamParsingOptions $parsingOptions = null,
            ?\Uri\QueryParamBuildingOptions $buildingOptions = null
        ): ?\Uri\QueryParams {}
 
        public function withQueryParams(?\Uri\QueryParams $queryParams): static {}
 
        // ...
    }
}
 
namespace Uri\WhatWg {
    final readonly class Url
    {
        // ...
 
        /**
         * @throws \Uri\InvalidQueryParamsException
         */
        public function getQueryParams(
            ?\Uri\QueryParamParsingOptions $parsingOptions = null,
            ?\Uri\QueryParamBuildingOptions $buildingOptions = null
        ): ?\Uri\QueryParams {}
 
        public function withQueryParams(?\Uri\QueryParams $queryParams): static {}
 
        // ...
    }
}
```

#### Construction &amp; parsing

`QueryParams` supports the following methods for instantiation:

- **`Uri\QueryParams::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 [RFC 1866](https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1). 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 “&amp;” character (e.g. `name1=value1&name2=value2`).
- **`Uri\QueryParams::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](https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1).
- **`Uri\QueryParams::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.
- **`Uri\QueryParams::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 ["Array API" section](https://externals.io/rfc/query_params#array_api).
- **`Uri\QueryParams::__construct()`**: It creates an empty query parameter list and optionally accepts building options. This method allows building query parameters from scratch.
- **`Uri\Rfc3986\Uri::getQueryParams()`**: It creates a new `Uri\QueryParams` instance by parsing the query string component stored by `Uri\Rfc3986\Uri`.
- **`Uri\WhatWg\Url::getQueryParams()`**: It creates a new `Uri\QueryParams` instance by parsing the query string component stored by `Uri\WhatWg\Url`.

```php
$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 highlight that `QueryParams` does not require the input to be a syntactically valid URI query component 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` follows 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()`). However, as we'll see, invalid characters are [automatically percent-encoded](https://externals.io/rfc/url_parsing_api#percent-encoding_decoding) during query parameter recomposition, so the `withQuery()` calls won't fail in practice either.

```php
$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:

```php
$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"
```

All `parse*()` variants percent-decode the input automatically when constructing the `QueryParams` instance. This is necessary so that one can work with the unencoded query parameters.

```php
$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
```

`Uri\QueryParams::parseRfc1866()` and `Uri\QueryParams::parseRfc3986()` split the non-empty input query string at each literal `&` character before percent-decoding its fields. Each field produces a parameter, including empty fields before a leading `&`, after a trailing `&`, or between consecutive `&` characters.

The first literal `=` in each field separates the parameter name from its value. If there is no `=`, the value is `null`. If `=` is present but nothing follows it, the value is an empty string. Additional `=` characters belong to the value.

```php
$params = Uri\QueryParams::parseRfc3986("foo=bar&");
$list = $params->list();                                // [["foo", "bar"], ["", null]]
 
echo $params->toRfc3986String();                        // foo=bar&
```

```php
$params = Uri\QueryParams::parseRfc3986("foo=bar&=");
$list = $params->list();                                // [["foo", "bar"], ["", ""]]
 
echo $params->toRfc3986String();                        // foo=bar&=
```

An empty input query string (`“”`) produces an empty parameter list. By comparison, `&` represents two parameters with empty names and `null` values, while `=` represents one parameter with an empty name and an empty string value.

An empty list and a list containing only one parameter with an empty name and a `null` value both recompose to an empty string. Consequently, recomposing and reparsing a parameter list is not guaranteed to reproduce every possible list.

The empty-field and missing-value rules above are conventions of this API, shared by `parseRfc1866()` and `parseRfc3986()`.

In contrast, `parseWhatWg()` discards empty fields in line with the WHATWG URL specification. A field without an `=` character has an empty string value rather than `null`. An empty name is allowed, and only the first literal `=` separates the name from the value.

| Input | Result of `parseWhatWg()->list()` |
|---|---|
| `“”`, `“&”`, or `“&&”` | `[]` |
| `“foo”` or `“foo=”` | `[["foo", ""]]` |
| `“=”` | `[["", ""]]` |
| `“=bar”` | `[["", "bar"]]` |
| `“foo=bar&”` | `[["foo", "bar"]]` |
| `“foo=bar&=”` | `[["foo", "bar"], ["", ""]]` |
| `“foo=a=b”` | `[["foo", "a=b"]]` |

The tolerant parsing principle is also applied when encountering malformed percent-encoded sequences, such as `%`, `%2`, or `%GG`. `QueryParams::parseRfc1866()` and `QueryParams::parseRfc3986()` preserve these literally both in parameter names and values rather than throwing an exception. This behavior is a design decision on PHP's side, as there's no relevant mandate either in RFC 1866 or in RFC 3986.

```php
$params = Uri\QueryParams::parseRfc3986("foo=%&bar=%2&baz=%GG");
 
var_dump($params->getFirst("foo"));        // string(1) "%"
var_dump($params->getFirst("bar"));        // string(2) "%2"
var_dump($params->getFirst("baz"));        // string(3) "%GG"
 
echo $params->toRfc3986String();           // foo=%25&bar=%252&baz=%25GG
```

On the other hand, WHATWG URL has detailed requirements for handling malformed percent-encoded sequences that `QueryParams::parseWhatWg()` respects: these sequences are also preserved during parsing.

```php
$params = Uri\QueryParams::parseWhatWg("foo=%&bar=%2&baz=%GG");
 
var_dump($params->getFirst("foo"));        // string(1) "%"
var_dump($params->getFirst("bar"));        // string(2) "%2"
var_dump($params->getFirst("baz"));        // string(3) "%GG"
 
echo $params->toWhatWgString();            // foo=%25&bar=%252&baz=%25GG
```

When recomposing the query string, literal percent signs are encoded as `%25`. Consequently, the parameter values are preserved, but the original query string is not necessarily reproduced.

Valid percent-encoded sequences are decoded into bytes. `parseRfc1866()` and `parseRfc3986()` preserve these bytes without character-encoding conversion or UTF-8 validation. For example, `%FF` represents the byte `0xFF`, even though that byte alone is not valid UTF-8:

```php
$params = Uri\QueryParams::parseRfc3986("foo=%FF&bar=%C3%A9");
 
echo bin2hex($params->getFirst("foo"));    // ff
var_dump($params->getFirst("bar"));        // string(2) "é"
 
echo $params->toRfc3986String();           // foo=%FF&bar=%C3%A9
```

In contrast, `parseWhatWg()` applies UTF-8 decoding after percent-decoding based on the requirements of WHATWG URL. Invalid UTF-8 sequences are replaced with `U+FFFD` (the replacement character), while valid UTF-8 is preserved:

```php
$params = Uri\QueryParams::parseWhatWg("foo=%FF&bar=%C3%A9");
 
var_dump($params->getFirst("foo"));        // string(3) "�"
echo bin2hex($params->getFirst("foo"));    // efbfbd
var_dump($params->getFirst("bar"));        // string(2) "é"
 
echo $params->toWhatWgString();            // foo=%EF%BF%BD&bar=%C3%A9
```

This replacement loses the original invalid bytes: recomposing the query string encodes the replacement character rather than recovering the original `%FF`.

According to WHATWG URL's [new URLSearchParams(init)](https://url.spec.whatwg.org/#dom-urlsearchparams-urlsearchparams) algorithm, exactly one leading “?” character is removed during the `QueryParams::parseWhatWg()` call. In contrast to this behavior, the leading “?” becomes part of the first query parameter name for RFC 1866 and RFC 3986 query params.

```php
$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*()` methods accept an optional `QueryParamParsingOptions` instance. If it is omitted or `null`, the default limits apply:

- `$maxQueryStringLength` limits the length of the input query string in bytes and defaults to `10000`. The length is measured before percent-decoding or any other parsing step, including removal of a leading `?` by `parseWhatWg()`. A value of `0` permits only an empty input string.
- `$maxParamCount` limits the number of parsed name-value pairs and defaults to `1000`, similar to the [max\_input\_vars INI option](https://www.php.net/manual/en/info.configuration.php#ini.max-input-vars). Each occurrence counts separately, including duplicate names and parameters without a value. A value of `0` permits only inputs that produce no parameters.

Both limits are inclusive. If either limit is exceeded, the parsing method throws an `Uri\InvalidQueryParamsException` and does not return a partially populated instance. Negative limits cause the `QueryParamParsingOptions` constructor to throw a `ValueError`.

These limits apply only when parsing a query string. They do not restrict `fromArray()`, `withArray()`, or subsequent calls to `append()` and `set()`.

```php
$options = new Uri\QueryParamParsingOptions(
    maxQueryStringLength: 11,
    maxParamCount: 2,
);
 
$params = Uri\QueryParams::parseRfc3986("a=1&a=2", parsingOptions: $options);   // Two parameters: accepted
 
Uri\QueryParams::parseRfc3986("a=1&a=2&a=3", parsingOptions: $options);         // Throws Uri\InvalidQueryParamsException
Uri\QueryParams::parseRfc3986("a=1&a=234567", parsingOptions: $options);        // Throws Uri\InvalidQueryParamsException
```

#### Parameter Retrieval

The `has()` and `hasValue()` methods can be used to find out if a parameter exists:

```php
$params = Uri\QueryParams::parseRfc3986("foo=bar&baz=qux&baz=quux");
 
var_dump($params->has("baz"));              // bool(true)
var_dump($params->has("non-existent"));     // bool(false)
 
var_dump($params->hasValue("foo", "bar"));  // bool(true) 
var_dump($params->hasValue("foo", "baz"));  // bool(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 match at least one parameter, otherwise it returns `false`.

The number of query parameters can be retrieved by calling the `count()` method:

```php
$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](https://url.spec.whatwg.org/#dom-urlsearchparams-get) 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](https://url.spec.whatwg.org/#dom-urlsearchparams-getall) 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.

```php
$params = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz&qux=quux");
 
var_dump($params->getFirst("foo"));          // string(3) "bar"
var_dump($params->getFirst("non-existent")); // NULL
 
var_dump($params->getLast("foo"));           // string(3) "baz"
var_dump($params->getLast("non-existent"));  // NULL
 
$all = $params->getAll("foo");               // ["bar", "baz"]
$all = $params->getAll("non-existent");      // []
 
$list = $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.

```php
$params = Uri\QueryParams::parseRfc3986("foo%5B%5D=b%61r");  // Internally stored as "foo[]=bar"
 
var_dump($params->getFirst("foo%5B%5D"));    // NULL
var_dump($params->getFirst("foo[]"));        // string(3) "bar"
 
var_dump($params->getLast("foo%5B%5D"));     // NULL
var_dump($params->getLast("foo[]"));         // string(3) "bar"
 
$all = $params->getAll("foo%5B%5D");         // []
$all = $params->getAll("foo[]");             // ["bar"]
 
$list = $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 it only performs 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](https://datatracker.ietf.org/doc/html/rfc1866#section-8.2.1) which specifies the percent-encoding and decoding rules of the `application/x-www-form-urlencoded` media type
- [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986) which defines the generic query string syntax.
- [URLSearchParams](https://url.spec.whatwg.org/#urlencoded-parsing) class specified by WHATWG 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 usual, a [dedicated percent-encoding set](https://url.spec.whatwg.org/#application-x-www-form-urlencoded-percent-encode-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](https://url.spec.whatwg.org/#urlencoded-serializing) 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](https://url.spec.whatwg.org/#query-percent-encode-set) contains far fewer 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: RFC 1866 percent-encodes the asterisk (`*`) symbol as `%2A`, while WHATWG URL leaves it unescaped.

#### 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.

```php
$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](https://externals.io/rfc/query_params#percent-encoding_and_decoding), otherwise it would be possible that an invalid output is returned.

```php
$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 anyway to make the produced query string valid.

#### Relation to Uri\\Rfc3986\\Uri and Uri\\WhatWg\\Url

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.

Both `Uri\Rfc3986\Uri` and `Uri\WhatWg\Url` provide `getQueryParams()` and `withQueryParams()` methods for retrieving and replacing their query parameters.

`Uri\Rfc3986\Uri::getQueryParams()` parses the raw (unnormalized) query component using the RFC 3986-based rules, and `Uri\WhatWg\Url::getQueryParams()` follows the WHATWG URL specification. If the query component is absent, they both return `null`. Otherwise, each call creates a new `QueryParams` instance, including an empty instance when the query component is present but empty. Returning `null` for a missing query component ensures that passing the result back to `withQueryParams()` does not introduce an empty query component.

```php
$uri = new Uri\Rfc3986\Uri("https://example.com");
 
$params = $uri->getQueryParams();                     // null
 
$uri = $uri->withQueryParams($params);
 
var_dump($uri->getQuery());                           // NULL
```

Both `getQueryParams()` methods accept optional `$parsingOptions` and `$buildingOptions` arguments, with the same defaults as the corresponding `QueryParams::parse*()` methods. Exceeding a parsing limit throws an `Uri\InvalidQueryParamsException`. Building options configure subsequent modifications of the returned instance.

For WHATWG URLs, a distinction must be made between parsing a query component and invoking the `URLSearchParams` constructor with a string. `Url::getQuery()` returns the component without its delimiting `?`, so any leading `?` in that result belongs to the first query parameter itself. Passing this value directly to `QueryParams::parseWhatWg()` would remove that character. `Url::getQueryParams()` handles this distinction by parsing the component directly, without removing a leading `?`:

```php
$url = new Uri\WhatWg\Url("https://example.com/??foo=bar");
 
echo $url->getQuery();                                // ?foo=bar
 
// Incorrect usage:
$params = Uri\QueryParams::parseWhatWg($url->getQuery());
$list = $params->list();                              // [["foo", "bar"]]
 
// Correct usage:
$params = $url->getQueryParams();
$list = $params->list();                              // [["?foo", "bar"]]
 
// Also correct:
$params = Uri\QueryParams::parseWhatWg("?" . $url->getQuery());
$list = $params->list();                              // [["?foo", "bar"]]
```

`withQueryParams()` creates a new URI or URL with its entire query component replaced. It uses `toRfc3986String()` for `Uri\Rfc3986\Uri` and `toWhatWgString()` for `Uri\WhatWg\Url`. Passing `null` removes the query component.

```php
$url = new Uri\WhatWg\Url("https://example.com/?page=1");
 
$params = $url
    ->getQueryParams()
    ->set("page", 2);
 
$url = $url->withQueryParams($params);
 
echo $url->getQuery();                                // page=2
```

An empty recomposed query string follows the behavior of the existing `withQuery(“”)` method: `Uri\Rfc3986\Uri` retains an empty query string component, while `Uri\WhatWg\Url` removes the component. Consequently, the RFC-based methods preserve the distinction between an absent and an empty query component when reading and writing an empty query string back.

Although preserving the original query string where possible was an important design goal, parsing and recomposing cannot always reproduce the original query string. `QueryParams` stores decoded parameter names and values, so the original percent-encoded representation is not retained. For example, `x=/` and `x=%2F` both produce the same parameter list, containing the name `“x”` and the value `“/”`, and recomposition cannot distinguish between these original representations.

Moreover, parsing and recomposing a WHATWG URL query string does not necessarily preserve its original string representation. The WHATWG URL specification defines different percent-encoding rules for URL query components and `application/x-www-form-urlencoded` data. For example, a space represented as `%20` in the original URL becomes `+` when recomposed by `toWhatWgString()`:

```php
$url = new Uri\WhatWg\Url("https://example.com?foo=a%20b");   // "a%20b" is the percent-encoded form of "a b"
 
$params = $url->getQueryParams();
 
$url = $url->withQueryParams($params);
 
$array = $params->toArray();                                  // ["foo" => "a b"]
echo $url->getQuery();                                        // foo=a+b
```

In this example, the parameter name and its internally stored (percent-decoded) value remain unchanged, even though the query string has a different representation.

#### Modification

The `append()` method can be used to append a parameter to the end of the list. As usual, the same query parameter can be added multiple times:

```php
$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:

```php
$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=baz&qux=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()` performs percent-encoding or decoding on its arguments, and the input is stored literally. However, as mentioned in the section about [Recomposition](https://externals.io/rfc/query_params#recomposition), each literal “`%`” character is encoded as “`%25`”, when the query string is recomposed:

```php
$params = new Uri\QueryParams();
$params = $params->append("foo%5B%5D", "ab%63");
$params = $params->set("bar%5B%5D", "de%66");
 
echo $params->getFirst("foo%5B%5D");    // ab%63
echo $params->getFirst("bar%5B%5D");    // de%66
 
echo $params->toRfc3986String();        // foo%255B%255D=ab%2563&bar%255B%255D=de%2566
```

Therefore, callers should pass decoded names and values to these methods. For example, `$params->append(“foo[]”, “abc”)` produces the expected “`foo%5B%5D=abc`” query string when recomposed.

`append()` and `set()` also preserve the bytes of string parameter names and values without UTF-8 validation or replacement. Invalid UTF-8 sequences remain in the parameter list: RFC 1866 and RFC 3986-based recomposition methods percent-encode their original bytes, while `toWhatWgString()` replaces them with `U+FFFD` when producing its output, leaving the stored data unchanged.

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 match it, as demonstrated below:

```php
$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 by parameter name in ascending lexicographical order, comparing byte values. The comparison is case-sensitive and locale-independent:

```php
$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.

This differs from WHATWG URL's `URLSearchParams::sort()`, which compares UTF-16 code units. Consequently, the two methods can produce different orders for non-ASCII names.

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) 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` constructor only accepts and returns string data according to the specification itself.

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**:

- **null:** Omits the query parameter value by default
- **bool:** Becomes string “0” (in case of `false`) or string “1” (in case of `true`) by default
- **int:** Becomes a numeric string (123 -&gt; “123”)
- **float:** Converted according to PHP's regular float-to-string conversion rules (e.g. 3.14 -&gt; “3.14”)
- **resource:** Invalid mapping, a `TypeError` exception is thrown
- **array:** Invalid mapping, a `TypeError` exception is thrown
- **enum:**
    - **backed enums** are converted to their backing value
    - **enums without backing type** are invalid, a `TypeError` exception is thrown
- **object:** invalid mapping, a `TypeError` exception is thrown

A few examples demonstrating how `QueryParams` handles non-string scalar types:

```php
$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
```

The default mapping of boolean and `null` values can be customized during query parameter building (`fromArray()`, `withArray()`, `append()` and `set()`) by passing a `Uri\QueryParamBuildingOptions` instance to the `QueryParams` constructor or its factory methods. The following properties control these conversions:

- `$trueValue`: The string representation of `true`. Defaults to `“1”`.
- `$falseValue`: The string representation of `false`. Defaults to `“0”`.
- `$nullPolicy`: How query parameters with a `null` value are treated. 
    - `\Uri\QueryParamNullPolicy::SkipTuple`: The parameter is not added to the query parameter list.
    - `\Uri\QueryParamNullPolicy::KeyOnly`: The parameter name is added, but the value is omitted. This is the default behavior.
    - `\Uri\QueryParamNullPolicy::EmptyString`: The parameter value is converted to an empty string.

For example, `$trueValue` can be used to represent enabled flags as `“yes”`:

```php
$options = new Uri\QueryParamBuildingOptions(trueValue: "yes");
$params = new Uri\QueryParams($options);
$params = $params->append("enabled", true);
 
var_dump($params->getFirst("enabled"));     // string(3) "yes"
echo $params->toRfc3986String();            // enabled=yes
```

Similarly, `$falseValue` controls how disabled flags are represented:

```php
$options = new Uri\QueryParamBuildingOptions(falseValue: "no");
$params = new Uri\QueryParams($options);
$params = $params->append("enabled", false);
 
var_dump($params->getFirst("enabled"));     // string(2) "no"
echo $params->toRfc3986String();            // enabled=no
```

By setting `$nullPolicy` to `\Uri\QueryParamNullPolicy::KeyOnly`, the query parameter name is stored, but its value is omitted:

```php
$options = new Uri\QueryParamBuildingOptions(nullPolicy: \Uri\QueryParamNullPolicy::KeyOnly);
$params = new Uri\QueryParams($options);
$params = $params->append("param", null);
 
var_dump($params->getFirst("param"));       // NULL
var_dump($params->toRfc3986String());       // string(5) "param"
```

On the other hand, setting `$nullPolicy` to `\Uri\QueryParamNullPolicy::EmptyString` removes the distinction between a `null` value and an empty string:

```php
$options = new Uri\QueryParamBuildingOptions(nullPolicy: \Uri\QueryParamNullPolicy::EmptyString);
$params = new Uri\QueryParams($options);
$params = $params->append("param", null);
 
var_dump($params->getFirst("param"));       // string(0) ""
var_dump($params->toRfc3986String());       // string(6) "param="
```

For reference, `http_build_query()` omits parameters with `null` values (thus conforms to the behavior of `\Uri\QueryParamNullPolicy::SkipTuple`):

```php
$options = new Uri\QueryParamBuildingOptions(nullPolicy: \Uri\QueryParamNullPolicy::SkipTuple);
$params = new Uri\QueryParams($options);
$params = $params->append("param", null);
 
var_dump($params->has("param"));            // bool(false)
var_dump($params->toRfc3986String());       // string(0) ""
```

Type conversions listed above happen when query parameter values are checked (`hasValue()`), added (`append()`), updated (`set()`), or deleted (`deleteValue()`), rather than when the query string is recomposed. The building options are stored by `QueryParams` instances, so subsequent query parameter modification operations use the same mapping rules. The configured strings are treated as ordinary parameter values: they are stored without percent-encoding and encoded when a query string is produced.

```php
enum Flag
{
    case Enabled;
}
 
$params = new Uri\QueryParams();
 
$params = $params->append("value", fopen("php://memory", "r+"));    // Throws TypeError
$params = $params->append("value", ["foo", "bar"]);                 // Throws TypeError
$params = $params->append("value", Flag::Enabled);                  // Throws TypeError
```

`hasValue()` and `deleteValue()` convert non-null values using the same type mapping rules as `append()`, including the configured boolean representations. For these two methods, the `$nullPolicy` option is ignored when comparing a `null` argument against the stored query parameter values, while an empty string matches only an empty string query parameter value. Names and converted string values are compared byte-for-byte.

To use the string representation of a `Stringable` object, callers must explicitly cast it to a string before passing it as a parameter value. Backed enums do not require an explicit conversion: their backing value is mapped according to the rules for strings or integers.

Rejecting arrays as individual parameter values does not prevent array-based construction. `fromArray()` and `withArray()` provide a separate API for converting PHP arrays into parameter lists, as described in the following section.

Building options do not affect parsing. Options passed to a parsing method are stored for subsequent operations. The configured `$nullPolicy` applies when building parameters through `fromArray()`, `withArray()`, `append()`, or `set()`, while boolean representations (`$trueValue`, `$falseValue`) also apply to `hasValue()` and `deleteValue()` in addition to the previously mentioned methods.

#### 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 this section to convert nested arrays into flat key-value pairs.
- **`QueryParams::toArray()`:** Converts the parameter list into a PHP array by interpreting bracket notation in parameter names according to the rules described below.
- **`QueryParams::withArray()`:** It replaces the whole query list with the input, but clones the current instance rather than creating a completely new one.

The intended workflow for array-based modifications is:

```php
$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 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.

The Array API distinguishes the following array types:

- **Empty array:** An array containing no elements.
- **List:** An array whose keys are consecutive integers starting from `0`, in iteration order, as determined by `array_is_list()`.
- **Map:** A non-empty array is a map if it is not a list.

`fromArray()` and `withArray()` convert PHP arrays into parameter lists according to the following rules:

- The outermost input array contains the query parameters to build. The keys will become parameter names: when encountering integer keys, int to string conversion is performed. Array items are processed in the regular array iteration order.
- Non-array values are converted according to the rules described in the “Supported types” section, using the present building options.
- Empty arrays are omitted, since they contain no items which could be added to the query parameter list.
- For a nested list containing no array elements, `[]` is appended to the resulting query parameter name for each element. For example, the `[“k” => [1, 2]]` input is internally stored as two parameters: `[["k[]" => "1"], ["k[]" => "2"]]`.
- If a nested list contains at least one array element, the original numeric index is included for every element at that level. For example, the `[“k” => [1, [“a” => 2]]]` input is internally stored as `[["k[0]" => "1"], ["k[1][a]" => "2"]]`.
- For a nested map, each key is appended to the parameter name within brackets. For example, the `[“k” => [“a” => 1, “b” => 2]]` input is internally stored as `[["k[a]" => "1"], ["k[b]" => "2"]]`.
- These rules apply recursively.

The `fromArray()` and `withArray()` methods reject recursive array structures with a `ValueError`:

```php
$array = [];
$array["self"] = &$array;
 
Uri\QueryParams::fromArray($array);         // Throws ValueError
```

References are otherwise supported. Referencing the same non-recursive array from multiple independent branches is allowed, and each occurrence is converted separately.

The `toArray()` method converts the stored parameter list into a PHP array. It interprets bracket-syntax in parameter names to reconstruct nested arrays. The following rules apply:

- **Empty parameter list:** Produces an empty PHP array.

- **Names without bracket notation:** A single query parameter name occurrence produces a scalar value. Repeated occurrences are collected into a list of values in encounter order. For example, `k=1` produces `[“k” => “1”]`, while `k=1&k=2` produces `[“k” => [“1”, “2”]]`.

- **Empty brackets:** Each `[]` appends a new element to the resulting array list using PHP's normal array appending semantics. Even a single occurrence produces an array: `k[]=1` produces `[“k” => [“1”]]`. If further bracket segments follow, a new nested array is created for that occurrence. For example, `k[][a]=1&k[][b]=2` creates two separate items within the list `k`: `["k" => [["a" => "1"], ["b" => "2"]]]`.

- **Explicit keys:** A non-empty bracket segment selects the corresponding array key. Distinct paths may share intermediate arrays, so `k[a]=1&k[b]=2` creates one array containing both keys. Assigning another value to an already populated leaf, as in `k[a]=1&k[a]=2`, throws an `Uri\InvalidQueryParamsException`.

- **Array key conversion:** Root names and explicit bracket keys follow PHP's normal array key conversion rules. For example, `“1”` becomes an integer key, while `“01”` remains a string key.

- **Conflicting structures:** Plain parameter names and bracket paths must not be mixed for the same root, as in `k=1&k[]=2`. Scalar/array conflicts at deeper levels, such as `k[a]=1&k[a][b]=2`, are also rejected with an `Uri\InvalidQueryParamsException`, regardless of parameter order.

- **Malformed bracket notation:** The entire parameter name is treated literally, without partial bracket interpretation. Examples include `k[`, `foo[]]`, and `foo[[a]`. Repeated occurrences of these literal names follow the same collection rule as other names without interpreted bracket notation.

- **Values:** Stored string and `null` values are preserved. Building options are not reapplied during conversion.

Explicit indices are necessary when list elements contain arrays, because empty brackets would otherwise lose the grouping between fields belonging to the same element.

```php
$params = Uri\QueryParams::fromArray([
    "rows" => [
        ["a" => 1, "b" => 2],
        ["a" => 3, "b" => 4],
    ],
]);
 
$list = $params->list();
// [
//     ["rows[0][a]", "1"],
//     ["rows[0][b]", "2"],
//     ["rows[1][a]", "3"],
//     ["rows[1][b]", "4"],
// ]
 
$array = $params->toArray();
// [
//     "rows" => [
//         ["a" => "1", "b" => "2"],
//         ["a" => "3", "b" => "4"],
//     ],
// ]
```

`toArray()` is not a general inverse of `fromArray()`. Values undergo the conversions described in the “Supported types” section, empty arrays are omitted, and bracket notation in parameter names is interpreted when constructing the result. When explicit indices are used, omitted elements may leave gaps in the resulting array's numeric keys.

The `toArray()` method accepts a `$maxNestingLevel` parameter, which limits the nesting depth of the resulting PHP array. It defaults to `64`. The outermost array is not counted as a nesting level: `a=1` has a nesting level of `0`, `a[b]=1` has a nesting level of `1`, and `a[b][c]=1` has a nesting level of `2`. Each additional nested array increases the nesting level by one.

If converting the parameter list would exceed this limit, `toArray()` throws an `Uri\InvalidQueryParamsException`. Passing `0` only permits a flat result array. Negative values cause a `ValueError` to be thrown.

This limit only applies when converting query parameters to a PHP array. Parameter names containing deeper bracket structures can still be parsed, stored, retrieved, and recomposed without calling `toArray()`.

```php
$params = Uri\QueryParams::parseRfc3986("a[b][c]=1");
 
$array = $params->toArray(maxNestingLevel: 2);        // ["a" => ["b" => ["c" => "1"]]]
 
$params->toArray(maxNestingLevel: 1);                 // Throws Uri\InvalidQueryParamsException
```

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:

```php
$params = Uri\QueryParams::parseRfc3986("foo%5B%5D=1&foo%5B%5D=2");
// Internally: [["foo[]", "1"], ["foo[]", "2"]]
 
$all = $params->getAll("foo[]");            // ["1", "2"]
$all = $params->getAll("foo");              // []
 
$array = $params->toArray();                // ["foo" => ["1", "2"]]  — brackets interpreted here
```

#### Class signature

The `QueryParams` class is `final` [for the same reason](https://externals.io/rfc/url_parsing_api#why_should_the_uri_rfc3986_uri_and_the_uri_whatwg_url_classes_be_final) 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. Modification methods whose name doesn't start with a “with” suffix are marked with `[\NoDiscard]` to warn end users not to forget to use the result. These methods (`append()`, `set()`, `delete()`, `deleteValue()`) are “withers” in fact, but using the usual prefix would have made the method names sound clunky (e.g. `withAppend()` or `withAppendedParam()`).

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&param=bar&param=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 as a two-element array containing its name and value. Similarly to the `get*()` methods, the iterator returns the “raw” parameter names and values without percent-encoding. Let's see an example:

```php
$params = Uri\QueryParams::parseRfc3986("param=foo&param=bar&param=baz");
 
foreach ($params as [$name, $value]) {
    echo "$name => $value\n";
}
 
/*
param => foo
param => bar
param => baz
*/
```

#### Cloning

Cloning of `QueryParams` is supported.

```php
$params1 = Uri\QueryParams::parseRfc3986("foo=bar&foo=baz");
$params2 = clone $params1;
 
var_dump($params1 === $params2); // bool(false)
```

#### Serialization

`QueryParams` supports serialization and deserialization via the [new serialization API](https://externals.io/rfc/custom_object_serialization). 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. Building options (`Uri\QueryParams::$buildingOptions`) are also preserved.

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.

```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"
    }
  }
}
*/
```

#### 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 in 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:

```php
$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:

```php
$queryParams = Uri\QueryParams::parseRfc1866($_SERVER["QUERY_STRING"] ?? "");
 
$order = $queryParams->has("order") ? ($queryParams->getLast("order") ?? "") : null;
$limit = $queryParams->has("limit") ? (int) $queryParams->getLast("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 decoded parameter names without replacing dots or spaces.

- **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.

- **Input truncation**: When the number of input variables exceeds the `max_input_vars` ini directive (default: 1000), `$_GET` discards the remaining parameters along with emitting an `E_WARNING`, potentially causing data loss that is difficult to detect. `QueryParams` also enforces a limit, but it throws an exception.

- **Input separators:** `$_GET` respects the `arg_separator.input` INI directive, while all `QueryParams::parse*()` methods use only `&` as a parameter separator.

## 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

Existing applications and libraries do not require any changes. Adoption of the new API is optional.

### 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.

**Vote: Add support for query parameter manipulation as outlined in the RFC?** — Yes: 0 · No: 0 · Abstain: 0 (closed)

## Implementation

After the RFC is implemented, this section should contain:

1. the version(s) it was merged into
2. a link to the git commit(s)
3. 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](https://externals.io/rfc/uri_followup)
- Followup Improvements for ext/uri RFC discussion thread: <https://externals.io/message/129486>
- Query Parameter Manipulation Support RFC discussion thread: <https://externals.io/message/130217>

## Rejected Features

- **Bracket-aware parameter manupulation methods:** Dedicated methods such as `getArray()`, `appendArray()`, and `setArray()` were rejected because of their complexity which would have required the reimplementation of native PHP array operations. The simplified Array API provides conversion between parameter lists and PHP arrays instead.

## Changelog

- Replaced the originally proposed bracket-aware `getArray()`, `appendArray()`, and `setArray()` methods with `fromArray()`, `toArray()`, and `withArray()`. Array manipulation is performed through native PHP arrays.
