镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

feat(serializer): support resource polymorphism through DiscriminatorMap - #8620

Open
dylan-rumble wants to merge 2 commits into
api-platform:mainfrom
dylan-rumble:feat/discriminator-map-polymorphism
Open

dylan-rumble wants to merge 2 commits into
api-platform:mainfrom
dylan-rumble:feat/discriminator-map-polymorphism

Conversation

@dylan-rumble

@dylan-rumble dylan-rumble commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Q A
Branch? main
Tickets Closes #2931, supersedes #7815
License MIT
Doc PR api-platform/docs#...

A resource can now declare its subtypes with Symfony's #[DiscriminatorMap]. This works for every operation (GET, GetCollection, POST, PUT, PATCH, DELETE), in JSON-LD, JSON, JSON:API and HAL, and in the generated JSON Schema / OpenAPI.

The map is the opt-in. #2931 was closed because exposing subclass properties leaks data. Here, only classes listed in the map are exposed. An unmapped subclass is still serialized as its closest mapped parent, and resources without a map behave exactly as before.

#[ApiResource(operations: [new Get(), new GetCollection(), new Post(), new Put(), new Patch(), new Delete()])]
#[DiscriminatorMap(typeProperty: 'kind', mapping: [
    'fiction' => DiscriminatedFictionBook::class,
    'technical' => DiscriminatedTechnicalBook::class,
])]
abstract class DiscriminatedBook { /* ... */ }
// tests/Functional/DiscriminatedResourceTest.php
public function testPostCreatesTheMappedSubtype(): void
{
    $response = self::createClient()->request('POST', '/discriminated_books', [
        'headers' => ['Content-Type' => 'application/ld+json'],
        'json' => [
            'kind' => 'technical',
            'title' => 'Refactoring',
            'programmingLanguage' => 'Java',
            'internalNote' => 'cannot be written anonymously', // subtype property with `security`
        ],
    ]);

    $this->assertResponseStatusCodeSame(201);
    $this->assertEquals([
        '@context' => '/contexts/DiscriminatedBook',
        '@id' => '/discriminated_books/4',
        '@type' => 'DiscriminatedBook',
        'kind' => 'technical',
        'id' => 4,
        'title' => 'Refactoring',
        'programmingLanguage' => 'Java',
    ], $response->toArray());
    $this->assertInstanceOf(DiscriminatedTechnicalBook::class, DiscriminatedBookStore::$books[4]);
    $this->assertNull(DiscriminatedBookStore::$books[4]->internalNote);
}

public function testPatchCannotChangeTheType(): void
{
    self::createClient()->request('PATCH', '/discriminated_books/1', [
        'headers' => ['Content-Type' => 'application/merge-patch+json'],
        'json' => ['kind' => 'technical', 'programmingLanguage' => 'Rust'],
    ]);

    $this->assertResponseStatusCodeSame(400);
    $this->assertInstanceOf(DiscriminatedFictionBook::class, DiscriminatedBookStore::$books[1]);
}

Tests

File Covers
DiscriminatedResourceTest All operations and formats, subtype security, unmapped subclasses, resources without a map, attribute cache across users, OpenAPI
DiscriminatedResourceOverrideTest Overridden ApiProperty and constraints, narrowed relations, covariant getters
DiscriminatedNestedResourceTest A subtype with its own map (kind + breed), allow_extra_attributes: false, schema validated against real payloads
DiscriminatedSelfMappedResourceTest A class listing itself in its own map

| DoctrineDiscriminatedResourceTest | Subtypes from ORM\DiscriminatorMap only: all operations, JSON:API/HAL, subtype security, a non-resource subtype, POST of a grandchild through a mid-hierarchy resource, JOINED with integer keys, serializer map precedence, OpenAPI |
| DoctrineDiscriminatedDocumentTest | The same for MongoDB ODM (runs in the MongoDB job) |

Unit tests: DoctrineDiscriminatorMappingLoaderTest, SerializerMappingLoaderPassTest, extension/configuration tests for the flag, ClassDiscriminatorHelperTest (including a cyclic map), and SchemaFactoryTest in JsonSchema and JsonApi.

How it works

  • AbstractItemNormalizer
    • The allowed attributes and metadata come from the mapped subtype, and the type property is added to the output.
    • Nested maps resolve to the most specific class.
    • A PATCH that changes the type returns a 400.
    • isCacheKeySafe() also checks the subtypes' properties.
  • JsonSchema\SchemaFactory / JsonApi\JsonSchema\SchemaFactory
    • Each subtype gets its own definition, plus oneOf and an OpenAPI discriminator on the base. Subtypes never reference the base, so there are no circular refs.
    • Each subtype definition keeps its own additionalProperties.
  • ClassDiscriminatorHelper (@internal) is the single walker over the maps. It only goes down to strict subclasses, so it always terminates.
  • Constructors of both schema factories take a new optional last argument, ?ClassDiscriminatorResolverInterface. It is wired for Symfony and Laravel.

Doctrine discriminator maps (second commit)

