Search

CSV Import and Export in Filament Without a Plugin

CSV Import and Export in Filament Without a Plugin

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 migrate

Two notes from the import docs:

  • On PostgreSQL, make sure the notifications data column uses $table->json('data').
  • If your User model uses UUIDs, change notifiable to $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,exports

Also 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 a required rule for NOT NULL columns.
  • Casting before validation. numeric(), integer() and boolean() 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. BelongsToMany works with multiple().
  • guess() improves automatic column mapping, and example() fills the downloadable example CSV, which saves a surprising number of support tickets.
  • resolveRecord() decides between creating and updating. Return null to skip a row, or throw RowImportFailedException to 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() and getJobConnection() 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() and getJobBackoff().
  • 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 --generate
namespace 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

Related on The Web Tier: Filament actions, modals and bulk actions

TWT Staff

TWT Staff

Writes about Programming, tech news, discuss programming topics for web developers (and Web designers), and talks about SEO tools and techniques

Your experience on this site will be improved by allowing cookies Cookie Policy