diff --git a/modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache b/modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache
index 3e51b750323b..0b5d2288ed8e 100644
--- a/modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache
+++ b/modules/openapi-generator/src/main/resources/php-nextgen/ObjectSerializer.mustache
@@ -198,6 +198,31 @@ class ObjectSerializer
};
}
+ /**
+ * Convert objects into arrays, recursively.
+ *
+ * sanitizeForSerialization() returns an object per model, but query parameters
+ * are flattened from arrays.
+ *
+ * @param mixed $data
+ *
+ * @return mixed the data with every object converted to an array
+ */
+ private static function toArrayRecursive(mixed $data): mixed
+ {
+ if (is_array($data) || is_object($data)) {
+ $result = [];
+
+ foreach ($data as $key => $value) {
+ $result[$key] = self::toArrayRecursive($value);
+ }
+
+ return $result;
+ }
+
+ return $data;
+ }
+
/**
* Take query parameter properties and turn it into an array suitable for
* native http_build_query or GuzzleHttp\Psr7\Query::build.
@@ -237,8 +262,19 @@ class ObjectSerializer
return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
}
+ // A model is typed with its class name rather than "object", but serializes as one.
+ if ($value instanceof ModelInterface) {
+ $openApiType = 'object';
+ }
+
$query = [];
- $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+ if ($openApiType === 'object' && is_object($value)) {
+ // Read the model's values through its getters; a plain (array) cast
+ // would expose only the protected $container holding them.
+ $value = self::toArrayRecursive(self::sanitizeForSerialization($value));
+ } elseif (in_array($openApiType, ['object', 'array'], true)) {
+ $value = (array) $value;
+ }
// since \GuzzleHttp\Psr7\Query::build fails with nested arrays
// need to flatten array first
diff --git a/modules/openapi-generator/src/main/resources/php-nextgen/phpunit.xml.mustache b/modules/openapi-generator/src/main/resources/php-nextgen/phpunit.xml.mustache
index b72fb7c6e77e..bbad79749af4 100644
--- a/modules/openapi-generator/src/main/resources/php-nextgen/phpunit.xml.mustache
+++ b/modules/openapi-generator/src/main/resources/php-nextgen/phpunit.xml.mustache
@@ -8,8 +8,7 @@
- {{apiTestPath}}
- {{modelTestPath}}
+ ./{{testBasePath}}
diff --git a/samples/client/echo_api/php-nextgen-streaming/phpunit.xml.dist b/samples/client/echo_api/php-nextgen-streaming/phpunit.xml.dist
index 1135449a8d9b..fff90cbd49db 100644
--- a/samples/client/echo_api/php-nextgen-streaming/phpunit.xml.dist
+++ b/samples/client/echo_api/php-nextgen-streaming/phpunit.xml.dist
@@ -8,8 +8,7 @@
- ./tests/Api
- ./tests/Model
+ ./tests
diff --git a/samples/client/echo_api/php-nextgen-streaming/src/ObjectSerializer.php b/samples/client/echo_api/php-nextgen-streaming/src/ObjectSerializer.php
index 2b4404907885..4aca1d803fb7 100644
--- a/samples/client/echo_api/php-nextgen-streaming/src/ObjectSerializer.php
+++ b/samples/client/echo_api/php-nextgen-streaming/src/ObjectSerializer.php
@@ -207,6 +207,31 @@ private static function isEmptyValue(mixed $value, string $openApiType): bool
};
}
+ /**
+ * Convert objects into arrays, recursively.
+ *
+ * sanitizeForSerialization() returns an object per model, but query parameters
+ * are flattened from arrays.
+ *
+ * @param mixed $data
+ *
+ * @return mixed the data with every object converted to an array
+ */
+ private static function toArrayRecursive(mixed $data): mixed
+ {
+ if (is_array($data) || is_object($data)) {
+ $result = [];
+
+ foreach ($data as $key => $value) {
+ $result[$key] = self::toArrayRecursive($value);
+ }
+
+ return $result;
+ }
+
+ return $data;
+ }
+
/**
* Take query parameter properties and turn it into an array suitable for
* native http_build_query or GuzzleHttp\Psr7\Query::build.
@@ -246,8 +271,19 @@ public static function toQueryValue(
return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
}
+ // A model is typed with its class name rather than "object", but serializes as one.
+ if ($value instanceof ModelInterface) {
+ $openApiType = 'object';
+ }
+
$query = [];
- $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+ if ($openApiType === 'object' && is_object($value)) {
+ // Read the model's values through its getters; a plain (array) cast
+ // would expose only the protected $container holding them.
+ $value = self::toArrayRecursive(self::sanitizeForSerialization($value));
+ } elseif (in_array($openApiType, ['object', 'array'], true)) {
+ $value = (array) $value;
+ }
// since \GuzzleHttp\Psr7\Query::build fails with nested arrays
// need to flatten array first
diff --git a/samples/client/echo_api/php-nextgen/phpunit.xml.dist b/samples/client/echo_api/php-nextgen/phpunit.xml.dist
index 1135449a8d9b..fff90cbd49db 100644
--- a/samples/client/echo_api/php-nextgen/phpunit.xml.dist
+++ b/samples/client/echo_api/php-nextgen/phpunit.xml.dist
@@ -8,8 +8,7 @@
- ./tests/Api
- ./tests/Model
+ ./tests
diff --git a/samples/client/echo_api/php-nextgen/src/ObjectSerializer.php b/samples/client/echo_api/php-nextgen/src/ObjectSerializer.php
index 2b4404907885..4aca1d803fb7 100644
--- a/samples/client/echo_api/php-nextgen/src/ObjectSerializer.php
+++ b/samples/client/echo_api/php-nextgen/src/ObjectSerializer.php
@@ -207,6 +207,31 @@ private static function isEmptyValue(mixed $value, string $openApiType): bool
};
}
+ /**
+ * Convert objects into arrays, recursively.
+ *
+ * sanitizeForSerialization() returns an object per model, but query parameters
+ * are flattened from arrays.
+ *
+ * @param mixed $data
+ *
+ * @return mixed the data with every object converted to an array
+ */
+ private static function toArrayRecursive(mixed $data): mixed
+ {
+ if (is_array($data) || is_object($data)) {
+ $result = [];
+
+ foreach ($data as $key => $value) {
+ $result[$key] = self::toArrayRecursive($value);
+ }
+
+ return $result;
+ }
+
+ return $data;
+ }
+
/**
* Take query parameter properties and turn it into an array suitable for
* native http_build_query or GuzzleHttp\Psr7\Query::build.
@@ -246,8 +271,19 @@ public static function toQueryValue(
return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
}
+ // A model is typed with its class name rather than "object", but serializes as one.
+ if ($value instanceof ModelInterface) {
+ $openApiType = 'object';
+ }
+
$query = [];
- $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+ if ($openApiType === 'object' && is_object($value)) {
+ // Read the model's values through its getters; a plain (array) cast
+ // would expose only the protected $container holding them.
+ $value = self::toArrayRecursive(self::sanitizeForSerialization($value));
+ } elseif (in_array($openApiType, ['object', 'array'], true)) {
+ $value = (array) $value;
+ }
// since \GuzzleHttp\Psr7\Query::build fails with nested arrays
// need to flatten array first
diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/phpunit.xml.dist b/samples/client/petstore/php-nextgen/OpenAPIClient-php/phpunit.xml.dist
index 1135449a8d9b..fff90cbd49db 100644
--- a/samples/client/petstore/php-nextgen/OpenAPIClient-php/phpunit.xml.dist
+++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/phpunit.xml.dist
@@ -8,8 +8,7 @@
- ./tests/Api
- ./tests/Model
+ ./tests
diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/ObjectSerializer.php b/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/ObjectSerializer.php
index fe0227e2f3c3..d6140c08d586 100644
--- a/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/ObjectSerializer.php
+++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/src/ObjectSerializer.php
@@ -206,6 +206,31 @@ private static function isEmptyValue(mixed $value, string $openApiType): bool
};
}
+ /**
+ * Convert objects into arrays, recursively.
+ *
+ * sanitizeForSerialization() returns an object per model, but query parameters
+ * are flattened from arrays.
+ *
+ * @param mixed $data
+ *
+ * @return mixed the data with every object converted to an array
+ */
+ private static function toArrayRecursive(mixed $data): mixed
+ {
+ if (is_array($data) || is_object($data)) {
+ $result = [];
+
+ foreach ($data as $key => $value) {
+ $result[$key] = self::toArrayRecursive($value);
+ }
+
+ return $result;
+ }
+
+ return $data;
+ }
+
/**
* Take query parameter properties and turn it into an array suitable for
* native http_build_query or GuzzleHttp\Psr7\Query::build.
@@ -245,8 +270,19 @@ public static function toQueryValue(
return ["{$paramName}" => $value->format(self::$dateTimeFormat)];
}
+ // A model is typed with its class name rather than "object", but serializes as one.
+ if ($value instanceof ModelInterface) {
+ $openApiType = 'object';
+ }
+
$query = [];
- $value = (in_array($openApiType, ['object', 'array'], true)) ? (array)$value : $value;
+ if ($openApiType === 'object' && is_object($value)) {
+ // Read the model's values through its getters; a plain (array) cast
+ // would expose only the protected $container holding them.
+ $value = self::toArrayRecursive(self::sanitizeForSerialization($value));
+ } elseif (in_array($openApiType, ['object', 'array'], true)) {
+ $value = (array) $value;
+ }
// since \GuzzleHttp\Psr7\Query::build fails with nested arrays
// need to flatten array first
diff --git a/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/ObjectSerializerTest.php b/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/ObjectSerializerTest.php
new file mode 100644
index 000000000000..4000b29226ef
--- /dev/null
+++ b/samples/client/petstore/php-nextgen/OpenAPIClient-php/tests/ObjectSerializerTest.php
@@ -0,0 +1,248 @@
+assertEquals($expected, $query);
+ }
+
+ /**
+ * Query params provider
+ *
+ * Values that are not model instances, whose serialization is unchanged.
+ *
+ * @return array[]
+ */
+ public static function provideQueryParams(): array
+ {
+ $statuses = ['available', 'pending', 'sold'];
+ $filter = ['name' => 'Rex', 'status' => 'available'];
+
+ $stdClass = new \stdClass();
+ $stdClass->name = 'Rex';
+ $stdClass->category = ['name' => 'Dogs'];
+
+ return [
+ // style form
+ // status=available&status=pending&status=sold
+ 'form array, explode on, required true' => [
+ $statuses, 'status', 'array', 'form', true, true, 'status=available&status=pending&status=sold',
+ ],
+ // status=available,pending,sold
+ 'form array, explode off, required true' => [
+ $statuses, 'status', 'array', 'form', false, true, 'status=available%2Cpending%2Csold',
+ ],
+ // name=Rex&status=available
+ 'form object, explode on, required true' => [
+ $filter, 'filter', 'object', 'form', true, true, 'name=Rex&status=available',
+ ],
+ // filter=name,Rex,status,available
+ 'form object, explode off, required true' => [
+ $filter, 'filter', 'object', 'form', false, true, 'filter=name%2CRex%2Cstatus%2Cavailable',
+ ],
+ // status=available
+ 'form string, explode on, required true' => [
+ 'available', 'status', 'string', 'form', true, true, 'status=available',
+ ],
+ // quantity=0
+ 'form 0 integer, explode on, required false' => [
+ 0, 'quantity', 'integer', 'form', true, false, 'quantity=0',
+ ],
+
+ // DEEP OBJECT
+ // status[0]=available&status[1]=pending&status[2]=sold
+ 'deepObject array, explode on, required true' => [
+ $statuses, 'status', 'array', 'deepObject', true, true,
+ 'status%5B0%5D=available&status%5B1%5D=pending&status%5B2%5D=sold',
+ ],
+ // filter[name]=Rex&filter[status]=available
+ 'deepObject object, explode on, required true' => [
+ $filter, 'filter', 'object', 'deepObject', true, true,
+ 'filter%5Bname%5D=Rex&filter%5Bstatus%5D=available',
+ ],
+ // filter[name]=Rex&filter[category][name]=Dogs
+ 'deepObject stdClass, explode on, required true' => [
+ $stdClass, 'filter', 'object', 'deepObject', true, true,
+ 'filter%5Bname%5D=Rex&filter%5Bcategory%5D%5Bname%5D=Dogs',
+ ],
+ ];
+ }
+
+ /**
+ * An object-typed query parameter given as a model must serialize to the model's
+ * values, read through its getters and keyed by its attributeMap - note photoUrls.
+ *
+ * @see https://github.com/OpenAPITools/openapi-generator/issues/11222
+ * @covers ObjectSerializer::toQueryValue
+ */
+ public function testToQueryValueWithModelInstance(): void
+ {
+ // photo_urls is required and non-nullable, so it must be set for its getter to return.
+ $pet = new Pet([
+ 'id' => 1,
+ 'name' => 'Rex',
+ 'photo_urls' => ['a.png', 'b.png'],
+ 'status' => 'available',
+ ]);
+
+ $query = ObjectSerializer::toQueryValue($pet, 'filter', 'object', 'deepObject', true, false);
+
+ $this->assertEquals(
+ 'filter[id]=1&filter[name]=Rex&filter[photoUrls][0]=a.png'
+ . '&filter[photoUrls][1]=b.png&filter[status]=available',
+ urldecode(ObjectSerializer::buildQuery($query))
+ );
+ }
+
+ /**
+ * A composed (allOf/oneOf/anyOf) parameter is typed with a model name rather than
+ * "object", so models are recognised by their interface instead of that type.
+ *
+ * @covers ObjectSerializer::toQueryValue
+ * @dataProvider provideModelTypedStyles
+ */
+ public function testToQueryValueWithModelInstanceTypedByItsModelName(
+ string $style,
+ string $expected
+ ): void {
+ $pet = new Pet(['id' => 1, 'name' => 'Rex', 'photo_urls' => [], 'status' => 'available']);
+
+ $query = ObjectSerializer::toQueryValue($pet, 'filter', Pet::class, $style, true, false);
+
+ $this->assertSame($expected, urldecode(ObjectSerializer::buildQuery($query)));
+ }
+
+ /**
+ * Styles provider for a parameter typed with a model name
+ *
+ * @return array[]
+ */
+ public static function provideModelTypedStyles(): array
+ {
+ return [
+ 'deepObject, explode on' => [
+ 'deepObject', 'filter[id]=1&filter[name]=Rex&filter[status]=available',
+ ],
+ 'form, explode on' => [
+ 'form', 'id=1&name=Rex&status=available',
+ ],
+ ];
+ }
+
+ /**
+ * A model holding no values flattens to nothing, so the parameter is dropped instead
+ * of being sent as an empty container key.
+ *
+ * A deepObject has no key of its own to fall back on, so a required parameter is
+ * dropped just the same - hence the identical result for both.
+ *
+ * @covers ObjectSerializer::toQueryValue
+ */
+ public function testToQueryValueWithAnEmptyModelInstance(): void
+ {
+ $this->assertSame(
+ [],
+ ObjectSerializer::toQueryValue(new Tag(), 'filter', Tag::class, 'deepObject', true, false)
+ );
+ $this->assertSame(
+ [],
+ ObjectSerializer::toQueryValue(new Tag(), 'filter', Tag::class, 'deepObject', true, true)
+ );
+ }
+
+ /**
+ * Objects nested in a plain object are converted as well: a shallow (array) cast would
+ * leave them in place, and building the query would then fail to stringify them.
+ *
+ * @covers ObjectSerializer::toQueryValue
+ */
+ public function testToQueryValueWithAPlainObjectHoldingNestedObjects(): void
+ {
+ $filter = new \stdClass();
+ $filter->name = 'Rex';
+ $filter->tag = new Tag(['id' => 2, 'name' => 'stray']);
+ $filter->bornAt = new \DateTime('2024-01-02T03:04:05Z');
+
+ $query = ObjectSerializer::toQueryValue($filter, 'filter', 'object', 'deepObject', true, false);
+
+ $this->assertSame(
+ 'filter[name]=Rex&filter[tag][id]=2&filter[tag][name]=stray'
+ . '&filter[bornAt]=2024-01-02T03:04:05+00:00',
+ urldecode(ObjectSerializer::buildQuery($query))
+ );
+ }
+
+ /**
+ * Array properties keep their indexes, at every depth.
+ *
+ * @covers ObjectSerializer::toQueryValue
+ */
+ public function testToQueryValueWithArrayPropertiesOfAModelInstance(): void
+ {
+ $model = new ArrayTest([
+ 'array_of_string' => ['a', 'b'],
+ 'array_array_of_integer' => [[1, 2]],
+ ]);
+
+ $query = ObjectSerializer::toQueryValue($model, 'filter', 'object', 'deepObject', true, false);
+
+ $this->assertEquals(
+ 'filter[array_of_string][0]=a&filter[array_of_string][1]=b'
+ . '&filter[array_array_of_integer][0][0]=1&filter[array_array_of_integer][0][1]=2',
+ urldecode(ObjectSerializer::buildQuery($query))
+ );
+ }
+
+ /**
+ * Nested models must be flattened too, so the whole object graph is converted to
+ * arrays first: a shallow cast would leave them as objects and fail to stringify.
+ *
+ * @covers ObjectSerializer::toQueryValue
+ */
+ public function testToQueryValueWithNestedModelInstances(): void
+ {
+ $pet = new Pet([
+ 'id' => 1,
+ 'name' => 'Rex',
+ 'photo_urls' => [],
+ 'category' => new Category(['id' => 7, 'name' => 'Dogs']),
+ 'tags' => [new Tag(['id' => 2, 'name' => 'cute'])],
+ ]);
+
+ $query = ObjectSerializer::toQueryValue($pet, 'filter', 'object', 'deepObject', true, false);
+
+ $this->assertEquals(
+ 'filter[id]=1&filter[category][id]=7&filter[category][name]=Dogs'
+ . '&filter[name]=Rex&filter[tags][0][id]=2&filter[tags][0][name]=cute',
+ urldecode(ObjectSerializer::buildQuery($query))
+ );
+ }
+}