PHP: Optimised File Response in Laravel Using HTTP Cache Headers

approximately 8 minutes of reading

In most common situations you are going to let your web server handle all HTTP responses for you. Imagine a case where a client is requesting a file by accessing an unique URL → server takes an incoming request → looks for corresponding resource directly on a storage level and (assuming a file exists) responds with HTTP 200 OK status. That's how it works in a nutshell. What if prior to return a file response, additional logic has to be executed? I'll show you how to pipe a file request through a bit of custom logic in PHP and respond more efficiently using Laravel Framework.

Why are you making default Laravel logic more convoluted? Just call Storage::download('file.jpg') facade and carry on…

I've been asked this particular question multiple times. Let's look at potential benefits of using slightly different approach than the Storage facade.

Security

Storing your uploaded files in a public directory is a very naive approach. In more demanding cases you should both upload and serve your files from locations that are not directly accessible by client's browser. Assuming that your server is named example.local and a file you request is located within the public directory (/public), you can access it by calling → https://example.local/file.jpg. This works for sure, however it may be better to store all your files within the /storage/app directory.

But then how can I access these files if the location is outside of the public directory?

You can do a symlink → php artisan storage:link, but you can also involve a bit of code. Carry on reading. We are going to implement a new route so that we can send our HTTP requests. In your Laravel application define a new GET route.

Route::get('/files/{path}', [FileController::class, 'get'])
    ->where('path', '(.*)')
    ->name('files.get');

At this point your should be able to make a GET request to → https://example.local/files/file.jpg. Now in the FileController.php class, create a new method called get → public function get(string $path): Response.

Obviously feel free to rename your routes, classess and method to whatever your want. Also you don't have to use path, instead use your primary key or any other value that can identify a record in a unique way. It's up to you how you're going to implement the part responsible for getting a correct file by a given wildcard.

Flexibility

Inside the get method you are free to write any custom logic your application requires. This gives enormous flexibility before you even return the Illuminate\Http\Response type of result. Clever arrangement of conditional statements here can boost some performace.

You could now directly query database to search for a model where $path matches the GET route wildcard, but if no path is provided, you can skip this extra database query by adding below condition before quering a database.

if (empty($path)) {
    abort(404);
}

In this simple evaluation if the given path is empty, we abort immediately. It's pointless to look for a matching model with empty path - there's none. If you pass through this stage (path is not empty), then it's natural to query a database in search for the corresponding model instance.

$resource = $this->getResourceByPath($path); // query DB

if (!$resource instanceof Model) {
    abort(404);
}

From now on let's assume that the $resource variable is our requested and existing file, represented by an Eloquent model (Illuminate\Database\Eloquent\Model). The exact implementation of the getResourceByPath method is up to you.

Potential structure of the files table can look like:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateFilesTable extends Migration
{
    public function up()
    {
        Schema::create('files', function (Blueprint $table) {
            $table->uuid('uuid');
            $table->text('name');
            $table->text('name_original');
            $table->text('path');
            $table->text('mime_type');
            $table->text('extension')->nullable();
            $table->unsignedBigInteger('size');
            $table->unsignedInteger('width')->nullable();
            $table->unsignedInteger('height')->nullable();
            $table->string('checksum');
            $table->boolean('is_private')->default(false);
            $table->timestamps();

            // indexes
            $table->primary('uuid');
        });
    }

    public function down()
    {
        Schema::dropIfExists('files');
    }
}

Potential dump of the File model can look like:

App\Models\File {#479 ▼
  (…) cut out
  #original: array:17 [▼
    "uuid" => "753bf041-2de9-4a6c-90cc-68c685d2b623"
    "name" => "2f38ec9b-f648-48f7-98c0-e2b31dd00ce6.jpg"
    "name_original" => "file.jpg"
    "path" => "files/2f38ec9b-f648-48f7-98c0-e2b31dd00ce6.jpg"
    "mime_type" => "image/jpeg"
    "extension" => "jpg"
    "size" => 994698
    "width" => 2400
    "height" => 1600
    "checksum" => "09d2a8a5-ca86-4efc-bb12-6084838be122"
    "is_private" => 0
    "created_at" => "2020-05-07 14:21:07"
    "updated_at" => "2020-05-07 14:53:57"
    "pivot_gallery_uuid" => "0e7aaeaa-6321-4a28-b90c-e8fa019a85e8"
    "pivot_file_uuid" => "09adc20f-105e-407e-ad68-ef10edec1282"
    "pivot_created_at" => "2020-05-07 14:21:46"
    "pivot_updated_at" => "2020-05-07 14:21:46"
  ]
  (…) cut out
}

Let's follow up that flexibility topic. Above structure is more than basic but it allows you to control access to a file (by checking model's boolean attribute called is_private).

Go back to your get method and check if a client is allowed to access requested file.

