Ga naar inhoud
getnextpdf.com

Een gegenereerde PDF leveren via een ondertekende, verlopende URL

Je genereert een Portable Document Format (PDF)-bestand en moet het aan een client overhandigen. Het simpelste pad streamt de bytes rechtstreeks door een controller, maar dat houdt een applicatie-worker bezet voor de hele download, leidt het verkeer door je servers, en stelt het bestand bloot aan iedereen die de route kan bereiken. Het leverpatroon op deze pagina doet het omgekeerde: genereer de PDF, sla de bytes op in objectopslag, en retourneer een kortlevende ondertekende Uniform Resource Locator (URL) die de client rechtstreeks van de opslag ophaalt. Je app overhandigt een kleine JavaScript Object Notation (JSON)-payload met een URL; de opslag serveert de bytes.

De NextPDF-kant is één aanroep: getPdfData() op het document retourneert de ruwe PDF-binary als een string. Alles daarna — het object plaatsen en een tijdgebonden ondertekende link aanmaken — is de taak van je framework of je cloudprovider. De ondertekenprimitieven zijn echte, gedocumenteerde API’s: Laravel Storage::temporaryUrl() en URL::temporarySignedRoute(), Symfony UriSigner, en de presigned-URL-operaties van Amazon Simple Storage Service (S3) of Google Cloud Storage (GCS) in hun software development kits (SDK’s). NextPDF definieert geen eigen URL-helper; zoek er niet naar.

Controleer deze onderdelen eerst:

  • NextPDF core is geïnstalleerd en je kunt een document bouwen.
  • Je hebt objectopslag waarvoor het framework kan ondertekenen: een S3- of S3-compatibele bucket, een GCS-bucket, of een Laravel-disk waarvan de driver temporary URLs ondersteunt.
  • Credentials staan in omgevingsvariabelen of een secrets manager, nooit in gecommitte config.

Dit is een how-to. Hij gaat ervan uit dat je al weet hoe je een request naar een controller routeert. Voor het rechtstreeks retourneren van bytes, zie Een gegenereerd PDF retourneren vanuit een controller.

Het patroon heeft drie stappen, en alleen de eerste raakt NextPDF aan:

  1. Genereren. Bouw het document en roep getPdfData() aan om de bytes te krijgen.
  2. Opslaan. Schrijf die bytes naar een objectopslag-key (reports/2026/r-42.pdf).
  3. Ondertekenen. Vraag het framework of de cloud-SDK om een ondertekende URL naar die key, met een vervaltijd, en retourneer de URL aan de client.

Waarom opslaan en ondertekenen in plaats van de bytes proxyen:

  • Bandbreedte ontlasten. Objectopslag (of de content-delivery-network-edge ervan) serveert de download. Je applicatie-worker retourneert een paar honderd bytes JSON en is meteen vrij, in plaats van te worden vastgehouden voor de duur van een overdracht van meerdere megabytes.
  • Toegang afbakenen. Een ondertekende URL verleent toegang tot één object voor een begrensd venster. De bucket zelf blijft privé. Er is geen openbare route om te bruteforcen en geen brede bucket-read-grant.
  • Vervaltijd. De handtekening sluit een vervaltijdstempel in. Nadat dat is verstreken, is de link dood. Een gelekte URL stopt vanzelf met werken, wat de blast radius van een onbedoeld gedeelde link begrenst.

Er zijn twee verschillende ondertekenmodellen, en ze verschillen in wat er wordt ondertekend:

  • Presigned-URL’s voor objectopslag (S3, GCS, of Laravels temporaryUrl() over een S3/GCS-disk) wijzen rechtstreeks naar het opslagobject. De download bereikt je app helemaal niet.
  • Ondertekende applicatieroutes (Laravel URL::temporarySignedRoute(), Symfony UriSigner) wijzen naar je eigen route. De request raakt nog steeds je app, die de handtekening verifieert en daarna naar het object streamt of doorverwijst. Gebruik deze wanneer je per download autorisatie, logging of boekhouding moet uitvoeren, of wanneer je opslag niet kan presignen.
AandachtspuntNextPDFLaravelSymfony
PDF-bytes ophalenNextPDF\Core\Document::getPdfData(): stringhetzelfdehetzelfde
Bytes opslaanStorage::disk($d)->put($key, $bytes)Filesystem::dumpFile($path, $bytes) of Flysystem write()
Presigned-opslag-URLStorage::disk($d)->temporaryUrl($key, $expiresAt)AWS/GCS-SDK-presigner (hieronder)
Ondertekende app-routeURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Een ondertekende app-route verifiërensigned-route-middleware / $request->hasValidSignature()UriSigner::check() / checkRequest()

