Documentation · v2.0.0

PDF Studio
for Laravel

Design, preview, and generate PDFs using HTML and TailwindCSS. From simple invoices to hosted rendering platforms.

Free & open source · MIT License · All features included

367
Tests Passing
3
PDF Drivers
M0–M9
Milestones
// 01 · Getting Started

Installation

Install via Composer. The service provider and Pdf facade are auto-discovered — no manual registration needed.

bash
$ composer require sarder/pdfstudio

✓ Package installed successfully.

Publish the config file to customise drivers, preview settings, and feature flags:

bash
$ php artisan vendor:publish --tag=pdf-studio-config
# → config/pdf-studio.php

# Pro & SaaS features also require migrations:
$ php artisan vendor:publish --tag=pdf-studio-migrations
$ php artisan migrate

Install optional dependencies for the features you need:

bash
# Interactive — pick features from a list
$ php artisan pdf-studio:install

# Or install all optional dependencies at once
$ php artisan pdf-studio:install --all
💡
Zero config for basic use The package works out of the box with the fake driver. Run php artisan pdf-studio:install to interactively pick which drivers and features to enable, or see the Dependency Installer section for details.
// 02 · Getting Started

Quick Start

The Pdf facade is your primary interface. All methods are fluent and chainable.

PHP
use PdfStudio\Laravel\Facades\Pdf;

// Download a PDF immediately
return Pdf::view('invoices.show')
    ->data(['invoice' => $invoice])
    ->download('invoice.pdf');

// Stream inline in browser
return Pdf::view('reports.quarterly')
    ->data(['report' => $report])
    ->inline('report.pdf');

// Save to Storage (S3, local, etc.)
Pdf::view('statements.monthly')
    ->data(['account' => $account])
    ->driver('chromium')
    ->format('A4')
    ->landscape()
    ->save('statements/2024-01.pdf', 's3');

// Render from raw HTML
$result = Pdf::html('<h1>Hello</h1>')->render();
echo $result->bytes;         // file size in bytes
echo $result->renderTimeMs;  // render duration

Fluent API Reference

MethodDescription
->view('blade.path')Set Blade view to render
->html('<h1>...')Set raw HTML string
->data([...])Pass data to the view
->driver('chromium')Override the PDF driver
->format('A4')Set paper format
->landscape()Set landscape orientation
->template('name')Use a registered template
->download('file.pdf')Return download response
->inline('file.pdf')Return inline browser response
->save('path', 'disk')Save to Laravel Storage
->render()Return raw result object
// 03 · Getting Started

Drivers

PDF Studio supports six rendering engines plus a test double. Install only what you need.

⚙️
wkhtmltopdf
System binary
No Node Good CSS
🐘
dompdf
dompdf/dompdf
PHP only Basic CSS
🐳
Gotenberg
Docker container
Full CSS PDF/A Docker
🐍
WeasyPrint
System binary
PDF/A & UA Attachments Python
☁️
Cloudflare Browser
Cloudflare Workers
Serverless Full CSS API Key
Install a driver
# Chromium (recommended for TailwindCSS)
$ composer require spatie/browsershot

# dompdf (no external binaries needed)
$ composer require dompdf/dompdf

# Gotenberg (Docker-based, supports PDF/A)
$ docker run --rm -p 3000:3000 gotenberg/gotenberg:8

# WeasyPrint (Python-based, supports PDF/A, PDF/UA, attachments)
$ pip install weasyprint
config/pdf-studio.php
'default_driver' => env('PDF_STUDIO_DRIVER', 'chromium'),
// 04 · Core Features

Template Registry

Register named templates with default options and data providers. Use them by name anywhere in your app.

config/pdf-studio.php
'templates' => [
    'invoice' => [
        'view'            => 'pdf.invoice',
        'description'     => 'Customer invoice template',
        'default_options' => ['format' => 'A4'],
        'data_provider'   => App\Pdf\InvoiceDataProvider::class,
    ],
    'report' => [
        'view'            => 'pdf.report',
        'default_options' => ['format' => 'A3', 'landscape' => true],
    ],
],
PHP · Usage
// Use a registered template
Pdf::template('invoice')
    ->data(['id' => 123])
    ->download('invoice-123.pdf');
artisan
$ php artisan pdf-studio:templates

  invoice   pdf.invoice   App\Pdf\InvoiceDataProvider
  report    pdf.report    —
// 05 · Core Features

Blade Directives

Custom directives for precise control over page layout and rendering. All directives work in any Blade template.

Blade
{{-- Page breaks --}}
@pageBreak
@pageBreakBefore

{{-- Prevent content splitting across pages --}}
@avoidBreak
    <div>This stays together</div>
@endAvoidBreak

{{-- Conditional visibility (CSS-based, prints correctly) --}}
@showIf($invoice->isPaid())
    <span>PAID</span>
@endShowIf

{{-- Keep a section on one page --}}
@keepTogether
    <table>...</table>
@endKeepTogether

{{-- Chromium page number footer --}}
@pageNumber(['format' => 'Page {page} of {total}'])

{{-- Barcode (requires picqer/php-barcode-generator) --}}
@barcode('CODE128', 'INV-001')

{{-- QR Code (requires chillerlan/php-qrcode) --}}
@qrcode('https://example.com')
// 06 · Core Features

Queue / Async Rendering

Offload heavy rendering to Laravel queues. The built-in RenderPdfJob handles saving output to any Storage disk.

PHP
use PdfStudio\Laravel\Jobs\RenderPdfJob;

// Single async job
RenderPdfJob::dispatch(
    view:       'invoices.show',
    data:       ['invoice' => $invoice->toArray()],
    outputPath: 'invoices/inv-001.pdf',
    disk:       's3',
    driver:     'chromium',
);

// Batch rendering
Pdf::batch([
    ['view' => 'invoices.show', 'data' => $inv1, 'outputPath' => 'inv-1.pdf'],
    ['view' => 'invoices.show', 'data' => $inv2, 'outputPath' => 'inv-2.pdf'],
], driver: 'dompdf', disk: 's3');
// 07 · Core Features

Preview Routes

Browser-based template preview for rapid development. Disabled in production by default via an environment gate.

config/pdf-studio.php
'preview' => [
    'enabled'              => env('PDF_STUDIO_PREVIEW', false),
    'middleware'           => ['web', 'auth'],
    'environment_gate'     => true,
    'allowed_environments' => ['local', 'staging', 'testing'],
],

Preview URLs

GET/pdf-studio/preview/{template}?format=html
GET/pdf-studio/preview/{template}?format=pdf
POST/pdf-studio/builder/preview (JSON schema)
// 08 · Core Features

Tailwind CSS

Compile Tailwind utility classes inside your PDF views. The output is cached to avoid re-compilation on every render.

config/pdf-studio.php
'tailwind' => [
    'binary' => env('TAILWIND_BINARY', base_path('node_modules/.bin/tailwindcss')),
    'config' => base_path('tailwind.config.js'),
],
Clear compiled CSS cache
$ php artisan pdf-studio:cache-clear
✓ CSS cache cleared.
// 09 · Core Features

Bootstrap CSS

Use Bootstrap 5 utility classes in your PDF views instead of Tailwind. The bundled CSS is injected automatically when Bootstrap mode is active.

PHP
// Per-render Bootstrap mode
Pdf::view('invoices.show')
    ->bootstrap()
    ->render();