if ($resource->is_private) {
    abort(403, 'You do not have permission to access this resource.');
}

This block of logic is trivial but it's a nice starting point to query model against various conditions before you serve a response to client.

More strict rules are limited only to your imagination.

  • Check if a client is allowed to pull files with certain MIME types?
  • Limit client downloads to files below a defined threshold of bytes?

This got you covered. Everytime your condition is not met, respond with either a flash message or an abort function.

Can you give me a broader picture of this strategy?

Sure, follow the last chapter.

Browser Caching

You're almost there. Assuming all checks are done, client is allowed to obtain a file. Let's prepare some HTTP headers first. You need values for the following headers.

  • Cache-Control
  • Content-Disposition
  • Content-Length
  • Content-Type
  • Etag
  • Expires
  • Last-Modified
  • Pragma

Before we continue, a brief explaination of each HTTP header.

Cache-Control - carries caching instructions for both request and response. Correct value should be max-age=0, must-revalidate. Expiration is controlled by max-age while revalidation and reloading by must-revalidate. More about Cache-Control.

Content-Disposition - indicates whether expected content should be either displayed inline (presented in a browser window) or shown as an attachment with a prompt asking where to store a file. Correct value shoud be sprintf('inline; filename="%s"', $resource->name). More about Content-Disposition.

Content-Length - indicates the size of response body that is going to be sent to client. Correct value should be $resource->size. Size in bytes is taken from the model (database). Remember the flexibility I was talking about? Now it pays a dividend. More about Content-Length.

Content-Type - indicates the type of a resource. Correct value should be $resource->mime_type. This along with size, name etc. should be collected during a file upload and saved in a persistent storage. More about Content-Type.

Etag - uniquely identifies the specific version of a resource. For the sake of this article I've used file's timestamp combined with it's checksum (checksum has been calculated during the upload process). So I've ended up with md5($resource->updated_at->format('U') . $resource->checksum). As soon as you re-upload a file (and update the checksum column) or just simply touch corresponding model to update its timestamp - a new md5 with Etag will be generated. More about Etag.

Expires - indicates a date and time after which the response should be considered as staled. In my case I have used a year from the date when a file has been uploaded to the application: $resource->updated_at->addYear()->toRfc2822String(). More about Expires.

Last-Modified - indicates a date and time when a file has been modified for the last time. Usually it's the upload date: $resource->updated_at->toRfc2822String(). More about Last-Modified.

Pragma - indicates the following:

Indicates that the response MAY be cached by any cache, even if it would normally be non-cacheable or cacheable only within a non-shared cache.

Set the value to public. More about Pragma can be found in the RFC 2616 specification of the HTTP/1.1 protocol.

OK, how to put all of this together?

At this point your headers should look like:

$headerEtag = md5($resource->updated_at->format('U') . $resource->checksum);
$headerExpires = $resource->updated_at->addYear()->toRfc2822String();
$headerLastModified = $resource->updated_at->toRfc2822String();

$headers = [
    'Cache-Control' => 'max-age=0, must-revalidate',
    'Content-Disposition' => sprintf('inline; filename="%s"', $resource->name),
    'Etag' => $headerEtag,
    'Expires' => $headerExpires,
    'Last-Modified' => $headerLastModified,
    'Pragma' => 'public',
];

Now let's determine whether you should serve HTTP 200 or HTTP 304 response.

The condition goes as follows:

$server = request()->server;

$requestModifiedSince =
    $server->has('HTTP_IF_MODIFIED_SINCE') &&
    $server->get('HTTP_IF_MODIFIED_SINCE') === $headerLastModified;

$requestNoneMatch =
    $server->has('HTTP_IF_NONE_MATCH') &&
    $server->get('HTTP_IF_NONE_MATCH') === $headerEtag;

If any of these conditions are true, then you should serve a cached response:

if ($requestModifiedSince || $requestNoneMatch) {
    return $this->responseFactory->make('', 304, $headers);
}

Provide an empty response, use 304 status code and pass above array of HTTP headers. However if none of the conditons are true, then you have to send 200 response.

$headers = array_merge($headers, [
    'Content-Length' => $resource->size,
    'Content-Type' => $resource->mime_type,
]);

$content = $this->fileSystem->get($path);

return $this->responseFactory->make($content, 200, $headers);

Include two extra headers that where not applicable to 304 response, obtain file's content and respond with 200.

In practice

Non-cached response:

Non-cached response. Check the status, transfer size and time.

Cached response:

Cached response. Status is 304, transfer size is none and time is much faster.

You have learnt how to serve files through custom logic in PHP na Laravel Framework. Now you know how to control basic access to a file by using model attributes as well as serve file in an efficient way by preparing cached HTTP response if possible.


Words: 1753
Published in: Laravel · PHP · Software Architecture
Last Revision: September 02, 2023

Related Articles   📚