De enige NextPDF-engine-AANROEP die dit leverpatroon vereist is getPdfData(); het document zelf wordt gebouwd zoals je app al documenten bouwt (bijv. de geïnjecteerde DocumentFactoryInterface / de Symfony PdfFactory). getPdfData() is gedeclareerd in de HasOutput-trait op NextPDF\Core\Document. Hij roept de writer één keer aan en retourneert de hele PDF als een string. Zijn tegenhanger save(string $path): void schrijft dezelfde bytes naar schijf via een atomaire writer; gebruik die alleen wanneer je opslag een echt lokaal filesystem-pad is. Geef voor objectopslag de voorkeur aan getPdfData() en laat de opslag-SDK de overdracht beheren.

Het document wordt gebouwd wanneer je getPdfData() (of save()) aanroept, en de build is niet idempotent. Roep hem één keer per document aan, vang de string op, en hergebruik die string voor zowel de upload als elke grootte of checksum die je berekent.

De filesystem-abstractie van Laravel ondertekent voor je. Op een S3- (of S3-compatibele) disk retourneert Storage::temporaryUrl() een presigned-URL rechtstreeks naar het object. De client downloadt van de opslag; je action retourneert alleen 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);
}
}
}

De disk moet er een zijn waarvan de driver temporary URLs ondersteunt — de meegeleverde s3-driver doet dat. temporaryUrl() aanroepen op de local-driver gooit een uitzondering tenzij je er een generator voor registreert, omdat een lokale disk niets te presignen heeft.

Wanneer je de download liever op je eigen route houdt — om per request autorisatie uit te voeren of om elke toegang te loggen — onderteken dan in plaats daarvan een route met URL::temporarySignedRoute(). De signed-middleware van de route wijst een geknoeide of verlopen link af voordat je action draait.

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 heeft geen Laravel-achtige storage-facade, dus je ondertekent je eigen route met de Symfony\Component\HttpFoundation\UriSigner van het framework, en laat die route vervolgens doorverwijzen naar een presigned-opslag-URL (of het object streamen). UriSigner::sign() voegt een keyed hash toe; checkRequest() wijst een geknoeide link af. Om het voorbeeld overdraagbaar te houden over Symfony-versies, sluit je je eigen expires-queryparameter (een Unix-tijdstempel enkele minuten verderop) vóór het ondertekenen in, en valideer je die parameter daarna zelf in de download-route nadat de handtekening klopt. Dit werkt op elke Symfony-versie, omdat UriSigner::sign(string $uri) alleen de URL aanneemt.

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 wordt geconstrueerd met een secret (Symfony autowiret het vanuit de parameter %kernel.secret% / APP_SECRET). Het voorbeeld hierboven is het overdraagbare pad: UriSigner::sign(string $uri) ondertekent alleen de URL en bestaat op elke Symfony-versie, dus de vervaltijd reist mee als je eigen expires-queryparameter. De handtekening dekt die parameter, dus er kan niet mee worden geknoeid — en nadat checkRequest() slaagt, dwingt de download-route hem af door het tijdstempel te vergelijken met de huidige tijd en 410 Gone te retourneren zodra het in het verleden ligt.

Op Symfony-versies waarvan UriSigner::sign() een vervaltijd-argument DateTimeInterface accepteert, kun je de vervaltijd rechtstreeks doorgeven — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — en checkRequest() verlopen links voor je laten afwijzen, waardoor de handmatige expires-parameter en de controle ervan vervallen. Bevestig de signatuur van UriSigner::sign() in je geïnstalleerde Symfony voordat je erop vertrouwt; het overdraagbare patroon hierboven werkt ongeacht.

Als je rechtstreeks met een cloud-SDK ondertekent in plaats van via een framework-disk, is de vorm hetzelfde: plaats het object, vraag de SDK daarna een GET ervoor te presignen. Dit is platte S3 (de GCS-flow weerspiegelt het: haal het object op met $bucket->object($key) en roep $object->signedUrl($expiresAt, [...]) aan).

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();

