When generating PDFs in PHP, DomPDF (laravel-dompdf) and wkhtmltopdf (barryvdh/laravel-snappy) have long been the go-to options. But each comes with frustrating limitations. DomPDF has narrow CSS support — flexbox and grid simply don't work. Snappy requires installing the wkhtmltopdf binary, making it awkward in Docker images and impossible in serverless environments. And running Chromium on your own servers carries a non-trivial resource cost.
FUNBREW PDF API lets you send HTML with Laravel's Http Facade and get back a high-quality PDF. Chromium-equivalent rendering, full CSS support, Japanese fonts included — and zero binaries on your server.
This guide walks through integrating FUNBREW PDF API into a Laravel project: service class design, Blade template rendering, Queue-based async processing, and testing with Http::fake(). For other languages and frameworks, see the language quickstart guide. For error handling patterns, check the error handling guide.
Why Use an API Instead of a Library?
DomPDF's Limitations
DomPDF runs natively in PHP with minimal dependencies, which is why it's popular. But as of 2024, it still has these constraints:
| Feature | DomPDF | FUNBREW PDF API |
|---|---|---|
| Flexbox / Grid | Not supported | Fully supported |
| Modern CSS (custom properties, etc.) | Partial | Fully supported |
| Japanese fonts | Manual setup required | Noto Sans JP built-in |
| Complex tables | Layout issues common | Faithfully rendered |
| Rendering engine | PHP internal implementation | Chromium-equivalent |
If your design uses flex or grid layouts, DomPDF will require a significant rewrite of your templates.
Snappy's (wkhtmltopdf) Operational Cost
Snappy depends on the wkhtmltopdf binary, which introduces several problems:
- Bloated Docker images: Adding wkhtmltopdf and its dependencies (libX11, etc.) adds 100+ MB to your image
- Alpine Linux incompatibility: wkhtmltopdf requires glibc, making Alpine setups complex
- Unusable in serverless environments: Lambda, Cloud Run, and similar platforms restrict binary executables
- Project is abandoned: wkhtmltopdf was archived in 2023 and is no longer maintained
With an API approach, you eliminate binaries from your application server entirely. Your Docker image stays lean, and serverless environments work without any special configuration.
Lower Server Resource Usage
DomPDF and Snappy both execute PDF generation on your application server. For complex HTML or high-volume generation, CPU and memory consumption becomes noticeable and can affect other requests.
With an API approach, the heavy lifting happens on the external service. Your Laravel application only sends an HTTP request, letting you keep instance sizes small and making scaling more predictable.
Setting Up Your Laravel Project
Get Your API Key
Create a free account and generate an API key from the dashboard.
# Add to your .env file
FUNBREW_PDF_API_KEY="sk-your-api-key"
FUNBREW_PDF_API_URL="https://api.pdf.funbrew.cloud/v1/pdf/from-html"
For secure key management best practices, see the security guide.
Add Configuration to config/services.php
Following Laravel conventions, external service settings belong in config/services.php.
// config/services.php
return [
// existing config...
'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),
],
];
No Additional Packages Required
Laravel ships with an HTTP client (Http Facade) built on Guzzle, so there's nothing extra to install. Laravel 7+ is all you need.
# Already available in Laravel 7+
# php artisan --version
Service Class Design
Keep PDF generation logic out of your controllers by centralizing it in a dedicated service class. This improves testability and makes it easy to swap API providers in the future.
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);
}
/**
* Generate a PDF from an HTML string and return the binary content.
*
* @param string $html HTML content to convert
* @param array<string, mixed> $options API options (format, engine, etc.)
* @return string PDF binary
*
* @throws RuntimeException When PDF generation fails
*/
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();
}
}
Register with the Service Container
Register as a singleton to avoid re-instantiation on every request.
// app/Providers/AppServiceProvider.php
use App\Services\PdfService;
public function register(): void
{
$this->app->singleton(PdfService::class, function () {
return new PdfService();
});
}
PDF Generation from Blade Templates
Laravel's Blade template engine integrates naturally with the PDF service. Render an existing Blade view to an HTML string, then pass it to the API.
Create the Blade Template
Create a dedicated Blade template for PDF output. Unlike web pages, load external fonts via CDN.
{{-- resources/views/pdf/invoice.blade.php --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap"
rel="stylesheet"
>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', 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;
text-transform: uppercase;
}
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>Invoice</h1>
</div>
<div class="meta">
<strong>#{{ $invoice['number'] }}</strong><br>
Issued: {{ $invoice['issued_at'] }}<br>
Due: {{ $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>Description</th>
<th>Qty</th>
<th>Unit Price</th>
<th class="amount">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'], 2) }}</td>
<td class="amount">${{ number_format($item['quantity'] * $item['unit_price'], 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
<div class="total-row">
<span>Total</span>
<span>${{ number_format($invoice['total'], 2) }}</span>
</div>
<div class="footer">
<p>{{ $company['name'] }} — {{ $company['address'] }} — {{ $company['email'] }}</p>
</div>
</body>
</html>
Controller Implementation
<?php
namespace App\Http\Controllers;
use App\Services\PdfService;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\View;
use RuntimeException;
class InvoiceController extends Controller
{
public function __construct(private readonly PdfService $pdfService)
{
}
/**
* Download invoice as a PDF
*/
public function download(int $invoiceId): Response
{
// In practice, fetch this from your database
$data = [
'invoice' => [
'number' => "INV-{$invoiceId}",
'issued_at' => now()->format('F j, Y'),
'due_at' => now()->addDays(30)->format('F j, Y'),
'items' => [
['name' => 'FUNBREW PDF Pro Plan', 'quantity' => 1, 'unit_price' => 49.00],
['name' => 'Additional API Calls (500)', 'quantity' => 2, 'unit_price' => 20.00],
],
'total' => 89.00,
],
'customer' => [
'name' => 'Acme Corp',
'address' => '123 Main St, San Francisco, CA 94105',
'email' => 'billing@acme.com',
],
'company' => [
'name' => 'Your Company',
'address' => '456 Market St, San Francisco, CA 94105',
'email' => 'hello@yourcompany.com',
],
];
// Render the Blade template to an HTML string
$html = View::make('pdf.invoice', $data)->render();
try {
$pdfBytes = $this->pdfService->generateFromHtml($html);
} catch (RuntimeException $e) {
abort(502, 'PDF generation failed. Please try again shortly.');
}
return response($pdfBytes, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => "attachment; filename=\"invoice-{$invoiceId}.pdf\"",
]);
}
}
Routes
// routes/web.php
use App\Http\Controllers\InvoiceController;
Route::middleware('auth')->group(function () {
Route::get('/invoices/{invoice}/pdf', [InvoiceController::class, 'download'])
->name('invoices.pdf');
});
For tips on template design, see the template engine guide.
Extending the Service Class
In production, different PDF types often need different settings. Add shortcut methods to the service class to keep callsites clean.
<?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);
}
/**
* Generic: generate PDF from HTML
*/
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();
}
/**
* Invoice PDF (A4 portrait, quality engine)
*/
public function generateInvoice(string $html): string
{
return $this->generateFromHtml($html, [
'format' => 'A4',
'orientation' => 'portrait',
'engine' => 'quality',
]);
}
/**
* Report PDF (A4 landscape, speed engine)
*/
public function generateReport(string $html): string
{
return $this->generateFromHtml($html, [
'format' => 'A4',
'orientation' => 'landscape',
'engine' => 'speed',
]);
}
/**
* Certificate PDF (Letter landscape)
*/
public function generateCertificate(string $html): string
{
return $this->generateFromHtml($html, [
'format' => 'Letter',
'orientation' => 'landscape',
'engine' => 'quality',
]);
}
}
For a broader look at use cases like invoices and certificates, see the use cases page.
Async PDF Generation with Laravel Queue
Making users wait for HTTP responses during PDF generation hurts UX, especially at scale. Use Laravel Queue to push generation to the background.
Create the Job Class
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;
/**
* Maximum number of retries
*/
public int $tries = 3;
/**
* Timeout in seconds
*/
public int $timeout = 90;
/**
* Retry backoff in seconds
*/
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
{
\Log::error('PDF generation job failed', [
'template' => $this->template,
'storagePath' => $this->storagePath,
'error' => $e->getMessage(),
]);
}
}
Dispatching the Job
<?php
namespace App\Http\Controllers;
use App\Jobs\GeneratePdfJob;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
public function generateAsync(int $reportId): JsonResponse
{
$data = [
'report_id' => $reportId,
'title' => 'Monthly Report',
'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 generation started. Check back shortly to download.',
], 202);
}
private function getMetrics(int $reportId): array
{
return [
['name' => 'PDFs Generated', 'value' => '1,234'],
['name' => 'Avg Response Time', 'value' => '0.8s'],
['name' => 'Success Rate', 'value' => '99.7%'],
];
}
}
Queue Configuration
Redis is recommended for production.
// config/queue.php (Redis section)
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 120,
'block_for' => null,
],
# .env (production)
QUEUE_CONNECTION=redis
# Start a dedicated worker for the PDF queue
php artisan queue:work redis --queue=pdf --tries=3 --timeout=90
For high-volume generation patterns, see the batch processing guide.
Downloading the Generated PDF
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Storage;
class ReportController extends Controller
{
public function download(int $reportId): Response
{
$storagePath = "reports/report-{$reportId}-" . now()->format('Ymd') . '.pdf';
if (! Storage::exists($storagePath)) {
abort(404, 'PDF not ready yet. Please try again shortly.');
}
return response(Storage::get($storagePath), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => "attachment; filename=\"report-{$reportId}.pdf\"",
]);
}
}
Testing with Http::fake()
Laravel's Http::fake() lets you mock external API calls so tests run fast and never hit the real API.
Unit Test for the Service Class
<?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>Test</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>Test</h1>', ['format' => 'Letter']);
Http::assertSent(function (Request $request) {
return $request->url() === 'https://api.pdf.funbrew.cloud/v1/pdf/from-html'
&& $request['html'] === '<h1>Test</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>Test</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';
});
}
}
Feature Test for the Controller
<?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 Test
<?php
namespace Tests\Unit\Jobs;
use App\Jobs\GeneratePdfJob;
use App\Services\PdfService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
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');
}
}
Production Tips
Retries and Timeouts
HTTP APIs can fail transiently. Use Laravel's Http::retry() to handle temporary failures automatically.
// 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();
}
PDF Caching
If you generate the same PDF repeatedly from unchanged data, cache the result to reduce API calls.
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)
);
}
Monitoring with Laravel Horizon
In production, use Laravel Horizon to visualize and monitor your PDF queue.
composer require laravel/horizon
php artisan horizon:install
// config/horizon.php (dedicated PDF supervisor)
'environments' => [
'production' => [
'supervisor-pdf' => [
'maxProcesses' => 5,
'queue' => ['pdf'],
'balance' => 'auto',
'minProcesses' => 1,
'memory' => 128,
'tries' => 3,
'timeout' => 90,
],
],
],
Logging and Observability
Log PDF generation performance to catch slowdowns early.
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();
}
For comprehensive production guidance, see the production operations guide.
DomPDF vs Snappy vs FUNBREW PDF API
| Aspect | DomPDF (laravel-dompdf) | Snappy (wkhtmltopdf) | FUNBREW PDF API |
|---|---|---|---|
| Setup | Composer only | Binary install required | API key only |
| CSS Support | No flexbox/grid, CSS2-level | CSS2.1, partial CSS3 | Full modern CSS, Chromium-equivalent |
| Japanese/CJK fonts | Manual config required | Manual config required | Noto Sans JP built-in |
| Docker | Easy | Image bloat | Easy (external HTTP call only) |
| Serverless | Works | Difficult | Fully supported |
| Server load | Runs on your server | Runs on your server | Offloaded to external service |
| Maintenance | Active | Abandoned (2023) | Active |
| Cost | Free | Free | Monthly subscription (pay-as-you-go available) |
When to choose what:
- DomPDF: Simple internal PDFs where CSS layout doesn't matter much
- Snappy: Existing projects already using it, non-Docker environments (but consider migrating)
- FUNBREW PDF API: Customer-facing high-quality PDFs, Docker environments, serverless, or CJK content
Summary
Connecting Laravel to FUNBREW PDF API takes only a few dozen lines using the Http Facade. Apply the design patterns in this guide to build PDF generation that's easy to test and reliable in production.
- Centralize in a service class — Encapsulate PDF logic in
PdfService, keep controllers thin - Leverage Blade templates — Use
View::make()->render()to generate HTML and reuse existing views - Go async with Queue — Dispatch
GeneratePdfJobto keep users from waiting on HTTP responses - Test with
Http::fake()— Mock external API calls for fast, stable tests - Add retries and logging — Handle transient failures gracefully and make performance visible
Try the Playground to see what your HTML looks like as a PDF. When you're ready to integrate, the documentation covers all available API options. Check the pricing page for the right plan.
Related Guides
- HTML to PDF: Complete Guide — Full landscape of conversion approaches
- PDF API Error Handling — Retry strategies and error responses
- Certificate PDF Automation — Auto-issue completion certificates
- PDF Webhook Integration — Async PDF generation patterns
- HTML to PDF CSS Optimization — Print CSS best practices
- WordPress PDF Integration — PDF generation in WordPress