Back to blog

Filament Import Action: when it's enough and when you need more

Manch Minasyan · · 10 min read

Filament's built-in Import Action is one of the best things to happen to the Laravel admin panel ecosystem. Before it shipped, every Filament project that needed CSV imports had to wire up a custom Livewire component, a file upload handler, a parser, and queue jobs from scratch. Now you get a column mapping modal and background processing in about 30 lines of code. For a lot of projects, this is genuinely all you need.

This post is not a takedown. Import Action was designed as a convenience feature inside an admin panel framework, not as a dedicated import system. The boundaries show up once your imports grow past a certain complexity. Knowing where those boundaries are saves you from discovering them in production with a client on the phone.

#What Filament Import Action does well

The setup experience is hard to beat. You create an importer class, define your columns, and attach the action to a list page. That is genuinely it:

use Filament\Actions\ImportAction;

class ListContacts extends ListRecords
{
    protected function getHeaderActions(): array
    {
        return [
            ImportAction::make()
                ->importer(ContactImporter::class),
        ];
    }
}

The importer class tells Filament what columns to expect and how to resolve each row:

use Filament\Actions\Imports\ImportColumn;
use Filament\Actions\Imports\Importer;

class ContactImporter extends Importer
{
    protected static ?string $model = Contact::class;

    public static function getColumns(): array
    {
        return [
            ImportColumn::make('first_name')
                ->requiredMapping()
                ->rules(['required', 'string']),

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

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

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

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

From this, Filament gives you:

For internal tools, admin dashboards, and simple flat-table imports where the data is reasonably clean and the columns map 1:1 to your model attributes, this is a solid solution. Ship it and move on.

#Where Filament Import Action hits its limits

The friction starts when your imports move beyond simple flat data into territory that most production applications eventually reach. These are not theoretical concerns -- they are documented in Filament's own GitHub issue tracker.

#Character encoding beyond UTF-8

Filament's CSV parser assumes UTF-8 input. When a user exports a file from Excel on Windows, the file often arrives as ISO-8859-1 or Windows-1252. Characters like accented names get corrupted silently, or worse, the import fails entirely with a JSON encoding exception.

This is GitHub issue #12063. The error manifests as: "Unable to encode attribute [data] for model [FailedImportRow] to JSON: Malformed UTF-8 characters, possibly incorrectly encoded." A community pull request exists, but the core issue remains: there is no built-in encoding detection or conversion. If your users are international -- or simply use Excel on Windows -- you will hit this.

#Error handling is after the fact

When rows fail validation during a Filament import, they are collected and offered as a downloadable CSV after the import completes. The user downloads the failures, opens the file, fixes the values in their spreadsheet application, and re-uploads the corrected rows.

This workflow has two problems. First, the user has to leave your application, open a separate tool, and re-upload a file. That is a significant UX gap for non-technical users. Second, the error context is limited. A validation message like "The email field must be a valid email address" next to row data is helpful, but it does not let the user click the offending cell, correct the value, and continue.

The validate-and-correct pattern -- where users see errors inline and fix them before any data touches the database -- is a fundamentally different approach. If your users are operations staff or clients uploading their own data, the download-fix-reupload cycle creates support tickets. For more on this pattern, see Handling CSV validation errors before they hit your database.

#Limited relationship support

Filament's resolveRecord() method gives you a hook to implement relationship resolution yourself:

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

This works for simple "find or create the main record" logic. But it does not extend to the harder relationship problems:

You can write all of this in resolveRecord() or afterSave(), but the logic grows fast and you get no UI for the user to participate in match decisions. For the full picture of what relationship resolution requires, see Importing relational data from CSV files in Laravel.

#Large file performance

GitHub issue #10651 documents users hitting "max execution time of 30 seconds exceeded" followed by 419 Page Expired errors on files as small as 2,000 rows. The bottleneck is not the queue processing itself -- that works fine -- but the initial file parsing and job dispatching phase, which happens synchronously during the HTTP request.

For files over a few thousand rows, this means you need to work around the limitation with PHP configuration changes or custom pre-processing. A dedicated import system handles this by moving the entire parsing phase to the queue, so the user uploads the file and immediately gets a progress indicator while parsing and dispatching happen in the background.

#Queue driver compatibility

GitHub issue #10002 reports that Import Action breaks when using an async queue driver like Laravel Horizon. The root cause: app(Authenticatable::class) resolves to null inside the queued job, because the authentication context from the original HTTP request is not preserved across the queue boundary. The import cannot determine which user initiated it.

This is a solvable problem -- Filament has added workarounds in later releases -- but it illustrates a broader architectural point. Queue-powered imports in multi-user applications need explicit context preservation: the authenticated user, the tenant scope, and any other request-specific state. Bolting that onto a feature that was designed for synchronous modal interactions requires ongoing patches.

#No multi-step wizard

The Filament Import Action flow is: upload a file, see a mapping modal, confirm, wait for completion. That is a single modal interaction. There is no separate step to preview the data, no validation review where users see and correct errors before committing, no relationship resolution step where users decide how to handle unmatched foreign keys.

For straightforward imports, a single modal is fine. For imports where data quality matters -- where a wrong mapping or a corrupted value means bad data in production -- a multi-step wizard with discrete Upload, Map, Review, and Execute stages gives users the control they need. The preview step alone prevents more bad imports than any amount of after-the-fact error reporting.

#Decision matrix

Not every import needs a wizard. Here is a practical guide for choosing the right tool:

Scenario Recommended tool Why
Simple flat data, under 1K rows, internal team Filament Import Action Native integration, minimal setup, good enough UX for technical users
Flat data, predictable format, backend or scheduled Laravel Excel Strongest parser, no UI needed, excellent for system-to-system transfers
Complex relationships, user-facing, data quality matters Tapix Multi-step wizard, inline validation, relationship linking with match behaviors
Mixed: some simple imports, some complex Filament Import Action + Tapix Use Import Action for simple resources, Tapix for complex ones in the same panel

The "mixed" row matters. This is not an either-or decision. Many applications have some resources where a basic import modal is perfect (importing a list of tags, for example) and others where the full wizard is necessary (importing contacts with company relationships and tag assignments). Both can coexist in the same Filament panel.

#Tapix and Filament: extending, not replacing

For the full story of why Tapix is designed as a complement rather than a competitor to Filament, see Why we're building Tapix.

Tapix was built as a Filament plugin from the start. The integration is three lines in your panel provider:

use Tapix\Core\Filament\TapixPlugin;

->plugin(
    TapixPlugin::make()
        ->importers([
            ContactImporter::class,
            ProductImporter::class,
        ])
)

That registers an import page and an import history page inside your existing Filament panel. Same authentication, same tenant context, same sidebar navigation. The TapixPlugin reads Filament's current tenant automatically via middleware, so multi-tenant applications do not need any extra configuration.

You keep Filament's Import Action for the resources where a simple modal is sufficient. You use Tapix for the resources where your users need column mapping with smart auto-detection, inline validation and correction, relationship resolution with configurable match behaviors, and queue-powered processing with live progress.

The importer class API will feel familiar if you have written Filament importers. Instead of ImportColumn, you define ImportField instances with the same builder pattern:

use Tapix\Core\Fields\ImportField;
use Tapix\Core\Fields\FieldType;
use Tapix\Core\Enums\MatchBehavior;

public function fields(): ImportFieldCollection
{
    return ImportFieldCollection::make([
        ImportField::make('first_name')
            ->required()
            ->guess(['first name', 'fname', 'given name']),

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

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

The guess() method powers auto-mapping: the wizard normalizes CSV headers and field guesses, then matches them automatically. Users only intervene when the auto-mapping misses. The relationship() method declares that this column resolves to a related model, with explicit control over whether unmatched values should fail, create new records, or skip.

#When to make the switch

If you are reading this and your Filament Import Action works fine today, keep using it. Seriously. Do not over-engineer an import that handles 200 rows of flat data from your internal team.

Consider Tapix when any of these become true:

For a broader comparison of all CSV import approaches in Laravel, including raw PHP and Laravel Excel, see The complete guide to CSV imports in Laravel.

Check out Tapix. The same Filament panel, the same authentication, the same tenant context -- with an import wizard that handles everything the built-in action was not designed to do.

Enjoyed this post?

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

Almost there — confirm your subscription via email.

Related posts