How to download files in Laravel: A guide to public vs. private files

- Andrés Cruz - ES En español

Video thumbnail

How to download a file in Laravel? It is one of those tasks that seem simple once we already know how to handle file uploads in Laravel… and they are, provided you understand where you want to serve that file from.

In my case, when I started working on projects distributing digital books, I quickly discovered that serving a public file is not the same as serving a protected one. The security implications are completely different.

In this guide, I explain both options —along with some more advanced ones— using real-world examples and practical tips that I use in production.

In all cases, we use the download() method, which generates an HTTP response that forces the download in the user's browser without exposing the actual physical path of the file on the server.

Quick Guide: Common Errors and Solutions

ErrorProbable CauseSolution
File not foundMalformed path or incorrect disk.Verify storage_path() or the disk name in config/filesystems.php.
Empty/corrupted downloadThere is an echo or dd() before the return statement.Remove any text output prior to the response.
Permission deniedLack of read permissions on the folder.Adjust permissions using chmod or chown on the server.

1. Introduction: Different Ways to Download Files in Laravel

Laravel offers us several strategies to serve files to users, each suitable depending on the context:

  • From the public directory: for files accessible directly by the browser without any restrictions.
  • From a Storage disk: when you need access control, privacy, or business logic before serving the file.
  • Via streaming or from external services like Amazon S3 or Google Cloud Storage: for large files or assets hosted outside your server.

Tip for S3: If you use Amazon S3, avoid downloading the file through your own application, as it consumes unnecessary bandwidth. Instead, generate a temporary URL using Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(5)) and redirect the user directly. It is faster, safer, and cheaper.

What is essential in all cases is that the response()->download() method generates an HTTP response with the correct headers (Content-Disposition: attachment) so that the browser forces the download instead of displaying the file on screen.

2. Downloading Files from the Public Folder

This is the simplest case. If the file is already inside the public directory, you can build its absolute path using the public_path() helper and pass it directly to the download() method:

public function download($file_name)
{
    $file_path = public_path('files/' . $file_name);
    return response()->download($file_path);
}

Laravel converts that path into a response ready for download. This is the method I usually use for open resources such as free guides, templates, or documents without access restrictions.

Advantages:

  • Simple and straightforward, with no additional configuration required.
  • Ideal for public resources that do not require authentication.

Limitations:

  • Not suitable for sensitive or paid files.
  • Anyone who knows the direct URL can access the file without going through your code.

3. Downloading from a Protected Disk (Laravel Storage)

When a file must be protected behind business logic —a payment, a subscription, user permissions— the strategy changes: it must be moved out of the public directory and stored in a disk configured in config/filesystems.php.

Remember that all folders outside public are private by design in Laravel: they cannot be accessed directly from the browser, making them ideal for these types of files.

The first step is to define a custom disk in config/filesystems.php:

// config/filesystems.php
'files_disk' => [
    'driver' => 'local',
    'root'   => app()->storagePath(),
],

With the disk configured, the download process is just as concise. You can pass only the file path, or also specify the filename the user will receive:

// Basic download
Storage::disk('files_disk')->download($routefile);

// Download with custom name and dynamic extension
Storage::disk('files_disk')->download('book/' . $file->file, $name . '.' . $file->type);

In my experience, this approach is much more secure and controllable. Everything outside of public is inaccessible from the browser, ensuring that only authorized users can reach the file through your code.

Personal tip: Maintain a clear directory structure inside the disk (e.g., books/, images/, invoices/) to avoid name collisions and improve traceability as the project scales.

4. Advanced Downloads: Streaming and Remote Files

If you deal with large files or need to download them from external providers, Laravel offers additional tools that improve both performance and user experience.

Streaming with streamDownload()

The response()->streamDownload() method allows sending the file to the client in chunks, instead of loading it entirely into memory before responding. This is ideal for heavy files such as large PDFs, videos, or compressed archives:

return response()->streamDownload(function () use ($path) {
    echo Storage::disk('files_disk')->get($path);
}, 'archivo.pdf');

This approach prevents memory_limit errors in PHP when the file size exceeds the server configuration limits, as the content is written directly to the output buffer without accumulating in RAM.

Downloading from Amazon S3 or Google Cloud Storage

Once the corresponding disk is configured in config/filesystems.php, the download API is identical to using a local disk. Laravel completely abstracts the underlying provider:

return Storage::disk('s3')->download('docs/manual.pdf');

You can also customize the downloaded filename and force the MIME type through additional HTTP headers, which is useful when the filename stored in S3 is a hash or a UUID:

return response()->download($path, 'mi-archivo.pdf', [
    'Content-Type' => 'application/pdf',
]);

In my experience, this approach is excellent for managing dynamic file catalogs, automated backups, or media assets served from a CDN.

5. How to Restrict Downloads to Authenticated Users Only

Security should be the starting point, not an afterthought. Laravel provides two levels of access control that you can combine based on your requirements.

The first is protecting the route with a middleware, which blocks any unauthenticated user before they even reach the controller:

Route::get('/download/{file}', [FileController::class, 'download'])
    ->middleware('auth');

The second level —more granular— is validating inside the controller itself whether the user has the right to download that specific file. This is essential when authorization depends on business logic, such as whether the user purchased the product:

if (auth()->user()->hasPurchased($file->id)) {
    return Storage::disk('files_disk')->download($file->path);
}

abort(403, 'Unauthorized');

