NaN/NaN/NaN

PHPでPDFを生成するとき、DomPDF(laravel-dompdf)やwkhtmltopdf(barryvdh/laravel-snappy)を使うのが定番でした。しかし両者にはそれぞれ頭を悩ませる問題があります。DomPDFはCSSの対応範囲が狭く、flexboxやgridが動かない。Snappyはwkhtmltopdfバイナリのインストールが必要で、DockerイメージやServerless環境では扱いが難しい。本番サーバーでChromiumを動かすとなればリソース消費も無視できません。

FUNBREW PDF APIを使えば、LaravelのHttp FacadeでHTMLを送るだけで高品質なPDFが返ってきます。Chrome相当のレンダリングエンジンで、CSSも日本語フォントも完全対応。サーバーにはバイナリ一切不要です。

この記事では、LaravelプロジェクトへのFUNBREW PDF API統合を、サービスクラス設計からBladeテンプレート連携、Queueによる非同期処理、Http::fake()を使ったテストまで、実践的なコード付きで解説します。他のフレームワーク・言語での統合は言語別クイックスタートを、エラーハンドリングの詳細はエラーハンドリングガイドを参照してください。

なぜAPI方式か

DomPDFの限界

DomPDFはPHPネイティブで動作し、依存が少ない点で人気があります。しかし2024年時点でも以下の制限が残っています。

機能 DomPDF FUNBREW PDF API
Flexbox / Grid 非対応 完全対応
最新CSS(カスタムプロパティ等) 部分対応 完全対応
日本語フォント 手動設定が必要 Noto Sans JP 標準搭載
複雑なテーブル レイアウト崩れが多い 忠実に再現
レンダリングエンジン PHP内部実装 Chromium相当

特にflex/gridで組んだデザインをそのままPDFにしたい場合、DomPDFでは大幅な書き直しが必要になります。

Snappy(wkhtmltopdf)の運用コスト

Snappyはwkhtmltopdfバイナリに依存するため、以下の問題が発生しやすいです。

  • Dockerイメージが肥大化: wkhtmltopdfとその依存ライブラリ(libX11等)を含めると100MB以上増加する
  • Alpine Linuxとの相性が悪い: glibc依存のためAlpineでのセットアップが複雑
  • Serverless/FaaS環境で使えない: Lambda・Cloud Runではバイナリ実行ファイルの制約がある
  • メンテナンス終了: wkhtmltopdfプロジェクトは2023年に非推奨(archived)になった

API方式ではアプリケーションサーバーからバイナリを完全に排除できます。Dockerイメージはスリムなまま、Serverless環境でも問題なく動作します。

サーバーリソースの節約

DomPDFやSnappyはPDF生成処理をアプリケーションサーバー上で実行します。複雑なHTMLや大量のPDF生成時はCPU・メモリの消費が顕著になり、他のリクエストに影響が出ることもあります。

API方式ではPDF生成の重い処理は外部サービス側で行われます。LaravelアプリケーションはシンプルなHTTPリクエストを送るだけなので、インスタンスサイズを抑えられ、スケールの予測も立てやすくなります。

Laravelプロジェクトのセットアップ

APIキーの取得

無料アカウントを作成し、ダッシュボードからAPIキーを発行します。

# .envファイルに追加
FUNBREW_PDF_API_KEY="sk-your-api-key"
FUNBREW_PDF_API_URL="https://api.pdf.funbrew.cloud/v1/pdf/from-html"

APIキーの安全な管理方法はセキュリティガイドで詳しく解説しています。

config/services.php に設定を追加

Laravelの慣習に従い、外部サービスの設定はconfig/services.phpにまとめます。

// config/services.php
return [
    // 既存の設定...

    'funbrew_pdf' => [
        'api_key' => env('FUNBREW_PDF_API_KEY'),
        'api_url' => env('FUNBREW_PDF_API_URL', 'https://api.pdf.funbrew.cloud/v1/pdf/from-html'),
        'timeout'  => env('FUNBREW_PDF_TIMEOUT', 60),
    ],
];

