Serving a Private File

Check permission in a controller, then hand the bytes off to the web server or the object store instead of pushing them through PHP.

Syntax$this->file(string|\SplFileInfo $file, ?string $fileName = null, string $disposition = ResponseHeaderBag::DISPOSITION_ATTACHMENT): BinaryFileResponse

A file under public/ is served by the web server to anyone who guesses its URL. A private file therefore lives outside the web root, and a controller decides, per request, who may have it. That controller does two things: authorise, then return the bytes.

Returning the bytes

For a local path, BinaryFileResponse handles Range and If-Range headers, so resumable downloads work. setContentDisposition() with ResponseHeaderBag::DISPOSITION_ATTACHMENT makes the browser save the file; DISPOSITION_INLINE displays it. For bytes that arrive as a stream, such as a Flysystem readStream(), use StreamedResponse with a callback that copies the handle to php://output.

Keeping PHP out of the transfer

Reading a gigabyte through PHP occupies a worker for the whole download. Two ways out. First, X-Sendfile: the response names a file in a header and the web server sends it, which BinaryFileResponse does once BinaryFileResponse::trustXSendfileTypeHeader() has been called and the server sends an X-Sendfile-Type request header. nginx and FrankenPHP use X-Accel-Redirect, Apache uses mod_xsendfile. Second, a pre-signed URL: temporaryUrl($path, $expiresAt) returns a URL the object store will honour until it expires, and you redirect to it.

What trips people up

A pre-signed URL carries its own permission. After it is issued, your voter is never consulted again, so keep expiry short. deleteFileAfterSend() does nothing once X-Sendfile is in play, because PHP no longer touches the file.

Example

Example · php
<?php
// src/Controller/DocumentDownloadController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Document;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class DocumentDownloadController extends AbstractController
{
    public function __construct(
        #[Autowire('%kernel.project_dir%/var/storage/documents')]
        private readonly string $storageDir,
    ) {
    }

    #[Route('/documents/{id}/download', name: 'app_document_download', methods: ['GET'])]
    // The voter for Document decides; a refusal is a 403 and the
    // method never runs.
    #[IsGranted('view', subject: 'document')]
    public function download(Document $document): BinaryFileResponse
    {
        // The stored name is generated, never the name the user sent.
        $path = $this->storageDir.'/'.$document->getStoredName();

        // Shorthand:
        // return $this->file($path, $document->getDisplayName());

        // The same response, written out:
        $response = new BinaryFileResponse($path);
        $response->setContentDisposition(
            ResponseHeaderBag::DISPOSITION_ATTACHMENT,
            $document->getDisplayName(),
        );

        return $response;
    }
}

When to use it

  • Let a customer download their own invoice while a 403 is returned for anyone else's.
  • Display a private PDF inline in the browser instead of forcing a save dialog.
  • Serve multi-gigabyte exports without tying up a PHP worker for the whole transfer.
  • Hand out a five-minute link to an S3 object after checking permission in the app.

More examples

Streaming out of a Flysystem storage

Use this when the file is in object storage and you want the response to start before the whole object has been fetched. The headers are set before returning, because once the callback has begun writing it is too late to change them. GET /documents/7/view returns 200 with the file's media type and the bytes in the body for a permitted user, and 403 otherwise.

Example · php
<?php
// src/Controller/DocumentStreamController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Document;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class DocumentStreamController extends AbstractController
{
    public function __construct(
        #[Target('documents.storage')]
        private readonly FilesystemOperator $filesystem,
    ) {
    }

    #[Route('/documents/{id}/view', name: 'app_document_view', methods: ['GET'])]
    #[IsGranted('view', subject: 'document')]
    public function view(Document $document): StreamedResponse
    {
        $path = $document->getStoredPath();
        $type = $this->filesystem->mimeType($path);

        $response = new StreamedResponse(function () use ($path): void {
            // The callback runs after the headers are sent.
            stream_copy_to_stream(
                $this->filesystem->readStream($path),
                fopen('php://output', 'wb'),
            );
        });

        $response->headers->set('Content-Type', $type);
        // nginx buffers FastCGI output by default; this turns it off.
        $response->headers->set('X-Accel-Buffering', 'no');

        return $response;
    }
}

Redirecting to a pre-signed URL

This is the cheapest way to serve large private files: the authorisation check happens in your app, the transfer happens between the browser and the bucket. The window must be short, because anyone holding the URL within it gets the file, signed in or not. Not every adapter can sign URLs; the local adapter needs a configured temporary URL generator.

Example · php
<?php
// src/Controller/DocumentLinkController.php

declare(strict_types=1);

namespace App\Controller;

use App\Entity\Document;
use League\Flysystem\FilesystemOperator;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

final class DocumentLinkController extends AbstractController
{
    public function __construct(
        #[Target('documents.storage')]
        private readonly FilesystemOperator $filesystem,
    ) {
    }

    #[Route('/documents/{id}/link', name: 'app_document_link', methods: ['GET'])]
    #[IsGranted('view', subject: 'document')]
    public function link(Document $document): RedirectResponse
    {
        // Signed by the adapter; the object store checks the signature
        // and the expiry, so the bytes never pass through PHP.
        $url = $this->filesystem->temporaryUrl(
            $document->getStoredPath(),
            new \DateTimeImmutable('+5 minutes'),
        );

        return new RedirectResponse($url, Response::HTTP_FOUND);
    }
}

Turning on X-Sendfile once per request

trustXSendfileTypeHeader() is static, so it has to be called somewhere early; a high-priority kernel.request listener is a predictable place. Only enable it where the web server is configured to accept the header, because trusting it on a server that forwards headers from clients would let a request name any file on disk.

Example · php
<?php
// src/EventListener/TrustXSendfileListener.php

declare(strict_types=1);

namespace App\EventListener;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;

#[AsEventListener(event: KernelEvents::REQUEST, priority: 1024)]
final class TrustXSendfileListener
{
    public function __invoke(RequestEvent $event): void
    {
        if (!$event->isMainRequest()) {
            return;
        }

        // Static and global: every BinaryFileResponse in this process
        // may now hand the file over to the web server.
        BinaryFileResponse::trustXSendfileTypeHeader();
    }
}

Discussion

  • Be the first to comment on this lesson.