DEV Community

Cover image for Serving Large Private Files from Laravel
Paul Isaris
Paul Isaris

Posted on Edited on

Serving Large Private Files from Laravel

Serving Large Private Files from Laravel

(image by pexels)

Introduction

Let's suppose you need to show a set of large files to a small group of users. Say a few hundred photographs, 6000 x 4000 JPEGs of about 7 MB each, straight from the camera, and only the members of a team may open them. Nobody else, logged in or not.

The reason you want to serve the originals might be scientific: the users need to zoom in and inspect details, so downscaling the images is not always a solution. The same applies to scanned documents, medical images, or any file where the detail is the point.

If you have served files from Laravel before, you know the first thing that comes to mind: php artisan storage:link, drop the files under storage/app/public, and write <img src="/storage/...">. It takes five minutes and it works. It is also the wrong tool for this case! I hit exactly this scenario in an application I work on, and this post walks through what I built instead and why.

The post is for anyone who serves files that are not public: uploads, datasets, scans, contracts, paid downloads. The code is written on Laravel 13 (PHP 8.3+), but the approach is decoupled from the framework and the version. The principles apply to any web application that needs to check who is asking before handing out a file.

The naive scenario, and why it fails

The public disk exists for files that anyone may fetch: logos, avatars, marketing assets. The symlink turns storage/app/public into public/storage, and from then on the web server hands the files out directly. Laravel is not involved in those requests at all. That is the point of the feature, and it is exactly the problem:

  • Anyone who has the URL gets the file. The session, the roles, and the policies never run.
  • The URLs are guessable. Files named 11.JPG, 12.JPG, 13.JPG can be fetched with a loop.
  • The symlink must exist on every server. Forget it on one deployment target and every file is a 404.
  • Moving the files later, to another directory or to S3, changes every URL the frontend ever rendered.

Signed URLs are the usual answer for the first two points, and they are a good one when the files live on S3. For a directory on the application server, I wanted something simpler: one endpoint that checks who is asking and then streams the file.

Keep the files where they are, point a disk at them

The files live in a directory outside the Laravel tree. In my case it is a separate git repository, cloned next to the application, because the data has its own history and its own owners. Nothing is copied into storage/. The application only needs to know where the directory is, so the path is a config value with an environment override:

// config/files.php
return [
    'root' => base_path(env('FILES_PATH', 'files')),
];
Enter fullscreen mode Exit fullscreen mode

A filesystem disk is rooted at that path. It is a local disk with no url key, which means Laravel cannot build a public URL for it even by accident:

// config/filesystems.php
'files' => [
    'driver' => 'local',
    'root' => config()->string('files.root'),
    'throw' => false,
    'report' => false,
],
Enter fullscreen mode Exit fullscreen mode

The database stores two things per file: the directory of the collection it belongs to (Expedition_2024) on the collection row, and the file name (11.JPG) on the file row. Together they make the path relative to the disk. The absolute path never leaves the server.

The tests get their own root: phpunit.xml sets FILES_PATH to a directory with three small committed fixtures, so the suite never touches the real data.

One authorized endpoint

The route takes the collection the user is browsing and the file they want:

Route::get(
    'collections/{collection}/files/{file}',
    [CollectionFileController::class, 'show'],
)->middleware('auth')->name('collections.file');
Enter fullscreen mode Exit fullscreen mode

The frontend receives this URL inside the page payload and puts it in an <img src>. The browser sends the session cookie with the request, as with any other same-origin URL, so nothing special happens on the client.

Authorization lives in a form request. It asks two questions: may this user see files of this collection, and does this file belong to this collection? The second check matters. Without it, a member of one collection could request any file id in the database and the policy check alone would pass:

class ShowCollectionFileRequest extends FormRequest
{
    public function authorize(): bool
    {
        $collection = Collection::query()->findOrFail($this->route('collection'));

        if (! Gate::allows('viewFile', $collection)) {
            return false;
        }

        $file = CollectionFile::query()->findOrFail($this->route('file'));

        return $file->collection_id === $collection->id;
    }
}
Enter fullscreen mode Exit fullscreen mode

The policy behind the gate is plain: admins, and users who are members of the collection. The controller then builds the path and streams the file:

class CollectionFileController extends Controller
{
    private const string CACHE_CONTROL = 'private, max-age=86400';

