Importer un CSV robuste dans Filament: mapping, prévisualisation, validation et import asynchrone
Import CSV Filament clé en main: mapping des colonnes, prévisualisation validée et import asynchrone en file d’attente. Un flux scalable, sûr et UX-friendly.
Sommaire
Importer un CSV robuste dans Filament: mapping, prévisualisation, validation et import asynchrone
Ce tutoriel montre comment construire une page d’import CSV professionnelle dans Filament v3 avec mapping dynamique des colonnes, prévisualisation validée, et import asynchrone via la queue. Vous obtiendrez un module fiable, scalable et agréable à utiliser pour vos ressources Laravel.
Objectif
L’objectif est de créer une page Filament permettant d’importer un fichier CSV en sélectionnant la correspondance entre les colonnes du fichier et les champs de votre modèle, de prévisualiser et valider les données sans les écrire, puis de lancer l’import en lot via la queue. Le tout inclura le suivi de progression, la gestion des erreurs et le nettoyage des fichiers temporaires pour éviter l’encombrement du stockage.
Objectif et périmètre
Nous allons importer des Clients à partir d’un CSV. L’utilisateur pourra choisir quelle colonne du CSV remplit quel champ du modèle, appliquer des transformations simples (trim, upper, format de date), prévisualiser les premières lignes pour détecter les erreurs et décider de lancer l’import. L’exécution se fera de façon asynchrone par batch dans la queue pour tenir la charge sur des fichiers importants, tout en affichant une barre de progression, en consignant les erreurs et en supprimant le fichier temporaire une fois le traitement terminé.
Pré-requis et mise en place
Le projet doit être en Laravel 10/11 avec Filament v3 et l’authentification configurée. Il faut activer une queue persistante et installer un parseur CSV performant. Exécutez les commandes suivantes pour préparer l’environnement.
# Installer Filament v3 si ce n’est pas déjà fait
composer require filament/filament:"^3.0" -W
# Activer la queue en base de données
php artisan queue:table
php artisan migrate
# Configurer la connexion de queue
# .env
# QUEUE_CONNECTION=database
# Pour isoler nos imports:
# QUEUE_PREFIX=app
# IMPORTS_QUEUE=imports
# Lier le storage public
php artisan storage:link
# Installer le parser CSV
composer require league/csv:^9.0
Pensez à lancer un worker dédié pour la file d’import, en séparant si besoin de vos autres jobs.
# Lancer un worker dédié
php artisan queue:work --queue=imports --max-time=3600 --sleep=2 --tries=3
Modèle d’exemple: Client
Nous allons manipuler un modèle Client doté des champs name, email, city, phone et birthday. Créez le modèle et la migration puis complétez la structure avec un index unique sur l’email pour des upserts fiables.
php artisan make:model Client -m
Dans la migration, définissez les colonnes et la contrainte d’unicité sur email.
// database/migrations/xxxx_xx_xx_create_clients_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('city')->nullable();
$table->string('phone')->nullable();
$table->date('birthday')->nullable();
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('clients');
}
};
Le modèle doit être mass-assignable et caster correctement la date de naissance.
// app/Models/Client.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Client extends Model
{
protected $fillable = [
'name', 'email', 'city', 'phone', 'birthday',
];
protected $casts = [
'birthday' => 'date',
];
}
Exécutez les migrations.
php artisan migrate
Page Filament dédiée à l’import
Nous allons créer une page Filament rattachée à la ressource ClientResource pour intégrer l’import directement dans le module Clients, sans affichage dans la navigation latérale. La génération de l’ossature se fait avec artisan et l’accès sera restreint aux utilisateurs autorisés à créer des clients.
php artisan make:filament-page Clients/ImportClients \
--resource=ClientResource --navigation=false
La page va s’articuler en trois zones: un formulaire d’upload et d’options, une prévisualisation validée, puis un bouton de lancement en queue. Le squelette suivant montre l’approche générale, notamment la protection d’accès via la politique.
// app/Filament/Resources/ClientResource/Pages/ImportClients.php
namespace App\Filament\Resources\ClientResource\Pages;
use App\Filament\Resources\ClientResource;
use App\Models\Client;
use Filament\Pages\Page;
use Filament\Forms;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Gate;
class ImportClients extends Page implements Forms\Contracts\HasForms
{
use Forms\Concerns\InteractsWithForms;
protected static string $resource = ClientResource::class;
protected static string $view = 'filament.pages.clients.import-clients';
public ?int $importJobId = null;
public function mount(): void
{
abort_unless(Gate::allows('create', Client::class), 403);
}
protected function getHeaderActions(): array
{
return [
// On ajoutera plus loin des actions comme "Deviner le mapping" et "Télécharger le modèle"
];
}
}
Vous pouvez créer la vue Blade minimaliste pour encapsuler le formulaire et la prévisualisation, puis injecter les composants Filament (Form, Sections, etc.). Nous allons maintenant détailler le formulaire.
Formulaire: upload + options + mapping
Le formulaire doit permettre de téléverser un CSV, de définir des options de parsing et de composer un mapping champ->colonne, avec des transformateurs basiques. Le snippet ci-dessous illustre un formulaire Filament complet, incluant des boutons pour deviner le mapping à partir de l’entête et télécharger un modèle CSV prêt à l’emploi.
// app/Filament/Resources/ClientResource/Pages/ImportClients.php (suite)
use Filament\Forms\Components\Section;
use Filament\Forms\Components\FileUpload;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Actions\Action;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use League\Csv\Reader;
class ImportClients extends Page implements Forms\Contracts\HasForms
{
// ...
public array $data = [
'file' => null,
'has_header' => true,
'delimiter' => 'auto',
'encoding' => 'UTF-8',
'mapping' => [],
];
protected function getHeaderActions(): array
{
return [
Action::make('downloadTemplate')
->label('Télécharger modèle CSV')
->action(fn () => $this->downloadTemplateCsv()),
Action::make('guessMapping')
->label('Deviner le mapping')
->action(fn () => $this->guessMappingFromHeader())
->visible(fn () => filled($this->data['file'] ?? null)),
Action::make('preview')
->label('Prévisualiser')
->color('gray')
->action(fn () => $this->runDryPreview()),
Action::make('startImport')
->label('Importer en file d’attente')
->color('success')
->requiresConfirmation()
->action(fn () => $this->startQueuedImport()),
];
}
public function form(Form $form): Form
{
return $form->schema([
Section::make('Fichier CSV')
->schema([
FileUpload::make('file')
->label('Fichier')
->directory('imports/tmp')
->disk('local')
->preserveFilenames()
->acceptedFileTypes(['text/csv'])
->maxSize(10240) // 10 MB
->required()
->helperText('Type: CSV, taille max 10 Mo'),
]),
Section::make('Options de parsing')
->schema([
Toggle::make('has_header')->label('Le fichier contient une ligne d’en-tête')->default(true),
Select::make('delimiter')
->label('Séparateur')
->options([
'auto' => 'Auto',
',' => 'Virgule (,)',
';' => 'Point-virgule (;)',
"\t" => 'Tabulation (\\t)',
])
->default('auto'),
Select::make('encoding')
->label('Encodage')
->options([
'UTF-8' => 'UTF-8',
'ISO-8859-1' => 'ISO-8859-1',
'CP1252' => 'Windows-1252',
])
->default('UTF-8'),
]),
Section::make('Mapping et transformations')
->schema([
Repeater::make('mapping')
->helperText('Associez chaque champ Client à une colonne CSV et une transformation éventuelle.')
->schema([
Select::make('field')
->label('Champ Client')
->options([
'name' => 'name',
'email' => 'email',
'city' => 'city',
'phone' => 'phone',
'birthday' => 'birthday',
])
->required(),
TextInput::make('csv_column')
->label('Colonne CSV (nom exact ou index)')
->placeholder('ex: email ou 2')
->required(),
Select::make('transform')
->label('Transformateur')
->options([
'none' => 'Aucun',
'trim' => 'Trim',
'upper' => 'Uppercase',
'lower' => 'Lowercase',
'date:d/m/Y' => 'Date (d/m/Y)',
'date:Y-m-d' => 'Date (Y-m-d)',
])->default('none'),
])
->columns(3)
->reorderable()
->default([
['field' => 'name', 'csv_column' => 'name', 'transform' => 'trim'],
['field' => 'email', 'csv_column' => 'email', 'transform' => 'lower'],
]),
]),
])->statePath('data');
}
protected function downloadTemplateCsv()
{
$header = ['name','email','city','phone','birthday'];
$content = implode(',', $header) . PHP_EOL .
"Alice,a@example.com,Paris,0612345678,1990-05-12" . PHP_EOL .
"Bob,b@example.com,Lyon,0711223344,1985-10-01";
$path = 'imports/tmp/template-clients.csv';
Storage::disk('local')->put($path, $content);
return response()->streamDownload(function () use ($content) {
echo $content;
}, 'template-clients.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
protected function guessMappingFromHeader(): void
{
$path = $this->resolveLocalPath();
[$reader, $header] = $this->makeReaderAndHeader($path);
if (!$header) {
Notification::make()->danger()->title('Aucune entête détectée')->send();
return;
}
$fields = ['name','email','city','phone','birthday'];
$guessed = [];
foreach ($fields as $f) {
$match = collect($header)->first(fn ($h) => Str::lower($h) === Str::lower($f));
if ($match) {
$guessed[] = ['field' => $f, 'csv_column' => $match, 'transform' => $f === 'email' ? 'lower' : 'trim'];
}
}
$this->form->fill([
'file' => $this->data['file'],
'has_header' => $this->data['has_header'],
'delimiter' => $this->data['delimiter'],
'encoding' => $this->data['encoding'],
'mapping' => $guessed ?: $this->data['mapping'],
]);
Notification::make()->success()->title('Mapping deviné à partir de l’entête')->send();
}
protected function resolveLocalPath(): string
{
$stored = $this->data['file'];
// FileUpload enregistre un chemin relatif (imports/tmp/xxx.csv) sur disk local
return Storage::disk('local')->path($stored);
}
protected function makeReaderAndHeader(string $path): array
{
$delimiter = $this->data['delimiter'] === 'auto'
? $this->detectDelimiter($path)
: $this->data['delimiter'];
$reader = Reader::createFromPath($path, 'r');
$reader->setDelimiter($delimiter);
$header = null;
if ($this->data['has_header']) {
$reader->setHeaderOffset(0);
$header = $reader->getHeader(); // array|null
}
return [$reader, $header];
}
protected function detectDelimiter(string $path): string
{
$line = strtok(file_get_contents($path, false, null, 0, 4096) ?: '', "\n");
$candidates = [',' => 0, ';' => 0, "\t" => 0];
foreach ($candidates as $d => $v) {
$candidates[$d] = substr_count((string) $line, $d);
}
arsort($candidates);
$best = array_key_first($candidates);
return $candidates[$best] > 0 ? $best : ',';
}
protected function runDryPreview(): void
{
// Implémenté plus loin (section Prévisualisation)
}
protected function startQueuedImport(): void
{
// Implémenté plus loin (section Import asynchrone en batch)
}
}
L’exemple ci-dessus enregistre le fichier sur le disque local dans storage/app/imports/tmp, propose des options d’entête, délimiteur et encodage, et capture un mapping souple. Le bouton “Deviner le mapping” lit l’entête si présente et remplit les correspondances. Le bouton “Télécharger modèle CSV” génère un exemple prêt à l’emploi.
Session d’import et persistance d’état
Pour rendre l’expérience fiable et reprise-tolérante, nous persistons l’état de l’import dans une table import_jobs. Chaque upload crée une session qui stocke le chemin du fichier, les options, le mapping, le statut, la progression et un journal des erreurs. Ajoutez la migration suivante.
php artisan make:migration create_import_jobs_table
// database/migrations/xxxx_xx_xx_create_import_jobs_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void {
Schema::create('import_jobs', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('path'); // storage/app/imports/tmp/xxx.csv
$table->json('options');
$table->json('mapping');
$table->enum('status', ['pending','preview','running','failed','done','canceled'])->default('pending');
$table->unsignedTinyInteger('progress')->default(0);
$table->json('stats')->nullable(); // total, inserted, updated, skipped, failed
$table->json('errors')->nullable(); // extrait des erreurs par ligne
$table->timestamp('started_at')->nullable();
$table->timestamp('finished_at')->nullable();
$table->timestamps();
});
}
public function down(): void {
Schema::dropIfExists('import_jobs');
}
};
Créez ensuite le modèle et un petit service pour centraliser la création et les mises à jour de session.
// app/Models/ImportJob.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class ImportJob extends Model
{
protected $fillable = [
'user_id','path','options','mapping','status','progress','stats','errors','started_at','finished_at',
];
protected $casts = [
'options' => 'array',
'mapping' => 'array',
'stats' => 'array',
'errors' => 'array',
'started_at' => 'datetime',
'finished_at' => 'datetime',
];
}
// app/Services/ImportSession.php
namespace App\Services;
use App\Models\ImportJob;
use Illuminate\Support\Facades\Auth;
class ImportSession
{
public function create(string $path, array $options, array $mapping): ImportJob
{
return ImportJob::create([
'user_id' => Auth::id(),
'path' => $path,
'options' => $options,
'mapping' => $mapping,
'status' => 'pending',
'progress' => 0,
'stats' => ['total' => 0, 'inserted' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0],
]);
}
public function update(ImportJob $job, array $attrs): ImportJob
{
$job->fill($attrs)->save();
return $job->refresh();
}
public function mark(string $status, ImportJob $job, array $extra = []): ImportJob
{
return $this->update($job, array_merge(['status' => $status], $extra));
}
}
Sur la page Filament, créez la session au moment pertinent, par exemple lors de la prévisualisation si elle n’existe pas encore, en enregistrant options et mapping. Le chemin complet du fichier est pris depuis le FileUpload.
Prévisualisation et validation à sec
La prévisualisation lit uniquement un petit nombre de lignes (par exemple 50), applique le mapping et les transformations, puis valide chaque ligne sans rien écrire en base. Nous utilisons League\Csv en mode itératif pour préserver la mémoire, et le Validator Laravel pour appliquer des règles cohérentes avec votre modèle.
// app/Filament/Resources/ClientResource/Pages/ImportClients.php (aperçu)
use App\Models\ImportJob;
use App\Services\ImportSession;
use Illuminate\Support\Facades\Validator;
use League\Csv\Statement;
use Carbon\Carbon;
protected function runDryPreview(): void
{
$path = $this->resolveLocalPath();
[$reader, $header] = $this->makeReaderAndHeader($path);
$stmt = (new Statement())->offset($this->data['has_header'] ? 0 : 0)->limit(50);
$records = $stmt->process($reader);
$options = [
'has_header' => $this->data['has_header'],
'delimiter' => $reader->getDelimiter(),
'encoding' => $this->data['encoding'],
];
$mapping = $this->data['mapping'] ?? [];
if (empty($mapping)) {
Notification::make()->warning()->title('Définissez d’abord le mapping')->send();
return;
}
$preview = [];
$errors = [];
$lineNumber = 1 + ($this->data['has_header'] ? 1 : 0);
foreach ($records as $row) {
$transformed = $this->applyMappingAndTransforms($row, $mapping, $header);
$validator = Validator::make($transformed, [
'name' => ['required','min:2'],
'email' => ['required','email','unique:clients,email'],
'city' => ['nullable','string'],
'phone' => ['nullable','string'],
'birthday' => ['nullable','date'],
]);
if ($validator->fails()) {
$errors[$lineNumber] = $validator->errors()->toArray();
}
$preview[] = [
'line' => $lineNumber,
'data' => $transformed,
'ok' => !$validator->fails(),
];
$lineNumber++;
}
$session = app(ImportSession::class);
$job = $this->importJobId
? ImportJob::find($this->importJobId)
: $session->create($this->resolveLocalPath(), $options, $mapping);
$job = $session->mark('preview', $job, [
'errors' => array_slice($errors, 0, 20, true),
'stats' => array_merge($job->stats ?? [], [
'preview_count' => count($preview),
'preview_ok' => count(array_filter($preview, fn ($r) => $r['ok'])),
'preview_errors' => count($errors),
]),
]);
$this->importJobId = $job->id;
if (count($errors) > 0 && count($errors) > count($preview) / 2) {
Notification::make()->danger()
->title('Beaucoup d’erreurs détectées')
->body('Corrigez le fichier ou le mapping avant d’importer.')
->send();
} else {
Notification::make()->success()
->title('Prévisualisation prête')
->body('Vous pouvez lancer l’import si les erreurs sont acceptables.')
->send();
}
}
protected function applyMappingAndTransforms(array $row, array $mapping, ?array $header): array
{
$out = [];
foreach ($mapping as $map) {
$field = $map['field'];
$col = $map['csv_column'];
$value = null;
if (is_numeric($col)) {
$index = (int) $col;
$value = $row[$index] ?? null;
} else {
if ($header) {
// Si Reader a un header, $row est associatif
$value = $row[$col] ?? null;
} else {
// Sans header, permettons un nom de colonne qui ressemble à un index
$value = $row[$col] ?? null;
}
}
$value = $this->transformValue($value, $map['transform'] ?? 'none');
$out[$field] = $value;
}
return $out;
}
protected function transformValue(mixed $value, string $transform): mixed
{
if ($value === null) {
return null;
}
[$type, $param] = array_pad(explode(':', $transform, 2), 2, null);
return match ($type) {
'trim' => is_string($value) ? trim($value) : $value,
'upper' => is_string($value) ? mb_strtoupper($value) : $value,
'lower' => is_string($value) ? mb_strtolower($value) : $value,
'date' => $this->parseDate((string) $value, $param),
default => $value,
};
}
protected function parseDate(string $value, ?string $format): ?string
{
if (!$value) return null;
if ($format) {
$dt = Carbon::createFromFormat($format, $value);
return $dt ? $dt->toDateString() : null;
}
$dt = Carbon::parse($value);
return $dt ? $dt->toDateString() : null;
}
En pratique, vous pouvez afficher un tableau de prévisualisation sur la page (par exemple avec un composant Table de Filament) en listant les 50 premières lignes avec un badge “OK” ou “Erreur” et les messages de validation par champ. L’essentiel est de bloquer l’import si le taux d’erreurs est anormalement élevé, tout en permettant la tolérance de quelques avertissements si votre cas d’usage le justifie.
Import asynchrone en batch
L’import réel se fait via des jobs dispatchés en batch. Nous allons découper le CSV en blocs de 1 000 lignes, appliquer le même mapping et la même validation, puis insérer ou mettre à jour par email grâce à upsert. Créez le job qui traitera un chunk donné.
php artisan make:job ImportClientsChunk
// app/Jobs/ImportClientsChunk.php
namespace App\Jobs;
use App\Models\Client;
use App\Models\ImportJob;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Bus\Batchable;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use League\Csv\Reader;
use League\Csv\Statement;
use Throwable;
class ImportClientsChunk implements ShouldQueue
{
use Batchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $importJobId,
public int $offset,
public int $limit
) {
$this->onQueue(config('queue.imports_queue', 'imports'));
}
public function handle(): void
{
$job = ImportJob::findOrFail($this->importJobId);
if ($job->status === 'canceled') {
return;
}
$path = $job->path;
$options = $job->options ?? [];
$mapping = $job->mapping ?? [];
$reader = Reader::createFromPath($path, 'r');
$reader->setDelimiter($options['delimiter'] ?? ',');
if (($options['has_header'] ?? true) === true) {
$reader->setHeaderOffset(0);
}
$records = (new Statement())
->offset($this->offset)
->limit($this->limit)
->process($reader);
$header = $reader->getHeader() ?: null;
$processed = 0;
$inserted = 0;
$updated = 0;
$skipped = 0;
$failed = 0;
$toUpsert = [];
$errors = $job->errors ?? [];
foreach ($records as $index => $row) {
if (ImportJob::whereKey($job->id)->value('status') === 'canceled') {
return;
}
$mapped = $this->applyMapping($row, $mapping, $header);
$validator = Validator::make($mapped, [
'name' => ['required','min:2'],
'email' => ['required','email'],
'city' => ['nullable','string'],
'phone' => ['nullable','string'],
'birthday' => ['nullable','date'],
]);
if ($validator->fails()) {
$failed++;
$lineNumber = $this->computeLineNumber($options, $this->offset, $index);
$errors[$lineNumber] = $validator->errors()->toArray();
continue;
}
$toUpsert[] = $mapped;
$processed++;
if (count($toUpsert) >= 500) {
[$ins, $upd] = $this->flushUpsert($toUpsert);
$inserted += $ins;
$updated += $upd;
$toUpsert = [];
}
}
if (!empty($toUpsert)) {
[$ins, $upd] = $this->flushUpsert($toUpsert);
$inserted += $ins;
$updated += $upd;
}
$skipped = 0; // ajustez si vous marquez certaines lignes comme ignorées
$stats = $job->stats ?? [];
$stats['inserted'] = ($stats['inserted'] ?? 0) + $inserted;
$stats['updated'] = ($stats['updated'] ?? 0) + $updated;
$stats['skipped'] = ($stats['skipped'] ?? 0) + $skipped;
$stats['failed'] = ($stats['failed'] ?? 0) + $failed;
$total = max($stats['total'] ?? 0, ($this->offset + $this->limit));
$progress = $this->batch()?->progress() ?? 0;
$job->update([
'errors' => array_slice($errors, 0, 500, true),
'stats' => array_merge($stats, ['total' => $total]),
'progress' => (int) $progress,
]);
}
protected function flushUpsert(array $rows): array
{
$before = Client::count();
// upsert par email, mise à jour des autres champs
Client::upsert($rows, ['email'], ['name','city','phone','birthday']);
// Estimation simple: différences de count pour inserted
$after = Client::count();
$inserted = max(0, $after - $before);
// Updated approximé: total traités - inserted
$updated = max(0, count($rows) - $inserted);
return [$inserted, $updated];
}
protected function applyMapping(array $row, array $mapping, ?array $header): array
{
$out = [];
foreach ($mapping as $map) {
$field = $map['field'];
$col = $map['csv_column'];
$value = null;
if (is_numeric($col)) {
$value = $row[(int) $col] ?? null;
} else {
$value = $row[$col] ?? null;
}
$out[$field] = $this->transform($value, $map['transform'] ?? 'none');
}
return $out;
}
protected function transform($value, string $transform)
{
if ($value === null) return null;
[$type, $param] = array_pad(explode(':', $transform, 2), 2, null);
return match ($type) {
'trim' => is_string($value) ? trim($value) : $value,
'upper' => is_string($value) ? mb_strtoupper($value) : $value,
'lower' => is_string($value) ? mb_strtolower($value) : $value,
'date' => $param ? \Carbon\Carbon::createFromFormat($param, (string) $value)?->toDateString() : \Carbon\Carbon::parse((string) $value)?->toDateString(),
default => $value,
};
}
protected function computeLineNumber(array $options, int $offset, int $index): int
{
$hasHeader = $options['has_header'] ?? true;
$base = $hasHeader ? 2 : 1; // 1 pour la première ligne, +1 si header
return $base + $offset + $index;
}
}
Nous devons maintenant calculer le nombre total de lignes et dispatcher le batch. La page déclenche le batching depuis startQueuedImport, avec des callbacks then/catch/finally pour mettre à jour le statut et nettoyer le fichier temporaire.
// app/Filament/Resources/ClientResource/Pages/ImportClients.php (lancement queue)
use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Storage;
use App\Jobs\ImportClientsChunk;
protected function startQueuedImport(): void
{
$path = $this->resolveLocalPath();
[$reader, $header] = $this->makeReaderAndHeader($path);
// Compter les lignes utiles
$totalLines = 0;
foreach ($reader->getRecords() as $r) { $totalLines++; }
if (($this->data['has_header'] ?? true) && $header) {
$totalLines = max(0, $totalLines); // getRecords() ignore déjà l’entête si setHeaderOffset(0)
}
$chunkSize = 1000;
$jobs = [];
for ($offset = 0; $offset < $totalLines; $offset += $chunkSize) {
$jobs[] = new ImportClientsChunk($this->ensureImportJob()->id, $offset, min($chunkSize, $totalLines - $offset));
}
$importJob = $this->ensureImportJob();
$session = app(\App\Services\ImportSession::class);
$session->update($importJob, [
'status' => 'running',
'started_at' => now(),
'stats' => array_merge($importJob->stats ?? [], ['total' => $totalLines]),
]);
Bus::batch($jobs)
->name('Import Clients')
->onQueue(config('queue.imports_queue', 'imports'))
->then(function (Batch $batch) use ($importJob) {
$importJob->refresh();
$importJob->update([
'status' => 'done',
'progress' => 100,
'finished_at' => now(),
]);
})
->catch(function (Batch $batch, Throwable $e) use ($importJob) {
$importJob->refresh();
$importJob->update([
'status' => 'failed',
]);
})
->finally(function (Batch $batch) use ($importJob) {
$importJob->refresh();
Storage::disk('local')->delete($importJob->path);
})
->dispatch();
Notification::make()->success()
->title('Import lancé')
->body("Le traitement de {$totalLines} lignes a démarré.")
->send();
}
protected function ensureImportJob(): ImportJob
{
if ($this->importJobId) {
return ImportJob::findOrFail($this->importJobId);
}
$session = app(\App\Services\ImportSession::class);
$job = $session->create(
$this->resolveLocalPath(),
[
'has_header' => $this->data['has_header'],
'delimiter' => $this->data['delimiter'] === 'auto' ? $this->detectDelimiter($this->resolveLocalPath()) : $this->data['delimiter'],
'encoding' => $this->data['encoding'],
],
$this->data['mapping']
);
$this->importJobId = $job->id;
return $job;
}
Ce code lit le CSV une première fois pour obtenir le nombre de lignes et découpe le travail en blocs. Chaque job traite un segment en relisant le fichier à l’offset et en appliquant mapping, validation et upsert. Les callbacks mettent à jour le statut et suppriment le fichier temporaire.
UX Filament: progression, notifications, annulation
Pour suivre la progression, la page peut interroger régulièrement la session d’import et afficher une barre de progression. Vous pouvez utiliser Livewire pour rafraîchir l’état et proposer un bouton d’annulation qui change le statut à canceled, ce qui permet aux jobs de s’arrêter proprement.
// app/Filament/Resources/ClientResource/Pages/ImportClients.php (progression & annulation)
use Filament\Actions\ActionGroup;
protected function getHeaderActions(): array
{
$actions = parent::getHeaderActions();
$actions[] = Action::make('refresh')
->label('Rafraîchir')
->color('gray')
->action(function () {
if ($this->importJobId) {
$job = ImportJob::find($this->importJobId);
if ($job) {
$this->dispatchBrowserEvent('import-progress', [
'progress' => $job->progress,
'status' => $job->status,
'stats' => $job->stats,
]);
}
}
});
$actions[] = Action::make('cancel')
->label('Annuler')
->color('danger')
->requiresConfirmation()
->visible(fn () => $this->importJobId !== null)
->action(function () {
if ($this->importJobId) {
ImportJob::whereKey($this->importJobId)->update(['status' => 'canceled']);
Notification::make()->warning()->title('Import annulé')->send();
}
});
return $actions;
}
Dans la vue Blade de la page, affichez un indicateur simple de statut, la progression en pourcentage et un résumé des statistiques au fur et à mesure. Par exemple, vous pouvez utiliser un composant Progress de Filament dans une Section et déclencher un rafraîchissement périodique via wire:poll. Les notifications de démarrage et de fin sont gérées par les Notifications Filament dans les actions de la page et les callbacks du batch. Le journal d’erreurs est stocké dans import_jobs.errors; un lien “Voir les erreurs” peut afficher une modale listant un extrait des lignes fautives avec le numéro de ligne et le message par champ.
Sécurité, performance et fiabilité
La sécurité commence par limiter le type de fichier et la taille maximale; l’exemple de FileUpload n’accepte que text/csv et refuse tout fichier trop volumineux. Si nécessaire, vous pouvez ajouter un scan antivirus en tâche asynchrone avant la prévisualisation. Côté performance, League\Csv permet un parsing itératif mémoire-safe et le choix du séparateur est contrôlé, y compris par une détection simple sur la première ligne. Les jobs doivent être tolérants aux pannes avec un timeout raisonnable, des tentatives de retry et un backoff exponentiel pour éviter d’épuiser la base. La concurrence sur les lignes est sécurisée grâce à l’upsert atomique sur la clé email, évitant les verrous applicatifs et les transactions longues. Enfin, chaque ligne invalide est journalisée avec son numéro et la raison, ce qui facilite la correction et la ré-exécution.
Variantes et extensions
Selon vos besoins, vous pouvez proposer des “presets” de mapping par ressource ou par utilisateur pour éviter de reconfigurer à chaque import. Des transformateurs personnalisés enrichissent la qualité des données: normalisation des numéros de téléphone, parseurs de dates locales complexes, ou encore conversion d’un nom de ville en identifiant via un find-or-create. Pour les gros volumes, l’upload direct sur S3 et la lecture streamée permettent d’éviter le disque local et de répartir la charge. Il est souvent utile d’exporter le journal d’erreurs au format CSV pour partager les corrections. Ce module est par ailleurs facilement réutilisable pour d’autres ressources Filament, en factorisant le service de session, la logique de mapping et les jobs.
Check-list finale
Avant de livrer en production, vérifiez qu’un import sur 1 000 lignes s’exécute avec une progression cohérente et des statistiques réalistes, que les doublons d’email sont correctement fusionnés via upsert, que la prévisualisation bloque bien les erreurs critiques, que l’annulation interrompt proprement les jobs et nettoie le fichier temporaire, et que les erreurs sont exportables et traçables avec leur numéro de ligne pour faciliter le support.
Checklist
Relisez le code et la page pour repérer les incohérences ou oublis, testez chaque commande et snippet y compris le worker de queue dédié, et publiez la page dans votre ressource Filament en validant le parcours utilisateur de bout en bout sur un fichier CSV de test représentatif.
Conclusion
Vous disposez maintenant d’un flux d’import CSV complet et robuste dans Filament v3, combinant mapping dynamique, prévisualisation validée et exécution asynchrone en batch. Cette approche offre une excellente expérience utilisateur, tout en garantissant la sécurité, la performance et l’observabilité nécessaires en production. En factorisant le service de session et les jobs, vous pourrez facilement étendre le module à d’autres ressources et cas d’usage.
Ressources
La documentation de Filament v3 explique comment structurer des pages personnalisées et intégrer des formulaires et actions. La référence Laravel sur les queues et les batches présente en détail l’API Bus::batch pour orchestrer les jobs et suivre la progression. Le projet League\Csv fournit un parseur efficace et flexible pour les fichiers CSV. Les règles de validation Laravel sont utiles pour aligner la prévisualisation et l’import sur les contraintes métiers.
Filament v3: https://filamentphp.com/docs/3.x
Laravel Queues: https://laravel.com/docs/11.x/queues
Laravel Bus Batches: https://laravel.com/docs/11.x/queues#job-batching
League CSV: https://csv.thephpleague.com/9.0/
Validation Laravel: https://laravel.com/docs/11.x/validation