sarder / pdfstudio
Design, preview, and generate PDFs using HTML and TailwindCSS in Laravel
Requires
- php: ^8.2
- illuminate/contracts: ^11.0|^12.0
- illuminate/database: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- dompdf/dompdf: ^3.1
- laravel/pint: ^1.0
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^2.0|^3.0
- pestphp/pest-plugin-arch: ^2.0|^3.0
- pestphp/pest-plugin-laravel: ^2.0|^3.0
- phpstan/phpstan: ^1.10
- spatie/browsershot: ^5.2
Suggests
- dompdf/dompdf: Required for the dompdf PDF driver (^2.0|^3.0)
- mikehaertl/php-pdftk: Required for AcroForm filling and password protection (^4.0)
- setasign/fpdi: Required for PDF merging and watermarking (^2.3)
- spatie/browsershot: Required for the Chromium PDF driver (^4.0)
This package is auto-updated.
Last update: 2026-03-09 00:08:41 UTC
README
Design, preview, and generate PDFs using HTML and TailwindCSS in Laravel.
Free and open source. All features — including template versioning, workspaces, the visual builder, and the hosted rendering API — are available under the MIT license with no key, subscription, or license fee required.
📖 Full User Guide — detailed examples, page layouts, framework integrations (Livewire, Vue, React, Node.js, vanilla JS), and troubleshooting.
Table of Contents
- Requirements
- Installation
- Quick Start
- Drivers
- Template Registry
- Blade Directives
- Queue / Async Rendering
- Preview Routes
- Tailwind CSS
- Template Versioning
- Workspaces & Access Control
- Visual Builder
- SaaS: Hosted Rendering API
- SaaS: Usage Metering
- SaaS: Analytics
- PDF Merging
- Watermarking
- Password Protection
- AcroForm Fill
- Livewire / Filament
- Render Caching
- Auto-Height Paper
- Header/Footer Per-Page Control
- Diagnostics
- Configuration Reference
- Testing
Requirements
- PHP >= 8.1
- Laravel 10.x, 11.x, or 12.x
Optional Dependencies
| Driver | Package | Required For |
|---|---|---|
| Chromium | spatie/browsershot ^5.2 |
Full CSS/TailwindCSS fidelity (recommended) |
| dompdf | dompdf/dompdf ^2.0|^3.0 |
Zero external dependencies, limited CSS |
| wkhtmltopdf | System binary | Good CSS fidelity, no Node.js needed |
PDF Manipulation (optional):
| Package | Required For |
|---|---|
setasign/fpdi ^2.3 |
PDF merging, watermarking |
mikehaertl/php-pdftk ^4.0 |
AcroForm fill, password protection |
Note: The Chromium driver requires Node.js and a Chromium/Chrome binary on the server.
Installation
composer require sarder/pdfstudio
Publish the config file:
php artisan vendor:publish --tag=pdf-studio-config
If you're using Pro or SaaS features, publish and run migrations:
php artisan vendor:publish --tag=pdf-studio-migrations php artisan migrate
Quick Start
The Pdf facade is auto-discovered. No manual registration needed.
use PdfStudio\Laravel\Facades\Pdf; // Download 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 Pdf::view('statements.monthly') ->data(['account' => $account]) ->driver('chromium') ->format('A4') ->landscape() ->save('statements/2024-01.pdf', 's3'); // Render from inline HTML $result = Pdf::html('<h1>Hello World</h1>')->render(); echo $result->bytes; // file size in bytes echo $result->renderTimeMs; // render duration
Drivers
| Driver | Package | Node | CSS Fidelity |
|---|---|---|---|
chromium (default) |
spatie/browsershot |
Yes | Full |
wkhtmltopdf |
System binary | No | Good |
dompdf |
dompdf/dompdf |
No | Limited |
fake |
Built-in | No | Testing only |
Install your preferred driver:
# Chromium (recommended for TailwindCSS) composer require spatie/browsershot # dompdf (zero external dependencies) composer require dompdf/dompdf
Set the default in config:
// config/pdf-studio.php 'default_driver' => 'chromium',
Switch per-render:
Pdf::view('report')->driver('dompdf')->download('report.pdf');
Template Registry
Register named templates with default options and data providers:
// config/pdf-studio.php 'templates' => [ 'invoice' => [ 'view' => 'pdf.invoice', 'description' => 'Customer invoice', 'default_options' => ['format' => 'A4'], 'data_provider' => App\Pdf\InvoiceDataProvider::class, ], ],
Use a registered template:
Pdf::template('invoice')->data(['id' => 123])->download('invoice.pdf');
List all registered templates:
php artisan pdf-studio:templates
Blade Directives
{{-- Force a page break --}} @pageBreak {{-- Page break before content --}} @pageBreakBefore {{-- Prevent content from splitting across pages --}} @avoidBreak <div>Keep this together on one page</div> @endAvoidBreak {{-- Show/hide based on condition (CSS-based, prints correctly) --}} @showIf($invoice->isPaid()) <span>PAID</span> @endShowIf {{-- Wrap content to avoid mid-section breaks --}} @keepTogether <table>...</table> @endKeepTogether {{-- Page number footer (Chromium only) --}} @pageNumber(['format' => 'Page {page} of {total}'])
Queue / Async Rendering
Dispatch a render job to a queue:
use PdfStudio\Laravel\Jobs\RenderPdfJob; 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->toArray(), 'outputPath' => 'inv-1.pdf'], ['view' => 'invoices.show', 'data' => $inv2->toArray(), 'outputPath' => 'inv-2.pdf'], ], driver: 'dompdf', disk: 's3');
Preview Routes
Enable browser-based template preview (disabled in production by default):
// config/pdf-studio.php 'preview' => [ 'enabled' => true, 'middleware' => ['web', 'auth'], ],
Visit:
GET /pdf-studio/preview/{template}?format=html— HTML previewGET /pdf-studio/preview/{template}?format=pdf— PDF preview
Tailwind CSS
Point PDF Studio at your Tailwind binary and config:
// config/pdf-studio.php 'tailwind' => [ 'binary' => env('TAILWIND_BINARY', base_path('node_modules/.bin/tailwindcss')), 'config' => base_path('tailwind.config.js'), ],
Compiled CSS is cached automatically. Clear the cache:
php artisan pdf-studio:cache-clear
Template Versioning
Requires: migrations (
php artisan vendor:publish --tag=pdf-studio-migrations && php artisan migrate).
Save a snapshot of a template definition at any point:
use PdfStudio\Laravel\Contracts\TemplateVersionServiceContract; $versioning = app(TemplateVersionServiceContract::class); // Save current version $version = $versioning->create( definition: $registry->get('invoice'), author: 'Jane Smith', changeNotes: 'Updated payment section layout', ); // List version history $versions = $versioning->list('invoice'); // Returns Collection<TemplateVersion> ordered newest first // Restore a previous version $definition = $versioning->restore('invoice', versionNumber: 3); // Diff two versions $changes = $versioning->diff('invoice', fromVersion: 2, toVersion: 3); // Returns array of changed field names
Workspaces & Access Control
Requires: migrations (
php artisan vendor:publish --tag=pdf-studio-migrations && php artisan migrate).
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 members with roles: owner | admin | member | viewer WorkspaceMember::create([ 'workspace_id' => $workspace->id, 'user_id' => $user->id, 'role' => 'admin', ]); // Check access in code $access = app(AccessControlContract::class); $access->canAccess($user->id, $workspace->id); // true/false $access->canManage($user->id, $workspace->id); // true for owner/admin // Protect routes with middleware Route::middleware('pdf-studio.workspace')->group(function () { // Route parameter must be named {workspace} (slug) Route::get('/workspaces/{workspace}/...', ...); });
Scope projects to a workspace:
use PdfStudio\Laravel\Models\Project; $project = Project::create([ 'workspace_id' => $workspace->id, 'name' => 'Q4 Reports', 'slug' => 'q4-reports', ]);
Visual Builder
Requires:
pdf-studio.preview.enabled = true
The visual builder lets you define document layouts as a JSON schema of typed blocks, preview them as HTML, and export them to Blade templates.
Block Schema
use PdfStudio\Laravel\Builder\Schema\DocumentSchema; use PdfStudio\Laravel\Builder\Schema\TextBlock; use PdfStudio\Laravel\Builder\Schema\TableBlock; use PdfStudio\Laravel\Builder\Schema\DataBinding; use PdfStudio\Laravel\Builder\Schema\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', ), ); // Serialize to JSON (store in DB, send to frontend) $json = $schema->toJson(); // Restore from JSON $schema = DocumentSchema::fromJson($json);
Compile to HTML
use PdfStudio\Laravel\Builder\Compiler\SchemaToHtmlCompiler; $html = app(SchemaToHtmlCompiler::class)->compile($schema); // Returns full HTML document ready to pass to PdfBuilder
Export to Blade
use PdfStudio\Laravel\Builder\Exporter\BladeExporter; $blade = app(BladeExporter::class)->export($schema); // Returns a Blade template string with @foreach loops for tables file_put_contents(resource_path('views/pdf/invoice.blade.php'), $blade);
Live Preview API
POST /pdf-studio/builder/preview
Content-Type: application/json
{
"schema": { ...DocumentSchema JSON... },
"format": "html" // or "pdf"
}
SaaS: Hosted Rendering API
Requires:
PDF_STUDIO_SAAS=truein.envand migrations.
Enable in .env:
PDF_STUDIO_SAAS=true
Issue an API Key
use PdfStudio\Laravel\Models\Workspace; use PdfStudio\Laravel\Models\ApiKey; $workspace = Workspace::create(['name' => 'Acme Corp', 'slug' => 'acme']); $generated = ApiKey::generate(); // ['key' => '...64 chars...', 'prefix' => '...8 chars...'] 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 ]); // Give $generated['key'] to your customer — it cannot be retrieved again
Revoke a key:
$apiKey->revoke();
Render Endpoints
All endpoints require:
Authorization: Bearer <raw_api_key>
Sync — immediate PDF response:
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 and options:
{
"view": "pdf.invoice",
"data": {"invoice": {"id": 42, "total": 1200}},
"options": {"format": "A4", "landscape": false},
"driver": "chromium"
}
Async — queue a render job:
curl -X POST https://yourapp.com/api/pdf-studio/render/async \ -H "Authorization: Bearer sk_abc123..." \ -d '{ "view": "pdf.report", "data": {"month": "January"}, "output_path": "reports/jan.pdf", "output_disk": "s3" }' # → 202 {"id": "uuid-...", "status": "pending"}
Poll job status:
curl https://yourapp.com/api/pdf-studio/render/{uuid} \
-H "Authorization: Bearer sk_abc123..."
# → {"id": "...", "status": "completed", "bytes": 14200, "render_time_ms": 312.5}
Status values: pending | completed | failed
SaaS: Usage Metering
Record usage events with idempotency (safe to call multiple times for the same job):
use PdfStudio\Laravel\Contracts\UsageMeterContract; $meter = app(UsageMeterContract::class); $meter->recordRender( workspaceId: $workspace->id, jobId: $job->id, // idempotency key — won't double-count bytes: $result->bytes, renderTimeMs: $result->renderTimeMs, );
Each recordRender call dispatches a BillableEvent. Hook into it to integrate your billing provider:
// In AppServiceProvider::boot() use PdfStudio\Laravel\Events\BillableEvent; Event::listen(BillableEvent::class, function (BillableEvent $event) { // $event->workspaceId // $event->eventType (e.g. 'render') // $event->quantity (always 1 per render) // $event->metadata (['bytes' => ..., 'render_time_ms' => ...]) Stripe::meterEvent('pdf_render', [ 'stripe_customer_id' => Workspace::find($event->workspaceId)->stripe_id, 'value' => $event->quantity, ]); });
Query raw usage:
$records = $meter->getUsage($workspace->id, now()->startOfMonth(), now()->endOfMonth()); $summary = $meter->getSummary($workspace->id, now()->startOfMonth(), now()->endOfMonth()); // ['render' => 1432]
SaaS: Analytics
Query render stats for a workspace over a date range:
use PdfStudio\Laravel\Contracts\AnalyticsServiceContract; $analytics = app(AnalyticsServiceContract::class); $stats = $analytics->getStats( workspaceId: $workspace->id, from: now()->startOfMonth(), to: now()->endOfMonth(), ); // [ // 'total' => 1432, // 'completed' => 1418, // 'failed' => 14, // 'avg_render_time_ms' => 287.3, // 'total_bytes' => 48392104, // ]
PDF Merging
Merge multiple PDFs into a single document. 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 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');
Watermarking
Add text or image watermarks to rendered PDFs. Requires setasign/fpdi.
// Text watermark 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 $result = Pdf::watermarkPdf(file_get_contents('existing.pdf')) ->text('CONFIDENTIAL') ->opacity(0.5) ->rotation(-30) ->apply();
Password Protection
Protect PDFs with user/owner passwords. Requires mikehaertl/php-pdftk.
// Set both 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. Requires mikehaertl/php-pdftk.
// Fill 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']
Livewire / Filament
Download PDFs from Livewire components without Livewire intercepting the response:
// In a Livewire component action public function downloadInvoice() { return Pdf::view('invoices.show') ->data(['invoice' => $this->invoice]) ->livewireDownload('invoice.pdf'); }
Get base64 content for embedding:
$result = Pdf::html('<h1>Report</h1>')->render(); $base64 = $result->toBase64(); // or $result->base64()
Render Caching
Cache rendered PDFs to avoid re-rendering identical content:
// 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();
Configure defaults in config:
// 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
Auto-Height Paper
Automatically size the paper height to fit content (no page breaks):
// 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();
Supported by all drivers (Chromium, dompdf, wkhtmltopdf).
Header/Footer Per-Page Control
Control header and footer visibility on specific pages (Chromium and wkhtmltopdf):
// Hide header on the first page (e.g., cover page) Pdf::view('report') ->headerExceptFirst() ->download('report.pdf'); // Hide footer on the 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 header/footer from specific pages Pdf::view('report') ->headerExcludePages([1, 5]) ->footerExcludePages([1]) ->download('report.pdf');
Diagnostics
Run a health check on your PDF Studio installation:
php artisan pdf-studio:doctor
Checks: PHP version, memory limit, Node.js, dompdf, wkhtmltopdf, pdftk, FPDI, Tailwind binary, and performs a test render.
Configuration Reference
// config/pdf-studio.php return [ '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'], ], 'logging' => [ 'enabled' => env('PDF_STUDIO_LOGGING', false), 'channel' => null, ], '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
composer test # run all tests composer analyse # PHPStan level 6 composer lint # Laravel Pint
PdfFake (Testing Assertions)
Use Pdf::fake() in tests for fluent assertions without real PDF rendering:
use PdfStudio\Laravel\Facades\Pdf; it('generates an invoice PDF', function () { $fake = Pdf::fake(); // ... trigger the code that generates a PDF ... $fake->assertRendered(); $fake->assertRenderedView('invoices.show'); $fake->assertRenderedCount(1); $fake->assertDownloaded('invoice.pdf'); $fake->assertSavedTo('invoices/inv-001.pdf', 's3'); $fake->assertDriverWas('chromium'); $fake->assertContains('Invoice'); $fake->assertMerged(); $fake->assertMergedCount(2); $fake->assertWatermarked(); $fake->assertProtected(); $fake->assertNothingRendered(); });
Or use the fake driver directly:
config(['pdf-studio.default_driver' => 'fake']);
Documentation
The README covers the full API surface. For deeper guidance see the User Guide, which includes:
- Page layout examples (paper sizes, margins, headers/footers, multi-column)
- Framework integration guides — Livewire, Vue 3, React, Node.js, vanilla JavaScript
- Troubleshooting — Tailwind class issues, image paths, custom fonts, page breaks, driver differences
License
MIT — see LICENSE.