Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Rich Model Forms Bundle Laravel Package

qossmic/rich-model-forms-bundle

The Qossmic Rich Model Forms Bundle integrates Symfony Forms with rich domain models, enabling form data to hydrate immutable objects and value objects through constructors instead of setters while keeping validation and mapping clean.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and register. One Composer package, no configuration file:

composer require qossmic/rich-model-forms-bundle
// config/bundles.php
return [
    // ...
    RichModelFormsBundle\RichModelFormsBundle::class => ['all' => true],
];

That is the whole setup — the bundle auto-registers a form type extension and swaps in its CallbackDataMapper as the default data mapper, so existing forms keep working untouched.

Where to look first. Two classes define the entire surface area:

  • RichModelFormsBundle\DataMapper\CallbackDataMapper — the DataMapperInterface implementation that actually reads/writes data.
  • RichModelFormsBundle\Form\Extension\CallbackDataMapperTypeExtension — where the added form options (getter, setter, factory, immutable) are declared and validated.

Everything else is boilerplate. The README examples are the canonical reference; there is no docs site.

First use case: replace a setter with a domain method. This is the reason the package exists — a model with private state and intention-revealing methods instead of setName():

final class User
{
    private string $name;

    public function __construct(string $name) { $this->name = $name; }

    public function getName(): string { return $this->name; }

    public function rename(string $name): void { $this->name = $name; }
}
final class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class, [
            'getter' => fn (User $user) => $user->getName(),
            'setter' => fn (User $user, string $name) => $user->rename($name),
        ]);
    }
}

Your controller does not change:

$form = $this->createForm(UserType::class, $user);
$form->handleRequest($request);

if ($form->isSubmitted() && $form->isValid()) {
    $em->flush(); // $user has already been mutated by the setter callback
}

If that works, you have already exercised the only mechanic you will use 90% of the time.

Implementation Patterns

1. Domain methods instead of setters (the default pattern). Put getter/setter on every field whose model API is not a plain property accessor:

$builder
    ->add('email', EmailType::class, [
        'getter' => fn (User $u) => $u->getEmail(),
        'setter' => fn (User $u, string $email) => $u->changeEmail($email), // validates, fires events
    ])
    ->add('password', PasswordType::class, [
        'getter' => fn (User $u) => '',
        'setter' => fn (User $u, string $plain) => $u->changePassword($plain),
    ]);

This is where the value is: the setter can reject invalid transitions, normalise input, dispatch domain events, etc., and the form still treats it as an ordinary field.

2. Read-only and computed fields. Supply a getter and skip writing. Use 'setter' => null if you only need display; use 'mapped' => false when you want the field to bypass the data mapper entirely (it then never invokes your callbacks):

$builder->add('fullName', TextType::class, [
    'mapped' => false,
    'getter' => fn (User $u) => $u->getFirstName() . ' ' . $u->getLastName(),
    'setter' => null,
]);

3. DTO / command forms with required constructor arguments. Symfony's default empty_data calls new $dataClass() with no arguments and blows up on immutable DTOs. Provide a closure:

$resolver->setDefaults([
    'data_class' => RegisterUser::class,
    'empty_data' => fn (FormInterface $form) => new RegisterUser(
        $form->get('email')->getData(),
        $form->get('password')->getData(),
    ),
]);

The bundle's factory option serves the same role for data classes it constructs itself — reach for whichever fits the form's lifecycle. For complex DTOs, a plain AbstractType per DTO plus empty_data is usually clearer than fighting the mapper.

4. Immutable / withX() models. For value objects that return a new instance on change, declare the model immutable and supply a factory that builds the replacement:

$builder->add('name', TextType::class, [
    'getter'    => fn (User $u) => $u->getName(),
    'immutable' => true,
    'factory'   => fn (User $u, string $name) => $u->withName($name),
]);

Because the data mapper receives the model by value, make sure you read the result back after handleRequest() — $user = $form->getData() — rather than assuming the object you passed in was mutated. Test this path explicitly; it is the part of the bundle with the least obvious semantics.

5. Value objects behind a single field. When a field maps to a VO rather than a scalar, attach a DataTransformer and let setter receive the VO:

$builder->add('price', MoneyType::class, [
    'getter' => fn (Product $p) => $p->getPrice(),
    'setter' => fn (Product $p, Money $money) => $p->setPrice($money),
])->get('price')->addModelTransformer(new StringToMoneyTransformer());

The getter must return something the field can render (string, array, VO with a transformer) — returning an object without a transformer is the most common runtime failure.

6. Collections. CollectionType entries are themselves forms built by the same (replaced) data mapper, so getter/setter on the entry type work per item. Define the entry type normally and let the collection handle array plumbing:

$builder->add('addresses', CollectionType::class, [
    'entry_type'    => AddressType::class, // AddressType uses getter/setter
    'allow_add'     => true,
    'allow_delete'  => true,
    'by_reference'  => false,
]);

7. DRY the callbacks up. For fields that repeat across types, set them as defaults in configureOptions() of a shared abstract type (getter/setter are options like any other and can be defaulted and overridden):

public function configureOptions(OptionsResolver $resolver): void
{
    $resolver->setDefaults([
        'getter' => fn (object $d) => $d->getEmail(),
        'setter' => fn (object $d, string $v) => $d->changeEmail($v),
    ]);
}

8. Coordinated writes. One field maps to one callback; anything that must update two properties, or derive one from another, belongs in a POST_SUBMIT/SUBMIT listener or a small custom DataMapperInterface — not in a setter that quietly does extra work.

Gotchas and Tips

  • "Unknown option 'getter'" in unit tests means the type extension is not loaded. Options exist only when the bundle is registered; test these forms in a KernelTestCase (or instantiate CallbackDataMapper plus the extension manually) rather than with a bare Forms::createFormFactory().
  • The bundle replaces the default data mapper. If a form type or a compiler pass calls setDataMapper(), or you set a custom data_mapper, your getter/setter callbacks are silently ignored — no error, just no writes. Verify with dump($form->getConfig()->getDataMapper()) when a form "does nothing".
  • Guard against null data. On create forms the callbacks fire with no initial object. Type-hint loosely (?User $user) or early-return; a TypeError inside getter surfaces as an opaque form failure.
  • setter receives the transformed value, not the raw request value. Constraints still run on the field, so validation and normalisation can live in either place — pick one and be consistent, or you will double-validate.
  • Setter exceptions bubble up as 500s. changeEmail() throwing a domain exception is not a form error. Catch it in a POST_SUBMIT listener and attach a FormError if users can trigger it.
  • mapped => false bypasses the mapper completely. Useful, but it is not the same as 'setter' => null; the former skips both callbacks, the latter only skips writing.
  • immutable + factory is the fragile corner. Never assume the original object was mutated; always reassign from $form->getData(). Write one test for "no change submitted" and one for "change submitted" before scaling the pattern.
  • Version drift is the real risk. Last release was March 2020 (Symfony 4.4/5.x era, PHP 7.x). On Symfony 6/7 and PHP 8.2+, audit before adopting: check PropertyPathMapper still exists in your Symfony version, run the test suite against your lowest supported PHP, and prefer pinning. The surface area is small enough that a bespoke DataMapperInterface (~60–80 lines) is a viable escape hatch if the bundle breaks.
  • Debugging recipe: dump() inside the setter to confirm invocation order; $form->getExtraData() for unmapped submissions; $form->getErrors(true) when a domain exception is swallowed upstream; and check property_path collisions if a field name does not match a real property.
  • No configuration, no extension points beyond the options. There is no YAML to tune and no service to decorate for the mapper itself. If you need behaviour beyond getter/setter/factory/immutable, write your own mapper on the specific form type rather than trying to extend this one.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
diephp/sequences
users-au/socialite-provider
twidpay/twid-logger
mrnewport/laravel-plaid
mrnewport/laravel-repo
mrnewport/laravel-docsign
mrnewport/laravel-stow
mrnewport/laravel-flow
mrnewport/laravel-priceable
mrnewport/laravel-nestedterms
fredmeng/laravel-apidoc-generator
itstructure/laravel-multilingual-tools
pdustdar/elasticsearch
markupai/php-sdk
rlacerda83/lumem-microservice-shipment
uni-method/symfony-lib
tacticmedia/scim-server-bundle
neika-ai/symfony-bundle
mattleads/agent-publishing-bundle
glavweb/framework-bundle