evozon-php/entity-extend-bundle
Evozon EntityExtendBundle is a Symfony bundle for extending Doctrine entities with additional fields without modifying their classes. Configure extra attributes, persist values, and use them alongside existing entity data.
Treat the source as the documentation. This is a fork of pjarmalavicius/PjEntityExtendBundle with zero dependents and no meaningful star history — the upstream README is usually more complete than the fork's, so read both. Budget an hour to read the bundle's Doctrine/, EventListener/, and DependencyInjection/ directories before writing any code; the whole mechanism is a loadClassMetadata listener plus a compiler pass, and you need to know which one you're relying on.
Minimal setup:
composer require evozon-php/entity-extend-bundle — check the composer.json constraints first. Bundles of this vintage are typically pinned to Symfony 3/4 and Doctrine ORM 2.x with annotation mapping; it will fight you on ORM 3 / PHP attributes.config/bundles.php (or AppKernel::registerBundles() on older skeletons). There is almost certainly no Flex recipe, so no config file is auto-generated.# config/packages/entity_extend.yaml
evozon_entity_extend:
entities:
App\Entity\Customer:
class: App\Entity\Extension\CustomerExtension
First use case: you have a Customer entity you don't own (vendor bundle, shared internal package, or a core model you refuse to pollute) and need to add a phone field. Create an extension class in your app's namespace:
namespace App\Entity\Extension;
use App\Entity\Customer;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
*/
class CustomerExtension extends Customer
{
/** @ORM\Column(type="string", length=32, nullable=true) */
private $phone;
public function getPhone(): ?string { return $this->phone; }
public function setPhone(?string $phone): self { $this->phone = $phone; return $this; }
}
Register it, then run bin/console doctrine:schema:update --dump-sql and read the SQL carefully — the field should land on the base table (customer), not a new one. If you see a second table, the mapping mode is wrong and you should stop and re-read the listener.
Where the extension lives in the object graph. The bundle merges extension fields into the base entity's metadata, so at runtime Customer is the extended class (or a proxy of it). Day-to-day this means your repositories, form types, and voters keep referencing Customer — you generally don't rewrite queries to target CustomerExtension. Confirm this early with:
$em->getClassMetadata(Customer::class)->getFieldNames(); // should include 'phone'
If phone isn't there, nothing downstream will work, so make this your smoke test in a functional test.
Form and validation integration. Because the property is declared on the extension class but resolved on the base metadata, data_class should stay Customer::class. Use getParent() on the base form type and add your fields:
class CustomerType extends AbstractType
{
public function buildForm(FormBuilderInterface $b, array $o): void
{
$b->add('phone', TextType::class, ['required' => false]);
}
public function getParent(): string { return BaseCustomerType::class; }
public function configureOptions(OptionsResolver $r): void
{
$r->setDefaults(['data_class' => Customer::class]);
}
}
Validation constraints go on the extension class's properties (annotation @Assert\... or YAML in your app config) — they're read through normal metadata reflection.
Schema and migration workflow. Use doctrine:migrations:diff as usual; the diff sees the merged metadata, so migrations are generated against the base table. The important discipline: commit the migration, not the schema dump, and regenerate the diff after every config change. In CI, run doctrine:schema:validate and treat the "mapping out of sync" output as a hard failure.
Layering for per-deployment customization. This bundle earns its keep in multi-tenant or multi-client setups where the same codebase ships different fields. Pattern: keep one Extension namespace per vertical, and let the bundle config be environment- or client-specific (config/packages/{env}/entity_extend.yaml). Keep the extension classes dumb — properties, getters/setters, constraints. Business logic belongs in services that consume Customer, not in the extension class.
Serialization / API exposure. Serializer groups on the extension properties work normally, but only if the property is actually reachable on the base metadata (again: the getFieldNames() test). API Platform's resource discovery on the base class won't automatically surface fields from a class it doesn't know about, so expect to add explicit #[Groups]/config on the extension properties and verify the JSON in a functional test.
Testing. Anything touching extended entities must boot the kernel, because the metadata is mutated at runtime by the listener. Pure unit tests instantiated against a bare EntityManager with an in-memory SQLite connection won't reproduce the merge. Use KernelTestCase + doctrine:schema:create against SQLite for fast feedback, and keep one MySQL/Postgres test for the real schema.
Cache invalidation is the #1 failure mode. Metadata is mutated per-request by a listener and then cached. After editing the bundle config or adding an extension class, always run bin/console cache:clear and clear the Doctrine metadata cache (doctrine:cache:clear-metadata), plus the proxy directory. Stale proxies will silently omit your new getters and throw undefined method at runtime.
Ordering. Your listener runs during loadClassMetadata. If another bundle also mutates the same entity's metadata, the winner depends on listener priority — inspect doctrine.event_listener tags if fields mysteriously vanish. Keep your own metadata listeners at a lower priority than this bundle's.
Every row gets the column. There is no discriminator in the usual configuration, so all existing rows receive the new columns. Make every added field nullable=true unless you ship a backfill migration in the same deploy. Non-nullable without a default will break the ALTER TABLE on any non-empty table.
One extension per entity. The config is a flat map; extending Customer from two places in the same app is undefined behaviour. If you need conditional field sets, branch in the config per environment rather than stacking classes.
get_class() and identity. The runtime class of a loaded entity may be a proxy or the extension subclass, so avoid strict get_class($c) === Customer::class checks — use instanceof. Similarly, avoid type-hinting properties on the extension class in services expecting the base class.
Fork risk. Zero dependents means zero battle-testing outside the maintainer's app. Pin the version exactly, vendor the composer.lock, and expect to patch it yourself when Doctrine or Symfony bumps land. If the listener is small, consider lifting it into a local EntityExtendSubscriber — you'd drop the dependency and own the upgrade path.
Debugging checklist when a field "doesn't work": (1) doctrine:mapping:info to confirm the entity is managed; (2) dump getFieldNames() and getColumnNames() from ClassMetadata; (3) doctrine:schema:update --dump-sql to confirm the column exists; (4) check the proxy file in var/cache/**/doctrine/orm/Proxies for the getter; (5) grep var/log for listener exceptions swallowed during metadata loading — these are frequently logged at debug only, so raise the Doctrine channel level.
Extension points worth knowing. The bundle's metadata listener is usually a normal service, so you can decorate or subclass it (register your class under the same service id) to add defaults — e.g., auto-appending nullable: true or auto-generating an index. That's often cleaner than repeating mapping boilerplate across a dozen extension classes.
How can I help you explore Laravel packages today?