Renderpaper
Log in Start free

Laravel HTML to PDF

Laravel's PDF landscape is a choice between four drivers and their four problems. spatie/laravel-pdf makes this explicit — it offers Browsershot, Gotenberg, WeasyPrint and DOMPDF behind one interface, which is a fair description of the situation: no single one of them is right.

This is Http::post and a Blade view.

Render a Blade view

view()->render() gives you the HTML; the API turns it into a PDF.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;

class InvoiceController extends Controller
{
    public function download(Request $request, string $id)
    {
        $html = view('invoices.show', [
            'invoice' => Invoice::findOrFail($id),
        ])->render();

        $response = Http::withHeaders([
                'X-API-Key' => config('services.renderpaper.key'),
            ])
            ->timeout(60)
            ->post('https://renderpaper.com/v1/render', [
                'template' => $html,
            ]);

        if ($response->failed()) {
            abort(502, 'Could not render the document');
        }

        return response($response->body(), 200, [
            'Content-Type'        => 'application/pdf',
            'Content-Disposition' => 'attachment; filename="invoice-' . $id . '.pdf"',
        ]);
    }
}

Add the key to config/services.php:

'renderpaper' => [
    'key' => env('RENDERPAPER_API_KEY'),
],

Because the whole document is rendered by Chromium, Tailwind works — including grid, flex and arbitrary values. Build your CSS as usual and inline it into the Blade layout, or link it as an absolute URL.

The repeating-header problem

This is the specific wall Laravel developers hit, and it is worth stating plainly because the usual fix does not exist here.

Most PDF tools offer a headerTemplate option. Chromium renders that template in an isolated context that cannot see your page's stylesheet, so Vite-injected Tailwind never arrives; inlining the built CSS into the header instead blows past Chromium's size limit for it. Both routes are closed. Gotenberg's maintainer has closed this as unfixable.

Renderpaper has no header or footer options at all — so rather than pretend, the answer is a technique that works in any Chromium renderer, including this one:

<table class="w-full">
  <thead>
    <tr><th colspan="3" class="pb-4">
      <div class="flex justify-between items-center">
        <span class="text-xl font-bold">Invoice {{ $invoice->number }}</span>
        <span class="text-sm text-gray-500">{{ $invoice->issued_at->format('Y-m-d') }}</span>
      </div>
    </th></tr>
  </thead>
  <tbody>
    @foreach ($invoice->lines as $line)
      <tr class="border-b">
        <td class="py-2">{{ $line->description }}</td>
        <td class="py-2 text-right">{{ $line->quantity }}</td>
        <td class="py-2 text-right">{{ number_format($line->amount, 2) }}</td>
      </tr>
    @endforeach
  </tbody>
</table>

with:

thead { display: table-header-group; }
tfoot { display: table-footer-group; }
tr    { break-inside: avoid; }

display: table-header-group makes the browser repeat that block at the top of every page it paginates, in normal flow — so the repeated header pushes content down rather than painting over it, and your own stylesheet applies because it is part of the document.

position: fixed is the obvious first guess and it does not work: the header disappears after page one and a bottom-anchored footer lands at the top of page two, over the rows. The full write-up, with measured coordinates from both routes, is in the repeating-header recipe.

Stored templates: no deploy for a design change

Rendering a Blade view keeps the document in your repository, which means a wider margin is a pull request. Storing the template moves it out, and your Laravel app sends data only.

<?php

use Illuminate\Support\Facades\Http;

$response = Http::withHeaders([
        'X-API-Key' => config('services.renderpaper.key'),
    ])
    ->timeout(60)
    ->post('https://renderpaper.com/v1/templates/your-template-id/render', [
        'data' => [
            'InvoiceNumber' => '2026-0142',
            'Customer'      => ['Name' => 'Northwind Trading AB'],
            'LineItems'     => [
                ['Description' => 'Consulting', 'Quantity' => 12, 'Amount' => 14400],
                ['Description' => 'Hosting',    'Quantity' => 1,  'Amount' => 4350],
            ],
            'Total' => 18750,
        ],
    ]);

abort_if($response->failed(), 502, 'Could not render the document');

file_put_contents(storage_path('app/invoice.pdf'), $response->body());

The stored template is a Go html/template rather than Blade — {{.Customer.Name}} instead of {{ $customer->name }} — which is a small syntax change in exchange for the design living outside your deploy cycle.

Queue it for anything slow

A render takes a second or two, which is too long to hold a web request for a batch. The usual Laravel shape applies:

<?php

namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;

class RenderInvoice implements ShouldQueue
{
    use Queueable;

    public function __construct(public string $invoiceId) {}

    public function handle(): void
    {
        $invoice = Invoice::findOrFail($this->invoiceId);

        $response = Http::withHeaders([
                'X-API-Key' => config('services.renderpaper.key'),
            ])
            ->timeout(120)
            ->post('https://renderpaper.com/v1/render', [
                'template' => view('invoices.show', ['invoice' => $invoice])->render(),
            ]);

        $response->throw();

        Storage::put("invoices/{$this->invoiceId}.pdf", $response->body());
    }
}

Try it without a key

https://renderpaper.com/sample.pdf is a real render, no account. The free tier is 50 documents a month with no card — enough to put a real integration in front of whoever signs off the design.

Last updated 2026-08-08.