Backbeat Software
Photo by Martin LONGIN on Unsplash

Use Bootstrap 3 and 4 form themes in the same Symfony project

How to get different sections of your project using different form themes automatically.

Glynn Forrest
Monday, February 8, 2021

Last year I helped upgrade a Symfony project from Bootstrap 3 to Boostrap 4. It had many different sections - the main application, admin area, marketing site, API, etc - which were all powered by the same Symfony codebase.

We wanted to upgrade the project one section at a time, i.e. move the admin area to Boostrap 4 first, deploy it, then move onto the other areas later.

Given a project structure like this:

templates
├── admin
│   ├── base.html.twig
│   ├── manage_users.html.twig
│   └── nuclear_button.html.twig
├── app
│   ├── base.html.twig
│   ├── dashboard.html.twig
│   └── payment_details.html.twig
└── marketing
    ├── base.html.twig
    ├── home.html.twig
    └── pricing.html.twig

We’d like to upgrade the admin/ templates to use Bootstrap 4 first without affecting anything else.

Most of this is straightforward if each section uses a different set of CSS files. Simply update admin.scss to import bootstrap 4 instead of 3 - simple!

Other aspects are harder though. What about form themes? Can we tell the admin/ templates to use the bootstrap_4_layout.html.twig theme, while keeping bootstrap_3_layout.html.twig for everything else?

Like most things in software, there are some options!

Tired approach - the form_theme twig tag

Assuming the default form theme is for Bootstrap 3:

# config/packages/twig.yaml
twig:
    form_themes: ['bootstrap_3_layout.html.twig']

We can override the form theme on a per-template basis with the {% form_theme %} twig tag:

{# admin/nuclear_button.html.twig #}
{% form_theme nuclear_launch_form 'boostrap_4_layout.html.twig' %}

{{ form_start(nuclear_launch_form) }}
    <div class="alert alert-danger">
        Warning! This button will launch the rockets!
    </div>
    {{ form_row(nuclear_launch_form.big_red_button) }}
{{ form_end(nuclear_launch_form) }}

This is the approach advocated for on the Symfony docs, and works pretty well for everyday use.

However, it doesn’t scale so well for this application, which has a lot of templates, not all of them extending from the same base templates, with multiple forms and partials.

We’d have to add {% form_theme %} to lots of files, then turn around and remove them all when we change the default theme in config/packages/twig.yaml to bootstrap_4_layout.html.twig anyway!

Wired approach - an event listener

What we really want is a way to tell twig:

All templates we render in the admin area should use the Bootstrap 4 form theme. For everything else, use Bootstrap 3.

  • Can we create multiple twig.yaml configuration files? Not really.
  • What about multiple twig environments? Way too complicated.

What does setting the twig.form_themes configuration node to ['bootstrap_3_layout.html.twig'] actually do? Let’s dive into the Symfony source to investigate.

  • form_themes is a configuration node defined in the TwigBundle’s Configuration class.
  • The Twig dependency injection extension uses that value to set the twig.form.resources parameter.
  • That parameter is injected into the TwigRendererEngine, which in turn is injected into the FormRenderer.
  • At the bottom of the chain, the FormRenderer is responsible for actually generating the form elements. It will use the injected TwigRendererEngine (an implementation of FormRendererEngineInterface) to find and render the form block we’re after, using the boostrap_3_layout.html.twig theme we configured.

Phew! Okay, what about the {% form_theme %} twig tag?

That’s actually fairly straightforward.

  • The FormRenderer has a setTheme() method.
  • The FormThemeNode calls this method when that part of the template is evaluated.

Does this mean we can call setTheme() in our code too?

It certainly does! Let’s create a listener / form extension to do that for admin routes.

<?php

namespace App\Form;

use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\FormRenderer;
use Symfony\Component\Form\FormView;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Twig\Environment;

class FormThemeExtension extends AbstractTypeExtension
{
    private $twig;
    private $enabled = false;

    public function __construct(Twig $twig)
    {
        $this->twig = $twig;
    }

    public function onKernelRequest(GetResponseEvent $event)
    {
        $routeName = $event->getRequest()->attributes->get('_route', '');

        // Some logic to determine this is an admin route
        if (substr($routeName, 0, 6) === 'admin_') {
            $this->enabled = true;
        }
    }

    public function buildView(FormView $view, FormInterface $form, array $options)
    {
        if (!$this->enabled) {
            // Do nothing if not on an admin route
            return;
        }

        if ($form->getParent() instanceof FormInterface) {
            // Don't modify child forms, just the root
            return;
        }

        $this->twig->getRuntime(FormRenderer::class)
            ->setTheme($view, 'form/bootstrap_4_layout_custom.html.twig');
    }
}

There are two parts to this class:

  • onKernelRequest() detects when we’re on an admin route and sets the $enabled property to use later. Make sure to give it a kernel.event_listener tag for the kernel.request event.
  • buildView() (part of Symfony\Component\Form\AbstractTypeExtension) is called every time a form view is created. This is perfect time to grab the FormRenderer and tell it to use a different theme for this form.

Conclusion

That’s it! While it might seem complicated, this approach dramatically reduces the amount of manual updates we have to make.

It also makes it way easier to switch another section of the project over to the newer theme:

  public function onKernelRequest(GetResponseEvent $event)
  {
      $routeName = $event->getRequest()->attributes->get('_route', '');

-     if (substr($routeName, 0, 6) === 'admin_') {
+     if (substr($routeName, 0, 6) === 'admin_' || substr($routeName, 0, 4) === 'app_') {
          $this->enabled = true;
      }
  }

These kinds of approaches are only possible because of Symfony’s architecture.

I’ve heard people complain about the sheer amount of code and layers of abstraction it has, but it’s this abstraction that makes it suited for almost any web application problem you can think of. Once you’ve mastered it, Symfony can do basically anything.

Need a hand with your project? Send me an email!

More from the blog

Logged out ajax requests in Symfony applications cover image

Logged out ajax requests in Symfony applications

Handling logged out ajax requests properly using Symfony’s security features.


Glynn Forrest
Monday, June 29, 2020

Symfony routing tricks (part 2) cover image

Symfony routing tricks (part 2)

Writing a custom router to handle some unusual requirements.


Glynn Forrest
Tuesday, March 31, 2020

Symfony routing tricks (part 1) cover image

Symfony routing tricks (part 1)

Advanced routing techniques for your Symfony applications.


Glynn Forrest
Saturday, February 29, 2020