Generate PDFs in Laravel with Api2Pdf: Blade Templates, Queued Jobs and Watermarks

Summary. Every Laravel app eventually grows a "Download PDF" button: invoices, quotes, statements, contracts. The usual packages each come with a catch. dompdf does not understand flexbox or grid, Snappy wraps wkhtmltopdf (archived since 2023), and Browsershot-style packages need Node and Chromium installed on every web and queue server. This guide keeps the browser off your servers. You render a Blade view to HTML, send it to the Api2Pdf Headless Chrome endpoint with Laravel's own Http client, and stream the PDF back from a controller or store it from a queued job. Then you stamp DRAFT and CONFIDENTIAL watermarks on generated and uploaded PDFs with the /pdfsharp/watermark endpoint, and finish with Http::fake() tests and production notes.

Using another stack? There are companion posts for FastAPI and Next.js App Router.

The architecture in one paragraph

The API key lives in .env and config/services.php, never in the browser. A controller loads the model, renders a Blade view with view(...)->render(), and calls POST https://v2.api2pdf.com/chrome/pdf/html with the raw API key in the Authorization header (no Bearer prefix). With ?outputBinary=true the response body is the PDF itself, which the controller returns with Content-Type: application/pdf. Without it you get JSON containing a FileUrl that Api2Pdf hosts for 24 hours, which is what you feed into the watermark endpoint, a mail job or your own storage. Chrome never runs inside PHP-FPM, Octane or Horizon workers.

Step 1: configure the key

Create an account and API key at portal.api2pdf.com. There is nothing to install with Composer for this guide: Laravel's Http client (Guzzle underneath) is all you need. The code below targets Laravel 11 and later on PHP 8.2+. Add the key to .env:

API2PDF_API_KEY=your-api-key
# optional: https://v2-xl.api2pdf.com for very large documents
API2PDF_BASE_URL=https://v2.api2pdf.com
API2PDF_TIMEOUT=60

Then expose it through config/services.php so it survives php artisan config:cache (calling env() outside config files returns null once the config is cached):

// config/services.php
return [
    // ...existing services

    'api2pdf' => [
        'key' => env('API2PDF_API_KEY'),
        'base_url' => env('API2PDF_BASE_URL', 'https://v2.api2pdf.com'),
        'timeout' => (int) env('API2PDF_TIMEOUT', 60),
    ],
];

Step 2: a small Api2Pdf service class

There is an official api2pdf.php Composer package, but it has not been updated since 2022 and does not cover newer endpoints such as watermarking, Markdown or zip. Calling the REST API directly is only a few lines with the Http facade, and it gives you explicit control over timeouts and retries. The request body takes html (or url), plus the optional fileName, inline and options (the Headless Chrome options such as margins, page size, headers and footers). outputBinary is a query string parameter.

<?php
// app/Services/Api2Pdf/Api2PdfException.php
namespace App\Services\Api2Pdf;

use RuntimeException;

class Api2PdfException extends RuntimeException
{
    public function __construct(
        string $message,
        public readonly int $status = 0,
        public readonly ?string $responseId = null,
    ) {
        parent::__construct($message);
    }

    /** Laravel adds this to the log entry when the exception is reported. */
    public function context(): array
    {
        return ['api2pdf_status' => $this->status, 'api2pdf_response_id' => $this->responseId];
    }
}
<?php
// app/Services/Api2Pdf/Api2Pdf.php
namespace App\Services\Api2Pdf;

use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Throwable;

final class Api2Pdf
{
    public function __construct(
        private readonly string $apiKey,
        private readonly string $baseUrl = 'https://v2.api2pdf.com',
        private readonly int $timeout = 60,
    ) {}

    /** Render HTML with Headless Chrome and return the PDF bytes. */
    public function htmlToPdfBytes(string $html, array $options = [], ?string $fileName = null): string
    {
        return $this->send('/chrome/pdf/html', $this->body(['html' => $html], $options, $fileName), binary: true)->body();
    }

