dualmedia/disable-orm-bundle
A Symfony + Doctrine bundle that lets you transparently disable entity fields from the ORM via a #[DisableORM] attribute, enabling zero-downtime deployments and safe field removal across multiple app versions.
Framework reality check first. Despite the "Laravel package" framing in your backlog, this is a Symfony + Doctrine ORM bundle. It works by swapping doctrine.orm.entity_managers.<name>.class_metadata_factory_name for its own ClassMetadataFactory, so there is nothing for Eloquent to hook into. If you're on Laravel, the equivalent capability is a hand-rolled expand/contract migration sequence (add column → dual-write → backfill → stop reading → drop), and this package is only useful to you as a reference design. Everything below assumes Symfony with a real EntityManager.
Minimal setup (4 steps):
composer require dualmedia/disable-orm-bundle
// config/bundles.php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
// ...
DualMedia\DisableORMBundle\DisableORMBundle::class => ['all' => true],
];
# config/packages/doctrine.yaml
doctrine:
orm:
entity_managers:
default:
class_metadata_factory_name: DualMedia\DisableORMBundle\Factory
Confirm the column already has a database-level default. This is the step people skip and then break inserts on newer instances. Adding options: ['default' => false] to the attribute does not retroactively ALTER TABLE; ship a migration that sets the DB default first.
Tag the field and deploy:
#[ORM\Entity]
class FooEntity
{
#[ORM\Column]
public ?int $normalField = null;
#[DisableORM]
#[ORM\Column(options: ['default' => false])]
public ?int $someField = null;
}
Where to look first: the README's Usage + Example sections (they are the whole tutorial), then DualMedia\DisableORMBundle\Factory (small class — read it, it's ~an hour of work in-house and you'll want to know exactly what it strips), the #[DisableORM] attribute, extension.neon, and /scripts for the change-detection job.
First use case: pick one dead, low-traffic column that already has a default. Verify nothing in your codebase reads the property through DQL or the entity (grep the property name across repositories, query builders, templates, serializers, admin configs). Tag it, deploy, watch one release cycle, then write the DROP COLUMN migration in the next release. Verify the field is actually gone from metadata rather than trusting the deploy:
$meta = $em->getClassMetadata(FooEntity::class);
self::assertFalse($meta->hasField('someField'));
The two-deploy contract sequence. The whole value of the bundle is that column removal stops being a synchronized event:
| Release | Action |
|---|---|
| N | Migration: ensure DB default on the column. Everything keeps using the field. |
| N+1 | Add #[DisableORM]. Remove all ORM references to the property. Deploy. Old instances still read/write the column. |
| N+2 (or later) | Migration: DROP COLUMN. Nothing references it any more. |
Never collapse N+1 and N+2. The attribute existing in N+1 is the proof that it's safe to drop in N+2 — that's exactly what the CI git-diff script enforces.
Keep the property declared during the drain. The property stays on the class as a plain PHP property; it just isn't hydrated or change-tracked. That keeps the file diff small and lets a rollback (remove the attribute, redeploy) restore behaviour. Strip the property only in the release that drops the column.
Raw SQL escape hatch. When you genuinely need the value during the transition — a backfill, a diagnostic command, a legacy report — go through DBAL, which bypasses ORM metadata entirely:
$value = $connection->fetchOne(
'SELECT some_field FROM foo_entity WHERE id = ?',
[$id]
);
Keep these in dedicated, clearly-commented methods so they're easy to delete in N+2.
Use disable_on_commands as your allowlist for backfills. Defaults keep doctrine:schema:validate and doctrine:migrations:diff seeing the field — that's what lets migrations:diff stay quiet about the column instead of spontaneously generating a DROP COLUMN against your will. Add your own commands that must still see the field:
dm_disable_orm:
disable_on_commands:
- 'doctrine:schema:validate'
- 'doctrine:migrations:diff'
- 'app:legacy:backfill-some-field'
Repository/query cleanup in the same PR as the attribute. Any SELECT f.someField in DQL, any ->orderBy('f.someField'), any partial hydration listing it, any ResultSetMapping — all of these throw once the field leaves metadata. Do the grep and the removal in the commit that adds #[DisableORM], not afterwards.
Wire the two guardrails into CI on day one. They're the actual product:
# phpstan.neon
includes:
- vendor/dualmedia/disable-orm-bundle/extension.neon
Plus the git change-detection job from /scripts (a ready-made .gitlab-ci.job.yml is provided). This fails builds where someone deletes a field that was never flagged — the one mistake that turns this tool into an outage generator.
Rollout pattern. Pilot on one entity, verify repository, hydration, and metadata-consuming layers (EasyAdmin, API Platform, form types, Gedmo/Stof extensions) behave, then standardize. Use @deprecated docblocks as the on-ramp: deprecate in N, tag in N+1, drop in N+2.
Writes to a disabled field are silently discarded. This is the sharpest edge. $entity->someField = true; $em->flush(); produces no UPDATE — the property isn't in the change set. No exception, no log line. Likewise reads return the PHP default (null), not the DB value. Every failure mode here looks like wrong data, not a loud error, so put the runbook line in the team wiki: "Column missing or query returning fewer fields? Check whether the property is #[DisableORM]-tagged."
doctrine:schema:update is not in the default allowlist. Only schema:validate and migrations:diff are. If anyone runs schema:update --force (or a tool wrapping it) with the attribute present, Doctrine sees no field and will happily emit a DROP COLUMN before you're ready. Add it to disable_on_commands if it's still in your workflow, or better, delete it.
Forgetting the DB default breaks inserts on new instances. New code doesn't include the column in INSERT, so a NOT NULL column without a server-side default throws. Nullable columns are fine (implicit NULL). Set the default in a migration before the release that carries the attribute.
Non-ORM layers still see the property. Symfony's Validator, Form component, Serializer, and API Platform work off reflection/property metadata, not Doctrine's. A #[Assert\NotBlank] on a disabled field will fail forever because the value is never hydrated; a serializer group can leak a stale null into your API. Audit and remove those annotations/fields in the same release.
Don't tag structural mapping. Identity/ID fields, discriminator columns, version fields, join columns, and anything referenced by #[ORM\Index]/#[ORM\UniqueConstraint] will blow up or corrupt mapping — the factory strips the field, not the index definition that names its column.
Metadata and second-level caches. After adding the attribute, clear Symfony cache and any Doctrine metadata/second-level cache (doctrine:cache:clear-metadata, cache:pool:clear) in the deploy. Stale metadata in a warmed production cache means the field is still visible on some instances and not others — a wonderfully confusing failure to debug.
Beware of SELECT * + native queries. They're fine against the entity (extra columns are ignored), but if you're mixing raw SQL and ORM hydration in the same request you now have two sources of truth for the same row. Keep raw reads confined to the methods noted above.
Audit for competing metadata decorators. If another bundle already replaces or decorates ClassMetadataFactory, only one wins. Check before committing — and sanity-check the factory's cost on an entity-heavy codebase.
Per-manager drift. Only entity managers with class_metadata_factory_name set get the behaviour. A newly added manager silently behaves differently, and you'll chase a "why does this field exist here but not there" bug for an afternoon. Treat the YAML as a checklist item when adding managers.
Rollback is a redeploy, not a toggle. There's no runtime switch, no per-environment flag, no reversibility. Removing the attribute and shipping is the rollback — fine for an incident, not fine for "let's try it in prod for five minutes."
CI script false positives. The git change-detection job keys off field removals; renames read as delete+add and can trip it. Expect to whitelist legitimate renames explicitly rather than weakening the check.
Maintenance posture. Zero stars, zero dependents, single maintainer, MIT. The surface area is small enough that forking is cheap — budget for the possibility, and read the factory once so a fork isn't a cold start.
How can I help you explore Laravel packages today?