XLSX Export plugin screenshot
Dark mode ready
Multilingual support
Supports v5.x

XLSX Export by asign

Community

Download the current table as a real Excel file: filters, search and sort applied, typed numbers and dates, streamed instantly. No queue, no CSV.

Tags: Tables
Supported versions:
5.x
asign avatar Author: asign

Package health

Automated checks of this plugin's Composer package

100 / 100
Security 100
Maintenance 100
Ecosystem 100
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.lock is 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.3 supports current PHP 8.5.
  • Skipped: Current Symfony version supported
Third-party plugin. This is built by the community, not the Filament team. Filament does not review, endorse, or vet the security of plugins outside the 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 .
Powered by Plumb Last scanned 8 hours ago

Documentation

Stand With Ukraine Latest Version on Packagist Tests Total Downloads License Plumb score

Filament XLSX Export

"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

The export modal with the column picker:

Export modal

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):

The resulting workbook

#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:

  1. In the Livewire request it validates the choice, then stores a hand-over in the cache under a random 48-character token (streaming.ttl seconds, default 120) and redirects the browser to a temporary signed URL.
  2. 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://output with lazy() — 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 to streaming.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_limit protects.
  • Money in cents. The exporter cannot see ->money(divideBy: 100); declare ColumnFormat::make()->divideBy(100).
  • Dates have no timezone in Excel. A date-time is written as the wall time in the column's timezone (->timezone(), else app.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 avatar Author: asign

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

Plugins
15
Stars
21

From the same author