追加パッケージは不要

LaravelにはHTTPクライアント(Http Facade)が標準搭載されているため、GuzzleやHTTPクライアントライブラリを別途インストールする必要はありません。Laravel 7以降であればすぐに使えます。

# Laravel 7+ ならすでに利用可能
# php artisan --version

サービスクラスの設計

PDF生成ロジックはControllerから分離し、専用のサービスクラスに集約します。テスタビリティが上がり、将来的なAPIプロバイダの切り替えも容易になります。

app/Services/PdfService.php

<?php

namespace App\Services;

use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;

class PdfService
{
    private string $apiKey;
    private string $apiUrl;
    private int $timeout;

    public function __construct()
    {
        $this->apiKey = config('services.funbrew_pdf.api_key');
        $this->apiUrl = config('services.funbrew_pdf.api_url');
        $this->timeout = (int) config('services.funbrew_pdf.timeout', 60);
    }

    /**
     * HTMLからPDFを生成してバイナリを返す
     *
     * @param  string               $html    PDF化するHTMLコンテンツ
     * @param  array<string, mixed> $options APIオプション(format, engine等)
     * @return string               PDFバイナリ
     *
     * @throws RuntimeException PDF生成に失敗した場合
     */
    public function generateFromHtml(string $html, array $options = []): string
    {
        $payload = array_merge([
            'html'   => $html,
            'format' => 'A4',
            'engine' => 'quality',
        ], $options);

        $response = Http::withToken($this->apiKey)
            ->timeout($this->timeout)
            ->post($this->apiUrl, $payload);

        if ($response->failed()) {
            throw new RuntimeException(
                "PDF generation failed: HTTP {$response->status()} — {$response->body()}"
            );
        }

        return $response->body();
    }
}

サービスコンテナへの登録

シングルトンとして登録することで、リクエストごとにインスタンスが再生成されるのを防ぎます。

// app/Providers/AppServiceProvider.php
use App\Services\PdfService;

public function register(): void
{
    $this->app->singleton(PdfService::class, function () {
        return new PdfService();
    });
}

Bladeテンプレートからのpdf生成

LaravelのBladeテンプレートエンジンと組み合わせることで、既存のビューを活用してPDFを生成できます。

テンプレートの作成

PDF用のBladeテンプレートを作成します。通常のWebページとは異なり、外部フォントはCDN経由で読み込みます。

