署名付きの有効期限つき URL で生成済み PDF を配信する
Portable Document Format(PDF)ファイルを生成し、それをクライアントに渡す必要があります。最もシンプルなパスはコントローラーを通じてバイトをそのままストリームすることですが、それはダウンロードの全期間アプリケーションワーカーを占有し、トラフィックをあなたのサーバー経由で流し、そのルートに到達できる誰にでもファイルを露出します。このページの配信パターンは正反対のことをします。PDF を生成し、バイトをオブジェクトストレージに保存 し、クライアントがストレージから直接取得する短命の 署名付き Uniform Resource Locator(URL) を返します。あなたのアプリは URL を含む小さな JavaScript Object Notation(JSON)ペイロードを返し、ストレージがバイトを提供します。
NextPDF 側は 1 つの呼び出しです。ドキュメントの getPdfData() が生の PDF バイナリを文字列として返します。その後のすべて——オブジェクトを置き、有効期限つきの署名付きリンクを発行すること——は、あなたのフレームワークまたはクラウドプロバイダーの仕事です。署名のプリミティブは、実在する文書化された API です。Laravel の Storage::temporaryUrl() と URL::temporarySignedRoute()、Symfony の UriSigner、そして Amazon Simple Storage Service(S3)または Google Cloud Storage(GCS)のソフトウェア開発キット(SDK)における事前署名 URL 操作です。NextPDF は独自の URL ヘルパーを定義しません。それを探さないでください。
最初にこれらの要素を確認してください。
- NextPDF コアがインストールされており、ドキュメントを構築できる。
- フレームワークが署名できるオブジェクトストレージがある。S3 または S3 互換バケット、GCS バケット、またはドライバーが一時 URL をサポートする Laravel ディスク。
- 認証情報は環境変数またはシークレットマネージャーにあり、コミットされた設定には決して置かない。
これはハウツーです。すでにリクエストをコントローラーにルーティングする方法を知っていることを前提とします。代わりにバイトを直接返す場合は、コントローラーから生成済み PDF を返す を参照してください。
概念的な概要
「概念的な概要」という見出しのセクションこのパターンには 3 つのステップがあり、最初のステップだけが NextPDF に触れます。
- 生成。 ドキュメントを構築し、
getPdfData()を呼んでバイトを得る。 - 保存。 それらのバイトをオブジェクトストレージのキー(
reports/2026/r-42.pdf)に書き込む。 - 署名。 フレームワークまたはクラウド SDK に、そのキーへの署名付き URL を有効期限つきで要求し、その URL をクライアントに返す。
バイトをプロキシする代わりに保存して署名する理由:
- 帯域幅をオフロードする。 オブジェクトストレージ(またはそのコンテンツ配信ネットワークのエッジ)がダウンロードを提供します。あなたのアプリケーションワーカーは数百バイトの JSON を返してただちに解放され、数メガバイトの転送の間ずっと拘束されることがありません。
- アクセスを限定する。 署名付き URL は、1 つのオブジェクト への 限られた期間 のアクセスを許可します。バケット自体は非公開のままです。ブルートフォースすべき公開ルートも、広範なバケット読み取り権限もありません。
- 有効期限。 署名は有効期限のタイムスタンプを埋め込みます。それが過ぎると、リンクは死にます。漏えいした URL はひとりでに機能を停止し、偶発的な共有の影響範囲を限定します。
署名には 2 つの異なるモデルがあり、何を 署名するかが異なります。
- オブジェクトストレージの事前署名 URL(S3、GCS、または S3/GCS ディスク上の Laravel の
temporaryUrl())は、ストレージオブジェクトを直接 指します。ダウンロードはあなたのアプリにまったく到達しません。 - アプリケーションの署名付きルート(Laravel の
URL::temporarySignedRoute()、Symfony のUriSigner)は、あなた自身のルート を指します。リクエストは依然としてあなたのアプリに当たり、アプリが署名を検証し、その後オブジェクトへストリームまたはリダイレクトします。ダウンロードごとに認可、ロギング、または会計処理を実行する必要があるとき、あるいはストレージが事前署名できないときに、これらを使ってください。
API サーフェス
「API サーフェス」という見出しのセクション| Concern | NextPDF | Laravel | Symfony |
|---|---|---|---|
| PDF バイトを得る | NextPDF\Core\Document::getPdfData(): string | 同じ | 同じ |
| バイトを保存する | — | Storage::disk($d)->put($key, $bytes) | Filesystem::dumpFile($path, $bytes) または Flysystem の write() |
| 事前署名ストレージ URL | — | Storage::disk($d)->temporaryUrl($key, $expiresAt) | AWS/GCS SDK の presigner(下記) |
| 署名付きアプリルート | — | URL::temporarySignedRoute($name, $expiresAt, $params) | UriSigner::sign($url) |
| 署名付きアプリルートを検証する | — | signed ルートミドルウェア / $request->hasValidSignature() | UriSigner::check() / checkRequest() |
この配信パターンが必要とする唯一の NextPDF エンジン呼び出しは getPdfData() です。ドキュメント自体は、アプリがすでにドキュメントを構築している方法(たとえば注入された DocumentFactoryInterface / Symfony の PdfFactory)で構築されます。getPdfData() は NextPDF\Core\Document の HasOutput トレイトで宣言されています。これはライターを 1 回呼び出し、PDF 全体を文字列として返します。その兄弟 save(string $path): void は、アトミックライターを通じて同じバイトをディスクに書き込みます。ストレージが実際のローカルファイルシステムパスである場合のみ使ってください。オブジェクトストレージには getPdfData() を優先し、ストレージ SDK に転送を任せてください。
ドキュメントは
getPdfData()(またはsave())を呼んだときに構築され、その構築は冪等ではありません。ドキュメントごとに 1 回 呼び、文字列をキャプチャし、その文字列をアップロードと、計算するあらゆるサイズやチェックサムの両方に再利用してください。
コードサンプル — Laravel 一時 URL
「コードサンプル — Laravel 一時 URL」という見出しのセクションLaravel のファイルシステム抽象化があなたの代わりに署名します。S3(または S3 互換)ディスクでは、Storage::temporaryUrl() がオブジェクトへの事前署名 URL を直接返します。クライアントはストレージからダウンロードし、あなたのアクションは 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); } }}ディスクは、ドライバーが一時 URL をサポートするものでなければなりません——同梱の s3 ドライバーはサポートします。local ドライバーでジェネレーターを登録せずに temporaryUrl() を呼ぶと、ローカルディスクには事前署名すべきものがないため例外を投げます。
ダウンロードを自分のルートに留めたい場合——リクエストごとの認可を実行したり、各アクセスをログに記録したりするため——は、代わりに URL::temporarySignedRoute() でルートに署名してください。ルートの signed ミドルウェアが、改ざんまたは期限切れのリンクをアクション実行前に拒否します。
<?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 UriSigner
「コードサンプル — Symfony UriSigner」という見出しのセクションSymfony には Laravel スタイルのストレージファサードがないため、フレームワークの Symfony\Component\HttpFoundation\UriSigner で あなた自身のルート に署名し、そのルートを事前署名ストレージ URL へリダイレクト(またはオブジェクトをストリーム)させます。UriSigner::sign() はキー付きハッシュを付加し、checkRequest() は改ざんされたリンクを拒否します。例を Symfony のバージョン間で可搬にするため、署名の 前 に独自の expires クエリパラメーター(数分先の Unix タイムスタンプ)を埋め込み、署名が通った後、download ルートでそのパラメーターを自分で検証します。UriSigner::sign(string $uri) は URL のみを受け取るため、これはすべての Symfony バージョンで機能します。
<?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 はシークレットで構築されます(Symfony は %kernel.secret% / APP_SECRET パラメーターからオートワイヤーします)。上の例は可搬なパスです。UriSigner::sign(string $uri) は URL のみに署名し、すべての Symfony バージョンに存在するため、有効期限はあなた自身の expires クエリパラメーターとして移動します。署名はそのパラメーターをカバーするため、改ざんできません——そして checkRequest() が通った後、download ルートはタイムスタンプを現在時刻と比較し、過去になっていれば 410 Gone を返すことでそれを強制します。
UriSigner::sign()が有効期限のDateTimeInterface引数を受け取る Symfony バージョンでは、有効期限を直接渡すことができ——$signer->sign($url, new \DateTimeImmutable('+10 minutes'))——checkRequest()に期限切れリンクの拒否を任せて、手動のexpiresパラメーターとそのチェックを省けます。それに頼る前に、インストール済みの Symfony でUriSigner::sign()のシグネチャを確認してください。上記の可搬なパターンはいずれにせよ機能します。
コードサンプル — クラウド SDK の事前署名 URL
「コードサンプル — クラウド SDK の事前署名 URL」という見出しのセクションフレームワークのディスクを通じてではなく、クラウド SDK で直接署名する場合も、形は同じです。オブジェクトを置き、その後 SDK に対してそれの GET を事前署名するよう要求します。これは素の S3 です(GCS のフローはこれを反映します。$bucket->object($key) でオブジェクトを得て、$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();GCS の場合は、同じく getPdfData() でバイトを構築し、Cloud Storage クライアントでオブジェクトをアップロードし、その後 $bucket->object($key) でストレージオブジェクトを得て、Carbon/DateTime の有効期限とともに $object->signedUrl($expiresAt, [...]) を呼んで、相当するリンクを発行します。両プロバイダーの署名付き URL の有効期限は、認証情報の種類によって制限されます。あなたの認証情報が許す最大の有効期間については、プロバイダーのドキュメントを参照してください。
エッジケースと落とし穴
「エッジケースと落とし穴」という見出しのセクション- ドキュメントを正確に 1 回だけ構築する。
getPdfData()は構築をトリガーし、その構築は冪等ではありません。1 回呼び、文字列を保持し、それをアップロードと、計算するあらゆるContent-Length、チェックサム、ETagの両方に再利用してください。バイトを「読み直す」ために再度呼ばないでください。 temporaryUrl()は事前署名可能なドライバーを必要とする。 Laravel のs3ドライバーは事前署名します。localドライバーは、Storage::disk('local')->buildTemporaryUrlsUsing(...)でカスタムジェネレーターを登録しない限り、temporaryUrl()で例外を投げます。署名できるディスクを選ぶか、代わりにアプリルートに署名してください。- オブジェクトのコンテンツタイプを設定する。
Content-Type: application/pdf(ContentTypeアップロードオプション、またはディスクメタデータ)で保存し、ブラウザが事前署名リンクをoctet-streamとしてダウンロードするのではなく PDF として開くようにしてください。 - 短い有効期限は遅いクライアントより先に切れることがある。 ユーザーが発行からかなり後にリンクをクリックすると、60 秒の窓はすでに死んでいるかもしれません。有効期限は、発行から最初のバイトまでの現実的な間隔——秒ではなく分——に合わせてサイジングし、時間単位に引き伸ばすのではなく、必要に応じて再発行してください。
- 署名付き URL はベアラーアクセスである。 有効期限前に URL を保持する誰もがオブジェクトをダウンロードできます。有効期限を短く保ち、1 オブジェクトのスコープを優先し、完全な署名付き URL を決してログに記録しないでください——署名は事実上トークンです。
- オブジェクトキーにユーザー入力をサニタイズせずに埋め込まない。 キーは、あなたが制御する値に加えてランダムバイト(
bin2hex(random_bytes(16)))から構築してください。推測可能なキーは、バケットがわずかでも露出した時点で列挙を招きます。
パフォーマンス
「パフォーマンス」という見出しのセクションこのパターンは、1 つの同期転送を、1 つのアップロードと小さな JSON レスポンスに置き換えます。アプリケーションワーカーは PDF の構築とストレージへのアップロードのためだけに拘束され、クライアントの完全なダウンロードのためには拘束されません。ダウンロード自体はクライアントとストレージ(またはそのエッジ)の間で走るため、アプリワーカーをまったく消費しません。
構築は依然として同期であり、大きなまたは複数ページのドキュメントでは依然として支配的です——getPdfData() はアップロードできる前に PDF 全体をメモリー内で実体化します。重いドキュメントの場合は、生成とアップロードをキュー化されたジョブに移し、署名付き URL を帯域外で配信してください(たとえばオブジェクトの準備ができたらクライアントに通知する)。キュー化されたジョブで PDF を生成する を参照。
セキュリティに関する注意
「セキュリティに関する注意」という見出しのセクション- バケットを非公開に保ち、署名にアクセスを許可させる。 配信を「簡単にする」ためにオブジェクトを公開で読み取り可能にしないでください。要点は、アクセスが短命の署名を通じてのみ流れることです。
- 短く限定された有効期限。 フローに合う最小の窓で署名し、各 URL を 1 つのオブジェクトに限定してください。漏えいしたリンクはひとりでに期限切れになり、それ以外の何も露出しません。
- シークレットは環境から。 S3/GCS の認証情報と、
UriSignerを支える Symfony のAPP_SECRETは、環境変数またはシークレットマネージャーから来るものであり、コミットされた設定からは決して来ません。署名シークレットをローテーションすると、未処理のすべての署名付きルートがただちに無効化されます。 - アプリ署名ルートでは提供前に検証する。 ダウンロードがあなたのアプリを横断するとき(Laravel の
signedミドルウェア、Symfony のUriSigner::checkRequest())は、いかなるストレージアクセスや認可の 前 に署名を検証してください。改ざんまたは期限切れのリンクは、定義されたステータスで拒否してください。 - 完全な署名付き URL を決してログに記録しない。 署名はベアラー認証情報です。署名付き URL ではなくオブジェクトキーと相関識別子をログに記録し、失敗時には例外クラスをログに記録してください——メッセージやスタックトレースは決して記録しないでください。
- 空の
catchを作らない。 すべての例が失敗クラスをログに記録し、定義されたエラーレスポンスを返します。
このガイドは規範的な標準の主張を行いません。この配信パターンが必要とする唯一の NextPDF エンジン呼び出しは NextPDF\Core\Document::getPdfData() であり、これは生の PDF バイナリを返す検証済みの公開メソッドです。ドキュメント自体は、アプリがすでにドキュメントを構築している方法(たとえば注入された DocumentFactoryInterface / Symfony の PdfFactory)で構築されます。署名のプリミティブは文書化されたフレームワークおよびクラウドの API です——Laravel の Storage::temporaryUrl() と URL::temporarySignedRoute()、Symfony の UriSigner、そして S3/GCS の事前署名 URL SDK 操作——そしてそれらの正確なシグネチャ、サポートされるドライバー、最大の有効期限の窓は、それら上流のプロジェクトによって統治されます。各プラットフォームの正式な契約については、それらのドキュメントを参照してください。
- コントローラーから生成済み PDF を返す — オブジェクトストレージをループに入れたくない場合にバイトを直接ストリームする。
- 大きな生成済み PDF を HTTP レスポンスとしてストリームする —
getPdfData()の背後にあるバッファード対ストリームのメモリーモデル。 - Cloudflare でエッジレンダリングする — このパターンの R2 固有の署名付き URL とエッジレンダリングのバリアント。
- キュー化されたジョブで PDF を生成する — 構築とアップロードをリクエストスレッドから外す。