Skip to content
CastPDF

Laravel PDF generation with one Http call

For Laravel PDF generation without a browser on your server, call CastPDF from a controller with Http::withToken(), pass your model’s data to a saved template and return the bytes with Content-Type: application/pdf. Long documents go to a queued job. It is the same PDF generation API any stack uses, with modern CSS and real page breaks.

  • Updated October 2026
  • 100 free PDFs a month

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.

Popular ways to make PDFs in a Laravel app
OptionEngine underneathWhat to expect
barryvdh/laravel-dompdfDompdf, a layout engine written in PHPInstalls with Composer alone and handles simple documents well; limited support for modern layout such as flexbox and grid
barryvdh/laravel-snappyThe wkhtmltopdf binaryBetter CSS than Dompdf, but the binary must be on every server and its upstream project was archived in 2023
spatie/laravel-pdfChromium, driven through Browsershot by defaultModern CSS and a pleasant API; the default setup needs Node, Puppeteer and Chrome alongside PHP
CastPDFChromium with a print engine, run as a serviceOnly 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.

config/services.php (excerpt)
'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.

app/Http/Controllers/QuotePdfController.php
<?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:

What the controller posts for a quote
{
  "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.

app/Jobs/RenderQuotePdf.php
<?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. After config:cache, a stray env() call in a controller or job silently returns null and every request fails with 401.
  • Keep the job $timeout below the retry_after value of your queue connection in config/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_time and 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

Errors Laravel apps hit and what fixes them
SymptomLikely causeFix
invalid_api_key (401) only in productionenv() called outside a config file after config:cacheRead the key through config() and clear the cache
ConnectionException: operation timed outThe client timeout is shorter than the renderUse ->timeout(75) and retry with the same idempotency key
invalid_request (400)An unknown key in the array, such as template instead of template_idCompare the array keys with the API reference
template_render_error (422)Blade syntax such as @foreach was pasted into a Liquid templateUse {% for %} in templates, or send rendered Blade as html
The browser shows garbled charactersThe response was echoed with a text or JSON content typeReturn it with Content-Type: application/pdf
A job runs twice at the same timeThe job timeout is longer than retry_afterLower $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.

FAQ

Common questions

Is CastPDF a drop in replacement for laravel-dompdf?

Not quite drop in, because there is no package to swap. You replace the Pdf facade call with one Http request, and you can keep sending your rendered Blade views as HTML. Most apps change a handful of lines.

Can I keep designing PDFs in Blade?

Yes. Render the view to a string in Laravel and send it as html without a data field. CastPDF then prints it as it is, with modern CSS that Dompdf could not lay out.

Does this work on Laravel Vapor or other serverless hosting?

Yes. The app only makes an outgoing HTTPS request, so there is no browser binary to package into the function. Raise the function timeout so long documents have time to finish.

How do I test PDF code without calling the API?

Use Http::fake to return a fixed response for the CastPDF URL, for example a small PDF file from your test fixtures. Then assert the request with Http::assertSent. Your CI needs no key at all.

Should quotes render in the controller or in a queued job?

Render in the controller when a user clicks and waits for the file. Use a queued job when the document is stored or attached to a message later. Both send the same request.

Do I need a Laravel package or service provider for CastPDF?

No. The Http facade already handles tokens, timeouts and retries, which is all the API needs. Wrapping it in a small service class of your own is usually enough.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.