Trimming your Controllers using Form Handlers

“Your application should have thin controllers!” It’s one of the most common statements in framework-world. It’s the basis of creating a decoupled code-base. Yet, creating thin controller can be quite a challenge. Hostnet’s FormHandlerBundle can help you quite a bit. Let’s have a deeper look!

In this post, I’ll be using the BlogController#commentNewAction() controller of the Symfony demo:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// src/AppBundle/Controller/BlogController.php

// ...
public function commentNewAction(Request $request, Post $post)
{
    $form = $this->createForm(CommentType::class);

    $form->handleRequest($request);

    if ($form->isSubmitted() && $form->isValid()) {
        /** @var Comment $comment */
        $comment = $form->getData();
        $comment->setAuthorEmail($this->getUser()->getEmail());
        $comment->setPost($post);

        $entityManager = $this->getDoctrine()->getManager();
        $entityManager->persist($comment);
        $entityManager->flush();

        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }

    return $this->render('blog/comment_form_error.html.twig', [
        'post' => $post,
        'form' => $form->createView(),
    ]);
}

While the controller doesn’t do a lot of things, it’s impossible to call it thin. The main reason? It manages one big task: Handling form submission. Let’s move this to a service as well!

Meet the HostnetFormHandlerBundle

The HostnetFormHandlerBundle introduces the concept of form handlers. These handle form submissions through the request. Install the bundle using Composer:

1
$ composer require hostnet/form-handler-bundle

…and enable it:

1
2
3
4
5
6
7
8
9
10
11
12
// app/AppKernel.php

// ...
public function registerBundles()
{
    $bundles = [
        // ...
        new Hostnet\Bundle\FormHandlerBundle\FormHandlerBundle(),
    ];

    // ...
}

Creating a Comment Form Handler

Now we can extract the form handling part from the controller. First things first, the form has to be rendered:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/AppBundle/Form/CommentFormHandler.php
namespace AppBundle\Form;

use Hostnet\Component\Form\AbstractFormHandler;

// Handlers must implement FormHandlerInterface, but extending the
// AbstractFormHandler makes things much easier.
class CommentFormHandler extends AbstractFormHandler
{
    public function getData()
    {
        // returns the initial form data (e.g. a new entity).
        // As the CommentType constructs the initial data itself using the
        // data_class option, we don't return anything here
        return null;
    }

    public function getType()
    {
        // the FQCN of the form type related to this handler
        return CommentType::class;
    }
}

This handler is now capable of creating the required CommentType form.

In a controller, pass this form handler to the form_handler.provider.simple service to handle the form:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
use AppBundle\Form\CommentFormHandler;

// ...
public function commentNewAction(Request $request, Post $post)
{
    $handler = new CommentFormHandler();

    $this->get('form_handler.provider.simple')->handle($request, $handler);

    return $this->render('blog/comment_form_error.html.twig', [
        'post' => $post,
        'form' => $handler->getForm()->createView(),
    ]);
}

The form provider will now create the form based on getType() and handle it using the passed request.

Handle Successful Form Submissions

To actually handle the form, we have to implement FormSuccesHandlerInterface and its onSuccess() method. This method is executed when the form is submitted and valid.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// ...
use Hostnet\Component\Form\FormSuccesHandlerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\HttpFoundation\Request;
use Doctrine\Common\Persistence\ObjectManager;
use AppBundle\Entity\Comment;

// ...
class CommentFormHandler extends AbstractFormHandler implements FormSuccesHandlerInterface
{
    /** @var ObjectManager **/
    private $entityManager;
    /** @var TokenStorageInterface **/
    private $tokenStorage;

    public function __construct(ObjectManager $manager, TokenStorageInterface $tokenStorage)
    {
        $this->entityManager = $manager;
        $this->tokenStorage  = $tokenStorage;
    }
    
    public function onSuccess(Request $request)
    {
        $currentUser = $this->tokenStorage->getToken()->getUser();
        $post = ...;

        // get the form data (Comment entity) after success
        /** @var Comment $comment */
        $comment = $this->getForm()->getData();
        $comment->setAuthorEmail($currentUser->getEmail());
        $comment->setPost($post);

        // persist the new Comment entity
        $this->entityManager->persist($comment);
        $this->entityManager->flush($comment);

        // this is passed to the controller
        return true;
    }

    // ...
}

As the handler now has some dependencies, create a service to manage it:

1
2
3
4
5
6
# app/config/services.yml
services:
    app.comment_form_handler:
        class:    AppBundle\Form\CommentFormHandler
        shared:   false # form handlers are statefull, never share them.
        autowire: true  # we're lazy, let the container figure out all class dependencies

And slightly tweak the controller to use this service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ...
public function commentNewAction(Request $request, Post $post)
{
    // get the handler service
    $handler = $this->get('app.comment_form_handler');

    $result = $this->get('form_handler.provider.simple')->handle($request, $handler);
    if ($result) {
        // the form handler returns true, which means the form is handled
        // successfully
        return $this->redirectToRoute('blog_post', ['slug' => $post->getSlug()]);
    }

    // the form is not submitted or not valid, render the page again
    return $this->render('blog/comment_form_error.html.twig', [
        'post' => $post,
        'form' => $handler->getForm()->createView(),
    ]);
}

Getting the current Post in the Handler

There is one missing bit in the form handler: It has to be aware of the post we’re commenting on. To fix this, add a new $post property and setter in the handler. Form handlers are already statefull. This means adding another statefull property wouldn’t harm anyone.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
use AppBundle\Entity\Post;
// ...

class CommentFormHandler extends AbstractFormHandler implements FormSuccesHandlerInterface
{
    /** @var Post **/
    private $post;

    // ...   
    public function setPost(Post $post)
    {
        $this->post = $post;
    }

    public function onSuccess(Request $request)
    {
        $post = $this->post;
        // ...
    }
}

Now, from inside the controller, pass the current post to the handler:

1
2
3
4
5
6
7
8
// ...
public function commentNewAction(Request $request, Post $post)
{
    $handler = $this->get('app.comment_form_handler');
    $handler->setPost($post);

    // ...
}

Cool, that’s it! All form handling stuff is now managed in a form handler. This makes it easy to reuse. The final controller contains 15 lines (compared to the original 23 lines). More importantly, the form handling logic is now delegated to a reusable service.

Take Home’s

  • Controllers shouldn’t contain much logic
  • Hostnet’s FormHandlerBundle helps you extract form handling logic

On top of these features, the bundle provides a param converter to pass form handlers as controller arguments. If you find this usefull, checkout the docs.