PHPnews.io

Using Symfony Form in WordPress

Written by JoliCode / Original link on Mar. 18, 2022

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 CMSdocumentation.

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!

Our WordPress Is Not Your Everyday Ride

bedrock-timber.png

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:

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.

Running a Symfony Form

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

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)NativeSession, and that the form component can handle request from the PHP superglobals (NativeRequestHandler). That means we don't need the HttpFoundation component at all!

Building 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'
);

Configuring 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.

But 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.

Getting 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:

Putting 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) }}

All 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 GPCSgpcs data for the following chars:

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 basemassive.

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;
        }
    }
});

End 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!

calevans beberlei calevans jolicode

« Get started with Symfony 6 - Detect and Change Indentation With PHP »