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."
Avant de débuter, il est essentiel de vérifier que les points suivants sont déjà en place dans le projet :
id
et name
.Une fois ces prérequis validés, on peut passer à l'étape suivante.
Le bundle VichUploader sera nécessaire :
composer require vich/uploader-bundle
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']
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 :
id
: l'identifiant de l'image.name
: le nom de l'image.updatedAt
: indispensable pour la notification des évènements.product
: la relation manyToOne vers l'entité Products.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 :
imageFile
, accompagnée de ses getters et setters. Cette propriété permettra de stocker l'objet UploadedFile après la soumission du formulaire. À noter que ce champ ne correspond pas à une colonne de la table Products.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.
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;
}
}
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,];
}
}
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
)
{}
}
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;
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,
) {}
}
#[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.
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 :
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%'
# ...
//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
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é.
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.