Skip to content

[6.x] Fix entries accepting a cyclic origin - #15288

Open
lwekuiper wants to merge 2 commits into
statamic:6.xfrom
lwekuiper:fix/6.x-guard-cyclic-entry-origins
Open

[6.x] Fix entries accepting a cyclic origin#15288
lwekuiper wants to merge 2 commits into
statamic:6.xfrom
lwekuiper:fix/6.x-guard-cyclic-entry-origins

Conversation

@lwekuiper

Copy link
Copy Markdown
Contributor

An entry's origin can be pointed at an entry that already originates from it. Nothing rejects this, the cycle is written to disk, and from then on loading either entry, or saving anything else in that collection, recurses until PHP dies. stache:clear doesn't recover from it either: the markdown needs hand editing plus removing the cached Stache files.

Reproduced on 6.29.0, file driver, multi-site, PHP 8.4:

$en = Entry::make()->collection('pages')->locale('en')->slug('a')->data(['title' => 'A']);
$en->save();

$nl = Entry::make()->collection('pages')->locale('nl')->slug('b')->data(['title' => 'B']);
$nl->save();

$nl->origin($en)->save(); // fine
$en->origin($nl)->save(); // memory exhausted, but the cycle is already on disk

The control panel can't produce this, but the API accepts it, so imports and content sync scripts can.

Cause

Resolving an origin runs a query, and the Stache calls syncOriginal() on every item it hands out, which resolved the origin again through getCurrentDirtyStateAttributes(). For a cyclic pair that never bottoms out:

BasicStore::getItem()        syncOriginal()
HasDirtyState                getCurrentDirtyStateAttributes()
Entry                        $this->origin()->id()
Entry::getOriginByString()   queryEntries()->where('id', ...)->first()
BasicStore::getItem()        syncOriginal() ...

root() and ancestors() also loop forever on a cycle.

Fix

  • Entry::save() throws EntryOriginRecursionException when the origin would close a loop: directly, through another entry, or on itself. An origin pointing at an entry that no longer exists still saves, as before.
  • getCurrentDirtyStateAttributes() uses the origin id it already holds instead of resolving it, so dirty state tracking no longer queries from inside the store.
  • root() and ancestors() walk the chain with a visited set instead of following origin() blindly. descendants() already does this for the other direction.

The check lives in save() rather than in origin() on purpose. The Stache calls the setter when hydrating from disk, so rejecting there would make an already broken site throw on load instead of letting you repair it.

Not in this PR

This keeps new cycles from being written. It doesn't make a site that already has one on disk usable again: value(), blueprint(), template(), layout() and date() each follow origin() recursively, and blueprint() is called for every entry whenever an index is rebuilt. Happy to do that in a follow-up, but I'd like to hear first whether you'd rather guard each of those reads or normalise bad origins when the Stache loads them.

Tests cover the three rejection cases, the dangling origin case, and root()/ancestors() on a cycle.

@jaygeorge

jaygeorge commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Overlaps with some fixes in this PR #15253

@lwekuiper

lwekuiper commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, hadn't seen that one. I ran the #15253 branch against a self-origin and against a cycle that's already in the Stache, and both still overflow the stack there: the store calls syncOriginal() on every item it returns, which resolves the origin again through getCurrentDirtyStateAttributes(). That's the one line this PR changes, so the two are complementary. Happy for it to be folded into #15253 instead if that's easier.

@jaygeorge

Copy link
Copy Markdown
Contributor

No worries, thanks for the PR! I'll let Jason decide how it should be handled – just wanted to flag it :)

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