Zum Inhalt springen
getnextpdf.com

Ein generiertes PDF über eine signierte, ablaufende URL ausliefern

Sie erzeugen eine Portable-Document-Format-(PDF-)Datei und müssen sie einem Client aushändigen. Der einfachste Weg streamt die Bytes direkt durch einen Controller, aber das bindet einen Anwendungs-Worker für den gesamten Download, leitet den Traffic durch Ihre Server und exponiert die Datei für jeden, der die Route erreichen kann. Das Auslieferungsmuster auf dieser Seite tut das Gegenteil: erzeugen Sie das PDF, speichern Sie die Bytes in Objektspeicher und geben Sie einen kurzlebigen, signierten Uniform Resource Locator (URL) zurück, den der Client direkt aus dem Speicher abruft. Ihre App gibt eine kleine JavaScript-Object-Notation-(JSON-)Payload mit einer URL zurück; der Speicher liefert die Bytes.

Die NextPDF-Seite ist ein Aufruf: getPdfData() auf dem Dokument gibt das rohe PDF-Binary als Zeichenkette zurück. Alles danach — das Objekt ablegen und einen zeitlich begrenzten signierten Link prägen — ist die Aufgabe Ihres Frameworks oder Ihres Cloud-Anbieters. Die Signatur-Primitive sind echte, dokumentierte APIs: Laravel Storage::temporaryUrl() und URL::temporarySignedRoute(), Symfony UriSigner sowie die Presigned-URL-Operationen von Amazon Simple Storage Service (S3) oder Google Cloud Storage (GCS) in deren Software Development Kits (SDKs). NextPDF definiert keinen eigenen URL-Helfer; suchen Sie nicht danach.

Prüfen Sie zuerst diese Teile:

  • NextPDF Core ist installiert und Sie können ein Dokument bauen.
  • Sie verfügen über Objektspeicher, für den das Framework signieren kann: einen S3- oder S3-kompatiblen Bucket, einen GCS-Bucket oder eine Laravel-Disk, deren Treiber Temporary-URLs unterstützt.
  • Anmeldedaten liegen in Umgebungsvariablen oder einem Secret-Manager, niemals in eingecheckter Konfiguration.

Dies ist eine betriebliche Anleitung. Sie setzt voraus, dass Sie bereits wissen, wie man eine Anfrage an einen Controller routet. Für die direkte Rückgabe von Bytes stattdessen siehe Ein generiertes PDF aus einem Controller zurückgeben.

Das Muster hat drei Schritte, und nur der erste berührt NextPDF:

  1. Erzeugen. Bauen Sie das Dokument und rufen Sie getPdfData() auf, um die Bytes zu erhalten.
  2. Speichern. Schreiben Sie diese Bytes auf einen Objektspeicher-Key (reports/2026/r-42.pdf).
  3. Signieren. Bitten Sie das Framework oder das Cloud-SDK um eine signierte URL zu diesem Key, mit einem Ablauf, und geben Sie die URL an den Client zurück.

Warum speichern und signieren statt die Bytes zu proxyen:

  • Bandbreite auslagern. Der Objektspeicher (oder sein Content-Delivery-Network-Edge) liefert den Download. Ihr Anwendungs-Worker gibt ein paar hundert Bytes JSON zurück und ist sofort frei, statt für die Dauer einer mehrere Megabyte großen Übertragung gehalten zu werden.
  • Zugriff eingrenzen. Eine signierte URL gewährt Zugriff auf ein Objekt für ein begrenztes Zeitfenster. Der Bucket selbst bleibt privat. Es gibt keine öffentliche Route zum Brute-Forcen und keine breite Bucket-Lese-Berechtigung.
  • Ablauf. Die Signatur bettet einen Ablaufzeitstempel ein. Nachdem dieser überschritten ist, ist der Link tot. Eine durchgesickerte URL hört von selbst auf zu funktionieren, was den Wirkungsradius einer versehentlichen Weitergabe begrenzt.

