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.
- DOMPDF is pure PHP and supports most of CSS 2.1. Your Tailwind classes do approximately nothing.
- Browsershot drives real Chrome, so the CSS is real — but your Laravel app now needs Node, Puppeteer and Chromium in every environment, including CI and every developer's laptop.
- Gotenberg is a Docker service you run, which moves the problem to your infrastructure rather than removing it.
- WeasyPrint needs Pango and cairo, and has no grid support.
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.