DEV Community

Custodia-Admin
Custodia-Admin

Posted on • Originally published at pagebolt.dev

How to Generate a PDF Invoice from HTML in PHP

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:

  1. Self-host wkhtmltopdf — manage a headless browser, handle crashes, scale across servers
  2. 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
Enter fullscreen mode Exit fullscreen mode

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];
    }
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • CURLOPT_BINARYTRANSFER => true handles 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']);
Enter fullscreen mode Exit fullscreen mode

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());
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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']);
    }
}
Enter fullscreen mode Exit fullscreen mode

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)