DEV Community

Cover image for Laravel Class Not Found After Deploy: Autoload & Cache Fixes
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Class Not Found After Deploy: Autoload & Cache Fixes

The app worked on your machine and in CI. You deploy, and production answers with Class "App\Services\InvoiceService" not found, or worse, a white page because a service provider cannot be loaded. The class is clearly in your repository, so something between your code and the running process is stale, missing or spelled differently. This guide covers the seven causes I would check, in the order they usually turn up, with the exact errors, how to confirm each one, and the fix. I reproduced the main ones with real Composer 2.8 output and with Laravel's own package manifest code, and the article says where that is not the case.

Read the error before you touch anything

Where the error comes from narrows it down quickly:

What the error names Most likely cause
One of your own classes, only on Linux File name case does not match the class name (cause 1)
A class you just added, while older classes load fine Authoritative classmap not regenerated (cause 2)
A class from a package such as Faker, Pail or Collision Dev package missing after --no-dev, or a stale package cache (causes 3 and 4)
A service provider, and the whole site is down Stale bootstrap/cache/packages.php (cause 4)
Old behavior or "class not found" on some requests only OPcache or old worker processes (causes 5 and 6)
Nothing in the autoloader seems wrong The file never reached the server (cause 7)

Cause 1: the file name has the wrong case

This is the classic "works on my laptop" failure. macOS and Windows file systems are typically case-insensitive, so a class named PaymentService stored in paymentService.php loads without complaint. Linux treats those as two different names. PSR-4 autoloading derives the file path from the class name, so the lookup for PaymentService.php fails.

I reproduced it with a PSR-4 mapping of App\ to app/ and a file saved as app/Services/paymentService.php on a Linux file system:

composer dump-autoload -o
Enter fullscreen mode Exit fullscreen mode
Generating optimized autoload files
Class App\Services\PaymentService located in ./app/Services/paymentService.php does not comply with psr-4 autoloading standard (rule: App\ => ./app). Skipping.
Generated optimized autoload files containing 2 classes
Enter fullscreen mode Exit fullscreen mode
Error: Class "App\Services\PaymentService" not found
Enter fullscreen mode Exit fullscreen mode

Composer tells you exactly which file is wrong, but only if you read the output of an optimized dump. A plain composer dump-autoload says nothing, and the class is simply missing at runtime.

Fix: rename the file so its name matches the class exactly. Case-only renames can be missed by Git on a case-insensitive file system, so do the rename in two steps and commit:

git mv app/Services/paymentService.php app/Services/tmp.php
git mv app/Services/tmp.php app/Services/PaymentService.php
Enter fullscreen mode Exit fullscreen mode

Prevent it: add a check to CI that runs on Linux. With --strict-psr, Composer exits with a non-zero status when a mapping is wrong. In my test the same case mismatch returned exit code 1, and the exit code was 0 once the file was renamed:

composer dump-autoload -o --strict-psr
Enter fullscreen mode Exit fullscreen mode

Cause 2: a new class and an authoritative classmap

Composer can optimize the autoloader in two levels, as described in its documentation. With -o (--optimize-autoloader), it converts PSR-4 rules into a classmap, but a class missing from the map still falls back to the file system. With -a (--classmap-authoritative), the map is final: if a class is not in it, Composer will not look anywhere else. The documentation warns this can cause "class not found" problems in production.

The difference in a real run, adding a new class file after the autoloader was generated:

Step Result for the new class
composer dump-autoload -o, then add ReportService.php Found (PSR-4 fallback)
composer dump-autoload -a, then add ExportService.php Not found
Run composer dump-autoload -a again Found

So if your deploy copies new code after Composer built an authoritative map, for example a Docker image where the autoloader was dumped in an earlier stage, or a script that runs composer install -a before rsync, the new classes will not exist as far as PHP is concerned.

Fix: make sure the autoloader is generated after all application files are in place, as the last step that touches code:

composer dump-autoload --no-dev --optimize --classmap-authoritative
Enter fullscreen mode Exit fullscreen mode

Also remember that a classmap is only as complete as the directories Composer scans. If you add a new top-level namespace or a directory that is not covered by autoload in composer.json, add it to composer.json and dump again. Laravel's default skeleton already enables optimize-autoloader in its Composer config, so Level 1 is on in most projects without you asking for it.

Cause 3: the class lives in a dev package

On production you should run composer install --no-dev, which skips everything under require-dev. In Laravel's default skeleton that list includes fakerphp/faker, laravel/pail, laravel/pint, laravel/sail, mockery/mockery, nunomaduro/collision and phpunit/phpunit. Anything in your application code that touches those classes will fail in production with the same "class not found" message. Typical examples:

  • Running php artisan db:seed on production, where your factories use Faker.
  • A seeder or console command that calls a Pint, Collision or PHPUnit class.
  • A service provider that registers a dev tool unconditionally.

Fix: decide whether the code is really needed in production. If it is, move the package from require-dev to require. If it is not, keep it out of the production code path: register dev-only providers conditionally, and do not run factories or seeders that depend on Faker in an environment installed with --no-dev.

Cause 4: a stale package cache in bootstrap/cache

Laravel keeps a package manifest at bootstrap/cache/packages.php. It lists the service providers of every installed package that uses auto-discovery. I read the framework source (PackageManifest, tag v13.35.0): the manifest is only rebuilt when the file does not exist. If the file is there, Laravel loads it as-is, even if the package behind it was removed.

I reproduced the consequence. First, a manifest built when a dev package (Laravel Pail) was installed:

Acme\Payments\PaymentsServiceProvider
Laravel\Pail\PailServiceProvider
Enter fullscreen mode Exit fullscreen mode

Then I removed that package, which is what composer install --no-dev does, and read the manifest again with the old cache file in place. It still listed both providers. Booting the second one:

