Livrer un PDF généré via une URL signée et expirante
En un coup d’œil
Section intitulée « En un coup d’œil »Tu génères un fichier Portable Document Format (PDF) et tu dois le remettre à un client. Le chemin le plus simple fait transiter les octets directement par un contrôleur, mais cela mobilise un worker d’application pour tout le téléchargement, fait passer le trafic par tes serveurs, et expose le fichier à quiconque peut atteindre la route. Le schéma de livraison de cette page fait l’inverse : génère le PDF, stocke les octets dans du stockage objet, et renvoie un Uniform Resource Locator (URL) signé de courte durée que le client récupère directement depuis le stockage. Ton app renvoie une petite charge utile JavaScript Object Notation (JSON) avec une URL ; le stockage sert les octets.
Le côté NextPDF est un seul appel : getPdfData() sur le document renvoie le binaire
PDF brut sous forme de chaîne. Tout ce qui suit — déposer l’objet et frapper un lien
signé à durée limitée — est le travail de ton framework ou de ton fournisseur cloud.
Les primitives de signature sont de vraies API documentées : Laravel
Storage::temporaryUrl() et URL::temporarySignedRoute(), Symfony UriSigner, et les
opérations d’URL présignée Amazon Simple Storage Service (S3) ou Google Cloud Storage
(GCS) dans leurs kits de développement logiciel (SDK). NextPDF ne définit aucun helper
d’URL qui lui soit propre ; ne va pas en chercher un.
Vérifie d’abord ces éléments :
- NextPDF core est installé et tu peux construire un document.
- Tu disposes d’un stockage objet que le framework peut signer : un bucket S3 ou compatible S3, un bucket GCS, ou un disque Laravel dont le pilote prend en charge les URL temporaires.
- Les identifiants vivent dans des variables d’environnement ou un gestionnaire de secrets, jamais dans une config committée.
C’est un mode d’emploi. Il suppose que tu sais déjà router une requête vers un contrôleur. Pour renvoyer les octets directement à la place, vois Renvoyer un PDF généré depuis un contrôleur.
Vue d’ensemble conceptuelle
Section intitulée « Vue d’ensemble conceptuelle »Le schéma a trois étapes, et seule la première touche NextPDF :
- Générer. Construis le document et appelle
getPdfData()pour obtenir les octets. - Stocker. Écris ces octets vers une clé de stockage objet (
reports/2026/r-42.pdf). - Signer. Demande au framework ou au SDK cloud une URL signée vers cette clé, avec une expiration, et renvoie l’URL au client.
Pourquoi stocker et signer plutôt que de faire transiter les octets :
- Décharger la bande passante. Le stockage objet (ou sa périphérie de réseau de diffusion de contenu) sert le téléchargement. Ton worker d’application renvoie quelques centaines d’octets de JSON et est libre immédiatement, au lieu d’être retenu pendant la durée d’un transfert de plusieurs mégaoctets.
- Restreindre l’accès. Une URL signée accorde l’accès à un seul objet pour une fenêtre bornée. Le bucket lui-même reste privé. Il n’y a pas de route publique à forcer par force brute ni d’autorisation de lecture large sur le bucket.
- Expiration. La signature embarque un horodatage d’expiration. Une fois passé, le lien est mort. Une URL fuitée cesse de fonctionner d’elle-même, ce qui borne le rayon d’explosion d’un partage accidentel.
Il y a deux modèles de signature distincts, et ils diffèrent par ce qui est signé :
- Les URL présignées de stockage objet (S3, GCS, ou le
temporaryUrl()de Laravel sur un disque S3/GCS) pointent directement vers l’objet de stockage. Le téléchargement n’atteint jamais ton app. - Les routes signées de l’application (Laravel
URL::temporarySignedRoute(), SymfonyUriSigner) pointent vers ta propre route. La requête frappe toujours ton app, qui vérifie la signature, puis diffuse en flux ou redirige vers l’objet. Utilise-les quand tu dois exécuter une autorisation, une journalisation ou de la comptabilité à chaque téléchargement, ou quand ton stockage ne peut pas présigner.
Surface de l’API
Section intitulée « Surface de l’API »| Préoccupation | NextPDF | Laravel | Symfony |
|---|---|---|---|
| Obtenir les octets PDF | NextPDF\Core\Document::getPdfData(): string | idem | idem |
| Stocker les octets | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) ou Flysystem write() |
| URL de stockage présignée | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | présigneur du SDK AWS/GCS (ci-dessous) |
| Route d’app signée | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| Vérifier une route d’app signée | — | middleware de route signed / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
Le seul APPEL au moteur NextPDF que ce schéma de livraison requiert est getPdfData() ;
le document lui-même est construit comme ton app construit déjà ses documents (par ex.
le DocumentFactoryInterface injecté / le PdfFactory Symfony). getPdfData() est
déclaré dans le trait HasOutput sur NextPDF\Core\Document. Il appelle le rédacteur
une fois et renvoie tout le PDF sous forme de chaîne. Son frère save(string $path): void
écrit les mêmes octets sur disque via un rédacteur atomique ; utilise-le seulement quand
ton stockage est un vrai chemin de système de fichiers local. Pour du stockage objet,
préfère getPdfData() et laisse le SDK de stockage gérer le transfert.
Le document est construit quand tu appelles
getPdfData()(ousave()), et le build n’est pas idempotent. Appelle-le une fois par document, capture la chaîne, et réutilise cette chaîne à la fois pour le téléversement et pour toute taille ou somme de contrôle que tu calcules.
Exemple de code — URL temporaire Laravel
Section intitulée « Exemple de code — URL temporaire Laravel »L’abstraction de système de fichiers de Laravel signe pour toi. Sur un disque S3 (ou
compatible S3), Storage::temporaryUrl() renvoie une URL présignée droit vers l’objet.
Le client télécharge depuis le stockage ; ton action ne renvoie que du 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); } }}Le disque doit être un disque dont le pilote prend en charge les URL temporaires — le
pilote s3 fourni le fait. Appeler temporaryUrl() sur le pilote local lève une
exception sauf si tu enregistres un générateur pour lui, parce qu’un disque local n’a
rien à présigner.
Quand tu préfères garder le téléchargement sur ta propre route — pour exécuter une
autorisation par requête ou pour journaliser chaque accès — signe plutôt une route avec
URL::temporarySignedRoute(). Le middleware signed de la route rejette un lien
falsifié ou expiré avant que ton action ne s’exécute.
<?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');Exemple de code — UriSigner Symfony
Section intitulée « Exemple de code — UriSigner Symfony »Symfony n’a pas de façade de stockage à la Laravel, donc tu signes ta propre route
avec le Symfony\Component\HttpFoundation\UriSigner du framework, puis tu fais rediriger
cette route vers une URL de stockage présignée (ou tu diffuses l’objet). UriSigner::sign()
ajoute un hachage à clé ; checkRequest() rejette un lien falsifié. Pour garder
l’exemple portable entre les versions de Symfony, embarque ton propre paramètre de
requête expires (un horodatage Unix à quelques minutes de là) avant de signer,
puis valide ce paramètre toi-même dans la route download une fois la signature
vérifiée. Cela fonctionne sur toutes les versions de Symfony, parce que
UriSigner::sign(string $uri) ne prend que l’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 est construit avec un secret (Symfony l’autowire depuis le paramètre
%kernel.secret% / APP_SECRET). L’exemple ci-dessus est le chemin portable :
UriSigner::sign(string $uri) ne signe que l’URL et existe sur toutes les versions de
Symfony, donc l’expiration voyage sous ton propre paramètre de requête expires. La
signature couvre ce paramètre, donc il ne peut pas être falsifié — et après que
checkRequest() passe, la route download l’applique en comparant l’horodatage à
l’heure courante et en renvoyant 410 Gone une fois qu’il est passé.
Sur les versions de Symfony dont le
UriSigner::sign()accepte un argument d’expirationDateTimeInterface, tu peux passer l’expiration directement —$signer->sign($url, new \DateTimeImmutable('+10 minutes'))— et laissercheckRequest()rejeter les liens expirés pour toi, en abandonnant le paramètreexpiresmanuel et sa vérification. Confirme la signature deUriSigner::sign()dans ton Symfony installé avant de t’y fier ; le schéma portable ci-dessus fonctionne quoi qu’il en soit.
Exemple de code — URL présignée via le SDK cloud
Section intitulée « Exemple de code — URL présignée via le SDK cloud »Si tu signes directement avec un SDK cloud plutôt qu’à travers un disque de framework,
la forme est la même : dépose l’objet, puis demande au SDK de présigner un GET pour
lui. C’est du S3 brut (le flux GCS le reflète : obtiens l’objet avec
$bucket->object($key) et appelle $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();Pour GCS, construis les octets de la même façon avec getPdfData(), téléverse l’objet
avec le client Cloud Storage, puis obtiens l’objet de stockage avec
$bucket->object($key) et appelle $object->signedUrl($expiresAt, [...]) avec une
expiration Carbon/DateTime pour frapper le lien équivalent. L’expiration de l’URL
signée chez les deux fournisseurs est bornée par le type d’identifiant ; consulte la doc
du fournisseur pour la durée de vie maximale que tes identifiants autorisent.
Cas limites et pièges
Section intitulée « Cas limites et pièges »- Construis le document exactement une fois.
getPdfData()déclenche le build, et le build n’est pas idempotent. Appelle-le une fois, garde la chaîne, et réutilise-la à la fois pour le téléversement et pour toutContent-Length, somme de contrôle ouETagque tu calcules. Ne l’appelle pas à nouveau pour « relire » les octets. temporaryUrl()a besoin d’un pilote capable de présigner. Le pilotes3de Laravel présigne ; le pilotelocallève une exception surtemporaryUrl()sauf si tu enregistres un générateur personnalisé avecStorage::disk('local')->buildTemporaryUrlsUsing(...). Choisis un disque qui peut signer, ou signe plutôt une route d’app.- Définis le type de contenu de l’objet. Stocke avec
Content-Type: application/pdf(l’option de téléversementContentType, ou les métadonnées du disque) pour que le navigateur ouvre le lien présigné comme un PDF au lieu de télécharger unoctet-stream. - Une expiration courte peut être plus brève qu’un client lent. Si l’utilisateur clique le lien bien après que tu l’aies frappé, une fenêtre de 60 secondes peut déjà être morte. Dimensionne l’expiration sur l’écart réaliste entre la frappe et le premier octet — des minutes, pas des secondes — et refrappe à la demande plutôt que de l’étirer à des heures.
- Une URL signée est un accès au porteur. Quiconque détient l’URL avant son expiration peut télécharger l’objet. Garde les expirations courtes, préfère une portée d’un seul objet, et ne journalise jamais l’URL signée complète — la signature est de fait un jeton.
- N’embarque pas d’entrée utilisateur non assainie dans la clé d’objet. Construis
les clés à partir de valeurs que tu contrôles plus des octets aléatoires
(
bin2hex(random_bytes(16))). Une clé prévisible invite à l’énumération dès que le bucket est ne serait-ce que partiellement exposé.
Performance
Section intitulée « Performance »Ce schéma échange un transfert synchrone contre un téléversement plus une petite réponse JSON. Le worker d’application n’est retenu que pour le build du PDF et le téléversement vers le stockage, pas pour le téléchargement complet du client. Le téléchargement lui-même se déroule entre le client et le stockage (ou sa périphérie), donc il ne consomme pas de worker d’app du tout.
Le build reste synchrone et domine toujours pour les documents grands ou à pages
multiples — getPdfData() réalise tout le PDF en mémoire avant que tu puisses le
téléverser. Pour les documents lourds, déplace la génération et le téléversement dans un
job mis en file et livre l’URL signée hors bande (par exemple en notifiant le client
quand l’objet est prêt). Vois
Générer un PDF dans un job mis en file.
Notes de sécurité
Section intitulée « Notes de sécurité »- Garde le bucket privé ; laisse la signature accorder l’accès. Ne rends jamais l’objet lisible publiquement pour « simplifier » la livraison. Tout l’intérêt est que l’accès ne passe que par une signature de courte durée.
- Expiration courte et restreinte. Signe pour la plus petite fenêtre qui convient à ton flux, et restreins chaque URL à un seul objet. Un lien fuité expire alors de lui-même et n’expose rien d’autre.
- Secrets depuis l’environnement. Les identifiants S3/GCS et l’
APP_SECRETSymfony qui adosseUriSignerviennent de variables d’environnement ou d’un gestionnaire de secrets, jamais d’une config committée. Faire tourner le secret de signature invalide immédiatement chaque route signée en cours. - Vérifie avant de servir sur les routes signées par l’app. Quand le téléchargement
traverse ton app (middleware
signedde Laravel,UriSigner::checkRequest()de Symfony), vérifie la signature avant tout accès au stockage ou toute autorisation. Rejette un lien falsifié ou expiré avec un statut défini. - Ne journalise jamais l’URL signée complète. La signature est un identifiant au porteur. Journalise la clé d’objet et un identifiant de corrélation, pas l’URL signée, et journalise la classe d’exception en cas d’échec — jamais le message ni une trace de pile.
- Pas de
catchvide. Chaque exemple journalise la classe d’échec et renvoie une réponse d’erreur définie.
Conformité
Section intitulée « Conformité »Ce guide ne fait aucune revendication normative de standards. Le seul APPEL au moteur
NextPDF que ce schéma de livraison requiert est NextPDF\Core\Document::getPdfData(),
la méthode publique vérifiée qui renvoie le binaire PDF brut ; le document lui-même est
construit comme ton app construit déjà ses documents (par ex. le
DocumentFactoryInterface injecté / le PdfFactory Symfony). Les primitives de
signature sont des API documentées de framework et de cloud — Laravel
Storage::temporaryUrl() et URL::temporarySignedRoute(), Symfony UriSigner, et les
opérations SDK d’URL présignée S3/GCS — et leurs signatures exactes, pilotes pris en
charge et fenêtres d’expiration maximales sont régis par ces projets amont. Consulte
leur documentation pour le contrat faisant autorité sur chaque plateforme.
Voir aussi
Section intitulée « Voir aussi »- Renvoyer un PDF généré depuis un contrôleur — diffuse les octets directement quand tu ne veux pas de stockage objet dans la boucle.
- Diffuser en flux un grand PDF généré comme réponse HTTP — le modèle de mémoire tamponné-vs-en-flux derrière
getPdfData(). - Rendre en périphérie avec Cloudflare — la variante d’URL signée et de rendu en périphérie spécifique à R2 de ce schéma.
- Générer un PDF dans un job mis en file — déplace le build et le téléversement hors du thread de requête.