コンテンツにスキップ

生成した大容量 PDF を HTTP レスポンスとしてストリーミングする

コントローラー内で大きな PDF を生成し、レスポンスバッファに 2 つ目の完全なコピーを保持せずに、そのバイト列を返したい場合があります。各フレームワーク統合には、PdfResponse ファクトリーのストリーミング版である streamInline()streamDownload() があります。どちらも、コールバック内で PDF 本体を固定 64 KB のチャンクとしてクライアントへ書き出す、フレームワークの StreamedResponse を返します。

この方法を選ぶ前に、メモリモデルを正確に理解しておいてください。エンジンはまず、完全なドキュメントをメモリ上で構築します。ストリーミングコールバックは getPdfData() を呼び出します。これは PDF 全体を 1 つの文字列として実体化し、その文字列を 64 KB ずつのスライスで走査します。節約できるピークメモリは 2 つ目 のコピー、つまりバッファリングされた Illuminate\Http\Response または Symfony\Component\HttpFoundation\Response が、フレームワークによる Content-Length の測定中に保持するコピーです。ストリーミング版は長さを測定しないため、Content-Length を省略します。レスポンス本体とドキュメント文字列を同時に保持することは決してありません。これは真のインクリメンタルストリーミングでは ありません。NextPDF にはインクリメンタルライターのインターフェイスがないため、最初のバイトがソケットに届く前にドキュメントは完全に実体化されます。

作業中に迷わないよう、前提条件を先に示します。

  • NextPDF コア、およびいずれかのフレームワーク統合(nextpdf/laravel または nextpdf/symfony)がインストールされ、検出されていること。
  • お使いのフレームワークで、リクエストをコントローラーへルーティングする方法を既に理解していること。
  • 既に コントローラーから生成した PDF を返す を読み終えていること。このレシピの前提となる、バッファリングされた inline() ファクトリーと download() ファクトリーについて説明しています。

このレシピでは、Laravel と Symfony に共通する StreamedResponse パターンを中心に取り上げます。CodeIgniter 4 も同じ streamInline() / streamDownload() というメソッド名を備えていますが、これらはバイト列を CodeIgniter\HTTP\DownloadResponse でラップします。これは、コールバック駆動の StreamedResponse とは異なります。「エッジケース」のセクションで、その違いを説明しています。

お使いのフレームワークに合った統合をインストールします。次のいずれかを実行してください。

Terminal window
composer require nextpdf/laravel
Terminal window
composer require nextpdf/symfony

Laravel の場合は、インストール後に設定を publish してください。

Terminal window
php artisan vendor:publish --tag=nextpdf-config

Symfony では、Flex を通じてバンドルが自動登録されます。続行する前に、お使いのフレームワークのインストールページで検出されていることを確認してください。

バッファリングされたレスポンスのファクトリー(PdfResponse::download() または PdfResponse::inline())は、getPdfData() を呼び出し、返された文字列を Response オブジェクトに保存し、Content-Lengthstrlen() から設定します。その後、フレームワークはレスポンスが存続する間、その文字列を保持します。大きなドキュメントの場合、これはドキュメント文字列とレスポンス本体の文字列が同時にメモリ上に存在することを意味します。

ストリーミングファクトリーは、これとは異なる動作をします。PdfResponse::streamDownload()PdfResponse::streamInline() は、コールバックを用いて構築された StreamedResponse を返します。フレームワークは、本体を送信する準備が整ったときにのみそのコールバックを呼び出します。コールバック内で、統合は getPdfData() を 1 回呼び出し、返された文字列を 64 KB のチャンクに分割し、各チャンクを echo してから flush() します。本体の 2 つ目の永続的なコピーは保持されず、Content-Length ヘッダーも出力されません。

このページの判断はすべて、次の 2 つの事実を前提にしています。

  • 構築はイーガーに行われ、転送はチャンク化されます。 getPdfData()NextPDF\Core\Document のメソッド)はライターを呼び出し、PDF 全体を 1 つの文字列として返します。64 KB のチャンク化は、構築済みのバイト列がプロセスから出ていく方法のみを制御します。ピークメモリは、小さなストリーミングウィンドウではなく、完成した 1 つのドキュメントのサイズによって制限されます。
  • Content-Length はありません。 ストリーミング版は、コールバック内で本体を構築しない限りその長さを知ることができないため、ヘッダーを省略します。クライアントの進捗バー、Range リクエスト、長さに依存するプロキシは、サイズを認識できません。レスポンスコピーの節約よりも既知の長さが重要な場合は、バッファリングされた download() / inline() を選択してください。

