pechynho/polymorphic-doctrine

Doctrine polymorphic relations for Symfony applications.

0.0.1 2025-07-16 07:14 UTC

This package is auto-updated.

Last update: 2025-07-16 07:18:03 UTC


README

A Symfony bundle that provides polymorphic relations for Doctrine ORM, allowing you to create flexible associations where a single property can reference different entity types.

License: MIT

Features

  • Two polymorphic modes: Dynamic and Explicit
  • Type-safe polymorphic relations with proper IDE support
  • Foreign key constraints preservation (explicit mode)
  • Query builder integration for searching polymorphic values
  • Automatic reference class generation for explicit mode
  • Caching support for improved performance

Installation

Install the bundle via Composer:

composer require pechynho/polymorphic-doctrine

If you're not using Symfony Flex, add the bundle to your config/bundles.php:

<?php

return [
    // ...
    Pechynho\PolymorphicDoctrine\PechynhoPolymorphicDoctrineBundle::class => ['all' => true],
];

Configuration

The bundle can be configured in your config/packages/pechynho_polymorphic_doctrine.yaml:

pechynho_polymorphic_doctrine:
    # Directory where reference classes are generated (explicit mode)
    references_directory: '%kernel.cache_dir%/pechynho/polymorphic-doctrine/references'

    # Namespace for generated reference classes
    references_namespace: 'Pechynho\PolymorphicDoctrine\AutogeneratedReference'

    # Entity discovery configuration
    discover:
        cache_directory: '%kernel.cache_dir%/pechynho/polymorphic-doctrine/discover'
        directories:
            - '%kernel.project_dir%/src'

Polymorphic Modes

Dynamic Mode

Uses two database columns: one for the entity type and another for the entity ID. This approach is more flexible but doesn't preserve foreign key constraints.

For a dynamic polymorphic property, two columns are created:

  • {property_name}_type - Stores the entity type key
  • {property_name}_id - Stores the entity ID

Example table structure for Payment entity with dynamicSubject property:

Sample data:

id dynamic_subject_type dynamic_subject_id
1 eshop_item 123
2 subscription 456
3 NULL NULL

Explicit Mode

Creates one column for the entity type and separate columns for each possible entity type's ID. This preserves foreign key constraints and provides better database integrity.

For an explicit polymorphic property, multiple columns are created:

  • {property_name}_type - Stores the entity type key
  • {property_name}_{type_key}_id - One column for each mapped entity type

Example table structure for Payment entity with explicitSubject property:

Sample data:

id explicit_subject_type explicit_subject_eshop_item_id explicit_subject_subscription_id
1 eshop_item 123 NULL
2 subscription NULL 456
3 NULL NULL NULL

Foreign key constraints are automatically created for explicit mode columns, ensuring referential integrity.

Usage

1. Define Your Entities

First, create the entities that will be referenced polymorphically:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class EshopItem
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // ... other properties and methods
}
<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Subscription
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    // ... other properties and methods
}

2. Create Entity with Polymorphic Relations

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Pechynho\PolymorphicDoctrine\Attributes\DynamicPolymorphicProperty;
use Pechynho\PolymorphicDoctrine\Attributes\EntityWithPolymorphicRelations;
use Pechynho\PolymorphicDoctrine\Attributes\ExplicitPolymorphicProperty;
use Pechynho\PolymorphicDoctrine\Contract\PolymorphicValueInterface;

#[ORM\Entity]
#[EntityWithPolymorphicRelations]
class Payment
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ExplicitPolymorphicProperty([
        'eshop_item' => EshopItem::class,
        'subscription' => Subscription::class,
    ])]
    public PolymorphicValueInterface $explicitSubject;

    #[DynamicPolymorphicProperty([
        'eshop_item' => EshopItem::class,
        'subscription' => Subscription::class,
    ])]
    public PolymorphicValueInterface $dynamicSubject;
}

3. Generate Reference Classes (Explicit Mode)