Error: Class "Laravel\Pail\PailServiceProvider" not found
Enter fullscreen mode Exit fullscreen mode

After running the equivalent of php artisan package:discover, which calls the manifest builder, the stale entry was gone.

How does a stale manifest get onto a server? In the default skeleton, Composer's post-autoload-dump script clears the cached config, services and packages files and then runs php artisan package:discover, so a normal composer install fixes itself. It breaks when:

  • You deploy with --no-scripts, which skips that cleanup. Docker builds commonly do this.
  • bootstrap/cache/*.php is copied from a developer machine or a CI job that had dev dependencies installed.
  • Vendor files and application files are assembled from different builds.

Fix on the broken server:

php artisan clear-compiled
php artisan package:discover
Enter fullscreen mode Exit fullscreen mode

If Artisan itself cannot boot because of the broken provider, delete the files directly and run discovery again:

rm -f bootstrap/cache/packages.php bootstrap/cache/services.php
php artisan package:discover
Enter fullscreen mode Exit fullscreen mode

Prevent it: never commit or ship bootstrap/cache/*.php (the skeleton's .gitignore already ignores them), and make sure the cache directory is writable by the user who runs the deploy. The framework throws "The ... directory must be present and writable." if it needs to build the manifest and cannot write it.

A warning about php artisan optimize:clear: the Laravel documentation and the command source both show that it also runs cache:clear, so it removes the keys in your default cache store along with the compiled files. On a production server that shares a Redis cache with live traffic, that is a lot more than you wanted. Prefer the narrower clear-compiled and package:discover above when the problem is only the package cache.

Cause 5: OPcache is still serving the old files

If you set opcache.validate_timestamps=0 (the usual production setting, and one I recommended in the Docker article), PHP never checks whether a file changed. The PHP manual says that when this directive is disabled, you must reset OPcache manually with opcache_reset() or opcache_invalidate(), or restart the web server, for file changes to take effect. The opcache.revalidate_freq setting is ignored in that mode.

After an in-place deploy, PHP-FPM workers can keep serving compiled copies of the old files. A new file may load while an old file that refers to it is still the cached version, so you can get "class not found" or plain old behavior, sometimes only on some requests. A CLI command such as php artisan will not show the problem, because the CLI uses its own process and does not share the web server's cache.

Fix: reload or restart PHP-FPM as the last step of every deploy. The service name depends on your distribution and PHP version; for example:

sudo systemctl reload php8.3-fpm
Enter fullscreen mode Exit fullscreen mode

Running opcache_reset() from a CLI script resets the CLI process only, not the FPM pool, so use the service reload (or a protected web endpoint that calls it) instead. Container-based deploys avoid the problem entirely, because a new container starts with an empty cache.

Cause 6: queue workers are still running old code

Queue workers boot your application once and keep it in memory. After a deploy, the web side can dispatch a job class that did not exist when the worker started, and the worker then fails to run it. The Laravel deployment documentation says long-running services should be reloaded after deploying, and provides:

php artisan reload
Enter fullscreen mode Exit fullscreen mode

On older versions use php artisan queue:restart. The workers stop after their current job, so a process manager such as Supervisor has to start them again. The same applies to Octane and Reverb. I covered this in more detail in the production mistakes article.

Cause 7: the file or the mapping never made it

Before debugging the autoloader for an hour, check the boring explanations:

  • Not committed. A new file that was never added to Git exists only on your machine. Run git status and check that the file is tracked.
  • Ignored. A broad .gitignore pattern, or an --exclude in your rsync command, can silently drop a directory.
  • Wrong namespace. A class whose namespace line does not match its folder is skipped by the PSR-4 rule just like a case mismatch.
  • Different build. You deployed an older artifact, or the server pulled a different branch.

A quick way to separate "autoloader problem" from "file problem" on the server:

ls -l app/Services/InvoiceService.php
php -r 'require "vendor/autoload.php"; var_dump(class_exists("App\\Services\\InvoiceService"));'
Enter fullscreen mode Exit fullscreen mode

If the file is missing, it is a deploy problem. If the file is present and class_exists returns false, it is one of causes 1, 2 or the namespace.

A deploy order that avoids all of this

git pull --ff-only                         # or unpack the release artifact
composer install --no-dev --no-interaction --optimize-autoloader
php artisan migrate --force --isolated
php artisan optimize                       # config, events, routes and views
sudo systemctl reload php8.3-fpm           # fresh OPcache
php artisan reload                         # workers; use queue:restart before Laravel 13
Enter fullscreen mode Exit fullscreen mode

Two notes on this script. composer install runs the skeleton's post-install scripts, which clear stale compiled files and rediscover packages, so leave --no-scripts off on a server deploy. And optimize runs after Composer, never before, because it caches the configuration, events, routes and views that the previous step may have changed. In Laravel 13.35 those four are exactly what optimize runs; its caches do not include the package manifest.

If you build images with --no-scripts, for example in the multi-stage Docker setup, run php artisan package:discover in the final stage after the code and vendor directory are in place. Otherwise Laravel builds the manifest on first boot, which needs a writable bootstrap/cache.

Quick checklist

  1. Run composer dump-autoload -o --strict-psr in CI, on Linux.
  2. Generate the final autoloader after all application files are in place.
  3. Deploy without bootstrap/cache/*.php, and without --no-scripts unless you run package:discover yourself.
  4. Keep anything production needs out of require-dev.
  5. Reload PHP-FPM when OPcache timestamps are off.
  6. Restart queue workers on every release.

References: Composer: Autoloader Optimization, Laravel: Deployment, PHP Manual: OPcache configuration and Laravel skeleton composer.json.


Originally published on DEV Talk.

Top comments (0)