Skip to content
getnextpdf.com

Deliver a generated PDF via a signed, expiring URL

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.

The pattern has three steps, and only the first touches NextPDF:

  1. Generate. Build the document and call getPdfData() to get the bytes.
  2. Store. Write those bytes to an object-storage key (reports/2026/r-42.pdf).
  3. 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(), Symfony UriSigner) 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.
ConcernNextPDFLaravelSymfony
Get PDF bytesNextPDF\Core\Document::getPdfData(): stringsamesame
Store bytesStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) or Flysystem write()
Presigned storage URLStorage::disk($d)->temporaryUrl($key, $expiresAt)AWS/GCS SDK presigner (below)
Signed app routeURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Verify a signed app routesigned 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() (or save()), 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.

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.

app/Http/Controllers/ReportDeliveryController.php
<?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.

routes/web.php
<?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');

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.

src/Controller/ReportDeliveryController.php
<?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 expiry DateTimeInterface argument, you can pass the expiry directly — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — and let checkRequest() reject expired links for you, dropping the manual expires parameter and its check. Confirm the UriSigner::sign() signature in your installed Symfony before relying on it; the portable pattern above works regardless.

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, [...])).

store-and-presign.php
<?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.

  • 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 any Content-Length, checksum, or ETag you compute. Do not call it again to “re-read” the bytes.
  • temporaryUrl() needs a presign-capable driver. Laravel’s s3 driver presigns; the local driver throws on temporaryUrl() unless you register a custom generator with Storage::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 (the ContentType upload option, or disk metadata) so the browser opens the presigned link as a PDF instead of downloading an octet-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.

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.

  • 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_SECRET that backs UriSigner come 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 signed middleware, Symfony UriSigner::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.

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.