From 84928da0a09ee7eddb7930712021f1f599c3ddec Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:01:12 +0000 Subject: [PATCH] feat(php): add support for deep object query parameters Co-Authored-By: naman.anand@buildwithfern.com --- .../src/asIs/Client/RawClient.Template.php | 39 ++++++++-- .../asIs/Client/RawClientTest.Template.php | 75 +++++++++++++++++++ generators/php/sdk/versions.yml | 8 ++ 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/generators/php/base/src/asIs/Client/RawClient.Template.php b/generators/php/base/src/asIs/Client/RawClient.Template.php index 8e93536dc3d1..d9f27d7f4311 100644 --- a/generators/php/base/src/asIs/Client/RawClient.Template.php +++ b/generators/php/base/src/asIs/Client/RawClient.Template.php @@ -252,17 +252,46 @@ private function buildUrl( */ private function encodeQuery(array $query): string { + $flatParams = $this->flattenQueryParams($query); $parts = []; - foreach ($query as $key => $value) { - if (is_array($value)) { + foreach ($flatParams as [$key, $value]) { + $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($value); + } + return implode('&', $parts); + } + + /** + * Recursively flattens nested arrays into query parameter format with bracket notation. + * For example: ['filter' => ['name' => 'john']] becomes [['filter[name]', 'john']] + * + * @param array $data + * @param string|null $keyPrefix + * @return array + */ + private function flattenQueryParams(array $data, ?string $keyPrefix = null): array + { + $result = []; + foreach ($data as $key => $value) { + $fullKey = $keyPrefix !== null ? "{$keyPrefix}[{$key}]" : (string)$key; + + if (is_array($value) && !empty($value) && !self::isSequential($value)) { + // Associative array (object-like) - recurse with bracket notation + $result = array_merge($result, $this->flattenQueryParams($value, $fullKey)); + } elseif (is_array($value)) { + // Sequential array - each item gets the same key (exploded) foreach ($value as $item) { - $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($item); + if (is_array($item) && !empty($item) && !self::isSequential($item)) { + // Array of objects + $result = array_merge($result, $this->flattenQueryParams($item, $fullKey)); + } else { + $result[] = [$fullKey, $item]; + } } } else { - $parts[] = urlencode($key) . '=' . $this->encodeQueryValue($value); + $result[] = [$fullKey, $value]; } } - return implode('&', $parts); + return $result; } private function encodeQueryValue(mixed $value): string diff --git a/generators/php/base/src/asIs/Client/RawClientTest.Template.php b/generators/php/base/src/asIs/Client/RawClientTest.Template.php index b5af82e8639e..219731dada6f 100644 --- a/generators/php/base/src/asIs/Client/RawClientTest.Template.php +++ b/generators/php/base/src/asIs/Client/RawClientTest.Template.php @@ -667,4 +667,79 @@ public function testMaxDelayCapIsApplied(): void $this->assertGreaterThanOrEqual(60000, $delay); $this->assertLessThanOrEqual(72000, $delay); } + + /** + * @throws ClientExceptionInterface + */ + public function testDeepObjectQueryParameters(): void + { + $this->mockHandler->append(new Response(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['filter' => ['name' => 'john', 'age' => 30]] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals( + 'https://api.example.com/test?filter%5Bname%5D=john&filter%5Bage%5D=30', + (string)$lastRequest->getUri() + ); + } + + /** + * @throws ClientExceptionInterface + */ + public function testDeeplyNestedObjectQueryParameters(): void + { + $this->mockHandler->append(new Response(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['filter' => ['user' => ['name' => 'john', 'role' => 'admin'], 'status' => 'active']] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals( + 'https://api.example.com/test?filter%5Buser%5D%5Bname%5D=john&filter%5Buser%5D%5Brole%5D=admin&filter%5Bstatus%5D=active', + (string)$lastRequest->getUri() + ); + } + + /** + * @throws ClientExceptionInterface + */ + public function testArrayOfObjectsQueryParameters(): void + { + $this->mockHandler->append(new Response(200)); + + $request = new JsonApiRequest( + $this->baseUrl, + '/test', + HttpMethod::GET, + [], + ['objects' => [['key' => 'hello', 'value' => 'world'], ['key' => 'foo', 'value' => 'bar']]] + ); + + $this->rawClient->sendRequest($request); + + $lastRequest = $this->mockHandler->getLastRequest(); + assert($lastRequest instanceof RequestInterface); + $this->assertEquals( + 'https://api.example.com/test?objects%5Bkey%5D=hello&objects%5Bvalue%5D=world&objects%5Bkey%5D=foo&objects%5Bvalue%5D=bar', + (string)$lastRequest->getUri() + ); + } } diff --git a/generators/php/sdk/versions.yml b/generators/php/sdk/versions.yml index c0de7a550fcf..384135d5e5dd 100644 --- a/generators/php/sdk/versions.yml +++ b/generators/php/sdk/versions.yml @@ -1,5 +1,13 @@ # yaml-language-server: $schema=../../../fern-versions-yml.schema.json +- version: 1.26.0 + changelogEntry: + - summary: | + Add support for deep object query parameters. Nested objects in query parameters are now encoded using bracket notation (e.g., `filter[user][name]=john&filter[user][age]=30`). This matches the OpenAPI 3.0 deepObject serialization style and is consistent with Python, Java, and C# generators. + type: feat + createdAt: "2026-01-26" + irVersion: 62 + - version: 1.25.3 changelogEntry: - summary: |