Een gegenereerde PDF leveren via een ondertekende, verlopende URL
In een oogopslag
Sectie met titel “In een oogopslag”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.
Conceptueel overzicht
Sectie met titel “Conceptueel overzicht”Het patroon heeft drie stappen, en alleen de eerste raakt NextPDF aan:
- Genereren. Bouw het document en roep
getPdfData()aan om de bytes te krijgen. - Opslaan. Schrijf die bytes naar een objectopslag-key (
reports/2026/r-42.pdf). - 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(), SymfonyUriSigner) 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.
API-oppervlak
Sectie met titel “API-oppervlak”| Aandachtspunt | NextPDF | Laravel | Symfony |
|---|---|---|---|
| PDF-bytes ophalen | NextPDF\Core\Document::getPdfData(): string | hetzelfde | hetzelfde |
| Bytes opslaan | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) of Flysystem write() |
| Presigned-opslag-URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS-SDK-presigner (hieronder) |
| Ondertekende app-route | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Een ondertekende app-route verifiëren | — | signed-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()(ofsave()) 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.
Codevoorbeeld — Laravel temporary URL
Sectie met titel “Codevoorbeeld — Laravel temporary URL”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.
<?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.
<?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');Codevoorbeeld — Symfony UriSigner
Sectie met titel “Codevoorbeeld — Symfony UriSigner”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.
<?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-argumentDateTimeInterfaceaccepteert, kun je de vervaltijd rechtstreeks doorgeven —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— encheckRequest()verlopen links voor je laten afwijzen, waardoor de handmatigeexpires-parameter en de controle ervan vervallen. Bevestig de signatuur vanUriSigner::sign()in je geïnstalleerde Symfony voordat je erop vertrouwt; het overdraagbare patroon hierboven werkt ongeacht.
Codevoorbeeld — Cloud SDK presigned URL
Sectie met titel “Codevoorbeeld — Cloud SDK presigned URL”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).
<?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.
Randgevallen en valkuilen
Sectie met titel “Randgevallen en valkuilen”- 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 elkeContent-Length, checksum ofETagdie je berekent. Roep hem niet opnieuw aan om de bytes “opnieuw te lezen”. temporaryUrl()heeft een presign-capabele driver nodig. Des3-driver van Laravel presigned; delocal-driver gooit optemporaryUrl()tenzij je een custom generator registreert metStorage::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-optieContentType, of disk-metadata) zodat de browser de presigned-link als een PDF opent in plaats van eenoctet-streamte 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.
Prestaties
Sectie met titel “Prestaties”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.
Beveiligingsnotities
Sectie met titel “Beveiligingsnotities”- 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_SECRETdatUriSignerondersteunt 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, SymfonyUriSigner::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.
Conformiteit
Sectie met titel “Conformiteit”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.
Zie ook
Sectie met titel “Zie ook”- Een gegenereerd PDF retourneren vanuit een controller — stream de bytes rechtstreeks wanneer je geen objectopslag in de lus wilt.
- Een groot gegenereerd PDF als HTTP-response streamen — het gebufferde-vs-gestreamde-geheugenmodel achter
getPdfData(). - Renderen aan de edge met Cloudflare — de R2-specifieke ondertekende-URL- en edge-render-variant van dit patroon.
- Een PDF genereren in een queued job — verplaats de build en upload van de request-thread.