    public function show(ShowCollectionFileRequest $request, int $collection, int $file): StreamedResponse
    {
        $fileRow = CollectionFile::query()->findOrFail($file);
        $collectionRow = Collection::query()->findOrFail($collection);

        $directory = $collectionRow->directory;
        $fileName = $fileRow->file_name;

        abort_unless($this->isSafeSegment($directory), Response::HTTP_NOT_FOUND);
        abort_unless($this->isSafeSegment($fileName), Response::HTTP_NOT_FOUND);

        $relativePath = $directory . '/' . $fileName;

        abort_unless(Storage::disk('files')->exists($relativePath), Response::HTTP_NOT_FOUND);

        return Storage::disk('files')->response($relativePath, null, [
            'Cache-Control' => self::CACHE_CONTROL,
        ]);
    }

    /** A single path segment: non-empty, no separators, no parent references. */
    private function isSafeSegment(mixed $segment): bool
    {
        return is_string($segment)
            && $segment !== ''
            && ! str_contains($segment, '/')
            && ! str_contains($segment, '\\')
            && ! str_contains($segment, '..');
    }
}
Enter fullscreen mode Exit fullscreen mode

Two details are deliberate.
First, both path segments come from the database, not from the request, and both are still checked. A row can be edited by hand, an import can have a bug, and ../../.env in a file name must not become a readable path. Each segment has to be a single directory or file name: no slashes, no backslashes, no ... Second, the status codes are a choice. A guest never reaches this code: the auth middleware redirects every guest to the login page, whether the collection exists or not, so anonymous traffic learns nothing. An authenticated user without access gets a 403. That tells them the id exists, and I accept it: they are known users, and a 404 for a collection they already know about is only confusing. Failures after authorization, such as an unsafe path or a missing file on disk, are a 404.

Storage::response() returns a StreamedResponse. PHP reads the file in chunks and writes them to the output, so a 7 MB file does not get loaded into memory as a string.

Cache it privately

The Cache-Control header is the one line that makes the approach practical. private tells the browser it may keep the file and tells every shared cache on the way that it may not. max-age=86400 means that for a day, opening the same file again costs no request at all. A user who moves back and forth between images gets each file once.

What is still missing is an ETag or a Last-Modified header. After the day is over, the browser downloads the file again instead of asking whether it changed. The fix is a few lines in the same controller; I left it out here to keep the example short.

The tests that pin it down

The feature test for the endpoint has eight cases, and I think the list is worth more than the code, because it is the contract:

  • a guest is redirected to the login page, so anonymous traffic cannot probe which ids exist;
  • an authenticated non-member gets a 403 (deliberate: known users may learn that the collection exists);
  • a member gets a 200 with image/jpeg and the private cache header;
  • an admin gets a 200 without being a member;
  • a file that belongs to a different collection gets a 403, for the same reason;
  • a file name containing .. gets a 404;
  • a collection with no directory gets a 404;
  • a directory containing .. gets a 404.

The fixtures are three small JPEGs committed to the repository. The real files are never needed to run the suite, which is the only way the suite stays runnable on a fresh clone and on CI.

Is streaming through PHP fast enough?

Honest answer: for the application I had in mind, yes, and I measured before deciding. One user opens one file at a time. The first load costs one PHP process for about 7 MB; every later load of the same file is served from the browser cache. And since the users zoom into details, generating smaller copies would not have replaced the original anyway; it would only have added a second set of files to keep in sync.

If your numbers are different, if hundreds of users open large files at once, keep the design and move the bytes out of PHP. nginx has X-Accel-Redirect and Apache has X-Sendfile: the controller still runs the authorization and then returns an empty response with a header that names the file; the web server streams it. The nginx side is one internal location:

location /protected-files/ {
    internal;
    alias /var/www/files/;
}
Enter fullscreen mode Exit fullscreen mode

And the controller returns the header instead of the stream:

return response('', 200, [
    'X-Accel-Redirect' => '/protected-files/' . $relativePath,
    'Content-Type' => $fileRow->mime,
    'Cache-Control' => self::CACHE_CONTROL,
]);
Enter fullscreen mode Exit fullscreen mode

The policy, the path checks and the tests stay the same. Only the last line changes, which is the reason to keep the authorization in Laravel in the first place.

Three things that need attention

EXIF orientation. Browsers rotate a JPEG according to its EXIF Orientation tag, but getimagesize() returns the raw sensor dimensions. If you store width and height at import time, for example so the page can reserve the right aspect ratio before the image loads, a phone photo with Orientation 6 ends up stored as landscape and displayed as portrait. Read the tag and swap the two values for orientations 5 to 8:

set_error_handler(static fn (): bool => true);

try {
    $exif = exif_read_data($absolutePath);
} finally {
    restore_error_handler();
}

