Skip to content

Deprecate leaving entity identifiers nulled after remove() - #12578

Open
isaackaara wants to merge 3 commits into
doctrine:3.7.xfrom
isaackaara:fix/12457-onremove-identifier-null
Open

Deprecate leaving entity identifiers nulled after remove()#12578
isaackaara wants to merge 3 commits into
doctrine:3.7.xfrom
isaackaara:fix/12457-onremove-identifier-null

Conversation

@isaackaara

Copy link
Copy Markdown
Contributor

Fixes #12457

What

Adds Configuration::isOnRemoveEntitySetIdentifierNull() / setOnRemoveEntitySetIdentifierNull(), defaulting to true (current behavior preserved). Leaving it at the default triggers a one-time deprecation when UnitOfWork is constructed. Setting it to false opts into the ORM 4.0 behavior early: the entity's identifier is no longer nulled after remove() + flush.

This follows the approach @greg0ire outlined in the issue, refined by @Amoifr's comment on the naming/targeting (3.7.x, 4.x no-op/cleanup as a follow-up).

Why

UnitOfWork::executeDeletions() currently nulls an entity's identifier property after removal, as a convention meaning "this object no longer represents a persisted row." That convention conflicts with PHP 8.4+ readonly properties and property hooks, which often cannot be reassigned after construction (see #12401 for a recent example of the breakage).

Notes for reviewers

  • Deprecation fires from UnitOfWork::__construct(), not from a Configuration getter/setter. Every other self-deprecating flag in Configuration.php (getAutoGenerateProxyClasses(), enableNativeLazyObjects()) fires from within the Configuration method itself. This one fires from the constructor instead, per the issue's explicit instruction, presumably so it only fires when it actually affects UnitOfWork behavior rather than every time application code merely reads the flag. Flagging in case that's worth reconsidering.
  • This deprecation is effectively on by default for every 3.7 user, not opt-in-triggered like most entries in this file (which only fire when a specific deprecated method is called). Confirmed while running the full test suite locally: it now surfaces across dozens of otherwise-unrelated tests that construct an EntityManager/UnitOfWork. Deprecation::trigger() dedupes so it only logs once per process, but the blast radius on upgrade is real. Worth a second look if that's more aggressive than intended for a minor-version deprecation.
  • Composite identifiers are a non-issue. ClassMetadata::validateIdentifier() already forces any class with a composite identifier onto GENERATOR_TYPE_NONE (natural ID), so ! $class->isIdentifierNatural() provably implies a single-column identifier in the existing (and unchanged) code path.
  • The ReadonlyAccessor guard is preserved even when the flag is left at its default true - dropping it would be a real regression, since a readonly property still can't be nulled regardless of this flag.
  • 4.x cleanup (making the flag a no-op, removing the guarded branch and eventually the flag itself) is intentionally out of scope here, per @Amoifr's comment - a follow-up PR once this lands.

