Laravel Magazine
Building a PDF Invoice Generator with Laravel and Browsershot

Building a PDF Invoice Generator with Laravel and Browsershot

Eric Van Johnson ·

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.

Stay Updated

Subscribe to our newsletter

Get latest news, tutorials, community articles and podcast episodes delivered to your inbox.

Weekly articles
We send a new issue of the newsletter every week on Friday.
No spam
We'll never share your email address and you can opt out at any time.