skip to content

In Laravel, what is the public disk, and why do its files return 404 in the browser until you run php artisan storage:link?

level: juniorimportance: must knowfreq 72%

answer

  1. web root is public/, not storage/
  2. public disk root: storage/app/public
  3. symlink public/storage to that folder
  4. filesystems.links lists every link
  5. --relative and --force flags

basics

~20 s

The public disk is a local-driver disk rooted at storage/app/public for files meant to be web-visible. The web server only serves the public/ directory, so php artisan storage:link creates a public/storage symlink that exposes that folder.

solid answer

~40 s

The `public` disk in `config/filesystems.php` uses the `local` driver with `root` set to `storage_path('app/public')`, `visibility` set to `public` and a `url` of `APP_URL/storage`. The web server's document root is `public/`, so nothing under `storage/` is reachable by URL until a link bridges the two. `php artisan storage:link` reads the `links` array in the same config file, by default `public_path('storage') => storage_path('app/public')`, and creates each symlink; `--relative` makes relative links and `--force` recreates links that already exist. The `local` disk is a different place entirely: its root is `storage/app/private` and the link never exposes it. Because `/public/storage` is git-ignored in the skeleton, every fresh server or container needs the command as part of its setup.

code

php · 9 lines
php
<?php

// config/filesystems.php (excerpt)
return [
    'links' => [
        public_path('storage') => storage_path('app/public'),
        public_path('portfolio') => storage_path('app/portfolio'),
    ],
];

go deeper

for a junior

Recall the two roots, storage/app/public for the public disk and storage/app/private for local, and that storage:link makes public/storage point at the first one.

for a middle

Explain why the web root hides storage/, how the links array drives the command, and what --relative and --force change.

for a senior

Show you have deployed this: the link is git-ignored, absolute links break across build and run paths, and multiple servers push uploads to shared storage.

for a principal

Frame the symlink as a single-server convenience and judge when shared object storage should replace it for scaling and disposable infrastructure.

## Two local disks in the skeleton A Laravel **disk** is a named entry in `config/filesystems.php` that pairs a **driver** (how files are stored) with a location and options. The Laravel 13 skeleton ships three disks, and two of them use the same `local` driver on the server's own filesystem: | Disk | Driver | Root | Meant for | |---|---|---|---| | `local` | `local` | `storage/app/private` | files the app reads and writes but never exposes directly | | `public` | `local` | `storage/app/public` | files browsers may fetch by URL | | `s3` | `s3` | a bucket | object storage (configured by `AWS_*` variables) | The `public` disk also carries `'visibility' => 'public'` and `'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage'`, which tells Laravel what base URL its files live under. Nothing in that configuration makes the files reachable, though; it only describes where they would be. ## Why the files 404 A Laravel web server (nginx, Apache, or `php artisan serve`) points its **document root** at the `public/` directory. Only files inside `public/` can be fetched as static assets; everything else, including all of `storage/`, is deliberately out of reach so that logs, sessions, cached views and private uploads never leak. So a design-portfolio site that saves a thumbnail with `Storage::disk('public')->put('thumbs/42.jpg', $bytes)` writes it to `storage/app/public/thumbs/42.jpg`, generates a link to `/storage/thumbs/42.jpg`, and gets a 404: there is no `public/storage` directory for the web server to look in. ## What storage:link does `php artisan storage:link` (the `StorageLinkCommand`) walks the `links` array in `config/filesystems.php`: 1. It reads each `link => target` pair; the default is `public_path('storage') => storage_path('app/public')`. 2. If the link path already exists and is not a symlink it may remove, it prints that the link already exists and skips it. 3. Otherwise it creates a **symbolic link**, absolute by default, relative with `--relative`. 4. With `--force`, an existing *symlink* at that path is deleted and recreated. After that, the web server resolves `/storage/thumbs/42.jpg` through `public/storage` into `storage/app/public/thumbs/42.jpg` and serves it as a static file, without routing the request through the application. You can expose more folders by adding pairs to `links`, for example `public_path('images') => storage_path('app/images')`, and `php artisan storage:unlink` removes the configured links again. ## Deployment traps - **The link is not in git.** The skeleton's `.gitignore` lists `/public/storage`, so each new server, container image or fresh release directory needs `storage:link` in its setup script. - **Absolute links break when paths move.** A link created at build time as `/build/app/storage/app/public` points nowhere once the code runs from `/var/www/app`; `--relative` avoids that. - **`--force` only replaces symlinks.** If `public/storage` is a real directory (someone copied files there), the command reports that the link exists and leaves it; you must remove the directory yourself. - **Shared storage between releases.** Zero-downtime deploy layouts usually keep `storage/` outside the release folder, so the link must point at the shared location. - **Multiple web servers.** A symlink only exposes files on the machine that wrote them; two servers behind a load balancer each have their own `storage/app/public`. That is the usual trigger for moving uploads to an `s3` disk. ## What the link does not cover - The `local` disk (`storage/app/private`) is never linked. Files written there stay private; serving them takes a controller or a signed URL, which the file-operations APIs handle. - The `s3` disk never needs the link: its files are fetched from the bucket, not from `public/`. - The link exposes **everything** under `storage/app/public`. Anything sensitive written to the `public` disk by mistake is downloadable by anyone who guesses the path. ## Checking that it worked When a portfolio image still 404s after deploy, work through the chain in order: 1. Does `public/storage` exist, and is it a symlink rather than a directory? `ls -l public/storage` shows the arrow and the target. 2. Does the target exist on this machine? A link can survive while the folder it points to was never created or was mounted elsewhere. 3. Is the file actually under `storage/app/public`, or was it written with the `local` disk into `storage/app/private`? 4. Does the web server follow symlinks? Some hardened configurations refuse to, which produces a 403 or 404 even though the link is correct. 5. Does the generated link match the disk's `url` key? `APP_URL` pointing at the wrong host sends browsers to a different server. Most "storage:link is broken" reports turn out to be the wrong disk, a missing link on a new server, or an absolute link that points into a directory the release no longer uses.

  • Why is a file written with Storage::disk('local') still unreachable at /storage/... after storage:link?
    The `local` disk's root is `storage/app/private`, while the default link targets only `storage/app/public`. The two disks share a driver but not a folder, so the symlink never exposes files on the `local` disk. That separation is intentional: private files should be served through a controller or a signed URL, not as static assets.
  • What does storage:link --force do when public/storage is a real directory rather than a symlink?
    Nothing destructive. The command only removes an existing path when it is a symlink and `--force` is set. A real directory makes it report that the link already exists and skip that entry, so you must move or delete the directory by hand before the link can be created.
  • When does the symlink approach stop working for a growing site?
    When the app runs on more than one server or in disposable containers. Each machine has its own `storage/app/public`, so an upload written on one node is missing on the others, and a container restart can lose it. Moving user files to an `s3` disk (or other shared storage) removes the dependency on the link.

saying these in an interview costs you the question

  • storage:link copies the uploaded files into public/storage
  • Files on the local disk become public once storage:link has run
  • The public/storage symlink is committed to git, so deploys never need the command
  • storage:link --force replaces a real public/storage directory with the symlink
  • The s3 disk also needs storage:link before its files can be downloaded