8min.

Using Symfony Form in WordPress

What a strange idea!

Once upon a time, a developer was asked to move a form from one application to another. The source application was a Symfony app. The target application was WordPress, the CMS that runs the Web.

Follow us in that journey that will take you to the edge of what is possible and what should not be done, but most importantly it will show you how to use the full power of Symfony Form inside the WordPress CMS1.

Searching the Web for this kind of development you could find ekino-wordpress-symfony or LIN3S/WPSymfonyForm but those are not maintained anymore. So let’s go on an adventure!

Section intitulée our-wordpress-is-not-your-everyday-rideOur WordPress Is Not Your Everyday Ride

Timber and BedRock

There are multiple flavors of WordPress in this world, and this story took place in Bedrock.

This is a game changer platform because you get:

  • Composer: autoloading, plugins and themes management, no need to push vendor code…
  • Environment variables
  • Better folder structure

So adding Symfony component in this kind of project is a piece of cake, composer takes care of all the dependencies and it would have been a very bad idea to go without it.

Another nice thing about this WordPress is the use of Timber. It allows to separate the PHP logic from the HTML by exposing Twig to your WordPress themes.

A classic WordPress installation doesn’t have any Composer or Twig integration.

Section intitulée running-a-symfony-formRunning a Symfony Form

The form component is quite empty in itself, we will need to wire other components on top of symfony/form :

  • the rendering engine, with Twig, via Timber;
  • the CSRF protection for security;
  • the Validator for data validation;
  • the Translator to get the validation errors in the appropriate locale.

Here is the full list of new dependencies we had to install:

$ composer require symfony/form symfony/twig-bridge symfony/validator symfony/security-csrf doctrine/annotations symfony/translation

HTTP and Session handling are done by the HttpFoundation component in Symfony – which of course does not exists in WordPress; so it was a very nice surprise to learn that CSRF can use native PHP session as storage (NativeSessionTokenStorage)2, and that the form component can handle request from the PHP superglobals (NativeRequestHandler). That means we don’t need the HttpFoundation component at all!

Section intitulée building-a-translatorBuilding a Translator

For the Twig |trans filter and for the Validator error messages to work, we need a valid \Symfony\Contracts\Translation\TranslatorInterface implementation.

WordPress has its own translation functions __($text, $domain = 'default'), so we could have built some kind of bridge like that:

$translator = new class implements TranslatorInterface {
    public function getLocale()
    {
        return get_locale();
    }

    public function trans(string $id, array $parameters = [], string $domain = null, string $locale = null)
    {
        return __($id, $domain);
    }
};

But this would require adding the validation error messages to WordPress translation files, and parameters cannot be handled with it, so it would have been messy.

As we are not translating content in a template or any WordPress code, let’s use directly the Symfony provided translation files and Translator:

$translator = new \Symfony\Component\Translation\Translator('fr');
$translator->addLoader('xliff', new \Symfony\Component\Translation\Loader\XliffFileLoader());
$translator->addResource(
    'xliff',
    __DIR__ . '/../../../../vendor/symfony/validator/Resources/translations/validators.fr.xlf',
    'fr',
    'validators'
);

Section intitulée configuring-timberConfiguring Timber

Next we add the Form Twig extension to Timber. This must be done via filters, a common way in WordPress to extend and configure things:

use Symfony\Bridge\Twig\Extension\FormExtension;
use Symfony\Bridge\Twig\Extension\TranslationExtension;
use Symfony\Bridge\Twig\Form\TwigRendererEngine;
use Symfony\Component\Form\FormRenderer;
use Twig\RuntimeLoader\FactoryRuntimeLoader;

// Register a new location where to find templates
add_filter('timber/locations', function ($loc) {
    $loc[] = __DIR__ . '/../../../../vendor/symfony/twig-bridge/Resources/views/Form/';
    return $loc;
});

add_filter('timber/twig', function(\Twig\Environment $twig) use ($translator) {
    // Boot the Form rendering engine with our form theme
    $rendererEngine = new TwigRendererEngine([
        'bootstrap_3_horizontal_layout.html.twig',
        'your_custom_theme.html.twig'
    ], $twig);

    $twig->addRuntimeLoader(new FactoryRuntimeLoader([
        FormRenderer::class => function () use ($rendererEngine) {
            return new FormRenderer($rendererEngine);
        },
    ]));

    // Add extensions to add the appropriate functions (form(), |trans...)
    $twig->addExtension(new FormExtension());
    $twig->addExtension(new TranslationExtension($translator));

    return $twig;
});

