The problem: After upgrading to a recent Laravel version, Storage::disk('local')->get(...) throws a file-not-found error for files that were working perfectly fine before the upgrade.
Environment: Laravel 12/13, PHP 8.3, default filesystems.php config (unmodified local disk)
The Error
League\Flysystem\UnableToReadFile: Unable to read file from location:
"invoices/invoice-2044.pdf". Unable to read file: file does not exist.
Illuminate\Contracts\Filesystem\FileNotFoundException
Why It Happens
Newer Laravel versions changed a default that's easy to miss because it doesn't touch a single line of your application code — only the framework's internal default value. If your application doesn't explicitly define a local disk in filesystems.php, Laravel now defaults the local disk's root to storage/app/private instead of storage/app.
This means every call to Storage::disk('local') now reads from and writes to a completely different folder than it did before the upgrade — but the files you stored before the upgrade are still physically sitting in the old storage/app directory. Nothing crashed at upgrade time because the change is silent; it only surfaces the first time your app tries to read a file that predates the upgrade.
It's especially confusing because new files written after the upgrade save and read back correctly — since both the write and the read now consistently use the new private path. Only previously stored files, or any hardcoded paths/URLs pointing at the old location, break.
The Fix
Fastest fix if you want the old behavior back exactly as it was — explicitly define the local disk's root in your config instead of relying on the framework default:
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'serve' => true,
'throw' => false,
],
],
If you'd rather adopt the new default going forward (recommended long-term, since storage/app/private is meant to keep non-public files out of anything web-accessible), move your existing files to match instead:
mkdir -p storage/app/private
rsync -av --exclude='private' storage/app/ storage/app/private/
Either way, audit your codebase for any hardcoded paths that assumed the old storage/app location directly, rather than going through the Storage facade:
- $path = storage_path('app/' . $filename);
+ $path = Storage::disk('local')->path($filename);
Step-by-Step
- Check your current Laravel version and confirm you're on a version where this default shipped (Laravel 12+).
- Open
config/filesystems.phpand check whether thelocaldisk'srootkey is explicitly set or left to the framework default. - Decide: keep old behavior (explicit
rootoverride) or migrate to the new default (move files tostorage/app/private). - If migrating files, use
rsyncor a manual copy to move existing contents, then verify file counts match between old and new locations before deleting anything. - Search the codebase for hardcoded
storage_path('app/...')calls and replace them withStorage::disk('local')->path(...)so future path changes don't break the same way again. - Test both an old (pre-upgrade) file and a newly-uploaded file to confirm both read correctly from the same disk.
Edge case: If your app uses Storage::disk('local')->url(...) anywhere to generate publicly accessible links, be aware that storage/app/private is intentionally not meant to be web-accessible — the naming is a deliberate signal from Laravel that this disk is for non-public files. If you were relying on the old local disk for anything user-facing (direct download links, embedded images), you likely want the public disk with php artisan storage:link instead, not a config override that quietly keeps sensitive files reachable from the old public-facing pattern.
Still seeing file-not-found errors after checking the disk config? Reach out on the Contact page and I'll help trace your specific setup.