If you're using explicit polymorphic properties, you need to generate reference classes for the defined polymorphic properties. Run the following command:

php bin/console pechynho:polymorphic-doctrine:generate-reference-classes

4. Working with Polymorphic Values

This section provides comprehensive examples of how to work with polymorphic values, covering initialization, updates, null handling, and property reading.

<?php

namespace App\Service;

use App\Entity\EshopItem;
use App\Entity\Payment;
use App\Entity\Subscription;
use Doctrine\ORM\EntityManagerInterface;
use Pechynho\PolymorphicDoctrine\Contract\PolymorphicSearchExprApplierFactoryInterface;
use Pechynho\PolymorphicDoctrine\Contract\PolymorphicValueFactoryInterface;

class PaymentService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly PolymorphicValueFactoryInterface $polymorphicValueFactory,
        private readonly PolymorphicSearchExprApplierFactoryInterface $searchExprApplierFactory,
    ) {}

    /**
     * Example 1: Initializing polymorphic properties with values
     */
    public function createPaymentWithValues(): void
    {
        // First, create and persist the entities you want to reference
        $eshopItem = new EshopItem();
        $this->em->persist($eshopItem);

        $subscription = new Subscription();
        $this->em->persist($subscription);
        $this->em->flush();

        // Create payment with polymorphic properties initialized with values
        $payment = new Payment();

        // Initialize with specific entities - third parameter is the entity to reference
        $payment->dynamicSubject = $this->polymorphicValueFactory->create(Payment::class, 'dynamicSubject', $eshopItem);
        $payment->explicitSubject = $this->polymorphicValueFactory->create(Payment::class, 'explicitSubject', $subscription);

        $this->em->persist($payment);
        $this->em->flush();
    }

    /**
     * Example 2: Initializing polymorphic properties with null values
     */
    public function createPaymentWithNullValues(): void
    {
        $payment = new Payment();

        // Initialize with null values - omit the third parameter or pass null explicitly
        $payment->dynamicSubject = $this->polymorphicValueFactory->create(Payment::class, 'dynamicSubject');
        $payment->explicitSubject = $this->polymorphicValueFactory->create(Payment::class, 'explicitSubject', null);

        $this->em->persist($payment);
        $this->em->flush();
    }

    /**
     * Example 3: Updating polymorphic values
     */
    public function updatePolymorphicValues(Payment $payment): void
    {
        // Create new entities to update to
        $newEshopItem = new EshopItem();
        $this->em->persist($newEshopItem);

        $newSubscription = new Subscription();
        $this->em->persist($newSubscription);
        $this->em->flush();

        // Update polymorphic values using the update() method
        $payment->dynamicSubject->update($newSubscription);
        $payment->explicitSubject->update($newEshopItem);

        // You can also update to null
        // $payment->dynamicSubject->update(null);

        $this->em->persist($payment);
        $this->em->flush();
    }

    /**
     * Example 4: Setting polymorphic values to null
     */
    public function setPolymorphicValuesToNull(Payment $payment): void
    {
        // Method 1: Using setNull() method (recommended)
        $payment->dynamicSubject->setNull();
        $payment->explicitSubject->setNull();

        // Method 2: Using update() with null
        // $payment->dynamicSubject->update(null);
        // $payment->explicitSubject->update(null);

        $this->em->persist($payment);
        $this->em->flush();
    }

    /**
     * Example 5: Reading polymorphic properties and checking their status
     */
    public function readPolymorphicProperties(Payment $payment): void
    {
        // Check if the polymorphic value is null
        if ($payment->dynamicSubject->isNull()) {
            echo "Dynamic subject is null\n";
            return;
        }

        // Check if the value can be resolved (has valid type and ID)
        if (!$payment->dynamicSubject->isResolvable()) {
            echo "Dynamic subject cannot be resolved (invalid type or ID)\n";
            return;
        }

        // Check if the entity is already loaded in memory
        if ($payment->dynamicSubject->isLoaded()) {
            echo "Dynamic subject is already loaded\n";
        } else {
            echo "Dynamic subject will be loaded from database when accessed\n";
        }

        // Get the actual entity value (this will load it from database if not already loaded)
        $value = $payment->dynamicSubject->getValue();

        // Type-check and handle different entity types
        if ($value instanceof EshopItem) {
            echo "Dynamic subject is an EshopItem with ID: " . $value->getId() . "\n";
            // Handle eshop item specific logic
        } elseif ($value instanceof Subscription) {
            echo "Dynamic subject is a Subscription with ID: " . $value->getId() . "\n";
            // Handle subscription specific logic
        }

        // You can also work with explicit polymorphic properties the same way
        if (!$payment->explicitSubject->isNull()) {
            $explicitValue = $payment->explicitSubject->getValue();

            switch (true) {
                case $explicitValue instanceof EshopItem:
                    echo "Explicit subject is an EshopItem\n";
                    break;
                case $explicitValue instanceof Subscription:
                    echo "Explicit subject is a Subscription\n";
                    break;
                default:
                    echo "Explicit subject is of unknown type\n";
            }
        }
    }

    /**
     * Example 6: Complete workflow - create, update, read, and nullify
     */
    public function completeWorkflow(): void
    {
        // Step 1: Create entities
        $eshopItem = new EshopItem();
        $subscription = new Subscription();
        $this->em->persist($eshopItem);
        $this->em->persist($subscription);
        $this->em->flush();

        // Step 2: Create payment with initial values
        $payment = new Payment();
        $payment->dynamicSubject = $this->polymorphicValueFactory->create(Payment::class, 'dynamicSubject', $eshopItem);
        $payment->explicitSubject = $this->polymorphicValueFactory->create(Payment::class, 'explicitSubject', $subscription);
        $this->em->persist($payment);
        $this->em->flush();

        // Step 3: Read and verify initial values
        echo "Initial dynamic subject: " . get_class($payment->dynamicSubject->getValue()) . "\n";
        echo "Initial explicit subject: " . get_class($payment->explicitSubject->getValue()) . "\n";

        // Step 4: Update values
        $payment->dynamicSubject->update($subscription);
        $payment->explicitSubject->update($eshopItem);
        $this->em->persist($payment);
        $this->em->flush();

        // Step 5: Read updated values
        echo "Updated dynamic subject: " . get_class($payment->dynamicSubject->getValue()) . "\n";
        echo "Updated explicit subject: " . get_class($payment->explicitSubject->getValue()) . "\n";

        // Step 6: Set to null
        $payment->dynamicSubject->setNull();
        $payment->explicitSubject->setNull();
        $this->em->persist($payment);
        $this->em->flush();

        // Step 7: Verify null state
        echo "Dynamic subject is null: " . ($payment->dynamicSubject->isNull() ? 'true' : 'false') . "\n";
        echo "Explicit subject is null: " . ($payment->explicitSubject->isNull() ? 'true' : 'false') . "\n";
    }
}