{{-- resources/views/pdf/invoice.blade.php --}}
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <link
    href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;600;700&display=swap"
    rel="stylesheet"
  >
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Noto Sans JP', sans-serif;
      font-size: 14px;
      color: #1a1a1a;
      padding: 40px;
      line-height: 1.6;
    }
    .header {
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      border-bottom: 3px solid #1a56db;
      padding-bottom: 20px;
      margin-bottom: 32px;
    }
    .header h1 {
      font-size: 28px;
      color: #1a56db;
    }
    .meta { text-align: right; color: #4b5563; font-size: 13px; }
    .meta strong { color: #1a1a1a; font-size: 15px; }
    .bill-to {
      background: #f9fafb;
      border-radius: 8px;
      padding: 16px 20px;
      margin-bottom: 32px;
    }
    .bill-to h2 { font-size: 12px; color: #6b7280; margin-bottom: 8px; letter-spacing: 0.05em; }
    table {
      width: 100%;
      border-collapse: collapse;
      margin-bottom: 24px;
    }
    th {
      background: #1a56db;
      color: #fff;
      padding: 10px 12px;
      text-align: left;
      font-weight: 600;
    }
    td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; }
    tr:last-child td { border-bottom: none; }
    .amount { text-align: right; }
    .total-row {
      display: flex;
      justify-content: flex-end;
      gap: 40px;
      font-size: 16px;
      font-weight: 700;
      border-top: 2px solid #1a1a1a;
      padding-top: 16px;
    }
    .footer { margin-top: 48px; font-size: 12px; color: #9ca3af; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <h1>請求書</h1>
      <p style="color:#6b7280; margin-top:4px;">Invoice</p>
    </div>
    <div class="meta">
      <strong>#{{ $invoice['number'] }}</strong><br>
      発行日: {{ $invoice['issued_at'] }}<br>
      支払期限: {{ $invoice['due_at'] }}
    </div>
  </div>

  <div class="bill-to">
    <h2>BILL TO</h2>
    <p style="font-weight:600">{{ $customer['name'] }}</p>
    <p>{{ $customer['address'] }}</p>
    <p>{{ $customer['email'] }}</p>
  </div>

  <table>
    <thead>
      <tr>
        <th>項目</th>
        <th>数量</th>
        <th>単価</th>
        <th class="amount">金額</th>
      </tr>
    </thead>
    <tbody>
      @foreach ($invoice['items'] as $item)
      <tr>
        <td>{{ $item['name'] }}</td>
        <td>{{ $item['quantity'] }}</td>
        <td>¥{{ number_format($item['unit_price']) }}</td>
        <td class="amount">¥{{ number_format($item['quantity'] * $item['unit_price']) }}</td>
      </tr>
      @endforeach
    </tbody>
  </table>

  <div class="total-row">
    <span>合計(税込)</span>
    <span>¥{{ number_format($invoice['total']) }}</span>
  </div>

  <div class="footer">
    <p>{{ $company['name'] }} — {{ $company['address'] }} — {{ $company['email'] }}</p>
  </div>
</body>
</html>

Controllerの実装

<?php

namespace App\Http\Controllers;

use App\Services\PdfService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use RuntimeException;

class InvoiceController extends Controller
{
    public function __construct(private readonly PdfService $pdfService)
    {
    }

    /**
     * 請求書PDFをダウンロードさせる
     */
    public function download(Request $request, int $invoiceId): Response
    {
        // 実際にはEloquentでデータを取得
        $data = [
            'invoice' => [
                'number'    => "INV-{$invoiceId}",
                'issued_at' => now()->format('Y年m月d日'),
                'due_at'    => now()->addDays(30)->format('Y年m月d日'),
                'items'     => [
                    ['name' => 'FUNBREW PDF Proプラン', 'quantity' => 1, 'unit_price' => 4980],
                    ['name' => '追加API呼び出し 500件', 'quantity' => 2, 'unit_price' => 2000],
                ],
                'total' => 8980,
            ],
            'customer' => [
                'name'    => 'サンプル株式会社',
                'address' => '東京都千代田区丸の内1-1-1',
                'email'   => 'billing@example.co.jp',
            ],
            'company' => [
                'name'    => '自社名',
                'address' => '東京都新宿区西新宿2-2-2',
                'email'   => 'info@mycompany.co.jp',
            ],
        ];

        // Bladeテンプレートをレンダリングしてhtml文字列を取得
        $html = View::make('pdf.invoice', $data)->render();

        try {
            $pdfBytes = $this->pdfService->generateFromHtml($html);
        } catch (RuntimeException $e) {
            abort(502, 'PDF生成に失敗しました。しばらく経ってから再試行してください。');
        }

        return response($pdfBytes, 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => "attachment; filename=\"invoice-{$invoiceId}.pdf\"",
        ]);
    }
}

ルーティング

// routes/web.php
use App\Http\Controllers\InvoiceController;

Route::middleware('auth')->group(function () {
    Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'download'])
        ->name('invoices.pdf');
});

テンプレート設計のコツはテンプレートエンジンガイドで詳しく解説しています。

サービスクラスの拡張

実際のプロダクションでは、PDFの種類によってオプションを変えたいケースが多いです。サービスクラスにショートカットメソッドを追加して使いやすくします。

