levelab/doctrine-discriminator-bundle
A Symfony bundle that simplifies Doctrine discriminator mapping for entity inheritance, reducing boilerplate when configuring discriminator maps. Useful for single-table inheritance setups and cleaner Doctrine configuration in Symfony applications.
Reality check before anything else. This is a Symfony bundle targeting Symfony 2.x/3.x and Doctrine ORM 2.5-era APIs. It has no Laravel ServiceProvider, no package discovery, and depends on symfony/http-kernel. If you are on Laravel + Eloquent, there is no discriminator concept to leverage and you should stop here. If you are on Laravel with laravel-doctrine/orm, or on a legacy Symfony app, the practical path is to read the bundle as a reference and port the mechanism, not to composer require it.
Minimal steps (legacy Symfony, if you must install as-shipped):
composer require levelab/doctrine-discriminator-bundle
Register the bundle in app/AppKernel.php:
$bundles[] = new Levelab\DoctrineDiscriminatorBundle\LevelabDoctrineDiscriminatorBundle();
Look first at two files: the Doctrine event subscriber (the loadClassMetadata listener that synthesises the map) and the DependencyInjection extension (the compiler pass that wires it). Everything else is Symfony boilerplate you will delete during a port.
Annotate each subclass with the bundle's discriminator-value annotation and remove the discriminatorMap array from the root entity:
/** @DiscriminatorValue("card") */
class CardPayment extends Payment {}
/** @DiscriminatorValue("bank_transfer") */
class BankTransferPayment extends Payment {}
First use case. A Payment (or Notification, Shipment, InvoiceType) hierarchy where each subtype lives in its own module/package and you want adding a subtype to touch exactly one new file. If you only have three subclasses, keep the explicit discriminatorMap — it is greppable, static-analysis-friendly, and has zero dependency risk.
If you are on Laravel, the first thing you build is the attribute + subscriber, not the bundle:
#[Attribute(Attribute::TARGET_CLASS)]
final class DiscriminatorValue
{
public function __construct(public readonly string $value) {}
}
final class DiscriminatorMapSubscriber implements EventSubscriber
{
public function __construct(private array $subclasses) {}
public function getSubscribedEvents(): array
{
return [Events::loadClassMetadata];
}
public function loadClassMetadata(LoadClassMetadataEventArgs $event): void
{
$meta = $event->getClassMetadata();
if ($meta->rootEntityName !== $meta->name) {
return; // only touch the root
}
// discover #[DiscriminatorValue] on known subclasses, populate:
// $meta->discriminatorMap and $meta->subClasses
}
}
Register it against the EntityManager's event manager inside a service provider.
1. Decentralized map via loadClassMetadata. The core pattern is a single Doctrine subscriber that runs on Events::loadClassMetadata for a root entity and populates ClassMetadata::$discriminatorMap and $subClasses. This is the mechanism worth copying, because it is genuinely Doctrine-idiomatic and works with both STI and CTI. Everything Symfony-specific (the bundle class, the DI extension) is disposable.
2. Per-package registration (the actual reason to do this). The value is realized when a plugin/composer package ships its own PricingRule subclass and registers it without patching core:
// In the package's own service provider
$subclasses[] = PartnerPricingRule::class; // or discovered via attribute scan
That is the workflow to design around: composer require vendor/partner-module → module registers its discriminator value → zero edits to the host app's base entity.
3. Static list, not runtime scanning. Discovery-by-scanning is expensive and fragile (autoload ordering, cache warmth). Prefer a config-driven list of subclasses keyed by discriminator, populated at container build time and cached. Scan-at-request-time is the pattern's main failure mode.
4. Build-time assertion that the resolved map is what you expect. This is the one non-negotiable pattern. Decentralization removes the single readable source of truth; replace it with a test/command:
$meta = $em->getClassMetadata(Payment::class);
self::assertSame([
'card' => CardPayment::class,
'bank_transfer' => BankTransferPayment::class,
], $meta->discriminatorMap);
Run this in CI so a missing or duplicated discriminator value fails the build, not a hydration path in production.
5. A debug:discriminator-map artisan command. Since no one edits a central map anymore, give the team one place to read it. Enumerate resolved maps per root entity, print discriminator values and classes, and diff against the expected set on deploy.
6. Migrations stay explicit. The bundle only affects metadata; your SQL schema needs ALTER TABLE payment ADD COLUMN work for new subtype-specific columns. Keep migrations owning the discriminator column and any added nullable columns — do not let a new subclass require a schema change at the moment it is registered.
7. Cache warming. Warm metadata (or your ported discovery cache) in a post-deploy step keyed by a deploy-stamped build ID. In Laravel, artisan doctrine:clear-metadata-cache (or your equivalent) alongside config:cache.
ClassNotFound or hydration errors can persist until you clear the Doctrine metadata cache (cache:clear, doctrine:cache:clear-metadata, or the Laravel equivalent). This is the number-one production-debugging pitfall: it works locally (cache cold) and fails in staging (cache warm).discriminatorMap must be autoloadable. Doctrine resolves each class eagerly during metadata building. A subclass from a disabled/removed package leaves a dangling reference that breaks the entire root entity, not just that subtype. Gate registration on the package actually being installed.discriminatorMap cannot be appended after loadClassMetadata returns. The moment passes; do all synthesis inside the listener. Modifying it later throws or is silently ignored depending on Doctrine version.Symfony\Component\HttpKernel\Bundle\Bundle coupling) emit deprecations and likely fatals. Do not attempt composer require --ignore-platform-reqs; port instead.doctrine/annotations. The annotation reader is deprecated and Doctrine 3 drops it. A #[DiscriminatorValue('card')] attribute plus a small subscriber removes both the annotations dependency and the Symfony DI coupling in one step.DiscriminatorRegistry service injected into the subscriber (or a SubclassesCompilerPass/config key in Laravel). Keep the subscriber a pure consumer of the registry so tests can swap a fixed list in without booting Doctrine.How can I help you explore Laravel packages today?