// Or set globally in config
'css_framework' => 'bootstrap',  // 'tailwind', 'bootstrap', or 'none'
ℹ️
Set css_framework to 'none' to skip CSS framework injection entirely and use only your own styles.
// 10 · Core Features

Barcode & QR Code

Generate inline SVG barcodes and QR codes directly in your Blade templates using Blade directives. Requires optional packages.

Install dependencies
$ composer require picqer/php-barcode-generator
$ composer require chillerlan/php-qrcode
Blade Template
{{-- Barcode: type, value, [options] --}}
@@barcode('CODE128', 'INV-2026-001')
@@barcode('EAN13', '5901234123457', ['width' => 3, 'height' => 60])

{{-- QR Code: data, [options] --}}
@@qrcode('https://example.com/invoice/123')
@@qrcode('Payment: $500', ['size' => 8, 'error_correction' => 'H'])
Supported Barcode TypesAlias
CODE128C128
CODE39C39
EAN13
EAN8
UPCA
UPCE
CODE93
ITF14
config/pdf-studio.php
'barcode' => [
    'default_type'   => 'CODE128',
    'default_width'  => 2,
    'default_height' => 50,
],

'qrcode' => [
    'default_size'       => 150,
    'error_correction'   => 'M',  // L, M, Q, H
],
// 11 · Core Features

Thumbnails

Generate image thumbnails from rendered PDFs. Uses Imagick when available, falls back to Chromium screenshots. Ideal for document previews and galleries.

PHP
// Generate thumbnail from a rendered PDF
$thumb = Pdf::view('invoices.show')
    ->data(['invoice' => $invoice])
    ->thumbnail(
        width:   300,
        format:  'png',
        quality: 85,
        page:    1,
    );

// Save to storage
$thumb->save('thumbnails/inv-001.png');

// Or generate from file
$thumb = Pdf::thumbnailFromFile('/path/to/document.pdf');
config/pdf-studio.php
'thumbnail' => [
    'strategy'       => 'auto',  // 'auto', 'imagick', 'chromium'
    'default_width'  => 300,
    'default_format' => 'png',
    'quality'        => 85,
],
// 12 · Core Features

Table of Contents

Automatically generate a table of contents from headings in your PDF. Uses a two-pass rendering approach: extract headings, build TOC, then re-render with the TOC prepended.

PHP
// Add a TOC to any PDF
Pdf::view('reports.annual')
    ->withTableOfContents(
        depth: 3,           // Include h1-h3
        title: 'Contents',   // TOC heading
    )
    ->render();

// Explicit mode — only include headings with data-toc attribute
Pdf::view('reports.annual')
    ->withTableOfContents(mode: 'explicit')
    ->render();
Blade Template
{{-- Auto mode: all headings are included unless excluded --}}
<h1>Introduction</h1>
<h2>Background</h2>
<h2 data-toc="false">This heading is excluded</h2>

{{-- Explicit mode: only headings with data-toc are included --}}
<h1 data-toc>Chapter 1</h1>
<h2 data-toc>Section 1.1</h2>
// 13 · Advanced

Template Versioning

Snapshot template definitions at any point, browse history, restore previous versions, and diff changes between versions.

ℹ️
Requires migrations Run php artisan vendor:publish --tag=pdf-studio-migrations && php artisan migrate to enable this feature.
PHP
use PdfStudio\Laravel\Contracts\TemplateVersionServiceContract;

$versioning = app(TemplateVersionServiceContract::class);

// Save current state as a new version
$version = $versioning->create(
    definition:  $registry->get('invoice'),
    author:      'Jane Smith',
    changeNotes: 'Updated payment section layout',
);

// List history (newest first)
$versions = $versioning->list('invoice');

// Restore a specific version to a TemplateDefinition DTO
$definition = $versioning->restore('invoice', versionNumber: 3);

// See what changed between versions
$changes = $versioning->diff('invoice', fromVersion: 2, toVersion: 3);
// → ['view', 'default_options']
// 10 · Advanced

Workspaces & Access Control

Multi-tenant workspace model with role-based access control. Scope your PDF resources to teams and enforce permissions with a middleware.

Role Hierarchy

RolecanAccesscanManageDescription
ownerFull control
adminManage members and settings
memberView and render
viewerView only
PHP
use PdfStudio\Laravel\Models\Workspace;
use PdfStudio\Laravel\Models\WorkspaceMember;
use PdfStudio\Laravel\Contracts\AccessControlContract;

// Create a workspace
$workspace = Workspace::create([
    'name' => 'Acme Corp',
    'slug' => 'acme',
]);

// Add a member with a role
WorkspaceMember::create([
    'workspace_id' => $workspace->id,
    'user_id'      => $user->id,
    'role'         => 'admin',
]);

// Check permissions programmatically
$access = app(AccessControlContract::class);
$access->canAccess($user->id, $workspace->id);
$access->canManage($user->id, $workspace->id);

// Protect routes — reads {workspace} slug from route
Route::middleware('pdf-studio.workspace')->group(function () {
    Route::get('/workspaces/{workspace}/templates', ...);
});
// 11 · Advanced

Visual Builder

Define document layouts as a typed JSON block schema. Compile to HTML, export to Blade, or preview live via HTTP.

Available Block Types

BlockKey Properties
TextBlockcontent, tag (h1–h6/p/span), classes
ImageBlocksrc, alt, classes
TableBlockheaders[], rowBinding (DataBinding), cellBindings[]
ColumnsBlockcolumns (Block[][]), gap
SpacerBlockheight
PHP · Build a schema
use PdfStudio\Laravel\Builder\Schema\DocumentSchema;
use PdfStudio\Laravel\Builder\Schema\{TextBlock, TableBlock, DataBinding, StyleTokens};

$schema = new DocumentSchema(
    blocks: [
        new TextBlock(content: 'Invoice', tag: 'h1', classes: 'text-2xl font-bold'),
        new TableBlock(
            headers: ['Item', 'Qty', 'Price'],
            rowBinding: new DataBinding(variable: 'items', path: 'items'),
            cellBindings: ['name', 'quantity', 'price'],
        ),
    ],
    styleTokens: new StyleTokens(
        primaryColor: '#1a1a1a',
        fontFamily: 'Inter, sans-serif',
    ),
);

// Compile to full HTML
$html = app(SchemaToHtmlCompiler::class)->compile($schema);

// Export to Blade template
$blade = app(BladeExporter::class)->export($schema);

// Serialize / deserialize
$json   = $schema->toJson();
$schema = DocumentSchema::fromJson($json);

Live Preview API

POST/pdf-studio/builder/preview
JSON payload
{
    "schema": { /* DocumentSchema JSON */ },
    "format": "html"   // or "pdf"
}
// 12 · SaaS Tier

API Keys

Issue scoped API keys to workspaces. Keys are hashed with SHA-256 before storage — the plaintext is shown once and never recoverable.

🚀
Requires SaaS Set PDF_STUDIO_SAAS=true in .env and run migrations.
01
Enable in .env
PDF_STUDIO_SAAS=true
02
Create a workspace
All API activity is scoped to a workspace for tenant isolation.
03
Generate and store a key
Hash the raw key before persisting. Give the raw key to your customer once.
PHP
use PdfStudio\Laravel\Models\{Workspace, ApiKey};

$workspace = Workspace::create(['name' => 'Acme Corp', 'slug' => 'acme']);

// Generate raw key + prefix
$generated = ApiKey::generate();
// → ['key' => '64-char string', 'prefix' => 'first8ch']

