How to import CSV files in Filament: basic to advanced | Tapix                  [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev) [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)

   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

  [Features](https://tapix.dev#features) [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog)   Try Demo  [ Get Tapix from $99](https://tapix.dev#pricing)

   ![Tapix](https://tapix.dev/img/tapix-logo-light.svg)

 TutorialsHow to import CSV files in Filament: basic to advanced
======================================================

 tapix.dev/blog

  [    Back to blog ](https://tapix.dev/blog) [ Tutorials ](https://tapix.dev/blog/category/tutorials)

How to import CSV files in Filament: basic to advanced
======================================================

 Manch Minasyan ·  July 28, 2026  · 2 min read

 Filament CSV import is one of the most searched topics in the Laravel admin panel ecosystem, and most results either stop at "create an Importer class" or go straight to selling a third-party package. This post does neither.

What follows is a complete tutorial: how to set up Filament's built-in Import Action from scratch, how to customize it for real-world requirements, where it runs into genuine problems with specific GitHub issue numbers, and how to add Tapix to your panel when those problems become your problems. Each section has copy-pasteable code that works.

If your import is simple and your users are technical, Filament's built-in action is the right choice. This post will tell you exactly where that stops being true.

[\#](#the-built-in-way-filament-import-action "Permalink")The built-in way: Filament Import Action
--------------------------------------------------------------------------------------------------

Filament has shipped a built-in CSV import capability since version 3.1. It provides a column mapping modal, background queue processing, and database notifications -- all without installing any additional packages. The [official Import Action documentation](https://filamentphp.com/docs/actions/import) covers the full API reference. For many applications, this feature is genuinely all you need.

### [\#](#prerequisites "Permalink")Prerequisites

- Filament v3.1 or later (run `composer show filament/filament | grep versions` to verify)
- A running queue worker (`php artisan queue:work`). Import Action dispatches queue jobs after the user confirms the mapping modal. Without a queue worker, nothing happens after the modal closes.

### [\#](#step-1-generate-the-importer-class "Permalink")Step 1: Generate the Importer class

```
php artisan make:filament-importer Contact

```

This creates `app/Filament/Imports/ContactImporter.php`. Open it and define your columns:

```
requiredMapping()
                ->rules(['required', 'string', 'max:255']),

            ImportColumn::make('last_name')
                ->requiredMapping()
                ->rules(['required', 'string', 'max:255']),

            ImportColumn::make('email')
                ->requiredMapping()
                ->rules(['required', 'email', 'max:255']),

            ImportColumn::make('phone')
                ->rules(['nullable', 'string', 'max:50']),

            ImportColumn::make('job_title')
                ->rules(['nullable', 'string', 'max:255']),
        ];
    }

    public function resolveRecord(): ?Contact
    {
        return Contact::firstOrNew([
            'email' => $this->data['email'],
        ]);
    }

    public static function getCompletedNotificationBody(Import $import): string
    {
        $body = 'Your contact 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;
    }
}

```

Each `ImportColumn::make('key')` maps to a column in your model. The key must match the model's fillable attribute name. `requiredMapping()` tells the modal that users must map this column -- it cannot be left unmapped.

`resolveRecord()` determines whether the import creates a new record or updates an existing one. Here, we look up contacts by email. If a contact with that email exists, we update it; if not, we create a new one.

### [\#](#step-2-register-importaction-on-a-listrecords-page "Permalink")Step 2: Register ImportAction on a ListRecords page

Open your resource's `ListRecords` page -- for example, `app/Filament/Resources/ContactResource/Pages/ListContacts.php`:

```
importer(ContactImporter::class),
        ];
    }
}

```

That is the complete setup. The action appears as an "Import" button in the top-right of your list page. Clicking it opens a modal where users upload a CSV file.

### [\#](#step-3-start-the-queue-worker "Permalink")Step 3: Start the queue worker

```
php artisan queue:work

```

In production, use a process supervisor like Supervisor or Laravel Horizon. In development, this artisan command is enough.

### [\#](#what-the-user-sees "Permalink")What the user sees

The flow is a single modal interaction. The user clicks "Import", uploads a CSV, and sees a mapping interface where the file's headers sit alongside your defined `ImportColumn` fields. Filament auto-maps headers that match exactly (case-insensitive); users manually fix anything that does not.

After confirming, Filament dispatches queue jobs to process the CSV in chunks (100 rows by default). A database notification arrives when the import finishes with success and failure counts. Failures are downloadable as a CSV; users fix values in a spreadsheet and re-upload.

[\#](#customizing-import-action "Permalink")Customizing Import Action
---------------------------------------------------------------------

The defaults get you started. Production imports usually require more.

### [\#](#custom-validation-rules-on-columns "Permalink")Custom validation rules on columns

`ImportColumn::make()` accepts a `->rules()` call that takes any Laravel validation rule -- strings, closures, or rule objects:

```
use Illuminate\Validation\Rule;

ImportColumn::make('email')
    ->requiredMapping()
    ->rules([
        'required',
        'email',
        Rule::unique('contacts', 'email')->ignore(
            $this->originalRecord?->id
        ),
    ]),

ImportColumn::make('status')
    ->rules([
        'nullable',
        Rule::in(['active', 'inactive', 'lead', 'lost']),
    ]),

ImportColumn::make('revenue')
    ->numeric()
    ->rules(['nullable', 'numeric', 'min:0']),

```

The `->numeric()` call on the last column tells Filament to cast the raw CSV string to a number before validation and model assignment.

### [\#](#casting-values-with--caststateusing "Permalink")Casting values with `->castStateUsing()`

For columns that need more than a type cast, `castStateUsing` runs a closure that transforms the raw CSV value before it is stored:

```
ImportColumn::make('tags')
    ->castStateUsing(function (string $state): array {
        return array_map('trim', explode(',', $state));
    }),

ImportColumn::make('country')
    ->castStateUsing(function (?string $state): ?string {
        if (blank($state)) {
            return null;
        }

        return strtoupper(trim($state));
    }),

```

### [\#](#mutating-data-before-the-record-saves "Permalink")Mutating data before the record saves

`resolveRecord()` runs first and returns the model instance. Filament then fills the model's attributes from the mapped and cast column values. To mutate the final data array before it is written to the model, add a `beforeFill()` method:

```
protected function beforeFill(): void
{
    $this->data['slug'] = str($this->data['name'])->slug()->toString();
}

```

Alternatively, use `afterFill()` to modify the model after Filament fills it but before it saves:

```
protected function afterFill(): void
{
    if (blank($this->record->owner_id)) {
        $this->record->owner_id = $this->import->user_id;
    }
}

```

Note: `auth()->id()` returns `null` inside queue workers. Use `$this->import->user_id` to get the ID of the user who triggered the import.

### [\#](#handling-relationships-during-import "Permalink")Handling relationships during import

`resolveRecord()` gives you the model instance. For BelongsTo relationships, look up or create the related record there. For many-to-many relationships, use `afterSave()`:

```
public function resolveRecord(): ?Contact
{
    $contact = Contact::firstOrNew([
        'email' => $this->data['email'],
    ]);

    if (filled($this->data['company_name'])) {
        $company = Company::firstOrCreate(
            ['name' => $this->data['company_name']],
        );

        $contact->company_id = $company->id;
    }

    return $contact;
}

protected function afterSave(): void
{
    if (filled($this->data['tags'])) {
        $tagIds = collect($this->data['tags'])
            ->map(fn (string $name) => Tag::firstOrCreate(['name' => $name])->id)
            ->all();

        $this->record->tags()->sync($tagIds);
    }
}

```

The row lifecycle is: `resolveRecord()` -&gt; Filament fills model attributes -&gt; `beforeFill()` -&gt; `afterFill()` -&gt; model saved -&gt; `afterSave()`.

### [\#](#chunk-size-and-row-limits "Permalink")Chunk size and row limits

Control the batch size and set an upper limit on file rows:

```
ImportAction::make()
    ->importer(ContactImporter::class)
    ->chunkSize(500)
    ->maxRows(10000),

```

`chunkSize` defaults to 100. Higher values reduce the number of queue jobs dispatched but increase memory usage per job. For flat data with no relationship lookups, 500-1000 is reasonable. Drop it to 50-100 if each row involves multiple database queries.

`maxRows` rejects any file larger than the specified row count before processing begins. Use this to prevent users from accidentally uploading enormous files to a panel that was not designed for them.

[\#](#where-import-action-hits-its-limits "Permalink")Where Import Action hits its limits
-----------------------------------------------------------------------------------------

Import Action was designed as a convenience feature inside an admin panel framework, not as a standalone import system. That distinction shows up in a handful of real scenarios.

### [\#](#no-preview-or-review-step "Permalink")No preview or review step

The Import Action flow is: upload, map, confirm, wait. There is no step where users see their data before it hits the database. If the mapping was wrong -- say the user mapped "Email" to `phone` instead of `email` -- they will not know until the import finishes and the data is corrupt.

A preview step that shows a sample of the mapped rows before execution would prevent the majority of import mistakes. Import Action does not have one.

### [\#](#error-handling-requires-leaving-the-application "Permalink")Error handling requires leaving the application

When rows fail validation, Filament collects them as `FailedImportRow` records and offers them as a downloadable CSV file at the end. The user:

1. Downloads the failure CSV
2. Opens it in their spreadsheet application
3. Fixes the bad rows
4. Re-uploads the corrected file and goes through the mapping step again

For technical users doing occasional internal imports, this is tolerable. For non-technical users or high-volume imports, this cycle creates support overhead. The alternative -- showing errors inline so users can fix values without leaving the application -- is architecturally different. Import Action cannot be configured to do this. See [Handling CSV validation errors before they hit your database](/blog/handling-csv-validation-errors) for a treatment of that pattern.

### [\#](#column-mapping-matches-on-exact-names-only "Permalink")Column mapping matches on exact names only

Import Action's auto-mapping normalizes headers and field names by lowercasing and replacing spaces with underscores, then compares them. "First Name" auto-maps to `first_name`. "Email Address" does not auto-map to `email` -- users must manually select the mapping each time.

There is no mechanism to define alias headers. If your users regularly upload files from different systems with different column naming conventions (HubSpot exports use "First Name", Salesforce exports use "firstname", internal tools might use "given\_name"), every import requires manual column mapping for any header that does not match exactly.

### [\#](#large-files-cause-http-layer-problems "Permalink")Large files cause HTTP-layer problems

[GitHub issue #10651](https://github.com/filamentphp/filament/issues/10651) documents "max execution time of 30 seconds exceeded" errors and 419 Page Expired responses on files as small as 2,000 rows. The problem is that parsing the CSV and dispatching queue jobs happens synchronously during the initial HTTP request. Large files take too long to process before Filament hands off to the queue.

Working around this requires raising PHP's `max_execution_time`, adjusting web server timeout settings, or pre-processing files in a way that Import Action was not designed to support. A system designed for large files moves the entire parsing phase off the HTTP request.

### [\#](#character-encoding-is-not-handled-automatically "Permalink")Character encoding is not handled automatically

[GitHub issue #12063](https://github.com/filamentphp/filament/issues/12063) documents import failures with non-UTF-8 CSV files. Windows exports from Excel commonly arrive as ISO-8859-1 or Windows-1252. The failure message is: "Unable to encode attribute \[data\] for model \[FailedImportRow\] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded."

There is no built-in encoding detection or conversion. If your users export from Excel on Windows -- which describes most non-developer users -- you will hit this.

### [\#](#queue-driver-context-preservation "Permalink")Queue driver context preservation

[GitHub issue #10002](https://github.com/filamentphp/filament/issues/10002) reports broken imports with async queue drivers when `app(Authenticatable::class)` resolves to `null` inside the queued job. The import cannot determine which user initiated it. Later Filament releases introduced workarounds, but the pattern of bolting queue context onto a feature originally designed for synchronous interactions leaves edge cases.

These are not dealbreakers for the right use cases. Import Action works well for internal tools, admin tasks, predictable file formats, and teams where everyone knows to export as UTF-8. It becomes a problem when your users are external customers who upload data from arbitrary sources, cannot be expected to fix and re-upload files, and have no tolerance for encoding errors.

[\#](#adding-tapix-to-your-filament-panel "Permalink")Adding Tapix to your Filament panel
-----------------------------------------------------------------------------------------

When Import Action's limitations become your limitations, Tapix adds a 4-step import wizard to your existing Filament panel. The authentication, tenant context, sidebar navigation, and panel theme stay exactly the same. Tapix extends the panel rather than replacing anything.

### [\#](#install-and-configure "Permalink")Install and configure

```
composer require tapix/core tapix/livewire
php artisan vendor:publish --tag=tapix-config

```

The published config lives at `config/tapix.php` with defaults for queue settings, chunk sizes, and table naming. Most applications do not need to change anything.

### [\#](#create-a-tapix-importer "Permalink")Create a Tapix importer

```
php artisan make:tapix-importer Contact

```

This creates `app/Importers/ContactImporter.php`. Fill in the two required methods:

```
required()
                ->guess(['first name', 'first', 'given name', 'fname']),

            ImportField::make('last_name')
                ->required()
                ->guess(['last name', 'last', 'surname', 'family name']),

            ImportField::make('email')
                ->type(FieldType::Email)
                ->required()
                ->rules(['unique:contacts,email'])
                ->guess(['email', 'email address', 'e-mail']),

            ImportField::make('phone')
                ->type(FieldType::Phone)
                ->acceptsArbitraryValues()
                ->guess(['phone', 'phone number', 'telephone', 'mobile']),

            ImportField::make('company')
                ->label('Company')
                ->guess(['company', 'company name', 'organization', 'org'])
                ->relationship(
                    name: 'company',
                    model: Company::class,
                    matchBy: ['name'],
                    behavior: MatchBehavior::MatchOrCreate,
                ),
        ]);
    }
}

```

The API shape is intentionally similar to Filament's `ImportColumn`. The key differences:

- `->guess()` defines alias headers that auto-map even when CSV headers do not match exactly. "First Name", "fname", and "given name" all resolve to the `first_name` field automatically.
- `->type()` adds built-in validation and parsing. `FieldType::Email` validates email format; `FieldType::Phone` validates phone format; `FieldType::Currency` parses "$1,234.56" into `1234.56`.
- `->relationship()` declares that this field resolves to a related model, with explicit control over the match behavior. `MatchBehavior::MatchOrCreate` looks up by the `matchBy` columns and creates a new record if none is found.

### [\#](#register-tapixplugin-in-your-panel-provider "Permalink")Register TapixPlugin in your panel provider

```
use Tapix\Core\Filament\TapixPlugin;
use App\Importers\ContactImporter;
use App\Importers\ProductImporter;

public function panel(Panel $panel): Panel
{
    return $panel
        ->default()
        ->id('admin')
        ->path('admin')
        // ... your existing configuration
        ->plugin(
            TapixPlugin::make()
                ->importers([
                    ContactImporter::class,
                    ProductImporter::class,
                ])
        );
}

```

If you have many importers, use auto-discovery instead of listing them manually:

```
TapixPlugin::make()
    ->discoverImporters(
        in: app_path('Importers'),
        for: 'App\\Importers',
    )

```

This scans `app/Importers/` and registers every class that extends `BaseImporter`. Add a new importer file and it appears in the panel immediately.

### [\#](#what-the-user-sees-with-tapix-vs-import-action "Permalink")What the user sees with Tapix vs Import Action

**With Import Action**, users see:

- A single modal with a file upload and column mapping interface
- After confirming, a spinning indicator until the notification arrives
- A notification at completion listing success/failure counts
- A downloadable failure CSV if any rows failed

**With Tapix**, users see a 4-step wizard:

1. **Upload** -- file drops in, Tapix parses headers and rows in the background. The user advances immediately without waiting.
2. **Map** -- the wizard shows auto-mapped columns (handled by `guess()`) and prompts for manual correction only where needed. "First Name", "fname", and "given\_name" all resolve automatically.
3. **Review** -- validation errors appear inline, per cell. Users click a cell with a red outline, type a corrected value, and move on. There is no download-fix-reupload cycle. The user does not leave the application. For the architecture behind this, see [Handling CSV validation errors before they hit your database](/blog/handling-csv-validation-errors).
4. **Execute** -- chunked queue jobs run with a live progress bar. The user can navigate away and come back -- all state is persisted.

The review step is the biggest practical difference. In Import Action, bad data reaches the database (or gets silently skipped) and the user finds out through a notification. In Tapix, bad data is surfaced before execution and corrected in place.

### [\#](#migration-path-coexistence-not-replacement "Permalink")Migration path: coexistence, not replacement

Your existing Filament importers do not need to change. Import Action and Tapix can run in the same panel simultaneously. Keep Import Action on resources where the simple modal is sufficient; register Tapix importers for the resources where users need the full wizard.

A typical migration approach: start with the importer that generates the most support tickets. Move it to Tapix. Keep everything else on Import Action. Evaluate from there.

For full Filament plugin configuration -- navigation groups, labels, icons, sort order, and multi-tenancy setup -- see [Adding a CSV import wizard to your Filament panel](/blog/filament-csv-import-panel). For context on how Tapix compares to Import Action across a wider set of scenarios, see [Filament Import Action: when it's enough and when you need more](/blog/filament-import-action-when-enough).

[\#](#when-to-stay-with-import-action "Permalink")When to stay with Import Action
---------------------------------------------------------------------------------

Import Action is the right tool in more cases than its limitations suggest.

**Simple imports with predictable columns.** If your users always upload files from the same source -- a fixed export from their accounting software, for example -- the columns are predictable and exact-match auto-mapping works fine.

**Internal tools where users are technical.** An admin team uploading 200 contacts once a week can handle the download-fix-reupload cycle for validation failures. The friction is low enough not to matter.

**Occasional admin tasks.** If the import feature is used once a month by one person on the team, the simplicity of Import Action's single-modal flow beats the completeness of a 4-step wizard.

**Tight budget.** Import Action ships with Filament at no additional cost. Tapix is a paid package ($99-$299/yr depending on plan). For teams where the import limitations never actually cause problems, the free option is the right option.

Filament's Import Action is maintained by the Filament core team, has thousands of production deployments, and covers the 80% case well. The 20% case -- external users, unpredictable file formats, data that needs review before it hits the database, large files, non-UTF-8 encoding -- is where a purpose-built solution pays for itself.

For a broader view of all CSV import options in Laravel, including raw PHP, Laravel Excel, and League CSV, see [The complete guide to CSV imports in Laravel](/blog/complete-guide-csv-imports-laravel).

If your requirements grow past what Import Action can handle, [see current pricing](/#pricing).

 ### Enjoyed this post?

Get notified when we publish new articles about Laravel imports and data handling.

  Email address   Subscribe

Almost there — confirm your subscription via email.

 Related posts
-------------

 [  Tutorials   Jul 24, 2026

 Importing Excel (XLSX) files in Laravel
-----------------------------------------

A practical breakdown of every way to import XLSX files in Laravel -- from raw PhpSpreadsheet to Tapix -- with real code and honest tradeoffs for each approach.

 ](https://tapix.dev/blog/importing-xlsx-excel-files-laravel) [  Tutorials   Jul 21, 2026

 How to fix slow CSV imports in Laravel
----------------------------------------

A diagnostic guide to the four most common Laravel CSV import performance problems -- memory exhaustion, timeout errors, N+1 queries, and file size limits -- with copy-pasteable fixes for each.

 ](https://tapix.dev/blog/fix-slow-csv-imports-laravel) [  Tutorials   Jul 3, 2026

 Multi-value fields in CSV imports: emails, phones, and tags
-------------------------------------------------------------

One cell, multiple values: 'a@x.com, b@x.com'. How to split, validate, and store multi-value email, phone, and tag fields during CSV import.

 ](https://tapix.dev/blog/email-phone-multi-value-csv)

   [ ![Tapix](/img/tapix-logo-light.svg) ![Tapix](/img/tapix-logo-dark.svg) ](https://tapix.dev)CSV and Excel import wizard for Laravel.

  Product [Pricing](https://tapix.dev#pricing) [Docs](https://docs.tapix.dev) [Blog](https://tapix.dev/blog) [Contact](mailto:hello@tapix.dev)

 Compare [vs Laravel Excel](https://tapix.dev/vs/laravel-excel) [vs Filament Import](https://tapix.dev/vs/filament-import)

 Legal [Privacy](https://tapix.dev/privacy-policy) [Terms](https://tapix.dev/terms-of-service)

© 2026 Tapix. All rights reserved.
