FilamentPHP 13 min de lecture

FilamentPHP : Bulk Action asynchrone avec barre de progression (export CSV)

#laravel#filamentphp

Export CSV asynchrone FilamentPHP avec progression en temps réel. Action de masse avec Livewire, queue Laravel et notification de téléchargement.

FilamentPHP : Bulk Action asynchrone avec barre de progression (export CSV)

Construisons pas à pas un export CSV massif et asynchrone dans Filament v3, sans package externe. L’action se lance depuis une Bulk Action, s’exécute dans la queue Laravel, expose une progression temps réel via polling Livewire, et notifie l’utilisateur quand le fichier est prêt.

Objectif

L’objectif est d’ajouter à une Table Filament un export CSV “long” qui s’exécute en arrière-plan, sans bloquer l’interface. Le traitement met à jour une tâche de suivi dans la base, une page “Mes tâches” affiche une barre de progression rafraîchie automatiquement, puis l’utilisateur reçoit une notification proposant un bouton de téléchargement dès que le CSV est disponible.

Pré-requis et objectif

Le tutoriel cible Laravel 10+ ou 11, Filament v3, Livewire v3, PHP 8.1+ et une base de données opérationnelle. Nous allons ajouter une Bulk Action “Exporter CSV (async)” à PostResource qui déclenche un Job dans la queue. Ce Job met à jour une table bulk_tasks pour la progression, pendant qu’une page Filament “Mes tâches” fait du polling Livewire pour rafraîchir l’état. À la fin, une notification Filament affichera un lien de téléchargement.

Activer la file d’attente (database) et lancer un worker

Configurez d’abord la connexion de queue sur le driver database dans votre fichier .env. Cette configuration garantit que les Jobs s’empilent en base et qu’un worker peut les traiter de manière fiable, y compris en production.

# .env
QUEUE_CONNECTION=database

Générez la table des jobs et migrez. Cela crée les tables jobs et failed_jobs nécessaires au fonctionnement du worker.

php artisan queue:table
php artisan migrate

Démarrez ensuite un worker local pour traiter la file. Ce worker relance le Job en cas d’échec jusqu’à trois fois.

php artisan queue:work --queue=default --tries=3

En production, un unique worker peut suffire pour ce tutoriel, mais la pratique recommandée consiste à superviser le worker avec Supervisor ou systemd pour assurer sa pérennité. Si vous servez des fichiers depuis le disk public, créez aussi le lien symbolique de stockage public si ce n’est pas déjà fait.

php artisan storage:link

Créer la table de suivi des tâches (bulk_tasks) + modèle

Créez une migration pour la table de suivi. Elle stocke l’utilisateur, le nom de la tâche, le total à traiter, le nombre traité, le statut, le chemin du fichier produit, un éventuel payload JSON et une raison d’échec.

php artisan make:migration create_bulk_tasks_table --create=bulk_tasks

Remplissez la migration avec le schéma suivant, puis migrez. Cet index sur (user_id, status) aide aux requêtes de tableaux de bord.

