Générer des factures PDF en masse avec FilamentPHP via une Bulk Action asynchrone
Apprenez à ajouter à un Resource Filament une action de table qui génère des factures PDF pour des centaines d’objets en arrière-plan, avec files, batchs et notifications.
Sommaire
Générer des factures PDF en masse avec FilamentPHP via une Bulk Action asynchrone
Générer des centaines de factures PDF sans bloquer l’interface, tout en offrant un suivi clair et des liens de téléchargement, est un cas d’usage idéal pour combiner Filament v3, les jobs Laravel, les batchs et la Media Library de Spatie. Ce tutoriel vous guide pas à pas, depuis l’installation jusqu’au widget de suivi, en ajoutant une Bulk Action asynchrone sur votre Resource Filament.
Objectif
L’objectif est d’ajouter à une Resource Filament une action de table qui génère des factures PDF pour des centaines d’objets en arrière-plan. L’utilisateur sélectionne ses commandes, lance l’action “Générer factures”, et le traitement s’exécute via la queue et des batchs Laravel. Chaque facture est rendue via Dompdf, stockée avec Spatie Media Library, et des notifications ainsi qu’un widget permettent de suivre et récupérer les PDF. Par exemple, un gestionnaire peut sélectionner 250 commandes en attente et déclencher la génération sans attendre que l’opération s’achève dans le navigateur.
Objectif et périmètre
Le but est de créer une Bulk Action Filament qui génère des factures PDF pour les commandes sélectionnées, sans bloquer l’UI. Concrètement, nous enverrons un job par commande dans un Bus::batch, afin de paralléliser et surveiller l’avancement. Les fichiers seront stockés dans une collection Media Library appelée invoices, rendus avec barryvdh/laravel-dompdf. Le résultat visible dans l’interface Filament sera une action “Générer factures” avec des options (choix de modèle, possibilité d’écraser l’existant), une notification de confirmation contenant l’ID du batch, et un widget listant les factures récemment produites. En pratique, cela vous permet de lancer massivement des PDF la nuit, de vérifier le statut minimal via la table job_batches, et de proposer aux utilisateurs finaux des liens de téléchargement à jour.
Pré-requis et installation
Assurez-vous d’être sur Laravel 10+ avec FilamentPHP v3 et un OrderResource déjà en place. Installez Dompdf et la Media Library de Spatie avec Composer, puis publiez les migrations nécessaires. Par exemple, sur un nouveau projet, exécutez les commandes suivantes pour installer les dépendances et créer les tables requises :
composer require barryvdh/laravel-dompdf spatie/laravel-medialibrary:^10
php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider"
php artisan vendor:publish --tag=media-library-migrations
php artisan migrate
Activez une queue asynchrone. La base de données est un bon point de départ, car elle ne demande pas d’infrastructure supplémentaire. Définissez la connexion et créez les tables nécessaires :
# .env
QUEUE_CONNECTION=database
# tables jobs et failed_jobs
php artisan queue:table
php artisan migrate
Activez les batchs de jobs afin d’obtenir un suivi basique (total, traités, échoués) dans la table job_batches :
php artisan queue:batches-table
php artisan migrate
Si vous souhaitez dédier un disque de stockage aux factures, ajoutez une entrée “invoices” dans config/filesystems.php. Cela isole les documents et facilite les règles de rétention :
// config/filesystems.php (extrait)
'disks' => [
// ...
'invoices' => [
'driver' => 'local',
'root' => storage_path('app/invoices'),
'url' => env('APP_URL') . '/storage/invoices',
'visibility' => 'public',
],
],
N’oubliez pas d’exécuter php artisan storage:link si vous souhaitez servir publiquement les fichiers depuis storage/app via /storage.
Modèle Order: intégrer Media Library
Dans App\Models\Order, implémentez HasMedia et utilisez le trait InteractsWithMedia pour brancher la Media Library. Déclarez une collection dédiée invoices, en choisissant un disque (public ou invoices) et en restreignant le type MIME aux PDF. Voici un exemple concret incluant l’option de n’autoriser qu’un fichier à la fois si vous ne voulez pas d’historique :
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
class Order extends Model implements HasMedia
{
use InteractsWithMedia;
protected $fillable = [
'number',
'customer_name',
'customer_email',
'total_in_cents',
'vat_rate',
];
public function registerMediaCollections(): void
{
$this->addMediaCollection('invoices')
->useDisk('public') // ou 'invoices' si vous avez créé ce disque
->singleFile() // retirez cette ligne pour conserver l’historique
->acceptsMimeTypes(['application/pdf']);
}
}
Avec singleFile(), un nouvel upload remplace automatiquement le précédent, ce qui simplifie la logique d’écrasement. Sans singleFile(), vous pouvez conserver l’historique complet des factures de chaque commande.
Vue Blade de facture
Créez une vue Blade minimaliste pour le rendu PDF. Dompdf gère bien le HTML/CSS basique ; évitez les layouts complexes, le positionnement fixed et les flexbox avancés. Préférez des tableaux simples, des marges définies via @page et une police compatible Unicode comme DejaVu Sans. Voici un exemple dans resources/views/pdf/invoice.blade.php :
{{-- resources/views/pdf/invoice.blade.php --}}
@php
$order = $order ?? null;
$currency = '€';
$vatRate = $order->vat_rate ?? 0;
$total = $order->total_in_cents / 100;
$vatAmount = round($total * ($vatRate / 100), 2);
$totalWithVat = round($total + $vatAmount, 2);
@endphp
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<title>Facture {{ $order->number }}</title>
<style>
@page { margin: 24mm; }
body { font-family: DejaVu Sans, sans-serif; font-size: 12px; color: #111; }
h1 { font-size: 20px; margin-bottom: 10px; }
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { border: 1px solid #ddd; padding: 8px; }
th { background: #f5f5f5; text-align: left; }
.totals { margin-top: 12px; width: 40%; float: right; }
.text-right { text-align: right; }
</style>
</head>
<body>
<h1>Facture {{ $order->number }}</h1>
<p>
Client: <strong>{{ $order->customer_name }}</strong><br>
Email: {{ $order->customer_email }}
</p>
<table>
<thead>
<tr>
<th>Produit</th>
<th class="text-right">Quantité</th>
<th class="text-right">Prix unitaire</th>
<th class="text-right">Total</th>
</tr>
</thead>
<tbody>
{{-- Exemple de lignes statiques pour la démonstration --}}
<tr>
<td>Commande {{ $order->number }}</td>
<td class="text-right">1</td>
<td class="text-right">{{ number_format($total, 2, ',', ' ') . " $currency" }}</td>
<td class="text-right">{{ number_format($total, 2, ',', ' ') . " $currency" }}</td>
</tr>
</tbody>
</table>
<table class="totals">
<tbody>
<tr>
<td>Sous-total</td>
<td class="text-right">{{ number_format($total, 2, ',', ' ') . " $currency" }}</td>
</tr>
<tr>
<td>TVA ({{ $vatRate }}%)</td>
<td class="text-right">{{ number_format($vatAmount, 2, ',', ' ') . " $currency" }}</td>
</tr>
<tr>
<th>Total TTC</th>
<th class="text-right">{{ number_format($totalWithVat, 2, ',', ' ') . " $currency" }}</th>
</tr>
</tbody>
</table>
</body>
</html>
Si vous insérez des images (logos), servez des assets locaux via public_path('images/logo.png') ou configurez Dompdf pour autoriser les URLs distantes en activant isRemoteEnabled. Les marges @page et une feuille de style sobre garantissent un rendu stable.
Job GenerateInvoicePdf
Créez un job ShouldQueue pour rendre la vue en PDF, nommer le fichier et l’enregistrer dans la collection invoices. Le job doit être idempotent : s’il existe déjà une facture identique et que l’option overwrite est désactivée, on sort sans rien faire. Vous pouvez aussi insérer une notification dans la table notifications si un utilisateur a demandé à être alerté.
php artisan make:job GenerateInvoicePdf
Voici une implémentation complète :
<?php
namespace App\Jobs;
use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Barryvdh\DomPDF\Facade\Pdf;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Notifications\DatabaseNotification;
class GenerateInvoicePdf implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public int $orderId,
public string $template = 'default',
public ?int $notifyUserId = null,
public bool $overwrite = false
) {}
public function handle(): void
{
$order = Order::findOrFail($this->orderId);
// Nom du fichier (idempotence basée sur le numéro de commande)
$filename = "invoice-{$order->number}.pdf";
// Idempotence : si on ne doit pas écraser et que le fichier existe déjà, on ignore
$existing = $order->getMedia('invoices')->firstWhere('file_name', $filename);
if (!$this->overwrite && $existing) {
return;
}
// Rendu du PDF avec options Dompdf
$pdf = Pdf::loadView('pdf.invoice', [
'order' => $order,
'template' => $this->template,
])
->setPaper('a4')
->setWarnings(false)
->setOption('isRemoteEnabled', true);
$output = $pdf->output();
// Si overwrite explicite et pas singleFile(), on peut supprimer l’ancienne facture
if ($this->overwrite && $existing) {
$existing->delete();
}
// Ajout dans la collection Media Library
$media = $order
->addMediaFromString($output)
->usingFileName($filename)
->toMediaCollection('invoices');
// Notification (optionnelle) stockée dans la base
if ($this->notifyUserId) {
DatabaseNotification::create([
'id' => (string) Str::uuid(),
'type' => 'App\\Notifications\\InvoiceGenerated',
'notifiable_type' => 'App\\Models\\User',
'notifiable_id' => $this->notifyUserId,
'data' => [
'message' => "La facture {$order->number} est prête.",
'order_id' => $order->id,
'file_name' => $media->file_name,
'url' => $media->getFullUrl(),
],
'read_at' => null,
]);
}
}
public function failed(\Throwable $exception): void
{
Log::error('Échec de génération PDF', [
'order_id' => $this->orderId,
'error' => $exception->getMessage(),
]);
}
}
Cet exemple active isRemoteEnabled pour permettre le chargement d’images distantes, désactive les warnings Dompdf et crée une DatabaseNotification minimaliste lorsque nécessaire. Si votre collection est en singleFile(), l’écrasement est automatique à chaque ajout.
Bulk Action Filament sur OrderResource
Dans la page ListOrders de votre OrderResource, ajoutez une Bulk Action nommée generateInvoices. Cette action affiche un court formulaire (template, overwrite, notify_user), crée un batch de jobs GenerateInvoicePdf, puis notifie l’utilisateur avec le nombre de jobs et l’ID du batch. L’interface est confirmée avant exécution et désélectionne les enregistrements une fois lancée.
<?php
namespace App\Filament\Resources\OrderResource\Pages;
use App\Filament\Resources\OrderResource;
use App\Jobs\GenerateInvoicePdf;
use App\Models\Order;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Forms;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Filament\Notifications\Notification;
class ListOrders extends ListRecords
{
protected static string $resource = OrderResource::class;
protected function getHeaderWidgets(): array
{
// Vous ajouterez ici votre widget de suivi des factures plus tard
return [];
}
protected function getTableBulkActions(): array
{
return [
Tables\Actions\BulkAction::make('generateInvoices')
->label('Générer factures')
->modalHeading('Générer des factures PDF')
->form([
Forms\Components\Select::make('template')
->label('Modèle')
->options([
'default' => 'Par défaut',
'pro' => 'Professionnel',
])
->required()
->default('default'),
Forms\Components\Toggle::make('overwrite')
->label('Écraser la facture existante')
->default(false),
Forms\Components\Toggle::make('notify_user')
->label('Me notifier quand c’est prêt')
->default(true),
])
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->action(function (Collection $records, array $data) {
$jobs = [];
/** @var Order $order */
foreach ($records as $order) {
$jobs[] = new GenerateInvoicePdf(
orderId: $order->id,
template: $data['template'] ?? 'default',
notifyUserId: ($data['notify_user'] ?? false) ? auth()->id() : null,
overwrite: (bool) ($data['overwrite'] ?? false),
);
}
$batch = Bus::batch($jobs)
->name('Factures ' . now()->format('Y-m-d H:i:s'))
->dispatch();
Notification::make()
->title('Génération lancée')
->body("{$records->count()} factures en file d’attente. Batch ID : {$batch->id}")
->success()
->send();
})
->visible(fn (): bool => auth()->user()?->can('generateInvoices', Order::class) ?? true),
];
}
}
En production, pensez à définir une Policy pour restreindre l’accès à cette action selon vos rôles, puis servez-vous de ->visible() pour la masquer aux utilisateurs non autorisés. Vous pouvez aussi afficher un lien vers un tableau de bord de suivi (Horizon par exemple) dans la notification.
Configuration de la queue et du batch
Démarrez le worker en développement avec php artisan queue:work afin de traiter les jobs dès qu’ils sont en file d’attente. En environnement de production, Horizon est recommandé, mais queue:work reste une option fiable pour des charges modérées.
php artisan queue:work
Chaque envoi de batch est enregistré dans la table job_batches. Vous pouvez consulter les colonnes total_jobs, pending_jobs, failed_jobs et finished_at pour un suivi minimaliste, par exemple via Tinker ou une vue d’administration. En cas d’erreur lors du rendu Dompdf (souvent liée à des images distantes bloquées, des polices non disponibles ou du CSS non supporté), capturez l’exception dans le job, consignez-la via Log ou Sentry, puis corrigez la vue en privilégiant des assets locaux ou en activant isRemoteEnabled comme montré plus haut.
Widget Filament: factures récemment générées
Créez un TableWidget qui liste les derniers médias de la collection invoices et offre un bouton “Télécharger”. Cela permet aux utilisateurs de récupérer rapidement les documents fraîchement générés. Voici un widget simple à placer sur votre Dashboard ou l’en-tête de ListOrders :
<?php
namespace App\Filament\Widgets;
use Filament\Tables;
use Filament\Widgets\TableWidget as BaseWidget;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
class RecentInvoicesWidget extends BaseWidget
{
protected static ?string $heading = 'Factures récemment générées';
protected function getTableQuery()
{
return Media::query()
->where('collection_name', 'invoices')
->latest()
->limit(15);
}
protected function getTableColumns(): array
{
return [
Tables\Columns\TextColumn::make('order_number')
->label('Commande')
->getStateUsing(fn (Media $record) => optional($record->model)->number ?? 'N/A'),
Tables\Columns\TextColumn::make('file_name')->label('Fichier'),
Tables\Columns\TextColumn::make('created_at')->label('Créé le')->dateTime('d/m/Y H:i'),
];
}
protected function getTableActions(): array
{
return [
Tables\Actions\Action::make('download')
->label('Télécharger')
->url(fn (Media $record) => $record->getFullUrl())
->openUrlInNewTab(),
];
}
}
Pour l’afficher sur votre dashboard Filament, enregistrez ce widget dans la page concernée. Pour l’ajouter en tête de ListOrders, retournez un tableau contenant RecentInvoicesWidget dans getHeaderWidgets() de votre page.
Action unitaire sur la ligne
Ajoutez aussi une action unitaire par ligne afin de régénérer la facture d’une seule commande. Cette action réutilise les mêmes options (template, overwrite) et déclenche un unique job. Vous pouvez ensuite notifier immédiatement l’utilisateur avec le lien de la dernière facture si vous utilisez singleFile().
<?php
use App\Jobs\GenerateInvoicePdf;
use Filament\Forms;
use Filament\Tables;
use Filament\Notifications\Notification;
use App\Models\Order;
// Dans votre définition de table (OrderResource)
Tables\Actions\Action::make('generateInvoice')
->label('Générer la facture')
->icon('heroicon-m-document-arrow-down')
->form([
Forms\Components\Select::make('template')
->label('Modèle')
->options([
'default' => 'Par défaut',
'pro' => 'Professionnel',
])
->required()
->default('default'),
Forms\Components\Toggle::make('overwrite')
->label('Écraser la facture existante')
->default(false),
])
->action(function (Order $record, array $data) {
GenerateInvoicePdf::dispatch(
orderId: $record->id,
template: $data['template'] ?? 'default',
notifyUserId: auth()->id(),
overwrite: (bool) ($data['overwrite'] ?? false),
);
$latest = $record->getFirstMedia('invoices');
$message = $latest
? "La dernière facture existante : {$latest->file_name}"
: "La génération démarre. Vous serez notifié dès que le PDF sera prêt.";
Notification::make()
->title('Facture en cours de génération')
->body($message)
->success()
->send();
})
->requiresConfirmation()
->visible(fn (): bool => auth()->user()?->can('generateInvoices', Order::class) ?? true);
Cette action est utile pour corriger une facture spécifique ou tester un nouveau modèle sans lancer un batch complet. En mode singleFile(), le lien existant reste valide et pointera vers le nouveau fichier une fois le job terminé.
Tests rapides et pièges courants
Commencez par tester avec une seule commande, puis avec des lots de 50 et 500 pour valider que l’utilisation de jobs maintient une empreinte mémoire stable côté serveur. Vérifiez vos permissions en utilisant des Policies et la méthode ->visible() afin de n’exposer l’action qu’aux rôles autorisés. Portez une attention particulière aux chemins d’images dans le PDF : préférez public_path('images/logo.png') pour des assets locaux, ou assurez-vous d’activer isRemoteEnabled si vous servez des images par URL. Contrôlez la taille des fichiers : setPaper('a4') et setWarnings(false) suffisent souvent, mais vous pouvez aussi simplifier les images. Enfin, informez l’utilisateur de la latence inhérente au traitement asynchrone grâce à une notification de lancement et un widget de suivi ; si vous ne souhaitez pas conserver l’historique des PDF, activez singleFile() et envisagez une tâche périodique pour nettoyer d’anciens fichiers si vous conservez plusieurs versions.
Checklist
Avant de mettre en production, relisez votre code et la vue Blade du PDF, testez les commandes Artisan (composer require, migrations, queue:work) et les snippets de Resource/Widget, puis publiez vos modifications avec une attention particulière à la configuration de la queue et des disques de stockage. En environnement réel, surveillez les logs lors des premières exécutions et validez que les batchs se terminent avec un taux d’échec nul.
Conclusion
Vous disposez maintenant d’une Bulk Action Filament qui génère des factures PDF en masse sans bloquer l’interface, avec un stockage fiable via Spatie Media Library, un rendu stable grâce à Dompdf et un suivi via les batchs et un widget dédié. Cette approche modulaire s’étend facilement : vous pouvez ajouter d’autres modèles de facture, intégrer Horizon pour le monitoring, ou déclencher des envois d’e-mails une fois les documents prêts. L’essentiel est de s’appuyer sur les jobs et le batching pour garder votre UI réactive tout en traitant efficacement de gros volumes.
Ressources
Pour approfondir, consultez la documentation de Filament sur les actions de table et les widgets, la Media Library de Spatie pour la gestion des fichiers, et le package Laravel Dompdf pour le rendu PDF. La référence Laravel sur les queues et les batchs fournit également des détails précieux sur le suivi et la tolérance aux pannes.
- Filament v3 (Tables, Actions, Widgets) : https://filamentphp.com/docs/3.x
- Spatie Media Library v10 : https://spatie.be/docs/laravel-medialibrary/v10/introduction
- barryvdh/laravel-dompdf : https://github.com/barryvdh/laravel-dompdf
- Queues et batchs Laravel : https://laravel.com/docs/10.x/queues
- Horizon (optionnel) : https://laravel.com/docs/10.x/horizon
# Récapitulatif des commandes utiles
composer require barryvdh/laravel-dompdf spatie/laravel-medialibrary:^10
php artisan vendor:publish --provider="Barryvdh\DomPDF\ServiceProvider"
php artisan vendor:publish --tag=media-library-migrations
php artisan migrate
php artisan queue:table && php artisan migrate
php artisan queue:batches-table && php artisan migrate
php artisan queue:work