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 ?

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

2 participants