Installation
Install via Composer. The service provider and Pdf facade are auto-discovered — no manual registration needed.
$ composer require sarder/pdfstudio ✓ Package installed successfully.
Publish the config file to customise drivers, preview settings, and feature flags:
$ 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:
# Interactive — pick features from a list $ php artisan pdf-studio:install # Or install all optional dependencies at once $ php artisan pdf-studio:install --all
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.
Quick Start
The Pdf facade is your primary interface. All methods are fluent and chainable.
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
| Method | Description |
|---|---|
->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 |
Drivers
PDF Studio supports six rendering engines plus a test double. Install only what you need.
# 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
'default_driver' => env('PDF_STUDIO_DRIVER', 'chromium'),
Template Registry
Register named templates with default options and data providers. Use them by name anywhere in your app.
'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], ], ],
// Use a registered template Pdf::template('invoice') ->data(['id' => 123]) ->download('invoice-123.pdf');
$ php artisan pdf-studio:templates invoice pdf.invoice App\Pdf\InvoiceDataProvider report pdf.report —
Blade Directives
Custom directives for precise control over page layout and rendering. All directives work in any Blade template.
{{-- 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')
Queue / Async Rendering
Offload heavy rendering to Laravel queues. The built-in RenderPdfJob handles saving output to any Storage disk.
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');
Preview Routes
Browser-based template preview for rapid development. Disabled in production by default via an environment gate.
'preview' => [ 'enabled' => env('PDF_STUDIO_PREVIEW', false), 'middleware' => ['web', 'auth'], 'environment_gate' => true, 'allowed_environments' => ['local', 'staging', 'testing'], ],
Preview URLs
Tailwind CSS
Compile Tailwind utility classes inside your PDF views. The output is cached to avoid re-compilation on every render.
'tailwind' => [ 'binary' => env('TAILWIND_BINARY', base_path('node_modules/.bin/tailwindcss')), 'config' => base_path('tailwind.config.js'), ],
$ php artisan pdf-studio:cache-clear ✓ CSS cache cleared.
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.
// Per-render Bootstrap mode Pdf::view('invoices.show') ->bootstrap() ->render(); // Or set globally in config 'css_framework' => 'bootstrap', // 'tailwind', 'bootstrap', or 'none'
css_framework to 'none' to skip CSS framework injection entirely and use only your own styles.
Barcode & QR Code
Generate inline SVG barcodes and QR codes directly in your Blade templates using Blade directives. Requires optional packages.
$ composer require picqer/php-barcode-generator $ composer require chillerlan/php-qrcode
{{-- 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 Types | Alias |
|---|---|
| CODE128 | C128 |
| CODE39 | C39 |
| EAN13 | — |
| EAN8 | — |
| UPCA | — |
| UPCE | — |
| CODE93 | — |
| ITF14 | — |
'barcode' => [ 'default_type' => 'CODE128', 'default_width' => 2, 'default_height' => 50, ], 'qrcode' => [ 'default_size' => 150, 'error_correction' => 'M', // L, M, Q, H ],
Thumbnails
Generate image thumbnails from rendered PDFs. Uses Imagick when available, falls back to Chromium screenshots. Ideal for document previews and galleries.
// 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');
'thumbnail' => [ 'strategy' => 'auto', // 'auto', 'imagick', 'chromium' 'default_width' => 300, 'default_format' => 'png', 'quality' => 85, ],
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.
// 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();
{{-- 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>
Template Versioning
Snapshot template definitions at any point, browse history, restore previous versions, and diff changes between versions.
php artisan vendor:publish --tag=pdf-studio-migrations && php artisan migrate to enable this feature.
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']
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
| Role | canAccess | canManage | Description |
|---|---|---|---|
owner | ✅ | ✅ | Full control |
admin | ✅ | ✅ | Manage members and settings |
member | ✅ | ❌ | View and render |
viewer | ✅ | ❌ | View only |
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', ...); });
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
| Block | Key Properties |
|---|---|
TextBlock | content, tag (h1–h6/p/span), classes |
ImageBlock | src, alt, classes |
TableBlock | headers[], rowBinding (DataBinding), cellBindings[] |
ColumnsBlock | columns (Block[][]), gap |
SpacerBlock | height |
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
{
"schema": { /* DocumentSchema JSON */ },
"format": "html" // or "pdf"
}
API Keys
Issue scoped API keys to workspaces. Keys are hashed with SHA-256 before storage — the plaintext is shown once and never recoverable.
PDF_STUDIO_SAAS=true in .env and run migrations.
PDF_STUDIO_SAAS=trueuse 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();
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
Authorization: Bearer <raw_api_key>Sync Render — immediate PDF download
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
# 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
error field in responseUsage 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.
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, );
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
$records = $meter->getUsage($workspace->id, now()->startOfMonth(), now()->endOfMonth()); $summary = $meter->getSummary($workspace->id, now()->startOfMonth(), now()->endOfMonth()); // → ['render' => 1432]
Analytics
Query aggregated rendering statistics for a workspace over any date range.
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, // ]
| Field | Type | Description |
|---|---|---|
total | int | All render jobs in range |
completed | int | Successfully rendered |
failed | int | Errored render jobs |
avg_render_time_ms | float | Mean render duration |
total_bytes | int | Cumulative output size in bytes |
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.
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 Type | Example |
|---|---|
| File path | '/path/to/file.pdf' |
| PdfResult | Pdf::html('...')->render() |
| Storage array | ['path' => '...', 'disk' => 's3', 'pages' => '1-3'] |
| Raw bytes | file_get_contents('file.pdf') |
Watermarking
Add text or image watermarks to rendered PDFs with configurable opacity, rotation, position, and font size. Requires setasign/fpdi.
// 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();
| Option | Default | Description |
|---|---|---|
opacity | 0.3 | Transparency (0.0–1.0) |
rotation | -45 | Rotation in degrees |
position | center | center, top-left, top-right, bottom-left, bottom-right |
fontSize | 48 | Text watermark font size |
color | #999999 | Text watermark color (hex) |
Password Protection
Protect PDFs with user and owner passwords, and control permissions like printing and copying. Requires mikehaertl/php-pdftk.
// 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');
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.
// 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']
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.
// 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();
'render_cache' => [ 'enabled' => true, 'store' => null, // uses default cache store 'ttl' => 3600, ],
$ php artisan pdf-studio:cache-clear --render
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.
// 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();
| Driver | Implementation |
|---|---|
| Chromium | Two-pass: measures document.body.scrollHeight, then renders with exact paper height |
| dompdf | Two-pass: renders to measure page count, then re-renders with custom paper size [0, 0, width, height] |
| wkhtmltopdf | Uses --page-height and --page-width flags with --disable-smart-shrinking |
Header/Footer Per-Page Control
Control header and footer visibility on specific pages using JavaScript injection. Supported by Chromium and wkhtmltopdf drivers.
// 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');
| Method | Description |
|---|---|
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 |
Dependency Installer
Install optional dependencies interactively or all at once with the pdf-studio:install Artisan command.
$ 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
$ 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!
| Feature | Package | Description |
|---|---|---|
| Chromium PDF driver | spatie/browsershot | Render PDFs via headless Chrome |
| Dompdf PDF driver | dompdf/dompdf | Render PDFs via dompdf |
| PDF manipulation | setasign/fpdi | Merge, watermark, split, and reorder PDFs |
| Form filling & protection | mikehaertl/php-pdftk | AcroForm filling and password protection |
| Barcodes | picqer/php-barcode-generator | @barcode Blade directive |
| QR codes | chillerlan/php-qrcode | @qrcode Blade directive |
imagick PHP extension, which must be installed separately (not via Composer). The installer will remind you if it's missing.
Diagnostics
The pdf-studio:doctor command runs a health check on your installation, verifying dependencies, binaries, and performing a test render.
$ 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.
| Check | Description |
|---|---|
| PHP Version | Verifies PHP >= 8.1 |
| Memory Limit | Ensures >= 128MB |
| Node.js | Required for Chromium driver |
| dompdf | Checks if dompdf/dompdf is installed |
| wkhtmltopdf | Checks binary at configured path |
| pdftk | Required for AcroForm fill and password protection |
| FPDI | Required for merge and watermark |
| Test render | Performs a render with the fake driver |
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.
// 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'], ...]
Page Editing
Reorder, remove, and rotate individual pages in existing PDFs. All operations return a new PdfResult without modifying the original.
// 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);
Inspect & Validate
Validate PDF content, count pages, inspect structure, and read metadata. Lightweight checks that don't require external binaries.
// 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');
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.
$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'], ]);
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.
$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>', ], ]);
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.
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', )); }
@font-face CSS and injected into every render via the CssInjector pipeline step.
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.
'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) ],
realpath() to ensure they remain within your application's base, public, and resource directories.
Configuration
Full annotated reference for 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], ], ];
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.
$ composer test # pest $ composer analyse # phpstan level 6 $ composer lint # laravel pint Tests: 367 passed, 9 skipped Duration: 4.23s
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
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.
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
| Assertion | Description |
|---|---|
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 |
Page Layouts
Control paper size, orientation, margins, headers, footers, and multi-column layouts. Every option works across all drivers unless noted.
Paper Sizes
| Format | Dimensions | Common use |
|---|---|---|
A4 | 210 × 297 mm | International standard, invoices, reports |
Letter | 215.9 × 279.4 mm | North American default |
Legal | 215.9 × 355.6 mm | Legal documents |
A3 | 297 × 420 mm | Wide tables, architectural sheets |
A5 | 148 × 210 mm | Compact receipts, vouchers |
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
// 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
@page rules and the @pageNumber directive. dompdf has limited header/footer support.
// 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');
{{-- 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
<!-- 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
@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
Troubleshooting
Common layout, rendering, and configuration issues — and how to fix them.
Tailwind classes not applying
// ❌ 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
// ❌ 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
<!-- ❌ 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
| Symptom | Fix |
|---|---|
| Break inside a flex/grid container | Add display:block wrapper around the section; flex/grid containers ignore break-before |
@keepTogether not holding | Section is taller than one page — can't be kept together; split content or reduce font size |
| Extra blank page at end | Remove trailing margin/padding on the last element: last:mb-0 |
| Table rows splitting mid-row | Add page-break-inside: avoid to tr elements in CSS |
Text/layout looks different from browser preview
// 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
| Feature | Chromium | dompdf | wkhtmltopdf |
|---|---|---|---|
| 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
// 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');
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
The livewireDownload() method returns a StreamedResponse that bypasses Livewire's response interception, fixing download issues with Livewire 3 and Filament.
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
Use toBase64() on a PdfResult to embed PDFs in Filament modals or data URIs.
$base64 = Pdf::view('pdf.invoice') ->data(['invoice' => $invoice]) ->render() ->toBase64(); // Use in an iframe src $dataUri = 'data:application/pdf;base64,' . $base64;
Alternative: manual streamDownload
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'); } }
<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
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(), ]); } }
<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>
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.
Sync download — click to get PDF
<!-- 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>
// 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
// 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 } }
React
Trigger PDF generation from React using fetch. Route requests through your Laravel backend to keep API credentials server-side.
Download button component
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)
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>
)
}
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)
// 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)
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',
});
Vanilla JavaScript
No framework needed — use the browser's native fetch API to trigger PDF downloads from any web page.
Single-file example
<!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
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);
}