Bouw voor GCS de bytes op dezelfde manier met getPdfData(), upload het object met de Cloud Storage-client, haal daarna het opslagobject op met $bucket->object($key) en roep $object->signedUrl($expiresAt, [...]) aan met een Carbon/DateTime-vervaltijd om de equivalente link aan te maken. De presigned-URL-vervaltijd op beide providers wordt begrensd door het credentialtype; raadpleeg de docs van de provider voor de maximale levensduur die je credentials toestaan.

  • Bouw het document precies één keer. getPdfData() triggert de build, en de build is niet idempotent. Roep hem één keer aan, houd de string vast, en hergebruik hem voor zowel de upload als elke Content-Length, checksum of ETag die je berekent. Roep hem niet opnieuw aan om de bytes “opnieuw te lezen”.
  • temporaryUrl() heeft een presign-capabele driver nodig. De s3-driver van Laravel presigned; de local-driver gooit op temporaryUrl() tenzij je een custom generator registreert met Storage::disk('local')->buildTemporaryUrlsUsing(...). Kies een disk die kan ondertekenen, of onderteken in plaats daarvan een app-route.
  • Stel het object-content-type in. Sla op met Content-Type: application/pdf (de upload-optie ContentType, of disk-metadata) zodat de browser de presigned-link als een PDF opent in plaats van een octet-stream te downloaden.
  • Een korte vervaltijd kan een trage client overleven. Als de gebruiker ruim nadat je hem aanmaakt op de link klikt, kan een venster van 60 seconden al dood zijn. Stem de vervaltijd af op de realistische kloof tussen aanmaken en de eerste byte — minuten, geen seconden — en maak op aanvraag opnieuw aan in plaats van hem tot uren op te rekken.
  • Een ondertekende URL is bearer-toegang. Iedereen die de URL vasthoudt voordat hij verloopt kan het object downloaden. Houd vervaltijden kort, geef de voorkeur aan één-object-bereik, en log nooit de volledige ondertekende URL — de handtekening is in feite een token.
  • Sluit geen ongesaniteerde gebruikersinvoer in de object-key in. Bouw keys uit waarden die je beheert plus random bytes (bin2hex(random_bytes(16))). Een voorspelbare key nodigt uit tot enumeratie zodra de bucket ook maar deels is blootgesteld.

Dit patroon ruilt één synchrone overdracht in voor één upload plus een kleine JSON-response. De applicatie-worker wordt alleen vastgehouden voor de PDF-build en de upload naar de opslag, niet voor de volledige download van de client. De download zelf loopt tussen de client en de opslag (of de edge ervan), dus hij verbruikt helemaal geen app-worker.

De build is nog steeds synchroon en domineert nog steeds voor grote of veelpagina’s-documenten — getPdfData() realiseert de hele PDF in het geheugen voordat je hem kunt uploaden. Voor zware documenten verplaats je generatie en upload naar een queued job en lever je de ondertekende URL out of band (bijvoorbeeld door de client te notificeren wanneer het object klaar is). Zie Een PDF genereren in een queued job.

  • Houd de bucket privé; laat de handtekening toegang verlenen. Maak het object nooit openbaar leesbaar om de levering te “vereenvoudigen”. Het hele punt is dat toegang alleen via een kortlevende handtekening loopt.
  • Korte, afgebakende vervaltijd. Onderteken voor het kleinste venster dat bij je flow past, en baken elke URL af tot één object. Een gelekte link verloopt dan vanzelf en stelt niets anders bloot.
  • Secrets uit de omgeving. S3/GCS-credentials en het Symfony APP_SECRET dat UriSigner ondersteunt komen uit omgevingsvariabelen of een secrets manager, nooit uit gecommitte config. Het roteren van het ondertekensecret maakt onmiddellijk elke uitstaande ondertekende route ongeldig.
  • Verifieer vóór het serveren op app-ondertekende routes. Wanneer de download je app kruist (Laravel signed-middleware, Symfony UriSigner::checkRequest()), verifieer de handtekening vóór elke opslagtoegang of autorisatie. Wijs een geknoeide of verlopen link af met een gedefinieerde status.
  • Log nooit de volledige ondertekende URL. De handtekening is een bearer-credential. Log de object-key en een correlatie-identifier, niet de ondertekende URL, en log de uitzonderingsklasse bij falen — nooit het bericht of een stack trace.
  • Geen lege catch. Elk voorbeeld logt de faalklasse en retourneert een gedefinieerde foutresponse.

Deze handleiding doet geen normatieve standaardenclaim. De enige NextPDF-engine-AANROEP die dit leverpatroon vereist is NextPDF\Core\Document::getPdfData(), de geverifieerde openbare methode die de ruwe PDF-binary retourneert; het document zelf wordt gebouwd zoals je app al documenten bouwt (bijv. de geïnjecteerde DocumentFactoryInterface / de Symfony PdfFactory). De ondertekenprimitieven zijn gedocumenteerde framework- en cloud-API’s — Laravel Storage::temporaryUrl() en URL::temporarySignedRoute(), Symfony UriSigner, en de S3/GCS-presigned-URL-SDK-operaties — en hun exacte signaturen, ondersteunde drivers en maximale vervalvensters worden bestuurd door die upstream-projecten. Raadpleeg hun documentatie voor het gezaghebbende contract op elk platform.