    /** Render HTML with Headless Chrome and return the JSON result (FileUrl, ResponseId, Cost, ...). */
    public function htmlToPdf(string $html, array $options = [], ?string $fileName = null): array
    {
        return $this->send('/chrome/pdf/html', $this->body(['html' => $html], $options, $fileName), binary: false)->json();
    }

    /**
     * Stamp a text watermark on every page of the PDF at $pdfUrl.
     * $style keys: fontSize (int, default 72), color (hex, default #000000),
     * opacity (0.0-1.0, default 0.15), rotation (degrees, default -45).
     */
    public function watermarkBytes(string $pdfUrl, string $text = 'DRAFT', array $style = [], ?string $fileName = null): string
    {
        $body = $this->body(['url' => $pdfUrl, 'text' => $text] + $style, [], $fileName);

        return $this->send('/pdfsharp/watermark', $body, binary: true)->body();
    }

    /** Delete a hosted file right away instead of waiting for the 24 hour expiry. */
    public function delete(string $responseId): void
    {
        $this->client()->delete('/file/'.urlencode($responseId));
    }

    private function body(array $source, array $options, ?string $fileName): array
    {
        $body = $source + ['inline' => true];
        if ($options !== []) {
            $body['options'] = $options;
        }
        if ($fileName !== null) {
            $body['fileName'] = $fileName;
        }

        return $body;
    }

    private function client(): PendingRequest
    {
        return Http::baseUrl($this->baseUrl)
            ->withHeaders(['Authorization' => $this->apiKey]) // raw key, no "Bearer"
            ->asJson()
            ->connectTimeout(10)
            ->timeout($this->timeout)
            // Two retries with backoff, only for network errors, 429 and 5xx.
            ->retry([500, 1500], when: function (Throwable $e): bool {
                if ($e instanceof ConnectionException) {
                    return true;
                }

                return $e instanceof RequestException
                    && ($e->response->status() === 429 || $e->response->serverError());
            }, throw: false);
    }

    private function send(string $path, array $body, bool $binary): Response
    {
        try {
            $response = $this->client()->post($path.'?outputBinary='.($binary ? 'true' : 'false'), $body);
        } catch (ConnectionException $e) {
            throw new Api2PdfException('Could not reach Api2Pdf: '.$e->getMessage());
        }

        $isJson = str_contains($response->header('Content-Type'), 'json');

        // Binary mode: a successful response body is the PDF itself.
        if ($binary && $response->successful() && ! $isJson) {
            return $response;
        }

        $data = $isJson ? (array) $response->json() : [];
        if ($response->successful() && ($data['Success'] ?? false)) {
            return $response;
        }

        throw new Api2PdfException(
            $data['Error'] ?? "Api2Pdf returned HTTP {$response->status()}",
            $response->status(),
            $data['ResponseId'] ?? null,
        );
    }
}

Register it as a singleton so it fails loudly when the key is missing, and so you can type-hint it in controllers, jobs and commands:

<?php
// app/Providers/AppServiceProvider.php
namespace App\Providers;

use App\Services\Api2Pdf\Api2Pdf;
use Illuminate\Support\ServiceProvider;
use RuntimeException;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(Api2Pdf::class, fn () => new Api2Pdf(
            apiKey: config('services.api2pdf.key') ?? throw new RuntimeException('API2PDF_API_KEY is not set'),
            baseUrl: config('services.api2pdf.base_url'),
            timeout: config('services.api2pdf.timeout'),
        ));
    }
}

Step 3: the invoice as a Blade view

Because Api2Pdf renders with a current Headless Chrome, the PDF view is ordinary HTML and CSS: flexbox, grid, web fonts and @page rules all work. Blade's {{ }} escapes output, which matters here: customer names and line item descriptions end up inside HTML that a browser renders, so never print them with {!! !!}.

