occtherapist/advanced-table-export-for-filament
Export Filament admin tables to CSV, XLSX, PDF, JSON, XML, or clipboard. Header and bulk actions export filtered, sorted, searched data or selected rows, with column selection, preview, flexible PDF drivers, and German/English UI. Supports Filament v4/v5 and Laravel 11–13.
Install and wire it up
composer require occtherapist/advanced-table-export-for-filament:^1.2
php artisan vendor:publish --tag="advanced-table-export-for-filament-config"
php artisan vendor:publish --tag="advanced-table-export-for-filament-assets" # if the README lists assets
If the package ships a Filament plugin object, register it once in your panel provider; otherwise the table action is enough:
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel->plugins([
\OccTherapist\AdvancedTableExport\AdvancedTableExportPlugin::make(),
]);
}
Then add the export action to a resource table — this is the minimum viable first use case:
use Filament\Tables\Table;
use OccTherapist\AdvancedTableExport\Actions\ExportAction; // verify namespace in README
public static function table(Table $table): Table
{
return $table
->columns([...])
->headerActions([
ExportAction::make()
->formats(['csv', 'xlsx', 'pdf']),
]);
}
Mind the new default in v1.2: the export modal now has an "additional columns" UI. End users can add their own columns (a title plus a default value) at export time. It's on unless disabled. Decide on day one whether that's acceptable for each action — for a human-facing "dump this filtered list" export it's genuinely useful; for anything whose output feeds an import (accounting, ETL, a customer's CSV spec) turn it off with ->disableAdditionalColumns() so the schema stays fixed.
Where to look first
config/advanced-table-export-for-filament.php after publishing — this is where default formats, disk, filename pattern, and PDF driver live. Read it before writing any code; most "why does it do that?" questions are answered there.vendor/occtherapist/advanced-table-export-for-filament/src/) — the fluent API is the documentation. README covers the happy path; the source covers the modifiers. In 1.2 that means the new additionalColumns*() helpers: disableAdditionalColumns(), additionalColumnsFieldLabel(), additionalColumnsTitleFieldLabel(), additionalColumnsDefaultValueFieldLabel(), additionalColumnsAddButtonLabel().composer update. v1.2 also wired up the previously dead disable_additional_columns config key, so a value you set months ago and forgot about now takes effect.First use case to ship: an "Export CSV/XLSX" header action on one high-traffic resource (orders, users, invoices) so an admin can dump the currently filtered list. Resist adding PDF on day one — see the PDF gotchas below.
Header action vs. bulk action. Header actions export "what the table currently shows" (filters + search applied); bulk actions export "what the user ticked". Use both, but be explicit in the label: Export filtered results vs Export selected. If the package's bulk variant doesn't respect the current filter, pass your own query modifier rather than reusing the table query blindly.
Respect the active filter. The single most common support ticket with export packages is "the file has every row." Always verify the action is reading the Livewire table's filtered query, and when in doubt be explicit:
ExportAction::make()
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', filament()->getTenant()->id))
Use this hook for tenancy, soft-delete scoping, and permission narrowing — never rely on the table's default query alone for data leaving the building.
Column selection is a feature, not a default. Turn on the column picker so finance gets total, tax, currency and support gets id, customer, status, created_at from the same table. Map table columns to export headers deliberately:
ExportAction::make()
->columns([
TextColumn::make('invoice_number')->label('Invoice #'),
TextColumn::make('customer.name')->label('Customer'),
TextColumn::make('total')->label('Total')->formatStateUsing(fn ($state) => number_format($state / 100, 2)),
TextColumn::make('created_at')->label('Created')->formatStateUsing(fn ($state) => $state?->toDateTimeString()),
])
Format at export time rather than relying on the Filament column's display formatter — currency symbols, diffForHumans(), and badge colors do not belong in a CSV that someone will pivot in Excel. Emit raw-ish values (ISO dates, numeric amounts) and let the spreadsheet do formatting.
End-user additional columns (v1.2). The new modal lets the requester append their own title => default value pairs to the export. Set intent explicitly per action rather than inheriting the global default:
ExportAction::make()
// human-facing ad-hoc export: allow it, but localise the UI
->additionalColumnsFieldLabel(__('export.additional_columns'))
->additionalColumnsTitleFieldLabel(__('export.column_title'))
->additionalColumnsDefaultValueFieldLabel(__('export.default_value'))
->additionalColumnsAddButtonLabel(__('export.add_column'));
ExportAction::make()
// fixed schema consumed by an importer: never allow it
->disableAdditionalColumns();
Good uses: a constant "Prepared by / Cost centre / Report date" column, a blank column for offline annotation, a batch reference tying the file to a ticket. Bad uses: anything where the column set is contractual. Two rules of thumb: (a) validate the title server-side (trim, length cap, strip newlines, de-duplicate against existing column headers), and (b) treat the values as untrusted user input, because they are — they land in CSV cells and in PDF HTML (see gotchas). If you need per-tenant defaults rather than free text, wrap the action in your own class and inject fixed columns there instead of exposing the UI.
Eager-load everything the export touches. Relationship columns (customer.name, items.count) inside a loop over 50k rows is an N+1 storm that ends in a 504.
->modifyQueryUsing(fn (Builder $q) => $q->with(['customer:id,name', 'assignee:id,name']))
Large exports: don't do them in the request. If the package offers chunking/streaming, use it. If not, wrap it: put a "Request export" action that dispatches a queued job writing to a private disk, then notify the user (database notification with a signed temporary URL). Anything past ~5–10k rows should be asynchronous, and XLSX/PDF in particular build the whole file in memory.
PDF driver choice. The package advertises "flexible PDF drivers" — decide early:
Pin the driver in config per environment and assert it in a smoke test, otherwise PDF export works locally and 500s in production.
Authorization. Gate the action like any other sensitive operation — exporting is a read of the full dataset and a data-exfiltration vector:
ExportAction::make()
->visible(fn () => auth()->user()->can('export', Order::class))
Also consider rate-limiting export actions (->rateLimit() or a middleware/RateLimiter in the action handler) and logging who exported what — auditors ask. The 1.2 additional-columns UI widens the surface: a user can now shape the payload as well as select it, so keep the export audited and, for sensitive resources, expose a fixed column set only.
Testing. Use Livewire's table action assertions so exports don't silently break on a Filament upgrade:
livewire(ListOrders::class)
->callTableAction(ExportAction::class, data: ['format' => 'csv'])
->assertFileDownloaded();
For 1.2, add a case that submits additional columns through the action data and asserts the header row contains the title exactly once, and that disableAdditionalColumns() yields a schema matching your importer. For queued exports, Queue::fake() and assert the job was pushed with the right filters.
You are on a young package. 5 stars, 0 dependents, first release recent. That's fine — it fills a real gap as the filament-export successor — but it means: pin the exact version in composer.json, commit composer.lock, read the changelog on every bump, and keep the export action behind a thin wrapper class of your own so swapping packages later is a one-file change. Write your formatting logic in your own columns/transformers, not in package callbacks, so it's portable.
v1.2: disable_additional_columns was dead config and is now live. If your published config sets it — even true from a copy-pasted template — the export modal changes behaviour after composer update with no code change of yours. Check the key on upgrade, prefer the per-action ->disableAdditionalColumns() over the global flag so intent lives next to the action, and remember php artisan config:cache after editing it.
CSV / spreadsheet formula injection. Additional-column titles and values are user text written straight into cells. A value starting with =, +, -, @, tab, or CR is executed as a formula when the file is opened in Excel/Sheets (=HYPERLINK(...), =cmd|...). Prefix such cells with ' or a zero-width space, and strip newlines from titles so the header row can't be split. Same class of problem as user-entered data in any export — the new UI just makes it reachable without a developer.
User text in the PDF path. With dompdf, user-supplied strings render as HTML. If remote/file access is enabled for local assets, untrusted content can become local-file read or outbound requests. Restrict isRemoteEnabled, escape additional-column values in your PDF template with e(), and cap title/value length.
Column-title collisions and blank titles. Two additional columns named "Notes", or one named like an existing table column, produce ambiguous files and break naive importers. Trim, reject-empty, length-cap, and de-duplicate titles in your wrapper; do the same for default values (null and '' are not the same in a downstream parser).
Translating the new UI. The default English labels are fine for an internal tool and wrong for a localised admin panel. Use the additionalColumns*FieldLabel() helpers with __() keys — note the value label is additionalColumnsDefaultValueFieldLabel(), which is easy to mistype as ...ValueFieldLabel().
Filament v4 vs v5 API drift. The package claims both. Action namespaces, ->form() schema vs ->form(), and table action registration changed across Filament majors. If a fluent method doesn't exist, you're probably looking at v4 docs while on v5 — check the installed Filament version first, then the action's source, before assuming the package is broken. The 1.2 modal fields (KeyValue repeater-style) render differently between the two majors; verify the modal on your actual Filament version, including the "add" button label.
Filters silently ignored. Confirm behaviour on day one with a test: apply a filter, export, assert row count. Do this for search, filter, and sort. This one bug class accounts for most of the "wrong data exported" reports.
XLSX memory and time limits. Excel output holds rows in memory. Bump memory_limit for the queue worker, chunk the query, and set max_execution_time deliberately. If a user can export an unbounded table, they will — impose a max row cap and surface it in the UI ("Export limited to 50,000 rows; narrow your filters").
PDF: fonts, UTF-8, and page size. Non-Latin characters (Cyrillic, Arabic, CJK) render as boxes with dompdf unless you register a Unicode font. Landscape A4 is usually the only way a wide admin table fits — and the additional-columns feature lets users widen it further at runtime, so test with a user-added column present. Test with real data containing an emoji and a long name; the layout breaks there, not in your fixtures.
Clipboard format needs HTTPS. navigator.clipboard is unavailable on plain HTTP and in some embedded browsers; the clipboard button silently does nothing. Ship CSV download as the fallback.
Config caching. After publishing config, remember php artisan config:cache in deploy. If the package reads config at boot for the PDF driver or the additional-columns flag, a stale cache is a classic "why is it still using dompdf / why did the column UI vanish" cause.
Filename collisions and disks. Customize the filename pattern with a timestamp and tenant/ID (->fileName(fn () => "orders-{$tenant}-" . now()->format('Ymd-His'))) or you'll get orders (1).xlsx in every download folder. Don't interpolate user-supplied additional-column titles into filenames — sanitise anything you do include. For queued exports, confirm the disk is private and the download URL is signed and expiring — public S3 exports of customer data are a breach, not a bug.
Extension points worth knowing. Before writing custom code, check the source for: a modifyQueryUsing-style hook, per-format writers/exporters (to register a custom format), a filename callback, a column-mapping callback, the new additionalColumns*() label/disable modifiers, and events fired before/after export. Hook the "after export" event for audit logging rather than wrapping the whole action — and log the additional columns included, since they change what left the system.
Upgrade checklist for this package specifically (1.1 → 1.2 is additive, but do these): run your export tests; open one export of each format in a real viewer (Excel, Acrobat, Word); open the export modal in a browser to confirm the new additional-columns UI is present or absent as intended; grep the published config for disable_additional_columns; and re-check the config file against the new defaults. Packages at this maturity often ship breaking config-key renames in minor releases — this one shipped a key that finally works, which is its own kind of surprise.
How can I help you explore Laravel packages today?