Es gibt zwei verschiedene Signaturmodelle, und sie unterscheiden sich darin, was signiert wird:

  • Objektspeicher-Presigned-URLs (S3, GCS oder Laravels temporaryUrl() über eine S3-/GCS-Disk) zeigen direkt auf das Speicherobjekt. Der Download erreicht Ihre App überhaupt nicht.
  • Signierte Anwendungsrouten (Laravel URL::temporarySignedRoute(), Symfony UriSigner) zeigen auf Ihre eigene Route. Die Anfrage trifft weiterhin Ihre App, die die Signatur verifiziert und dann zum Objekt streamt oder weiterleitet. Verwenden Sie diese, wenn Sie bei jedem Download Autorisierung, Logging oder Abrechnung ausführen müssen oder wenn Ihr Speicher nicht presignen kann.
BelangNextPDFLaravelSymfony
PDF-Bytes holenNextPDF\Core\Document::getPdfData(): stringsamesame
Bytes speichernStorage::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)
Signierte App-RouteURL::temporarySignedRoute($name, $expiresAt, $params)UriSigner::sign($url)
Eine signierte App-Route verifizierensigned route middleware / $request->hasValidSignature()UriSigner::check() / checkRequest()

Der einzige NextPDF-Engine-AUFRUF, den dieses Auslieferungsmuster benötigt, ist getPdfData(); das Dokument selbst wird so gebaut, wie Ihre App Dokumente bereits baut (z. B. die injizierte DocumentFactoryInterface / die Symfony PdfFactory). getPdfData() ist im HasOutput-Trait auf NextPDF\Core\Document deklariert. Es ruft den Writer einmal auf und gibt das gesamte PDF als Zeichenkette zurück. Sein Pendant save(string $path): void schreibt dieselben Bytes über einen atomaren Writer auf die Festplatte; verwenden Sie es nur, wenn Ihr Speicher ein echter lokaler Dateisystempfad ist. Für Objektspeicher bevorzugen Sie getPdfData() und lassen das Storage-SDK die Übertragung besitzen.

Das Dokument wird gebaut, wenn Sie getPdfData() (oder save()) aufrufen, und der Build ist nicht idempotent. Rufen Sie es einmal pro Dokument auf, fangen Sie die Zeichenkette ein und verwenden Sie diese Zeichenkette für den Upload und jede Größe oder Prüfsumme wieder, die Sie berechnen.

Laravels Dateisystem-Abstraktion signiert für Sie. Auf einer S3- (oder S3-kompatiblen) Disk gibt Storage::temporaryUrl() eine Presigned-URL direkt zum Objekt zurück. Der Client lädt aus dem Speicher herunter; Ihre Action gibt nur JSON zurück.

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

Die Disk muss eine sein, deren Treiber Temporary-URLs unterstützt — der gebündelte s3-Treiber tut das. Ein Aufruf von temporaryUrl() auf dem local-Treiber wirft, sofern Sie keinen Generator dafür registrieren, weil eine lokale Disk nichts zum Presignen hat.

Wenn Sie den Download lieber auf Ihrer eigenen Route halten möchten — um Autorisierung pro Anfrage auszuführen oder jeden Zugriff zu loggen —, signieren Sie stattdessen eine Route mit URL::temporarySignedRoute(). Die signed-Middleware der Route weist einen manipulierten oder abgelaufenen Link zurück, bevor Ihre Action läuft.

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 hat keine Laravel-artige Storage-Fassade, also signieren Sie Ihre eigene Route mit dem Symfony\Component\HttpFoundation\UriSigner des Frameworks und lassen diese Route dann zu einer Presigned-Storage-URL weiterleiten (oder das Objekt streamen). UriSigner::sign() hängt einen geschlüsselten Hash an; checkRequest() weist einen manipulierten Link zurück. Um das Beispiel über Symfony-Versionen hinweg portabel zu halten, betten Sie Ihren eigenen expires-Query-Parameter (einen Unix-Zeitstempel einige Minuten in der Zukunft) vor dem Signieren ein und validieren diesen Parameter dann selbst in der download-Route, nachdem die Signatur geprüft wurde. Das funktioniert auf jeder Symfony-Version, weil UriSigner::sign(string $uri) nur die URL nimmt.

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 wird mit einem Geheimnis konstruiert (Symfony verdrahtet es automatisch aus dem Parameter %kernel.secret% / APP_SECRET). Das obige Beispiel ist der portable Pfad: UriSigner::sign(string $uri) signiert nur die URL und existiert auf jeder Symfony-Version, sodass der Ablauf als Ihr eigener expires-Query-Parameter mitreist. Die Signatur deckt diesen Parameter ab, sodass er nicht manipuliert werden kann — und nachdem checkRequest() durchgeht, erzwingt die download-Route ihn, indem sie den Zeitstempel mit der aktuellen Zeit vergleicht und 410 Gone zurückgibt, sobald er in der Vergangenheit liegt.