ApiKey::create([
    'workspace_id' => $workspace->id,
    'name'         => 'Production Key',
    'key'          => hash('sha256', $generated['key']),  // store hash only
    'prefix'       => $generated['prefix'],
    'expires_at'   => now()->addYear(),               // optional
]);

// Show $generated['key'] to customer — cannot be retrieved later

// Revoke a key
$apiKey->revoke();
// 13 · SaaS Tier

Hosted Rendering API

A complete HTTP API for rendering PDFs. All endpoints require a Bearer token API key and are scoped to the key's workspace.

Authentication

🔑
All requests must include Authorization: Bearer <raw_api_key>

Sync Render — immediate PDF download

POST/api/pdf-studio/render
cURL
curl -X POST https://yourapp.com/api/pdf-studio/render \
  -H "Authorization: Bearer sk_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #42</h1>",
    "filename": "invoice-42.pdf"
  }'
# → application/pdf download

# With a Blade view, data, and options:
curl -X POST .../render \
  -d '{
    "view": "pdf.invoice",
    "data": {"invoice": {"id": 42}},
    "options": {"format": "A4", "landscape": false},
    "driver": "chromium"
  }'

Async Render — queue a job, poll for result

POST/api/pdf-studio/render/async
GET/api/pdf-studio/render/{uuid}
cURL
# 1. Dispatch
curl -X POST .../render/async \
  -H "Authorization: Bearer sk_abc123..." \
  -d '{"view": "pdf.report", "output_path": "reports/jan.pdf", "output_disk": "s3"}'
# ← 202 {"id": "uuid-...", "status": "pending"}

# 2. Poll
curl .../render/uuid-... -H "Authorization: Bearer sk_abc123..."
# ← {"id": "...", "status": "completed", "bytes": 14200, "render_time_ms": 312.5}

Job Status Values

pendingJob queued, waiting for a worker
completedRender finished, file saved to output_disk
failedRender failed — check error field in response
// 14 · SaaS Tier

Usage Metering

Record render events with idempotency keys — safe to call multiple times for the same job without double-counting. Emits a BillableEvent you can hook into any billing provider.

PHP · Record usage
use PdfStudio\Laravel\Contracts\UsageMeterContract;

$meter = app(UsageMeterContract::class);

// Idempotent — won't double-count the same jobId
$meter->recordRender(
    workspaceId:  $workspace->id,
    jobId:        $job->id,
    bytes:        $result->bytes,
    renderTimeMs: $result->renderTimeMs,
);
PHP · Hook billing provider via BillableEvent
use PdfStudio\Laravel\Events\BillableEvent;

// AppServiceProvider::boot()
Event::listen(BillableEvent::class, function (BillableEvent $event) {
    // $event->workspaceId
    // $event->eventType   ('render')
    // $event->quantity    (1 per render)
    // $event->metadata    (['bytes' => ..., 'render_time_ms' => ...])

    Stripe::meterEvent('pdf_render', [
        'stripe_customer_id' => $workspace->stripe_id,
        'value' => $event->quantity,
    ]);
});

Query Usage

PHP
$records = $meter->getUsage($workspace->id, now()->startOfMonth(), now()->endOfMonth());

$summary = $meter->getSummary($workspace->id, now()->startOfMonth(), now()->endOfMonth());
// → ['render' => 1432]
// 15 · SaaS Tier

Analytics

Query aggregated rendering statistics for a workspace over any date range.

PHP
use PdfStudio\Laravel\Contracts\AnalyticsServiceContract;

$analytics = app(AnalyticsServiceContract::class);

$stats = $analytics->getStats(
    workspaceId: $workspace->id,
    from:        now()->startOfMonth(),
    to:          now()->endOfMonth(),
);

// Returns:
// [
//   'total'              => 1432,
//   'completed'          => 1418,
//   'failed'             => 14,
//   'avg_render_time_ms' => 287.3,
//   'total_bytes'        => 48_392_104,
// ]
FieldTypeDescription
totalintAll render jobs in range
completedintSuccessfully rendered
failedintErrored render jobs
avg_render_time_msfloatMean render duration
total_bytesintCumulative output size in bytes
// 16 · PDF Manipulation

PDF Merging

Merge multiple PDFs into a single document. Accepts file paths, PdfResult objects, Storage paths with disk/page options, and raw bytes. Requires setasign/fpdi.

PHP · Merging PDFs
use PdfStudio\Laravel\Facades\Pdf;

// Merge file paths
$result = Pdf::merge([
    storage_path('pdf/cover.pdf'),
    storage_path('pdf/report.pdf'),
    storage_path('pdf/appendix.pdf'),
]);

// Merge rendered PdfResult objects
$page1 = Pdf::html('<h1>Page 1</h1>')->render();
$page2 = Pdf::html('<h1>Page 2</h1>')->render();
$result = Pdf::merge([$page1, $page2]);

// Merge with Storage paths and page ranges
$result = Pdf::merge([
    ['path' => 'documents/report.pdf', 'disk' => 's3', 'pages' => '1-3,5'],
    storage_path('pdf/appendix.pdf'),
]);

$result->download('merged.pdf');
Source TypeExample
File path'/path/to/file.pdf'
PdfResultPdf::html('...')->render()
Storage array['path' => '...', 'disk' => 's3', 'pages' => '1-3']
Raw bytesfile_get_contents('file.pdf')
// 17 · PDF Manipulation

Watermarking

Add text or image watermarks to rendered PDFs with configurable opacity, rotation, position, and font size. Requires setasign/fpdi.

PHP · Text & image watermarks
// Text watermark with custom styling
Pdf::html('<h1>Invoice</h1>')
    ->watermark('DRAFT', opacity: 0.3, fontSize: 72, position: 'center')
    ->download('invoice-draft.pdf');

// Image watermark
Pdf::view('report')
    ->watermarkImage(storage_path('images/logo.png'), opacity: 0.2, position: 'bottom-right')
    ->download('report.pdf');

// Watermark an existing PDF (standalone builder)
$result = Pdf::watermarkPdf(file_get_contents('existing.pdf'))
    ->text('CONFIDENTIAL')
    ->opacity(0.5)
    ->rotation(-30)
    ->apply();
OptionDefaultDescription
opacity0.3Transparency (0.0–1.0)
rotation-45Rotation in degrees
positioncentercenter, top-left, top-right, bottom-left, bottom-right
fontSize48Text watermark font size
color#999999Text watermark color (hex)
// 18 · PDF Manipulation

Password Protection

Protect PDFs with user and owner passwords, and control permissions like printing and copying. Requires mikehaertl/php-pdftk.

PHP · Password protection
// User + owner passwords
Pdf::html('<h1>Secret Report</h1>')
    ->protect(userPassword: 'user123', ownerPassword: 'admin456')
    ->download('protected.pdf');

// Owner password with restricted permissions
Pdf::view('contract')
    ->protect(
        ownerPassword: 'admin',
        permissions: ['Printing', 'CopyContents'],
    )
    ->save('contracts/signed.pdf');
// 19 · PDF Manipulation

AcroForm Fill

Fill PDF form fields programmatically with a fluent builder. Supports filling, flattening (making fields non-editable), and listing available fields. Requires mikehaertl/php-pdftk.

PHP · Form filling
// Fill and flatten form fields
$result = Pdf::acroform(storage_path('forms/application.pdf'))
    ->fill([
        'name'  => 'John Doe',
        'email' => 'john@example.com',
        'date'  => '2024-01-15',
    ])
    ->flatten()
    ->output();

