fresh/vich-uploader-serialization-bundle
Symfony bundle that integrates VichUploaderBundle with the Symfony Serializer, exposing uploaded file URLs and metadata in JSON API responses. It removes the need for custom normalizers when serializing entities with file fields. Lightweight and easy to configure for API Platform and Symfony APIs.
This bundle is a small bridge: it teaches JMSSerializer how to turn a serialized payload (typically a base64 string) into a Symfony\Component\HttpFoundation\File\UploadedFile, so VichUploaderBundle's normal upload lifecycle can take over from there. If you already expose REST/JSON APIs and want clients to send files as JSON instead of multipart/form-data, this is the missing piece.
Minimal setup
composer require fresh/vich-uploader-serialization-bundle:^3.3
v3.3.0 is a dependency-only release: it widens the constraints so the bundle installs cleanly on Symfony 6. There are no new features, config keys, or BC breaks versus 3.2.x — the handler code path is unchanged. Practically, this means you can now add it to a Symfony 6 application without --with-all-dependencies gymnastics or a conflict workaround; on Symfony 5.x the older 3.2 line still works fine.
Register it alongside the two bundles it depends on (order matters only in that both must be present):
// config/bundles.php
return [
Vich\UploaderBundle\VichUploaderBundle::class => ['all' => true],
JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true],
Fresh\VichUploaderSerializationBundle\VichUploaderSerializationBundle::class => ['all' => true],
];
Confirm the serializer handler is actually registered before writing any code — most "it doesn't work" reports start here:
bin/console cache:clear
bin/console debug:container --tag=jms_serializer.subscribing_handler
You should see a handler from the Fresh\VichUploaderSerializationBundle namespace covering UploadedFile. The bundle exposes a serializer type alias (used as vich_upload in its published examples) that you attach to the uploadable property.
First use case: JSON create endpoint
use JMS\Serializer\Annotation as Serializer;
use Vich\UploaderBundle\Mapping\Annotation as Vich;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* @Vich\Uploadable
*/
class Product
{
/**
* @Vich\UploadableField(mapping="product_image", fileNameProperty="imageName")
* @Serializer\Type("vich_upload")
* @Serializer\Exclude(if="true") // never serialize the file object back out
*/
private ?UploadedFile $imageFile = null;
private ?string $imageName = null;
// getters/setters, setUpdatedAt() in setImageFile() if you rely on Vich's date-based update hook
}
Client payload:
{ "name": "Widget", "imageFile": "/9j/4AAQSkZJRgABAQ..." }
Controller:
$product = $this->serializer->deserialize($request->getContent(), Product::class, 'json');
$em->persist($product);
$em->flush(); // Vich's listener moves the file and fills imageName
That single flow — JSON in, UploadedFile out, Vich moves it — is 90% of day-to-day usage.
DTO-first, then map to the entity. Deserializing straight into a Doctrine entity couples your API contract to your persistence model and makes constructor-based JMS deserialization painful. Keep an UploadProductInput DTO with the vich_upload property, validate it, then copy fields onto the entity in a service. It also keeps the UploadedFile from leaking into serialized responses.
Output URLs via virtual properties, not the file property. The file property should be write-only (@Serializer\Exclude). Expose the public path with Vich's helper:
/**
* @Serializer\VirtualProperty
* @Serializer\SerializedName("imageUrl")
* @Serializer\Groups({"read"})
*/
public function getImageUrl(): ?string
{
return $this->imageName
? $this->uploaderHelper->asset($this, 'imageFile')
: null;
}
Inject Vich\UploaderBundle\Templating\Helper\UploaderHelper (or vich_uploader.templating.helper.uploader_helper) — fully autowirable on Symfony 6 — and combine with an asset base URL. Never expose imageName as a filesystem path.
PATCH / partial updates. JMS only invokes the handler when the key is present, so a missing imageFile naturally means "don't touch the file." For explicit removal, add a removeImage boolean to the DTO and null out imageFile/imageName yourself — the bridge has no concept of deletion.
Collections of files. If you need array<vich_upload>, verify the handler accepts a list type; if not, wrap the array in a value object with a dedicated property per index, or register a custom handler (below). Multiple uploads in one request are the most common reason to extend the bundle.
Validation after deserialization. Attach @Assert\File(maxSize="5M", mimeTypes={"image/jpeg","image/png"}) to the DTO property and run the validator in the controller. Remember the base64 decode happens before validation, so the validator cannot protect you from a 200 MB payload — cap request size at the web server and add an early length check.
Extension point: your own handler. The bundle's handler is intentionally narrow. To support URLs ("imageFile": "https://cdn/..."), S3 pre-signed references, or a different encoding, implement JMS\Serializer\Handler\SubscribingHandlerInterface for UploadedFile, register it with jms_serializer.subscribing_handler, and give it a distinct type name (e.g. app_upload_url) so both handlers can coexist. This is also the cleanest escape hatch if you'd rather not depend on the package — the handler is small enough to vendor into your app, and it is unchanged in 3.3.0, so a copy taken today is current.
Storage abstraction is preserved. The handler only creates the UploadedFile; Vich's storage service (vich_uploader.storage.local, ...gaufrette, S3 adapters) still performs the move. That means S3/Gaufrette setups keep working unchanged, and tests can swap in a filesystem storage.
composer.json.composer why-not after install, then the debug:container --tag=jms_serializer.subscribing_handler check. Compatibility failures usually show up as a missing or silently unregistered handler, not a hard error.test flag trap. UploadedFile created with $test = true returns from move() without moving anything. If uploads "succeed" but no file appears, inspect the object's isTest() — it's the single most common silent failure in bridges like this.getClientOriginalName()/getClientMimeType(). Do not name files from the payload and do not trust its MIME. Force a namer (uuid, sha1, or a custom NamerInterface) and validate with @Assert\File(mimeTypes: ...), which uses finfo on the temp file.client_max_body_size and PHP memory_limit bite before post_max_size does for JSON bodies. Reject oversized strings in a custom handler or a kernel request listener.@Serializer\Exclude on the file property prevents JMS from trying to serialize a live UploadedFile (which produces garbage or throws). Use groups (read/write) rather than excluding entirely if you ever need to inspect the input DTO.config/serializer) is a frequent "type not applied" cause — pick one mapping style per class and verify with bin/console debug:config jms_serializer.UploadedFile argument in a constructor will not be handled reliably. Use setters or @Serializer\Accessor for the file property.JMS\Serializer\Exception\RuntimeException (or a subclass). Catch it in the controller and convert to a 400 with a generic message — never echo the base64 content or filesystem paths back.prePersist/preUpdate. If the entity is otherwise unchanged, the update may not fire; the standard fix is touching an updatedAt field inside setImageFile().debug:container --tag=jms_serializer.subscribing_handler (is the handler there?), debug:config vich_uploader (is the mapping name right?), a profiler look at the serializer collector (did the type resolve?), and var_dump($uploadedFile->isValid()) before flush.flush(). Set vich_uploader.mappings.<mapping>.uri_prefix to a test value so asset() assertions are deterministic. Nothing in 3.3.0 changes this, so existing fixtures keep passing after the bump.How can I help you explore Laravel packages today?