To display an image from the storage folder in Laravel 13, store the file on the public disk, run php artisan storage:link once, and then use Storage::url($path) or asset('storage/' . $path) as the image source. Files in storage/app/public are not reachable by the web server until that symbolic link exists. This guide targets Laravel 13 and PHP 8.3 or newer.
Why can't the browser open files in storage/app?
Only the public directory is served by your web server. The storage/app/private and storage/app/public folders sit outside it. The storage:link command creates a symbolic link from public/storage to storage/app/public, so files in the second location get a public URL while the rest of storage stays private.
Prerequisites
- Laravel 13 on PHP 8.3 or newer.
- Permission to create symbolic links on your server or dev machine. On Windows, run the terminal as administrator the first time if the command fails.
Step 1: Create the symbolic link
php artisan storage:linkThis reads the links array in config/filesystems.php. By default, public_path('storage') points to storage_path('app/public'). Run it once per environment, including after each fresh deployment to a new server or container, because a symlink is not stored in version control.
Step 2: How do you upload and save the file?
<?php
namespace App\Http\Controllers;
use App\Models\Photo;
use Illuminate\Http\Request;
class PhotoController extends Controller
{
public function store(Request $request)
{
$request->validate([
'title' => ['required', 'string', 'max:150'],
'image' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
]);
$path = $request->file('image')->store('photos', 'public');
Photo::create([
'title' => $request->input('title'),
'path' => $path,
]);
return back()->with('status', 'Photo uploaded.');
}
}The store() method generates a unique file name and returns the relative path, such as photos/Xk3f...jpg. Save that path in the database, not the full URL, so you can change your domain or move to cloud storage later.
Step 3: Show the image in Blade
{{-- resources/views/photos/index.blade.php --}}
@foreach ($photos as $photo)
<figure>
<img src="{{ Storage::disk('public')->url($photo->path) }}"
alt="{{ $photo->title }}"
width="320" loading="lazy">
<figcaption>{{ $photo->title }}</figcaption>
</figure>
@endforeachYou can also write asset('storage/' . $photo->path). Storage::url() is the better choice because it follows the disk configuration. If you switch the disk to S3 later, the same view code still works.
How do you set the correct URL?
The public disk builds URLs from APP_URL plus /storage. If images show a wrong host or port, such as http://localhost when you run on another port, fix APP_URL in .env and clear the config cache:
php artisan config:clearCan you add a fallback when the file is missing?
@php
$exists = $photo->path && Storage::disk('public')->exists($photo->path);
@endphp
<img src="{{ $exists ? Storage::disk('public')->url($photo->path) : asset('images/placeholder.png') }}"
alt="{{ $photo->title }}">How do you serve private files?
Files that only logged-in users should see belong on the local disk, not the public one. Serve them through a controller that checks permissions:
public function show(Photo $photo)
{
$this->authorize('view', $photo);
return Storage::disk('local')->response($photo->path);
}For temporary access to private files on S3-compatible disks, Laravel can create temporary URLs with Storage::temporaryUrl().
How do you delete or replace the image?
Deleting the database row does not delete the file. Remove the file when you replace or delete a record, otherwise unused uploads fill the disk over time.
public function update(Request $request, Photo $photo)
{
$request->validate(['image' => ['required', 'image', 'max:2048']]);
Storage::disk('public')->delete($photo->path);
$photo->update([
'path' => $request->file('image')->store('photos', 'public'),
]);
return back();
}
public function destroy(Photo $photo)
{
Storage::disk('public')->delete($photo->path);
$photo->delete();
return back();
}How do you test uploads without touching real files?
Laravel can fake a disk, so tests never write to your real storage folder.
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
it('stores an uploaded photo', function () {
Storage::fake('public');
$this->actingAs(User::factory()->create())
->post('/photos', [
'title' => 'Sunset',
'image' => UploadedFile::fake()->image('sunset.jpg'),
])
->assertRedirect();
$photo = Photo::first();
Storage::disk('public')->assertExists($photo->path);
});The fake disk has no symlink, so this test checks storing and validation only. Use a manual browser check to confirm the public URL works.
Common mistakes
- Not running
php artisan storage:link, which gives a 404 for every image. - Saving the file on the default
localdisk but looking for it under the public URL. Pass'public'as the disk name. - Storing the complete URL in the database instead of the relative path.
- Wrong
APP_URL, which produces links to the wrong host. - Copying the project to a new server with an old, broken symlink. Delete
public/storageand run the command again. - Allowing any file type. Always validate with
imageandmimes.
How do you verify it works?
- Confirm
public/storageexists and opensstorage/app/public. - Upload a test image and check that it appears in
storage/app/public/photos. - Open the URL printed in the page source in a new tab. It should show the image.
- Run
php artisan tinkerand callStorage::disk('public')->url('photos/example.jpg')to inspect the generated URL.
FAQ
Why do I get a 403 or 404 for /storage/file.jpg?
The most common reasons are a missing symlink, a wrong disk, or incorrect permissions on the folder. Re-run storage:link and check the file exists.
Does storage:link work in Docker?
Yes, but run it inside the container, or in your entrypoint script, so the link points to the paths inside the container.
Can I use a different link location?
Yes. Add entries to the links array in config/filesystems.php and run the command again.
Is it safe to expose storage/app/public?
Yes for files meant to be public. Never put private documents there.