<?php

namespace App\Services;

use Illuminate\Support\Facades\Http;
use RuntimeException;

class PdfService
{
    private string $apiKey;
    private string $apiUrl;
    private int $timeout;

    public function __construct()
    {
        $this->apiKey = config('services.funbrew_pdf.api_key');
        $this->apiUrl = config('services.funbrew_pdf.api_url');
        $this->timeout = (int) config('services.funbrew_pdf.timeout', 60);
    }

    /**
     * 汎用: HTMLからPDFを生成
     */
    public function generateFromHtml(string $html, array $options = []): string
    {
        $payload = array_merge([
            'html'   => $html,
            'format' => 'A4',
            'engine' => 'quality',
        ], $options);

        $response = Http::withToken($this->apiKey)
            ->timeout($this->timeout)
            ->post($this->apiUrl, $payload);

        if ($response->failed()) {
            throw new RuntimeException(
                "PDF generation failed: HTTP {$response->status()} — {$response->body()}"
            );
        }

        return $response->body();
    }

    /**
     * 請求書PDF(A4縦向き、高品質エンジン)
     */
    public function generateInvoice(string $html): string
    {
        return $this->generateFromHtml($html, [
            'format'      => 'A4',
            'orientation' => 'portrait',
            'engine'      => 'quality',
        ]);
    }

    /**
     * レポートPDF(A4横向き、高速エンジン)
     */
    public function generateReport(string $html): string
    {
        return $this->generateFromHtml($html, [
            'format'      => 'A4',
            'orientation' => 'landscape',
            'engine'      => 'speed',
        ]);
    }

    /**
     * 証明書PDF(Letter横向き)
     */
    public function generateCertificate(string $html): string
    {
        return $this->generateFromHtml($html, [
            'format'      => 'Letter',
            'orientation' => 'landscape',
            'engine'      => 'quality',
        ]);
    }
}

請求書や証明書のユースケース詳細はユースケース一覧も参考にしてください。

Laravel Queueによる非同期PDF生成

PDF生成が完了するまでユーザーをHTTPリクエストで待機させるのは、UX上好ましくありません。大量のPDFを一括生成する場合はなおさらです。Laravel Queueを使ってバックグラウンド処理にしましょう。

Jobクラスの作成

php artisan make:job GeneratePdfJob
<?php

namespace App\Jobs;

use App\Services\PdfService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\View;
use RuntimeException;

class GeneratePdfJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    /**
     * 最大リトライ回数
     */
    public int $tries = 3;

    /**
     * タイムアウト(秒)
     */
    public int $timeout = 90;

    /**
     * リトライ間隔(秒)
     */
    public array $backoff = [10, 30, 60];

    public function __construct(
        private readonly string $template,
        private readonly array  $data,
        private readonly string $storagePath,
        private readonly array  $options = [],
    ) {
    }

    public function handle(PdfService $pdfService): void
    {
        $html = View::make($this->template, $this->data)->render();

        $pdfBytes = $pdfService->generateFromHtml($html, $this->options);

        Storage::put($this->storagePath, $pdfBytes);
    }

    public function failed(\Throwable $e): void
    {
        // 失敗を記録・通知する(Slack, メール等)
        \Log::error('PDF generation job failed', [
            'template'    => $this->template,
            'storagePath' => $this->storagePath,
            'error'       => $e->getMessage(),
        ]);
    }
}

Jobのディスパッチ

<?php

namespace App\Http\Controllers;

