DEV Community

Cover image for Laravel Storage Link Not Working on Shared Hosting: Fix
Faizan
Faizan

Posted on Originally published at headsntail.com

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'),
],
Enter fullscreen mode Exit fullscreen mode

Then clear Laravel's configuration cache and create the link:

cd /home/username/laravel-app
php artisan config:clear
php artisan storage:link
Enter fullscreen mode Exit fullscreen mode

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,
],
Enter fullscreen mode Exit fullscreen mode

This configuration stores public uploads in:

storage/app/public
Enter fullscreen mode Exit fullscreen mode

But browsers cannot automatically access that directory.

Laravel expects a symbolic link:

public/storage -> storage/app/public
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'),
],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

For example:

$path = $request->file('photo')->store('photos');
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

Generate its URL with:

$url = Storage::disk('public')->url($path);
Enter fullscreen mode Exit fullscreen mode

If these statements appear in a controller, import the facade:

use Illuminate\Support\Facades\Storage;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A working symbolic link should resemble:

lrwxrwxrwx ... storage -> /home/username/laravel-app/storage/app/public
Enter fullscreen mode Exit fullscreen mode

The important information is the arrow:

storage -> /home/username/laravel-app/storage/app/public
Enter fullscreen mode Exit fullscreen mode

It identifies the link target.

If the output begins with d, such as:

drwxr-xr-x ... storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

the storage entry is missing.

To inspect a link's stored target without following it, use:

readlink /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Edit:

/home/username/laravel-app/config/filesystems.php
Enter fullscreen mode Exit fullscreen mode

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'),
],
Enter fullscreen mode Exit fullscreen mode

Preserve any additional custom links your application actually needs.

The expression:

base_path('../public_html/storage')
Enter fullscreen mode Exit fullscreen mode

resolves relative to the Laravel application's base directory.

In this example, it identifies:

/home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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].
Enter fullscreen mode Exit fullscreen mode

Verify the result rather than relying only on the message:

ls -ld /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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__))
Enter fullscreen mode Exit fullscreen mode

and ending:

->create();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

resolves to the account's home directory.

Laravel's public_path() will then point to:

/home/username/public_html
Enter fullscreen mode Exit fullscreen mode

The standard links entry can consequently remain:

'links' => [
    public_path('storage') => storage_path('app/public'),
],
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If it is a symbolic link, inspect the target:

readlink /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Then inspect the destination:

ls -ld /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

If no entry occupies the destination, create the link:

ln -sT /home/username/laravel-app/storage/app/public /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Verify:

ls -ld /home/username/public_html/storage
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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',
Enter fullscreen mode Exit fullscreen mode

If the production .env still contains:

APP_URL=http://localhost
Enter fullscreen mode Exit fullscreen mode

then:

Storage::disk('public')->url('photos/car.jpg');
Enter fullscreen mode Exit fullscreen mode

can generate:

http://localhost/storage/photos/car.jpg
Enter fullscreen mode Exit fullscreen mode

That URL points to the visitor's own computer, not your hosting server.

Correct the .env setting:

APP_URL=https://example.com
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

If the deployment uses configuration caching, rebuild the cache:

php artisan config:cache
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Inspect the target:

ls -ld /home/username/laravel-app/storage/app/public
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Removing FollowSymLinks may interfere with symbolic-link traversal.

However, blindly replacing it with:

Options +SymLinksIfOwnerMatch
Enter fullscreen mode Exit fullscreen mode

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:

  1. Back up the existing .htaccess.
  2. Check for an explicit symlink restriction.
  3. Confirm whether the host permits Options overrides.
  4. Review Apache or LiteSpeed error logs.
  5. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

The route's {path} parameter accepts nested paths because of:

->where('path', '.*')
Enter fullscreen mode Exit fullscreen mode

For example:

/files/photos/2026/car.jpg
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

generate the route URL:

route('public-files.show', ['path' => $path]);
Enter fullscreen mode Exit fullscreen mode

Here, $path is the relative file path returned by the public filesystem disk.

For example:

photos/car.jpg
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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


Originally published at headsntail.com. Free tools for PHP, Laravel and cPanel work: headsntail.com/tools.

Top comments (0)