Skip to content

Autoloader cache not refreshing #30

Open

Description

@NicoleGeissinger

Describe the bug

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

  1. Create a new class using the ModuleInterface and deploy to your server.
  2. Make some changes that you can observe either from the front or backend (both affected) and deploy.
  3. Double check if your changes can be observed and double check the last modified date of the class-loader-cache and your autoloaded classes.
  4. 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

Image 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.

WordPress information

WordPress 6.8.3

Code of Conduct

  • I agree to follow this project's Code of Conduct

Activity

  1. changed the title [-]Autloader cache not refreshing[/-] [+]Autoloader cache not refreshing[/+] on Nov 14, 2025
  2. added this to the 1.4.0 milestone on Nov 17, 2025
  3. claytoncollie commented on Dec 18, 2025

    @claytoncollie
    Contributor

    @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

    protected function get_cache_hash( string $dir ): string {
        $files = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator( $dir, \RecursiveDirectoryIterator::SKIP_DOTS )
        );
        
        $hash_data = [];
        foreach ( $files as $file ) {
            if ( $file->isFile() && $file->getExtension() === 'php' ) {
                // Use file path + modification time for hash
                $hash_data[] = $file->getPathname() . ':' . $file->getMTime();
            }
        }
        
        sort( $hash_data ); // Ensure consistent ordering
        return md5( implode( '|', $hash_data ) );
    }
    
    public function get_classes( $dir ) {
        $this->directory_check( $dir );
    
        $class_finder = Discover::in( $dir );
        $class_finder->classes();
        $class_finder->withoutChains();
    
        if ( $this->should_use_cache() ) {
            $cache_hash = $this->get_cache_hash( $dir );
            $class_finder->withCache(
                __NAMESPACE__ . '_' . $cache_hash,
                new FileDiscoverCacheDriver( $dir . '/class-loader-cache' )
            );
        }
    
        $classes = array_filter( $class_finder->get(), fn( $cl ) => is_string( $cl ) );
    
        return $classes;
    }
  4. darylldoyle commented on Jun 18, 2026

    @darylldoyle
    Collaborator

    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?
  5. fabiankaegy commented on Jun 18, 2026

    @fabiankaegy
    Member

    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.

  6. rleeson commented on Jun 18, 2026

    @rleeson

    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?
  7. darylldoyle commented on Jun 18, 2026

    @darylldoyle
    Collaborator

    @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.

  8. rleeson commented on Jun 18, 2026

    @rleeson

    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?

  9. claytoncollie commented on Jun 19, 2026

    @claytoncollie
    Contributor

    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.

  10. darylldoyle commented on Jun 22, 2026

    @darylldoyle
    Collaborator

    @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.

  11. modified the milestones: 1.4.0, 2.0.0 on Jun 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions