Article Image
Jeudi 9 janvier 2025
Pour les projets nécessitant la gestion de plusieurs fichiers dans une seule opération, une approche efficace est essentielle.
Cet article explore l'upload multiple d'images avec Symfony et API Platform.
En s'appuyant sur une configuration adaptée et des outils comme les DTOs et Processors, l’article met en avant une structure modulaire permettant de gérer des relations complexes entre entités. 
Les concepts sont présentés de manière concise pour vous aider à intégrer cette fonctionnalité rapidement dans vos projets.

Pour un rappel sur le processus de transformation des données dans Symfony, consultez le Cycle de Transformation et Validation des Données décrit dans l'article sur l'upload d'une seule image."

Prérequis

Avant de débuter, il est essentiel de vérifier que les points suivants sont déjà en place dans le projet :

Une fois ces prérequis validés, on peut passer à l'étape suivante.

Installation de VichUploaderBundle

Le bundle VichUploader sera nécessaire :

composer require vich/uploader-bundle

Configuration de VichUploaderBundle et d'API Platform

Le mapping de configuration sera adapté pour fonctionner avec une entité dédiée : ImagesProducts.

# api/config/packages/vich_uploader.yaml
vich_uploader:
    db_driver: orm
    metadata:
        type: attribute
    mappings:
        images_products:
            uri_prefix: /images/products
            upload_destination: '%kernel.project_dir%/public/images/products'
            namer: Vich\UploaderBundle\Naming\SmartUniqueNamer

API Platform doit pouvoir accepter le format multipart.

#config/packages/api_platform.yaml
api_platform:
  formats:
    multipart: ['multipart/form-data']
    jsonld: ['application/ld+json']

Création d’une entité dédiée : ImagesProducts

Contrairement à l’upload d’une seule image, où les données des images sont souvent gérées directement dans une entité (comme Products avec une propriété imageName), ici, une entité distincte, ImagesProducts, sera créée.
Cette structure permettra de traiter chaque image de manière indépendante via une relation ManyToOne avec l’entité Products.

Structure de base de l'entité ImagesProducts :

Ajoutons tout d'abord l'attribut PHP #[Vich\Uploadable] à la classe Products, afin de spécifier qu'elle est uploadable dans le cadre de l'utilisation du VichUploaderBundle.```php use Vich\UploaderBundle\Mapping\Annotation as Vich;

#[Vich\Uploadable]
class ImagesProducts
{ 
   // ...
}

Nous devrons ensuite ajouter une propriété essentielle :

La propriété updatedAt est indispensable, car elle sert à informer Doctrine des modifications apportées aux fichiers, à déclencher les événements nécessaires et à garantir une synchronisation avec la base de données.

//src/Entity/ImagesProducts.php
//...
use Vich\UploaderBundle\Mapping\Annotation as Vich;

#[ORM\Entity(repositoryClass: ImagesProductsRepository::class)]
#[Vich\Uploadable()]
class ImagesProducts
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255)]
    private ?string $name = null;

    #[ORM\ManyToOne(inversedBy: 'imagesProducts')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Products $product = null;

    #[Vich\UploadableField(mapping: 'images_products', fileNameProperty: 'name')]
    private ?File $imageFile = null;

    #[ORM\Column(nullable: true)]
    private ?\DateTimeImmutable $updatedAt = null;     

    //...

    public function setImageFile(?File $imageFile = null): void
    {
        $this->imageFile = $imageFile;

        if (null !== $imageFile) {
            $this->updatedAt = new \DateTimeImmutable();
        }
    }
    public function getImageFile(): ?File
    {
        return $this->imageFile;
    }    
    //...
}

Une fois la configuration terminée, n'oubliez pas d'effectuer la migration en base de données.

Création du Decodeur et du Denormaliseur

Le rôle de classe MultipartDecode.

La classe MultipartDecoder sert de passerelle pour extraire et assembler les paramètres et les fichiers d'une requête multipart, les renvoyant sous la forme d'un tableau prêt à être utilisé lors du processus de dénormalisation

// src/Encoder/MultipartDecoder.php
namespace App\Encoder;

use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Serializer\Encoder\DecoderInterface;

final class MultipartDecoder implements DecoderInterface
{
    public const FORMAT = 'multipart';

    public function __construct(private readonly RequestStack $requestStack)
    {
    }
        public function decode(string $data, string $format, array $context = []): ?array
    {
        $request = $this->requestStack->getCurrentRequest();

        if (!$request) return null;

        return $request->request->all() + $request->files->all();
    }

    public function supportsDecoding(string $format): bool
    {
        return self::FORMAT === $format;
    }
}

(Source doc API Platform)

Le rôle du Dénormaliseur

La classe UploadedFileDenormalizer s'assure que les données à dénormaliser sont des fichiers, c'est-à-dire des instances de la classe File. Si cette condition est remplie, elle les retourne sans modification.

// src/Serializer/UploadedFileDenormalizer.php
namespace App\Serializer;

use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;

final class UploadedFileDenormalizer implements DenormalizerInterface
{
    public function denormalize($data, string $type, string $format = null, array $context = []): File
    {
        return $data;
    }

    public function supportsDenormalization($data, $type, $format = null, array $context = []): bool
    {
        return $data instanceof File;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [File::class => true,];
    }
}

(Source doc API Platform)

Création d'un DTO d'entrée

Les DTOs joueront un rôle clé pour structurer les données entrantes et sortantes. Par exemple, nous utiliserons un DTO d’entrée (très basique) pour encapsuler les données de la requête POST.

//src/ApiResource/ProductImagesRequestDto.php
namespace App\ApiResource;

readonly class ProductImagesRequestDto
{
    public function __construct(
        public string $name, //Nom du produit
        
        #[Assert\All([
            new Assert\File(
                maxSize: '5M',
                maxSizeMessage: 'Seules les images dont la taille est inférieure à 5 Mo sont acceptées.',
                mimeTypes: ['image/jpeg','image/png','image/webp'],
                mimeTypesMessage: 'Seules les images aux formats JPEG, PNG ou WEBP sont autorisées.'
            )
        ])]
        public array $imageFile, // Tableau d'images
    )
    {}
}

Implémentation d’un Processor personnalisé

Nous mettrons en place un Processor, responsable de gérer la logique métier associée à l’upload multiple.
Il prendra en charge la création du produit, l’association des images via l’entité ImagesProducts, et l’enregistrement des données dans la base de données.

//src/ApiPlatform/State/ProductImagesProcessor.php
namespace App\ApiPlatform\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\ProductImagesResponseDto;
use App\Entity\ImagesProducts;
use App\Entity\Products;
use Doctrine\ORM\EntityManagerInterface;

readonly class ProductImagesProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager)
    {
    }
    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): ProductImagesResponseDto
    {
        // On crée le nouveau produit
        $product = (new Products())->setName($data->name);

        // Traitement des fichiers uploadés
        foreach ($data->imageFile as $imageFile) {
            $image = new ImagesProducts();
            $image->setImageFile($imageFile);
            // Utilisation de la méthode existante pour ajouter l'image
            $product->addImagesProduct($image);
        }
        // On persiste
        $this->entityManager->persist($product);
        $this->entityManager->flush();

        // Et on construit la réponse avant de retourner
        $imageNames = $product->getImagesProducts()
            ->map(fn($image) => $image->getName())
            ->toArray();

        return new ProductImagesResponseDto(
            $product->getName(),
            $imageNames
        );
    }
}

⚠️ Remarque :

N'oubliez pas d'ajouter cascade: ['persist'] dans les relations si vous voulez que les entités liées soient enregistrées automatiquement en même temps que l'entité principale.

//Entité Products
#[ORM\OneToMany(targetEntity: ImagesProducts::class, mappedBy: 'product', cascade: ['persist'], orphanRemoval: true)]
private Collection $imagesProducts;

Création d'un DTO de sortie

Notre processor retourne une instance de ProductImagesResponseDto, notre DTO de sortie. Nous devons donc le créer !

<?php
//src/ApiResource/ProductImagesResponseDto.php
namespace App\ApiResource;

class ProductImagesResponseDto
{
    public function __construct(
        public string $name,
        public array $imageFile,
    ) {}
}

Configuration de l'Opération POST dans l'entité Products

#[Post(
    input: ProductImagesRequestDto::class,
    processor: ProductImagesProcessor::class,
    output: ProductImagesResponseDto::class,
)]
class Products

