API Platform version(s) affected: 4.3.17 (4.1.30 behaves correctly; observed regression somewhere in 4.2/4.3)
Description
Any property whose type is another class — a single reference, a nullable reference, or a collection of references — loses all property-level metadata in the generated OpenAPI/JSON Schema document:
description (from #[ApiProperty(description: ...)] and from the property docblock)
default / example (from #[ApiProperty] or its openapiContext)
readOnly (from openapiContext)
- validator-derived restrictions, e.g.
#[Assert\Count(max: 1)] → maxItems
Scalar properties are unaffected; only reference-shaped properties lose their metadata.
OpenAPI 3.1 / JSON Schema 2020-12 explicitly allow annotation keywords next to $ref, so this metadata was meaningful in the produced document (docs renderers such as ReDoc display it). readOnly and maxItems are also behavioral, not just prose: code generators consuming the spec now offer read-only fields as writable input and no longer enforce collection limits.
How to reproduce
Self-contained, version-agnostic PHPUnit test (attached; no kernel — real SchemaFactory, real SchemaPropertyMetadataFactory, and api-platform's own attribute + property-info extraction chain, so the #[ApiProperty] attributes on the fixtures are read exactly as in a real app). Run against both versions with nothing changed but the api-platform/core version:
api-platform/core 4.1.30:
OK (5 tests, 8 assertions)
api-platform/core 4.3.17:
1) SchemaFactoryRefAnnotationsTest::testDescriptionIsKeptOnNullableObjectReference
2) SchemaFactoryRefAnnotationsTest::testDescriptionIsKeptOnArrayOfReferences
3) SchemaFactoryRefAnnotationsTest::testOpenapiContextReadOnlyIsKeptOnArrayOfReferences
4) SchemaFactoryRefAnnotationsTest::testCountConstraintRestrictionsAreKeptOnArrayOfReferences
(minItems from Assert\Count(min: 2) is lost)
FAILURES! Tests: 5, Assertions: 7, Failures: 4.
(The sanity test confirming the anyOf/$ref/array structures themselves passes on both versions — only the annotations go missing on 4.3.)
SchemaFactoryRefAnnotationsTest.php (drop anywhere, run with vendor/bin/phpunit path/to/file)
<?php
declare(strict_types=1);
/*
* Standalone reproducer for an api-platform/core bug report
* Property-level annotations (description, readOnly, ...) are dropped from the generated
* JSON Schema for properties that reference another class ($ref / anyOf / array of $ref).
*
* - api-platform/core 4.1.30: PASSES
* - api-platform/core 4.3.17: FAILS
*
* The test is version-agnostic: it uses api-platform's own metadata extraction
* (AttributePropertyMetadataFactory + PropertyInfoPropertyMetadataFactory), so the
* #[ApiProperty] attributes on the fixture classes below are read exactly as in a real app.
*
* Run: vendor/bin/phpunit <path-to-this-file>
*/
namespace App\Tests\Reproducer;
use ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory;
use ApiPlatform\JsonSchema\Schema;
use ApiPlatform\JsonSchema\SchemaFactory;
use ApiPlatform\Metadata\ApiProperty;
use ApiPlatform\Metadata\Property\Factory\AttributePropertyMetadataFactory;
use ApiPlatform\Metadata\Property\Factory\PropertyInfoPropertyMetadataFactory;
use ApiPlatform\Metadata\Property\Factory\PropertyInfoPropertyNameCollectionFactory;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use ApiPlatform\Metadata\ResourceClassResolverInterface;
use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaCountRestriction;
use ApiPlatform\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory;
use Symfony\Component\Validator\Mapping\Loader\AttributeLoader;
use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
class SchemaFactoryRefAnnotationsTest extends TestCase
{
public function testReferenceStructuresAreGenerated(): void
{
[$pickupContact, $items] = $this->buildOrderProperties();
// sanity — passes on every version; only the annotations below go missing
self::assertNotEmpty(
$pickupContact['anyOf'] ?? $pickupContact['$ref'] ?? null,
'nullable object property should reference the Contact schema'
);
self::assertSame('array', $items['type'] ?? null);
self::assertArrayHasKey('$ref', (array) ($items['items'] ?? []));
}
public function testDescriptionIsKeptOnNullableObjectReference(): void
{
[$pickupContact,] = $this->buildOrderProperties();
self::assertSame(
'Pickup contact, required for order placement.',
$pickupContact['description'] ?? null,
);
}
public function testDescriptionIsKeptOnArrayOfReferences(): void
{
[, $items] = $this->buildOrderProperties();
self::assertSame(
'Only one allowed per order.',
$items['description'] ?? null,
);
}
public function testOpenapiContextReadOnlyIsKeptOnArrayOfReferences(): void
{
[, $items] = $this->buildOrderProperties();
self::assertTrue($items['readOnly'] ?? false);
}
public function testCountConstraintRestrictionsAreKeptOnArrayOfReferences(): void
{
[, $items] = $this->buildOrderProperties();
self::assertSame(2, $items['minItems'] ?? null, 'minItems from Assert\\Count(min: 2) is lost');
self::assertSame(5, $items['maxItems'] ?? null, 'maxItems from Assert\\Count(max: 5) is lost');
}
/**
* @return array{0: array, 1: array} the pickupContact and items property schemas of ReproducerOrder
*/
private function buildOrderProperties(): array
{
// OpenAPI flavor — the same call the OpenAPI factory makes when building /docs.json
$schema = $this->buildSchemaFactory()->buildSchema(
ReproducerOrder::class,
'json',
Schema::TYPE_OUTPUT,
null,
new Schema(Schema::VERSION_OPENAPI),
);
$order = $schema->getDefinitions()[$this->definitionNameFor($schema, 'pickupContact')];
return [(array) $order['properties']['pickupContact'], (array) $order['properties']['items']];
}
private function buildSchemaFactory(): SchemaFactory
{
$reflection = new ReflectionExtractor();
$phpDoc = new PhpDocExtractor();
$propertyInfo = new PropertyInfoExtractor(
[$reflection],
[$phpDoc, $reflection],
[$phpDoc],
[$reflection],
[$reflection],
);
$resourceClassResolver = new class implements ResourceClassResolverInterface {
public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string
{
return \is_object($value) ? $value::class : (string) $resourceClass;
}
public function isResourceClass(string $type): bool
{
return false; // plain (non-resource) classes, like embedded DTOs
}
};
$resourceMetadataFactory = new class implements ResourceMetadataCollectionFactoryInterface {
public function create(string $resourceClass): ResourceMetadataCollection
{
return new ResourceMetadataCollection($resourceClass);
}
};
// api-platform's real extraction chain: attributes -> property-info -> validator restrictions -> schema
$propertyMetadataFactory = new SchemaPropertyMetadataFactory(
$resourceClassResolver,
new ValidatorPropertyMetadataFactory(
new LazyLoadingMetadataFactory(new AttributeLoader()),
new PropertyInfoPropertyMetadataFactory(
$propertyInfo,
new AttributePropertyMetadataFactory(),
),
[new PropertySchemaCountRestriction()],
),
);
return new SchemaFactory(
$resourceMetadataFactory,
new PropertyInfoPropertyNameCollectionFactory($propertyInfo),
$propertyMetadataFactory,
null,
$resourceClassResolver,
);
}
private function definitionNameFor(Schema $schema, string $propertyName): string
{
foreach ($schema->getDefinitions() as $name => $definition) {
if (isset($definition['properties'][$propertyName])) {
return $name;
}
}
self::fail(\sprintf('No definition with property "%s" was generated.', $propertyName));
}
}
class ReproducerOrder
{
#[ApiProperty(description: 'Pickup contact, required for order placement.')]
public ?ReproducerContact $pickupContact = null;
/** @var ReproducerItem[] */
#[ApiProperty(description: 'Only one allowed per order.', openapiContext: ['readOnly' => true])]
#[Assert\Count(min: 2, max: 5)]
public array $items = [];
}
class ReproducerContact
{
public string $name;
}
class ReproducerItem
{
public string $sku;
}
#[ApiResource(operations: [new Post()])]
class Order
{
/** Pickup contact, required for order placement. */
#[ApiProperty(description: 'Pickup contact, required for order placement.')]
public ?Contact $pickupContact = null;
/**
* Only one allowed per order.
* @var Item[]
*/
#[Assert\Count(max: 1)]
#[ApiProperty(openapiContext: ['readOnly' => true])]
public array $items = [];
}
class Contact
{
public string $name;
}
class Item
{
public string $sku;
}
Generated Order schema with 4.1.30:
{
"pickupContact": {
"anyOf": [
{ "$ref": "#/components/schemas/Contact" },
{ "type": "null" }
],
"description": "Pickup contact, required for order placement."
},
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Item" },
"description": "Only one allowed per order.",
"maxItems": 1,
"readOnly": true
}
}
Generated Order schema with 4.3.17 (same code):
{
"pickupContact": {
"anyOf": [
{ "$ref": "#/components/schemas/Contact" },
{ "type": "null" }
]
},
"items": {
"type": "array",
"items": { "$ref": "#/components/schemas/Item" }
}
}
description, maxItems and readOnly are gone.
Possible Solution
The metadata is discarded in three spots of ApiPlatform\JsonSchema\SchemaFactory::buildPropertySchema():
- For single/nullable object references, the property schema built so far (which still carries the metadata) is replaced instead of merged:
if (($c = \count($refs)) > 1) {
$propertySchema = ['anyOf' => $refs]; // <-- metadata discarded
} elseif (1 === $c) {
$propertySchema = ['$ref' => $refs[0]['$ref']]; // <-- metadata discarded
}
- For collections, it is lost earlier — a property schema considered "unknown" is reset wholesale before the
items/$ref structure is added:
if (Schema::UNKNOWN_TYPE === $propertySchemaType) {
$propertySchema = []; // <-- metadata discarded
}
- The
readOnly tail condition explicitly skips $ref-shaped schemas (... && !isset($propertySchema['$ref'])).
Suggested fix: preserve the annotation keys when building the reference structure, e.g. merge instead of replace. Sibling annotations next to $ref are valid in JSON Schema 2020-12 / OpenAPI 3.1; if draft-4 output must stay sibling-free, the existing BackwardCompatibleSchemaFactory would be a natural place to strip them for that version only.
I'm happy to turn this into a PR if the merge-instead-of-replace direction is acceptable.
Additional Context
Found while upgrading a production API from 4.1.30 to 4.3.17: a snapshot test on the generated OpenAPI document flagged ~25 property descriptions/defaults/constraints silently disappearing — all of them on properties typed as embedded DTOs. Downstream we currently work around it with a SchemaFactoryInterface decorator that re-applies ApiProperty::getOpenapiContext(), ApiProperty::getSchema(), getDescription(), getDefault() and getExample() onto reference-shaped property schemas (filling missing keys only, never overwriting generated values).
Environment: symfony/* 7.4.16, PHP 8.3, formats json only (no JSON-LD/Hydra).
API Platform version(s) affected: 4.3.17 (4.1.30 behaves correctly; observed regression somewhere in 4.2/4.3)
Description
Any property whose type is another class — a single reference, a nullable reference, or a collection of references — loses all property-level metadata in the generated OpenAPI/JSON Schema document:
description(from#[ApiProperty(description: ...)]and from the property docblock)default/example(from#[ApiProperty]or itsopenapiContext)readOnly(fromopenapiContext)#[Assert\Count(max: 1)]→maxItemsScalar properties are unaffected; only reference-shaped properties lose their metadata.
OpenAPI 3.1 / JSON Schema 2020-12 explicitly allow annotation keywords next to
$ref, so this metadata was meaningful in the produced document (docs renderers such as ReDoc display it).readOnlyandmaxItemsare also behavioral, not just prose: code generators consuming the spec now offer read-only fields as writable input and no longer enforce collection limits.How to reproduce
Self-contained, version-agnostic PHPUnit test (attached; no kernel — real
SchemaFactory, realSchemaPropertyMetadataFactory, and api-platform's own attribute + property-info extraction chain, so the#[ApiProperty]attributes on the fixtures are read exactly as in a real app). Run against both versions with nothing changed but the api-platform/core version:(The sanity test confirming the
anyOf/$ref/array structures themselves passes on both versions — only the annotations go missing on 4.3.)SchemaFactoryRefAnnotationsTest.php (drop anywhere, run with vendor/bin/phpunit path/to/file)
Generated
Orderschema with 4.1.30:{ "pickupContact": { "anyOf": [ { "$ref": "#/components/schemas/Contact" }, { "type": "null" } ], "description": "Pickup contact, required for order placement." }, "items": { "type": "array", "items": { "$ref": "#/components/schemas/Item" }, "description": "Only one allowed per order.", "maxItems": 1, "readOnly": true } }Generated
Orderschema with 4.3.17 (same code):{ "pickupContact": { "anyOf": [ { "$ref": "#/components/schemas/Contact" }, { "type": "null" } ] }, "items": { "type": "array", "items": { "$ref": "#/components/schemas/Item" } } }description,maxItemsandreadOnlyare gone.Possible Solution
The metadata is discarded in three spots of
ApiPlatform\JsonSchema\SchemaFactory::buildPropertySchema():items/$refstructure is added:readOnlytail condition explicitly skips$ref-shaped schemas (... && !isset($propertySchema['$ref'])).Suggested fix: preserve the annotation keys when building the reference structure, e.g. merge instead of replace. Sibling annotations next to
$refare valid in JSON Schema 2020-12 / OpenAPI 3.1; if draft-4 output must stay sibling-free, the existingBackwardCompatibleSchemaFactorywould be a natural place to strip them for that version only.I'm happy to turn this into a PR if the merge-instead-of-replace direction is acceptable.
Additional Context
Found while upgrading a production API from 4.1.30 to 4.3.17: a snapshot test on the generated OpenAPI document flagged ~25 property descriptions/defaults/constraints silently disappearing — all of them on properties typed as embedded DTOs. Downstream we currently work around it with a
SchemaFactoryInterfacedecorator that re-appliesApiProperty::getOpenapiContext(),ApiProperty::getSchema(),getDescription(),getDefault()andgetExample()onto reference-shaped property schemas (filling missing keys only, never overwriting generated values).Environment: symfony/* 7.4.16, PHP 8.3, formats
jsononly (no JSON-LD/Hydra).