Bug description
In a long-lived PHP process (a queue:work worker, or Octane), if Statamic's Stache/Blink
in-process caches have already been populated once (e.g. by any code calling
Entry::all()), and an entry is then deleted from a different process/request, the
already-running worker's in-memory view of that entry does not get invalidated — nothing
forces it to recheck the filesystem, because that recheck only happens when the Stache
file watcher is enabled, and STATAMIC_STACHE_WATCHER defaults to 'auto', which is
already false in production (app()->isLocal() === false). This is the default
production configuration, not an opt-in edge case.
If that same stale worker later processes any job that touches Entry::all() again (a
scheduled re-warm, a queued content-processing job, etc.), it resolves the deleted entry
from its stale in-memory cache instead of getting null. Calling ->absoluteUrl() (or
similar) on that stale object appears to trigger a write of the in-memory object back to
disk — via ExistsAsFile::writeFile(), which writes unconditionally with no check that a
file currently exists for that entry. The result: a deleted entry reappears on disk as a
new, essentially empty file — a freshly generated random ID, with only id and
blueprint set (no slug, no title, no real content) — and it does not appear anywhere in
the Statamic Control Panel until php artisan statamic:stache:refresh is run, because the
write bypasses Entry::save()'s normal index-sync path.
This is a data-integrity bug: deleted content can reappear on disk unattended, hours or
days after deletion, with no user action other than normal queue processing.
Impact
- Silent data resurrection: a deleted entry can reappear as a blank/incomplete file with a
different ID than the one that was deleted, with no corresponding user action.
- Invisible until
stache:refresh: because the write bypasses Entry::save()'s index
update, the CP shows the entry as correctly deleted until an explicit Stache rebuild,
which then "discovers" the resurrected file and starts treating it as real content.
- This is not a rare timing coincidence — it only requires (a) a queue worker that's been
up for a while and has touched Entry::all() at least once, and (b)
STATAMIC_STACHE_WATCHER resolving to false, which — per 'auto''s default — is true
for essentially all production Statamic sites out of the box.
Suggested areas to investigate for a fix
ExistsAsFile::writeFile() (or its callers) could check that the entry is still present
in the Stache's authoritative index / that the file's directory entry still exists
before writing, particularly for an in-memory object that was never freshly re-read from
disk in the current process.
Structures\Tree::entry()'s permanent Blink memoization could have a mechanism to be
invalidated by delete events even when the watcher is disabled (e.g. explicitly
listening for EntryDeleted and clearing the relevant Blink key, rather than relying
solely on the file watcher).
- More generally: consider whether any Stache-backed lookup should ever silently persist
an in-memory object to disk as a side effect of a read-only-looking call like
absoluteUrl().
How to reproduce
This reproduces with a single throwaway job class and Statamic's own Entry facade —
no custom package code.
-
In a Statamic project with a pages (or any) collection, add one job class:
// app/Jobs/StacheReproJob.php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Statamic\Facades\Entry;
class StacheReproJob implements ShouldQueue
{
use Dispatchable, Queueable;
public function handle(): void
{
Entry::all()->each(fn ($e) => $e->absoluteUrl());
}
}
-
Create a test entry, e.g. via php artisan tinker:
$entry = \Statamic\Facades\Entry::make()->collection('pages')->locale('nl')->slug('repro')->data(['title' => 'Repro']);
$entry->save();
echo $entry->id(); // note this ID
-
Start a queue worker with the watcher disabled:
STATAMIC_STACHE_WATCHER=false php artisan queue:work
-
Prime the worker while the entry still exists — dispatch the job once (from a
separate tinker/HTTP call) and let the worker process it:
App\Jobs\StacheReproJob::dispatch();
-
Delete the entry, from a separate process (a fresh tinker invocation, or via
the CP):
\Statamic\Facades\Entry::find('<id-from-step-2>')->delete();
Confirm the file is gone from content/collections/pages/nl/repro.md.
-
Dispatch the same job again — it's picked up by the same, already-primed worker
process:
App\Jobs\StacheReproJob::dispatch();
-
Check content/collections/pages/nl/repro.md. In our testing it reappeared, containing
only:
---
id: <a brand new, different UUID>
blueprint: pages
---
Note the new entry has a freshly generated ID — different from the one that was
deleted — and no slug, title, or other data. It will not appear in the Statamic CP's
entry listing until php artisan statamic:stache:refresh is run, at which point the
Stache rescans the filesystem, finds this now-real file, and indexes it as a genuine
(if empty) entry.
We also reproduced this with a real, structured, multi-site news collection using a
production-shaped queue job (a "static cache re-warmer" that lists every published entry
and warms its rendered output) — the same mechanism resurrected a real deleted article as
an incomplete stub. The minimal repro above strips that down to the smallest core-only
case.
Logs
Environment
- Laravel queue worker (`php artisan queue:work`), any queue driver that isn't `sync`
(confirmed with `redis`)
- `STATAMIC_STACHE_WATCHER=false` (or simply `APP_ENV=production` / `app()->isLocal() ===
false`, since `'auto'` already resolves to `false` there — see
`config/statamic/stache.php`'s default and `Stache::isWatcherEnabled()`)
Environment
Application Name: --REDACTED--
Laravel Version: 12.67.0
PHP Version: 8.4.23
Composer Version: 2.10.1
Environment: local
Debug Mode: ENABLED
URL: local-project.test
Maintenance Mode: OFF
Timezone: Europe/Amsterdam
Locale: en
Cache
Config: NOT CACHED
Events: NOT CACHED
Routes: NOT CACHED
Views: CACHED
Drivers
Broadcasting: log
Cache: redis
Database: mysql
Logs: stack / single
Mail: smtp
Queue: redis
Session: file
Storage
public/storage: LINKED
Statamic
Addons: 23
Sites: 5 (Dutch, English, Bulgarian, Polish, Romanian)
Stache Watcher: Enabled
Static Caching: inertia
Version: 5.73.13 PRO
Statamic Addons
jacksleight/statamic-bard-texstyle: 3.6.0
rias/statamic-data-import: 1.5.0
rias/statamic-redirect: 3.14.3
steets/inertia-statamic: 4.4.2
steets/statamic-addon-browser-lang-redirect: 0.1.10
steets/statamic-addon-extend-forms: 0.3.1
steets/statamic-addon-full-sitemap: 0.2.11
steets/statamic-addon-google-address: 0.1.9
steets/statamic-addon-mapbox-address: 0.2.0
steets/statamic-addon-markdown-text-field: 0.1.9
steets/statamic-addon-meilisearch: 0.2.14
steets/statamic-addon-permissions: 0.1.4
steets/statamic-addon-recaptcha: 0.2.6
steets/statamic-addon-responsive-image-field: 0.2.1
steets/statamic-addon-search-index-transformers: 0.2.30
steets/statamic-addon-special-text-field: 0.1.3
steets/statamic-addon-steets-theme: 0.2.4
steets/statamic-addon-structured-data-tag: 0.1.11
steets/statamic-addon-submit-button-field: 0.1.6
steets/statamic-starter-kit: 0.1.19
thoughtco/statamic-cache-tracker: 1.1.0
twithers/icons-plus: 1.1.0
visuellverstehen/statamic-picturesque: 2.2.0
Installation
Starter Kit using via CLI
Additional details
I've encountered this in a custom project where we use a custom static cache (and a custom warmer for that static cache).
Based on dumps of the data encountered during the queue processes we were able to locate the issue in a stale stache, after encountering stale entries during the queue process the entries suddenly re-appeared as almost empty files in the file system.
We've circumvented the issue by triggering a stache:clear(); Blink::flush(); before warming the static cache in our own code, but it looks like this could happen in other cases too.
Therefore I asked Claude to isolate the exact problem to make it reproducable for regular Statamic projects.
Bug description
In a long-lived PHP process (a
queue:workworker, or Octane), if Statamic's Stache/Blinkin-process caches have already been populated once (e.g. by any code calling
Entry::all()), and an entry is then deleted from a different process/request, thealready-running worker's in-memory view of that entry does not get invalidated — nothing
forces it to recheck the filesystem, because that recheck only happens when the Stache
file watcher is enabled, and
STATAMIC_STACHE_WATCHERdefaults to'auto', which isalready
falsein production (app()->isLocal() === false). This is the defaultproduction configuration, not an opt-in edge case.
If that same stale worker later processes any job that touches
Entry::all()again (ascheduled re-warm, a queued content-processing job, etc.), it resolves the deleted entry
from its stale in-memory cache instead of getting
null. Calling->absoluteUrl()(orsimilar) on that stale object appears to trigger a write of the in-memory object back to
disk — via
ExistsAsFile::writeFile(), which writes unconditionally with no check that afile currently exists for that entry. The result: a deleted entry reappears on disk as a
new, essentially empty file — a freshly generated random ID, with only
idandblueprintset (no slug, no title, no real content) — and it does not appear anywhere inthe Statamic Control Panel until
php artisan statamic:stache:refreshis run, because thewrite bypasses
Entry::save()'s normal index-sync path.This is a data-integrity bug: deleted content can reappear on disk unattended, hours or
days after deletion, with no user action other than normal queue processing.
Impact
different ID than the one that was deleted, with no corresponding user action.
stache:refresh: because the write bypassesEntry::save()'s indexupdate, the CP shows the entry as correctly deleted until an explicit Stache rebuild,
which then "discovers" the resurrected file and starts treating it as real content.
up for a while and has touched
Entry::all()at least once, and (b)STATAMIC_STACHE_WATCHERresolving tofalse, which — per'auto''s default — is truefor essentially all production Statamic sites out of the box.
Suggested areas to investigate for a fix
ExistsAsFile::writeFile()(or its callers) could check that the entry is still presentin the Stache's authoritative index / that the file's directory entry still exists
before writing, particularly for an in-memory object that was never freshly re-read from
disk in the current process.
Structures\Tree::entry()'s permanent Blink memoization could have a mechanism to beinvalidated by delete events even when the watcher is disabled (e.g. explicitly
listening for
EntryDeletedand clearing the relevant Blink key, rather than relyingsolely on the file watcher).
an in-memory object to disk as a side effect of a read-only-looking call like
absoluteUrl().How to reproduce
This reproduces with a single throwaway job class and Statamic's own
Entryfacade —no custom package code.
In a Statamic project with a
pages(or any) collection, add one job class:Create a test entry, e.g. via
php artisan tinker:Start a queue worker with the watcher disabled:
Prime the worker while the entry still exists — dispatch the job once (from a
separate
tinker/HTTP call) and let the worker process it:Delete the entry, from a separate process (a fresh
tinkerinvocation, or viathe CP):
Confirm the file is gone from
content/collections/pages/nl/repro.md.Dispatch the same job again — it's picked up by the same, already-primed worker
process:
Check
content/collections/pages/nl/repro.md. In our testing it reappeared, containingonly:
Note the new entry has a freshly generated ID — different from the one that was
deleted — and no slug, title, or other data. It will not appear in the Statamic CP's
entry listing until
php artisan statamic:stache:refreshis run, at which point theStache rescans the filesystem, finds this now-real file, and indexes it as a genuine
(if empty) entry.
We also reproduced this with a real, structured, multi-site news collection using a
production-shaped queue job (a "static cache re-warmer" that lists every published entry
and warms its rendered output) — the same mechanism resurrected a real deleted article as
an incomplete stub. The minimal repro above strips that down to the smallest core-only
case.
Logs
Environment
Installation
Starter Kit using via CLI
Additional details
I've encountered this in a custom project where we use a custom static cache (and a custom warmer for that static cache).
Based on dumps of the data encountered during the queue processes we were able to locate the issue in a stale stache, after encountering stale entries during the queue process the entries suddenly re-appeared as almost empty files in the file system.
We've circumvented the issue by triggering a stache:clear(); Blink::flush(); before warming the static cache in our own code, but it looks like this could happen in other cases too.
Therefore I asked Claude to isolate the exact problem to make it reproducable for regular Statamic projects.