use App\Jobs\GeneratePdfJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ReportController extends Controller
{
    public function generateAsync(Request $request, int $reportId): JsonResponse
    {
        $data = [
            'report_id' => $reportId,
            'title'     => '月次レポート',
            'metrics'   => $this->getMetrics($reportId),
        ];

        $storagePath = "reports/report-{$reportId}-" . now()->format('Ymd') . '.pdf';

        GeneratePdfJob::dispatch(
            template:    'pdf.report',
            data:        $data,
            storagePath: $storagePath,
        )->onQueue('pdf');

        return response()->json([
            'status'       => 'processing',
            'storage_path' => $storagePath,
            'message'      => 'PDFの生成を開始しました。完了後にダウンロードできます。',
        ], 202);
    }

    private function getMetrics(int $reportId): array
    {
        // 実際にはDBから取得
        return [
            ['name' => 'PDF生成件数', 'value' => '1,234'],
            ['name' => '平均レスポンス', 'value' => '0.8秒'],
            ['name' => '成功率', 'value' => '99.7%'],
        ];
    }
}

キュー設定(config/queue.php)

本番環境ではRedisキューを推奨します。

// config/queue.php(Redis設定部分)
'redis' => [
    'driver'      => 'redis',
    'connection'  => 'default',
    'queue'       => env('REDIS_QUEUE', 'default'),
    'retry_after' => 120,
    'block_for'   => null,
],
# .env(本番)
QUEUE_CONNECTION=redis

# PDFキュー専用ワーカーを起動
php artisan queue:work redis --queue=pdf --tries=3 --timeout=90

バッチ処理の詳細はPDF一括生成ガイドを参照してください。

生成済みPDFのダウンロード

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;

class ReportController extends Controller
{
    public function download(Request $request, int $reportId): Response
    {
        $storagePath = "reports/report-{$reportId}-" . now()->format('Ymd') . '.pdf';

        if (! Storage::exists($storagePath)) {
            abort(404, 'PDFがまだ生成されていません。しばらく後に再試行してください。');
        }

        return response(Storage::get($storagePath), 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => "attachment; filename=\"report-{$reportId}.pdf\"",
        ]);
    }
}

Http::fake() によるテスト

LaravelのテストではHttp::fake()を使うことで、外部APIを実際に呼び出さずにモックできます。FeatureテストとUnitテストどちらにも適用できます。

サービスクラスのUnit Test

<?php

namespace Tests\Unit\Services;

use App\Services\PdfService;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Tests\TestCase;

class PdfServiceTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();

        config([
            'services.funbrew_pdf.api_key' => 'test-api-key',
            'services.funbrew_pdf.api_url' => 'https://api.pdf.funbrew.cloud/v1/pdf/from-html',
            'services.funbrew_pdf.timeout' => 60,
        ]);
    }

    public function test_generate_from_html_returns_pdf_binary(): void
    {
        $fakePdf = '%PDF-1.4 fake content';

        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response($fakePdf, 200, [
                'Content-Type' => 'application/pdf',
            ]),
        ]);

        $service = new PdfService();
        $result = $service->generateFromHtml('<h1>テスト</h1>');

        $this->assertSame($fakePdf, $result);
    }

    public function test_generate_from_html_sends_correct_payload(): void
    {
        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response('%PDF-1.4', 200),
        ]);

        $service = new PdfService();
        $service->generateFromHtml('<h1>テスト</h1>', ['format' => 'Letter']);

        Http::assertSent(function (Request $request) {
            return $request->url() === 'https://api.pdf.funbrew.cloud/v1/pdf/from-html'
                && $request['html'] === '<h1>テスト</h1>'
                && $request['format'] === 'Letter'
                && $request->hasHeader('Authorization', 'Bearer test-api-key');
        });
    }

    public function test_generate_from_html_throws_on_api_error(): void
    {
        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response('Internal Server Error', 500),
        ]);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessageMatches('/PDF generation failed/');

        $service = new PdfService();
        $service->generateFromHtml('<h1>テスト</h1>');
    }

    public function test_generate_invoice_uses_portrait_orientation(): void
    {
        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response('%PDF-1.4', 200),
        ]);

        $service = new PdfService();
        $service->generateInvoice('<h1>Invoice</h1>');

        Http::assertSent(function (Request $request) {
            return $request['orientation'] === 'portrait'
                && $request['engine'] === 'quality';
        });
    }
}

