You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I found that when the class-loader-cache is created, it isn't refreshing when new (or existing) classes are created/changed. I found instances where my cache was over 2 weeks old despite many changes performed to related classes.
My solve for this was to SFTP into my WP Engine hosted server and manually delete the class-loader-cache directory to ensure it grabs the latest changes.
This results in scenarios of "works locally but not on my servers". I think this issue stems out of iterative deployments. One scenario in my testing, it worked just fine, but it was a server that we hadn't deployed any of the work to yet before any caches were created. If a cache exists and you adjust something in the autoloaded classes, the cache doesn't pick those up.
Ideally there is a mechanism to manually refresh that cache or it auto refreshes when it detects that a class has been modified. I did see the const to skip cache which will be really helpful in debugging this in the future, but ideally we have a reliable source to auto refresh it along with a manual refresh option.
Steps to Reproduce
Create a new class using the ModuleInterface and deploy to your server.
Make some changes that you can observe either from the front or backend (both affected) and deploy.
Double check if your changes can be observed and double check the last modified date of the class-loader-cache and your autoloaded classes.
If the changes aren't showing up due to the cache, deleting the class-loader-cache will fix the issue.
Screenshots, screen recording, code snippet
Observe the last modified dates not in alignment. This reflects the effect of the site working in a past state despite all the files being up to date.
Environment information
Myself and another co-worker reported this issue on WP Engine hosted sites so unsure this is relevant at all. There is a thread in the company slack around this.
@darylldoyle Can we create a hash of all PHP files in the directory and use that as the cache key? If any file changes, the hash changes and the cache is invalidated.
The autoloader cache goes stale because of how it gets built. The first request on a server runs class discovery and writes the result to disk. After that the framework only checks whether the file exists, never whether it is still correct; Spatie's has() is a file_exists() call. So the first deploy that creates the file wins, and every later change is ignored until someone removes the directory by hand.
One detail makes the symptom intermittent. The cache stores the list of discovered class names, not the classes themselves, so a site only notices the staleness when that list changes; when a module is added, removed or renamed. That matches the report here: a new ModuleInterface class deployed onto an existing cache never gets picked up.
The shape I'd like feedback on
Move cache generation off the server and treat the cache as a build artefact.
Generation happens during the build, either in CI or locally, through a small command the framework ships; a vendor/bin script with no WordPress dependency. The resulting file deploys with everything else. At runtime the framework only ever reads: if the file is there it uses it, and if it isn't it discovers classes on each request, exactly as it would with no cache. It never writes.
This removes the failure mode rather than detecting it. If the server cannot write the cache, it cannot hold a stale one. The only file that can exist is the one the build produced for that exact deploy, so it always matches the code it shipped with. No hashing, no freshness checks, no background regeneration.
What it costs
Caching becomes opt-in at the pipeline level. A project that doesn't add the generate step runs uncached, which is correct but slower on each request. The cache directory wants to be gitignored and rebuilt on every deploy so an old copy can't linger.
On the framework side this stays close to Spatie's documented inline caching API for both the read and the generate. I looked at their higher-level StructureScout warming layer and it doesn't fit: it writes on a cache miss, and it assumes a fixed directory baked into the scout, where ours is passed in at init_classes() time.
Upgrading existing sites
Sites already carry a cache file written by the current version. The new read-only runtime can still read it, and because nothing rewrites it, that old file would keep being served straight after the upgrade. To avoid asking everyone to delete a directory by hand, I'd bump the cache identifier in this release. The new code then looks for a filename that doesn't exist yet, runs uncached until the build produces a fresh one, and leaves the old file as harmless cruft that a clean deploy clears.
Alternatives I ruled out
Runtime auto-invalidation, hashing the files on each request and rebuilding when they change, brings back a per-request cost and a fair amount of machinery, all to keep doing something the build can do once. The TENUP_FRAMEWORK_DISABLE_CLASS_CACHE constant from #26 already lets you switch caching off, though that avoids the bug rather than fixing it.
Questions for the group
Are we comfortable making caching depend on a build step, and running uncached when it's absent?
Are there hosts or workflows where adding that step isn't realistic?
Is a manual "clear the cache" command worth shipping, or is a redeploy enough?
I think making this part of the build process is the way to go. For Daryll's questions, running uncached until a redeployment through CI makes sense. While getting things working on hosts with write access (like SFTP) is a work-around, these projects are built to work through CI. If there is a broken autoloader cache after build, then it's appropriate to consider the whole build suspect and in need of resolution.
Another few questions/framings:
Since moving to CI build-deploy driven engineering, when have we built a project reliant on dynamic, drop-in, PHP files that aren't in the form of a separate WP plugin?
If there was a case for that, could it simply use a suggestion like Clayton wrote above for its own hash-based cache? Perhaps that suggestion can be part of a "Troubleshooting and Extended Usage" section?
@rleeson, agreed. If the cache is broken after a build, the build is the thing to fix; patching it over SFTP only hides something that comes back on the next deploy.
On the non-plugin question, I can't think of one either. Our modules are always loaded from within a plugin or theme, so the build step has a clear home. That also answers where the cache file goes: it's generated per plugin or theme, into the same directory passed to init_classes(), and shipped inside that plugin. A single cache at the project root wouldn't work once a few plugins share the scaffold; it would have no way of telling which classes belong to which, and we'd be back to scanning everything to find out.
On the hash-based version, I don't think it has a place here. To know whether the cache is still valid it has to walk every file on each request and hash them. If the hash matches, that's one full walk before we use the cache; if it doesn't, it's two; one to check, one to rebuild. So it lands at the same cost as running uncached at best, and worse on a miss, which means it doesn't buy us anything. If a project can't run the build step, I'd just run uncached. That's what we do on most VIP builds and we've not seen real performance problems from it. If you genuinely need the cache, my preference is that it comes from CI.
I'm going to state what I see as an unstated premise across the Frameworks documentation, please correct me if any of this is wrong.
The framework prefers a system of separate theme and plugin module stores focused around one directory to one namespace per Composer project. There are multiple packages along with an "unshared" overhead in duplicate vendor packages. This creates a trade-off where multiple groups of modules and caches on a larger project require more DevOps/CI work in exchange for "domain" focused functionality which can be readily decoupled.
It is plausible to use a set of namespaces defined at the root alongside creating a modified mono-repo style autoloader into the framework. However, that style of approach would require reengineering without solving any substantial problem, and creates an inherent coupling of all projects within the repository.
As we roll changes for the cache, can we improve guidance and language to reflect the intended implementation pattern?
If we are going to require the pipeline to build the cache, then caching should be opt in, not opt out as it currently stands. Some projects that use this framework only have a handful of classes and do not need caching. But they are being tripped up by this hidden caching layer that does not automatically bust on deployment. If we remove the automatic caching as it stand right now, this issue is solved. Then we have documentation to encourage and help people implmenet caching with the proper CI configuration if their project is experiencing performance issues.
@claytoncollie, agreed — and I think your opt-in framing and the build-time approach are the same change seen from two sides. If the runtime stops generating the cache on its own, the only way a cache exists is if a project deliberately produces one in its pipeline. That's opt-in by definition: do nothing and you run uncached, which is the right default for a project with a handful of classes and exactly the case being tripped up today. Removing the automatic layer closes this issue on its own.
From there, caching becomes something a project turns on when it has a performance reason to, with the docs showing how to wire the generate step into CI. No hidden behaviour, and no cache that outlives the code it was built from.
@rleeson, that ties into your point too. The reason caching has to be opt-in per project is that each plugin or theme is its own unit — its own namespace, directory and cache, required through Composer per package. That's the intended model, and you're right that we don't state it anywhere. I'll get it into the docs up front, along with the per-package vendor and CI trade-off, so none of it is left to infer.
Activity
[-]Autloader cache not refreshing[/-][+]Autoloader cache not refreshing[/+]claytoncollie commentedon Dec 18, 2025
@darylldoyle Can we create a hash of all PHP files in the directory and use that as the cache key? If any file changes, the hash changes and the cache is invalidated.
Potential solution
darylldoyle commentedon Jun 18, 2026
The autoloader cache goes stale because of how it gets built. The first request on a server runs class discovery and writes the result to disk. After that the framework only checks whether the file exists, never whether it is still correct; Spatie's
has()is afile_exists()call. So the first deploy that creates the file wins, and every later change is ignored until someone removes the directory by hand.One detail makes the symptom intermittent. The cache stores the list of discovered class names, not the classes themselves, so a site only notices the staleness when that list changes; when a module is added, removed or renamed. That matches the report here: a new
ModuleInterfaceclass deployed onto an existing cache never gets picked up.The shape I'd like feedback on
Move cache generation off the server and treat the cache as a build artefact.
Generation happens during the build, either in CI or locally, through a small command the framework ships; a
vendor/binscript with no WordPress dependency. The resulting file deploys with everything else. At runtime the framework only ever reads: if the file is there it uses it, and if it isn't it discovers classes on each request, exactly as it would with no cache. It never writes.This removes the failure mode rather than detecting it. If the server cannot write the cache, it cannot hold a stale one. The only file that can exist is the one the build produced for that exact deploy, so it always matches the code it shipped with. No hashing, no freshness checks, no background regeneration.
What it costs
Caching becomes opt-in at the pipeline level. A project that doesn't add the generate step runs uncached, which is correct but slower on each request. The cache directory wants to be gitignored and rebuilt on every deploy so an old copy can't linger.
On the framework side this stays close to Spatie's documented inline caching API for both the read and the generate. I looked at their higher-level
StructureScoutwarming layer and it doesn't fit: it writes on a cache miss, and it assumes a fixed directory baked into the scout, where ours is passed in atinit_classes()time.Upgrading existing sites
Sites already carry a cache file written by the current version. The new read-only runtime can still read it, and because nothing rewrites it, that old file would keep being served straight after the upgrade. To avoid asking everyone to delete a directory by hand, I'd bump the cache identifier in this release. The new code then looks for a filename that doesn't exist yet, runs uncached until the build produces a fresh one, and leaves the old file as harmless cruft that a clean deploy clears.
Alternatives I ruled out
Runtime auto-invalidation, hashing the files on each request and rebuilding when they change, brings back a per-request cost and a fair amount of machinery, all to keep doing something the build can do once. The
TENUP_FRAMEWORK_DISABLE_CLASS_CACHEconstant from #26 already lets you switch caching off, though that avoids the bug rather than fixing it.Questions for the group
fabiankaegy commentedon Jun 18, 2026
I have no concerns about making it dependent on a build step as we already so heavily rely on it for all other aspects of projects.
rleeson commentedon Jun 18, 2026
I think making this part of the build process is the way to go. For Daryll's questions, running uncached until a redeployment through CI makes sense. While getting things working on hosts with write access (like SFTP) is a work-around, these projects are built to work through CI. If there is a broken autoloader cache after build, then it's appropriate to consider the whole build suspect and in need of resolution.
Another few questions/framings:
darylldoyle commentedon Jun 18, 2026
@rleeson, agreed. If the cache is broken after a build, the build is the thing to fix; patching it over SFTP only hides something that comes back on the next deploy.
On the non-plugin question, I can't think of one either. Our modules are always loaded from within a plugin or theme, so the build step has a clear home. That also answers where the cache file goes: it's generated per plugin or theme, into the same directory passed to
init_classes(), and shipped inside that plugin. A single cache at the project root wouldn't work once a few plugins share the scaffold; it would have no way of telling which classes belong to which, and we'd be back to scanning everything to find out.On the hash-based version, I don't think it has a place here. To know whether the cache is still valid it has to walk every file on each request and hash them. If the hash matches, that's one full walk before we use the cache; if it doesn't, it's two; one to check, one to rebuild. So it lands at the same cost as running uncached at best, and worse on a miss, which means it doesn't buy us anything. If a project can't run the build step, I'd just run uncached. That's what we do on most VIP builds and we've not seen real performance problems from it. If you genuinely need the cache, my preference is that it comes from CI.
rleeson commentedon Jun 18, 2026
I'm going to state what I see as an unstated premise across the Frameworks documentation, please correct me if any of this is wrong.
The framework prefers a system of separate theme and plugin module stores focused around one directory to one namespace per Composer project. There are multiple packages along with an "unshared" overhead in duplicate vendor packages. This creates a trade-off where multiple groups of modules and caches on a larger project require more DevOps/CI work in exchange for "domain" focused functionality which can be readily decoupled.
It is plausible to use a set of namespaces defined at the root alongside creating a modified mono-repo style autoloader into the framework. However, that style of approach would require reengineering without solving any substantial problem, and creates an inherent coupling of all projects within the repository.
As we roll changes for the cache, can we improve guidance and language to reflect the intended implementation pattern?
claytoncollie commentedon Jun 19, 2026
If we are going to require the pipeline to build the cache, then caching should be opt in, not opt out as it currently stands. Some projects that use this framework only have a handful of classes and do not need caching. But they are being tripped up by this hidden caching layer that does not automatically bust on deployment. If we remove the automatic caching as it stand right now, this issue is solved. Then we have documentation to encourage and help people implmenet caching with the proper CI configuration if their project is experiencing performance issues.
darylldoyle commentedon Jun 22, 2026
@claytoncollie, agreed — and I think your opt-in framing and the build-time approach are the same change seen from two sides. If the runtime stops generating the cache on its own, the only way a cache exists is if a project deliberately produces one in its pipeline. That's opt-in by definition: do nothing and you run uncached, which is the right default for a project with a handful of classes and exactly the case being tripped up today. Removing the automatic layer closes this issue on its own.
From there, caching becomes something a project turns on when it has a performance reason to, with the docs showing how to wire the generate step into CI. No hidden behaviour, and no cache that outlives the code it was built from.
@rleeson, that ties into your point too. The reason caching has to be opt-in per project is that each plugin or theme is its own unit — its own namespace, directory and cache, required through Composer per package. That's the intended model, and you're right that we don't state it anywhere. I'll get it into the docs up front, along with the per-package vendor and CI trade-off, so none of it is left to infer.
Move class-loader cache to build-time and add loader debug page
Surface corrupt-cache fallback on the debug page
fail the build when the class cache is not written; resolve review nits
fail the build when the class cache is not written; resolve review nits