Auf Symfony-Versionen, deren UriSigner::sign() ein Ablauf-Argument vom Typ DateTimeInterface akzeptiert, können Sie den Ablauf direkt übergeben — $signer->sign($url, new \DateTimeImmutable('+10 minutes')) — und checkRequest() abgelaufene Links für Sie zurückweisen lassen, wodurch der manuelle expires-Parameter und seine Prüfung entfallen. Bestätigen Sie die Signatur von UriSigner::sign() in Ihrer installierten Symfony-Version, bevor Sie sich darauf verlassen; das portable Muster oben funktioniert unabhängig davon.

Wenn Sie direkt mit einem Cloud-SDK signieren statt über eine Framework-Disk, ist die Form dieselbe: das Objekt ablegen, dann das SDK bitten, einen GET dafür zu presignen. Dies ist reines S3 (der GCS-Ablauf spiegelt es: das Objekt mit $bucket->object($key) holen und $object->signedUrl($expiresAt, [...]) aufrufen).

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

Für GCS bauen Sie die Bytes auf dieselbe Weise mit getPdfData(), laden das Objekt mit dem Cloud-Storage-Client hoch, holen dann das Speicherobjekt mit $bucket->object($key) und rufen $object->signedUrl($expiresAt, [...]) mit einem Carbon/DateTime-Ablauf auf, um den gleichwertigen Link zu prägen. Der Signed-URL-Ablauf bei beiden Anbietern ist durch den Anmeldedaten-Typ begrenzt; ziehen Sie die Dokumentation des Anbieters für die maximale Lebensdauer zurate, die Ihre Anmeldedaten zulassen.

  • Bauen Sie das Dokument genau einmal. getPdfData() löst den Build aus, und der Build ist nicht idempotent. Rufen Sie es einmal auf, halten Sie die Zeichenkette und verwenden Sie sie für den Upload und jede Content-Length, Prüfsumme oder ETag wieder, die Sie berechnen. Rufen Sie es nicht erneut auf, um die Bytes „neu zu lesen“.
  • temporaryUrl() braucht einen presign-fähigen Treiber. Laravels s3-Treiber presignt; der local-Treiber wirft bei temporaryUrl(), sofern Sie keinen eigenen Generator mit Storage::disk('local')->buildTemporaryUrlsUsing(...) registrieren. Wählen Sie eine Disk, die signieren kann, oder signieren Sie stattdessen eine App-Route.
  • Setzen Sie den Objekt-Content-Type. Speichern Sie mit Content-Type: application/pdf (die ContentType-Upload-Option oder Disk-Metadaten), sodass der Browser den Presigned-Link als PDF öffnet, statt ein octet-stream herunterzuladen.
  • Ein kurzer Ablauf kann einen langsamen Client überdauern. Wenn der Nutzer den Link deutlich nach dem Prägen klickt, kann ein 60-Sekunden-Fenster bereits tot sein. Dimensionieren Sie den Ablauf auf die realistische Lücke zwischen dem Prägen und dem ersten Byte — Minuten, nicht Sekunden — und prägen Sie auf Anforderung neu, statt ihn auf Stunden zu dehnen.
  • Eine signierte URL ist Bearer-Zugriff. Jeder, der die URL vor ihrem Ablauf besitzt, kann das Objekt herunterladen. Halten Sie Abläufe kurz, bevorzugen Sie Ein-Objekt-Scope und loggen Sie niemals die vollständige signierte URL — die Signatur ist effektiv ein Token.
  • Betten Sie Nutzereingaben nicht unsaniert in den Objekt-Key ein. Bauen Sie Keys aus Werten, die Sie kontrollieren, plus zufälligen Bytes (bin2hex(random_bytes(16))). Ein vorhersehbarer Key lädt zur Enumeration ein, sobald der Bucket auch nur teilweise exponiert ist.

