pugx/bus-form-bundle
PugxBusFormBundle integrates Symfony Forms with a command/message bus, letting form submissions be dispatched as commands and handled by dedicated bus handlers. It keeps controllers thin, separates form handling from business logic, and enables a cleaner, more testable workflow for Symfony applicati...
Install and register
composer require pugx/bus-form-bundle:^1.5
v1.5.0 raises the floor to PHP 8.3 (8.2 support was dropped in PR #21). Check php -v and your CI matrix before bumping — a composer update on an 8.2 image will either fail resolution or silently keep you on 1.4.x. Pin the exact version while you evaluate.
Symfony Flex usually registers it; if not, add it to config/bundles.php (Symfony 5.4+/6+) and check whether a recipe dropped a config/packages/pugx_bus_form.yaml. If no recipe exists, the bundle likely works with zero configuration and a default bus.
v1.5.0 removed SimpleBus support (PR #22). The only bus abstraction wired up now is Symfony Messenger's MessageBusInterface. If your pugx_bus_form.yaml names a SimpleBus service as the bus, the container will fail to compile — see the Gotchas section for the migration shape.
Where to look first — in this order
README.md — updated wording/badges in v1.5.0, but for a 3-star bundle this is still the real documentation; the API surface is small.DependencyInjection/Configuration.php — this is the ground truth for every option (bus name, auto-dispatch flag, form type base class). Re-read this after the 1.5.0 upgrade — dropping SimpleBus almost certainly removed a config key, and debug:config will reject a stale one. Ignore the docs if they disagree.Form/ and EventSubscriber/ (or EventListener/) — find the class that listens on FormEvents::POST_SUBMIT; that's where the MessageBusInterface::dispatch() happens and where the whole package's behaviour lives.tests/ — the functional tests show the intended controller shape faster than any prose. v1.5.0 moved the suite to phpUnit 13 (PR #16), so if you copy test scaffolding from the bundle, match that major version or you'll fight attribute/assertion deprecations in your own suite.Confirm what you actually have installed before writing code:
composer show pugx/bus-form-bundle # confirm 1.5.x, and PHP requirement
php -v # must be >= 8.3
bin/console debug:config pugx_bus_form # options actually in the tree (changed in 1.5.0)
bin/console debug:container --tag=form.type # find the bundle's form types
Your first use case: a contact/registration form that dispatches a message instead of writing to the DB in the controller.
// src/Message/RegisterUser.php
final class RegisterUser
{
#[Assert\NotBlank]
#[Assert\Email]
public string $email = '';
#[Assert\Length(min: 8)]
public string $password = '';
}
// src/Controller/RegistrationController.php
#[Route('/register', methods: ['GET', 'POST'])]
public function register(Request $request): Response
{
$message = new RegisterUser();
$form = $this->createForm(MessageFormType::class, $message); // base type name varies
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// the bundle has already dispatched on POST_SUBMIT — do NOT dispatch here
return $this->redirectToRoute('registration_success');
}
return $this->render('registration/register.html.twig', ['form' => $form]);
}
Start on the sync transport so the handler runs inline and you can dump() inside it; switch the message's routing to async only once the handler is correct.
Validation lives on the message, not the form. The whole point of the bundle is that the message is the single input contract. Put constraints on the DTO so the same object is valid whether it arrives from HTTP, a console command, or a replay from the failure transport. Keep buildForm() to widget choice, labels, and layout only.
Immutable messages via empty_data. If your message has a constructor (good practice), the default data mapper can't instantiate it. Supply a factory:
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => RegisterUser::class,
'empty_data' => static fn (FormInterface $form): RegisterUser => new RegisterUser(
(string) $form->get('email')->getData(),
(string) $form->get('password')->getData(),
),
]);
}
Keep the controller to three lines. With auto-dispatch, the controller's only job is createForm → handleRequest → render/redirect. Extract the redirect target into a route constant so the "did it dispatch?" logic never leaks back in.
Command-bus vs. event-bus routing. Register the form-submitted message on your command bus (transactional, one handler, may return a result) rather than the event bus, otherwise you cannot tell the user "handler failed" unless you are on sync. With sync + Doctrine's messenger.middleware.doctrine_transaction you get an all-or-nothing submit for free.
One bus abstraction only. After v1.5.0 there is exactly one supported bus integration: MessageBusInterface from symfony/messenger. That's actually a simplification — routing, middleware, stamps, retries, and the failure transport are all standard Messenger features, so you can lean on the framework docs rather than bundle-specific config. If you need something SimpleBus-shaped, write a thin adapter service implementing MessageBusInterface that forwards to your own bus.
Progressive enhancement / multi-step. Each wizard step is its own short message; hold the accumulated state in the session (or a persisted draft entity with an ID) and dispatch the final "commit" message only on the last step. Never carry an entity inside a message body — carry the ID.
API/JSON endpoints. If you reuse the same form type behind a JSON controller, guard the automatic dispatch: the subscriber fires on POST_SUBMIT for any valid submit, and handleRequest will not mark a JSON body as submitted in older Symfony versions. Either build a separate FormType without the bundle base class, or submit manually with $form->submit($payload, false) and check isValid() before letting the subscriber see it.
Testing. Use the in-memory transport so you can assert on dispatched envelopes without a worker:
$client->submitForm('Register', ['register_user[email]' => '[email protected]', /* ... */]);
$transport = self::getContainer()->get('messenger.transport.async');
self::assertCount(1, $transport->getSent());
For sync messages, assert on the handler side effect (the user row exists) instead — it proves the whole chain. Note the bundle's own suite is on phpUnit 13 as of 1.5.0; if you extend or vendor its tests, keep your phpunit constraint aligned.
simple_bus/SimpleBus service reference in config/packages/pugx_bus_form.yaml — delete it or switch the value to your Messenger bus ID; (3) bin/console debug:config pugx_bus_form compiles cleanly, which catches any other key the release removed. Then run your form functional tests on a 1.5.0 branch before merging.CHANGELOG/git log before bumping — 1.5.0 is a good example of a "minor" version that contains two breaking-ish changes (PHP floor, SimpleBus removal). Assume you may need to fork; the package is small enough that vendoring the subscriber into your app is a viable escape hatch.POST_SUBMIT; if you also call $bus->dispatch($message) in the controller "just to be safe," every submit is handled twice. Grep your controllers for dispatch( after adding the bundle.isSubmitted() && isValid() in the controller is still required for the redirect. Rendering on a valid-but-already-dispatched form gives the user a re-submittable page backed by a second handler run.messenger:failed:*, not in the user's face. Configure failure_transport, monitor with bin/console messenger:failed:show, and keep a sync/handleSync variant for user-critical paths (registration, payments).async. Pass scalars/IDs and reload in the handler — this also sidesteps EntityManager-closed errors after a retry.array and normalise into value objects inside the handler, or the form's ArrayMapper will fight your DTO.POST_SUBMIT subscriber (file upload, audit log), check its priority against the bundle's. If yours must run first, give it a higher priority in getSubscribedEvents(); otherwise the message is already on the bus by the time you mutate the data.bin/console debug:event-dispatcher FormEvents::POST_SUBMIT to confirm the subscriber is registered, debug:config pugx_bus_form to confirm the bus name (especially after the SimpleBus removal), and messenger:consume -vv to see handler exceptions that the web request swallowed.MessageBusInterface for cross-cutting concerns, (b) overriding your message's FormType::buildForm() for UI, and (c) an EnvelopeStamp (tenant ID, correlation ID, user ID) added by a middleware — over patching the bundle. Stamps are the idiomatic way to carry request context into an async handler, and they're now the only bus integration surface, so they're worth learning properly.FormRequest → dispatch(new Job()) collapsed into one step. The pitfalls you already know transfer directly — validation location, sync-vs-queued, and idempotency on retry — and the package only removes the mapping boilerplate, not the need to think about any of them. After 1.5.0, the bus side is a plain PSR-friendly MessageBusInterface, which maps closely to Laravel's Bus::dispatch()/queue dispatcher, so a custom adapter between the two is trivial if you're bridging systems.How can I help you explore Laravel packages today?