$result->download('application-filled.pdf');

// List available form fields
$fields = Pdf::acroform(storage_path('forms/application.pdf'))->fields();
// ['name', 'email', 'date', 'signature']
// 20 · PDF Manipulation

Render Caching

Cache rendered PDF output to avoid re-rendering identical content. The cache key is a SHA-256 hash of the view name, data, options, and driver.

PHP · Render caching
// Cache for 1 hour (3600 seconds)
$result = Pdf::html('<h1>Report</h1>')->cache(3600)->render();

// Second call returns cached result instantly (renderTimeMs = 0)
$result2 = Pdf::html('<h1>Report</h1>')->cache(3600)->render();

// Bypass cache for a specific render
$fresh = Pdf::html('<h1>Report</h1>')->cache(3600)->noCache()->render();
PHP · config/pdf-studio.php
'render_cache' => [
    'enabled' => true,
    'store'   => null, // uses default cache store
    'ttl'     => 3600,
],
Clear render cache
$ php artisan pdf-studio:cache-clear --render
// 21 · PDF Manipulation

Auto-Height Paper

Automatically size the paper height to fit content — ideal for receipts, tickets, and single-page documents with variable-length content. Supported by all drivers.

PHP · Auto-height rendering
// Auto-fit content height
Pdf::html('<h1>Long receipt...</h1>')
    ->contentFit()
    ->download('receipt.pdf');

// With maximum height cap (in pixels)
Pdf::view('receipt')
    ->contentFit(maxHeight: 3000)
    ->download('receipt.pdf');

// Alias
Pdf::html($html)->autoHeight()->render();
DriverImplementation
ChromiumTwo-pass: measures document.body.scrollHeight, then renders with exact paper height
dompdfTwo-pass: renders to measure page count, then re-renders with custom paper size [0, 0, width, height]
wkhtmltopdfUses --page-height and --page-width flags with --disable-smart-shrinking
// 22 · PDF Manipulation

Header/Footer Per-Page Control

Control header and footer visibility on specific pages using JavaScript injection. Supported by Chromium and wkhtmltopdf drivers.

PHP · Per-page header/footer
// Hide header on cover page (first page)
Pdf::view('report')
    ->headerExceptFirst()
    ->download('report.pdf');

// Hide footer on last page
Pdf::view('report')
    ->footerExceptLast()
    ->download('report.pdf');

// Show header only on specific pages
Pdf::view('report')
    ->headerOnPages([2, 3, 4])
    ->download('report.pdf');

// Exclude from specific pages
Pdf::view('report')
    ->headerExcludePages([1, 5])
    ->footerExcludePages([1])
    ->download('report.pdf');
MethodDescription
headerExceptFirst()Hide header on page 1
footerExceptLast()Hide footer on the last page
headerOnPages([2, 3])Show header only on listed pages
headerExcludePages([1, 5])Hide header on listed pages
footerExcludePages([1])Hide footer on listed pages
// Getting Started

Dependency Installer

Install optional dependencies interactively or all at once with the pdf-studio:install Artisan command.

Interactive install
$ php artisan pdf-studio:install

  PDF Studio Dependency Installer

  Which features would you like to install?
  [0] Chromium PDF driver — Render PDFs via headless Chrome
  [1] Dompdf PDF driver — Render PDFs via dompdf
  [2] PDF manipulation — Merge, watermark, split, and reorder PDFs
  [3] Form filling & protection — AcroForm filling and password protection
  [4] Barcodes — @barcode Blade directive
  [5] QR codes — @qrcode Blade directive
Install all at once
$ php artisan pdf-studio:install --all

  [SKIP] Dompdf PDF driver — already installed
  Installing: spatie/browsershot setasign/fpdi mikehaertl/php-pdftk picqer/php-barcode-generator chillerlan/php-qrcode
  ...
  Dependencies installed successfully!
FeaturePackageDescription
Chromium PDF driverspatie/browsershotRender PDFs via headless Chrome
Dompdf PDF driverdompdf/dompdfRender PDFs via dompdf
PDF manipulationsetasign/fpdiMerge, watermark, split, and reorder PDFs
Form filling & protectionmikehaertl/php-pdftkAcroForm filling and password protection
Barcodespicqer/php-barcode-generator@barcode Blade directive
QR codeschillerlan/php-qrcode@qrcode Blade directive
💡
imagick PHP extension PDF thumbnail generation requires the imagick PHP extension, which must be installed separately (not via Composer). The installer will remind you if it's missing.
// 23 · PDF Manipulation

Diagnostics

The pdf-studio:doctor command runs a health check on your installation, verifying dependencies, binaries, and performing a test render.

Run diagnostics
$ php artisan pdf-studio:doctor

  PDF Studio Diagnostics

  [PASS] PHP Version >= 8.1
  [PASS] Memory Limit (512M)
  [PASS] Node.js (v22.16.0)
  [PASS] dompdf/dompdf installed
  [FAIL] wkhtmltopdf (/usr/local/bin/wkhtmltopdf)
         Fix: Install wkhtmltopdf or update config path
  [PASS] setasign/fpdi installed
  [PASS] Test render (fake driver)

  Some checks failed. See suggestions above.
CheckDescription
PHP VersionVerifies PHP >= 8.1
Memory LimitEnsures >= 128MB
Node.jsRequired for Chromium driver
dompdfChecks if dompdf/dompdf is installed
wkhtmltopdfChecks binary at configured path
pdftkRequired for AcroForm fill and password protection
FPDIRequired for merge and watermark
Test renderPerforms a render with the fake driver
// PDF Manipulation

Split & Chunk

Split PDFs by page ranges or chunk them into equal-sized parts. Useful for breaking large documents into smaller files for email or parallel processing.

PHP
// Split into specific page ranges
$parts = Pdf::split($pdfContent, ['1-3', '4-6', '7-10']);
// Returns array of PdfResult objects

// Split from a file path
$parts = Pdf::splitFile('/path/to/document.pdf', ['1-5', '6-10']);

// Chunk into equal-sized parts
$chunks = Pdf::chunk($pdfContent, pagesPerChunk: 5);

// Get chunk ranges without splitting
$ranges = Pdf::chunkRanges($pdfContent, 5);
// ['1-5', '6-10', '11-13']

// Get a detailed chunk plan
$plan = Pdf::chunkPlan($pdfContent, 5);
// [['index' => 0, 'start' => 1, 'end' => 5, 'pages' => 5, 'range' => '1-5'], ...]
// PDF Manipulation

Page Editing

Reorder, remove, and rotate individual pages in existing PDFs. All operations return a new PdfResult without modifying the original.

PHP
// Reorder pages (put page 3 first, then 1, then 2)
$result = Pdf::reorderPages($pdfContent, [3, 1, 2]);

// Remove specific pages
$result = Pdf::removePages($pdfContent, [2, 4]);

// Rotate pages (90, 180, or 270 degrees)
$result = Pdf::rotatePages($pdfContent, 90);              // All pages
$result = Pdf::rotatePages($pdfContent, 90, [1, 3]);     // Specific pages

// File-based variants
$result = Pdf::reorderPagesFile('/path/to/doc.pdf', [3, 1, 2]);
$result = Pdf::removePagesFile('/path/to/doc.pdf', [2]);
$result = Pdf::rotatePagesFile('/path/to/doc.pdf', 180);
// PDF Manipulation

