A Symfony bundle that brings polymorphic relations to Doctrine ORM. It allows a single entity property to reference different entity types — with type safety, IDE support, and optional foreign key constraints.
Doctrine ORM doesn't natively support a property that needs to reference multiple unrelated entity types. Imagine a Payment entity with a subject property that can point to either an EshopItem or a Subscription. Standard Doctrine associations require a fixed target type.
This bundle solves it with two approaches:
| Mode | DB Columns | Foreign Keys | Best For |
|---|---|---|---|
| Dynamic | {property}_type + {property}_id |
No | Flexible scenarios where FK constraints aren't needed |
| Explicit | {property}_type + one ID column per mapped type |
Yes | Scenarios requiring referential integrity |
composer require pechynho/polymorphic-doctrineWithout Symfony Flex, add the bundle to config/bundles.php:
return [
// ...
Pechynho\PolymorphicDoctrine\PechynhoPolymorphicDoctrineBundle::class => ['all' => true],
];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;
#[DynamicPolymorphicProperty([
'eshop_item' => EshopItem::class,
'subscription' => Subscription::class,
])]
public PolymorphicValueInterface $dynamicSubject;
#[ExplicitPolymorphicProperty([
'eshop_item' => EshopItem::class,
'subscription' => Subscription::class,
])]
public PolymorphicValueInterface $explicitSubject;
}php bin/console pechynho:polymorphic-doctrine:generate-reference-classesclass PaymentService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PolymorphicValueFactoryInterface $polymorphicValueFactory,
) {}
public function createPayment(EshopItem $item): void
{
$payment = new Payment();
// Initialize with a value
$payment->dynamicSubject = $this->polymorphicValueFactory->create(
Payment::class, 'dynamicSubject', $item
);
// Initialize as null
$payment->explicitSubject = $this->polymorphicValueFactory->create(
Payment::class, 'explicitSubject'
);
$this->em->persist($payment);
$this->em->flush();
}
}Creates two columns per polymorphic property:
| id | dynamic_subject_type | dynamic_subject_id |
|---|---|---|
| 1 | eshop_item | 123 |
| 2 | subscription | 456 |
| 3 | NULL | NULL |
Pros: Simple setup, no class generation needed. Cons: No foreign keys — the database cannot enforce referential integrity.
Creates a type column plus a dedicated ID column for each mapped entity type:
| 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 |
Pros: Foreign key on each ID column, referential integrity enforced by the database. Cons: Requires reference class generation, more columns per table.
// With a value
$payment->subject = $this->polymorphicValueFactory->create(Payment::class, 'subject', $entity);
// As null
$payment->subject = $this->polymorphicValueFactory->create(Payment::class, 'subject');// Set a new value
$payment->subject->update($newEntity);
// Set to null
$payment->subject->setNull();
// or: $payment->subject->update(null);// Check status
$payment->subject->isNull(); // Is the value null?
$payment->subject->isResolvable(); // Does it have a valid type and ID?
$payment->subject->isLoaded(); // Is the entity already loaded in memory?
// Get the entity (lazy-loaded from DB on first access)
$entity = $payment->subject->getValue();
if ($entity instanceof EshopItem) {
// ...
} elseif ($entity instanceof Subscription) {
// ...
}Tip: Always check
isNull()before callinggetValue().
The getValueAs() method provides a convenient way to retrieve the referenced entity with a narrowed return type. It accepts a class-string<T> and returns T, giving you full IDE autocompletion and static analysis support (PHPStan / Psalm).
// Instead of manual instanceof checks:
$item = $payment->subject->getValueAs(EshopItem::class);
// $item is now typed as EshopItem — full IDE support, no manual narrowing neededThe method throws a ReferenceResolutionException if:
- The value is
null— always checkisNull()first, or handle the exception. - The resolved entity is not an instance of the requested class.
The bundle provides two approaches for querying polymorphic properties via QueryBuilder.
Directly modifies the QueryBuilder. Best for straightforward queries.
class PaymentService
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly PolymorphicSearchExprApplierFactoryInterface $applierFactory,
) {}
public function findByEntity(object $entity): array
{
$qb = $this->em->createQueryBuilder()
->select('p')->from(Payment::class, 'p');
$applier = $this->applierFactory->create(Payment::class, 'dynamicSubject', 'p');
$applier->eq($qb, $entity);
return $qb->getQuery()->getResult();
}
public function findByType(string $entityClass): array
{
$qb = $this->em->createQueryBuilder()
->select('p')->from(Payment::class, 'p');
$applier = $this->applierFactory->create(Payment::class, 'explicitSubject', 'p');
$applier->isInstanceOf($qb, $entityClass);
return $qb->getQuery()->getResult();
}
}Returns expression objects with parameters that can be composed into complex queries.
$builder = $this->builderFactory->create(Payment::class, 'dynamicSubject', 'p');
$eqResult = $builder->eq($item);
$instanceOfResult = $builder->isInstanceOf(EshopItem::class);
$qb->where($qb->expr()->orX($eqResult->expr, $instanceOfResult->expr));
foreach ([$eqResult, $instanceOfResult] as $result) {
foreach ($result->params as $key => $value) {
$qb->setParameter($key, $value);
}
}| Method | Description |
|---|---|
eq($entity) |
Matches the given entity |
neq($entity) |
Does not match the given entity |
in(...$entities) |
Matches any of the given entities |
notIn(...$entities) |
Does not match any of the given entities |
isNull() |
Value is null |
isNotNull() |
Value is not null |
isInstanceOf(...$classes) |
Is an instance of the given type(s) |
isNotInstanceOf(...$classes) |
Is not an instance of the given type(s) |
# config/packages/pechynho_polymorphic_doctrine.yaml
pechynho_polymorphic_doctrine:
# Directory for generated reference classes (explicit mode)
references_directory: '%kernel.cache_dir%/pechynho/polymorphic-doctrine/references'
# Namespace for generated classes
references_namespace: 'Pechynho\PolymorphicDoctrine\AutogeneratedReference'
# Entity discovery settings
discover:
cache_directory: '%kernel.cache_dir%/pechynho/polymorphic-doctrine/discover'
directories:
- '%kernel.project_dir%/src'Marks an entity as containing polymorphic relations. Required for entity discovery.
Defines a dynamic polymorphic property (two columns: type + ID).
| Parameter | Type | Description |
|---|---|---|
$mapping |
array |
Map of type keys to entity class names |
$idProperty |
?string |
Custom ID property name |
$enableDiscriminatorIndex |
?bool |
Add index on discriminator column |
$enablePairIndex |
?bool |
Add index on type + ID pair |
Defines an explicit polymorphic property (type column + dedicated ID column per mapped type).
| Parameter | Type | Description |
|---|---|---|
$mapping |
array |
Map of type keys to entity class names or detailed config |
$idProperty |
?string |
Default ID property name |
$idPropertyType |
?string |
Default ID property type |
$onDelete |
?string |
Foreign key ON DELETE action |
$onUpdate |
?string |
Foreign key ON UPDATE action |
$enableDiscriminatorIndex |
?bool |
Add index on discriminator column |
$enablePairIndex |
?bool |
Add index on type + ID pair |
Per-type detailed configuration in the mapping:
#[ExplicitPolymorphicProperty([
'product' => Product::class,
'service' => [
'fqcn' => Service::class,
'idProperty' => 'serviceId',
'onDelete' => 'CASCADE',
],
])]
public PolymorphicValueInterface $subject;| Method | Return Type | Description |
|---|---|---|
isNull() |
bool |
Is the value null? |
isResolvable() |
bool |
Can the value be resolved to an entity? |
isLoaded() |
bool |
Is the entity already loaded in memory? |
getValue() |
?object |
Returns the referenced entity (lazy-loaded) |
getValueAs(string $fqcn) |
T |
Returns the entity narrowed to the given type, or throws |
update(?object $value) |
void |
Updates the reference to another entity or null |
setNull() |
void |
Sets the reference to null |
| Service | Description |
|---|---|
PolymorphicValueFactoryInterface |
Creates polymorphic value instances |
PolymorphicSearchExprBuilderFactoryInterface |
Creates search expression builders |
PolymorphicSearchExprApplierFactoryInterface |
Creates appliers that directly modify QueryBuilder |
# Generate reference classes for explicit polymorphic properties
php bin/console pechynho:polymorphic-doctrine:generate-reference-classes
# Clear cache (discovery + reference classes)
php bin/console pechynho:polymorphic-doctrine:cache-clearImportant: After any change to explicit polymorphic property definitions, you must re-run the reference class generation command.
| Problem | Solution |
|---|---|
| Reference classes not found (explicit mode) | Run php bin/console pechynho:polymorphic-doctrine:generate-reference-classes then php bin/console cache:clear |
| Polymorphic properties not working | Verify the #[EntityWithPolymorphicRelations] attribute is on the entity, the bundle is registered, and discovery directories are configured |
| Slow queries | Enable indexes via enableDiscriminatorIndex and enablePairIndex on your attributes |
- PHP >= 8.4
- Symfony 6.4 / 7.x / 8.x
- Doctrine ORM 2.19+ or 3.3+
MIT — see LICENSE.
Jan Pech — pechynho@gmail.com