Skip to content

Make draft element resolution pluggable - #1991

Draft
markus-moser wants to merge 3 commits into
2026.xfrom
feat/draft-element-resolver
Draft

Make draft element resolution pluggable#1991
markus-moser wants to merge 3 commits into
2026.xfrom
feat/draft-element-resolver

Conversation

@markus-moser

Copy link
Copy Markdown
Contributor

Behaviour-preserving refactor. Pimcore's own answer is unchanged and stays the default.

Package A, PR 1 of 2.

The problem

Seven services independently answer "which state should this user see — what is published, or the edits they have not saved yet?", each with the same two inlined lines:

$version = $this->getLatestVersionForUser($element, $user);
$element = $this->getVersionData($element, $version);

UpdateService, DataObjectService, LayoutService, DocumentService, PropertyService, WorkflowDetailsService, Search\Hydrator\Preview\DataObjectHydrator.

The policy is therefore decided seven times over and can only ever have one answer: a draft is a version row. A bundle that stores pending edits anywhere else has to patch each response individually — and every endpoint nobody thought about keeps rendering published values.

The change

Move it behind DraftElementResolverInterface, with today's behaviour as VersionDraftElementResolver. The seven call sites inject and delegate.

Deliberately minimal: no registry, no discriminator, no knowledge of who might replace it. It is an ordinary decoratable service, like UpdateServiceInterface already is — so a bundle decorates one thing and every read path follows.

The version lookup stays in ElementProviderTrait for the two detail paths that also report draftData. That is the draft identity the UI labels and deletes by, not the element's state; the two coincide by default and are allowed to diverge.

Verification

  • phpstan: 43 file errors before, 43 after, 0 in touched files (the 43 are pre-existing local dependency-resolution noise)
  • Codeception Unit: 38 errors before, 38 after, 0 failures — 614 tests (up from 608), 1312 assertions (up from 1303)
  • New VersionDraftElementResolverTest — 6 tests covering version-applied, no-draft passthrough, user scoping, null user, unversioned types skipped, and assets/page-snippets

Note the resolver's own test needed real subclasses rather than mocks: getLatestVersion() is not declared on the models at all — it lives on the Dao and is reached via AbstractModel::__call, so a mocked __call silently answers null and such a test passes while asserting nothing.

Three existing test fixtures gained the new constructor argument.

Not in this PR

draftData remains version-shaped. Making the identity pluggable is a separate, larger conversation.

Seven services independently answered "which state should this user see —
what is published, or their unsaved edits?" with the same two inlined lines:
look up the newest unpublished version for the user, then swap in its data.
The policy was therefore decided seven times over and could only ever have
one answer.

Move it behind DraftElementResolverInterface, with the existing behaviour as
the default implementation. Nothing changes for Pimcore itself; what changes
is that a bundle storing pending edits somewhere other than a version can now
decorate one service and have every read path follow, instead of each having
to be taught separately — or silently keeping the old answer and rendering
values the user has already changed.

The version lookup stays in ElementProviderTrait for the two detail paths that
also report `draftData`: that is the draft IDENTITY the UI labels and deletes
by, not the element's state, and the two are allowed to diverge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@markus-moser
markus-moser force-pushed the feat/draft-element-resolver branch from eb3e341 to fe5326c Compare August 13, 2026 08:22
markus-moser and others added 2 commits August 18, 2026 16:55
Review follow-up to the extraction.

resolve() now answers a ResolvedDraft carrying both halves: the element STATE
to render, and the Version that is the draft IDENTITY reported as `draftData`.
The two detail paths were asking for those separately, which meant two
uncached `SELECT id FROM versions` + `Version::getById()` round trips per
element open, and a window in which an autosave landing between the two calls
would have `draftData` name a different row than the one rendered.

With the identity coming back from the same call, ElementProviderTrait
::getLatestVersionForUser() has no callers left; deleting it also removes the
second copy of the "which types carry versions" gate, leaving the resolver as
the only place that decides.

The gate in UpdateService::getDraftElement() deliberately stays and is now
documented. It is narrower than the resolver's on purpose: `useDraftData`
arrives on an unvalidated payload array, so delegating assets there would let
an asset update swap the live asset for its latest version before saving.

Also guards the default resolver against Version::getData() answering null —
a pruned payload, or an __PHP_Incomplete_Class after a class rename — which
the non-nullable return type would otherwise turn into a 500 on element open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cut the multi-line reasoning prose from the draft-resolver additions
(ResolvedDraft, the resolver interface + its default, the UpdateService
asset-exclusion note, and the test double) down to single-line
constraints. No behaviour change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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.

1 participant