sensiolabs-de/rich-model-forms-bundle
A Symfony bundle for building forms around rich domain models and value objects. It supplies form types, data mappers, and transformers so immutable objects, DTOs, and constructor-based models can be bound and validated without public setters, keeping form and domain layers cleanly separated.
Install and register. The package is archived, so pin it and verify compatibility first:
composer require sensiolabs-de/rich-model-forms-bundle
Confirm the bundle FQCN in vendor/ (the SensioLabs → Qossmic rename changed namespaces in some tags) and register it:
// config/bundles.php
return [
// ...
SensioLabs\RichModelForms\RichModelFormsBundle::class => ['all' => true],
];
Where to look first. Open README.md, then src/DataMapper/ (the three mappers: DataMapper, ImmutableDataMapper, CallbackDataMapper) and src/Form/Type/ (the immutable date/time types and money wiring). Everything the bundle does is a thin DataMapperInterface implementation plus a couple of FormTypes — you can read it all in an afternoon.
First use case — map a form to an immutable value object. Replace a fragile chain of DataTransformers with an ImmutableDataMapper that calls a with*() method:
use SensioLabs\RichModelForms\DataMapper\ImmutableDataMapper;
use App\Domain\Money;
$builder = $this->createFormBuilder(null)
->add('amount', IntegerType::class)
->add('currency', ChoiceType::class, ['choices' => ['EUR' => 'EUR', 'USD' => 'USD']])
->setDataMapper(new ImmutableDataMapper(
Money::class,
'withAmount', // replaces the amount on the current Money instance
));
The key win: the form produces a real Money object, the domain stays setter-free, and the controller no longer hand-assembles the object in POST_SUBMIT.
Value-object forms (data_class => null). Use the bundle when a form should produce a rich object rather than mutate one. Set data_class => null on the root form and let the data mapper build the object. This is the canonical pattern for Money, DateTimeImmutable, and similar VO wrappers.
ImmutableDataMapper for with*() builders. Ideal for entities/VOs whose setters are private and whose only mutation path is a withAmount(), withStatus(), etc. The mapper receives the existing object (or null) plus the mapped child data, and returns a new instance — so it composes cleanly with FormEvents::POST_SUBMIT listeners that expect a fully-hydrated object.
DataMapper for property-path mapping. When a form is a projection of one nested field (e.g., a "set the street address" form on a User), map to address.street via PropertyPath rather than flattening the whole entity into the form. Useful for inline-edit UIs and API payloads where you only accept a subset of a larger aggregate.
CallbackDataMapper for legacy or exotic structures. When the target isn't a plain property (e.g., an associative array of keyed options, or a DTO whose constructor takes a compound argument), pass closures for read and write and keep the adaptation logic out of the form type. This is the safest escape hatch before subclassing.
Immutable date/time types. Swap DateType/DateTimeType for the bundle's immutable variants where the destination property has no setter. Pair them with the mapper — the DateTimeMutableType + ImmutableDataMapper combination is the usual end-to-end setup.
MoneyPHP integration. The bundle was born out of SensioLabs' need to form-map Money\Money without breaking its invariants. Use the bundled money form type plus an ImmutableDataMapper that delegates to $money->add(...)/$money->subtract(...) or constructs a fresh Money from the mapped amount and currency.
Functional tests still work normally. Because these are standard DataMapperInterface implementations, TypeTestCase/KernelTestCase assertions (assertTrue($form->isValid()), assertSame($expected, $form->getData())) behave identically. Write one test per mapper that asserts identity ($form->getData() !== $original for immutable paths) — that's the contract you're buying.
Composition with PropertyPathMapper. Data mappers apply to the root form only. Child forms keep Symfony's default mapping; use the bundle mapper at the top and normal field types below. If you need per-child custom logic, use CallbackTransformer on the child instead of nesting mappers.
It's archived — treat it as frozen. No Symfony 7 support guarantees, no security backports, zero dependents. Before adopting, run composer why-not sensiolabs-de/rich-model-forms-bundle and confirm your Symfony version. For new projects, implementing DataMapperInterface yourself (30 lines) is often a better long-term bet than adopting an archived dependency; the bundle is essentially a curated set of those implementations.
data_class conflicts. Setting both data_class on the form and a custom data mapper leads to double mapping and confusing getData() results. Pick one: either an entity-backed form with a property path, or data_class => null with a mapper that constructs.
Null handling is on you. On first render, mapDataToForms() receives null (or an empty object). Your mapper and any ImmutableDataMapper closure must tolerate null — otherwise $form->getData() on an unsubmitted form throws. Guard with if ($data === null) { ... } and hydrate defaults explicitly.
Data mappers only run on compound/root forms. Adding setDataMapper() to a simple field type is a silent no-op. If mapping "isn't happening," confirm the form has children and is the root.
Ordering with POST_SUBMIT. The mapper runs before POST_SUBMIT listeners, so listeners see the fully-mapped object. But POST_SET_DATA fires during mapDataToForms() — reading $form->getData() there can see the pre-map value. Dump both $event->getData() and $form->getData() if behaviour looks inverted.
Empty submits and empty_data. A mapper that constructs a new object on every submit will mask "form was submitted empty" semantics. Combine with empty_data => null or an explicit PRE_SUBMIT guard when you need to distinguish "blank" from "unchanged".
PropertyPath syntax. DataMapper property paths go through Symfony's PropertyPath component: address.street works, address[street] is inconsistent, and array indices only work on arrays. Typo'd paths fail at submit time, not at build time — add a form-type test that submits and asserts getData().
Debugging recipe. On submit failures: dump($form->getData()) after handleRequest(), then step into the mapper's mapFormsToData(). Enable the form profiler in dev (framework.profiler + form panel) — it shows the mapper class and the resolved data, which is faster than var_dumping closures. For immutable mappers, assert object identity (spl_object_id) to prove a new instance was produced rather than a mutated one.
Extension point. If the three bundled mappers don't fit, implement Symfony\Component\Form\DataMapperInterface directly — three methods — and inject it. Don't fork the bundle; the interface is stable across Symfony majors and that keeps your escape route open when you eventually drop the dependency.
How can I help you explore Laravel packages today?