ControllerのFeature Test

<?php

namespace Tests\Feature;

use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class InvoiceControllerTest extends TestCase
{
    public function test_download_returns_pdf_response(): void
    {
        $fakePdf = '%PDF-1.4 fake invoice content';

        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response($fakePdf, 200, [
                'Content-Type' => 'application/pdf',
            ]),
        ]);

        $this->actingAs($this->createUser())
            ->get('/invoices/1/pdf')
            ->assertStatus(200)
            ->assertHeader('Content-Type', 'application/pdf')
            ->assertHeader('Content-Disposition', 'attachment; filename="invoice-1.pdf"');
    }

    public function test_download_returns_502_on_api_failure(): void
    {
        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response('Service Unavailable', 503),
        ]);

        $this->actingAs($this->createUser())
            ->get('/invoices/1/pdf')
            ->assertStatus(502);
    }

    public function test_download_requires_authentication(): void
    {
        $this->get('/invoices/1/pdf')
            ->assertRedirect('/login');
    }
}

Jobのテスト

<?php

namespace Tests\Unit\Jobs;

use App\Jobs\GeneratePdfJob;
use App\Services\PdfService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Mockery;
use Tests\TestCase;

class GeneratePdfJobTest extends TestCase
{
    public function test_handle_stores_pdf_to_storage(): void
    {
        Storage::fake('local');

        Http::fake([
            'api.pdf.funbrew.cloud/*' => Http::response('%PDF-1.4 fake', 200),
        ]);

        $job = new GeneratePdfJob(
            template:    'pdf.invoice',
            data:        ['invoice' => ['number' => 'INV-001', 'items' => [], 'total' => 0]],
            storagePath: 'reports/test.pdf',
        );

        $job->handle(app(PdfService::class));

        Storage::assertExists('reports/test.pdf');
    }
}

本番運用Tips

リトライとタイムアウト

HTTP APIは一時的な障害で失敗することがあります。LaravelのHttp::retry()を使って自動リトライを実装します。

// app/Services/PdfService.php

public function generateFromHtml(string $html, array $options = []): string
{
    $payload = array_merge([
        'html'   => $html,
        'format' => 'A4',
        'engine' => 'quality',
    ], $options);

    $response = Http::withToken($this->apiKey)
        ->timeout($this->timeout)
        ->retry(
            times: 3,
            sleepMilliseconds: 500,
            when: fn (\Throwable $e) => ! ($e instanceof \Illuminate\Http\Client\ConnectionException),
        )
        ->post($this->apiUrl, $payload);

    if ($response->failed()) {
        throw new RuntimeException(
            "PDF generation failed: HTTP {$response->status()} — {$response->body()}"
        );
    }

    return $response->body();
}

レート制限への対処

APIレート制限(429 Too Many Requests)に対応するため、キュー側でもスロットリングを設定できます。

// app/Providers/AppServiceProvider.php
use Illuminate\Support\Facades\Queue;
use Illuminate\Queue\Events\JobProcessing;

public function boot(): void
{
    // PDFキューの同時実行数を制限
    Queue::looping(function () {
        // ワーカー起動時のカスタム処理(ログ等)
    });
}
# ワーカーの同時実行数を制限(本番環境)
php artisan queue:work redis --queue=pdf --max-jobs=100 --max-time=3600

PDFのキャッシュ

同じデータから繰り返しPDFを生成する場合は、Laravelのキャッシュを活用してAPIコールを削減できます。

use Illuminate\Support\Facades\Cache;

public function getCachedPdf(string $cacheKey, string $html, array $options = []): string
{
    return Cache::remember(
        "pdf:{$cacheKey}",
        now()->addHours(24),
        fn () => $this->generateFromHtml($html, $options)
    );
}

Horizonでキュー監視

