Arrêtez de réécrire votre système d'import à chaque projet
Chaque projet e-commerce ou applicatif finit par avoir le même besoin : importer des fichiers CSV, XML ou JSON fournis par des fournisseurs tiers. Et à chaque fois, on repart de zéro. iq2i/data-importer est une librairie PHP qui centralise toute cette mécanique, y compris la gestion mémoire sur les gros fichiers et l'archivage en fin de traitement.
Le code qu'on réécrit à chaque fois
Prenons un cas concret : une boutique Symfony qui reçoit chaque nuit un flux produits CSV d'un fournisseur. 50 000 lignes. La première chose qu'on fait, c'est ouvrir le fichier avec fgetcsv() dans une boucle. Ça marche. Jusqu'au jour où le fichier fait 200 Mo et que PHP explose en mémoire.
La solution classique, c'est de passer à un lecteur par flux. Sauf qu'on se retrouve à gérer manuellement le pointeur de fichier, le parsing des en-têtes, la gestion des séparateurs non-standard (; au lieu de ,, merci les exports Excel). Et on n'a encore rien fait de métier.
Ensuite vient la question de l'archivage : est-ce que le fichier a été traité ? On le déplace ? On le renomme avec un timestamp ? On le supprime ? Ce genre de décision finit souvent dans un coin du execute() d'une commande Symfony, copié-collé d'un projet à l'autre avec quelques adaptations.
// Le classique qu'on retrouve dans trop de projets $handle = fopen('/var/import/products.csv', 'r'); $headers = fgetcsv($handle, 0, ';'); while (($row = fgetcsv($handle, 0, ';')) !== false) { $data = array_combine($headers, $row); // traitement métier... } fclose($handle); // Et ensuite, l'archivage bricolé rename( '/var/import/products.csv', '/var/import/archived/products_' . date('Y-m-d') . '.csv' );
Ce code fonctionne, mais il a des problèmes. Il ne gère pas les erreurs de lecture. Il ne batche pas les insertions en base. Il n'est pas testable facilement. Et il faudra le réécrire pour le prochain format XML du fournisseur suivant.
`iq2i/data-importer` : une architecture pensée pour durer
La librairie s'installe en une commande et requiert PHP >= 8.1 :
composer require iq2i/data-importer
Elle repose sur trois concepts : un Reader qui lit un format de fichier ligne par ligne sans tout charger en mémoire, un Processor qui contient la logique métier, et un Archiver optionnel qui gère le devenir du fichier après traitement. On instancie un DataImporter avec ces composants, et on appelle execute().
use IQ2i\DataImporter\Archiver\DateTimeArchiver; use IQ2i\DataImporter\DataImporter; use IQ2i\DataImporter\Reader\CsvReader; $dataImporter = new DataImporter( reader: new CsvReader('/var/import/products.csv', ProductDto::class, [ CsvReader::CONTEXT_DELIMITER => ';', ]), processor: $productProcessor, archiver: new DateTimeArchiver('/var/import/archived'), ); $dataImporter->execute();
Le rename() bricolé de tout à l'heure disparaît au passage : le DateTimeArchiver fourni range chaque fichier traité dans une arborescence datée sous le chemin indiqué. Et si la cible est un bucket S3 ou un SFTP, ArchiverInterface s'implémente en une classe.
Les Readers : CSV, XML, JSON, ou le vôtre
Trois readers sont fournis : CsvReader, XmlReader et JsonReader. Ils lisent tous les fichiers en streaming : pas de file_get_contents(), pas de simplexml_load_file() qui charge tout en RAM.
Pour XML et JSON, il est possible de cibler un nœud précis dans la structure du fichier. Utile quand le fournisseur enveloppe ses données dans une hiérarchie qu'on ne contrôle pas.
use IQ2i\DataImporter\Reader\XmlReader; // Structure : <catalog><author><books><book>...</book></books></author></catalog> $xmlReader = new XmlReader( '/var/import/catalog.xml', ProductDto::class, [XmlReader::CONTEXT_XPATH => 'catalog/author/books'] );
use IQ2i\DataImporter\Reader\JsonReader; // Structure : { "author": { "products": [...] } } $jsonReader = new JsonReader( '/var/import/catalog.json', ProductDto::class, [JsonReader::POINTER => '/author/products'] );
Si aucun des trois formats ne correspond, la librairie expose une ReaderInterface à implémenter. Ça reste rare, mais le cas existe : un format binaire propriétaire, un flux EDI, ou un fichier Excel converti à la volée.
Les DTOs : travailler avec des objets typés
Par défaut, le reader expose chaque ligne comme un tableau associatif. Mais on peut passer un nom de classe en second paramètre : la librairie utilise alors le Serializer de Symfony pour désérialiser chaque ligne directement dans un objet. On récupère dans le processor un DTO typé plutôt qu'un array. C'est plus sûr, plus agréable avec l'autocomplétion, et ça évite les $data['price'] qui plantent silencieusement si la clé change dans le prochain export du fournisseur.
namespace App\DTO; class ProductDto { public string $sku; public string $name; public float $price; public int $stock; }
use App\DTO\ProductDto; use IQ2i\DataImporter\Reader\CsvReader; $csvReader = new CsvReader('/var/import/products.csv', ProductDto::class, [ CsvReader::CONTEXT_DELIMITER => ';', ]);
Le Processor : toute la logique métier au même endroit
Le processor implémente ProcessorInterface et expose trois méthodes : begin() appelée une fois avant de traiter le fichier, item() appelée pour chaque ligne, et end() appelée à la fin. C'est là qu'on injecte ses services Symfony habituels : EntityManager, un service métier, un logger.
namespace App\Processor; use App\DTO\ProductDto; use App\Entity\Product; use App\Repository\ProductRepository; use Doctrine\ORM\EntityManagerInterface; use IQ2i\DataImporter\Exchange\Message; use IQ2i\DataImporter\Processor\ProcessorInterface; class ProductProcessor implements ProcessorInterface { public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ProductRepository $productRepository, ) {} public function begin(Message $message): void { // Rien à préparer ici pour ce cas } public function item(Message $message): void { /** @var ProductDto $dto */ $dto = $message->getData(); $product = $this->productRepository->findOneBySku($dto->sku) ?? new Product(); $product->setName($dto->name); $product->setPrice($dto->price); $product->setStock($dto->stock); $this->entityManager->persist($product); } public function end(Message $message): void { $this->entityManager->flush(); } }
Pour les fichiers volumineux, un flush à chaque ligne est trop coûteux. La librairie propose BatchProcessorInterface : on implémente une méthode batch() qui s'exécute tous les N éléments, et getBatchSize() qui fixe N. Le pattern est le même, on ajoute juste les deux méthodes.
use App\DTO\ProductDto; use App\Entity\Product; use App\Repository\ProductRepository; use Doctrine\ORM\EntityManagerInterface; use IQ2i\DataImporter\Exchange\Message; use IQ2i\DataImporter\Processor\BatchProcessorInterface; class ProductProcessor implements BatchProcessorInterface { private const BATCH_SIZE = 200; public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ProductRepository $productRepository, ) {} public function begin(Message $message): void { } public function item(Message $message): void { /** @var ProductDto $dto */ $dto = $message->getData(); $product = $this->productRepository->findOneBySku($dto->sku) ?? new Product(); $product->setName($dto->name); $product->setPrice($dto->price); $product->setStock($dto->stock); $this->entityManager->persist($product); } public function batch(Message $message): void { $this->entityManager->flush(); $this->entityManager->clear(); } public function end(Message $message): void { $this->entityManager->flush(); } public function getBatchSize(): int { return self::BATCH_SIZE; } }
Le
clear()dansbatch()est essentiel : il détache les entités déjà persistées de l'UnitOfWork de Doctrine. Sans ça, Doctrine continue de suivre des milliers d'objets en mémoire, ce qui annule en grande partie le bénéfice du batching.
L'intégration Symfony : une commande abstraite prête à l'emploi
Quand on travaille avec Symfony, la librairie fournit une AbstractImportCommand qui câble automatiquement DataImporter dans une commande console. Deux méthodes à implémenter : getReader() pour créer le reader adapté au format, et handleItem() qui retourne la logique par ligne sous forme de Closure. Trois hooks optionnels complètent le cycle : handleBegin(), handleBatch() et handleEnd().
namespace App\Command; use App\DTO\ProductDto; use App\Entity\Product; use App\Repository\ProductRepository; use Doctrine\ORM\EntityManagerInterface; use IQ2i\DataImporter\Bundle\Command\AbstractImportCommand; use IQ2i\DataImporter\Exchange\Message; use IQ2i\DataImporter\Reader\CsvReader; use IQ2i\DataImporter\Reader\ReaderInterface; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'app:import:products')] class ProductImportCommand extends AbstractImportCommand { public function __construct( private readonly EntityManagerInterface $entityManager, private readonly ProductRepository $productRepository, ) { parent::__construct(); } protected function handleItem(): callable { return function (Message $message) { /** @var ProductDto $dto */ $dto = $message->getData(); $product = $this->productRepository->findOneBySku($dto->sku) ?? new Product(); $product->setName($dto->name); $product->setPrice($dto->price); $product->setStock($dto->stock); $this->entityManager->persist($product); }; } protected function handleBatch(): callable { return function (Message $message) { $this->entityManager->flush(); $this->entityManager->clear(); }; } protected function getReader(?string $filename = null): ReaderInterface { return new CsvReader($filename, ProductDto::class, [ CsvReader::CONTEXT_DELIMITER => ';', ]); } }
La commande accepte automatiquement un argument filename. Elle embarque aussi des options de debug pratiques : --step pour avancer ligne par ligne, --pause-on-error pour stopper l'import dès qu'une ligne lève une erreur, et --batch-size pour ajuster la fréquence d'appel de handleBatch() sans toucher au code (100 par défaut).
# Import standard php bin/console app:import:products /var/import/products_20260805.csv # Debug ligne par ligne php bin/console app:import:products /var/import/products_20260805.csv --step # Arrêt sur erreur php bin/console app:import:products /var/import/products_20260805.csv --pause-on-error
Give it a try!
La librairie est disponible sur Packagist et son code source est ouvert sur GitHub. La version actuelle est la 4.4.0, compatible PHP >= 8.1. Les contributions, retours et issues sont les bienvenus.
Documentation : github.com/IQ2i/data-importer/tree/4.x/docs
Dépôt GitHub : github.com/IQ2i/data-importer