jeffersongoncalves/filament-action-export
Export Filament tables to CSV, XLSX, and PDF with preview, print support, and full customization. Adds a bulk export action to your tables, with optional config, view, and translation publishing. Supports Filament 3.x, 4.x, and 5.x.
1. Match the version to your stack before anything else. This package has three parallel branches and they are not interchangeable:
| Package | Filament | PHP |
|---|---|---|
^1.0 |
3.x | ^8.1 |
^2.0 |
4.x | ^8.2 |
^3.0 |
5.x (Livewire 4) | ^8.2 |
composer require jeffersongoncalves/filament-action-export "^3.0"
If you plan to offer PDF, install the driver yourself — the package assumes barryvdh/laravel-dompdf is present:
composer require barryvdh/laravel-dompdf
2. Publish only what you actually intend to change. Config is worth publishing on day one; views and translations can wait until a stakeholder asks for something specific.
php artisan vendor:publish --tag=filament-action-export-config
3. Where to look first. Open config/filament-action-export.php immediately after publishing. It is the fastest way to understand the package's surface area — format list, default format, pdf_driver, csv_delimiter, chunk_size, time_format, the four disable_* flags, and the icon map. Every one of those keys has a fluent per-action equivalent, so the config doubles as an API index.
4. First use case — a bulk export on one resource. Do not start with the header action; the bulk action is the smaller blast radius and lets you validate the modal, preview, and file output against a handful of selected rows.
use JeffersonGoncalves\FilamentExportAction\Actions\FilamentExportBulkAction;
use JeffersonGoncalves\FilamentExportAction\Enums\ExportFormat;
->bulkActions([
FilamentExportBulkAction::make('export')
->formats([ExportFormat::Csv, ExportFormat::Xlsx, ExportFormat::Pdf])
->defaultFormat(ExportFormat::Xlsx)
->excludeColumns(['password', 'remember_token']),
])
Ship that, click it on three records, open the XLSX, then decide whether you need the header action, filter/search propagation, or PDF branding. Most resources in a typical admin panel need the bulk action only.
Decide bulk vs. header per resource, not per panel. The two actions are genuinely different operations:
FilamentExportBulkAction |
FilamentExportHeaderAction |
|
|---|---|---|
| Record set | User-selected rows | Entire table query |
| Filters/search/sort | N/A | Opt-in via withFilters() / withSearch() / withSort() |
| Query shaping | Not applicable | modifyQueryUsing() |
| Typical use | "Send me these 40 invoices" | "Give me the full report" |
A resource that needs both can declare both, but resist the urge to add a header action to every table — one unbounded export on a large table is enough to make a support ticket.
Centralise your export configuration. Because both actions expose an identical fluent API (single-sourced through the InteractsWithExportAction trait as of 3.7.0), you can extract a shared builder and keep your resource classes clean:
// app/Filament/Concerns/HasStandardExport.php
protected static function standardExport(): FilamentExportBulkAction
{
return FilamentExportBulkAction::make('export')
->formats([ExportFormat::Csv, ExportFormat::Xlsx])
->defaultFormat(ExportFormat::Xlsx)
->excludeColumns(['password', 'remember_token', 'created_at'])
->timeFormat('Y-m-d')
->csvDelimiter(';');
}
Resources then add only their resource-specific deviations. When the finance team asks for a new column, you change one file.
Use formatStates() for human-facing output rather than mutating Filament columns. Table columns are optimised for the UI (badges, icons, tooltips); the export needs plain, unambiguous text. Keeping that translation in formatStates() means you never compromise the table just to fix the spreadsheet:
->formatStates([
'status' => fn ($value) => match ($value) {
'active' => 'Active',
'inactive' => 'Inactive',
default => (string) $value,
},
'created_at' => fn ($value) => Carbon::parse($value)->format('d/m/Y'),
'amount' => fn ($value) => number_format((float) $value, 2, ',', ''),
])
Note the deliberate use of machine-parseable numbers and locale-consistent dates — a spreadsheet that mixes 1,234.50 and 1.234,50 is worse than no export.
Treat additionalColumns() as the audit-trail hook. This is the pattern that earns the package its keep in regulated environments — an export should carry provenance, not just data:
->additionalColumns([
AdditionalColumn::make('exported_at')->defaultValue(now()->format('d/m/Y H:i')),
AdditionalColumn::make('exported_by')->defaultValue(auth()->user()->name),
])
Combined with ->extraViewData(fn () => ['companyName' => 'Acme Corp']), the same metadata lands in the PDF header and the print view, so the PDF and the CSV tell the same story.
For quick "download everything" buttons, use directDownload(). The modal with format picker, column checkboxes, and preview is the right default for ad-hoc exports, but for a fixed reporting action (say, a nightly-snapshot style button) skipping the modal removes three clicks and a class of user error:
FilamentExportHeaderAction::make('download')
->directDownload()
->formats([ExportFormat::Csv])
->disableFileName()
->modifyQueryUsing(fn ($query) => $query->where('active', true))
Namespace your filenames. fileNamePrefix() and timeFormat() exist precisely so that a folder of downloads stays legible. A prefix like users plus ->timeFormat('Y_m_d-H_i') gives users-2025-03-14-09-30.xlsx, which sorts naturally and never collides:
->fileNamePrefix('users')
->timeFormat('Y_m_d-H_i')
If your panel is multi-tenant, consider the tenant key in the prefix instead of relying on the user to type a filename — and pair it with disableFileName() so they cannot accidentally produce report.xlsx for the fifth time.
Scope the header action's query, don't rely on the table. withFilters() propagates whatever the user has clicked, which is not the same as a permission boundary:
->withFilters()
->withSearch()
->withSort()
->modifyQueryUsing(fn ($query) => $query->where('tenant_id', filament()->getTenant()?->id))
->modifyQueryUsing(fn ($query) => $query->with(['customer', 'assignedTo'])) // eager-load for export
The modifyQueryUsing() calls stack, which makes a chain of small, independently testable scoping clauses the idiomatic style.
Preview and print are a workflow, not two features. The preview modal is where the user validates the export before committing; print is the "hand this to someone in a meeting" escape hatch. If you disable either via config or ->disablePreview() / ->disablePrint(), do it deliberately — on wide tables, the preview table is often the only place a user notices a 40-column export.
Reach for the writer callbacks only when the high-level API runs out. They are the documented extension point, and they're a clean seam — but they operate on SimpleExcelWriter / the DomPDF instance, so they tie your resource to third-party APIs and to the exact PDF driver. Reserve them for genuine requirements (password-protected workbook, suppressing DomPDF warnings) and leave vendor-specific logic out of resources that might switch to Snappy later.
PDF is the fragile format — plan for it explicitly. DomPDF is pure PHP and deploys anywhere; Snappy shells out to wkhtmltopdf, which means binary installation, font availability, and a different rendering engine between dev and prod. If you enable ->snappy() locally but not in the container, you'll get an export that works on your laptop and 500s in CI. Prefer the global config flag (use_snappy) over per-action ->snappy() calls so there is one place to audit, and remember the changelog's precedence rule: explicit ->pdfDriver() / ->snappy() beats global config, so a stray per-action call silently overrides your config.
Pin a minimum of 3.2.0 — or better, 3.7.0. Changelog archaeology matters with a package this young:
withFilters() / withSearch() / withSort() actually started affecting the query. On 3.0–3.1 those methods are declared but non-functional, which is the worst kind of bug: no error, wrong data.InteractsWithExportAction, so if you've built subclasses or traits around the action, do that work after the upgrade.Constrain in composer.json ("^3.7") rather than floating, and add an integration test that clicks the export and asserts a non-empty file.
There is no plugin registration step. FilamentExportPlugin was removed in 3.1.0 — if you're following a v2 tutorial or an older README snippet, the ->plugin() call will fail. Actions are configured entirely per-resource.
Closures don't survive into config or serialised state. formatStates(), extraViewData(), modifyQueryUsing(), and fileNameUsing() all take closures, which means they must live in PHP (your resources or a shared trait) — never in the published config file. If a closure captures $this or a model instance, be aware it runs inside a Livewire request; keep them pure and derived from the action/query, not from request-time state you haven't validated.
Watch memory and N+1 on relation columns. chunk_size defaults to 1000 and is your primary lever for large exports. But chunking doesn't help if each row triggers a lazy load on customer.name — always eager-load through modifyQueryUsing(fn ($q) => $q->with([...])). For genuinely large datasets, note that the package generates the file synchronously within the Livewire request; there is no built-in queued export. If a resource can exceed a few tens of thousands of rows, build your own queued job rather than letting the export action time out mid-download.
Filename behaviour surprises people. There's a prefix by default, disableFileName() hides the input, disableFileNamePrefix() removes just the prefix, and fileNameUsing() overrides everything. Read those four as a set; teams routinely combine disableFileNamePrefix() with a manual prefix and end up with users-users-...xlsx. ->fileName('my-report') sets the name, not the full filename — the timestamp suffix still applies unless you control it with timeFormat().
CSV delimiter is a locale decision, not a style one. In locales where the comma is the decimal separator, Excel expects ;. Set ->csvDelimiter(';') for those tenants/panels; otherwise a "CSV" that opens as a single column in Excel will be reported as a bug against your panel, not against the user's locale settings.
Authorization lives outside the action. The bulk action honours the selected rows and the header action honours the (optionally filtered) query, but neither evaluates a policy. Whatever gate you rely on for the table — policy scoping, tenant global scopes, modifyQueryUsing() filters — must also constrain the export, because an export button that reveals rows the table would hide is a data leak with a friendly icon.
Config caching and published views. After publishing, run php artisan config:cache and confirm your overrides survive — and remember the published views live at resources/views/vendor/filament-action-export/ (pdf.blade.php, print.blade.php, components/table-view.blade.php). Once published, upstream template fixes in later releases will no longer reach you; if you publish views for branding, diff them on upgrade.
Translations are broad but shallow. Nine locales ship (en, pt_BR, es, fr, de, it, nl, ar, tr). Arabic implies RTL, so verify the published PDF/print templates actually render right-to-left before advertising Arabic support — the export table is plain HTML and RTL correctness is on your template, not on the package's translation files.
Budget for the ecosystem risk. Eight stars, zero dependents, and a Filament-major-version release cadence that resets the API surface every cycle. That is fine for an internal admin panel — the code is MIT and the logic is small enough to fork — but treat it as a dependency you own: pin versions, keep an integration test around the export path, and avoid building your own abstractions on top of the action's internals. The trait extraction in 3.7.0 is a good sign, but the package is still pre-1.0 in ecosystem terms.
Test the parts you control. You can't meaningfully unit-test DomPDF output, but you can test your formatStates() closures as plain functions, assert both actions are registered on a resource's table, and run one end-to-end test that exercises the bulk action over three factory records and asserts the download contains three data rows. That test is what turns a 3.1 → 3.2-style silent behaviour change into a red build instead of a wrong spreadsheet.
How can I help you explore Laravel packages today?