Doctrine inheritance already declares the subtypes, so declaring them again for the serializer shouldn't be required. DoctrineDiscriminatorMappingLoader is a serializer mapping loader that exposes the ORM/ODM discriminator map as a serializer ClassDiscriminatorMapping:

#[ApiResource]
#[ORM\Entity]
#[ORM\InheritanceType('SINGLE_TABLE')]
#[ORM\DiscriminatorColumn(name: 'kind')]
#[ORM\DiscriminatorMap(['car' => Car::class, 'sportsCar' => SportsCar::class, 'truck' => Truck::class])]
abstract class Vehicle { /* ... */ }
  • Only API resource hierarchies are affected; plain entities serialized elsewhere are left alone.
  • An explicit serializer #[DiscriminatorMap] anywhere in the hierarchy wins, so you can still expose a subset.
  • Every non-leaf class gets a map of itself and its subclasses, so a mid-hierarchy resource (POST /cars creating a SportsCar) resolves too.
  • The type property is the discriminator column (ORM) or field (ODM); integer values are exposed as strings.
  • The loader is appended to serializer.mapping.chain_loader and the cache warmer by SerializerMappingLoaderPass.
  • On by default, can be disabled:
api_platform:
    doctrine:
        discriminator_map: false
    doctrine_mongodb_odm:
        discriminator_map: false

DoctrineDiscriminatorSerializerPropertyMetadataFactory (#8283) is untouched; part of it may become redundant, which could be a follow-up.

Behavior changes for existing #[DiscriminatorMap] resources

No public API breaks. Resources that already carry a serializer map will see:

  • Subtype properties and the type property in responses (previously trimmed to the base class).
  • Subtype properties accepted on write.
  • oneOf and discriminator in schemas, and no additionalProperties on the base definition.
  • JSON:API relations typed with a subtype resolving to their resource.
  • IRIs to discriminated abstract relations no longer throwing.

Behavior changes for existing Doctrine inheritance resources

With discriminator_map enabled (the default):

  • STI/JOINED resources expose the discriminator property and mapped subclass properties, and accept them on write. POST to a parent resource requires the discriminator unless the class itself is concrete and mapped.
  • Doctrine subclasses that are not #[ApiResource] are serialized as their own subtype instead of as the parent. TableInheritanceTest::testNonApiResourceChildAppearsAsParent was updated accordingly.
  • Set the flag to false, or declare a serializer #[DiscriminatorMap] subset, to keep the previous output.

If you would rather put the serializer map support behind a configuration flag too, every touch point is listed above and I'm happy to add it.

@dylan-rumble
dylan-rumble changed the base branch from main to 5.0 October 1, 2026 12:38
@dylan-rumble
dylan-rumble force-pushed the feat/discriminator-map-polymorphism branch from 5488d58 to 2e7aa3c Compare October 1, 2026 12:41
@dylan-rumble
dylan-rumble changed the base branch from 5.0 to main October 1, 2026 12:41
@dylan-rumble dylan-rumble changed the title Feat/discriminator map polymorphism feat(serializer): support resource polymorphism through DiscriminatorMap Oct 1, 2026
@dylan-rumble
dylan-rumble force-pushed the feat/discriminator-map-polymorphism branch from 2e7aa3c to 15ae810 Compare October 1, 2026 12:43
@dylan-rumble
dylan-rumble marked this pull request as ready for review October 1, 2026 12:48
@dylan-rumble
dylan-rumble marked this pull request as draft October 1, 2026 13:15
@dylan-rumble

Copy link
Copy Markdown
Contributor Author

I just realised the code as is will not read the ORM\DiscriminatorMap and only respect Symfony\Component\Serializer\Attribute\DiscriminatorMap. Its a bit awkward to double declare this, looking into if I can simplify it.

…tor maps

Expose the ORM and MongoDB ODM inheritance discriminator map of an API
resource hierarchy as a serializer discriminator map, so subtypes are read,
written and documented without declaring them twice.

An explicit serializer DiscriminatorMap in the hierarchy takes precedence.
Every non-leaf class gets a map of itself and its subclasses, so resources
declared mid-hierarchy resolve their own subtypes.

Enabled by default; disable with api_platform.doctrine.discriminator_map
or api_platform.doctrine_mongodb_odm.discriminator_map.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dylan-rumble
dylan-rumble force-pushed the feat/discriminator-map-polymorphism branch from 2634e95 to 29635ac Compare October 1, 2026 15:11
@dylan-rumble
dylan-rumble marked this pull request as ready for review October 1, 2026 15:15
@dylan-rumble

dylan-rumble commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor Author

@soyuka without going in depth into the code in the PR, what is your opinion on this feature? it would allow both reads and writes to entities using a discriminator column with any sort of InheritanceType. For custom database types that map json to objects for example, the Symfony DiscriminatorMap can be used.

Right now we have to make a seperate api resource for every single subclass in our code that uses doctrine inheritance.

@soyuka

soyuka commented Oct 2, 2026

Copy link
Copy Markdown
Member

Yeah its quite interesting I haven't yet looked at the code @Maxcastel has something going on at #7815 I think that with both pr we should be able to get some quite nice new feature.

Not sure how to go forward, I can read the code if you think its close to be mergeable, let me know. Can you also run an agent to check if #7815 could maybe be adapted ?

@dylan-rumble

Copy link
Copy Markdown
Contributor Author

I think as is its fairly close to mergable, but I won't have time for a while to spend time into looking into the tests. I will get around to it eventually. I did review #7815 when creating this one, it didn't account for a lot of things. I no longer have the comprehensive list anymore.

I'll let you know when I finish the PR.

@philpichet

Copy link
Copy Markdown

Hi @dylan-rumble, thanks for this PR! I tested it on a small app (Symfony 7.4, PHP 8.4, SQLite) with a Doctrine-only setup, and wanted to share some feedback.

Test case: JOINED inheritance, Doctrine discriminator map only

#[ORM\Entity]
#[ORM\InheritanceType('JOINED')]
#[ORM\DiscriminatorColumn(name: 'type', type: 'string')]
#[ORM\DiscriminatorMap(['car' => Car::class, 'bike' => Bike::class])]
#[ApiResource]
abstract class Vehicle
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    public ?int $id = null;

    #[ORM\Column]
    public string $brand = '';
}