Dieses Muster tauscht eine synchrone Übertragung gegen einen Upload plus eine winzige JSON-Antwort. Der Anwendungs-Worker wird nur für den PDF-Build und den Upload in den Speicher gehalten, nicht für den vollständigen Download des Clients. Der Download selbst läuft zwischen dem Client und dem Speicher (oder dessen Edge), sodass er überhaupt keinen App-Worker verbraucht.

Der Build ist weiterhin synchron und dominiert weiterhin bei großen oder mehrseitigen Dokumenten — getPdfData() realisiert das gesamte PDF im Speicher, bevor Sie es hochladen können. Für schwere Dokumente verschieben Sie Erzeugung und Upload in einen Queued Job und liefern die signierte URL out of band aus (zum Beispiel, indem Sie den Client benachrichtigen, wenn das Objekt bereit ist). Siehe Ein PDF in einem Queued Job erzeugen.

  • Halten Sie den Bucket privat; lassen Sie die Signatur den Zugriff gewähren. Machen Sie das Objekt niemals öffentlich lesbar, um die Auslieferung zu „vereinfachen“. Der ganze Sinn ist, dass der Zugriff nur über eine kurzlebige Signatur fließt.
  • Kurzer, eingegrenzter Ablauf. Signieren Sie für das kleinste Fenster, das zu Ihrem Ablauf passt, und grenzen Sie jede URL auf ein einzelnes Objekt ein. Ein durchgesickerter Link läuft dann von selbst ab und exponiert nichts anderes.
  • Geheimnisse aus der Umgebung. S3-/GCS-Anmeldedaten und das Symfony APP_SECRET, das UriSigner untermauert, kommen aus Umgebungsvariablen oder einem Secret-Manager, niemals aus eingecheckter Konfiguration. Das Rotieren des Signierungsgeheimnisses invalidiert sofort jede ausstehende signierte Route.
  • Verifizieren Sie vor dem Bedienen auf app-signierten Routen. Wenn der Download Ihre App durchquert (Laravel signed-Middleware, Symfony UriSigner::checkRequest()), verifizieren Sie die Signatur vor jedem Speicherzugriff oder jeder Autorisierung. Weisen Sie einen manipulierten oder abgelaufenen Link mit einem definierten Status zurück.
  • Loggen Sie niemals die vollständige signierte URL. Die Signatur ist ein Bearer-Credential. Loggen Sie den Objekt-Key und einen Korrelationsbezeichner, nicht die signierte URL, und loggen Sie bei einem Fehler die Exception-Klasse — niemals die Nachricht oder einen Stack-Trace.
  • Kein leeres catch. Jedes Beispiel loggt die Fehlerklasse und gibt eine definierte Fehlerantwort zurück.

Diese Anleitung erhebt keinen normativen Standardanspruch. Der einzige NextPDF-Engine-AUFRUF, den dieses Auslieferungsmuster benötigt, ist NextPDF\Core\Document::getPdfData(), die verifizierte öffentliche Methode, die das rohe PDF-Binary zurückgibt; das Dokument selbst wird so gebaut, wie Ihre App Dokumente bereits baut (z. B. die injizierte DocumentFactoryInterface / die Symfony PdfFactory). Die Signatur-Primitive sind dokumentierte Framework- und Cloud-APIs — Laravel Storage::temporaryUrl() und URL::temporarySignedRoute(), Symfony UriSigner sowie die S3-/GCS-Presigned-URL-SDK-Operationen —, und ihre exakten Signaturen, unterstützten Treiber und maximalen Ablauffenster werden von diesen Upstream-Projekten bestimmt. Ziehen Sie deren Dokumentation für den maßgeblichen Vertrag auf jeder Plattform zurate.