Tests

  • ConfigurationTest::testSetGetOnRemoveEntitySetIdentifierNull - plain getter/setter round-trip.
  • UnitOfWorkTest::testConstructingUnitOfWorkTriggersDeprecationWhenOnRemoveEntitySetIdentifierNullIsLeftEnabled / testConstructingUnitOfWorkDoesNotTriggerDeprecationWhenFlagIsDisabled - unit-level, asserts the deprecation fires (or doesn't) at UnitOfWork construction.
  • UnitOfWorkLifecycleTest::testRemoveStillSetsIdentifierNullByDefault / testRemoveDoesNotSetIdentifierNullWhenFlagIsDisabled - functional, actual remove() + flush() round trip against a real (in-memory) database, asserting the identifier is/isn't nulled.

Full suite run locally: 3649 tests, 0 failures, 0 errors. PHPStan clean on both modified source files.

UnitOfWork currently sets an entity's identifier property back to null
after it is removed and flushed, as a convention meaning "this object
no longer represents a persisted row." That convention conflicts with
PHP 8.4+ readonly properties and property hooks, which often cannot be
reassigned after construction.

Add Configuration::isOnRemoveEntitySetIdentifierNull() /
setOnRemoveEntitySetIdentifierNull(), defaulting to true (preserving
current behavior). Leaving it at the default triggers a one-time
deprecation when UnitOfWork is constructed. Setting it to false opts
into the ORM 4.0 behavior early: the identifier is left untouched.

Entities with readonly identifier properties are already exempt from
the nulling behavior regardless of this flag, and composite
identifiers are unaffected since ClassMetadata::validateIdentifier()
already forces any class with a composite identifier onto
GENERATOR_TYPE_NONE, so the existing single-column assumption in the
guarded code path holds either way.

4.x cleanup (making the flag a no-op and removing the guarded branch
entirely) is left as a follow-up, out of scope here.

Fixes doctrine#12457
…ruct()

CI's "fail on deprecations" job broke because the deprecation was firing
unconditionally on every UnitOfWork construction, not just when the
deprecated behavior actually executes. Every other deprecation in this
codebase only fires when the deprecated code path is actually exercised
(e.g. calling a specific deprecated method) - this one fired passively
from normal usage, which is a different kind of deprecation entirely and
incompatible with a zero-tolerance CI gate.

Moves the trigger into executeDeletions(), right where the identifier
actually gets nulled, so it only fires for entities that are actually
removed with the old behavior in effect. Confirmed locally this reduces
the blast radius from every EntityManager construction in the process to
only tests that genuinely exercise remove() on a non-natural-identifier
entity - still 74 pre-existing tests in this suite alone, which is a
whole-suite test-infrastructure question (bulk-annotate those tests, or
flip a global test default) that needs a maintainer call, not something
to decide unilaterally in this PR.

Fixes doctrine#12457
@isaackaara

Copy link
Copy Markdown
Contributor Author

Just pushed a fix for the "fail on deprecations" job. Root cause: the deprecation was firing unconditionally from UnitOfWork::__construct(), not just when the deprecated behavior actually runs - every other deprecation in this codebase fires from an explicit deprecated-method call, so this was a different kind of trigger and incompatible with a zero-tolerance CI gate. Moved it into executeDeletions(), right where the identifier is actually nulled.

That fixes the CI job for anything that doesn't remove a non-natural-identifier entity, but it doesn't fully close it out: 74 pre-existing tests elsewhere in this suite legitimately remove such entities and now trigger the deprecation too (confirmed by running --fail-on-deprecation locally against ci/github/phpunit/sqlite3.xml). That's a real design question I don't think I should resolve unilaterally in this PR:

  1. Bulk-annotate those 74 tests with #[IgnoreDeprecations] (large, noisy diff, and silences future deprecations in those tests too), or
  2. Flip the test suite's own default globally (e.g. in TestInit.php or the base test case, call Configuration::setOnRemoveEntitySetIdentifierNull(false) for the whole suite, with only the tests that specifically exercise old-vs-new behavior - like the two I added - explicitly toggling it back)

(2) is probably the more correct long-term shape, matching how the suite already exercises the native-lazy-objects default via ENABLE_NATIVE_LAZY_OBJECTS, but it's a bigger call than I want to make without a maintainer's sign-off. Happy to implement whichever direction you'd prefer.

@greg0ire

greg0ire commented Aug 24, 2026

Copy link
Copy Markdown
Member

This one fires from the constructor instead, per the issue's explicit instruction, presumably so it only fires when it actually affects UnitOfWork behavior rather than every time application code merely reads the flag. Flagging in case that's worth reconsidering.

I think it's worth reconsidering. Having the deprecation surface before it affects the application means when the application gets affected, it might never actually go through a behavioral change, if the user adopted the new way early.

Full suite run locally: 3649 tests, 0 failures, 0 errors. PHPStan clean on both modified source files.

Please tell your AI not to brag about this, we have a CI.

Regarding the tests, don't ignore the deprecations, address them. The test suite should mostly use the new way. I don't think you'll be able to do it from TestInit.php though. I don't think it's worth running the whole test suite with that flag set to true. So I think you should probably change the places where a new Configuration object in OrmTestCase and OrmFunctionalTestCase. That should take care of most deprecations. Then you can write specific tests that exercise the legacy path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants