PDF export · Open source
Record PDF
Preview, download, email, save or bulk-download as a ZIP any Filament 4 or 5 record as a PDF from your own Blade view, rendered with the pure-PHP dompdf driver by default, so no Chromium is needed.
What it solves
Sooner or later someone asks for an invoice, a packing slip or a record summary as a PDF.
In a Filament app that usually means a hand-written controller and route per resource, a headless Chrome on the server, and file names built from record data that nobody checks before they reach the disk.
Record PDF adds five actions that turn a record into a PDF through a Blade view you write once per resource: PreviewPdfAction, DownloadPdfAction, EmailPdfAction and SavePdfAction for rows and page headers, and BulkDownloadPdfAction for the table toolbar. The view gets $record plus anything you return from pdfData(); without a view, a built-in one lists the record's visible attributes. Rendering goes through spatie/laravel-pdf with the plugin's own driver setting, dompdf by default, so the server needs no Chromium or Node; any other laravel-pdf driver can be chosen in config or per action. Preview shows the PDF in an iframe inside a modal, served from the cache behind a signed URL only the same user can open. Email sends the PDF as an attachment to up to 10 addresses, in the request or queued. Save writes it to a disk, optionally storing the path on the record, or into a media library collection. Bulk download zips the selected records in the request, or above 25 records builds the ZIP on the queue and sends a database notification with a signed link. Each record is checked against its model's policy, and file names and save directories are cleaned so record data cannot become a path. An independent plugin, not made or endorsed by Filament.
- Five actions: Preview PDF, Download PDF, Email PDF and Save PDF for table rows and View or Edit page headers, and Download PDFs (ZIP) for the table toolbar
- One Blade view per resource, receiving $record and the array from pdfData(); a built-in view lists the record's attributes without the hidden ones
- php artisan make:record-pdf-view with a record or invoice starter view, and the invoice preset usable directly as filament-record-pdf::presets.invoice
- dompdf by default, with no Chromium or Node; browsershot, chrome, gotenberg, cloudflare or weasyprint from spatie/laravel-pdf in config or per action with pdfDriver()
- Paper format (A4 by default) and landscape per action, and modifyPdfUsing() for any laravel-pdf builder option such as margins
- File names from a string or closure, cleaned to lowercase letters, digits and dashes with .pdf added; the default is the model name and key, such as invoice-42.pdf
- Preview in an iframe in a modal, rendered once and kept in the cache for 10 minutes behind a signed URL that only the user who opened it can use, with a download button that reuses the same file
- Email to up to 10 addresses with a subject and message, sent in the request or queued, or through your own sendUsing() closure
- Save to a disk and directory with a visibility, optionally into a column on the record, or into a spatie/laravel-medialibrary collection; overwrite(false) keeps the old file
- Bulk ZIP built in the request for up to 25 records; above that, on the queue with a database notification and a signed download link valid for 24 hours; at most 500 records per download
- Per-record policy checks (viewPdf, downloadPdf, emailPdf, savePdf, falling back to view or update), with pdfAbility() and authorizePdfUsing() to change them
- Save directories must be relative to the disk root; absolute paths, drive letters, URLs and . or .. segments throw before anything is written
Install
Requires PHP 8.2+, Filament 4 and 5, and Laravel 12 and 13.
composer require amjadiqbal/filament-record-pdf
Usage
use AmjadIqbal\FilamentRecordPdf\Actions\BulkDownloadPdfAction;
use AmjadIqbal\FilamentRecordPdf\Actions\DownloadPdfAction;
use AmjadIqbal\FilamentRecordPdf\Actions\EmailPdfAction;
use AmjadIqbal\FilamentRecordPdf\Actions\PreviewPdfAction;
use AmjadIqbal\FilamentRecordPdf\Actions\SavePdfAction;
use Filament\Actions\ActionGroup;
public static function table(Table $table): Table
{
return $table
->columns([/* ... */])
->recordActions([
PreviewPdfAction::make()->pdfView('pdf.invoice'),
ActionGroup::make([
DownloadPdfAction::make()->pdfView('pdf.invoice'),
EmailPdfAction::make()->pdfView('pdf.invoice')
->defaultRecipient(fn (Invoice $record) => $record->customer_email),
SavePdfAction::make()->pdfView('pdf.invoice')->saveToColumn('pdf_path'),
]),
])
->toolbarActions([
BulkDownloadPdfAction::make()->pdfView('pdf.invoice'),
]);
}
Setup
There is no panel plugin to register: the service provider is discovered automatically and registers the config, views, translations, the make:record-pdf-view command and two routes (the preview file and the queued ZIP download). spatie/laravel-pdf and dompdf/dompdf 3.1.6 or newer are installed with the plugin, so the dompdf driver needs nothing else. There are no assets to publish and no migrations. The bulk ZIP action needs the PHP zip extension.
- 1 composer require amjadiqbal/filament-record-pdf
- 2 php artisan make:record-pdf-view invoice --preset=invoice (creates resources/views/pdf/invoice.blade.php)
- 3 Add the actions to the resource table's recordActions() and toolbarActions(), or to getHeaderActions() on a View or Edit page, with ->pdfView('pdf.invoice').
- 4 Optional: php artisan vendor:publish --tag="filament-record-pdf-config" to change the driver, paper, disk or limits.
- 5 For queued bulk ZIPs: ->databaseNotifications() on the panel and a running queue worker.
The Blade view
Each action renders view(pdfView, ['record' => $record] merged with pdfData()) to HTML and hands it to spatie/laravel-pdf. pdfView() and pdfData() take a value or a closure that receives the record.
- Without pdfView(), the config's default_view is used: filament-record-pdf::default, a table of the record's attributesToArray() (so hidden attributes are left out). It also reads optional $title and $attributes from pdfData().
- make:record-pdf-view {name} --preset=record|invoice copies a starter view into resources/views/pdf (a name like orders/packing-slip makes a subfolder) and refuses to overwrite an existing file without --force.
- The invoice preset expects an $invoice array (number, date, due, currency, from, to, items with description, quantity and unit_price, tax_rate, notes, logo) and works out the subtotal, tax and total itself.
- With dompdf, lay pages out with tables rather than flexbox or grid, and use the bundled DejaVu Sans font for non-Latin text. spatie/laravel-pdf's @pageBreak directive is available.
- Remote images and stylesheets are off with dompdf unless you set LARAVEL_PDF_DOMPDF_REMOTE_ENABLED in spatie/laravel-pdf's config; the plugin does not change it. Local files are limited by dompdf's chroot, which LARAVEL_PDF_DOMPDF_CHROOT sets.
DownloadPdfAction::make()
->pdfView('pdf.invoice')
->pdfData(fn (Invoice $record) => [
'invoice' => [
'number' => $record->number,
'date' => $record->issued_at->toDateString(),
'currency' => 'USD',
'items' => $record->lines->map(fn ($line) => [
'description' => $line->description,
'quantity' => $line->quantity,
'unit_price' => $line->unit_price,
])->all(),
'tax_rate' => 10,
],
]);
Shared options
All five actions take the same options, so one view and one file-name rule cover every action on a resource. Each option takes a value or a closure that receives the record.
- pdfFilename(): the name without a directory. It is converted to ASCII, slugged to lowercase letters, digits and dashes, cut to 150 characters, and given .pdf, so "Invoice INV-1042" becomes invoice-inv-1042.pdf and ../../.env cannot become a path. The default is the kebab-case model name and key, such as invoice-42.pdf.
- pdfFormat(): any paper format laravel-pdf accepts, such as a4 (the default), letter, legal, a3 or a5. pdfLandscape() turns the page; portrait is the default.
- pdfDriver(): the laravel-pdf driver for this action. The default comes from the plugin's own driver setting (RECORD_PDF_DRIVER, dompdf), not from laravel-pdf's default.
- modifyPdfUsing(fn (PdfBuilder $pdf, Model $record) => ...): change the builder before rendering, for example margins or Browsershot options.
- pdfAbility() and authorizePdfUsing(): see Who can use it.
DownloadPdfAction::make()
->pdfView('pdf.invoice')
->pdfFilename(fn (Invoice $record) => "Invoice {$record->number}")
->pdfFormat('letter')
->pdfLandscape()
->modifyPdfUsing(fn (PdfBuilder $pdf) => $pdf->margins(10, 10, 10, 10));
Preview and download
PreviewPdfAction opens a wide modal headed with the file name and shows the PDF in an iframe, with a "PDF not showing? Open it in a new tab" link under it. DownloadPdfAction renders the PDF and streams it as a download in the same request.
- The preview PDF is rendered once per record and request and stored in the cache (cache_store, the default store when empty) for preview_ttl minutes, 10 by default.
- The iframe and the modal's Download PDF button use two temporary signed URLs to the same cached file, one inline and one as an attachment. They expire with the cache entry and return 403 to any user other than the one who opened the preview.
- The file is sent with Cache-Control: private, no-store and may only be framed by the same site.
- ->downloadButton(false) removes the modal's download button.
EmailPdfAction opens a form headed "Email" plus the file name, with To, Subject and Message. The PDF is rendered when the form is sent and attached to a RecordPdfMail with an HTML and a plain-text body.
- To takes 1 to 10 addresses, separated by commas, semicolons or spaces; duplicates are dropped and each address is validated.
- Subject (up to 200 characters) defaults to the file name; Message is optional, up to 5000 characters. defaultRecipient(), defaultSubject() and defaultMessage() prefill them per record.
- The mail is sent in the request by default. queueMail(), or mail.queue in the config, queues it instead; the PDF is still rendered in the request and travels in the queued message.
- sendUsing(fn (array $recipients, RecordPdfMail $mail, Model $record, array $data) => ...) replaces the sending, for example to use another mailer.
- A success notification says the file name and how many recipients it was sent to.
EmailPdfAction::make()
->pdfView('pdf.invoice')
->defaultRecipient(fn (Invoice $record) => $record->customer_email)
->defaultSubject(fn (Invoice $record) => "Invoice {$record->number}")
->defaultMessage('Please find your invoice attached.')
->queueMail();
Save
SavePdfAction asks for confirmation ("Generate the PDF now and store it with this record."), renders the PDF, writes it and shows the saved path in a notification.
- disk() defaults to the config's disk (RECORD_PDF_DISK, local), directory() to record-pdfs and visibility() to private.
- The directory must be relative to the disk root. Absolute paths, drive letters, URLs, NUL bytes, . or .. segments and characters other than letters, digits, spaces, dots, dashes and underscores throw an exception before anything is written.
- An existing file is overwritten by default. With overwrite(false) the old file is kept and the new one gets a -Ymd-His suffix.
- saveToColumn('pdf_path') stores the saved path on the record. afterSaving(fn (Model $record, string $path, string $disk) => ...) runs after the file is written.
- toMediaCollection('pdfs') saves into a spatie/laravel-medialibrary collection instead; the model must implement HasMedia, and medialibrary is not installed by the plugin. disk(), directory(), visibility() and overwrite() do not apply to it.
SavePdfAction::make()
->pdfView('pdf.invoice')
->disk('s3')
->directory(fn (Invoice $record) => 'invoices/'.$record->issued_at->year)
->saveToColumn('pdf_path')
->overwrite(false);
Bulk ZIP
BulkDownloadPdfAction ("Download PDFs (ZIP)") checks each selected record, renders one PDF per allowed record and zips them. Names that repeat inside one ZIP get -2, -3 and so on. The ZIP name comes from zipFilename(), else pdfs- plus the date and time.
- More than bulk.max_records (500) selected records are refused with a message. Records the user may not access are skipped, and the user is told how many; if none are allowed, nothing is built. Without the zip extension the action says so.
- Up to queue_after records (25; queueAfter() per action, null never queues, 0 always does), the PDFs are rendered one after another in the request, written to a ZIP in the system temp directory, streamed as the download and the temp file deleted.
- Above that, the Blade views are rendered to HTML in the request and stored on the plugin's disk under zip_directory/<uuid>/html, and a BuildPdfZip job on bulk.connection and bulk.queue renders the PDFs, stores the ZIP in that folder, deletes the HTML and sends a Filament database notification with a Download ZIP button.
- The link is signed, expires after bulk.link_hours (24) and only works for the user who asked. It is built on the address the user was on, not APP_URL.
- The ZIP is built in the request instead when the panel has no database notifications, when nobody is logged in, or when modifyPdfUsing() is set, because a closure cannot be queued.
- The job runs once (no retries) with a 30-minute timeout. If it fails, its folder is deleted and the user gets a notification with the error. Finished ZIPs stay in zip_directory until you delete them, for example with a scheduled task.
BulkDownloadPdfAction::make()
->pdfView('pdf.invoice')
->zipFilename(fn () => 'invoices-'.now()->toDateString())
->queueAfter(25);
Who can use it
Each record is checked against its model's policy. The first ability in the list that the policy defines decides; if the model has no policy, or the policy defines none of them, access to the panel is the only check.
- Preview: viewPdf, downloadPdf, view. Download and bulk download: downloadPdf, view. Email: emailPdf, view. Save: savePdf, update.
- So an existing view() policy method already guards downloads; add downloadPdf() to let users view a record but not download it.
- Preview, download, email and save are hidden on records the user may not use. The bulk action stays in the toolbar and skips those records when it runs.
- pdfAbility([...]) changes the list for one action; authorizePdfUsing(fn (Model $record, ?Authenticatable $user) => bool) replaces the policy check.
DownloadPdfAction::make()
->pdfAbility(['downloadPdf', 'view'])
->authorizePdfUsing(fn (Invoice $record, ?User $user) => $user?->is($record->owner));
Drivers and configuration
The plugin passes the driver name to spatie/laravel-pdf, which does the rendering. Set RECORD_PDF_DRIVER (or driver in the config), or pdfDriver() on one action. Each driver's own options live in laravel-pdf's config/laravel-pdf.php.
- dompdf (default): pure PHP, nothing extra on the server.
- browsershot: spatie/browsershot, Node, Puppeteer and Chromium. chrome: chrome-php/chrome and a Chrome binary.
- gotenberg: a running Gotenberg service. cloudflare: Cloudflare Browser Rendering. weasyprint: pontedilana/php-weasyprint and the WeasyPrint binary.
- Other settings: format and landscape defaults, default_view, disk, directory, zip_directory, visibility, preview_ttl, cache_store, route_prefix (filament-record-pdf), route_middleware (['web']), bulk (queue_after, connection, queue, link_hours, max_records) and mail.queue.
- Translations and views publish with the filament-record-pdf-translations and filament-record-pdf-views tags.
Credits and trademarks
By Amjad Iqbal. PDF rendering is built on spatie/laravel-pdf by Spatie (MIT) and dompdf (LGPL-2.1). This plugin is not affiliated with or endorsed by Spatie. It is also not made or endorsed by Filament.
Hire me
Need this built into your app, or something custom?
I wrote Record PDF. I can wire it into your Filament panel, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.
More Filament plugins
All plugins
RankForge Lite
Adds meta, Open Graph and X fields to any Filament resource, scores the content for SEO and readability as you type, and previews the Google result before you publish.
Change Requests
Holds edits to Filament records as change requests that a reviewer checks field by field, then approves, rejects or rolls back.
Deleted Records
Lists the records spatie/laravel-deleted-models kept when they were deleted, previews their stored attributes, and restores or permanently deletes them.
Stat Cards
Adds progress bars, icon tiles, card and text colours, trends, badges and bar charts to the stats overview widget you already have, by changing one import.
Uptime Monitor
Lists the sites spatie/laravel-uptime-monitor watches with their uptime and certificate status, and lets you add, edit, check, turn on and off and delete them from your panel.
XLSX Import
A drop-in replacement for Filament's ImportAction that also reads Excel (.xlsx) and OpenDocument (.ods) workbooks, with a sheet picker, through the Importer classes you already have.
Billing Console
A free, read-only billing console for apps that bill with Laravel Cashier: customers, subscriptions, invoices and payments, with an MRR and churn summary, in Filament 4 and 5.
Record Settings
A free way to edit per-user, per-team and per-tenant settings in Filament 4 and 5: a settings page, a settings modal on table rows and settings fields inside resource forms, stored with glorand/laravel-model-settings or in a JSON column.
Audience Sync
A free plugin that subscribes, unsubscribes, tags and updates your app's records in an email-marketing audience or list, with queued bulk sync, a status column, list and campaign stats, and consent rules that never resubscribe anyone who opted out, in Filament 4 and 5.
CRM Sync
A free lead sync for your CRM, in your Filament panel: push records as contacts, companies and deals with field mapping in code; manual, bulk and queued sync; the sync status, and the CRM record and its recent activity on each record, in Filament 4 and 5.