Like this, the Twig instance used by Timber is now capable of displaying a Symfony Form using a Form Theme and translations.

Section intitulée but-there-is-no-auto-escapingBut There Is No Auto-Escaping!

One difference between Twig in Symfony and Twig in Timber is the escaping strategy. There is none in the later, it’s completely turned off by default 💥:

/**
 * By default, Timber does NOT autoescape values. Want to enable Twig's autoescape?
 * No prob! Just set this value to true
 */
Timber::$autoescape = false;

“No prob!” they say 😋

Try having rich data attributes (with HTML) on your form types and you are going to break the display. Try sending "><script>alert(1337)</script> in a text field and you are going to fear for your security.

Try setting Timber::$autoescape = true; and you are going to see the source code of everything. It’s like watching the Matrix green digital rain but with your WYSIWYG content.

That’s mostly a “legacy code” issue, as all the templates of our destination application assume no escaping, there is no |raw filters on variables containing safe HTML.

To avoid rewriting all the legacy templates if changing a default Timber behavior, we added a custom form theme to force attribute escaping:

{# your_custom_theme.html.twig #}
{# Add escaping on form attribute because Timber does not do it #}

{% block attributes -%}
    {%- for attrname, attrvalue in attr -%}
        {{- " " -}}
        {%- if attrname in ['placeholder', 'title'] -%}
            {{- attrname }}="{{ translation_domain is same as(false) or attrvalue is null ? attrvalue|escape('html_attr') : attrvalue|trans(attr_translation_parameters, translation_domain)|escape('html_attr') }}"
        {%- elseif attrvalue is same as(true) -%}
            {{- attrname }}="{{ attrname }}"
        {%- elseif attrvalue is not same as(false) -%}
            {{- attrname }}="{{ attrvalue|escape('html_attr') }}"
        {%- endif -%}
    {%- endfor -%}
{%- endblock attributes -%}

{%- block textarea_widget -%}
    {% autoescape 'html' %}
        {% set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) %}
        <textarea {{ block('widget_attributes') }}>{{ value }}</textarea>
    {% endautoescape %}
{%- endblock textarea_widget -%}

{%- block form_widget_simple -%}
    {% if type is not defined or type not in ['file', 'hidden'] %}
        {%- set attr = attr|merge({class: (attr.class|default('') ~ ' form-control')|trim}) -%}
    {% endif %}
    {%- set type = type|default('text') -%}
    {%- if type == 'range' or type == 'color' -%}
        {# Attribute "required" is not supported #}
        {%- set required = false -%}
    {%- endif -%}

    {% autoescape 'html' %}
    <input type="{{ type }}" {{ block('widget_attributes') }} {% if value is not empty %}value="{{ value }}" {% endif %}/>
    {% endautoescape %}
{%- endblock form_widget_simple -%}

That was a nice bump in the road – there is definitely an issue here, I suggest you enable autoescaping as soon as you can when using Timber.

Section intitulée getting-the-formfactoryGetting The FormFactory

This is the most complex service to boot manually in this project.

function init_form_factory(TranslatorInterface $translator): FormFactoryInterface {
    // Init Form Builder and Factory
    $formFactoryBuilder = new FormFactoryBuilder(true);

    // Add validation support
    $validator = Validation::createValidatorBuilder()
        ->enableAnnotationMapping()
        ->setTranslator($translator)
        ->setTranslationDomain('validators')
        ->getValidator()
    ;
    $formFactoryBuilder->addExtension(new ValidatorExtension($validator));

    // Add CSRF support
    $csrfGenerator = new UriSafeTokenGenerator();
    $csrfStorage = new NativeSessionTokenStorage();
    $csrfManager = new CsrfTokenManager($csrfGenerator, $csrfStorage);
    $formFactoryBuilder->addExtension(new CsrfExtension($csrfManager));

    return $formFactoryBuilder->getFormFactory();
}

The Form builder works very well with just new FormFactoryBuilder but we need to add our extensions:

  • Validation support: to automatically pass our data to the validator component upon submit;
  • CSRF protection: to add the token hidden field on our form and make sure it checks out.

Section intitulée putting-it-all-together-in-the-themePutting It All Together In The Theme

Now you can boot a Form and pass it to Timber anywhere you want, in a Page Template for example:

$context = Timber::get_context();

$contact = new Contact();
$formFactory = init_form_factory($translator);
$formBuilder = $formFactory->createBuilder(ContactType::class, $contact);

$form = $formBuilder->getForm();
$form->handleRequest();
if ($form->isSubmitted() && $form->isValid()) {
    // Do your thing, like sending an email maybe?
    wp_mail($contact->getEmail(), 'Thanks from the WordPress testing app', 'body lorem ipsum');

    // Redirect to avoid double submit
    wp_redirect($successUrl);
    exit;
}

$context['form'] = $form->createView();

Timber::render('page-contact.twig', $context);

Inside your view you run the form as usual:

{{ form_start(form) }}
    {{ form_errors(form) }}
    {{ form_row(form.firstName) }}
    {{ form_row(form.lastName) }}
    {{ form_row(form.email) }}

    <button class="btn" type="submit">Send</button>
{{ form_end(form) }}

Section intitulée all-my-values-are-backslashed-like-it-s-2010-againAll My Values Are Backslashed Like It’s 2010 Again 😱

As Symfony Form uses the NativeRequestHandler, it fetches the form data from the $_POST global. This is the standard way to gather form values in PHP, and we all used it one time or another.

There was also a behavior in PHP called Magic Quote. Younger developers may not know about it because it’s a relic of the past; it’s been deprecated and removed from PHP as it was kind of broken. Magic Quote automatically added backslashes inside GPCS3 data for the following chars:

  • single quote (');
  • double quote (");
  • backslash (\);
  • NUL (the NUL byte).

Why do we care if it’s disabled now inside PHP? Because WordPress is an exceptionally high backward compatibility software and still support PHP 5.6 under the hood!

And to keep that compatibility with lots of existing installations, plugins and themes, WordPress applies Magic Quote by default, manually and systematically. There is an eleven years old issue about that and it just shows how hard it is to move forward when you have such a MASSIVE user base4.

We had to find a solution because it’s breaking our data: if I submit “Vegan Mac 'n’ Cheese”, I will get “Vegan Mac \'n\' Cheese”, then “Vegan Mac \\'n\\' Cheese”, etc.

So we had to clean the globals like this:

$_POST = stripslashes_deep($_POST);

To avoid introducing a risk for existing plugins and themes in the WordPress breaking because of that (if they expect the backslashes to be there), we are going to escape the value just for the Request Handler. So we have to extends the NativeRequestHandler like this:

$formBuilder->setRequestHandler(new class extends NativeRequestHandler {
    public function handleRequest(FormInterface $form, $request = null)
    {
        $initialPostData = $_POST;

        try {
            // We need the RAW $_POST without Backslashes
            $_POST = stripslashes_deep($_POST); // See https://stackoverflow.com/questions/8949768/with-magic-quotes-disabled-why-does-php-wordpress-continue-to-auto-escape-my

            parent::handleRequest($form, $request);
        } finally {
            // Restore the data
            $_POST = $initialPostData;
        }
    }
});

Section intitulée end-of-the-rideEnd Of The Ride

We have successfully and securely moved our form from Symfony to WordPress and saved the day. Weeks of developments have been recycled as we did not have to rewrite our forms, so in that aspect this is a win.

On the other hand, what a mess! Mixing together two frameworks should not be the default path: WordPress has a lot of great form building plugins, and they are better integrated with the CMS than what we did. Use them.

A project timing, budget and legacy constraints can sometimes drive you to strange territories; but I’ve learned a lot along the way!


  1. There is a great piece of documentation about running Symfony Form outside Symfony on symfony.com by the way 

  2. WordPress does not use the native PHP Session at all! And starting your own is considered a bad practice – use this carefully. 

  3. G, P, C, E & S are abbreviations for the following respective super globals: GET, POST, COOKIE, ENV and SERVER. 

  4. Just as a reminder, WordPress runs approximately 43% of the Web! 

Commentaires et discussions

Nos articles sur le même sujet

Ces clients ont profité de notre expertise