hostnet/form-handler-bundle
Symfony bundle that adds reusable form handler services to separate form processing from controllers. Define handlers for validation, persistence, and success/failure flow, keeping controller actions thin and form logic testable.
hostnet/form-handler-bundle is a Symfony bundle (not Laravel) that collapses the isSubmitted() && isValid() boilerplate into a single controller call. If you're a Laravel developer, the value proposition maps to a Form Request + controller action, but the implementation is Symfony Form-component-specific — it won't drop into an Eloquent/FormRequest codebase without the Symfony Form component and HttpKernel in place. Treat it as relevant when you're working in (or consulting on) a Symfony app.
Install and register (Flex handles this automatically):
composer require hostnet/form-handler-bundle
// config/bundles.php
Hostnet\Bundle\FormHandlerBundle\FormHandlerBundle::class => ['all' => true],
Where to look first:
src/ — the trait you mix into controllers and the handler service it delegates to. That's the entire contract.Tests/ — this is where you'll find the actual behavior for edge cases (non-submitted form, invalid form, callback return types) since docs rarely cover them.First use case — the contact/CRUD create form you'd otherwise write by hand:
use Hostnet\Bundle\FormHandlerBundle\Controller\FormHandlerControllerTrait;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
class ContactController extends AbstractController
{
use FormHandlerControllerTrait;
#[Route('/contact', name: 'contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
$form = $this->createForm(ContactType::class, new ContactDto());
return $this->handleForm($form, $request, function (ContactDto $data) {
// Only reached on a submitted + valid form.
$this->addFlash('success', 'Thanks, we will be in touch.');
return $this->redirectToRoute('contact');
});
}
}
The callback is the "success path"; if the form is unsubmitted or invalid, the trait falls through and lets you render the form. (Exact method names/signatures vary slightly between versions — confirm against the README handleForm/handler signature before copying.)
Post/Redirect/Get as the default. Always return a redirect from the success callback. Rendering the same template with a success flash after a POST means F5 re-submits and sends duplicate emails/creates duplicate rows. The trait is designed around this flow; lean into it.
Create + edit in one action. The handler doesn't care where the entity came from, so a shared controller works cleanly:
#[Route('/posts/new', name: 'post_new', methods: ['GET','POST'])]
#[Route('/posts/{id}/edit', name: 'post_edit', methods: ['GET','POST'])]
public function edit(?Post $post, Request $request): Response
{
$post ??= new Post();
$form = $this->createForm(PostType::class, $post);
return $this->handleForm($form, $request, function (Post $post) {
$this->em->flush();
return $this->redirectToRoute('post_show', ['id' => $post->getId()]);
});
}
Keep the callback thin — delegate to a service. The bundle buys you the dispatch logic; it doesn't buy you good separation. Inject a PostCreator / command-bus handler and call it inside the closure. This makes the "what happens on success" logic unit-testable without spinning up the kernel.
JSON/AJAX endpoints. Because the callback returns a Response, you can short-circuit the PRG pattern for XHR:
return $this->handleForm($form, $request, fn (ContactDto $dto) => $this->json(['ok' => true]));
Pair this with a check on $request->isXmlHttpRequest() if the same URL serves both HTML and JSON.
Reusable base controller. If you have 20+ actions, wrap the trait in AbstractAppController and add helpers (handleFormAndFlash, handleFormJson). This is the sane extension point — don't fork the bundle for it.
Validation groups and form options. Anything you'd pass to $form->submit() you still control via createForm(..., ['validation_groups' => ...]). The handler just calls handleRequest; it doesn't override your options. Don't expect it to.
Testing. Functional tests with KernelBrowser ($client->submitForm('Save', [...]), assert redirect + flash) cover the whole pipeline including CSRF. For faster tests, extract the callback body into a service and test that directly — the handler itself is the bundle's responsibility, not yours.
FormRequest integration, no Eloquent, no Blade. If you're evaluating this "for a Laravel project," stop — you'd need the Symfony Form component, which is a different framework's architecture.handleRequest semantics still apply. HTTP method, CSRF token, and _method spoofing are all Symfony's. If your form uses PUT/DELETE, the form must have method => 'PUT' and the template form_start(form) will render the hidden _method — the bundle won't infer it for you.null or an array from the success callback will produce a Response-shaped hole in your controller return type at runtime. Return a Response, always.Tests/ — if your version requires an explicit fallback response, silently returning null from a controller action is a classic 500.$form->isValid() failing lands you on the fallback path with form_errors(form) in Twig — if you're not rendering those, invalid submissions look like no-ops."hostnet/form-handler-bundle": "1.0.*" or whatever the release line is), and budget an hour to read src/ before relying on it in a revenue path. The trade-off is a very small, auditable surface — MIT licensed, so a fork is cheap.handleForm is unavailable or autowiring fails after a Composer update, check that config/services.yaml isn't excluding the bundle's namespace and that the trait is used (not extended). Bundle registration order rarely matters here, but Flex sometimes misses new bundles on upgrade — verify bundles.php.dd() inside the callback — if it never hits on a POST, the failure is upstream of the bundle: CSRF (csrf_token_id mismatch), wrong HTTP method in the route, or the form name not matching the request payload. Use the Symfony profiler's Forms panel to confirm isSubmitted/isValid without touching code.FormEvents (use POST_SUBMIT for cross-cutting concerns rather than stuffing the callback). Don't monkey-patch the bundle; decorate.How can I help you explore Laravel packages today?