Combining both layers —middleware('auth') on the route and authorization in the controller— is the practice I recommend for any digital product download system. This ensures that every download is backed by business logic and that no file is accidentally exposed due to a configuration error.

In my case, this pattern was essential when implementing digital product downloads with per-user license management.

6. Common Errors When Downloading Files in Laravel (and How to Avoid Them)

ErrorCauseSolution
File not foundIncorrectly constructed path or wrong diskCheck storage_path() or the disk() name
Empty or corrupted downloadThere is text output before the responseRemove any echo or dd() before the return statement
Permission deniedFolder lacks read permissions for the web serverFix permissions using chmod or chown
Incorrect filenameSecond parameter not specified in download()Add $name and extension manually as the second argument

Tip: Always use absolute paths with Laravel helpers (public_path(), storage_path()) instead of relative paths. Relative paths can behave differently depending on the environment (local, staging, production) and are a frequent source of bugs that are hard to trace.

7. Real-World Use Case: Selling and Downloading Protected Files in Laravel

File downloading is a core functionality in any digital product store. Allowing a user to download a file only after completing a payment is the perfect example of why controlled access logic is so important.

Below is the full implementation I have used in production projects.

Why You Should NOT Use the public Folder for Paid Files

It is critical that files for sale are never placed inside the public directory. Anyone who knows —or guesses— the URL of the file could download it without paying, without your application having any opportunity to intercept it.

In Laravel, the only folder publicly accessible from the browser is public. Therefore, for files requiring controlled access, the solution is to store them outside of it, for instance in the storage folder, and serve them exclusively through your controller.

Configuring the Protected Storage Disk

Define a custom disk in config/filesystems.php pointing to a directory outside public:

// config/filesystems.php
'files_sell_uploads' => [
    'driver' => 'local',
    'root'   => app()->storagePath(),
],

Uploading Files to the Protected Disk

When uploading the file, use storeAs() to save it to the protected disk under a controlled name (in this case, based on the timestamp to avoid collisions):

function uploadBook()
{
    $this->rules = [
        'fileBook' => 'nullable|mimes:epub,pdf|max:20024'
    ];
    $this->validate();

    if ($this->fileBook) {
        $name = time() . '.' . $this->fileBook->getClientOriginalExtension();
        $this->fileBook->storeAs('book', $name, 'files_sell_uploads');

        YourModel::create([
            'file' => $name,
            'type' => $this->fileBook->getClientOriginalExtension(),
            // other fields according to your model
        ]);
    }
}

Access Control for Downloads

To verify payment before granting access to the download, the logic is straightforward: if the payment record and the file exist, it is served; otherwise, a 403 error is returned:

if ($filePayment && $file) {
    return Storage::disk('files_sell_uploads')->download('book/' . $file->file, 'book.' . $file->type);
}

It is worth noting that the only way to access these files over HTTP is through this function. Being stored outside of public, there is no direct URL that exposes them.

Complete Secure Download Function

public function downloadFile(File $file)
{
    $user        = auth()->user() ?? auth('sanctum')->user();
    $filePayment = FilePayment::where(/* condition */)->first();
    $file        = File::where(/* condition */)->first();

    if ($filePayment && $file) {
        return Storage::disk('files_sell_uploads')->download('book/' . $file->file, 'book.' . $file->type);
    }

    return response()->json(['message' => 'Product not acquired or does not exist'], 403);
}

With this implementation, files are downloaded only if all business conditions are met. The user never interacts directly with the file's path on the server.

Benefits of This Protected Download Scheme

This pattern serves as an ideal foundation for building a digital product store inside your Laravel application. Files are stored securely, access is fully controlled via code, and you can add any extra logic —such as download limits per license, expiration dates, or audit logging— without touching the storage infrastructure.

8. Conclusion and Final Tips

Downloading files in Laravel can be as simple or as robust as your use case requires. Here are the criteria I use to decide which approach to take:

  • For unrestricted public files: response()->download() with public_path() is sufficient.
  • For protected or paid files: use Storage disks outside public and protect routes with middleware and authorization logic in the controller.
  • For large files or external services: streamDownload() and temporary S3 URLs are your best allies.

In my experience, the key lies not so much in the specific function you use, but in understanding the security context of your application. Laravel provides all the tools; the difference lies in how you combine them and where you set access boundaries.

Frequently Asked Questions

  • What is the difference between public_path() and storage_path()?
    • public_path() points to the public directory, accessible directly from the browser. storage_path() points to the storage folder, which is private and only accessible via PHP code in your application.
  • Can I download multiple files at once?
    • Yes. You can use PHP's native ZipArchive class to generate a .zip file dynamically and return it with response()->download(). There are also packages in the Laravel community that simplify this process.
  • Is it possible to force the filename of the download?
    • Yes. Pass it as the second parameter in the download() method: response()->download($path, 'mi-archivo.pdf'). This is especially useful when the file is saved with an internal name (hash, UUID) and you want the user to receive a descriptive filename.
  • What is deleteFileAfterSend() and when should I use it?
    • It is a chainable method on the download response that automatically deletes the file from the server after sending it to the client: response()->download($path)->deleteFileAfterSend(true). It is useful for temporary files (dynamically generated PDFs, exports, zips) that you don't need to keep on disk.

The next step is to optimize your application's performance by learning how to use Caching in Laravel.

Learn how to download files in Laravel using response()->download(), protect disks in Storage, stream with streamDownload(), and use temporary URLs in S3. Includes real-world examples, user access control, and deleteFileAfterSend().


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.