Deliver a generated PDF via a signed, expiring URL
At a glance
Section titled “At a glance”You generate a Portable Document Format (PDF) file and need to hand it to a client. The simplest path streams the bytes straight through a controller, but that ties up an application worker for the whole download, runs the traffic through your servers, and exposes the file to anyone who can reach the route. The delivery pattern on this page does the opposite: generate the PDF, store the bytes in object storage, and return a short-lived signed Uniform Resource Locator (URL) that the client fetches directly from storage. Your app hands back a small JavaScript Object Notation (JSON) payload with a URL; storage serves the bytes.
The NextPDF side is one call: getPdfData() on the document returns the raw
PDF binary as a string. Everything after that — putting the object and minting a
time-limited signed link — is your framework’s or your cloud provider’s job. The
signing primitives are real, documented APIs: Laravel Storage::temporaryUrl()
and URL::temporarySignedRoute(), Symfony UriSigner, and the Amazon Simple
Storage Service (S3) or Google Cloud Storage (GCS) presigned-URL operations in
their software development kits (SDKs). NextPDF defines no URL helper of its own;
do not look for one.
Check these pieces first:
- NextPDF core is installed and you can build a document.
- You have object storage the framework can sign for: an S3 or S3-compatible bucket, a GCS bucket, or a Laravel disk whose driver supports temporary URLs.
- Credentials live in environment variables or a secrets manager, never in committed config.
This is a how-to. It assumes you already know how to route a request to a controller. For returning bytes directly instead, see Return a generated PDF from a controller.
Conceptual overview
Section titled “Conceptual overview”The pattern has three steps, and only the first touches NextPDF:
- Generate. Build the document and call
getPdfData()to get the bytes. - Store. Write those bytes to an object-storage key (
reports/2026/r-42.pdf). - Sign. Ask the framework or cloud SDK for a signed URL to that key, with an expiry, and return the URL to the client.
Why store and sign instead of proxying the bytes:
- Offload bandwidth. Object storage (or its content-delivery-network edge) serves the download. Your application worker returns a few hundred bytes of JSON and is free immediately, instead of being held for the length of a multi-megabyte transfer.
- Scope access. A signed URL grants access to one object for a bounded window. The bucket itself stays private. There is no public route to brute force and no broad bucket-read grant.
- Expiry. The signature embeds an expiry timestamp. After it passes, the link is dead. A leaked URL stops working on its own, which bounds the blast radius of an accidental share.
There are two distinct signing models, and they differ in what gets signed:
- Object-storage presigned URLs (S3, GCS, or Laravel’s
temporaryUrl()over an S3/GCS disk) point directly at the storage object. The download never reaches your app at all. - Application signed routes (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) point at your own route. The request still hits your app, which verifies the signature, then streams or redirects to the object. Use these when you need to run authorization, logging, or accounting on each download, or when your storage cannot presign.
API surface
Section titled “API surface”| Concern | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Get PDF bytes | NextPDF\Core\Document::getPdfData(): string | same | same |
| Store bytes | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) or Flysystem write() |
| Presigned storage URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK presigner (below) |
| Signed app route | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Verify a signed app route | — | signed route middleware / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
The only NextPDF engine CALL this delivery pattern requires is getPdfData();
the document itself is built however your app already builds documents (e.g. the
injected DocumentFactoryInterface / the Symfony PdfFactory). getPdfData()
is declared in the HasOutput trait on NextPDF\Core\Document. It calls the writer once and returns the whole
PDF as a string. Its sibling save(string $path): void writes the same bytes to
disk through an atomic writer; use it only when your storage is a real local
filesystem path. For object storage, prefer getPdfData() and let the storage
SDK own the transfer.
The document is built when you call
getPdfData()(orsave()), and the build is not idempotent. Call it once per document, capture the string, and reuse that string for both the upload and any size or checksum you compute.
Code sample — Laravel temporary URL
Section titled “Code sample — Laravel temporary URL”Laravel’s filesystem abstraction signs for you. On an S3 (or S3-compatible)
disk, Storage::temporaryUrl() returns a presigned URL straight to the object.
The client downloads from storage; your action returns only JSON.
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;use Illuminate\Support\Facades\Storage;use NextPDF\Contracts\DocumentFactoryInterface;use Psr\Log\LoggerInterface;use Throwable;
final class ReportDeliveryController extends Controller{ public function __construct( private readonly DocumentFactoryInterface $documents, private readonly LoggerInterface $logger, ) {}
public function store(int $reportId): JsonResponse { try { // 1. Generate. Build once; getPdfData() returns the raw bytes. $document = $this->documents->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true); $bytes = $document->getPdfData();
// 2. Store under a non-guessable key on a private disk. $key = sprintf('reports/%d/%s.pdf', $reportId, bin2hex(random_bytes(16))); Storage::disk('s3')->put($key, $bytes, ['visibility' => 'private']);
// 3. Sign. A presigned URL straight to the object, valid 10 minutes. $url = Storage::disk('s3')->temporaryUrl($key, now()->addMinutes(10));
return new JsonResponse(['download_url' => $url], 201); } catch (Throwable $exception) { // Log the class, never the message or trace, so detail does not leak. $this->logger->error('Report PDF delivery failed', [ 'report_id' => $reportId, 'exception' => $exception::class, ]);
return new JsonResponse(['error' => 'Could not prepare the report.'], 500); } }}The disk must be one whose driver supports temporary URLs — the bundled s3
driver does. Calling temporaryUrl() on the local driver throws unless you
register a generator for it, because a local disk has nothing to presign.
When you would rather keep the download on your own route — to run per-request
authorization or to log each access — sign a route instead with
URL::temporarySignedRoute(). The route’s signed middleware rejects a tampered
or expired link before your action runs.
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
// Mint the link elsewhere:// URL::temporarySignedRoute('reports.download', now()->addMinutes(10),// ['report' => $reportId]);Route::get('/reports/{report}/download', DownloadReportController::class) ->name('reports.download') ->middleware('signed');Code sample — Symfony UriSigner
Section titled “Code sample — Symfony UriSigner”Symfony has no Laravel-style storage facade, so you sign your own route with
the framework’s Symfony\Component\HttpFoundation\UriSigner, then have that route
redirect to a presigned storage URL (or stream the object). UriSigner::sign()
appends a keyed hash; checkRequest() rejects a tampered link. To keep the
example portable across Symfony versions, embed your own expires query
parameter (a Unix timestamp some minutes out) before signing, then validate
that parameter yourself in the download route after the signature checks out.
This works on every Symfony version, because UriSigner::sign(string $uri) takes
only the URL.
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Service\PdfFactory;use Symfony\Component\HttpFoundation\JsonResponse;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;use Symfony\Component\HttpFoundation\UriSigner;use Symfony\Component\Routing\Attribute\Route;use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class ReportDeliveryController{ // 1 + 2 + sign: build, store, and return a signed URL to our own route. #[Route('/reports/{reportId}', name: 'report_prepare', methods: ['POST'])] public function prepare( int $reportId, PdfFactory $pdf, UriSigner $signer, UrlGeneratorInterface $urls, ReportStorage $storage, // your storage adapter ): JsonResponse { $document = $pdf->create(); $document->addPage(); $document->cell(0, 10, "Report #{$reportId}", newLine: true);
$key = $storage->put($reportId, $document->getPdfData());
$url = $urls->generate( 'report_download', ['reportId' => $reportId, 'key' => $key], UrlGeneratorInterface::ABSOLUTE_URL, );
// Embed our own expiry (a Unix timestamp 10 minutes out), then sign the // URL only. UriSigner::sign(string $uri) is portable across all versions. $url .= (str_contains($url, '?') ? '&' : '?') . 'expires=' . ((new \DateTimeImmutable('+10 minutes'))->getTimestamp());
return new JsonResponse(['download_url' => $signer->sign($url)]); }
// verify: the signed route. checkRequest() rejects a tampered link; then we // enforce the embedded expiry ourselves. #[Route('/reports/{reportId}/download', name: 'report_download', methods: ['GET'])] public function download( Request $request, UriSigner $signer, ReportStorage $storage, ): Response { if (!$signer->checkRequest($request)) { return new Response('Link invalid.', 403); }
// Enforce the embedded expiry: reject once the timestamp is in the past. $expires = (int) $request->query->get('expires'); if ($expires < time()) { return new Response('Link expired.', 410); }
// Redirect to a presigned storage URL, or stream the object here. return new Response('', 302, ['Location' => $storage->presign( (string) $request->query->get('key'), )]); }}UriSigner is constructed with a secret (Symfony autowires it from the
%kernel.secret% / APP_SECRET parameter). The example above is the portable
path: UriSigner::sign(string $uri) signs the URL only and exists on every
Symfony version, so the expiry travels as your own expires query parameter.
The signature covers that parameter, so it cannot be tampered with — and after
checkRequest() passes, the download route enforces it by comparing the
timestamp against the current time and returning 410 Gone once it is in the
past.
On Symfony versions whose
UriSigner::sign()accepts an expiryDateTimeInterfaceargument, you can pass the expiry directly —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— and letcheckRequest()reject expired links for you, dropping the manualexpiresparameter and its check. Confirm theUriSigner::sign()signature in your installed Symfony before relying on it; the portable pattern above works regardless.
Code sample — Cloud SDK presigned URL
Section titled “Code sample — Cloud SDK presigned URL”If you sign with a cloud SDK directly rather than through a framework disk, the
shape is the same: put the object, then ask the SDK to presign a GET for it.
This is plain S3 (the GCS flow mirrors it: get the object with $bucket->object($key) and call $object->signedUrl($expiresAt, [...])).
<?php
declare(strict_types=1);
use Aws\S3\S3Client;use NextPDF\Core\Document;
/** @var Document $document Already built by your generation code. */$bytes = $document->getPdfData(); // NextPDF: the only engine call.
$s3 = new S3Client(['region' => 'eu-central-1', 'version' => 'latest']);$key = 'reports/' . bin2hex(random_bytes(16)) . '.pdf';
// Store the object privately.$s3->putObject([ 'Bucket' => 'my-private-reports', 'Key' => $key, 'Body' => $bytes, 'ContentType' => 'application/pdf',]);
// Presign a GET valid for 10 minutes. The returned URI is the signed URL.$command = $s3->getCommand('GetObject', [ 'Bucket' => 'my-private-reports', 'Key' => $key,]);$signedUrl = (string) $s3->createPresignedRequest($command, '+10 minutes')->getUri();For GCS, build the bytes the same way with getPdfData(), upload the object with
the Cloud Storage client, then get the storage object with $bucket->object($key) and call $object->signedUrl($expiresAt, [...]) with a
Carbon/DateTime expiry to mint the equivalent link. The signed-URL expiry on
both providers is bounded by the credential type; consult the provider’s docs for
the maximum lifetime your credentials allow.
Edge cases & gotchas
Section titled “Edge cases & gotchas”- Build the document exactly once.
getPdfData()triggers the build, and the build is not idempotent. Call it once, hold the string, and reuse it for both the upload and anyContent-Length, checksum, orETagyou compute. Do not call it again to “re-read” the bytes. temporaryUrl()needs a presign-capable driver. Laravel’ss3driver presigns; thelocaldriver throws ontemporaryUrl()unless you register a custom generator withStorage::disk('local')->buildTemporaryUrlsUsing(...). Pick a disk that can sign, or sign an app route instead.- Set the object content type. Store with
Content-Type: application/pdf(theContentTypeupload option, or disk metadata) so the browser opens the presigned link as a PDF instead of downloading anoctet-stream. - A short expiry can outlive a slow client. If the user clicks the link well after you mint it, a 60-second window may already be dead. Size the expiry to the realistic gap between minting and the first byte — minutes, not seconds — and re-mint on demand rather than stretching it to hours.
- A signed URL is bearer access. Anyone holding the URL before it expires can download the object. Keep expiries short, prefer one-object scope, and never log the full signed URL — the signature is effectively a token.
- Do not embed user input in the object key unsanitized. Build keys from
values you control plus random bytes (
bin2hex(random_bytes(16))). A predictable key invites enumeration once the bucket is even partly exposed.
Performance
Section titled “Performance”This pattern trades one synchronous transfer for one upload plus a tiny JSON response. The application worker is held only for the PDF build and the upload to storage, not for the client’s full download. The download itself runs between the client and storage (or its edge), so it does not consume an app worker at all.
The build is still synchronous and still dominates for large or multi-page
documents — getPdfData() realizes the whole PDF in memory before you can upload
it. For heavy documents, move generation and upload into a queued job and deliver
the signed URL out of band (for example by notifying the client when the object
is ready). See
Generate a PDF in a queued job.
Security notes
Section titled “Security notes”- Keep the bucket private; let the signature grant access. Never make the object publicly readable to “simplify” delivery. The whole point is that access flows only through a short-lived signature.
- Short, scoped expiry. Sign for the smallest window that fits your flow, and scope each URL to a single object. A leaked link then expires on its own and exposes nothing else.
- Secrets from the environment. S3/GCS credentials and the Symfony
APP_SECRETthat backsUriSignercome from environment variables or a secrets manager, never committed config. Rotating the signing secret immediately invalidates every outstanding signed route. - Verify before serving on app-signed routes. When the download crosses your
app (Laravel
signedmiddleware, SymfonyUriSigner::checkRequest()), verify the signature before any storage access or authorization. Reject a tampered or expired link with a defined status. - Never log the full signed URL. The signature is a bearer credential. Log the object key and a correlation identifier, not the signed URL, and log the exception class on failure — never the message or a stack trace.
- No empty
catch. Every example logs the failure class and returns a defined error response.
Conformance
Section titled “Conformance”This guide makes no normative standards claim. The only NextPDF engine CALL this
delivery pattern requires is NextPDF\Core\Document::getPdfData(), the verified
public method that returns the raw PDF binary; the document itself is built
however your app already builds documents (e.g. the injected
DocumentFactoryInterface / the Symfony PdfFactory). The signing primitives are documented framework and cloud APIs —
Laravel Storage::temporaryUrl() and URL::temporarySignedRoute(), Symfony
UriSigner, and the S3/GCS presigned-URL SDK operations — and their exact
signatures, supported drivers, and maximum expiry windows are governed by those
upstream projects. Consult their documentation for the authoritative contract on
each platform.
See also
Section titled “See also”- Return a generated PDF from a controller — stream the bytes directly when you do not want object storage in the loop.
- Stream a large generated PDF as an HTTP response — the buffered-vs-streamed memory model behind
getPdfData(). - Render at the edge with Cloudflare — the R2-specific signed-URL and edge-render variant of this pattern.
- Generate a PDF in a queued job — move the build and upload off the request thread.