XLSX Export by asign
CommunityDownload the current table as a real Excel file: filters, search and sort applied, typed numbers and dates, streamed instantly. No queue, no CSV.
Author:
asign
Package health
Automated checks of this plugin's Composer package
15 checks
- Passed: GitHub Actions pinned to SHA
- Skipped: GitLab CI includes pinned to SHA
- Passed: Open security advisories
- Passed: Dependabot PR responsiveness — No open Dependabot PRs.
- Skipped: Renovate MR responsiveness
- Passed: Dependabot or Renovate configured
- Passed: Dependency update cooldown configured
- Passed: Provides a security policy
- Passed: Abandoned or archived — No consulted source marks the package abandoned (packagist, github).
- Passed: Commit and release recency — Active: last commit 0 days ago; last release 0 days ago.
-
Passed:
composer.lock not committed by library
—
composer.lockis absent from the released dist archive. - Passed: Dist archive is lean
-
Passed:
Current Laravel version supported
—
Package dependencies resolve together with current Laravel
13.0. -
Passed:
Current PHP version supported
—
Constraint
^8.3supports current PHP8.5. - Skipped: Current Symfony version supported
filament/
namespace. Review the source and install at your own risk. Found
malware or an unresolved security issue the author won't
address?
Report it
.
Documentation
- Screenshots
- Requirements
- Installation
- Usage
- Typed cells
- ColumnFormat
- Export options that change the query
- Layout
- Row limit and memory
- Streaming mode
- Typed cells for core exporters
- Configuration
- Gotchas
- Translations
- AI agents
- Testing
- Changelog
- License
"Download what is on the screen" as a real Excel file: the table's current filters, search and sort — or the rows you ticked — streamed straight to the browser, with numbers that are numbers and dates that are dates.
Filament's built-in export is queued, chunked, stored and goes through a CSV first, so every cell of the XLSX ends up as text. People keep asking for the simple version:
- an immediate download without queues — discussion #11950
- numbers, dates and enums as real cells instead of text, because core builds the XLSX from an intermediate CSV — #16233
- export options that change the query — #12519
- select-all in the column picker — #16062
This plugin does exactly that, and keeps core's Exporter usable too (see Typed cells for core exporters).
- Screenshots
- Requirements
- Installation
- Usage
- Typed cells
- ColumnFormat
- Export options that change the query
- Layout
- Row limit and memory
- Streaming mode
- Typed cells for core exporters
- Configuration
- Gotchas
- Translations
- AI agents
- Testing
#Screenshots
The export modal with the column picker:

The downloaded workbook - numbers, dates and booleans are real cells, the total is a bold row (a rendering of the file's cells, not a screenshot of Excel):

#Requirements
- PHP 8.3+
- Filament 5, Laravel 12 or 13
- OpenSpout 4 (installed with the package)
#Installation
composer require asignua/filament-xlsx-export
There are no assets, migrations or panel registration: the package only adds actions. To change the defaults:
php artisan vendor:publish --tag=filament-xlsx-export-config
#Usage
On a list page:
use Asignua\FilamentXlsxExport\Actions\XlsxExportAction;
protected function getHeaderActions(): array
{
return [
XlsxExportAction::make(),
];
}
Selected rows, in the table:
use Asignua\FilamentXlsxExport\Actions\XlsxExportBulkAction;
$table->toolbarActions([
BulkActionGroup::make([
XlsxExportBulkAction::make(),
]),
]);
Both actions open a small modal with the column picker (a checkbox list with select all), ticked with the
columns the table shows right now: columns toggled off and hidden() ones stay out, image columns are never offered.
->chooseColumns(false) skips the modal and downloads at once.
The file is exactly the table's query: filters, search and sort. The bulk action turns the selection (including "select
all" across pages, with its deselections) into a query, never into a loaded collection. Like every core bulk action it honours
->authorizeIndividualRecords() and the table's checkIfRecordIsSelectableUsing(): refused rows are skipped as the file
streams (the row count used for the limits and $rowCount is taken before that check).
XlsxExportAction::make()
->title('Orders') // bold first row, also the sheet name
->footer(fn () => 'Generated '.now()->format('Y-m-d H:i')) // under the data and totals
->caption(fn (array $data, int $rowCount) => "Open orders, {$rowCount} rows")
->fileName(fn () => 'orders-'.now()->format('Y-m-d'))
->rowLimit(10_000)
->columnFormats([...]);
Closures may ask for $data (the modal's values), $livewire and, for caption(), $rowCount.
#Typed cells
| Table value | Cell |
|---|---|
int, float, decimal strings of numeric() / money() columns |
number (money columns get #,##0.00) |
Carbon / DateTimeInterface, strings of date() / dateTime() columns |
Excel date with a number format (yyyy-mm-dd, yyyy-mm-dd hh:mm), shown in the column's timezone |
time() columns |
day fraction with hh:mm |
bool |
TRUE / FALSE |
HasLabel enum |
its label; other backed enums their value |
| arrays, collections, relationship lists | joined with , |
| everything else | text (never a formula, even when it starts with =) |
The value is the column's getState(), so relationships (customer.name), getStateUsing(), accessors and casts work.
formatStateUsing(), prefixes and limits are not applied — you get the typed value. Opt in per column with
ColumnFormat::make()->formatted().
#ColumnFormat
Override a column by its name:
use Asignua\FilamentXlsxExport\ColumnFormat;
XlsxExportAction::make()->columnFormats([
'total' => ColumnFormat::make()->money('€')->sum(), // "€" #,##0.00 and a bold total row
'price' => ColumnFormat::make()->divideBy(100)->decimal(2), // stored in cents
'ratio' => ColumnFormat::make()->percent(1),
'weight' => ColumnFormat::make()->number('0.000'),
'zip' => ColumnFormat::make()->text(), // keeps "00123"
'created_at' => ColumnFormat::make('Created')->date('dd.mm.yyyy')->width(14),
'paid' => ColumnFormat::make()->boolean(),
'internal' => ColumnFormat::make()->exclude(), // never offered
'notes' => ColumnFormat::make()->unselected(), // offered, not ticked
'vat' => ColumnFormat::make('VAT') // virtual column
->value(fn (Order $record, $state, array $data) => $record->total * 0.2)
->decimal(2),
]);
A key that is not a table column and has ->value() is added after the table's columns and appears in the picker.
The value() closure takes $record, $state (what the table column would have given), $data and $livewire.
| Method | Effect |
|---|---|
label() |
header text |
number(?string), integer(), decimal($places), money($symbol, $places), percent($places) |
number formats |
date(), dateTime(), time() |
date formats (Excel format codes) |
text(), boolean() |
force the cell type (boolean() writes FALSE for an empty CSV cell, so a nullable boolean reads FALSE for NULL) |
width() |
column width in characters |
value() |
where the value comes from |
divideBy() |
divide numbers (money stored in cents) |
formatted() |
use the text the table shows |
sum() |
bold total row under the column |
exclude(), unselected() |
picker behaviour |
#Export options that change the query
Add fields to the modal and use them to reshape the query, the file name and the caption:
XlsxExportAction::make()
->exportOptions([
Toggle::make('only_paid')->label('Only paid orders'),
])
->queryUsing(fn (Builder $query, array $data) => ($data['only_paid'] ?? false)
? $query->where('paid', true)
: $query)
->caption(fn (array $data) => $data['only_paid'] ? 'Paid orders' : 'All orders');
The row-limit check runs on the final query.
#Layout
The sheet is: optional title (merged across the columns, bold), optional caption, a blank line when either is there,
the header (bold, grey), the data, a bold total row when a column asks for sum(), and the footer() lines (string,
list or closure) after a blank row.
The header row is frozen, an auto filter covers the data, and every column gets a width (the label length plus padding
within width.min and width.max, or your ->width()). Switch the first two off with ->freezeHeader(false) and
->autoFilter(false), or in the config.
#Row limit and memory
Rows are read with lazy() in chunks (chunk_size, default 500) and written through OpenSpout with inline strings, so
the workbook never exists in memory as a whole. In Livewire mode (up to streaming.above_rows) the limit
row_limit (default 25 000, per action ->rowLimit(n), 0 disables) is checked with a COUNT(*) first; over it the
user sees a notification instead of a download. Livewire still holds that finished file in memory once, which is what
the limit protects. Bigger exports use the streaming mode below.
#Streaming mode
A Livewire action cannot stream: it captures the response and sends it back base64-encoded. So above
streaming.above_rows (default 5 000), or when forced with ->streamed(), the action does something else:
- In the Livewire request it validates the choice, then stores a hand-over in the cache under a random 48-character
token (
streaming.ttlseconds, default 120) and redirects the browser to a temporary signed URL. - That plain HTTP request checks the signature, that the logged-in user is the one the token was issued to, and spends
the token (one download per link). It then streams the workbook to
php://outputwithlazy()— nothing is buffered, so memory stays flat at any size (a test exports 20 000 rows with a memory bound).
XlsxExportAction::make()->streamed(); // always stream
XlsxExportAction::make()->streamed(false); // never; stay in Livewire (bound by row_limit)
A guest (no logged-in user on the panel's guard, e.g. a public table outside any panel) never streams: the link is bound
to the user it was issued to, so such a table always stays in Livewire mode, bound by row_limit.
In streaming mode the config row_limit does not apply; streaming.hard_cap (default 500 000, null = none) does. An
explicit ->rowLimit(n) on the action holds in both modes: when streaming, the lower of it and hard_cap applies.
The download request counts the rows again, so rows added between the click and the download cannot carry the file
past the cap. Grouped, HAVING and UNION queries (for example from queryUsing()) are counted by their result rows,
not by the size of their first group.
Panels with tenancy never stream. Filament scopes a tenant panel's queries through a global scope that does nothing
without a current tenant, and the tenant comes from the page's URL and the tenant middleware — neither of which the
download request has. A streamed file would therefore contain every tenant's rows. So on a panel with ->tenant(...)
the action always stays in Livewire mode (bound by row_limit, even with ->streamed()), and the route refuses a token
issued for such a panel.
When a link cannot be used — it expired (a slow click, a retry from the browser history), it was already used, it belongs to another user, or the action cannot be found after rehydration — the user is sent back to the page that asked for the file with a notification, not to an error page. A link with a forged or altered signature gets a 403. An error while rows are already being streamed cannot be turned into a message any more (the response has started): the browser gets a truncated file, and the exception is reported as usual.
How the query is rebuilt outside Livewire, and the trade-off. A query cannot be serialised soundly: eager loads, casts
and the table's columns (closures) are code, and replaying SQL plus bindings would lose them. Storing the filtered
primary keys would work for the rows but not for the columns, and needs a cap. So nothing about the query is stored;
what is stored is the component's own Livewire snapshot (filters, search, sort, selected keys, mount state, signed
with your app key), the action's name and the modal's values. The route rehydrates the component, runs its lifecycle
hooks, finds the action by name and asks the table for getFilteredSortedTableQuery() (or, for the bulk action,
getSelectedTableRecordsQuery()) — the same query the table would build itself. The cost:
- The component must be rehydratable from its public properties alone. That holds for resource list pages and for plain
table components. A component whose table depends on request state (route parameters read in
table(), a tenant resolved from the URL, a query-string value) will see the download request instead of the page request. Force->streamed(false)on such tables. - The panel is restored from the id stored with the token and booted (a table outside any panel gets no panel, and its user is checked against the app's default guard); the rehydrated component sees the logged-in user, but not the page's route. Panel tenancy is not carried over, which is why tenant panels never stream (see above).
- The download route does not run the panel's middleware — only
streaming.middleware. The locale of the click is restored (from the token and from the component's snapshot), so labels match Livewire mode. Everything else a panel's persistent middleware does is not:canAccessPanel()is not re-checked within the link's ttl, and scopes or settings that your own panel middleware applies per request are missing. Add such middleware tostreaming.middleware. - The action must be reachable by name from the rehydrated component (table header/toolbar/bulk actions, or the page's header actions, groups included). A renamed action is fine; one created on the fly is not.
- State changes between click and download (a few seconds) are not seen: the snapshot is the state at the click.
- The token store is the default cache store; use a shared one (Redis, database) behind several servers.
Route options live under streaming in the config: register_route, path, middleware (default ['web']; it only
needs to start the session; the controller itself switches the default guard to the panel's guard). The controller checks the URL signature itself. To register your own route
instead (register_route => false), keep its name and its {token} parameter, since the action generates the link with
URL::temporarySignedRoute(StreamedExports::ROUTE, ...):
Route::middleware(['web'])
->get('exports/{token}', \Asignua\FilamentXlsxExport\Http\DownloadController::class)
->where('token', '[A-Za-z0-9]{48}')
->name(\Asignua\FilamentXlsxExport\Support\StreamedExports::ROUTE); // 'filament-xlsx-export.download'
#Typed cells for core exporters
Core's queued Exporter writes CSV files and then copies them into an XLSX as strings; the plugin cannot change the
queued job, but core exposes the hooks, so a trait covers the common case:
use Asignua\FilamentXlsxExport\Concerns\ExportsTypedXlsx;
class OrderExporter extends Exporter
{
use ExportsTypedXlsx;
public function xlsxColumnFormats(): array
{
return [
'total' => ColumnFormat::make()->money(),
'created_at' => ColumnFormat::make()->dateTime(),
'paid' => ColumnFormat::make()->boolean(),
];
}
}
There is deliberately no auto-detection: a CSV cell 00123 or 1e5 is indistinguishable from a number, and guessing
would silently corrupt zip codes, phone numbers and IDs. Columns you declare are converted from the CSV text back into numbers, dates and booleans while the workbook is written;
the rest stay text. Core writes an empty CSV cell for both false and null, so a boolean() column reads FALSE for NULL too (core turns null into an empty string, which boolean() reads as FALSE). If NULL must stay empty, drop boolean() and keep the column as text. The header row stays text, and the trait adds widths, a frozen header and a filter. It is still
queued and still goes through CSV — that is core's design. What it cannot do: format a value that the CSV already lost
(a number rounded by formatStateUsing()), and it needs the column's CSV text to parse (Y-m-d H:i:s for dates).
#Configuration
config/filament-xlsx-export.php: row_limit, streaming.*, chunk_size, header.bold, header.background, freeze_header,
auto_filter, formats.* (Excel number formats for dates, date-times, times and money), width.min, width.max,
total_label. Everything is a plain value, so the config can be cached.
#Gotchas
- Livewire mode buffers the file. Only the streaming mode (see above) avoids it; that is what
row_limitprotects. - Money in cents. The exporter cannot see
->money(divideBy: 100); declareColumnFormat::make()->divideBy(100). - Dates have no timezone in Excel. A date-time is written as the wall time in the column's timezone
(
->timezone(), elseapp.timezone). - Numbers over 15 digits (IBANs, card numbers) stay text; Excel keeps 15 significant digits.
- Long text is cut at 32 767 characters, Excel's cell limit.
- Sorting a chunked read. Rows are read in pages; unless the query already sorts by the primary key, the plugin adds
it as the last sort column, so rows that tie on a non-unique sort are neither repeated nor skipped across pages. A
grouped query (
GROUP BY,HAVING,UNION) is left as it is, since the key is not a valid sort column there; give it a unique order yourself if it can span more than one page. - Tables without an Eloquent query (array or API data sources) are not supported.
#Translations
The interface ships in English, Ukrainian, German, Spanish, French, Italian, Dutch, Polish, Brazilian Portuguese and
Turkish under the filament-xlsx-export::xlsx-export namespace. A test keeps every language in step with the English
keys and placeholders. Override a string by publishing the translations (--tag=filament-xlsx-export-translations).
#AI agents
The package ships Laravel Boost guidelines (resources/boost/guidelines/core.blade.php)
that describe the actions, ColumnFormat and the options, so a coding agent wires it up correctly.
#Testing
composer install
vendor/bin/phpunit
vendor/bin/phpstan analyse --memory-limit=1G
vendor/bin/pint --test
The suite runs on Orchestra Testbench with a workbench/ panel and an Order
resource. Tests read the generated workbook back from its XML and assert cell types, values and number formats.
#Changelog
See CHANGELOG.md.
#License
The MIT License (MIT). See LICENSE.md.
The author
asign is a small web-dev company from Lviv, Ukraine. We build business applications on Laravel and Filament — CRMs, automation systems for standard and non-standard business processes, booking and content management systems, including our own Filament-based CMS. We open-source the parts that prove useful beyond a single project
From the same author
Relation Manager Tabs
Render relation managers as ordinary tabs of the record form, so an edit or view page has exactly one row of tabs.
Author:
asign
SEO Files
Generate and edit sitemap.xml with hreflang and a sitemap index for large sites, robots.txt and llms.txt / llms-full.txt from your Filament panel
Author:
asign
Rich Links
Internal links for the RichEditor: pick a record, store its ID instead of a URL, and resolve it to the current address in every language — slug changes and unpublished pages handled.
Author:
asign
CSP Nonce
A Content-Security-Policy for your panel without 'unsafe-inline' scripts: per-request nonce, a ready header, report-only mode and stored violation reports.
Author:
asign
Featured Plugins
A selection of plugins curated by the Filament team
Noir Theme
A theme that gives panels a focused, refined look with near-black surfaces, crisp actions, and restrained color.
Filament
Spotlight Pro
Browse your Filament Panel with ease. Filament Spotlight Pro adds a Spotlight like Command Palette to your Filament Panel.
Dennis Koch
Compact Theme
A theme that makes data-heavy panels easier to scan by fitting more useful information on each screen.
Filament