Inspect & Validate

Validate PDF content, count pages, inspect structure, and read metadata. Lightweight checks that don't require external binaries.

PHP
// Validate PDF content
$valid = Pdf::isPdf($content);         // true/false
Pdf::assertPdf($content);               // Throws on invalid

// Page count
$pages = Pdf::pageCount($content);

// Full inspection summary
$info = Pdf::inspectPdf($content);
// ['valid' => true, 'page_count' => 12, 'byte_size' => 84210]

// Read metadata (Title, Author, Creator, etc.)
$meta = Pdf::readPdfMetadata($content);
// ['Title' => 'Invoice', 'Author' => 'Acme Corp', ...]

// File-based variants
$valid = Pdf::isPdfFile('/path/to/doc.pdf');
$info  = Pdf::inspectPdfFile('/path/to/doc.pdf');
$meta  = Pdf::readPdfMetadataFile('/path/to/doc.pdf');
// PDF Manipulation

File Embedding

Embed files (XML invoices, spreadsheets, images) into existing PDFs as attachments. Essential for PDF/A-3 compliant invoices like ZUGFeRD and Factur-X.

PHP
$result = Pdf::embedFiles($pdfContent, [
    ['path' => '/path/to/invoice.xml', 'name' => 'factur-x.xml', 'mime' => 'text/xml'],
    ['path' => '/path/to/data.csv'],
]);

// File-based variant
$result = Pdf::embedFilesIntoFile('/path/to/invoice.pdf', [
    ['path' => '/path/to/factur-x.xml'],
]);
// PDF Manipulation

Compose

Render multiple sections independently (different views, different data) and merge them into a single PDF in one call. Each section can use its own driver and options.

PHP
$result = Pdf::compose([
    [
        'view'    => 'pdf.cover',
        'data'    => ['title' => 'Annual Report 2026'],
        'options' => ['landscape' => true],
    ],
    [
        'view' => 'pdf.financials',
        'data' => ['figures' => $figures],
    ],
    [
        'html' => '<h1>Appendix</h1><p>Raw HTML section</p>',
    ],
]);
// PDF Manipulation

Custom Fonts

Register custom font families and have them automatically inlined as base64 @font-face rules in every render. Supports TTF, OTF, WOFF, and WOFF2 formats.

PHP — AppServiceProvider
use PdfStudio\Laravel\Fonts\FontRegistry;
use PdfStudio\Laravel\DTOs\FontDefinition;

public function boot(FontRegistry $fonts): void
{
    $fonts->register(new FontDefinition(
        family:  'Inter',
        sources: [resource_path('fonts/Inter-Regular.woff2')],
        weight:  '400',
        style:   'normal',
    ));

    $fonts->register(new FontDefinition(
        family:  'Inter',
        sources: [resource_path('fonts/Inter-Bold.woff2')],
        weight:  '700',
        style:   'normal',
    ));
}
ℹ️
Registered fonts are automatically converted to inline @font-face CSS and injected into every render via the CssInjector pipeline step.
// PDF Manipulation

Asset Resolution

Local images, stylesheets, and CSS url() references are automatically resolved and inlined as base64 data URIs. Remote assets can be allowed or blocked per-host.

config/pdf-studio.php
'assets' => [
    'inline_local'  => true,        // Convert local files to data URIs
    'allow_remote'  => true,        // Allow remote URL assets
    'allowed_hosts' => [],          // Restrict to specific hosts (empty = all)
    'allowed_roots' => [],          // Restrict local paths (empty = app dirs)
],
ℹ️
Asset resolution includes path traversal protection. Local file paths are validated with realpath() to ensure they remain within your application's base, public, and resource directories.
// 24 · Reference

Configuration

Full annotated reference for config/pdf-studio.php.

config/pdf-studio.php
return [
    // Which driver to use by default
    'default_driver' => env('PDF_STUDIO_DRIVER', 'chromium'),

    'tailwind' => [
        'binary' => env('TAILWIND_BINARY'),
        'config' => null,
    ],

    'preview' => [
        'enabled'              => env('PDF_STUDIO_PREVIEW', false),
        'middleware'           => ['web', 'auth'],
        'environment_gate'     => true,
        'allowed_environments' => ['local', 'staging', 'testing'],
        'prefix'               => 'pdf-studio/preview',
    ],

    'logging' => [
        'enabled' => env('PDF_STUDIO_LOGGING', false),
        'channel' => null,  // null = default channel
    ],

    'pro' => [
        'enabled'    => env('PDF_STUDIO_PRO', false),
        'versioning' => [
            'enabled'      => true,
            'max_versions' => 50,
        ],
        'workspaces' => [
            'enabled' => true,
            'roles'   => ['owner', 'admin', 'member', 'viewer'],
        ],
    ],

    'saas' => [
        'enabled'  => env('PDF_STUDIO_SAAS', false),
        'api'      => [
            'prefix'     => 'api/pdf-studio',
            'middleware' => ['api'],
            'rate_limit' => 60,
        ],
        'metering' => ['enabled' => true],
    ],
];
// 25 · Reference

Testing

PDF Studio ships with two testing strategies: a fake driver that returns minimal valid PDF bytes, and Pdf::fake() — a full test double with fluent assertions for every operation.

Run the test suite
$ composer test          # pest
$ composer analyse       # phpstan level 6
$ composer lint          # laravel pint

  Tests:    367 passed, 9 skipped
  Duration: 4.23s

Fake driver

PHP · Testing with fake driver
// In your TestCase or test file
config(['pdf-studio.default_driver' => 'fake']);

// All assertions work normally
$response = $this->postJson('/api/invoices/1/download');
$response->assertOk()
          ->assertHeader('Content-Type', 'application/pdf');

Pdf::fake() — fluent assertions v2

Pdf::fake() swaps the facade with a test double that records every operation. Chain fluent assertions to verify renders, downloads, merges, watermarks, and more — without ever generating a real PDF.

PHP · Pdf::fake() assertions
use PdfStudio\Laravel\Facades\Pdf;

it('renders an invoice PDF', function () {
    $fake = Pdf::fake();

    // Trigger the code that generates the PDF
    $this->get('/invoices/1/pdf');

    // Assert a PDF was rendered
    $fake->assertRendered();
    $fake->assertRenderedView('pdf.invoice');
    $fake->assertRenderedCount(1);
});

it('downloads with the correct filename', function () {
    $fake = Pdf::fake();

    $this->get('/invoices/1/download');

    $fake->assertDownloaded('invoice-001.pdf');
});

it('saves to storage', function () {
    $fake = Pdf::fake();

    $this->post('/invoices/1/archive');

    $fake->assertSavedTo('invoices/001.pdf');
});

Available assertions

AssertionDescription
assertRendered(?Closure)A PDF was rendered (optionally matching closure)
assertRenderedView(string)A specific Blade view was rendered
assertRenderedCount(int)Exact number of renders occurred
assertDownloaded(string)A PDF was downloaded with filename
assertSavedTo(string, ?disk)A PDF was saved to a storage path
assertDriverWas(string)A specific driver was used
assertContains(string)Rendered HTML contains a string
assertMerged()A merge operation occurred
assertMergedCount(int)Exact number of merge sources
assertWatermarked()A watermark was applied
assertProtected()Password protection was applied
assertNothingRendered()No renders occurred at all
// 26 · Layout & Design

Page Layouts