<?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('bulk_tasks', function (Blueprint $table) {
            $table->id();
            $table->foreignId('user_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->unsignedInteger('total')->default(0);
            $table->unsignedInteger('processed')->default(0);
            $table->enum('status', ['pending', 'running', 'done', 'failed'])->default('pending');
            $table->string('file_path')->nullable();
            $table->json('payload')->nullable();
            $table->text('failed_reason')->nullable();
            $table->timestamps();

            $table->index(['user_id', 'status']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('bulk_tasks');
    }
};

Appliquez la migration.

php artisan migrate

Générez ensuite le modèle Eloquent et complétez-le pour rendre les colonnes assignables et caster le JSON. La relation user facilitera les contrôles d’accès.

php artisan make:model BulkTask
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

class BulkTask extends Model
{
    protected $fillable = [
        'user_id',
        'name',
        'total',
        'processed',
        'status',
        'file_path',
        'payload',
        'failed_reason',
    ];

    protected $casts = [
        'payload' => 'array',
    ];

    public function user(): BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

Job ExportPostsCsv (queue) avec mises à jour incrémentales

Créez un Job de queue dédié à l’export CSV. Il reçoit l’ID de la tâche BulkTask, la liste des IDs de posts à exporter, et l’ID de l’utilisateur initiateur. Le Job traite les posts par chunks, écrit le CSV dans un fichier temporaire, met à jour le progrès en base de façon atomique, publie le résultat sur le disk public, puis notifie l’utilisateur.

php artisan make:job ExportPostsCsv

Voici une implémentation robuste avec try/catch, incréments atomiques et notifications de fin. L’ajout des propriétés tries, backoff et timeout améliore la résilience sur de gros volumes.

<?php

namespace App\Jobs;

use App\Models\BulkTask;
use App\Models\Post;
use App\Models\User;
use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Throwable;

class ExportPostsCsv implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public array $backoff = [10, 30, 60];
    public int $timeout = 1200; // 20 minutes

    public function __construct(
        public int $taskId,
        public array $postIds,
        public int $userId,
    ) {}

    public function handle(): void
    {
        $task = BulkTask::findOrFail($this->taskId);
        $task->update([
            'status' => 'running',
            'total'  => count($this->postIds),
        ]);

        $tmp = storage_path('app/tmp/export-' . $task->id . '-' . uniqid('', true) . '.csv');
        if (! is_dir(dirname($tmp))) {
            mkdir(dirname($tmp), 0775, true);
        }

        $fp = fopen($tmp, 'w');
        fputcsv($fp, ['id', 'title', 'status', 'created_at']);

        try {
            foreach (collect($this->postIds)->chunk(500) as $chunk) {
                $posts = Post::whereIn('id', $chunk->all())
                    ->orderBy('id')
                    ->get(['id', 'title', 'status', 'created_at']);

                foreach ($posts as $p) {
                    fputcsv($fp, [$p->id, $p->title, $p->status, $p->created_at]);
                }

                BulkTask::whereKey($task->id)->update([
                    'processed' => DB::raw('processed + ' . count($posts)),
                ]);
            }

            fclose($fp);

            $path = 'exports/posts-' . $task->id . '.csv';
            Storage::disk('public')->put($path, file_get_contents($tmp));
            @unlink($tmp);

            $task->update([
                'status'    => 'done',
                'file_path' => $path,
            ]);

            $user = User::find($this->userId);
            if ($user) {
                Notification::make()
                    ->title('Export prêt')
                    ->body('Cliquez pour télécharger le CSV.')
                    ->success()
                    ->actions([
                        Action::make('download')
                            ->label('Télécharger')
                            ->url(Storage::disk('public')->url($path), shouldOpenInNewTab: true),
                    ])
                    ->sendToDatabase($user);
            }
        } catch (Throwable $e) {
            try {
                fclose($fp);
            } catch (Throwable) {
                // Ignorer si déjà fermé
            }

            @unlink($tmp);

            $task->update([
                'status'        => 'failed',
                'failed_reason' => $e->getMessage(),
            ]);

            $user = User::find($this->userId);
            if ($user) {
                Notification::make()
                    ->title('Export échoué')
                    ->body(str($e->getMessage())->limit(140))
                    ->danger()
                    ->sendToDatabase($user);
            }

            throw $e;
        }
    }
}

Si vous souhaitez enregistrer les notifications en base via Filament, assurez-vous d’avoir migré les tables nécessaires aux notifications de Filament, puis migrez. Selon la version, la commande de publication des migrations peut être la suivante :

php artisan vendor:publish --tag=filament-notifications-migrations
php artisan migrate

Ajouter le Bulk Action à PostResource

Ouvrez la page ListPosts de votre PostResource et déclarez une Bulk Action nommée exportCsvAsync. L’action valide qu’au moins une ligne est sélectionnée, applique un plafond de sécurité, crée la tâche bulk_tasks, pousse le Job en queue, puis affiche une notification immédiate à l’utilisateur.

<?php

namespace App\Filament\Resources\PostResource\Pages;

use App\Filament\Resources\PostResource;
use App\Jobs\ExportPostsCsv;
use App\Models\BulkTask;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Illuminate\Support\Collection;

class ListPosts extends ListRecords
{
    protected static string $resource = PostResource::class;

    protected function getTableBulkActions(): array
    {
        return [
            Tables\Actions\BulkAction::make('exportCsvAsync')
                ->label('Exporter CSV (async)')
                ->icon('heroicon-m-arrow-down-tray')
                ->requiresConfirmation()
                ->deselectRecordsAfterCompletion()
                ->action(function (Collection $records) {
                    $count = $records->count();

                    if ($count === 0) {
                        Notification::make()
                            ->title('Aucune ligne sélectionnée')
                            ->warning()
                            ->send();

                        return;
                    }

                    if ($count > 50000) {
                        Notification::make()
                            ->title('Sélection trop volumineuse')
                            ->body('Veuillez réduire à 50 000 lignes maximum.')
                            ->danger()
                            ->send();

                        return;
                    }

                    $task = BulkTask::create([
                        'user_id' => auth()->id(),
                        'name'    => 'Export Posts CSV',
                        'total'   => $count,
                        'status'  => 'pending',
                        'payload' => ['resource' => 'posts'],
                    ]);

                    ExportPostsCsv::dispatch(
                        $task->id,
                        $records->pluck('id')->all(),
                        auth()->id()
                    );

                    Notification::make()
                        ->title('Export lancé')
                        ->body('Suivez la progression dans “Mes tâches”.')
                        ->success()
                        ->send();
                }),
        ];
    }
}

Cette approche n’embarque aucun modèle sérialisé dans la queue, uniquement des IDs, ce qui évite les problèmes d’objets volumineux et les incohérences de sérialisation. Le plafond à 50 000 lignes est un exemple raisonnable pour limiter les traitements trop longs sur une instance standard.

Page Filament "Mes tâches" avec barre de progression et polling

Générez une page Filament simple baptisée Tasks. Elle listera les 20 dernières tâches de l’utilisateur et se rafraîchira automatiquement toutes les 750 ms grâce à wire:poll. Le bouton de téléchargement apparaît lorsque file_path est défini et le statut passe à done. En cas d’échec, la raison est affichée.

php artisan make:filament-page Tasks

Complétez la classe de page pour exposer une propriété computed tasks et configurez l’icône et le libellé de navigation.

<?php

namespace App\Filament\Pages;

use App\Models\BulkTask;
use Filament\Pages\Page;
use Illuminate\Support\Collection;

class Tasks extends Page
{
    protected static ?string $navigationIcon = 'heroicon-o-clock';
    protected static ?string $navigationLabel = 'Mes tâches';
    protected static string $view = 'filament.pages.tasks';

    public function getTasksProperty(): Collection
    {
        return BulkTask::where('user_id', auth()->id())
            ->latest()
            ->limit(20)
            ->get();
    }
}

Dans la vue Blade resources/views/filament/pages/tasks.blade.php, utilisez le polling Livewire pour rafraîchir l’affichage. Le code suivant propose un rendu simple avec badge de statut, barre de progression et action Télécharger.

<x-filament::page>
    <div wire:poll.750ms>
        @forelse($this->tasks as $task)
            <div class="space-y-2 rounded-lg border p-4 mb-3">
                <div class="font-medium">{{ $task->name }}</div>

                <div class="flex items-center gap-2">
                    <span>Statut:</span>
                    <x-filament::badge>
                        {{ $task->status }}
                    </x-filament::badge>
                </div>

                <div>
                    Progression:
                    {{ $task->processed }} / {{ $task->total }}
                    ({{ $task->total ? intval($task->processed * 100 / $task->total) : 0 }}%)
                </div>

                <div class="h-2 rounded bg-gray-200 dark:bg-gray-700">
                    <div
                        class="h-2 rounded bg-success-600"
                        style="width: {{ $task->total ? ($task->processed * 100 / $task->total) : 0 }}%"
                    ></div>
                </div>

                @if ($task->file_path)
                    <a
                        href="{{ Storage::disk('public')->url($task->file_path) }}"
                        target="_blank"
                        class="fi-btn"
                    >
                        Télécharger
                    </a>
                @endif

                @if ($task->status === 'failed')
                    <div class="text-danger-600">
                        {{ $task->failed_reason }}
                    </div>
                @endif
            </div>
        @empty
            <div>Aucune tâche pour le moment.</div>
        @endforelse
    </div>
</x-filament::page>

L’ajout de cette page dans la navigation est automatique grâce aux propriétés statiques définies dans la classe. Vous pouvez ajuster son ordre avec protected static ?int $navigationSort si nécessaire.

Notifications de fin + action de téléchargement

La notification est envoyée dans le Job, après passage du statut en done. Elle inclut un bouton “Télécharger” qui ouvre le CSV dans un nouvel onglet via l’URL publique du disk. Le code utilise Notification::sendToDatabase($user) afin de persister la notification côté Filament et de la rendre visible même si l’utilisateur n’a pas la page ouverte pendant le traitement. Voici l’essentiel déjà intégré dans le Job :

use Filament\Notifications\Actions\Action;
use Filament\Notifications\Notification;
use Illuminate\Support\Facades\Storage;

Notification::make()
    ->title('Export prêt')
    ->body('Cliquez pour télécharger le CSV.')
    ->success()
    ->actions([
        Action::make('download')
            ->label('Télécharger')
            ->url(Storage::disk('public')->url($path), shouldOpenInNewTab: true),
    ])
    ->sendToDatabase($user);

En cas d’échec, la notification signale l’erreur de manière concise pour aider au diagnostic. L’exemple suivant, déjà dans le catch du Job, limite la longueur du message :

Notification::make()
    ->title('Export échoué')
    ->body(str($e->getMessage())->limit(140))
    ->danger()
    ->sendToDatabase($user);

Si vous souhaitez en plus afficher un toast immédiat à l’initiateur, conservez la notification ->send() dans la Bulk Action côté interface. Le Job, lui, ne peut pas émettre de toast en direct puisqu’il s’exécute côté worker, sans session navigateur.

Tester en conditions réelles

Commencez par créer un jeu de données conséquent afin d’observer clairement la progression. Par exemple, générez 5 000 posts avec une factory pour simuler un export coûteux.

php artisan tinker
>>> \App\Models\Post::factory()->count(5000)->create();

Ouvrez ensuite votre ressource Filament des posts, sélectionnez un grand nombre de lignes (ou utilisez un filtre et “Select all on all pages” si activé), puis lancez la Bulk Action “Exporter CSV (async)”. Surveillez la console où tourne le worker queue:work pour voir les traitements s’enchaîner. Accédez à la page “Mes tâches” et observez la barre de progression qui monte au rythme du polling. Une fois le statut à done, cliquez sur “Télécharger” et vérifiez que le CSV contient bien les colonnes et l’encodage attendus (UTF-8, séparateur virgule par défaut). Si le navigateur ne parvient pas à accéder au fichier, vérifiez l’existence du lien de stockage public créé par php artisan storage:link.

Durcissement, nettoyage et variantes

Pour améliorer la robustesse, laissez Laravel réessayer le Job en cas de panne transitoire en déclarant public $tries=3 et un backoff exponentiel. Le timeout protège des traitements qui s’éternisent et évite d’accaparer un worker indéfiniment. En complément, pensez à limiter le volume sélectionnable dans la Bulk Action, comme illustré avec un plafond à 50 000. Cette contrainte évite les erreurs de mémoire et garde un temps de traitement raisonnable.

Côté atomicité, l’incrément de processed se fait via DB::raw pour éliminer les conditions de concurrence si plusieurs étapes mettent à jour en parallèle. Transmettez uniquement des IDs dans le Job plutôt que des modèles sérialisés afin de minimiser la taille du payload et de garantir la fraîcheur des données au moment du traitement.

La sécurité repose sur vos Policies. Vérifiez que l’utilisateur est autorisé à exporter les posts, et qu’il ne puisse consulter que ses propres tâches. La relation user_id sur BulkTask sert de base à ce contrôle, y compris dans la page “Mes tâches”. Voici un exemple de règle simple dans une Policy pour BulkTask afin d’empêcher la consultation d’une tâche qui ne lui appartient pas.

public function view(User $user, BulkTask $task): bool
{
    return $task->user_id === $user->id;
}

Pour le nettoyage, mettez en place une purge quotidienne des exports et tâches anciens. Un Scheduler peut supprimer les fichiers âgés et leurs enregistrements correspondants afin d’éviter l’encombrement du disk public. Cet exemple supprime les tâches “done” et leur fichier au-delà de 7 jours.

// app/Console/Kernel.php
protected function schedule(\Illuminate\Console\Scheduling\Schedule $schedule): void
{
    $schedule->call(function () {
        \App\Models\BulkTask::where('status', 'done')
            ->where('updated_at', '<', now()->subDays(7))
            ->get()
            ->each(function ($task) {
                if ($task->file_path) {
                    \Illuminate\Support\Facades\Storage::disk('public')->delete($task->file_path);
                }
                $task->delete();
            });
    })->dailyAt('03:30');
}

Côté UX, vous pouvez afficher dans la topbar un badge indiquant le nombre de tâches en cours (statuses pending/running) pour l’utilisateur connecté. Une autre amélioration consiste à proposer un bouton “Arrêter” qui marque la tâche comme annulée et que le Job vérifie périodiquement. Cette variante implique d’ajouter cancelled à l’énumération des statuts, puis d’interrompre proprement la boucle si la tâche est annulée.

Pour aller plus loin, la même architecture s’applique à de nombreuses actions “bulk” coûteuses : réindexation de recherche, génération de vignettes, export vers JSON ou Excel, ou encore envoi du résultat vers un bucket S3 en URL présignée.

Checklist

Avant de publier, relisez le code et les messages pour vous assurer de la cohérence, exécutez toutes les commandes et snippets sur un environnement local pour valider le flux complet, puis testez avec plusieurs tailles d’échantillons afin de confirmer la progression, la résilience du worker et la validité du CSV. Lorsque tout est satisfaisant, poussez vos modifications et déployez en vous assurant que le worker de queue tourne sous supervision.

Ressources

La documentation officielle de Laravel sur les files d’attente présente les concepts de base, la configuration des drivers et le fonctionnement de queue:work. Elle constitue le point d’entrée pour comprendre les essais, délais et timeouts. https://laravel.com/docs/11.x/queues

La documentation Filament v3 sur les Tables et Bulk Actions explique comment configurer des actions groupées et interagir avec la sélection de lignes. https://filamentphp.com/docs/3.x/tables/actions#bulk-actions

La référence Filament Notifications détaille l’API des notifications, y compris l’enregistrement en base et les actions cliquables. https://filamentphp.com/docs/3.x/support/notifications

Livewire v3 documente le polling côté composant, utile pour rafraîchir l’état des tâches sans écrire de JavaScript custom. https://livewire.laravel.com/docs/polling

La documentation Laravel sur le stockage de fichiers (disks, storage:link) décrit comment publier et servir des fichiers depuis le disk public. https://laravel.com/docs/11.x/filesystem

Supervisor est une solution éprouvée pour surveiller et relancer les workers en production sur Linux. Le guide DigitalOcean propose une mise en place claire avec des exemples concrets. https://www.digitalocean.com/community/tutorials/how-to-install-and-manage-supervisor-on-ubuntu-and-debian

Conclusion

Avec une table de suivi minimaliste, un Job de queue incrémental et une page Filament alimentée par Livewire, vous obtenez un export CSV non bloquant, fiable et agréable à utiliser. L’utilisateur lance l’action, voit sa progression en temps réel, puis reçoit un lien de téléchargement dès que le fichier est prêt, sans mobiliser le serveur web pendant de longues secondes. Cette méthode, générique et sans dépendances externes, s’adapte facilement à d’autres traitements lourds de vos back-offices Filament.