Laravel PDF packages compared
Laravel developers usually reach for a package that turns a Blade view into a PDF. The three popular ones wrap very different engines underneath, and that engine decides how much CSS you can use and what has to be installed on each server or container.
| Option | Engine underneath | What to expect |
|---|---|---|
barryvdh/laravel-dompdf | Dompdf, a layout engine written in PHP | Installs with Composer alone and handles simple documents well; limited support for modern layout such as flexbox and grid |
barryvdh/laravel-snappy | The wkhtmltopdf binary | Better CSS than Dompdf, but the binary must be on every server and its upstream project was archived in 2023 |
spatie/laravel-pdf | Chromium, driven through Browsershot by default | Modern CSS and a pleasant API; the default setup needs Node, Puppeteer and Chrome alongside PHP |
| CastPDF | Chromium with a print engine, run as a service | Only the Http facade in your app; one request per document and a plan above the free allowance |
Dompdf is a sensible starting point for a one page receipt, and many apps never outgrow it. Teams tend to look elsewhere when a designer hands over a flexbox layout that Dompdf cannot place, or when a quote with forty line items splits a row across two pages. Chromium based packages fix the CSS, but then each Forge server, Vapor function or Docker image has to carry a browser. With an API the PHP side stays plain: no extensions, no binaries, nothing to rebuild when the base image changes.
Keep the key in config/services.php
Laravel’s convention for third party credentials is an entry in config/services.php that reads from the environment. Add one for CastPDF, then put a test key (it starts with cpdf_test_) and the ID of your quote template in .env. Reading the values through config() rather than env() matters: once you run php artisan config:cache in production, calls to env() outside config files return null.
'castpdf' => [
'key' => env('CASTPDF_API_KEY'),
'quote_template' => env('CASTPDF_QUOTE_TEMPLATE_ID'),
],A controller that returns the quote as a PDF
The single action controller below uses route model binding to load a Quote, checks the policy, sends its data to the template and returns the PDF. withToken() sets the Authorization: Bearer header, timeout(75) gives the render enough room, and retry() repeats the call only for network failures, 429 and 503. Passing throw: false means that after the last attempt you get the response back and decide what to do with it.
<?php
namespace App\Http\Controllers;
use App\Models\Quote;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Http;
use Throwable;
class QuotePdfController extends Controller
{
public function __invoke(Quote $quote): Response
{
Gate::authorize('view', $quote);
// withToken() sends the Authorization: Bearer header.
$response = Http::withToken(config('services.castpdf.key'))
->withHeaders(['Idempotency-Key' => "quote-{$quote->number}-r{$quote->revision}"])
->timeout(75)
->retry(3, 2000, function (Throwable $e) {
return $e instanceof ConnectionException
|| ($e instanceof RequestException && in_array($e->response->status(), [429, 503], true));
}, throw: false)
->post('https://api.castpdf.com/v1/pdf', [
'template_id' => config('services.castpdf.quote_template'),
'data' => [
'quote' => [
'number' => $quote->number,
'issued' => $quote->created_at->toDateString(),
'valid_until' => $quote->valid_until->toDateString(),
],
'client' => ['name' => $quote->client->name, 'contact' => $quote->client->contact_name],
'lines' => $quote->lines->map(fn ($line) => [
'description' => $line->description,
'days' => $line->days,
'day_rate' => $line->day_rate,
])->all(),
'notes' => $quote->notes,
],
'filename' => "quote-{$quote->number}",
]);
if ($response->failed()) {
report("CastPDF {$response->status()}: {$response->json('error.code')}");
abort(502, 'The quote PDF could not be created.');
}
return response($response->body(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="quote-'.$quote->number.'.pdf"',
]);
}
}Register it with Route::get('/quotes/{quote}/pdf', QuotePdfController::class)->middleware('auth');. The idempotency key includes the revision, so a client who asks for changes gets a fresh document while repeated clicks on the same revision return the first one. If you prefer a forced download, swap the last statement for response()->streamDownload(fn () => print($response->body()), "quote-{$quote->number}.pdf", ['Content-Type' => 'application/pdf']).
Saved templates, Blade views and JSON
The data array above becomes JSON, and Liquid in the saved template turns it into the layout: {% for line in lines %} repeats the rows, {{ line.day_rate | money: "GBP", "en-GB" }} formats the price. You design the quote once in the dashboard with a live preview, and anyone on the sales side can tweak the sample wording in Simple mode. The request the controller sends looks like this:
{
"template_id": "0f6e2d4c-8a1b-4c3d-9e5f-7a8b9c0d1e2f",
"data": {
"quote": { "number": "Q-2026-118", "issued": "2026-10-03", "valid_until": "2026-11-02" },
"client": { "name": "Harbour Lane Dental", "contact": "Dr Ellis Morgan" },
"lines": [
{ "description": "Discovery workshop", "days": 2, "day_rate": 850 },
{ "description": "Booking portal build", "days": 12, "day_rate": 780 },
{ "description": "Staff training session", "days": 1, "day_rate": 600 }
],
"notes": "Prices exclude VAT. Work can start within two weeks of acceptance."
},
"filename": "quote-Q-2026-118"
}Already have Blade views from a Dompdf setup? You can keep them. Render the view yourself with view('pdf.quote', compact('quote'))->render() and send the string as html without a data key, so CastPDF prints it exactly as Blade produced it. Add "mode": "print" to get repeating table headers and page counters. Moving the design into a saved template later is optional, and it lets you change the layout without a deploy.
Render in a queued job
For documents nobody is waiting on, such as a quote attached to a follow up message your app sends tomorrow, render on the queue and store the file. The job below saves the PDF to a filesystem disk and records the path. It gives up at once on errors that a retry cannot fix, and lets the queue retry the rest with a growing delay.
<?php
namespace App\Jobs;
use App\Models\Quote;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
class RenderQuotePdf implements ShouldQueue
{
use Queueable;
public int $tries = 5;
public int $timeout = 120;
public array $backoff = [10, 30, 90];
public function __construct(public Quote $quote)
{
}
public function handle(): void
{
// withToken() sends the Authorization: Bearer header.
$response = Http::withToken(config('services.castpdf.key'))
->withHeaders(['Idempotency-Key' => "quote-{$this->quote->number}-r{$this->quote->revision}"])
->timeout(75)
->post('https://api.castpdf.com/v1/pdf', [
'template_id' => config('services.castpdf.quote_template'),
'data' => $this->quote->toPdfData(),
'filename' => "quote-{$this->quote->number}",
]);
if ($response->clientError() && ! in_array($response->status(), [409, 429], true)) {
$this->fail(new RuntimeException('CastPDF rejected the quote: '.$response->json('error.code')));
return;
}
$response->throw();
$path = "quotes/{$this->quote->number}-r{$this->quote->revision}.pdf";
Storage::disk('s3')->put($path, $response->body());
$this->quote->update(['pdf_path' => $path]);
}
}Dispatch it with RenderQuotePdf::dispatch($quote); after the quote is saved. Because the idempotency key stays the same across attempts, a worker that dies halfway through and runs the job again receives the document that was already made rather than paying for a duplicate. The toPdfData() method is a small helper on your model that returns the same array the controller builds.
Production checklist for Laravel
- Read the key with
config('services.castpdf.key')everywhere. Afterconfig:cache, a strayenv()call in a controller or job silently returns null and every request fails with401. - Keep the job
$timeoutbelow theretry_aftervalue of your queue connection inconfig/queue.php. Otherwise a second worker can pick up a job that is still rendering. - Give HTTP calls a timeout above 60 seconds. A long render can take up to 30 seconds plus queue time on a busy minute, and the Http client gives up after 30 seconds unless you say otherwise.
- Watch the PHP side in web requests too:
max_execution_timeand your PHP-FPM or proxy timeouts must allow for the slowest document, or move those documents to the queue. - Use
Http::fake()in feature tests with a small PDF fixture, so your test suite never calls the API or needs a key. - Stay within 50 pages and 40 MB per document. Large logos uploaded by clients are the usual cause of heavy quotes, so resize them when they are stored.
Troubleshooting Laravel integrations
| Symptom | Likely cause | Fix |
|---|---|---|
invalid_api_key (401) only in production | env() called outside a config file after config:cache | Read the key through config() and clear the cache |
ConnectionException: operation timed out | The client timeout is shorter than the render | Use ->timeout(75) and retry with the same idempotency key |
invalid_request (400) | An unknown key in the array, such as template instead of template_id | Compare the array keys with the API reference |
template_render_error (422) | Blade syntax such as @foreach was pasted into a Liquid template | Use {% for %} in templates, or send rendered Blade as html |
| The browser shows garbled characters | The response was echoed with a text or JSON content type | Return it with Content-Type: application/pdf |
| A job runs twice at the same time | The job timeout is longer than retry_after | Lower $timeout or raise retry_after |
The quickstart walks through keys and starter templates, and the API reference lists every request field and response header. Working in PHP outside Laravel? The PHP page shows the same calls with Guzzle and curl. The Rails page covers the matching patterns in Ruby.