Control paper size, orientation, margins, headers, footers, and multi-column layouts. Every option works across all drivers unless noted.

Paper Sizes

FormatDimensionsCommon use
A4210 × 297 mmInternational standard, invoices, reports
Letter215.9 × 279.4 mmNorth American default
Legal215.9 × 355.6 mmLegal documents
A3297 × 420 mmWide tables, architectural sheets
A5148 × 210 mmCompact receipts, vouchers
PHP · Paper size & orientation
use PdfStudio\Laravel\Facades\Pdf;

// A4 portrait (default)
Pdf::view('reports.annual')
    ->format('A4')
    ->download('report.pdf');

// A4 landscape — great for wide tables / dashboards
Pdf::view('reports.quarterly')
    ->format('A4')
    ->landscape()
    ->download('quarterly.pdf');

// US Letter — most common for North American clients
Pdf::view('invoices.us')
    ->format('Letter')
    ->download('invoice.pdf');

Margins

PHP · Custom margins
// All sides equal (mm)
Pdf::view('invoices.show')
    ->margins(20)
    ->download('invoice.pdf');

// Individual sides: top, right, bottom, left (mm)
Pdf::view('invoices.show')
    ->margins(top: 15, right: 20, bottom: 30, left: 20)
    ->download('invoice.pdf');

// Compact receipt — very tight margins
Pdf::view('receipts.thermal')
    ->format('A5')
    ->margins(5)
    ->download('receipt.pdf');

Headers & Footers

ℹ️
Headers and footers work differently by driver. wkhtmltopdf supports full HTML headers/footers. Chromium uses CSS @page rules and the @pageNumber directive. dompdf has limited header/footer support.
PHP · wkhtmltopdf HTML header & footer
// wkhtmltopdf: pass raw HTML strings
Pdf::view('reports.show')
    ->driver('wkhtmltopdf')
    ->headerHtml('<div style="font-size:10px;text-align:right;width:100%;">Acme Corp — Confidential</div>')
    ->footerHtml('<div style="font-size:9px;text-align:center;width:100%;">Page [page] of [topage]</div>')
    ->download('report.pdf');
Blade · Chromium page numbers via @pageNumber
{{-- resources/views/pdf/report.blade.php --}}
<html>
<head>
  <style>
    @media print {
      @page {
        margin: 20mm 15mm 25mm 15mm; /* leave space for footer */
      }
      .page-footer {
        position: fixed;
        bottom: -18mm;
        width: 100%;
        text-align: center;
        font-size: 9pt;
        color: #888;
      }
    }
  </style>
</head>
<body>
  <div class="page-footer">
    @pageNumber(['format' => 'Page {page} of {total}'])
  </div>

  <h1>Annual Report</h1>
  <!-- content -->
</body>
</html>

Multi-Column Layouts

Blade · Two-column invoice layout
<!-- Two-column header: logo left, client info right -->
<div style="display:flex; justify-content:space-between; margin-bottom:32px;">
  <div>
    <img src="{{ asset('logo.png') }}" style="height:48px;">
    <p style="color:#666; font-size:12px; margin-top:8px;">123 Main St, London</p>
  </div>
  <div style="text-align:right;">
    <h2 style="margin:0;">INVOICE #{{ $invoice->number }}</h2>
    <p style="color:#666;">{{ $invoice->date->format('d M Y') }}</p>
    <p style="font-weight:600;">{{ $invoice->client->name }}</p>
  </div>
</div>

<!-- Line items table -->
<table style="width:100%; border-collapse:collapse; font-size:13px;">
  <thead>
    <tr style="background:#F8F8F8; border-bottom:2px solid #E5E5E5;">
      <th style="padding:10px 8px; text-align:left;">Description</th>
      <th style="padding:10px 8px; text-align:right;">Qty</th>
      <th style="padding:10px 8px; text-align:right;">Rate</th>
      <th style="padding:10px 8px; text-align:right;">Total</th>
    </tr>
  </thead>
  <tbody>
    @foreach($invoice->items as $item)
    <tr style="border-bottom:1px solid #F0F0F0;">
      <td style="padding:8px;">{{ $item->description }}</td>
      <td style="padding:8px; text-align:right;">{{ $item->qty }}</td>
      <td style="padding:8px; text-align:right;">£{{ number_format($item->rate, 2) }}</td>
      <td style="padding:8px; text-align:right;">£{{ number_format($item->total, 2) }}</td>
    </tr>
    @endforeach
  </tbody>
</table>

<!-- Totals: right-aligned summary -->
<div style="margin-top:24px; display:flex; justify-content:flex-end;">
  <table style="font-size:13px;">
    <tr><td style="padding:4px 16px 4px 0; color:#666;">Subtotal</td>
        <td style="text-align:right;">£{{ number_format($invoice->subtotal, 2) }}</td></tr>
    <tr><td style="padding:4px 16px 4px 0; color:#666;">VAT (20%)</td>
        <td style="text-align:right;">£{{ number_format($invoice->vat, 2) }}</td></tr>
    <tr style="border-top:2px solid #333; font-weight:700; font-size:15px;">
        <td style="padding:8px 16px 0 0;">Total</td>
        <td style="text-align:right;">£{{ number_format($invoice->total, 2) }}</td></tr>
  </table>
</div>

Page Break Control

Blade · Page break directives in practice
@foreach($sections as $section)
  <!-- Keep a section header with its content -->
  @keepTogether
    <h2>{{ $section->title }}</h2>
    <p>{{ $section->intro }}</p>
  @endKeepTogether

  @foreach($section->tables as $table)
    @keepTogether
      <h3>{{ $table->title }}</h3>
      <table>...</table>
    @endKeepTogether

    @if(!$loop->last)
      @pageBreak  {{-- force break between tables --}}
    @endif
  @endforeach
@endforeach
// 27 · Layout & Design

Troubleshooting

Common layout, rendering, and configuration issues — and how to fix them.

Tailwind classes not applying

The Tailwind compiler scans HTML for class names and generates only the CSS classes it finds. If a class is missing from the rendered PDF, the HTML sent to the compiler may not contain it — usually because of dynamic class construction.
Blade · Dynamic classes — wrong vs. right
// ❌ Wrong — Tailwind cannot detect these at compile time
<div class="text-{{ $color }}-500">...</div>

// ✅ Right — use a full class map
@php
$colorClass = match($status) {
    'paid'    => 'text-green-600 bg-green-50',
    'overdue' => 'text-red-600 bg-red-50',
    default   => 'text-gray-600 bg-gray-50',
};
@endphp
<div class="{{ $colorClass }}">...</div>

// ✅ Or — add a safelist in your tailwind.config.js
// safelist: [ 'text-red-500', 'text-green-500', 'text-blue-500' ]

Images not rendering

Blade · Image paths for PDF rendering
// ❌ Wrong — relative paths break outside a browser context
<img src="/images/logo.png">

// ✅ Right — use absolute URLs or storage paths
<img src="{{ asset('images/logo.png') }}">

// ✅ Best for local files — convert to base64
@php
  $logo = 'data:image/png;base64,' . base64_encode(
      file_get_contents(public_path('images/logo.png'))
  );
@endphp
<img src="{{ $logo }}">

// ✅ For S3 / cloud storage — use a temporary signed URL
<img src="{{ Storage::temporaryUrl('logos/acme.png', now()->addMinutes(5)) }}">

Custom fonts not loading

