Filament CSV import header action with row validation
A Filament 4 header action that uploads a CSV, validates each row, upserts valid ones in chunks and reports imported and skipped counts.
FilamentUploadsfilamentcsvimportvalidation
Code
<?php
namespace App\Filament\Actions;
use App\Models\Contact;
use Filament\Actions\Action;
use Filament\Forms\Components\FileUpload;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
use SplFileObject;
class ImportContactsAction
{
private const REQUIRED_HEADERS = ['name', 'email'];
public static function make(): Action
{
return Action::make('importCsv')
->label('Import CSV')
->icon('heroicon-o-arrow-up-tray')
->modalDescription('First row must be a header with at least: '.implode(', ', self::REQUIRED_HEADERS).'.')
->schema([
FileUpload::make('file')
->disk('local')
->directory('imports')
->acceptedFileTypes(['text/csv', 'text/plain', 'application/vnd.ms-excel'])
->maxSize(5120)
->required(),
])
->action(function (array $data): void {
$path = Storage::disk('local')->path($data['file']);
$result = self::import($path);
Storage::disk('local')->delete($data['file']);
Notification::make()
->title("Imported {$result['imported']} rows")
->body($result['skipped'].' skipped'.($result['firstError'] ? ' (first: '.$result['firstError'].')' : ''))
->color($result['skipped'] ? 'warning' : 'success')
->send();
});
}
/** @return array{imported:int, skipped:int, firstError:?string} */
private static function import(string $path): array
{
$file = new SplFileObject($path);
$file->setFlags(SplFileObject::READ_CSV | SplFileObject::SKIP_EMPTY | SplFileObject::READ_AHEAD | SplFileObject::DROP_NEW_LINE);
$headers = null;
$batch = [];
$imported = $skipped = 0;
$firstError = null;
foreach ($file as $line => $cells) {
if ($headers === null) {
$headers = array_map(fn ($h) => strtolower(trim((string) $h, "\xEF\xBB\xBF \t")), $cells); // strip BOM
if (array_diff(self::REQUIRED_HEADERS, $headers)) {
return ['imported' => 0, 'skipped' => 0, 'firstError' => 'missing required column'];
}
continue;
}
$row = array_combine($headers, array_pad(array_slice($cells, 0, count($headers)), count($headers), null));
$validator = Validator::make($row, ['name' => 'required|max:120', 'email' => 'required|email|max:190']);
if ($validator->fails()) {
$skipped++;
$firstError ??= 'line '.($line + 1).': '.$validator->errors()->first();
continue;
}
$batch[] = ['name' => $row['name'], 'email' => strtolower($row['email'])];
if (count($batch) === 500) {
$imported += self::flush($batch);
$batch = [];
}
}
$imported += self::flush($batch);
return compact('imported', 'skipped', 'firstError');
}
private static function flush(array $batch): int
{
if ($batch === []) {
return 0;
}
Contact::upsert($batch, ['email'], ['name']); // idempotent: re-importing updates names
return count($batch);
}
}
Needs: filament/filament@^4.0
How it works
What it solves
Users arrive with spreadsheets. A forgiving CSV importer saves support time: it should accept real-world files (BOM, blank lines, short rows), tell the user what went wrong, and never half-fail silently.
How it works
ImportContactsAction::make() returns a header Action with a private-disk FileUpload, a size cap and a modal hint listing the required columns. In action(), the stored file is parsed, then deleted, and a notification reports the result.
Parsing uses SplFileObject with the CSV flags, so the file streams line by line and large files do not exhaust memory. The first line becomes lower-cased headers, with any byte-order mark trimmed, and missing required headers abort early. Every following row is padded or cut to the header width, validated with the normal Validator, and either skipped (counting the first error with its line number) or added to a batch.
Valid rows are flushed with upsert in groups of 500, keyed by email, so importing the same file twice updates names instead of duplicating contacts.
Adapting it
Change REQUIRED_HEADERS, the validation rules and the mapped columns. Add a unique:contacts,email style rule only if you want duplicates reported rather than updated. For files with many thousands of rows, move import() into a queued job and notify at the end.
Pitfalls
- The
emailcolumn needs a unique index, orupsertcannot resolve conflicts. - Browsers report CSV mime types inconsistently, hence the three accepted types.
- Excel on some locales exports semicolons; set
$file->setCsvControl(';')if you need to support them.
How to use it
- Add a unique index on
contacts.email(andnameas a normal column). - Save the action class under
app/Filament/Actions/. - Attach it to a list page:
protected function getHeaderActions(): array { return [ImportContactsAction::make()]; }. - Upload a CSV whose first row is
name,email. - Large files: move the
import()call into a queued job and keep the action as the trigger.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.