You need bulk CSV import on a Filament resource. You search "filament csv import" and get a wall of plugin recommendations, three-year-old Medium tutorials targeting v2, and packages that haven't seen a commit in eighteen months. The framework itself ships an ImportAction and ExportAction that cover 95% of what most teams actually need — queued processing, validation, failed-row downloads, BelongsToMany resolution, native XLSX output. The official docs are good but split across two pages, and the queue/notification flow gets glossed over.
This walkthrough wires both actions into a UserResource, with the column declarations, the queue setup, and the dialect tweaks Excel-on-Windows needs. By the end you have header-action and bulk-action export, an importer with validation and relationship resolution, and a failed-rows CSV the user can fix and re-upload — without installing a single plugin.
Publish the queue and notification migrations#
The import and export systems lean on Laravel's job batches and database notifications, so you need three sets of migrations before either action will work. Filament fails silently if any of them are missing — the modal opens, the file uploads, the job dispatches, and the user never sees a result. Run these once per application:
php artisan make:queue-batches-table
php artisan make:notifications-table
php artisan vendor:publish --tag=filament-actions-migrations
php artisan migrate
The first two are Laravel core. The third publishes the imports, exports, import_failed_rows, and related tables that Filament uses to track each job and the failed-row payload. If your queue isn't running, the action still appears to work — the file uploads, the modal closes, and nothing happens. Make sure a worker is consuming the right queue before you debug column-mapping bugs; the Laravel queues production guide walks through the worker config and supervision setup the rest of this article assumes.
If you're on PostgreSQL, swap data in the notifications migration to json() and notifiable to uuidMorphs() when your User model uses UUIDs. Both are silent failures — the migration runs, the action dispatches, and the result row never reaches the user's bell icon.
Generate an Importer with make:filament-importer#
Filament's importer is a class that tells the action how to map CSV columns to model columns. Generate one with the artisan command, passing the model name:
php artisan make:filament-importer User
That creates app/Filament/Imports/UserImporter.php with a getColumns() skeleton and a commented-out resolveRecord() example. If you'd rather scaffold the columns from the model's database schema, pass --generate:
php artisan make:filament-importer User --generate
--generate reads the columns off the users table and emits one ImportColumn::make('...') per column, including types where it can infer them. It's a head-start, not a finished file — you still need to add the requiredMapping() calls and the validation rules.
Declare the columns and validation rules on the importer#
Open the generated class and define which columns the importer accepts. Each ImportColumn::make() declares one mapping target; requiredMapping() forces the user to select a CSV column for it; rules() runs Laravel validation rules before the row is saved:
<?php
namespace App\Filament\Imports;
use App\Models\User;
use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;
use Filament\Actions\Imports\Models\Import;
class UserImporter extends Importer
{
protected static ?string $model = User::class;
public static function getColumns(): array
{
return [
ImportColumn::make('name')
->requiredMapping()
->rules(['required', 'string', 'max:255']),
ImportColumn::make('email')
->requiredMapping()
->rules(['required', 'email', 'max:255']),
ImportColumn::make('role')
->rules(['nullable', 'in:admin,editor,viewer'])
->example('viewer'),
ImportColumn::make('teams')
->relationship()
->multiple(',')
->example('engineering,product'),
];
}
public function resolveRecord(): ?User
{
// Upsert by email — if a user already exists, fill the existing record,
// otherwise spin up a new one.
return User::firstOrNew([
'email' => $this->data['email'],
]);
}
public function getCompletedNotificationBody(Import $import): string
{
$body = 'Your user import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
if ($failedRowsCount = $import->getFailedRowsCount()) {
$body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
}
return $body;
}
}
A few things worth calling out. relationship() on the teams column wires the column to the user's teams() Eloquent relationship — Filament resolves the value to a model and attaches it automatically. Combined with multiple(',') it handles a BelongsToMany where the CSV cell contains comma-separated values. Both BelongsTo and BelongsToMany are supported in v4; MorphTo and HasMany are not, and you'll need a manual fillRecordUsing() callback for those.
The resolveRecord() method runs once per row and decides whether to upsert or insert. Returning User::firstOrNew(['email' => $this->data['email']]) gives you upsert-by-email — existing users get their name and role updated, new emails create new rows. Return new User() to always create; return a query result or null to update-only. If you want to fail the row entirely when no match is found, throw a RowImportFailedException from resolveRecord() and Filament writes the message into the failed-rows CSV.
getCompletedNotificationBody() is the toast text shown when the queue finishes. It's not required — Filament has sensible defaults — but the failed-row counter is the kind of thing operations teams will thank you for.
Attach the ImportAction to the resource's header#
The importer is just the schema. To actually expose the upload modal, wire ImportAction::make() into your resource. There are two places it can live: the resource page's getHeaderActions() or the table builder's headerActions(). The former is the v4 convention for top-level CRUD pages:
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Imports\UserImporter;
use App\Filament\Resources\UserResource;
use Filament\Actions\CreateAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
ImportAction::make()
->importer(UserImporter::class)
->maxRows(50_000)
->chunkSize(250),
];
}
}
maxRows() caps the upload at 50,000 rows so a curious user can't drop a 4-million-row file and pin your queue. chunkSize(250) controls how many rows go into each queued job — the default is 100; bump it for narrow rows, lower it for wide rows that hit memory limits. Filament dispatches one ImportCsv job per chunk through Laravel's batch system, so the rest of the queue keeps moving while the import runs.
The pattern here is the same as any other Filament action — the same Action::make() chain you'd use for a one-off destructive flow. If you've already built the kind of custom Filament v4 action with confirmation modal and notifications for refunds or archives, the import button drops into the same headers and works with the same lifecycle.
Generate an Exporter and declare the export columns#
Exports follow the same Importer/ImportAction shape but in the opposite direction. Generate the exporter class:
php artisan make:filament-exporter User
That creates app/Filament/Exports/UserExporter.php. Define the columns you want available in the export modal:
<?php
namespace App\Filament\Exports;
use App\Models\User;
use Filament\Actions\Exports\ExportColumn;
use Filament\Actions\Exports\Exporter;
use Filament\Actions\Exports\Models\Export;
class UserExporter extends Exporter
{
protected static ?string $model = User::class;
public static function getColumns(): array
{
return [
ExportColumn::make('id'),
ExportColumn::make('name'),
ExportColumn::make('email'),
ExportColumn::make('role')
->enabledByDefault(false),
ExportColumn::make('teams.name')
->label('Teams'),
ExportColumn::make('orders_count')
->counts('orders'),
ExportColumn::make('created_at'),
];
}
public function getCompletedNotificationBody(Export $export): string
{
$body = 'Your user export has completed and ' . number_format($export->successful_rows) . ' ' . str('row')->plural($export->successful_rows) . ' exported.';
if ($failedRowsCount = $export->getFailedRowsCount()) {
$body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to export.';
}
return $body;
}
}
A few v4-specific tricks. enabledByDefault(false) hides the column from the default selection — the user can still tick it in the export modal, but it won't be included unless they do. teams.name uses dot notation to walk relationships; Filament emits comma-separated values in a single cell by default, or call listAsJson() to write a JSON array instead. counts('orders') translates to Laravel's withCount('orders') under the hood, exactly like Eloquent — the column name must be {relationship}_count for the result to bind.
For calculated columns where you want full control, pass a closure to state():
ExportColumn::make('lifetime_value')
->state(fn (User $record): float => $record->orders()->sum('total')),
The closure runs once per record. If you're pulling enough rows that the per-row sum will hammer the database, lean on modifyQuery() on the exporter class to add a withSum('orders', 'total') eager-load before the chunk fetches.
Wire the ExportAction as both header and bulk action#
The same exporter powers two placements. ExportAction on the page header exports the whole filtered query. ExportBulkAction inside the table's toolbarActions() exports only the rows the user has ticked. Wire both onto the resource:
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Exports\UserExporter;
use App\Filament\Imports\UserImporter;
use App\Filament\Resources\UserResource;
use Filament\Actions\CreateAction;
use Filament\Actions\ExportAction;
use Filament\Actions\ImportAction;
use Filament\Resources\Pages\ListRecords;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
ImportAction::make()
->importer(UserImporter::class),
ExportAction::make()
->exporter(UserExporter::class)
->enableVisibleTableColumnsByDefault(),
];
}
}
And on the resource's table definition (UserResource::table()):
use App\Filament\Exports\UserExporter;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\ExportBulkAction;
public static function table(Table $table): Table
{
return $table
->columns([
// ...
])
->toolbarActions([
BulkActionGroup::make([
ExportBulkAction::make()
->exporter(UserExporter::class),
DeleteBulkAction::make(),
]),
]);
}
Two things have changed from v3 worth knowing. The namespace consolidated: in v4 everything lives under Filament\Actions\ — there's no separate Filament\Tables\Actions\ExportAction. And bulkActions() was renamed to toolbarActions() on the table; v3 imports will still resolve to deprecated aliases but a fresh project should use the new name. enableVisibleTableColumnsByDefault() on the header action ticks only the columns the user currently sees in the table — handy when your exporter declares twenty columns but the default table view shows seven.
The default export modal lets the user pick between CSV and XLSX. Filament's XLSX writer uses OpenSpout, which means real .xlsx output rather than a CSV with a different extension — formulas, styled headers, frozen rows are all possible via getXlsxCellStyle() and configureXlsxWriterBeforeClose() on the exporter.
Customise the CSV settings for Excel-compatible output#
Excel-on-Windows opens UTF-8 CSVs as mojibake unless you give it the right delimiter and encoding. Filament v4's hooks for this aren't the withCsvSettings() DTO you might have seen in blog posts — that API doesn't exist on the framework's first-party actions. The real knobs are:
// On the ImportAction for one-off override
ImportAction::make()
->importer(UserImporter::class)
->csvDelimiter(';');
For the exporter, override getCsvDelimiter() on the exporter class so every action that uses this exporter writes the same dialect:
class UserExporter extends Exporter
{
public static function getCsvDelimiter(): string
{
return ';';
}
}
For European locales (Germany, France, the Netherlands) Excel expects ; because the comma is the decimal separator. UK and US Excel handle , correctly. If your users are on mixed locales, the safer move is to default to XLSX — Filament v4 supports it natively, and Excel opens it without locale gymnastics. Force XLSX-only with the formats() method on the action or the getFormats() override on the exporter:
use Filament\Actions\Exports\Enums\ExportFormat;
ExportAction::make()
->exporter(UserExporter::class)
->formats([ExportFormat::Xlsx]);
If you still need a UTF-8 BOM in the CSV (Excel uses it to detect the encoding), there's no first-party toggle in v4 — the workaround is to subclass PrepareCsvExport and prepend "\xEF\xBB\xBF" to the stream, then point the exporter at the custom job with ->job(CustomPrepareCsvExport::class). For most teams, switching to XLSX is the cheaper answer.
For the failed-rows CSV that gets generated on the import side, the delimiter is taken from the action's csvDelimiter() setting — the file uses the same dialect the user uploaded, so they can re-import the corrected version without changing locale.
Gotchas and Edge Cases#
The import queue uses WithoutOverlapping middleware keyed by the import ID, so a single import processes one chunk at a time. That's deliberate — it prevents one user's 100k-row import from starving the rest of the queue. If you've moved imports to a dedicated queue via getJobQueue() on the importer, make sure your worker is consuming it; "queued but nothing happens" almost always means the worker isn't on the right queue. The mechanics of job batches matter here too — Filament uses Laravel's batch system to track the chunks, so Laravel queue chains vs batches is the right mental model for what's actually executing.
Filament v4's import supports CSV only, not XLSX. Yes, the export action writes XLSX. No, the import action does not read it. If users send you .xlsx uploads, the cleanest path is to convert at the edge — instruct them to "Save as CSV" before upload, or write a beforeUpload hook that converts via PhpSpreadsheet. There's an open feature request on the Filament repo for XLSX import, but as of v4 it's not shipped.
The failed-rows CSV is authorised by default to the user who started the import. If you swap that for an ImportPolicy, you replace the default check entirely — make sure your policy still returns $import->user()->is($user) unless you genuinely want anyone to see anyone else's failed rows. Same applies to ExportPolicy for the download.
CSV formula injection is a real production risk on exports. If your database has a row where name starts with =, +, -, or @, Excel interprets it as a formula. Filament's docs flag this and the fix is to add formatStateUsing() on the relevant column to prefix a single quote — the v4 export action does not sanitise automatically.
The imports and exports tables grow unbounded — Filament does not garbage-collect them. A scheduled prune (Import::where('completed_at', '<', now()->subMonths(3))->delete() for example) keeps the table light. Same for the actual files on disk — they live where FILESYSTEM_DISK points, and Filament leaves cleanup to you on the assumption that users might come back for the download.
If you're processing CSV that's larger than memory before Filament gets to it — say, an upload step where you want to validate the file before it hits the queue — pair the import with Laravel lazy collections for CSV processing. Streaming through LazyCollection::make() keeps memory flat regardless of file size, which the chunked queue does inside but won't help for a pre-flight check.
Wrapping Up#
Filament v4's first-party Import and Export actions cover the realistic 95% case — declarative columns, queued processing, validation, failed-rows CSV, native XLSX, BelongsToMany resolution. The plugins still exist but the framework reaches feature parity for most teams. If you've installed pxlrbt/filament-excel or alperenersoy/filament-export recently, this is the migration path; the API shapes are different but the work each plugin was doing is now first-party.
Next steps depend on where the data flows after import. If you're attaching related models through the admin panel — orders to users, projects to teams — Filament v4 many-to-many relation managers is the next pattern. If the import volume justifies a dedicated worker pool with autoscaling, the Filament v4 zero-to-production dashboard guide covers the deployment shape.
FAQ#
How do I add CSV import to a Filament v4 resource?
Generate an importer class with php artisan make:filament-importer ModelName, declare the columns and validation rules in getColumns(), then attach ImportAction::make()->importer(ModelImporter::class) to your resource page's getHeaderActions() array. You also need to run php artisan make:queue-batches-table, php artisan make:notifications-table, and php artisan vendor:publish --tag=filament-actions-migrations to publish the supporting tables, then php artisan migrate. With a queue worker running, the modal handles file upload, column mapping, validation, and the failed-rows CSV automatically.
What is the difference between Filament's built-in export and pxlrbt/filament-excel?
Filament v4's built-in export is a first-party class that ships with the framework — no Composer install, no extra dependency to keep updated. It writes CSV and XLSX, supports queued chunked processing through Laravel's batch system, handles column selection at runtime, and uses database notifications for completion. pxlrbt/filament-excel is a third-party plugin that predates the built-in action and offers some extra features like more granular Excel cell styling and per-row callbacks, but for the standard "export this table to CSV/XLSX" case the built-in action is the better default. The plugin still has use for projects already on it; greenfield Filament v4 projects should reach for ExportAction first.
How do I show progress on a Filament import?
Filament v4 uses database notifications for import status — there's no per-row progress bar, but the user gets a toast notification when the queue finishes with a summary of successful and failed rows. To customise the body, override getCompletedNotificationBody() on the importer class and return a string that includes $import->successful_rows and $import->getFailedRowsCount(). For real-time progress, you'd need to broadcast a custom event from the importer's lifecycle hooks (afterValidate, afterSave) and listen for it in your Livewire layout — that's not built in, but the hooks are there if you need it.
How do I customise the CSV format that Filament exports?
For one-off delimiter changes, call csvDelimiter(';') on the action. For consistent dialect across every action that uses the exporter, override getCsvDelimiter() on the exporter class and return the delimiter string. There's no first-party API for BOMs, line endings, or enclosure characters in v4 — those need a custom job (subclass PrepareCsvExport and replace the writer). For Excel compatibility on non-UTF-8 locales, the cleanest path is to switch the export format to XLSX with formats([ExportFormat::Xlsx]) rather than fighting CSV encoding.
Can Filament v4 import from Excel xlsx files, not just CSV?
No. Filament v4's ImportAction reads CSV only — the upload step rejects .xlsx files. The export action does write XLSX (and CSV, and lets the user pick), but the import is one-way. If your users send Excel files, the workarounds are: instruct them to "Save as CSV" before upload, or accept the upload, convert with PhpSpreadsheet, and hand the converted CSV to Filament's importer programmatically. There's a community feature request for native XLSX import, but it isn't shipped as of v4.
How do I handle failed rows in a Filament importer?
You don't need to write error handling — Filament collects every row that fails validation or throws a RowImportFailedException from resolveRecord(), compiles them into a single CSV with per-row error messages, and offers it as a download in the completion notification. To enrich the messages, throw RowImportFailedException("Reason here") from resolveRecord() for business-logic failures, and set getValidationMessages() on the importer for friendlier validation errors. The user fixes the rows in the downloaded CSV and re-imports it; the format matches the dialect they uploaded, so a round-trip works without delimiter changes.