Blade · Embed fonts via base64
<!-- ❌ Google Fonts URLs fail in headless environments -->
<link href="https://fonts.googleapis.com/css2?family=Inter">

<!-- ✅ Embed font as base64 in <style> -->
@php
  $font = base64_encode(file_get_contents(public_path('fonts/Inter-Regular.woff2')));
@endphp
<style>
  @font-face {
    font-family: 'Inter';
    src: url('data:font/woff2;base64,{{ $font }}') format('woff2');
  }
  body { font-family: 'Inter', sans-serif; }
</style>

Page breaks not working

SymptomFix
Break inside a flex/grid containerAdd display:block wrapper around the section; flex/grid containers ignore break-before
@keepTogether not holdingSection is taller than one page — can't be kept together; split content or reduce font size
Extra blank page at endRemove trailing margin/padding on the last element: last:mb-0
Table rows splitting mid-rowAdd page-break-inside: avoid to tr elements in CSS

Text/layout looks different from browser preview

PHP · Debug: dump compiled HTML to inspect it
// Enable debug recording in config
// pdf-studio.debug.enabled = true

// Or dump inline for a single render
$result = Pdf::view('invoices.show')
    ->data(['invoice' => $invoice])
    ->render();

// Inspect the compiled HTML in your browser
return response($result->html, 200, [
    'Content-Type' => 'text/html'
]);

Chromium vs dompdf rendering differences

FeatureChromiumdompdfwkhtmltopdf
Flexbox✅ Full⚠️ Partial⚠️ Partial
CSS Grid✅ Full
Tailwind v4⚠️ Utilities only⚠️ Utilities only
Custom fonts✅ (woff/ttf)
Background images⚠️ Partial
@page margins
HTML header/footer

Performance — large PDFs taking too long

PHP · Tips for faster rendering
// 1. Use the cache — Tailwind compilation is the slowest step
//    Ensure pdf-studio.tailwind.cache.enabled = true (default)

// 2. Use dompdf for simple layouts (no external process spawn)
Pdf::view('receipts.simple')->driver('dompdf')->download();

// 3. Offload heavy renders to the queue
RenderPdfJob::dispatch(
    view: 'reports.annual',
    data: $data,
    outputPath: 'reports/annual.pdf',
    disk: 's3',
);

// 4. For bulk rendering use Pdf::batch()
Pdf::batch($invoiceJobs, driver: 'chromium', disk: 's3');
// 28 · Framework Integrations

Livewire

PDF Studio integrates natively with Livewire — call the Pdf facade directly from any Livewire component action. In v2.0.0, use the dedicated livewireDownload() method for reliable downloads.

livewireDownload() — recommended v2

The livewireDownload() method returns a StreamedResponse that bypasses Livewire's response interception, fixing download issues with Livewire 3 and Filament.

PHP · app/Livewire/InvoiceShow.php (recommended)
namespace App\Livewire;

use Livewire\Component;
use PdfStudio\Laravel\Facades\Pdf;
use App\Models\Invoice;

class InvoiceShow extends Component
{
    public Invoice $invoice;

    public function downloadPdf(): mixed
    {
        return Pdf::view('pdf.invoice')
            ->data(['invoice' => $this->invoice])
            ->format('A4')
            ->livewireDownload('invoice-' . $this->invoice->number . '.pdf');
    }
}

Base64 for Filament modals v2

Use toBase64() on a PdfResult to embed PDFs in Filament modals or data URIs.

PHP · Base64 embedding
$base64 = Pdf::view('pdf.invoice')
    ->data(['invoice' => $invoice])
    ->render()
    ->toBase64();

// Use in an iframe src
$dataUri = 'data:application/pdf;base64,' . $base64;

Alternative: manual streamDownload

PHP · app/Livewire/InvoiceShow.php (manual approach)
public function downloadPdf(): mixed
    {
        return response()->streamDownload(function () {
            echo Pdf::view('pdf.invoice')
                ->data(['invoice' => $this->invoice])
                ->format('A4')
                ->render()->content;
        }, 'invoice-' . $this->invoice->number . '.pdf');
    }

    public function render(): mixed
    {
        return view('livewire.invoice-show');
    }
}
Blade · resources/views/livewire/invoice-show.blade.php
<div>
  <h1>Invoice #{{ $invoice->number }}</h1>

  <button wire:click="downloadPdf" wire:loading.attr="disabled">
    <span wire:loading.remove>Download PDF</span>
    <span wire:loading>Generating...</span>
  </button>
</div>

Live PDF preview in an iframe

PHP · Livewire component with live preview
use Livewire\Attributes\Reactive;

class InvoiceEditor extends Component
{
    public string $clientName = '';
    public float  $amount     = 0;

    /** Returns data URL of rendered HTML for preview iframe */
    public function previewHtml(): string
    {
        return Pdf::view('pdf.invoice')
            ->data(['clientName' => $this->clientName, 'amount' => $this->amount])
            ->render()->html; // returns compiled HTML string
    }

    public function render(): mixed
    {
        return view('livewire.invoice-editor', [
            'previewHtml' => $this->previewHtml(),
        ]);
    }
}
Blade · Embedded HTML preview
<div class="flex gap-8">
  <!-- Edit panel -->
  <div>
    <input wire:model.live="clientName" placeholder="Client name">
    <input wire:model.live="amount" type="number" placeholder="Amount">
  </div>

  <!-- Live preview iframe -->
  <iframe
    srcdoc="{{ $previewHtml }}"
    style="width:595px; height:842px; border:1px solid #ccc; border-radius:4px;"
  ></iframe>
</div>
// 29 · Framework Integrations

Vue 3

Call the PDF Studio Render API from your Vue 3 frontend using fetch or Axios. Requires the SaaS API to be enabled and an API key.

🔑
Never expose API keys in client-side code. Store the key in your backend and proxy requests through a controller, or restrict keys to server-to-server calls only.

Sync download — click to get PDF

Vue 3 · Composition API
<!-- components/DownloadInvoice.vue -->
<script setup>
import { ref } from 'vue'

const props = defineProps({ invoiceId: Number })
const loading = ref(false)

async function downloadPdf() {
  loading.value = true
  try {
    // Proxy through your own API endpoint to keep the key server-side
    const response = await fetch(`/api/invoices/${props.invoiceId}/pdf`, {
      headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content }
    })

    if (!response.ok) throw new Error('PDF generation failed')

    const blob = await response.blob()
    const url  = URL.createObjectURL(blob)
    const a    = document.createElement('a')
    a.href     = url
    a.download = `invoice-${props.invoiceId}.pdf`
    a.click()
    URL.revokeObjectURL(url)
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <button @click="downloadPdf" :disabled="loading">
    {{ loading ? 'Generating PDF…' : 'Download PDF' }}
  </button>
</template>
PHP · Laravel proxy route (keeps API key server-side)
// routes/api.php
Route::middleware('auth:sanctum')->get('/invoices/{invoice}/pdf', function (Invoice $invoice) {
    return Pdf::view('pdf.invoice')
        ->data(['invoice' => $invoice->load('items')])
        ->download('invoice-' . $invoice->number . '.pdf');
});

Async render with progress polling

Vue 3 · Async + polling composable
// composables/usePdfRender.js
import { ref } from 'vue'

export function usePdfRender() {
  const status  = ref('idle')   // idle | pending | completed | failed
  const jobId   = ref(null)
  const error   = ref(null)

  async function startRender(payload) {
    status.value = 'pending'
    error.value  = null

    // Step 1: dispatch async job via your backend proxy
    const res = await fetch('/api/pdf/async', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    })
    const { id } = await res.json()
    jobId.value = id

    // Step 2: poll until done
    const interval = setInterval(async () => {
      const poll = await fetch(`/api/pdf/status/${id}`)
      const job  = await poll.json()

      if (job.status === 'completed') {
        status.value = 'completed'
        clearInterval(interval)
      } else if (job.status === 'failed') {
        status.value = 'failed'
        error.value  = job.error
        clearInterval(interval)
      }
    }, 2000)
  }

  return { status, jobId, error, startRender }
}
// 30 · Framework Integrations