ドキュメントは、各フレームワークに即した resolve(解決)パスで取得します。

  • Laravel: コンテナーから NextPDF\Contracts\DocumentFactoryInterface を解決し、create() を呼び出します。これにより、新しい NextPDF\Core\Document が返されます。これは、ストリーミングファクトリーが受け取る具象型です。
  • Symfony: NextPDF\Symfony\Service\PdfFactory をインジェクトし、create() を呼び出します。これにより、設定済みのデフォルトが適用された新しい NextPDF\Core\Document が返されます。
関心事LaravelSymfony
新しいドキュメントapp(DocumentFactoryInterface::class)->create()PdfFactory::create()
ストリーミングのインラインPdfResponse::streamInline($doc, $name)PdfResponse::streamInline($doc, $name)
ストリーミングのダウンロードPdfResponse::streamDownload($doc, $name)PdfResponse::streamDownload($doc, $name)
戻り値の型Symfony\Component\HttpFoundation\StreamedResponseSymfony\Component\HttpFoundation\StreamedResponse
コールバック内の構築呼び出しNextPDF\Core\Document::getPdfData()NextPDF\Core\Document::getPdfData()
チャンクサイズ64 KB(決定的な str_split64 KB(決定的な substr ループ)

Laravel の PdfResponseNextPDF\Laravel\Http\PdfResponse にあり、Symfony のものは NextPDF\Symfony\Http\PdfResponse にあります。どちらのストリーミングファクトリーも、同じ Symfony\Component\HttpFoundation\StreamedResponse 型を返します。どちらも、固定された同一の Open Web Application Security Project(OWASP)レスポンス強化ヘッダーセット(X-Content-Type-Options: nosniffX-Frame-Options: DENYContent-Security-Policy: default-src 'none'X-Robots-Tag: noindex, nofollowReferrer-Policy: no-referrer)を適用し、ダウンロードのファイル名もサニタイズします。これらのヘッダーを自分で追加する必要はありません。

どちらのファクトリーも、同じ基盤のコアインターフェイス NextPDF\Core\Document::getPdfData(): string を呼び出します。これは PDF バイナリ全体を構築して返します。対となる save(string $path): void は、同じバイト列をアトミックライターを通じてディスクに書き込みます。このレシピでは、送信先がファイルではなく HTTP ソケットであるため getPdfData() を使用します。

各フレームワークでの、最小限のストリーミングダウンロードアクションです。ドキュメントを扱う呼び出しは同じコアインターフェイスであり、異なるのはコントローラーのスキャフォールディングのみです。ストリーミングファクトリーはフレームワークにコールバックを渡すため、アクションはすぐに返ります。本体は、フレームワークがレスポンスを送信するときに構築され、フラッシュされます。

Laravel: app/Http/Controllers/ReportController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Laravel\Http\PdfResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class ReportController extends Controller
{
public function annualReport(): StreamedResponse
{
$document = app(DocumentFactoryInterface::class)->create();
$document->addPage();
$document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf');
}
}
Symfony: src/Controller/ReportController.php
<?php
declare(strict_types=1);
namespace App\Controller;
use NextPDF\Symfony\Http\PdfResponse;
use NextPDF\Symfony\Service\PdfFactory;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
final class ReportController
{
#[Route('/report', name: 'report_pdf')]
public function annualReport(PdfFactory $pdf): StreamedResponse
{
$document = $pdf->create();
$document->addPage();
$document->cell(0, 10, 'Annual report', newLine: true);
return PdfResponse::streamDownload($document, 'annual-report.pdf');
}
}

ダウンロードを強制する代わりにブラウザーのタブでプレビューするには、streamInline(...)streamDownload(...) の代わりに呼び出します。Content-Dispositioninline になり、その他のヘッダーはすべて同じままです。

本番環境向けのアクションでは、依存関係をインジェクトし、パス入力を検証し、構築時に発生し得る最も具体的な例外をキャッチし、トレースを漏らさずに失敗クラスをログに記録し、定義済みの HTTP エラーを返します。以下の例では、Laravel のコンストラクターインジェクションを使用します。Symfony でも同等のコードは同じ形になり、PdfFactory をアクションごとにインジェクトします。

getPdfData() はストリーミングコールバック内で実行されるため、そこで発生する例外は、フレームワークがヘッダーの送信を開始した に表面化します。エラー処理を有効に保つには、レスポンスを返す にドキュメント(失敗し得るステップ)を構築し、そこで構築の失敗をキャッチします。これにより、コールバック内では構築済みのバイト列のチャンク化された転送のみが行われます。

Laravel: app/Http/Controllers/StatementController.php
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Core\Document;
use NextPDF\Exception\NextPdfException;
use NextPDF\Laravel\Http\PdfResponse;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\StreamedResponse;
final class StatementController extends Controller
{
private const int MAX_STATEMENT_ID = 9_999_999;
public function __construct(
private readonly DocumentFactoryInterface $documents,
private readonly LoggerInterface $logger,
) {}
public function show(int $statementId): StreamedResponse|Response
{
// Validate input at the boundary before any build work runs.
if ($statementId < 1 || $statementId > self::MAX_STATEMENT_ID) {
return new Response('Invalid statement identifier.', 422);
}
try {
// Build the whole document up front. getPdfData(), invoked inside
// the streamed callback, materializes the full PDF in memory, so
// do the failure-prone build here, where the catch can still set a
// clean HTTP status before any byte is sent.
$document = $this->buildStatement($statementId);
$document->getPdfData();
} catch (NextPdfException $exception) {
// Log the exception class, never the message or a stack trace, so
// internal detail does not leak into the log sink.
$this->logger->error('Statement PDF build failed', [
'statement_id' => $statementId,
'exception' => $exception::class,
]);
return new Response('Could not generate the statement PDF.', 500);
}
// The build succeeded. The streamed factory rebuilds the bytes inside
// its callback and flushes them to the client in 64 KB chunks.
return PdfResponse::streamDownload(
$document,
"statement-{$statementId}.pdf",
);
}
private function buildStatement(int $statementId): Document
{
$document = $this->documents->create();
$document->addPage();
$document->cell(0, 10, "Statement #{$statementId}", newLine: true);
return $document;
}
}

構築時のあらゆる失敗に 1 つのハンドラーで対応したい場合は、すべての NextPDF 例外が継承する抽象基底クラス NextPDF\Exception\NextPdfException をキャッチします。特定の原因に分けて対応するには、まず getPdfData() が発生させ得る具象サブタイプをキャッチします。コンテンツがページのジオメトリに収まらない場合は NextPDF\Exception\PageLayoutException、ストリームの圧縮が失敗した場合は NextPDF\Exception\CompressionException、無効な出力設定の場合は NextPDF\Exception\InvalidConfigException です。空の catch ブロックは決して書かないでください。ここでは各分岐が失敗クラスをログに記録し、定義済みのステータスを返します。

アクションごとに新しいドキュメントを解決することで、テストでファクトリーを差し替え可能に保てます。単一の長時間稼働するワーカープロセス内で、無関係な 2 つのドキュメントに 1 つのコントローラーインスタンスを再利用しないでください。古いコンテンツ状態が引き継がれてしまうためです。

  • 「検証してからストリーミングする」パターンでは、ドキュメントが 2 回構築されます。 本番のサンプルは、構築を検証するために getPdfData() を 1 回呼び出し、その後ファクトリーがコールバック内で再度それを呼び出します。これは、失敗ポイントをヘッダーの前に移動することのコストです。特定のドキュメントで 2 回の構築コストが高すぎる場合は、事前構築のプローブを省略し、コールバック内での構築の失敗が、すでに開始されたレスポンスを切り詰めることを受け入れてください。
  • Content-Length はありません。 ストリーミング版はこのヘッダーを省略します。ダウンロードの進捗バーや Range リクエストは機能しません。既知の長さが必要な場合は、バッファリングされた download() / inline() を使用してください。
  • バッファリングするプロキシは利点を打ち消します。 転送前に本体全体をキャプチャするリバースプロキシや PHP 出力バッファは、PDF 全体を再び保持し、節約したコピーを帳消しにします。プロキシが application/pdf レスポンスをストリーミングするように設定するか、その経路ではバッファリングされたレスポンスを使用してください。
  • CodeIgniter 4 はコールバックによるストリーミングではありません。 CodeIgniter 統合は同じ streamInline() / streamDownload() というメソッド名を備えていますが、これらは本体全体を保持する CodeIgniter\HTTP\DownloadResponse を返します。これは、コールバック駆動の StreamedResponse とは異なります。このページの StreamedResponse パターンは、Laravel と Symfony のみに適用されます。
  • リターン後に本体へ書き込まないでください。 ストリーミングコールバックが出力を所有します。自分で echo したりレスポンス本体に書き込んだりすることは、StreamedResponse をフレームワークに返した後では行わないでください。
  • 署名済みドキュメントは早期に失敗します。 高レベルの PAdES 署名用に設定されたドキュメントで getPdfData() を呼び出すと、署名なしのファイルを出力するのではなく NextPDF\Exception\NotImplementedException が発生します。署名済みの出力は、このレシピではなく、ドキュメント化された署名の経路を通じてストリーミングしてください。

ストリーミングで抑えられるのはレスポンスのコピーであり、ドキュメントの構築ではありません。getPdfData() は最初のチャンクが送信される前にドキュメント全体を実体化するため、ピークメモリはおおよそ完成した 1 つの PDF のサイズになります。非常に大きなドキュメントや複数ページのドキュメントの場合、リクエストのバジェットを左右するのは転送ではなく構築そのものです。キューに入れたジョブを使って、生成をリクエストスレッドから切り離しましょう。キューに入れたジョブで PDF を生成する を参照してください。

64 KB のチャンクサイズは、どちらの統合でも固定かつ決定的です。これは転送の粒度のみを制御し、送信される総バイト数やピークメモリを変えるものではありません。制約になっているのがレスポンスのコピーで、進捗バーが不要な場合は、ストリーミング版を選択してください。既知の Content-Length が役立つ、小さくレイテンシーに敏感なレスポンスには、バッファリング版を選択してください。

  • 構築する前に入力を検証してください。 本番のアクションは、構築作業が実行される前に、範囲外の識別子を 422 で拒否します。検証されていない入力を、構築やファイル名に決して埋め込まないでください。
  • ファイル名のサニタイズは自動的に適用されます。 どちらのストリーミングファクトリーもファイル名をサニタイズし、OWASP のレスポンス強化ヘッダーセットを追加します。自分が制御する値を渡し、ファクトリーには第 2 の層としてそれをサニタイズさせてください。ファイル名を手作業でエンコードしないでください。
  • 同時実行時のメモリを制限してください。 PDF 全体がリクエストごとにメモリ上で実体化されるため、大量の同時トラフィックはピークメモリを倍増させます。メモリ枯渇によるサービス拒否を緩和するため、構築に影響する入力にサイズとレート制限を適用してください。
  • メッセージではなく失敗クラスをログに記録してください。 catch ブロックは $exception::class と相関識別子をログに記録し、例外メッセージやスタックトレースは決して記録しません。ログシンクに生のトレースが残ることは、情報漏えいです。
  • 空の catch は禁止です。 このページのすべての catch 分岐は、ログに記録し、定義済みのエラーレスポンスを返します。

このガイドは、規範的な標準への主張を一切行いません。示されているすべてのクラス、メソッド、ヘッダーは、名前を挙げた統合の検証済みのパブリックインターフェイスです。すなわち、NextPDF\Core\Document::getPdfData()NextPDF\Laravel\Http\PdfResponse および NextPDF\Symfony\Http\PdfResponse のストリーミングファクトリー、そして Symfony\Component\HttpFoundation\StreamedResponse 戻り値の型です。ファクトリーが適用する OWASP のレスポンス強化ヘッダーのセマンティクスは、「関連項目」の下にリンクされた各統合のセキュリティと運用のページに、引用とともに記載されています。このクックブックのページでは使い方を改めて述べ、規範的な引用についてはそれらのページに委ねます。