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
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
Error: Class "App\Services\PaymentService" not found
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
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
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
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:seedon 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
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
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/*.phpis 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
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
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
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
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 statusand check that the file is tracked. -
Ignored. A broad
.gitignorepattern, or an--excludein your rsync command, can silently drop a directory. -
Wrong namespace. A class whose
namespaceline 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"));'
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
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
- Run
composer dump-autoload -o --strict-psrin CI, on Linux. - Generate the final autoloader after all application files are in place.
- Deploy without
bootstrap/cache/*.php, and without--no-scriptsunless you runpackage:discoveryourself. - Keep anything production needs out of
require-dev. - Reload PHP-FPM when OPcache timestamps are off.
- 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)