React

Trigger PDF generation from React using fetch. Route requests through your Laravel backend to keep API credentials server-side.

Download button component

React · DownloadPdfButton.jsx
import { useState } from 'react'

export function DownloadPdfButton({ invoiceId }) {
  const [loading, setLoading] = useState(false)
  const [error,   setError]   = useState(null)

  async function handleDownload() {
    setLoading(true)
    setError(null)

    try {
      const response = await fetch(`/api/invoices/${invoiceId}/pdf`, {
        headers: {
          'X-Requested-With': 'XMLHttpRequest',
          'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '',
        },
      })

      if (!response.ok) throw new Error(`Server error: ${response.status}`)

      const blob     = await response.blob()
      const url      = URL.createObjectURL(blob)
      const link     = document.createElement('a')
      link.href      = url
      link.download  = `invoice-${invoiceId}.pdf`
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      URL.revokeObjectURL(url)
    } catch (e) {
      setError(e.message)
    } finally {
      setLoading(false)
    }
  }

  return (
    <div>
      <button onClick={handleDownload} disabled={loading}>
        {loading ? 'Generating PDF…' : 'Download PDF'}
      </button>
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  )
}

Async render with status polling (hooks)

React · usePdfRender.js custom hook
import { useState, useRef, useEffect } from 'react'

export function usePdfRender() {
  const [status, setStatus] = useState('idle')
  const [jobId,  setJobId]  = useState(null)
  const [err,    setErr]    = useState(null)
  const timerRef = useRef(null)

  useEffect(() => () => clearInterval(timerRef.current), [])

  async function startRender(payload) {
    setStatus('pending')
    setErr(null)

    const res  = await fetch('/api/pdf/async', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    })
    const { id } = await res.json()
    setJobId(id)

    timerRef.current = setInterval(async () => {
      const poll = await fetch(`/api/pdf/status/${id}`)
      const job  = await poll.json()

      if (job.status === 'completed') {
        setStatus('completed')
        clearInterval(timerRef.current)
      } else if (job.status === 'failed') {
        setStatus('failed')
        setErr(job.error)
        clearInterval(timerRef.current)
      }
    }, 2000)
  }

  return { status, jobId, err, startRender }
}

// Usage in a component:
function ReportPage() {
  const { status, err, startRender } = usePdfRender()

  return (
    <div>
      <button
        onClick={() => startRender({ view: 'pdf.report', data: { month: 'Jan' } })}
        disabled={status === 'pending'}
      >
        {status === 'pending' ? 'Rendering…' : 'Generate Report'}
      </button>
      {status === 'completed' && <p>✅ Report saved to S3</p>}
      {status === 'failed'    && <p>❌ {err}</p>}
    </div>
  )
}
// 31 · Framework Integrations

Node.js

Call the PDF Studio API from a Node.js service — useful for microservices, automation scripts, or non-Laravel backends that need to generate PDFs.

Download PDF buffer (native fetch)

Node.js 18+ · fetch API
// pdf-client.mjs  (Node 18+ has native fetch)
const API_BASE = 'https://yourapp.com/api/pdf-studio';
const API_KEY  = process.env.PDF_STUDIO_KEY;  // never hardcode keys

async function renderPdf({ view, data = {}, options = {}, filename = 'document.pdf' }) {
  const response = await fetch(`${API_BASE}/render`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${API_KEY}`,
      'Content-Type':  'application/json',
      'Accept':        'application/pdf',
    },
    body: JSON.stringify({ view, data, options }),
  });

  if (!response.ok) {
    const text = await response.text();
    throw new Error(`PDF render failed (${response.status}): ${text}`);
  }

  return Buffer.from(await response.arrayBuffer());
}

// Usage
const pdf = await renderPdf({
  view:    'pdf.invoice',
  data:    { invoice: { number: 'INV-001', total: 1500 } },
  options: { format: 'A4' },
});

await fs.writeFile('invoice.pdf', pdf);
console.log(`Written ${pdf.length} bytes`);

Async render + polling (Node.js)

Node.js · async render with polling
import { setTimeout as sleep } from 'timers/promises'; async function renderAsync({ view, data, outputPath, disk = 's3' }) { // 1. Dispatch const res = await fetch(`${API_BASE}/render/async`, { method: 'POST', headers: { 'Authorization': `Bearer ${API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ view, data, output_path: outputPath, output_disk: disk }), }); const { id } = await res.json(); // 2. Poll for (let attempt = 0; attempt < 30; attempt++) { await sleep(2000); const poll = await fetch(`${API_BASE}/render/${id}`, { headers: { 'Authorization': `Bearer ${API_KEY}` }, }); const job = await poll.json(); if (job.status === 'completed') { console.log(`Done — ${job.bytes} bytes in ${job.render_time_ms}ms`); return job; } if (job.status === 'failed') throw new Error(job.error); } throw new Error('Render timed out'); } await renderAsync({ view: 'pdf.report', data: { month: 'January', year: 2026 }, outputPath: 'reports/jan-2026.pdf', });
// 32 · Framework Integrations

Vanilla JavaScript

No framework needed — use the browser's native fetch API to trigger PDF downloads from any web page.

Single-file example

HTML + JavaScript
<!DOCTYPE html>
<html>
<head><meta name="csrf-token" content="{{ csrf_token() }}"></head>
<body>

<button id="downloadBtn">Download Invoice PDF</button>
<p id="status"></p>

<script>
document.getElementById('downloadBtn').addEventListener('click', async () => {
  const btn    = document.getElementById('downloadBtn');
  const status = document.getElementById('status');

  btn.disabled  = true;
  btn.textContent = 'Generating...';
  status.textContent = '';

  try {
    const response = await fetch('/api/invoices/42/pdf', {
      headers: {
        'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').content,
      },
    });

    if (!response.ok) throw new Error('Generation failed');

    const blob = await response.blob();
    const url  = URL.createObjectURL(blob);

    // Trigger download
    Object.assign(document.createElement('a'), {
      href:     url,
      download: 'invoice-42.pdf',
    }).click();

    URL.revokeObjectURL(url);
    status.textContent = '✅ Download started';
  } catch (e) {
    status.textContent = '❌ ' + e.message;
  } finally {
    btn.disabled    = false;
    btn.textContent = 'Download Invoice PDF';
  }
});
</script>
</body>
</html>

Open PDF inline in a new tab

JavaScript · Open PDF in browser tab
async function previewPdf(invoiceId) { const response = await fetch(`/api/invoices/${invoiceId}/pdf`); const blob = await response.blob(); const url = URL.createObjectURL(new Blob([blob], { type: 'application/pdf' })); // Opens in a new browser tab for inline viewing window.open(url, '_blank'); // Optional: clean up after tab loads (5 second grace period) setTimeout(() => URL.revokeObjectURL(url), 5000); }