Part of the guide: Laravel on cPanel & Shared Hosting: The Complete Guide
Laravel Storage Link Not Working on Shared Hosting: Fix
Your Laravel app saves an uploaded image successfully. The file exists in storage/app/public, but the browser returns 404 Not Found.
When a Laravel storage link is not working on shared hosting, the problem is usually one of five things: the link is missing, Laravel created it outside the actual web root, an old symlink points to another machine, the hosting provider restricts symbolic links, or the generated image URL is incorrect.
The good news is that you can diagnose these problems without reinstalling Laravel or moving your entire application into public_html.
Quick answer: Laravel's php artisan storage:link normally connects public/storage to storage/app/public. On cPanel, the actual document root may be /home/username/public_html, while the Laravel application lives in /home/username/laravel-app.
In that layout, change the links configuration to point at the real web root:
'links' => [
base_path('../public_html/storage') => storage_path('app/public'),
],
Then clear Laravel's configuration cache and create the link:
cd /home/username/laravel-app
php artisan config:clear
php artisan storage:link
Replace username and laravel-app with your actual account and project directory.
These commands work only if the hosting environment allows symbolic links and the destination does not contain an incompatible existing file or directory.
If the command fails, or the images still return 404, use the checks below to find the exact cause.
Why Laravel Storage Link Is Not Working on Shared Hosting
Laravel separates publicly accessible files from the application's private storage.
In Laravel 12, the default public filesystem disk looks like this:
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
This configuration stores public uploads in:
storage/app/public
But browsers cannot automatically access that directory.
Laravel expects a symbolic link:
public/storage -> storage/app/public
The link provides a public URL without relocating the original files.
For example:
Physical file:
storage/app/public/photos/car.jpg
Public URL:
https://example.com/storage/photos/car.jpg
What php artisan storage:link actually does
The command reads the links array in config/filesystems.php.
A standard Laravel 12 installation contains:
'links' => [
public_path('storage') => storage_path('app/public'),
],
The array key identifies where the symbolic link should be created.
The value identifies the directory it points to.
Laravel provides several related commands:
| Command | Purpose |
|---|---|
php artisan storage:link | Creates configured symbolic links |
php artisan storage:link --force | Replaces existing symbolic links |
php artisan storage:link --relative | Creates relative links when the required Symfony filesystem component is installed |
php artisan storage:unlink | Removes configured symbolic links |
There are two details worth understanding.
First, --force does not mean "delete anything at this path."
In Laravel 12, the command can replace a symbolic link, but it does not delete a normal directory occupying the destination.
Second, storage:unlink works against the configured links. Inspect the configuration before using it on a production application with multiple storage links.
A common mistake: uploading to the wrong disk
A perfectly working symbolic link cannot expose a file stored on the wrong disk.
In a standard Laravel 12 installation, the default local disk uses:
storage/app/private
For example:
$path = $request->file('photo')->store('photos');
Without an explicit disk, the upload uses the configured default filesystem disk.
If that disk is local, the file is not stored in the public directory.
For files intended to be publicly accessible, use:
$path = $request->file('photo')->store('photos', 'public');
Generate its URL with:
$url = Storage::disk('public')->url($path);
If these statements appear in a controller, import the facade:
use Illuminate\Support\Facades\Storage;
Do not change the default filesystem disk to public merely to make an image work. Other application uploads may contain confidential information.
Employee records, identification documents, contracts, and similar files should remain private.
Diagnose Laravel Storage 404 and 403 Errors on cPanel
Before editing configuration files or deleting existing links, inspect the directory structure.
Suppose your hosting account uses this layout:
/home/username/
|
|-- laravel-app/
| |-- app/
| |-- bootstrap/
| |-- config/
| |-- public/
| |-- storage/
| | |-- app/
| | |-- public/
| |-- artisan
| |-- .env
|
|-- public_html/
| |-- index.php
| |-- .htaccess
| |-- storage
Here, public_html is the domain's document root.
The Laravel application's own public directory exists outside that document root.
That distinction explains why storage:link can complete while images remain inaccessible.
Step 1: inspect the existing storage link
If your cPanel account provides Terminal, run:
ls -ld /home/username/public_html/storage
A working symbolic link should resemble:
lrwxrwxrwx ... storage -> /home/username/laravel-app/storage/app/public
The important information is the arrow:
storage -> /home/username/laravel-app/storage/app/public
It identifies the link target.
If the output begins with d, such as:
drwxr-xr-x ... storage
the destination is an ordinary directory, not a symbolic link.
A normal directory may contain copied uploads, but it does not automatically reflect new files saved elsewhere.
If the command returns:
No such file or directory
the storage entry is missing.
To inspect a link's stored target without following it, use:
readlink /home/username/public_html/storage
To determine whether a symbolic link is broken:
test -L /home/username/public_html/storage && echo "Symlink exists"
test -e /home/username/public_html/storage && echo "Target is accessible"
If the first command prints a message but the second does not, the link may be dangling, or its target may be inaccessible.
Unlike test -e, test -L also detects a dangling symlink.
Step 2: confirm the public storage directory exists
Run:
ls -ld /home/username/laravel-app/storage/app/public
The target directory must exist.
If it does not, and you have confirmed that this is the correct Laravel project, create it:
mkdir -p /home/username/laravel-app/storage/app/public
This command creates the directory if necessary without deleting existing contents.
If Laravel still cannot save uploads, inspect directory ownership, permissions, and the application's filesystem configuration.
Step 3: test a harmless file
Create a uniquely named text file in the public storage directory. First ensure that the chosen filename is not already in use.
For example:
printf 'storage-check-ok\n' > /home/username/laravel-app/storage/app/public/hnt-storage-check.txt
The command writes a simple test response into the public disk.
Now request that file through the website:
curl -sS -D - -o /dev/null https://example.com/storage/hnt-storage-check.txt
This sends a GET request, prints the HTTP response headers, and discards the response body.
You can also open the URL in a browser and verify that it displays:
storage-check-ok
A GET request is useful because some servers or security layers handle HEAD requests differently.
How to interpret the result
| Result | Likely explanation | Next action |
|---|---|---|
| 200 OK | The test file is publicly accessible | Check the actual image path and generated URL |
| 301 or 302 | A redirect occurred | Inspect the Location header and test the destination |
| 403 Forbidden | Web server permissions or symlink restrictions | Check ownership and hosting security settings |
| 404 Not Found | Missing link, wrong target, wrong document root, or rewrite behavior | Inspect the physical paths |
| 500 Internal Server Error | Server configuration or application error | Check Apache/LiteSpeed and Laravel logs |
| Test works, uploaded images fail | Upload disk, filename, URL, or permissions differ | Inspect the stored file and application code |
A 404 response containing an HTML page does not prove that the symbolic link is broken.
The request might have reached Laravel's router, a custom error handler, or the hosting provider's error page.
Similarly, a 200 response from an application fallback page does not prove the expected file was served.
Check the response content, not just its status.
If your site is behind Cloudflare, repeat the test against the correct origin configuration when necessary. A CDN cache or security rule can make the public result differ from the origin server.
No Terminal available?
cPanel's Terminal feature depends on the hosting provider enabling shell access.
If Terminal is unavailable but Cron Jobs is enabled, you can use a temporary cron command to inspect the directory.
For example:
ls -ld /home/username/public_html/storage
Configure a valid schedule in cPanel's Cron Jobs interface and enable cron email notifications.
The command output can be mailed to the configured address, depending on the server's cron email setup.
Remove the inspection job after obtaining the result.
Do not create a permanent every-minute cron task just to inspect a symbolic link.
Fix the public_html Path and Broken Storage Links
The most common shared-hosting configuration keeps Laravel's application code outside the website's document root.
This is the right general approach because the public web server should not expose Laravel's application source, .env, or private files.
For the complete deployment layout, see Deploy Laravel on cPanel: Step-by-Step Checklist.
Fix 1: change the configured link destination
Assume the following paths are correct:
Laravel application:
/home/username/laravel-app
Public website:
/home/username/public_html
Edit:
/home/username/laravel-app/config/filesystems.php
Locate the existing links array.
For a standard installation using the separate public_html document root, change it to:
'links' => [
base_path('../public_html/storage') => storage_path('app/public'),
],
Preserve any additional custom links your application actually needs.
The expression:
base_path('../public_html/storage')
resolves relative to the Laravel application's base directory.
In this example, it identifies:
/home/username/public_html/storage
Back up config/filesystems.php before editing it.
If a real public_html/storage directory already exists, inspect and preserve its files before proceeding. Artisan will not replace that directory automatically.
After making the change, run:
cd /home/username/laravel-app
php artisan config:clear
php artisan storage:link
The first command switches to the Laravel application.
The second removes cached configuration so Laravel reads the updated filesystem settings.
The third attempts to create the configured symbolic link.
A successful command normally prints a message similar to:
The [/home/username/public_html/storage] link has been connected to [/home/username/laravel-app/storage/app/public].
Verify the result rather than relying only on the message:
ls -ld /home/username/public_html/storage
Then request the test file through the website.
If your production deployment uses cached configuration, rebuild it after confirming the configuration is valid:
php artisan config:cache
Be aware that a configuration cache can also preserve an outdated APP_URL.
Important: Some addon domains use their own document roots.
For example:
/home/username/domains/example.com/public_html
Others may use a subdirectory beneath public_html.
Do not assume every domain uses the account's primary public_html directory. Confirm the document root in cPanel's Domains interface before modifying the link.
Fix 1B: change Laravel's public path
An alternative is to configure Laravel's actual public path at application bootstrap.
Laravel 11 and 12 provide usePublicPath() for this purpose.
In a standard Laravel 12 application, bootstrap/app.php creates the application with an expression beginning:
Application::configure(basePath: dirname(__DIR__))
and ending:
->create();
If you choose this alternative, change the initial return Application::configure(...) assignment to $app = Application::configure(...), keeping all existing chained routing, middleware, and exception configuration intact.
After the existing ->create(); statement, add:
$app->usePublicPath(dirname(__DIR__, 2).'/public_html');
return $app;
This snippet is intended to follow the existing application creation code, not replace the entire bootstrap file.
For this particular directory layout:
dirname(__DIR__, 2)
resolves to the account's home directory.
Laravel's public_path() will then point to:
/home/username/public_html
The standard links entry can consequently remain:
'links' => [
public_path('storage') => storage_path('app/public'),
],
This approach affects more than storage links.
Other application code calling public_path() will also use the changed directory.
That can be appropriate if public_html is genuinely the application's public directory, but it deserves additional deployment testing.
Use either Fix 1 or Fix 1B as the primary path correction. Do not combine them without a specific reason.
For most straightforward cPanel deployments, editing the links array is the smaller change.
Fix 3: remove a broken link from Windows or an old server
Another common problem appears when developers move a Laravel application from XAMPP on Windows to Linux hosting.
On Windows, Laravel's filesystem implementation uses mklink /J for directory links.
This creates a directory junction.
The stored target may refer to a Windows path such as:
C:\xampp\htdocs\myapp\storage\app\public
A Linux hosting server cannot use that Windows path.
FTP clients, archive extraction tools, and deployment systems may handle junctions and symbolic links differently.
Depending on the transfer method, you may find that:
- The link was not uploaded.
- A real directory containing old files was uploaded instead.
- A copied Unix symlink points to the former server.
- The link exists but cannot be followed because of permissions or hosting restrictions.
Laravel's default Git configuration also excludes the generated public storage link, so Git deployments generally recreate it separately.
First inspect the destination:
ls -ld /home/username/public_html/storage
If it is a symbolic link, inspect the target:
readlink /home/username/public_html/storage
If it is a normal directory, inspect the contents before making changes.
Use conditional cleanup rather than running separate destructive commands blindly:
cd /home/username/public_html || exit 1
if [ -L storage ]; then
ls -ld storage
rm -- storage
elif [ -e storage ]; then
mv -- storage "storage-backup-$(date +%Y%m%d-%H%M%S)"
else
echo "No existing storage entry found."
fi
This script performs different operations depending on the destination.
For a symbolic link, rm -- storage removes only the link itself, not the directory it points to.
For a real directory or file, mv preserves it under a timestamped backup name.
The cd guard prevents the commands from running in an unintended directory if changing directories fails.
Do not run this script until you have verified the exact destination path and taken any required production backup.
If the preserved real directory contains uploads missing from Laravel's actual public disk, reconcile those files before deleting the backup.
Once the destination is clear, recreate the link using your corrected Artisan configuration or the Linux command below.
Should you use relative links?
Laravel 12 supports:
php artisan storage:link --relative
This requires the relevant Symfony filesystem class to be installed.
If it is absent, Laravel reports that the symfony/filesystem package is needed.
For the example structure, the relative target would resemble:
../laravel-app/storage/app/public
A relative link can remain valid when an entire directory layout is moved together.
It does not automatically fix an incorrect document root, missing target directory, or restrictive hosting policy.
Use it when your deployment structure benefits from relative paths, not as a universal solution to storage 404 errors.
Create a Storage Link Without SSH or symlink()
Shared hosting providers sometimes restrict PHP functions such as symlink() and exec().
The PHP manual documents disable_functions as a system-level configuration directive.
This means your application cannot reliably override a provider's disabled-function list.
What happens when symlink() is disabled?
The exact behavior depends on PHP and Laravel versions.
Before PHP 8.0, calling a disabled internal PHP function commonly produced a warning.
From PHP 8.0 onward, disabled internal functions are removed from the function table.
Code directly calling one can therefore encounter an undefined-function error.
Laravel 12's Unix filesystem implementation checks whether symlink() exists.
If it does, Laravel calls it.
Otherwise, Laravel attempts a shell-based ln -s fallback using PHP's exec() function.
If exec() is unavailable too, the fallback can fail.
There is another important detail: the success or failure of a shell operation should be verified on the filesystem. Do not assume an Artisan informational message alone confirms that the final destination is accessible.
You usually do not need to enable dangerous PHP functions just to create one symbolic link.
Option 1: use cPanel Terminal
If Terminal is available, first confirm the source directory exists:
test -d /home/username/laravel-app/storage/app/public && echo "Source directory exists"
Then inspect the destination:
ls -ld /home/username/public_html/storage
If no entry occupies the destination, create the link:
ln -sT /home/username/laravel-app/storage/app/public /home/username/public_html/storage
On GNU/Linux, -s creates a symbolic link.
The -T option tells ln not to treat the destination as an existing directory.
This helps prevent a subtle mistake where a link is accidentally created inside another directory or a symbolic link that resolves to one.
If your hosting environment does not support GNU ln -T, use the standard ln -s form only after confirming the destination is absent:
ln -s /home/username/laravel-app/storage/app/public /home/username/public_html/storage
Verify:
ls -ld /home/username/public_html/storage
You should see the intended source path after the arrow.
A successful filesystem link still needs HTTP testing because Apache, LiteSpeed, and CloudLinux can impose additional restrictions.
Option 2: create a one-time cPanel cron job
You may have access to cPanel Cron Jobs even when Terminal is disabled.
Cron jobs execute shell commands under the hosting account's allowed environment.
They do not guarantee that every operating-system command is permitted.
If your provider allows symbolic link creation, you can use a temporary cron job.
Choose the schedule in cPanel's Cron Jobs interface. For a short-lived test, a one-minute schedule can be used if permitted, provided the job is removed immediately after the first execution.
Enter this command:
if [ ! -e /home/username/public_html/storage ] && [ ! -L /home/username/public_html/storage ]; then ln -sT /home/username/laravel-app/storage/app/public /home/username/public_html/storage && echo "Storage link created"; else echo "Storage destination already exists; inspect before changing"; fi
This command checks two conditions.
The -e test detects an existing file or accessible directory.
The -L test detects an existing symbolic link, including a dangling one.
Only when both tests indicate that the destination is absent does the command attempt link creation.
The use of ln -sT adds protection against treating an existing destination directory as a container for another link.
Check the cron output or email report.
Then remove the job from cPanel.
Do not leave this job running indefinitely.
Once the link exists, Laravel does not need to recreate it every minute.
If the job fails with Operation not permitted, Permission denied, or a command restriction, ask the hosting provider whether account-owned symbolic links are allowed.
If Cron Jobs is unavailable or cron execution is blocked, this method will not work.
You can use the HeadsNTail Cron Job Generator to understand cron schedule fields.
Option 3: ask hosting support
If neither Terminal nor cron execution works, request the link directly from the host.
Provide the exact source and destination paths:
Source:
/home/username/laravel-app/storage/app/public
Destination:
/home/username/public_html/storage
Ask support to create the symbolic link under the appropriate hosting account and confirm it is accessible through the site's configured web server.
Also ask whether CloudLinux SecureLinks, CageFS, or another symlink-protection mechanism restricts this arrangement.
A support-created link may fail if ownership or security policy does not match the target.
There is usually no reason to request global disabling of symlink protection or to enable unrestricted PHP shell execution.
A narrowly scoped link under the same account is preferable.
Fix 403 Forbidden, Permissions, and Wrong APP_URL
Sometimes the symbolic link exists and points to the correct directory, but uploaded files still cannot be opened.
At this point, investigate the HTTP response and generated file URL separately.
Problem 1: APP_URL points to localhost
In Laravel 12's standard public disk configuration, the url setting is built using APP_URL.
For example:
'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage',
If the production .env still contains:
APP_URL=http://localhost
then:
Storage::disk('public')->url('photos/car.jpg');
can generate:
http://localhost/storage/photos/car.jpg
That URL points to the visitor's own computer, not your hosting server.
Correct the .env setting:
APP_URL=https://example.com
Use the site's intended public hostname and HTTPS scheme.
If the application uses a custom filesystems.disks.public.url setting, update that configuration too.
After the change:
cd /home/username/laravel-app
php artisan config:clear
If the deployment uses configuration caching, rebuild the cache:
php artisan config:cache
Now inspect a freshly generated image URL.
If the URL is correct but the image still fails, the problem is probably not APP_URL.
Also check for hardcoded URLs in Blade templates, database records, frontend JavaScript, and any configured asset host.
Changing APP_URL will not rewrite old URLs already stored in a database.
Problem 2: Apache refuses to follow the symlink
Apache has specific rules governing symbolic links.
According to Apache HTTP Server 2.4 documentation, symbolic links may be followed when the relevant directory configuration permits FollowSymLinks or SymLinksIfOwnerMatch.
With SymLinksIfOwnerMatch, the link and target must have matching ownership under Apache's documented rule.
On shared hosting, other security layers may apply additional restrictions.
Inspect the link:
ls -ld /home/username/public_html/storage
Inspect the target:
ls -ld /home/username/laravel-app/storage/app/public
Inspect the containing directories if necessary:
ls -ld /home/username
ls -ld /home/username/laravel-app
ls -ld /home/username/laravel-app/storage
ls -ld /home/username/laravel-app/storage/app
The web server needs sufficient filesystem access to traverse the relevant directories and read publicly served files.
If the symlink belongs to one user and the target belongs to another, ownership-related protection may block the request.
CloudLinux SecureLinks can also enforce ownership restrictions at the operating-system level.
This is why a link may appear correct in Terminal but still return 403 in the browser.
Do not apply broad chmod 777 permissions as a workaround.
The correct fix depends on the server's execution user, directory permissions, and provider security configuration.
If permissions or ownership are wrong and you do not control them, ask hosting support to correct the specific account-owned paths.
Problem 3: .htaccess overrides symlink behavior
Inspect the website's .htaccess file for entries such as:
Options -FollowSymLinks
Removing FollowSymLinks may interfere with symbolic-link traversal.
However, blindly replacing it with:
Options +SymLinksIfOwnerMatch
is not always the correct solution.
Apache permits Options directives in .htaccess only when the server's override configuration allows them.
On restrictive shared hosting, adding an unauthorized directive can produce a 500 error.
Also, the provider may already enforce a separate symlink-security policy that cannot be overridden from .htaccess.
The correct troubleshooting sequence is:
- Back up the existing
.htaccess. - Check for an explicit symlink restriction.
- Confirm whether the host permits
Optionsoverrides. - Review Apache or LiteSpeed error logs.
- Ask the host to confirm the supported configuration if access remains blocked.
Do not disable provider-wide symlink protection simply to make image URLs work.
For other safe Apache configuration practices, see Security Headers in .htaccess on cPanel.
Problem 4: storage files are not readable
A valid link is only one part of the chain.
The target file must exist, and the hosting environment must allow the web server to read it.
Check a specific image:
ls -l /home/username/laravel-app/storage/app/public/photos/car.jpg
If the file is missing, inspect the application's upload disk and saved database path.
If the file exists but cannot be read, investigate ownership, directory traversal permissions, and filesystem policies.
On ordinary Linux hosting, common directory and file modes are 755 and 644, respectively, but these are not universal requirements.
Do not recursively change ownership or permissions across your application without understanding the hosting provider's configuration.
A file stored on an S3 disk or another remote filesystem also will not become accessible through a local symlink.
Check the actual disk first.
No Symlink Available? Serve Files Through Laravel
If the hosting provider prohibits symbolic links and will not create an exception, you can serve publicly intended files through a Laravel route.
This avoids the symlink requirement.
It does introduce application overhead because every file request passes through Laravel.
Example: serve public images through a route
Open:
routes/web.php
Add:
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
Route::get('/files/{path}', function (string $path) {
$disk = Storage::disk('public');
abort_unless($disk->exists($path), 404);
return $disk->response($path);
})->where('path', '.*')->name('public-files.show');
The route's {path} parameter accepts nested paths because of:
->where('path', '.*')
For example:
/files/photos/2026/car.jpg
The exists() check returns 404 when the requested file does not exist.
The response() method streams the file using Laravel's filesystem response handling.
The code is intended for files already considered public.
Use an unused URL prefix if your application already has a /files route.
Before deploying, also verify that the hosting server forwards application routes to Laravel correctly.
If Apache or LiteSpeed rewrite rules are broken, adding a Laravel route will not automatically fix that problem.
Generate URLs for the fallback route
Instead of:
Storage::disk('public')->url($path);
generate the route URL:
route('public-files.show', ['path' => $path]);
Here, $path is the relative file path returned by the public filesystem disk.
For example:
photos/car.jpg
It should not be an arbitrary operating-system path.
For normal application uploads, prefer generated, normalized filenames. Test unusual filenames containing spaces and special characters before relying on them in public URLs.
What about private documents?
Do not use the public route above for confidential files.
In Laravel 12, the standard local disk stores files under:
storage/app/private
Private files should be served through an authenticated, authorized endpoint.
Authentication alone may not be sufficient.
For example, an employee should not be able to download another employee's private documents simply by guessing a path.
A private-document controller should identify the requested document, verify the authenticated user's permission to access it, and only then return the file.
Laravel also supports temporary file URLs for appropriately configured local storage disks. Signed temporary URLs can be useful, but access-control requirements still need to be considered when generating and exposing them.
Never create a public symbolic link to the entire Laravel storage directory.
That could expose files such as:
storage/logs/laravel.log
storage/app/private/
storage/framework/sessions/
Depending on the application, those locations may contain sensitive information.
Likewise, do not place the complete Laravel application or its .env file inside a publicly accessible document root.
Symlink versus Laravel route
| Feature | Symbolic link | Laravel file route |
|---|---|---|
| File delivery | Web server serves files directly | Request passes through Laravel |
| PHP processing | Usually unnecessary for static files | Required for each request |
| Shared hosting resource usage | Generally lower | Potentially higher |
| Access control | Public files are directly accessible | Authentication and authorization can be added |
| Hosting requirements | Symlink and filesystem access must be permitted | PHP application routes must work |
| Best use case | Public uploads and images | Controlled downloads or restrictive hosting |
For a gallery containing many images, a symlink is normally the better solution.
Routing every image through PHP can consume more worker capacity and increase response times on shared hosting.
If your application serves many authenticated downloads, consider proper caching, rate limits, and storage architecture rather than assuming a basic route will scale indefinitely.
Final verification checklist
After applying the appropriate fix, confirm the result end to end.
1. Check the destination.
ls -ld /home/username/public_html/storage
Confirm the link exists, if your solution uses one.
2. Check the original file.
ls -l /home/username/laravel-app/storage/app/public/hnt-storage-check.txt
Confirm the test file is present.
3. Test public HTTP access.
curl -sS -D - -o /dev/null https://example.com/storage/hnt-storage-check.txt
If you use the Laravel route fallback, test the /files/ URL instead.
4. Confirm the actual response.
Open the test URL in a browser.
Verify it returns:
storage-check-ok
An HTTP 200 response alone is insufficient if the server returns the wrong content.
5. Test a real application upload.
Upload a new image using the application's normal interface.
Confirm that the new image is stored on the expected disk and can be opened from its generated URL.
6. Check the production page.
Open the page that previously displayed broken images.
Use the browser's Network panel to confirm that the relevant image requests succeed.
If the direct image URL works but the page still does not display it, inspect the HTML, CSS, JavaScript, and any cached frontend URLs.
7. Remove the temporary test file.
rm -- /home/username/laravel-app/storage/app/public/hnt-storage-check.txt
Run this command only after confirming the filename and path refer to the temporary file you created.
Remove any temporary diagnostic cron jobs as well.
The storage problem is resolved only when a newly uploaded file can be retrieved from the correct production URL.
FAQ
Why does php artisan storage:link say success but images still return 404?
The link may have been created in Laravel's internal public directory rather than the domain's actual document root.
This happens in some split-directory cPanel deployments.
Inspect the link path shown by Artisan and compare it with the document root configured for the domain.
Also confirm the target exists, the file uses the public disk, and the generated URL is correct.
How do I run Laravel storage:link without SSH on cPanel?
If PHP can create symbolic links, you can run Artisan through a permitted hosting mechanism.
If that is not possible, a one-time cPanel Cron Job may create the Linux symlink directly, provided the hosting provider permits the command.
Otherwise, ask hosting support to create the link.
If symlinks are entirely prohibited, a Laravel file-serving route is an alternative for suitable workloads.
How do I fix "symlink() has been disabled for security reasons" in Laravel?
The hosting environment has restricted PHP's symlink() function.
On supported Linux environments, creating the link with the operating system's ln command avoids requiring PHP's symlink() function.
However, the hosting provider may separately restrict operating-system symbolic links.
In that case, contact support rather than trying to bypass the server's security configuration.
Can I copy storage/app/public into public_html/storage instead?
Copying files may temporarily make existing uploads accessible, but it does not synchronize future uploads.
Laravel will continue writing to its configured filesystem disk.
Unless your application has an intentional synchronization process or custom storage architecture, the copied directory will become outdated.
Use a symbolic link when permitted, or serve the files through Laravel.
Sources and technical references
- Laravel 12 — File Storage: https://laravel.com/docs/12.x/filesystem
- Laravel 12 — HTTP Responses: https://laravel.com/docs/12.x/responses
- Laravel 12 — Configuration: https://laravel.com/docs/12.x/configuration
- Laravel Framework 12.x — StorageLinkCommand source: https://github.com/laravel/framework/blob/12.x/src/Illuminate/Foundation/Console/StorageLinkCommand.php
- Laravel Framework 12.x — Filesystem source: https://github.com/laravel/framework/blob/12.x/src/Illuminate/Filesystem/Filesystem.php
- Laravel 12 Application Skeleton — Filesystem Configuration: https://github.com/laravel/laravel/blob/12.x/config/filesystems.php
- PHP Manual — disable_functions: https://www.php.net/manual/en/ini.core.php#ini.disable-functions
- Apache 2.4 — Options Directive: https://httpd.apache.org/docs/2.4/mod/core.html#options
- Apache 2.4 — Mapping URLs to Filesystem Locations: https://httpd.apache.org/docs/2.4/urlmapping.html
- cPanel — Terminal: https://docs.cpanel.net/cpanel/advanced/terminal-in-cpanel/
- cPanel — Cron Jobs: https://docs.cpanel.net/cpanel/advanced/cron-jobs/
- cPanel — Symlink Race Condition Protection: https://docs.cpanel.net/ea4/apache/symlink-race-condition-protection/
- GNU Coreutils — ln Command: https://www.gnu.org/software/coreutils/manual/html_node/ln-invocation.html
- Microsoft — mklink: https://learn.microsoft.com/windows-server/administration/windows-commands/mklink