#[ORM\Entity]
class Car extends Vehicle
{
    #[ORM\Column]
    public int $doors = 4;
}

#[ORM\Entity]
class Bike extends Vehicle
{
    #[ORM\Column]
    public bool $electric = false;
}

No Symfony #[DiscriminatorMap] and no #[ApiResource] on the subclasses. Everything works:

  • GET /vehicles returns {"type":"car","doors":5,...} and {"type":"bike","electric":true,...}; on 4.4 only id and brand were returned
  • POST /vehicles with {"type":"bike",...} creates a Bike
  • PATCH changing type is rejected with a 400
  • OpenAPI documents oneOf + discriminator with a Vehicle.Car / Vehicle.Bike schema per format

The related tests (Discriminated*Test, DoctrineDiscriminatedResourceTest, TableInheritanceTest, the SchemaFactoryTests) pass locally, except DoctrineDiscriminatorMappingLoaderTest::testMongoDbOdmDiscriminatorField, which only failed because the ODM isn't installed in my environment.

Suggestion: only oneOf on the parent schema

The parent schema currently keeps the common properties next to oneOf:

"Vehicle": {
  "type": "object",
  "properties": { "id": {...}, "brand": {...} },
  "required": ["brand"],
  "oneOf": [{ "$ref": "#/components/schemas/Vehicle.Car" }, { "$ref": "#/components/schemas/Vehicle.Bike" }],
  "discriminator": {...}
}

Each branch already contains all of its properties (Vehicle.Bike = id, brand, electric, type), so Swagger UI shows id and brand twice: once at the top level and again in each oneOf branch. Keeping only oneOf + discriminator on the parent makes it much easier to read, and each branch stays a complete description of one subtype:

+            // Every branch carries the common properties: they are not repeated next to "oneOf"
             // "additionalProperties" ignores the properties declared in the "oneOf" branches, they are restricted by the branches instead
-            unset($definition['additionalProperties']);
+            unset($definition['properties'], $definition['required'], $definition['additionalProperties']);
         }

         // An instance of the class itself carries its own type
-        if ($ownTypeValues && !isset($definition['properties'][$typeProperty])) {
-            if ($oneOf) {
-                $properties = $definition['properties'] ?? [];
-                $properties[$typeProperty] = ['type' => 'string', 'enum' => array_map('strval', array_keys($discriminatorMapping))];
-                $definition['properties'] = $properties;
-            } else {
-                $this->addTypeProperties($definition, [$typeProperty => ['type' => 'string', 'enum' => $ownTypeValues]], $isPartialUpdate || \in_array($mapping->getDefaultType(), $ownTypeValues, true));
-            }
+        if ($ownTypeValues && !$oneOf && !isset($definition['properties'][$typeProperty])) {
+            $this->addTypeProperties($definition, [$typeProperty => ['type' => 'string', 'enum' => $ownTypeValues]], $isPartialUpdate || \in_array($mapping->getDefaultType(), $ownTypeValues, true));
         }

A concrete parent declared in its own map is still covered by the branch you already add in front of oneOf. With this change, 5 schema assertions read properties on the parent and would need to read the branches instead (SchemaFactoryTest ×2, DiscriminatedResourceTest::testOpenApiDocumentsSubtypes, DiscriminatedResourceOverrideTest ×2); the serialization tests are unaffected. Of course, it's your call.

Question: discriminator_map enabled by default

The description says the map is the opt-in and resources without one behave as before. But with doctrine.discriminator_map / doctrine_mongodb_odm.discriminator_map defaulting to true, an existing resource with Doctrine inheritance (like the one above) starts exposing its subclass properties after the upgrade, which is the data-leak concern that closed #2931. Would defaulting to false (or at least an UPGRADE note) make sense?

Help

The branch is currently 106 commits behind main. If it helps, I'd be happy to take care of the rebase and/or the test updates and open a PR against your branch, so you stay in control of what gets in. Just let me know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Entity inheritance normalises based on parent class

3 participants