eightynine/filament-excel-import
Filament Excel Import adds a ready-made import action to your Filament resources, letting users upload Excel or CSV files with column mapping, validation, chunked processing, and progress feedback—no custom import code required.
1. Install against the right major line
composer require eightynine/filament-excel-import:^4.0
php artisan vendor:publish --tag="filament-excel-import-config" # tag name may differ; check README
php artisan filament:assets # only if the plugin ships assets
v4.0.0 is the official Filament v4 support release — the ambiguity is gone. Use:
^4.0 on a Filament v4 panel.^3.1 on a Filament v3 panel, which is still actively maintained (v3 fixes landed in the same release).Because v4.0.0 is explicitly an initial v4 release, expect a .1 shortly. Pin exactly ("0.x.y" / "4.0.0") until the line settles, and read the diff before bumping.
2. Still check composer.json before writing code
Open vendor/eightynine/filament-excel-import/composer.json and confirm:
maatwebsite/excel is a direct dependency. It is, effectively, a thin Filament wrapper around Laravel Excel — importers, chunk reading and heading rows all behave exactly as you already know.3. Register the plugin (if your version exposes one)
// app/Providers/Filament/AdminPanelProvider.php
public function panel(Panel $panel): Panel
{
return $panel
// ...
->plugin(\EightyNine\FilamentExcelImport\FilamentExcelImportPlugin::make());
}
Some releases are action-only. Check src/ for a class implementing Filament\Contracts\Plugin; if there isn't one, skip this step.
4. Where to look first
| Location | Why |
|---|---|
README.md on GitHub |
The only docs; examples are the canonical usage |
src/Actions/ |
The ImportAction class — its public methods are your real API surface |
src/Importer.php (or similar) |
The base class / contract your importers must satisfy |
composer.json |
Filament + Laravel Excel version constraints |
CHANGELOG / release notes |
v4 changed modal + notification behaviour — see Gotchas |
5. First use case
The 80% case: a ListRecords page gets an "Import" header button, the user uploads an .xlsx, rows become Eloquent records.
// app/Filament/Resources/UserResource/Pages/ListUsers.php
use App\Imports\UserImport;
use EightyNine\FilamentExcelImport\Actions\ImportAction; // confirm namespace in vendor
protected function getHeaderActions(): array
{
return [
ImportAction::make()
->importer(UserImport::class)
->label('Import users'),
Actions\CreateAction::make(),
];
}
Then test two paths immediately, because v4 changed both:
If path 2 closes the modal anyway, your importer is swallowing the exception instead of letting it bubble. That distinction is the single most important behavioural change in this release.
One importer class per entity, kept dumb. Treat the importer as an adapter, not a place for business logic.
namespace App\Imports;
use App\Actions\Users\UpsertUserFromRow;
use App\Models\User;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
class UserImport implements ToModel, WithHeadingRow
{
public function model(array $row): ?User
{
return app(UpsertUserFromRow::class)->execute($row);
}
}
If the package ships its own base Importer with hooks (beforeImport, afterImport, model resolution), use it — but only for the wiring. Keep mapping in your own action so it is unit-testable without an Excel file.
Decide how failures surface — this is now a design decision, not an accident. With v4, "modal stays open" is driven by an exception escaping the import. That gives you two coherent strategies:
// A) Strict: fail the whole upload, keep the modal open, let the user fix the file.
// Best for small, contract-driven imports (settings, price lists).
throw ValidationException::withMessages([
'data' => ["Row {$index}: ".implode(' ', $errors)],
]);
// B) Tolerant: skip bad rows, import the rest, but notify explicitly —
// the built-in success notification will fire regardless.
$this->failures[$index] = $errors;
return null; // skip this row only
With (B) you must add your own summary notification ("142 imported, 3 skipped") including row numbers, because the package's success notification will happily announce success on a partial import. Don't mix the two strategies in one importer; pick per resource and document which.
Upsert on a natural key, not on id. Imports get re-run. Match on email, sku, or an external reference and use updateOrCreate. If there is no natural key, add one to the spreadsheet contract before you ship the feature.
Resolve relationships by human-readable value. Spreadsheets contain "Acme Ltd", not company_id. Load a lookup map once per import, not per row:
$companies = Company::pluck('id', 'name'); // once
$row['company_id'] = $companies[$row['company']] ?? null;
Multi-tenancy: set the tenant explicitly. Filament scopes resources by tenant; a raw importer does not. Every created record needs team_id / tenant_id from Filament::getTenant(). Still the most common production bug with import features in Filament panels.
Queue anything that might exceed ~1,000 rows. WithChunkReading caps memory; ShouldQueue keeps the Livewire request from timing out. Combine both, accept the import is no longer transactional, and rely on idempotent upserts. Note the v4 modal/notification UX is a synchronous affordance — a queued import returns immediately, so you still own the "how did it go?" story (database notifications, a status column, or a Filament\Notifications push from the job).
Testing. UploadedFile::fake()->create('users.xlsx') produces a file with no valid XLSX internals. Keep a real fixture in tests/Fixtures/users.xlsx and feed it via createWithContent() or a real new UploadedFile(...). Test the importer class directly against a Collection — faster, no Livewire needed. Add one Livewire test asserting the modal stays open on a throwing import and closes on a clean one, so a future minor bump can't silently regress the UX you designed around.
Don't double-notify. v4.0.0 adds a success notification on completion. If you also flash your own "Import finished" toast you'll get two. Keep the built-in one and only add notifications for additional information (failure summaries, skipped rows, queued-job outcomes).
A swallowed exception looks like a successful import. Because the package treats "no throw" as success, try/catch blocks inside your importer will produce a green success toast and a closed modal on a partially failed file. Log and rethrow, or use the tolerant pattern plus your own summary — but never silently catch (\Throwable $e) {}.
The modal-stays-open behaviour is new and version-specific. If you support both v3 and v4 panels (multi-panel apps do exist), the UX diverges: v3 closes on error. Either normalise with your own notification layer, or pin a single major per deployment.
Heading-row slugging is silent and lossy. First Name, First name and first_name all normalise to first_name, but E-mail Address becomes e_mail_address. dd($rows->first()) on a real customer file before trusting key names, and validate the header row up front with an error listing missing columns.
Dates arrive as integers or floats, not Carbon. Excel stores dates as serials — you get 45870, not 2025-08-24. Convert with \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject() or configure date columns in config/excel.php. Test a file mixing 2025-08-24, 24/08/2025 and a real datetime cell; locale is not inferred.
Formula cells return the formula string by default. Add WithCalculatedFormulas if users upload =SUM() or =VLOOKUP() columns, or you'll store "=A2*B2" in a numeric column.
Blank cells are null, trailing rows exist. Filter with collect($row)->filter(fn ($v) => filled($v))->isNotEmpty(), and trim() every string — non-breaking spaces from copy-paste are a recurring source of failed email validation.
Number localisation. 1.234,56 (EU) parses as a string; 1,234.56 parses as 1234.56 or 1.23456 depending on the reader. Normalise explicitly for monetary columns and document the expected format in the UI.
Temp file lifecycle. The uploaded file lives on the temporary upload disk. If you queue the import, copy it to permanent storage first and pass the path — otherwise the temp file may be cleaned before the worker reads it. More relevant now: the v4 modal stays open while the user sits on the page, so synchronous files are safe, but anything you dispatch is not.
Pin the version, and read both branches. Two maintained lines (v3.1.x, v4.0.x) means a fix you need may land on the other one first. Use an exact constraint, and check 3.x as well as 4.x before filing a bug. Wrapping ImportAction::make() in your own App\Filament\Actions\ImportAction costs ten minutes and insulates every resource from upstream API movement — still worth it on a package this size.
Authorisation is your job. Add ->visible(fn () => auth()->user()->can('import', User::class)) and enforce the same policy inside the importer. A hidden action is not authorisation.
Bypass $fillable deliberately. Bulk inserts hit mass-assignment guards. Either forceFill() inside the importer, or ensure $fillable covers every imported column — a MassAssignmentException mid-chunk leaves a half-imported file (and, in v4, an open modal over a partially mutated database).
When to walk away. If your import needs column-mapping UI, dry-run previews, rollback, or per-row review screens, this package gives you the entry point but not the workflow. v4's improved error affordance narrows that gap slightly — the modal now holds context on failure — but the wizard is still yours to build. At that point maatwebsite/excel directly, with a Filament custom page, is the more honest starting point.
How can I help you explore Laravel packages today?