Key Points for Working with Polymorphic Values:

  1. Initialization with values: Use $this->polymorphicValueFactory->create(EntityClass::class, 'propertyName', $entity) where the third parameter is the entity to reference.

  2. Initialization with null: Use $this->polymorphicValueFactory->create(EntityClass::class, 'propertyName') or pass null as the third parameter.

  3. Updating values: Use the update($entity) method on the polymorphic value object. You can pass any entity that matches the configured mapping or null.

  4. Setting to null: Use the setNull() method (recommended) or update(null).

  5. Reading values: Always check isNull() first, then optionally check isResolvable() and isLoaded() before calling getValue().

  6. Status methods:

    • isNull(): Returns true if the polymorphic value is null
    • isResolvable(): Returns true if the value has valid type and ID that can be resolved to an entity
    • isLoaded(): Returns true if the entity is already loaded in memory (avoids database query)
    • getValue(): Returns the actual entity object (loads from database if needed)
  7. Persistence: Always call $this->em->persist($entity) and $this->em->flush() after modifying polymorphic values to save changes to the database.

API Reference

Attributes

#[EntityWithPolymorphicRelations]

Mark an entity class to indicate it contains polymorphic relations. This attribute is required for entity discovery.

#[DynamicPolymorphicProperty(array $mapping)]