本番ではLaravel Horizonを使ってキューを可視化・監視します。

composer require laravel/horizon
php artisan horizon:install
// config/horizon.php(PDF専用スーパーバイザー設定)
'environments' => [
    'production' => [
        'supervisor-pdf' => [
            'maxProcesses'  => 5,
            'queue'         => ['pdf'],
            'balance'       => 'auto',
            'minProcesses'  => 1,
            'maxTime'       => 0,
            'memory'        => 128,
            'tries'         => 3,
            'timeout'       => 90,
        ],
    ],
],

本番環境での運用ノウハウの詳細はプロダクション運用ガイドにまとめています。

ログとモニタリング

PDF生成のパフォーマンスをログに記録しておくと、問題の早期発見に役立ちます。

use Illuminate\Support\Facades\Log;

public function generateFromHtml(string $html, array $options = []): string
{
    $startedAt = microtime(true);

    $response = Http::withToken($this->apiKey)
        ->timeout($this->timeout)
        ->post($this->apiUrl, array_merge([
            'html'   => $html,
            'format' => 'A4',
            'engine' => 'quality',
        ], $options));

    $elapsed = round((microtime(true) - $startedAt) * 1000);

    if ($response->failed()) {
        Log::error('PDF generation failed', [
            'status'  => $response->status(),
            'elapsed' => "{$elapsed}ms",
        ]);

        throw new RuntimeException(
            "PDF generation failed: HTTP {$response->status()} — {$response->body()}"
        );
    }

    Log::info('PDF generated', [
        'size'    => strlen($response->body()),
        'elapsed' => "{$elapsed}ms",
    ]);

    return $response->body();
}

DomPDFとの機能比較

まとめとして、DomPDFとFUNBREW PDF APIの比較表を示します。

観点 DomPDF (laravel-dompdf) Snappy (wkhtmltopdf) FUNBREW PDF API
セットアップ Composer のみ バイナリのインストールが必要 APIキーのみ
CSSサポート Flexbox非対応、CSS2相当 CSS2.1、一部CSS3 Chromium相当、最新CSS完全対応
日本語 手動フォント設定 手動フォント設定 Noto Sans JP 標準搭載
Docker対応 容易 イメージが肥大化しやすい 容易(外部API呼び出しのみ)
Serverless 動作可能 困難 完全対応
サーバー負荷 PHP側で処理 PHP側で処理 外部サービスが処理
メンテナンス 継続中 非推奨(2023年archived) 継続中
コスト 無料 無料 月額課金(従量制あり)

使い分けの目安:

  • DomPDF: 社内向けの簡単なPDF、CSSレイアウトにこだわらない場合
  • Snappy: 既存プロジェクトで使用中だがDockerを使っていない場合(ただし移行を検討)
  • FUNBREW PDF API: 顧客向けの高品質なPDF、Dockerを使っている、Serverless環境、日本語PDF

まとめ

LaravelからFUNBREW PDF APIに接続するのは、Http Facadeを使えば数十行で完了します。本記事でカバーした設計パターンを適用することで、テストしやすく、本番で安心して使えるPDF生成機能を実装できます。

  1. サービスクラスに集約するPdfService にPDF生成ロジックをカプセル化し、Controllerは薄く保つ
  2. Bladeテンプレートを活用するView::make()->render() でHTMLを生成し、既存のビューを再利用する
  3. Queueで非同期化するGeneratePdfJob でバックグラウンド処理し、ユーザーを待たせない
  4. Http::fake() でテストする — 外部API呼び出しをモックして、高速・安定したテストを書く
  5. リトライとログを入れる — 一時的な障害に強くし、パフォーマンスを可視化する

まずはPlaygroundで自分のHTMLがどんなPDFになるか確認してみてください。準備ができたらドキュメントでAPIの全オプションを確認し、料金プランで最適なプランを選びましょう。

関連記事

Powered by FUNBREW PDF