À ce stade notre upload d'images est fonctionnel, mais pour aller plus loin et démontrer une approche plus flexible et extensible, nous pouvons poursuivre en implémentant un normaliseur personnalisé, qui permettra d'enrichir dynamiquement les données de sortie.

Aller plus loin : Ajout d’un normaliseur spécifique

En complément du Decoder et du Denormalizer utilisés pour interpréter les données multipart, nous pouvons introduire un Normaliseur personnalisé. Son rôle sera d'enrichir le DTO de sortie en lui ajoutant, par exemple les URL d'images.

//src/Serializer/ProductImagesResponseNormalizer.php
namespace App\Serializer;

use App\ApiResource\ProductImagesResponseDto;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class ProductImagesResponseNormalizer implements NormalizerInterface
{
    private const string ALREADY_CALLED = 'PRODUCT_IMAGES_RESPONSE_NORMALIZER_ALREADY_CALLED';

    public function __construct(
        #[Autowire(service: 'api_platform.jsonld.normalizer.item')]
        private readonly NormalizerInterface $normalizer,
        private readonly string $uriPrefix
    ) {
    }

    public function normalize($object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
    {
        $context[self::ALREADY_CALLED] = true;

        foreach ($object->imageFile as $key => $image) {
            $object->imageUrls[$key] = $this->uriPrefix . $image;
        }     
        return $this->normalizer->normalize($object, $format, $context);
    }

    public function supportsNormalization($data, ?string $format = null, array $context = []): bool
    {

        if (isset($context[self::ALREADY_CALLED])) {
            return false;
        }

        return $data instanceof ProductImagesResponseDto;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [
            ProductImagesResponseDto::class => true,
        ];
    }
}

Sans oublier deux choses :

  1. Définir le paramètre vich_uploader.uri_prefix.images_products dans parameters de services.yaml, car il injecte sa valeur dans le constructeur du normaliseur via $uriPrefix.
# config/services.yaml
    # ...
parameters:
    # ...
    vich_uploader.uri_prefix.images_products: '/images/products/'
services:
    # ...
    App\Serializer\ProductImagesResponseNormalizer:
        arguments:
            $uriPrefix: '%vich_uploader.uri_prefix.images_products%'
    # ...
  1. Ajouter une propriété dans le DTO de sortie pour reçevoir les URL :
//src/ApiResource/ProductImagesResponseDto.php
namespace App\ApiResource;

class ProductImagesResponseDto
{
    public function __construct(
        public string $name,
        public array $imageFile,
        public array $imageUrls = [], //AJOUTEZ CECI !
    ) {}
}

Lancez le serveur si ce n'est pas déjà fait

symfony serve -d

Envoie d'une requête POST

Avec votre client API préféré (j'utilise Postman), nous envoyons une requête POST à cette adresse :
http://localhost:8000/api/products

Avec les paramètres suivants dans l'onglet body/form-data(multipart)

Key Value Type
name monProduit Text
imageFile[] [Vos fichiers] File

Et le format de réponse attendu dans le Header:

Key Value Type
Accept application/ld+json Text

⚠️ REMARQUE IMPORTANTE ⚠️

Assurez-vous d'ajouter les crochets "[]" à la clé imageFile[] dans le formulaire.
Cela est crucial pour indiquer au serveur que vous envoyez plusieurs fichiers dans une seule requête.
Sans ces crochets, les fichiers pourraient ne pas être correctement interprétés comme une collection, ce qui entraînerait des erreurs dans le traitement de votre requête.

Parfait ! Le serveur renvoie une réponse 201, accompagnée du nom du produit, des images uploadées et les urls grâce au normaliseur personnalisé.

Conclusion

L’upload de plusieurs images en une seule requête représente un défi technique que Symfony, API Platform et le VichUploaderBundle permettent de relever avec élégance.
En combinant une configuration adaptée et une gestion optimisée des entités, il est possible de créer une solution flexible et performante. Cette approche est idéale pour les projets nécessitant un traitement efficace des données en masse, tout en assurant une structure robuste et facilement extensible pour répondre aux besoins évolutifs de vos applications.

Documentation officielle d’API Platform, de Symfony et du VichUploaderBundle.


Technologies :

PHP Symfony API Platform