$rotated = $exif !== false && in_array($exif['Orientation'] ?? 1, [5, 6, 7, 8], true);
Enter fullscreen mode Exit fullscreen mode

The error handler is there because exif_read_data() emits a warning, not an exception, for files without EXIF data, and a missing tag is a normal case. exif_read_data() also lives in an optional PHP extension. Declare it, or the import fails on the first host that lacks it:

"require": {
    "php": "^8.3",
    "ext-exif": "*"
}
Enter fullscreen mode Exit fullscreen mode

CMYK JPEGs. Some files come out of desktop tools in CMYK. Browsers decode CMYK JPEGs, but the colours come out wrong. Since the files are served unchanged, the fix belongs in the data: convert those sources to sRGB once. If you generate derivatives, this is where you would do it instead.

Inline serving of any type. The endpoint streams whatever the row points at, with the file's own content type, on the application's origin. For a trusted directory that is acceptable. For user uploads it is not: an SVG or HTML file served inline runs scripts on your origin. Restrict the content type to what the feature needs, or add Content-Disposition: attachment for everything else.

Trade-offs to accept

  • The files live outside the Laravel directory layout. Every deployment target has to provide the directory and set FILES_PATH. It is one more step in the deploy, and it must be documented, but it is a step that storage:link did not need.
  • Each first load of a file is a PHP process. The cache header and, if needed, X-Accel-Redirect keep that in check, but it is a cost that a static file server does not have.
  • Membership is checked per collection, not per file. If some members may see only some files, the form request is where a tighter check would go.
  • No S3 yet. When the disk becomes S3, streaming through PHP stops making sense and temporary signed URLs take over. The authorization code stays; the controller's last line changes again.

Conclusion

Private files need an endpoint, not a symlink. The endpoint has four jobs: check who is asking, check that the file belongs to what they are allowed to see, refuse to build a path from anything that is not a plain file name, and stream the bytes with a private cache header. Everything else, from where the files live to which server sends the bytes, is a configuration detail that can change later without touching the rules. Write the eight tests first; they are the part you will still rely on after the next refactoring.

Top comments (6)

Collapse
 
kamrankhandev profile image
Kamran Khan •

Solid walkthrough — "path segments from the database but still validated" is the kind of detail people skip and then learn about the hard way.

One inconsistency I noticed: you argue every failure should be a 404 so existence doesn't leak, but the test list pins a 403 for an authenticated non-member. Those two disagree — a 403 for the wrong collection tells an attacker the collection exists, which is exactly the leak the 404 argument guards against. Was that deliberate? There's a defensible split: 404 for anonymous traffic (hide everything), 403 for logged-in non-members (a 404 there is just confusing for a legitimate user who already knows the collection exists). But the tests as written pin the leakier variant.

Also agree on the inline/attachment split — the type I'd be most careful with inline is SVG, since browsers execute script inside it and it would be same-origin with your app. Worth an explicit inline whitelist rather than an attachment blacklist if you haven't already.

Collapse
 
pavlosisaris profile image
Paul Isaris •

Hello, thanks for reading my post and commenting with such an analytic comment! You are right, the test does not exactly cover the intent: An authenticated user who tries the collection/file they do not have access to, should get a 403. Unauthenticated ones should get 404.
Also agree about SVG! Although this case does not exactly fit the post, its importance is worth a separate one!

Collapse
 
kamrankhandev profile image
Kamran Khan •

That's a good split — 404 for anonymous so existence doesn't leak, 403 for authenticated users who'd just be confused by a 404 for a collection they already know exists. One small thing: I'd name that intent in the tests themselves (something like guests get 404 to avoid leaking existence), because the pinned 403 reads like the leaky variant until you know the story behind it.

Thread Thread
 
pavlosisaris profile image
Paul Isaris •

great recommendation, thanks!

Thread Thread
 
kamrankhandev profile image
Kamran Khan •

Any time. It's one of those renames that pays off the first time someone reads the test file cold — the intent is right there in the test list instead of living in your head. Would be interested to see the updated names if you do a follow-up.

Thread Thread
 
pavlosisaris profile image
Paul Isaris •

Thanks, I took your advice and updated the post.
One correction to my last reply: guests do not get a 404 in this code. The auth middleware redirects them to the login page before any lookup runs, and the redirect is identical for real and fake ids, so anonymous traffic learns nothing.
The 403 is only for authenticated users without access. That is a deliberate trade-off, and the test list now says so. I also rewrote the "every failure is a 404" paragraph, because it promised more than the code does. Good catch on both points!