Laravel for Beginners: Setting Up Your First Project
A complete first-day guide to Laravel — installation, folder structure, your first route, and your first migration.
How to Fix Laravel Storage 403 Forbidden on cPanel Using Symbolic Links
If Laravel images or uploaded files are not loading from URLs such as:
https://yourdomain.com/storage/media/image.jpg
and the server returns 403 Forbidden, the issue is often related to how the Laravel storage directory is exposed through the domain's document root.
This guide explains how to correctly configure Laravel storage symlinks when your Laravel project is located outside the domain's public_html directory on cPanel.
In this setup, the Laravel application is located at:
/home/codewith/codewithbikesh
while the domain's document root is:
/home/codewith/public_html
The Laravel storage directory is:
/home/codewith/codewithbikesh/storage/app/public
The project structure looks like this:
/home/codewith/
│
├── public_html/ ← Domain document root
│ ├── index.php
│ ├── .htaccess
│ └── storage -> ../codewithbikesh/public/storage
│
└── codewithbikesh/ ← Laravel project
├── app/
├── bootstrap/
├── config/
├── routes/
├── public/
│ └── storage -> ../storage/app/public
│
└── storage/
└── app/
└── public/
└── media/
└── image.jpg
Why Does the 403 Error Happen?
Laravel normally creates this symbolic link:
public/storage
↓
storage/app/public
This allows files inside:
storage/app/public
to be accessed through:
/storage/...
However, in this particular cPanel configuration, the domain is not directly using codewithbikesh/public as its document root.
The domain is using:
/home/codewith/public_html
Therefore, when a visitor requests:
https://yourdomain.com/storage/media/image.jpg
Apache initially looks for:
/home/codewith/public_html/storage/media/image.jpg
But Laravel's storage symlink exists here:
/home/codewith/codewithbikesh/public/storage
These are two different locations.
Therefore, we need a second symbolic link inside public_html.
Step 1 — Go to the Laravel Project
Open your cPanel Terminal and navigate to the Laravel project:
cd ~/codewithbikesh
Step 2 — Remove the Existing Storage Link
Remove the existing Laravel storage link:
rm -rf public/storage
Warning: This removes only the symbolic link, not your uploaded files inside storage/app/public.
Step 3 — Create Laravel's Storage Link
Run:
php artisan storage:link --relative
You should see:
INFO The [public/storage] link has been connected to [storage/app/public].
Verify it:
ls -la public/storage
Expected output:
public/storage -> ../storage/app/public/
--relative Produces an ErrorYou may see:
RuntimeException
To enable support for relative links, please install the symfony/filesystem package.
If this happens, install the required package:
composer require symfony/filesystem
Then recreate the storage link:
rm -rf public/storage
php artisan storage:link --relative
Verify again:
ls -la public/storage
Step 4 — Verify the Uploaded File Exists
Before troubleshooting Apache or permissions, make sure the actual image exists.
Check the media directory:
ls -lah storage/app/public/media/
Or check a specific image:
ls -lah storage/app/public/media/IMAGE_NAME.jpg
For example:
ls -lah storage/app/public/media/QIQzYP4K0yMCeTdlMIfPonIrvf2J9A09MjNXQRMC.jpg
If the file exists, you should see something similar to:
-rw-r--r-- 1 codewith codewith 121K ... IMAGE_NAME.jpg
If the file does not exist, the problem is not the symbolic link. You need to check where Laravel is actually storing the uploaded file.
Step 5 — Check Directory Permissions
Check the permissions of the storage directories:
ls -ld storage
ls -ld storage/app
ls -ld storage/app/public
ls -ld storage/app/public/media
Typical directory permissions should be:
755
If necessary:
chmod 755 storage
chmod 755 storage/app
chmod 755 storage/app/public
chmod 755 storage/app/public/media
For individual image files, 644 is normally appropriate:
chmod 644 storage/app/public/media/IMAGE_NAME.jpg
Step 6 — Create the Important public_html Storage Link
This is the most important step when your domain's document root is public_html.
First, go to the domain document root:
cd ~/public_html
Remove an existing storage link or directory:
rm -rf storage
Now create a symbolic link:
ln -s ../codewithbikesh/public/storage storage
This creates:
/home/codewith/public_html/storage
which points to:
/home/codewith/codewithbikesh/public/storage
Step 7 — Verify the public_html Symlink
Run:
ls -la storage
You should see:
storage -> ../codewithbikesh/public/storage
Now verify the complete destination:
readlink -f storage
The expected result is:
/home/codewith/codewithbikesh/storage/app/public
This confirms that both symbolic links are working correctly.
Understanding the Complete Storage Flow
The final configuration looks like this:
Browser
│
│ https://yourdomain.com/storage/media/image.jpg
↓
/home/codewith/public_html/storage
│
│ Symlink
↓
/home/codewith/codewithbikesh/public/storage
│
│ Symlink
↓
/home/codewith/codewithbikesh/storage/app/public
│
↓
media/image.jpg
This is the key concept behind the fix.
Step 8 — Test the Image URL
After creating the symlink, test the actual image URL from the server:
curl -I https://yourdomain.com/storage/media/IMAGE_NAME.jpg
A successful response should look like:
HTTP/2 200
or:
HTTP/1.1 200 OK
You can then open the same URL directly in your browser:
https://yourdomain.com/storage/media/IMAGE_NAME.jpg
Real Example
Suppose the uploaded image is:
storage/app/public/media/QIQzYP4K0yMCeTdlMIfPonIrvf2J9A09MjNXQRMC.jpg
The public URL becomes:
https://codewithbikesh.com/storage/media/QIQzYP4K0yMCeTdlMIfPonIrvf2J9A09MjNXQRMC.jpg
The request travels through:
https://codewithbikesh.com/storage/
↓
/home/codewith/public_html/storage
↓
/home/codewith/codewithbikesh/public/storage
↓
/home/codewith/codewithbikesh/storage/app/public
↓
media/QIQzYP4K0yMCeTdlMIfPonIrvf2J9A09MjNXQRMC.jpg
Quick Fix for Future Projects
If you encounter the same setup again, these are the essential commands:
cd ~/codewithbikesh
rm -rf public/storage
php artisan storage:link --relative
Then create the additional link from the domain document root:
cd ~/public_html
rm -rf storage
ln -s ../codewithbikesh/public/storage storage
Verify:
readlink -f storage
Expected:
/home/codewith/codewithbikesh/storage/app/public
Finally test:
curl -I https://yourdomain.com/storage/media/IMAGE_NAME.jpg
Troubleshooting Checklist
If the image is still returning 403 Forbidden, check these items in order:
ls -lah storage/app/public/media/
ls -la ~/codewithbikesh/public/storage
ls -la ~/public_html/storage
readlink -f ~/public_html/storage
It should point to:
/home/codewith/codewithbikesh/storage/app/public
Directories:
755
Files:
644
curl -I https://yourdomain.com/storage/media/IMAGE_NAME.jpg
If everything above is correct but you still receive 403, check:
.htaccessImportant Notes
storage/app/publicYour uploaded files are stored there.
The command:
rm -rf public/storage
is intended to remove the symbolic link, not your actual uploaded files.
If:
readlink -f ~/public_html/storage
already returns:
/home/codewith/codewithbikesh/storage/app/public
then the symlink is working. Move on to checking permissions, Apache configuration, or the actual HTTP response.
If storage is the only problem, avoid:
composer update
A full dependency update can change many packages and potentially introduce compatibility problems.
Only install the required package if Laravel specifically reports that symfony/filesystem is missing:
composer require symfony/filesystem
Conclusion
Laravel's storage:link normally solves public file access, but cPanel setups where the domain document root is public_html and the Laravel project is located elsewhere can require an additional symbolic link.
The final setup is:
public_html/storage
↓
codewithbikesh/public/storage
↓
codewithbikesh/storage/app/public
↓
media/image.jpg
Once this chain is correctly established, URLs such as:
https://yourdomain.com/storage/media/image.jpg
can be served directly by the web server.
The most important lesson is:
Always check the actual domain document root. The storage symlink must ultimately be reachable from the directory Apache is serving.
A complete first-day guide to Laravel — installation, folder structure, your first route, and your first migration.
API routes, resources, Form Request validation, correct status codes, and Sanctum auth — a real, production-reasonable Laravel API.
One-to-many, many-to-many, polymorphic, and the N+1 query trap that catches almost every Laravel developer at least once.