Key takeaways
- ✓ Filament has native, queued CSV import and CSV/XLSX export. For most apps you don’t need Laravel Excel or a plugin.
- ✓ Both need job batches, database notifications and Filament’s actions migrations, plus a running queue worker.
- ✓ Importers validate and cast each row, can update existing records through
resolveRecord(), and produce a downloadable CSV of failed rows. - ✓ Neither does per-record authorisation, and failed-row CSVs can carry formula-injection payloads. Lock both down if untrusted users can use them.
Also Read: Filament v3 to v5 Upgrade: PHP Skip v4 Without Breaking Your Panel
“Can we get this as a spreadsheet?” is probably the most common feature request any admin panel receives. Its opposite, “can I upload this spreadsheet?”, comes a close second. Years ago the answer in Filament was a plugin. Today both are built into Filament’s actions package, queued and chunked, with column mapping and error reports. This guide sets them up properly and covers the parts the quick-start leaves out.
One-time setup
Imports and exports run as queued job batches and tell the user when they finish through database notifications. Publish the required tables:
php artisan make:queue-batches-table
php artisan make:notifications-table
php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrateTwo notes from the import docs:
- On PostgreSQL, make sure the notifications
datacolumn uses$table->json('data'). - If your
Usermodel uses UUIDs, changenotifiableto$table->uuidMorphs('notifiable').
Turn on the notification bell in your panel, and run a real queue worker:
// AdminPanelProvider
->databaseNotifications()php artisan queue:work --queue=default,imports,exportsAlso Read: Laravel and PHP
With QUEUE_CONNECTION=sync, everything runs inside the HTTP request. That’s fine for a demo and a guaranteed timeout with a 50,000-row file.
Importing CSV files
Generate an importer
php artisan make:filament-importer Product --generate--generate reads your table’s columns and writes a starting set of ImportColumns into app/Filament/Imports/ProductImporter.php. Then refine them:
namespace App\Filament\Imports;
use App\Models\Product;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
use Filament\Forms\Components\Checkbox;
class ProductImporter extends Importer
{
protected static ?string $model = Product::class;
public static function getColumns(): array
{
return [
ImportColumn::make('sku')
->label('SKU')
->requiredMapping()
->rules(['required', 'max:32'])
->guess(['id', 'product code', 'stock keeping unit'])
->example('TSHIRT-BLK-M'),
ImportColumn::make('name')
->requiredMapping()
->rules(['required', 'max:255']),
ImportColumn::make('price')
->numeric(decimalPlaces: 2)
->rules(['required', 'numeric', 'min:0']),
ImportColumn::make('is_visible')
->boolean()
->rules(['boolean']),
ImportColumn::make('category')
->relationship(resolveUsing: ['slug', 'name']),
ImportColumn::make('tags')
->multiple(',')
->helperText('Comma-separated tag names'),
];
}
public static function getOptionsFormComponents(): array
{
return [
Checkbox::make('updateExisting')->label('Update products that already exist (matched by SKU)'),
];
}
public function resolveRecord(): ?Product
{
if ($this->options['updateExisting'] ?? false) {
return Product::firstOrNew(['sku' => $this->data['sku']]);
}
return new Product();
}
public static function getCompletedNotificationBody(Import $import): string
{
$body = number_format($import->successful_rows) . ' products imported.';
if ($failed = $import->getFailedRowsCount()) {
$body .= ' ' . number_format($failed) . ' rows failed. Download the error file to fix them.';
}
return $body;
}
}Also Read: Laravel: Livewire 4 Islands
Here’s what each piece does:
requiredMapping()makes the user map this column in the upload modal. Pair it with arequiredrule for NOT NULL columns.- Casting before validation.
numeric(),integer()andboolean()turn CSV strings into proper types.castStateUsing()handles messy cases like “£1,299.00”. relationship(resolveUsing: [...])finds the related record by slug or name, so users don’t need to know internal IDs.BelongsToManyworks withmultiple().guess()improves automatic column mapping, andexample()fills the downloadable example CSV, which saves a surprising number of support tickets.resolveRecord()decides between creating and updating. Returnnullto skip a row, or throwRowImportFailedExceptionto fail it with a message.
Add the action
use App\Filament\Imports\ProductImporter;
use Filament\Actions\ImportAction;
public static function table(Table $table): Table
{
return $table
->headerActions([
ImportAction::make()
->importer(ProductImporter::class)
->chunkSize(250)
->maxRows(100_000)
->csvDelimiter(','),
]);
}On a resource list page, you can also put it in getHeaderActions() on ListProducts.
Handle large files well
- Chunk size: the default is 100 rows per job. Raise it for simple rows, and lower it if you hit memory or timeout limits.
maxRows()stops someone uploading a 3 GB export by mistake.- Queue and connection: override
getJobQueue()andgetJobConnection()on the importer to send imports to a dedicated queue (imports), so they can’t starve password-reset emails. - Retries: since v4, jobs retry for up to 24 hours or 5 failures, with backoff of 1, 2, 5 then 10 minutes. Tune this with
getJobRetryUntil()andgetJobBackoff(). - Overlap: by default only one job per import runs at a time, via
WithoutOverlapping. Keep that unless you’re sure rows are independent.
Lifecycle hooks for side effects
Hooks such as beforeSave(), afterCreate() and afterUpdate() run per row. Inside them, $this->data is the processed row, $this->originalData is the raw CSV row, $this->record is the model and $this->options holds the modal options:
protected function afterSave(): void
{
$this->record->tags()->sync(
Tag::whereIn('name', $this->data['tags'] ?? [])->pluck('id')
);
}Also Read: Filament on Laravel 13: The Complete Compatibility Checklist
Exporting to CSV and XLSX
Generate an exporter
php artisan make:filament-exporter Product --generatenamespace App\Filament\Exports;
use App\Models\Product;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
class ProductExporter extends Exporter
{
protected static ?string $model = Product::class;
public static function getColumns(): array
{
return [
ExportColumn::make('sku')->label('SKU'),
ExportColumn::make('name'),
ExportColumn::make('category.name')->label('Category'),
ExportColumn::make('price')->formatStateUsing(fn ($state) => number_format($state, 2, '.', '')),
ExportColumn::make('orders_count')->counts('orders')->label('Orders'),
ExportColumn::make('created_at')->enabledByDefault(false),
];
}
public static function getCompletedNotificationBody(Export $export): string
{
return number_format($export->successful_rows) . ' products exported.';
}
}Add the actions
use App\Filament\Exports\ProductExporter;
use Filament\Actions\ExportAction;
use Filament\Actions\ExportBulkAction;
use Filament\Actions\Exports\Enums\ExportFormat;
$table
->headerActions([
ExportAction::make()
->exporter(ProductExporter::class)
->formats([ExportFormat::Xlsx, ExportFormat::Csv])
->fileDisk('s3')
->modifyQueryUsing(fn (Builder $query) => $query->where('is_visible', true)),
])
->toolbarActions([
ExportBulkAction::make()->exporter(ProductExporter::class),
]);Also Read: Build a Multi-Tenant SaaS Panel PHP with Filament v5 (Step by Step)
Users pick which columns to include (turn that off with ->columnMapping(false)), the job runs on the queue, and a notification appears with download links when the file is ready. The export respects the table’s current filters and search, so “export what I’m looking at” just works.
A synchronous alternative for small tables
The queued export is right for big datasets, but it’s heavy for a 200-row settings table. For small, bounded data, a plain action that streams a CSV is simpler and needs no queue or notifications:
use Filament\Actions\Action;
use Filament\Support\Icons\Heroicon;
Action::make('downloadCsv')
->label('Download CSV')
->icon(Heroicon::OutlinedArrowDownTray)
->action(function () {
return response()->streamDownload(function () {
$out = fopen('php://output', 'w');
fputcsv($out, ['Code', 'Name', 'Rate']);
TaxRate::orderBy('code')->each(fn (TaxRate $rate) =>
fputcsv($out, [$rate->code, $rate->name, $rate->rate]));
fclose($out);
}, 'tax-rates-' . now()->format('Y-m-d') . '.csv');
}),Use it only when you can put an upper limit on the row count. For anything user-sized, use ExportAction.
Security: the part tutorials skip
The Filament docs flag two issues explicitly.
1. No per-record authorisation. “The import system does not perform per-record authorization checks.” If a user can trigger an import, they can create or update any record the importer touches. In a multi-tenant panel, that includes other tenants’ records if resolveRecord() matches only on SKU. Fix it in the importer:
public function resolveRecord(): ?Product
{
$teamId = $this->import->user->current_team_id; // or pass via ->options()
return Product::firstOrNew([
'team_id' => $teamId,
'sku' => $this->data['sku'],
]);
}
protected function beforeSave(): void
{
if (! $this->import->user->can('update', $this->record)) {
throw new RowImportFailedException('Not allowed to modify this product.');
}
}Also Read: Claude Opus 5.5 Released: Price Cut, Benchmarks & Breaking Changes for Developers
Exports have the same caveat: scope queries with modifyQueryUsing() to what the user is allowed to see.
2. CSV formula injection. Failed-row CSVs repeat the uploaded data as-is. A cell like =HYPERLINK(...) becomes a live formula when someone opens the error file in Excel. If uploads can come from untrusted sources, turn on the built-in protection:
// Per importer
protected static bool $shouldPreventFormulaInjection = true;
// Or globally, in a service provider's boot()
\Filament\Actions\Imports\Importer::preventFormulaInjection();It’s opt-in because it prefixes legitimate values like -5 or +44 20… with a quote. For exports containing user-entered text, the export docs describe the equivalent preventFormulaInjection() option on columns.
When a plugin still makes sense
Filament’s native tools cover plain CSV and XLSX well. Reach for pxlrbt/filament-excel (it supports Filament 4 and 5) when you need heavily formatted Excel output, multiple sheets, or Laravel Excel’s wider feature set. More options are in our list of the best Filament plugins.
FAQ
Does Filament have built-in CSV import?
Yes. Filament\Actions\ImportAction with an importer class (created with php artisan make:filament-importer) supports column mapping, validation, casting, relationships, upserts and a downloadable failed-rows file.
Why does my Filament import never finish?
Imports run on the queue. Check that QUEUE_CONNECTION isn’t sync for large files, that a worker is running on the right queue, and that the job batches and notifications tables exist.
Can Filament export to Excel?
Yes. ExportAction::make()->formats([ExportFormat::Xlsx, ExportFormat::Csv]) produces XLSX and/or CSV. You can style XLSX header and body cells by overriding methods on the exporter.
How do I update existing records during import?
Return Model::firstOrNew([...]) from resolveRecord(), matching on a unique key such as SKU. Add an options checkbox if users should choose between create-only and upsert.
Sources and further reading
- Filament import action
- Filament export action
- Filament database notifications
- Laravel job batching
- OWASP: CSV injection
Related on The Web Tier: Filament actions, modals and bulk actions