Defines a dynamic polymorphic property that uses two database columns (type and ID).

Parameters:

  • $mapping - Array mapping type keys to entity class names
  • $iddProperty - Custom ID property name (optional)
  • $enableDiscriminatorIndex - Enable index on discriminator column (optional)
  • $enablePairIndex - Enable index on type+ID pair (optional)

Example:

#[DynamicPolymorphicProperty([
    'product' => Product::class,
    'service' => Service::class,
])]
public PolymorphicValueInterface $subject;

#[ExplicitPolymorphicProperty(array $mapping)]

Defines an explicit polymorphic property that creates separate columns for each entity type, preserving foreign key constraints.

Parameters:

  • $mapping - Array mapping type keys to entity class names or detailed configuration
  • $idProperty - Default ID property name (optional)
  • $idPropertyType - Default ID property type (optional)
  • $onDelete - Foreign key ON DELETE action (optional)
  • $onUpdate - Foreign key ON UPDATE action (optional)
  • $enableDiscriminatorIndex - Enable index on discriminator column (optional)
  • $enablePairIndex - Enable index on type+ID pair (optional)

Example:

#[ExplicitPolymorphicProperty([
    'product' => Product::class,
    'service' => [
        'fqcn' => Service::class,
        'idProperty' => 'serviceId',
        'onDelete' => 'CASCADE',
    ],
])]
public PolymorphicValueInterface $subject;

PolymorphicValueInterface

The main interface for working with polymorphic values.

Methods

  • isNull(): bool - Check if the polymorphic value is null
  • isResolvable(): bool - Check if the value can be resolved to an entity
  • isLoaded(): bool - Check if the entity is already loaded
  • setNull(): void - Set the polymorphic value to null
  • update(?object $value): void - Update the polymorphic value with a new entity
  • getValue(): ?object - Get the actual entity object

Services

PolymorphicValueFactoryInterface

Factory for creating polymorphic value instances.

public function create(string $fqcn, string $property, ?object $value = null): PolymorphicValueInterface

PolymorphicSearchExprApplierFactoryInterface

Factory for creating search expression appliers for querying polymorphic values.

$applier = $this->searchExprApplierFactory->create(Entity::class, 'propertyName', 'alias');
$applier->eq($queryBuilder, $entity); // Add WHERE condition

Console Commands

pechynho:polymorphic-doctrine:cache-clear

Clears the cache for polymorphic relations.

php bin/console pechynho:polymorphic-doctrine:cache-clear

This command clears both the polymorphic locator cache and generated reference classes cache.

pechynho:polymorphic-doctrine:generate-reference-classes

Generates reference classes for explicit polymorphic properties.

php bin/console pechynho:polymorphic-doctrine:generate-reference-classes

Important: This command must be run after each change to polymorphic property definitions when using explicit mode. The generated classes are required for proper foreign key constraint handling.

Searching Polymorphic Values

The bundle provides powerful search capabilities for polymorphic values through two main interfaces: builders and appliers. Both support the same search operations but differ in how they're used.

Search Operations

All search interfaces support these operations:

  • eq(entity) - Find records where polymorphic value equals the given entity
  • neq(entity) - Find records where polymorphic value does not equal the given entity
  • isNull() - Find records where polymorphic value is null
  • isNotNull() - Find records where polymorphic value is not null
  • isInstanceOf(class...) - Find records where polymorphic value is instance of given class(es)
  • isNotInstanceOf(class...) - Find records where polymorphic value is not instance of given class(es)
  • in(entities...) - Find records where polymorphic value is one of the given entities
  • notIn(entities...) - Find records where polymorphic value is not one of the given entities

