Building a PDF Invoice Generator with Laravel and Browsershot
Most PHP PDF libraries make you write your layout in a restricted dialect of HTML that doesn't support modern CSS: no flexbox, patchy support for web fonts, and surprises with page breaks. Browsershot sidesteps all of that by rendering your Blade view in headless Chrome and printing the result to PDF — meaning if it looks right in a browser, it looks right in the PDF. This tutorial builds an invoice generator for PhpArchitect's billing system.
Step 1: Install Browsershot
composer require spatie/browsershot
Browsershot shells out to Puppeteer, which needs Node and Chrome available on the server:
npm install puppeteer
On a fresh Ubuntu server you'll also need Chrome's runtime dependencies; Spatie's documentation has a copy-paste apt-get list for exactly this.
Step 2: Build the invoice view
{{-- resources/views/invoices/pdf.blade.php --}}
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: 'Helvetica Neue', sans-serif; color: #1f2937; }
.header { display: flex; justify-content: space-between; margin-bottom: 40px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #e5e7eb; }
.total-row td { font-weight: bold; border-top: 2px solid #1f2937; }
</style>
</head>
<body>
<div class="header">
<div>
<h1>PHP Architect</h1>
<p>{{ $invoice->company_address }}</p>
</div>
<div>
<h2>Invoice #{{ $invoice->number }}</h2>
<p>Due {{ $invoice->due_at->format('M j, Y') }}</p>
</div>
</div>
<table>
<thead>
<tr><th>Description</th><th>Qty</th><th>Price</th><th>Subtotal</th></tr>
</thead>
<tbody>
@foreach ($invoice->lineItems as $item)
<tr>
<td>{{ $item->description }}</td>
<td>{{ $item->quantity }}</td>
<td>${{ number_format($item->price, 2) }}</td>
<td>${{ number_format($item->quantity * $item->price, 2) }}</td>
</tr>
@endforeach
<tr class="total-row">
<td colspan="3">Total</td>
<td>${{ number_format($invoice->total, 2) }}</td>
</tr>
</tbody>
</table>
</body>
</html>
Step 3: Render it to PDF
namespace App\Actions;
use App\Models\Invoice;
use Spatie\Browsershot\Browsershot;
class GenerateInvoicePdf
{
public function handle(Invoice $invoice): string
{
$html = view('invoices.pdf', ['invoice' => $invoice])->render();
$path = storage_path("app/invoices/invoice-{$invoice->number}.pdf");
Browsershot::html($html)
->format('A4')
->margins(20, 20, 20, 20)
->showBackground()
->save($path);
return $path;
}
}
showBackground() matters here — Chrome strips background colors and images from printed output by default, which silently breaks any styled header or table.
Step 4: Stream it as a download
namespace App\Http\Controllers;
use App\Actions\GenerateInvoicePdf;
use App\Models\Invoice;
class InvoiceDownloadController extends Controller
{
public function __invoke(Invoice $invoice, GenerateInvoicePdf $generator)
{
$path = $generator->handle($invoice);
return response()->download($path, "invoice-{$invoice->number}.pdf")
->deleteFileAfterSend();
}
}
deleteFileAfterSend() cleans up the temporary file once the response finishes streaming, so you're not accumulating generated PDFs on disk for invoices nobody re-downloads.
Step 5: Queue it for bulk generation
Rendering Chrome per invoice is not free — expect somewhere in the range of a few hundred milliseconds to a couple of seconds depending on page complexity. For a "generate PDFs for all of this month's invoices" admin action, queue each one instead of blocking a request:
namespace App\Jobs;
use App\Actions\GenerateInvoicePdf;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class GenerateInvoicePdfJob implements ShouldQueue
{
use Queueable;
public function __construct(private readonly Invoice $invoice) {}
public function handle(GenerateInvoicePdf $generator): void
{
$path = $generator->handle($this->invoice);
$this->invoice->update(['pdf_path' => $path]);
}
}
A note on server resources
Browsershot spins up a real Chrome process per render, which is heavier than a pure-PHP PDF library. On a memory-constrained server, cap the number of concurrent queue workers processing this job, or run it on a dedicated queue with --queue=pdf-generation and a low --max-jobs/worker count so a burst of invoice generation doesn't starve the rest of your application's queues.