{{-- resources/views/pdf/invoice.blade.php --}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Invoice {{ $invoice->number }}</title>
<style>
  @page { size: Letter; margin: 0.6in 0.6in 0.8in; }
  body { font-family: "Helvetica Neue", Arial, sans-serif; color: #0f172a; font-size: 12px; }
  header { display: flex; justify-content: space-between; align-items: flex-start; }
  h1 { font-size: 28px; margin: 0 0 4px; }
  .muted { color: #64748b; }
  table { width: 100%; border-collapse: collapse; margin-top: 28px; }
  th, td { padding: 8px 6px; border-bottom: 1px solid #e2e8f0; text-align: left; }
  th:last-child, td:last-child { text-align: right; }
  thead { display: table-header-group; } /* repeat the header row on every page */
  tr { break-inside: avoid; }
  .total { font-size: 16px; font-weight: 700; }
</style>
</head>
<body>
  <header>
    <div>
      <h1>Invoice</h1>
      <div class="muted">#{{ $invoice->number }} &middot; issued {{ $invoice->issued_at->format('M j, Y') }}</div>
    </div>
    <div style="text-align:right">
      <strong>{{ config('app.name') }}</strong><br>
      <span class="muted">Bill to: {{ $invoice->customer->name }}</span>
    </div>
  </header>

  <table>
    <thead><tr><th>Description</th><th>Qty</th><th>Unit</th><th>Amount</th></tr></thead>
    <tbody>
    @foreach ($invoice->lines as $line)
      <tr>
        <td>{{ $line->description }}</td>
        <td>{{ $line->quantity }}</td>
        <td>${{ number_format($line->unit_price, 2) }}</td>
        <td>${{ number_format($line->quantity * $line->unit_price, 2) }}</td>
      </tr>
    @endforeach
    </tbody>
    <tfoot>
      <tr><td colspan="3" class="total">Total</td><td class="total">${{ number_format($invoice->total, 2) }}</td></tr>
    </tfoot>
  </table>
</body>
</html>

Keep the Chrome options for this document type in one place. preferCSSPageSize tells Chrome to honor the @page size and margins in the view, and the footer template prints page numbers using Chrome's built-in pageNumber and totalPages classes. Header and footer templates need an explicit font size or they render almost invisibly small (more on that here).

<?php
// app/Pdf/InvoicePdf.php
namespace App\Pdf;

use App\Models\Invoice;

final class InvoicePdf
{
    public static function html(Invoice $invoice): string
    {
        return view('pdf.invoice', ['invoice' => $invoice->loadMissing('lines', 'customer')])->render();
    }

    public static function fileName(Invoice $invoice): string
    {
        return "invoice-{$invoice->number}.pdf";
    }

    public static function options(): array
    {
        return [
            'preferCSSPageSize' => true,
            'printBackground' => true,
            'displayHeaderFooter' => true,
            'headerTemplate' => '<span></span>',
            'footerTemplate' => '<div style="font-size:9px;width:100%;text-align:center;color:#64748b">'
                .'Page <span class="pageNumber"></span> of <span class="totalPages"></span></div>',
        ];
    }
}

Step 4: a controller that returns the PDF

The controller authorizes the user, renders the view, asks Api2Pdf for the bytes and returns them. ?download=1 switches the browser from viewing inline to saving the file.

<?php
// app/Http/Controllers/InvoicePdfController.php
namespace App\Http\Controllers;

use App\Models\Invoice;
use App\Pdf\InvoicePdf;
use App\Services\Api2Pdf\Api2Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;

class InvoicePdfController extends Controller
{
    public function __invoke(Request $request, Invoice $invoice, Api2Pdf $api2pdf)
    {
        Gate::authorize('view', $invoice);

        $fileName = InvoicePdf::fileName($invoice);
        $pdf = $api2pdf->htmlToPdfBytes(InvoicePdf::html($invoice), InvoicePdf::options(), $fileName);

        $disposition = $request->boolean('download') ? 'attachment' : 'inline';

        return response($pdf, 200, [
            'Content-Type' => 'application/pdf',
            'Content-Disposition' => "{$disposition}; filename=\"{$fileName}\"",
            'Cache-Control' => 'private, no-store',
        ]);
    }
}
// routes/web.php
use App\Http\Controllers\InvoicePdfController;

Route::get('/invoices/{invoice}/pdf', InvoicePdfController::class)
    ->middleware('auth')
    ->name('invoices.pdf');

If Api2PdfException escapes, Laravel's handler turns it into a 500 and logs it. The context() method on the exception class (Step 2) adds the HTTP status and ResponseId to that log entry automatically, so support can find the exact request.

Step 5: generate, store and email from a queued job

Rendering in the request is fine for on-demand downloads. For "invoice issued" flows, do it on the queue: render once, store the PDF on your own disk, and attach it to the email. Issued invoices do not change, so the job skips rendering if the file already exists. That keeps retries cheap when only the mail step failed.

<?php
// app/Jobs/GenerateInvoicePdf.php
namespace App\Jobs;

use App\Mail\InvoiceIssued;
use App\Models\Invoice;
use App\Pdf\InvoicePdf;
use App\Services\Api2Pdf\Api2Pdf;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;

class GenerateInvoicePdf implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public array $backoff = [30, 120];
    public int $timeout = 240; // longer than the HTTP timeout times its attempts

    public function __construct(public Invoice $invoice) {}

    public function handle(Api2Pdf $api2pdf): void
    {
        $path = "invoices/{$this->invoice->number}.pdf";
        $disk = Storage::disk('s3');

        if (! $disk->exists($path)) {
            $pdf = $api2pdf->htmlToPdfBytes(
                InvoicePdf::html($this->invoice),
                InvoicePdf::options(),
                InvoicePdf::fileName($this->invoice),
            );
            $disk->put($path, $pdf);
            $this->invoice->forceFill(['pdf_path' => $path])->save();
        }

        Mail::to($this->invoice->customer->email)->send(new InvoiceIssued($this->invoice));
    }
}

The mailable attaches the stored file straight from the disk:

// app/Mail/InvoiceIssued.php (excerpt)
use Illuminate\Mail\Mailables\Attachment;

public function attachments(): array
{
    return [
        Attachment::fromStorageDisk('s3', $this->invoice->pdf_path)
            ->as("invoice-{$this->invoice->number}.pdf")
            ->withMime('application/pdf'),
    ];
}

Dispatch it after the transaction that issues the invoice commits, so the worker never sees a half-written row:

GenerateInvoicePdf::dispatch($invoice)->afterCommit();

Make sure the queue connection's retry_after in config/queue.php is larger than the job's $timeout, otherwise a slow render can be picked up by a second worker while the first is still waiting on it.

Step 6: DRAFT watermarks on generated PDFs

Quotes, proposals and contracts usually go through a review stage where every copy should say DRAFT. Api2Pdf's watermark endpoint, POST https://v2.api2pdf.com/pdfsharp/watermark, takes the url of an existing PDF and stamps text on every page. The options are text (default DRAFT), fontSize (default 72), color (hex, default #000000), opacity (0.0 to 1.0, default 0.15) and rotation (degrees, default -45, a diagonal stamp). It also accepts fileName, inline, outputBinary and custom storage like the other endpoints.

For a generated draft that means two calls: render the HTML to a hosted PDF (JSON mode, so you get a FileUrl), then watermark that URL and return the bytes. Delete the unwatermarked intermediate when you are done so a clean copy is not left on a public link:

<?php
// app/Http/Controllers/ProposalDraftPdfController.php
namespace App\Http\Controllers;

use App\Models\Proposal;
use App\Services\Api2Pdf\Api2Pdf;
use Illuminate\Support\Facades\Gate;

class ProposalDraftPdfController extends Controller
{
    public function __invoke(Proposal $proposal, Api2Pdf $api2pdf)
    {
        Gate::authorize('view', $proposal);

        $html = view('pdf.proposal', ['proposal' => $proposal])->render();
        $fileName = "proposal-{$proposal->id}-draft.pdf";

        // 1. Render to a hosted PDF and get its URL.
        $rendered = $api2pdf->htmlToPdf($html, ['printBackground' => true], $fileName);

        try {
            // 2. Stamp DRAFT on every page and get the bytes back.
            $pdf = $api2pdf->watermarkBytes($rendered['FileUrl'], 'DRAFT', [
                'color' => '#DC2626',
                'opacity' => 0.12,
                'fontSize' => 96,
            ], $fileName);
        } finally {
            // 3. Remove the clean copy instead of leaving it up for 24 hours.
            $api2pdf->delete($rendered['ResponseId']);
        }

        return response($pdf, 200, [
            'Content-Type' => 'application/pdf',
            'Content-Disposition' => "inline; filename=\"{$fileName}\"",
            'Cache-Control' => 'private, no-store',
        ]);
    }
}

When the proposal is approved, render it again without the watermark step and store the final version as in Step 5.

When a CSS watermark is enough

If you control the HTML, you can also print a watermark in the same single render with a fixed-position element, because Chrome repeats position: fixed elements on every printed page:

@if ($proposal->isDraft())
  <div style="position:fixed; top:42%; left:0; right:0; text-align:center;
              font-size:110px; font-weight:700; color:rgba(220,38,38,.10);
              transform:rotate(-30deg); z-index:1000; pointer-events:none">DRAFT</div>
@endif

That is one call instead of two. Use the watermark endpoint when you do not control the HTML: PDFs users uploaded, documents converted from Word with /libreoffice/any-to-pdf, files from third parties, or when you want the same stamp applied uniformly to every document in a workflow.

Step 7: CONFIDENTIAL watermarks on uploaded PDFs

A common pattern in data rooms and client portals: a user downloads a contract someone else uploaded, and every copy is stamped with who downloaded it. Store the uploads on a private S3 disk, hand Api2Pdf a short-lived signed URL, and put the recipient in the watermark text:

<?php
// app/Http/Controllers/DocumentDownloadController.php
namespace App\Http\Controllers;

use App\Models\Document;
use App\Services\Api2Pdf\Api2Pdf;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Storage;

class DocumentDownloadController extends Controller
{
    public function __invoke(Request $request, Document $document, Api2Pdf $api2pdf)
    {
        Gate::authorize('download', $document);

        // Private object; Api2Pdf fetches it through a 10 minute signed URL.
        $sourceUrl = Storage::disk('s3')->temporaryUrl($document->path, now()->addMinutes(10));

        $pdf = $api2pdf->watermarkBytes($sourceUrl, 'CONFIDENTIAL - '.$request->user()->email, [
            'fontSize' => 36,
            'opacity' => 0.18,
            'color' => '#1E293B',
        ], $document->download_name);

        return response($pdf, 200, [
            'Content-Type' => 'application/pdf',
            'Content-Disposition' => "attachment; filename=\"{$document->download_name}\"",
            'Cache-Control' => 'private, no-store',
        ]);
    }
}

Longer text needs a smaller fontSize to fit diagonally across the page, so preview a few documents and tune it. If the source URL needs a header to download (an internal API token, for example), pass it in extraHTTPHeaders; the watermark endpoint forwards those headers when it fetches the PDF. A 403 from your storage while Api2Pdf fetches the file is covered in this troubleshooting post.

Step 8: test it with Http::fake()

Because everything goes through Laravel's Http client, tests never need a real key or network access. Fake the Api2Pdf host, hit the route, and assert on what was sent:

<?php
// tests/Feature/InvoicePdfTest.php
namespace Tests\Feature;

use App\Models\Invoice;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;

class InvoicePdfTest extends TestCase
{
    use RefreshDatabase;

    public function test_it_returns_the_invoice_pdf(): void
    {
        config(['services.api2pdf.key' => 'test-key']);
        Http::preventStrayRequests();
        Http::fake([
            'v2.api2pdf.com/chrome/pdf/html*' => Http::response('%PDF-1.7 fake', 200, ['Content-Type' => 'application/pdf']),
        ]);

        $invoice = Invoice::factory()->hasLines(3)->create();

        $this->actingAs($invoice->customer->user)
            ->get(route('invoices.pdf', $invoice))
            ->assertOk()
            ->assertHeader('Content-Type', 'application/pdf');

        Http::assertSent(fn (Request $request) =>
            $request->hasHeader('Authorization', 'test-key')
            && str_contains($request->url(), 'outputBinary=true')
            && str_contains($request['html'], $invoice->number)
            && $request['options']['preferCSSPageSize'] === true
        );
    }
}

Adjust the factory and the user relationship to your own models. A second test with Http::response(['Success' => false, 'Error' => 'boom', 'ResponseId' => 'r1'], 400) is a cheap way to check that failures surface as Api2PdfException and are not retried (only 429 and 5xx are).

Production notes

  • Never expose the key to clients. Browsers, Livewire components and Inertia pages call your routes; only PHP talks to Api2Pdf. Keep API2PDF_API_KEY in the server environment or your secret manager, and run php artisan config:cache on deploy.
  • Timeouts line up end to end. The client uses a 60 second timeout. PHP-FPM's request_terminate_timeout, nginx's fastcgi_read_timeout and your load balancer must allow at least that for the synchronous controller. Typical invoices render in a second or two; long reports belong on the queue.
  • Very large documents. Hundred-page reports and image-heavy catalogs can exceed the standard limits (2 GB of memory, 2 CPU, 90 second timeout). Set API2PDF_BASE_URL=https://v2-xl.api2pdf.com to use V2 XL (4 CPU, 8 GB of memory and a 5 minute timeout per request, at 10x the standard price), and raise API2PDF_TIMEOUT and the job $timeout to match.
  • Retries can bill twice. If your side times out, Api2Pdf may still have finished the render. Keep the client timeout generous and retries low, and make jobs idempotent like the one in Step 5.
  • Log ResponseId. Every JSON response includes it, and the exception's context() method puts it in your logs. Support can find the exact request with it.
  • Store what does not change. Issued invoices and signed contracts are immutable. Render once, keep the PDF on your own disk, and serve later downloads from there. For bulk exports, see batch PDF generation with zip and S3.
  • Cleanup. Hosted files expire after 24 hours. Call DELETE /file/{responseId} (the delete() method above) to remove one immediately, as the draft flow does.
  • No rate limits on the Api2Pdf side. Your queue worker count is your concurrency limit, so size Horizon or queue:work processes for the throughput you need.
  • Pricing is pay as you go, based on the bandwidth of the output plus compute time; see pricing. The Cost field on every JSON response shows what a call cost. The draft flow is two calls (render plus watermark), the CSS watermark is one.

If you are deciding between this and running Chromium yourself, the numbers are in Puppeteer vs Playwright vs HTML-to-PDF API: the real cost of PDF generation.

Troubleshooting

SymptomLikely causeFix
401 with "Invalid API key"Wrong key, or a Bearer prefix in the headerSend the raw key in Authorization
402 with an x402Version bodyNo Authorization header at all: the key is null, often because env() is called outside a config file after config:cacheRead the key with config('services.api2pdf.key') and re-run php artisan config:cache
405 from the watermark callWrong pathThe endpoint is POST /pdfsharp/watermark
Watermark call fails to fetch the source PDFThe url is private, expired or behind a loginUse a signed URL (temporaryUrl) or pass auth in extraHTTPHeaders
504 from nginx or the load balancerProxy timeout lower than the render timeRaise proxy and PHP-FPM timeouts, queue the work, or use V2 XL for heavy documents
Job runs twice or emails twiceretry_after shorter than the job's run timeSet retry_after above $timeout and keep the job idempotent
Layout differs from the browserScreen styles vs print stylesOpen the rendered HTML in Chrome print preview; see test your HTML locally first
Missing images or fontsRelative URLs; Api2Pdf cannot resolve /storage/logo.pngUse absolute URLs with asset() or url(), or inline small images as data URIs

Wrap-up

You now have a Laravel PDF setup with no browser on your servers: a service class with explicit timeouts and safe retries, Blade views that use modern CSS, a controller that returns the PDF, a queued job that stores and emails it, DRAFT and CONFIDENTIAL watermarks for generated and uploaded documents, and tests that never touch the network. Get an API key at portal.api2pdf.com, and see the API documentation for every endpoint and option.