How to Generate a PDF Invoice from HTML in PHP
You're building a Laravel app that generates invoices. You need to convert HTML to PDF. Your options:
- Self-host wkhtmltopdf — manage a headless browser, handle crashes, scale across servers
- Use a PDF API — one HTTP request, PDF response, no infrastructure
Use the API. Your ops team will thank you.
The Basic Pattern: cURL Binary Download
curl -X POST https://api.pagebolt.dev/v1/pdf \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice #001</h1><p>Total: $100</p>",
"format": "A4"
}' \
> invoice.pdf
But in PHP, you want a reusable class.
PHP Implementation: cURL Binary Response
<?php
class PageBoltClient {
private $apiKey;
private $baseUrl = 'https://api.pagebolt.dev/v1';
public function __construct($apiKey) {
$this->apiKey = $apiKey;
}
public function pdf(array $options) {
$url = $options['url'] ?? null;
$html = $options['html'] ?? null;
$outputPath = $options['path'] ?? null;
if (!$url && !$html) {
throw new Exception('Either url or html is required');
}
$payload = [
'url' => $url,
'html' => $html,
'format' => $options['format'] ?? 'A4',
'margin' => $options['margin'] ?? '1cm',
'landscape' => $options['landscape'] ?? false,
'printBackground' => $options['printBackground'] ?? true
];
// Remove null values
$payload = array_filter($payload, fn($v) => $v !== null);
$ch = curl_init($this->baseUrl . '/pdf');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_BINARYTRANSFER => true,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_TIMEOUT => 30
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode !== 200) {
throw new Exception("API error: HTTP $statusCode");
}
// Response is binary PDF — write directly to file
if ($outputPath) {
file_put_contents($outputPath, $response);
return ['success' => true, 'path' => $outputPath];
}
return ['success' => true, 'data' => $response];
}
public function screenshot(array $options) {
$url = $options['url'];
$outputPath = $options['path'] ?? null;
$payload = [
'url' => $url,
'format' => $options['format'] ?? 'png',
'width' => $options['width'] ?? 1280,
'height' => $options['height'] ?? 720,
'fullPage' => $options['fullPage'] ?? false,
'blockBanners' => $options['blockBanners'] ?? true
];
$ch = curl_init($this->baseUrl . '/screenshot');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json'
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_BINARYTRANSFER => true
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($statusCode !== 200) {
throw new Exception("API error: HTTP $statusCode");
}
if ($outputPath) {
file_put_contents($outputPath, $response);
return ['success' => true, 'path' => $outputPath];
}
return ['success' => true, 'data' => $response];
}
}
Key points:
-
CURLOPT_BINARYTRANSFER => truehandles binary data correctly -
file_put_contents()writes binary PDF directly - No JSON decoding — the response is PDF bytes, not JSON
Laravel Integration: Invoice Generation
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class InvoiceController extends Controller {
private $pdfClient;
public function __construct() {
$this->pdfClient = new PageBoltClient(env('PAGEBOLT_API_KEY'));
}
public function generate(Request $request) {
$invoiceId = $request->input('id');
$invoice = Invoice::findOrFail($invoiceId);
// Render invoice HTML
$html = view('invoices.template', ['invoice' => $invoice])->render();
// Convert to PDF
$result = $this->pdfClient->pdf([
'html' => $html,
'format' => 'A4',
'margin' => '1cm'
]);
$pdfPath = storage_path("invoices/invoice-{$invoiceId}.pdf");
file_put_contents($pdfPath, $result['data']);
return response()->download($pdfPath);
}
public function email(Request $request) {
$invoiceId = $request->input('id');
$invoice = Invoice::findOrFail($invoiceId);
// Generate PDF
$html = view('invoices.template', ['invoice' => $invoice])->render();
$result = $this->pdfClient->pdf([
'html' => $html,
'format' => 'A4'
]);
// Send email with PDF attachment
Mail::to($invoice->customer_email)->send(
new InvoiceMail($invoice, $result['data'])
);
return response()->json(['message' => 'Invoice emailed']);
}
}
// routes/web.php
Route::post('/invoices/{id}/generate', [InvoiceController::class, 'generate']);
Route::post('/invoices/{id}/email', [InvoiceController::class, 'email']);
Real Use Case: Batch Invoice Generation
Generate 100s of invoices in the background:
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
class GenerateMonthlyInvoices implements ShouldQueue {
use Queueable;
public function handle() {
$pdfClient = new PageBoltClient(env('PAGEBOLT_API_KEY'));
$invoices = Invoice::whereMonth('created_at', now()->month)->get();
foreach ($invoices as $invoice) {
try {
$html = view('invoices.template', ['invoice' => $invoice])->render();
$result = $pdfClient->pdf([
'html' => $html,
'format' => 'A4',
'margin' => '1.5cm'
]);
$filename = "invoices/invoice-{$invoice->id}-" . now()->format('Y-m') . '.pdf';
Storage::put($filename, $result['data']);
$invoice->update(['pdf_path' => $filename, 'generated_at' => now()]);
Log::info("Invoice {$invoice->id} generated");
} catch (\Exception $e) {
Log::error("Invoice {$invoice->id} failed: " . $e->getMessage());
}
}
}
}
Queue this job monthly:
// app/Console/Kernel.php
protected function schedule(Schedule $schedule) {
$schedule->job(new GenerateMonthlyInvoices())
->monthlyOn(1, '02:00'); // 1st of month at 2am
}
HTML Template: Invoice Design
<!-- resources/views/invoices/template.blade.php -->
<html>
<head>
<style>
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
.header { border-bottom: 2px solid #333; padding-bottom: 20px; }
.company-name { font-size: 24px; font-weight: bold; }
.invoice-details { float: right; text-align: right; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid #ddd; }
th { background-color: #f5f5f5; font-weight: bold; }
.total { font-weight: bold; font-size: 18px; }
.footer { margin-top: 40px; border-top: 1px solid #ddd; padding-top: 20px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="header">
<div class="company-name">{{ config('app.name') }}</div>
<div class="invoice-details">
<div><strong>Invoice #{{ $invoice->number }}</strong></div>
<div>Date: {{ $invoice->date->format('M d, Y') }}</div>
<div>Due: {{ $invoice->due_date->format('M d, Y') }}</div>
</div>
</div>
<div style="margin-top: 40px;">
<div><strong>Bill To:</strong></div>
<div>{{ $invoice->customer_name }}</div>
<div>{{ $invoice->customer_address }}</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th style="text-align: right;">Quantity</th>
<th style="text-align: right;">Unit Price</th>
<th style="text-align: right;">Total</th>
</tr>
</thead>
<tbody>
@foreach($invoice->items as $item)
<tr>
<td>{{ $item->description }}</td>
<td style="text-align: right;">{{ $item->quantity }}</td>
<td style="text-align: right;">${{ number_format($item->unit_price, 2) }}</td>
<td style="text-align: right;">${{ number_format($item->total, 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
<div style="text-align: right; margin-top: 20px;">
<div>Subtotal: ${{ number_format($invoice->subtotal, 2) }}</div>
<div>Tax ({{ $invoice->tax_rate }}%): ${{ number_format($invoice->tax, 2) }}</div>
<div class="total" style="font-size: 18px;">Total: ${{ number_format($invoice->total, 2) }}</div>
</div>
<div class="footer">
<p>Thank you for your business.</p>
<p>Questions? Contact {{ config('app.email') }}</p>
</div>
</body>
</html>
Error Handling Patterns
<?php
class PageBoltClient {
// ... existing code ...
public function pdf(array $options) {
$ch = curl_init($this->baseUrl . '/pdf');
curl_setopt_array($ch, [
// ... options ...
]);
$response = curl_exec($ch);
$statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new Exception("cURL error: $error");
}
if ($statusCode === 429) {
throw new Exception("Rate limited. Wait before retrying.");
}
if ($statusCode !== 200) {
throw new Exception("API error: HTTP $statusCode");
}
return $response;
}
public function pdfWithRetry(array $options, int $maxRetries = 3) {
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
return $this->pdf($options);
} catch (Exception $e) {
if (strpos($e->getMessage(), 'Rate limited') !== false && $attempt < $maxRetries) {
sleep($attempt * 2); // Exponential backoff
continue;
}
throw $e;
}
}
}
}
Testing: Mock the API
<?php
use PHPUnit\Framework\TestCase;
class PageBoltClientTest extends TestCase {
public function testPdfGeneration() {
$mockClient = $this->createMock(PageBoltClient::class);
$mockClient->expects($this->once())
->method('pdf')
->with($this->arrayHasKey('html'))
->willReturn(['success' => true, 'data' => 'fake_pdf_bytes']);
$result = $mockClient->pdf(['html' => '<h1>Test</h1>']);
$this->assertTrue($result['success']);
}
}
Pricing
| Plan | Requests/Month | Cost | Best For |
|---|---|---|---|
| Free | 100 | $0 | Learning, low-volume projects |
| Starter | 5,000 | $29 | Small teams, moderate use |
| Growth | 25,000 | $79 | Production apps, frequent calls |
| Scale | 100,000 | $199 | High-volume automation |
Summary
- ✅ Idiomatic PHP with cURL and binary handling
- ✅ PDF generation from HTML or URL
- ✅ Laravel integration examples
- ✅ Batch invoice generation with queues
- ✅ Error handling and retry logic
- ✅ No wkhtmltopdf, no browser management
- ✅ Works in web requests and background jobs
Get started free: pagebolt.dev — 100 requests/month, no credit card required.
Top comments (0)