Search Expression Builders

PolymorphicSearchExprBuilderInterface returns expression objects and parameters that you can use to manually build complex queries.

Usage Example

use Pechynho\PolymorphicDoctrine\Contract\PolymorphicSearchExprBuilderFactoryInterface;

class PaymentService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly PolymorphicSearchExprBuilderFactoryInterface $builderFactory,
    ) {}

    public function findComplexPayments(EshopItem $item, Subscription $subscription): array
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')->from(Payment::class, 'p');

        // Create builder for the polymorphic property
        $builder = $this->builderFactory->create(Payment::class, 'dynamicSubject', 'p');

        // Build complex conditions
        $eqResult = $builder->eq($item);
        $instanceOfResult = $builder->isInstanceOf(EshopItem::class, Subscription::class);
        $inResult = $builder->in($item, $subscription);

        // Combine expressions manually
        $qb->where($qb->expr()->orX(
            $eqResult->expr,
            $instanceOfResult->expr,
            $inResult->expr
        ));

        // Set parameters
        foreach ([$eqResult, $instanceOfResult, $inResult] as $result) {
            foreach ($result->params as $key => $value) {
                $qb->setParameter($key, $value);
            }
        }

        return $qb->getQuery()->getResult();
    }
}

Search Expression Appliers

PolymorphicSearchExprApplierInterface directly modifies the QueryBuilder, making it easier to use for simple queries.

Usage Example

use Pechynho\PolymorphicDoctrine\Contract\PolymorphicSearchExprApplierFactoryInterface;

class PaymentService
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly PolymorphicSearchExprApplierFactoryInterface $applierFactory,
    ) {}

    public function findPaymentsByEntity(object $entity): array
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')->from(Payment::class, 'p');

        // Create applier and directly apply condition
        $applier = $this->applierFactory->create(Payment::class, 'dynamicSubject', 'p');
        $applier->eq($qb, $entity);

        return $qb->getQuery()->getResult();
    }

    public function findPaymentsByType(string $entityClass): array
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')->from(Payment::class, 'p');

        $applier = $this->applierFactory->create(Payment::class, 'explicitSubject', 'p');
        $applier->isInstanceOf($qb, $entityClass);

        return $qb->getQuery()->getResult();
    }

    public function findNullPayments(): array
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')->from(Payment::class, 'p');

        $applier = $this->applierFactory->create(Payment::class, 'dynamicSubject', 'p');
        $applier->isNull($qb);

        return $qb->getQuery()->getResult();
    }

    public function findPaymentsExcluding(object ...$entities): array
    {
        $qb = $this->em->createQueryBuilder();
        $qb->select('p')->from(Payment::class, 'p');

        $applier = $this->applierFactory->create(Payment::class, 'dynamicSubject', 'p');
        $applier->notIn($qb, ...$entities);

        return $qb->getQuery()->getResult();
    }
}

Best Practices

  1. Use explicit mode when you need foreign key constraints and database integrity
  2. Use dynamic mode when you need maximum flexibility and don't require foreign key constraints
  3. Always run the generate-reference-classes command after modifying explicit polymorphic properties
  4. Clear cache after making configuration changes
  5. Use meaningful type keys in your mapping arrays for better readability

Troubleshooting

Common Issues

Reference classes not found (explicit mode)

  • Run php bin/console pechynho:polymorphic-doctrine:generate-reference-classes
  • Clear cache with php bin/console cache:clear

Polymorphic properties not working

  • Ensure the entity is marked with #[EntityWithPolymorphicRelations]
  • Check that the bundle is properly registered
  • Verify your entity discovery configuration

Performance issues

  • Enable appropriate database indexes using the enableDiscriminatorIndex and enablePairIndex options
  • Consider using explicit mode for better query performance with foreign key constraints

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

This bundle is released under the MIT License. See the LICENSE file for details.

Author