Keeping Symfony Form Values Around on Empty Submissions
Christopher Davis has written this article. More details coming soon.
Last week we ran into a problem with a Symfony form: we had a password field in a form meant to store an FTP password (FTP & SFTP are still the defacto way to move data around in the marketing world). When that form was first submitted with the password in place it was saved just fine, but subsequent submissions where the password was empty on the frontend of our application (for obvious reasons) would erase the password. Not good: expectations were violated and errors happened.
As complex as the Symfony form component is, it’s also incredibly flexible. Like the HTTP Kernel, the form component comes with a set of events that can used to keep original values around.
The essential idea here is that we’ll hook into the PRE_SUBMIT event and inspect the incoming data. If the field we want to keep is empty, we can use the property access component to fetch the original value from the object/array bound to the form on creation. The property accessor is important: data bound to forms may be objects or arrays or just about anything.
KeepValueListener.php
Of note here is that $event->getData() returns the data submitted to the form, while $event->getForm()->getData() returns the original values bound to the form on creation — what you passed in as the second argument to your controllers createForm method, for instance.<?php
use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Form\FormEvents; use Symfony\Component\Form\FormEvent; use Symfony\Component\EventDispatcher\EventSubscriberInterface; final class KeepValueListener implements EventSubscriberInterface { private $keepField; private $accessor; public function __construct($keepField, PropertyAccessorInterface $accessor=null) { $this->keepField = $keepField; $this->accessor = $accessor ?: PropertyAccess::createPropertyAccessor(); } public static function getSubscribedEvents() { return [ FormEvents::PRE_SUBMIT => 'onPreSubmit', ]; } public function onPreSubmit(FormEvent $event) { $field = $event->getForm()->get($this->keepField); $submitData = $event->getData(); if (empty($submitData[$this->keepField])) { $submitData[$this->keepField] = $this->accessor->getValue( $event->getForm()->getData(), $field->getPropertyPath() ); $event->setData($submitData); } } }
It’s pretty simple to prove this works with an integration test. I wouldn’t recommend a unit test for this listener or somethign that calls KeepValueListener::onPreSubmit directly because it’s less valuable than knowing that the listener works within the system as a whole.
Our test case will set up a form factory and provide a way to create form with the our listener added.
test_skeleton.php
<?php
use Symfony\Component\Form\Forms; use Symfony\Component\Form\Extension\Core\Type as CoreTypes; class KeepValueListenerTest extends \PHPUnit_Framework_TestCase { private $formFactory; protected function setUp() { $this->formFactory = Forms::createFormFactory(); } private function createForm($data) { return $this->formFactory->createBuilder(CoreTypes\FormType::class, $data) ->add('password', CoreTypes\PasswordType::class, [ 'empty_data' => null, ]) ->add('clear_password', CoreTypes\CheckboxType::class) ->addEventSubscriber(new KeepValueListener('password')) ->getForm(); } }
From here there’s two paths we need to test: submission with an empty value which should keep the original password value bound on form creation and submission with a new value which should keep the new value around.
KeepValueListenerTest.php
<?php
use Symfony\Component\Form\Forms; use Symfony\Component\Form\Extension\Core\Type as CoreTypes; class KeepValueListenerTest extends \PHPUnit_Framework_TestCase { public function testEmptyFieldValueInSubmissionKeepsTheOriginalValueAround() { $form = $this->createForm([ 'password' => 'original', ]); $form->submit([ 'password' => '', ]); $data = $form->getData(); $this->assertEquals('original', $data['password']); } public function testNonEmptyValueInSubmissionReplacesTheExistingValue() { $form = $this->createForm([ 'password' => 'original', ]); $form->submit([ 'password' => 'changed', ]); $data = $form->getData(); $this->assertEquals('changed', $data['password']); } // ... }
This listener as it stands makes this impossible, so let’s fix it! We’ll add another field the listener is aware of that tells the thing to erase the field on a truthy value.
KeepValueListener changes a bit to accept the $clearField to the constructor. If it sees a truthy value in that field it will erase the other field by setting its data to the forms configured empty value.
KeepValueListener2.php
<?php
final class KeepValueListener implements EventSubscriberInterface { private $keepField; private $clearField; private $accessor; public function __construct($keepField, $clearField=null, PropertyAccessorInterface $accessor=null) { $this->keepField = $keepField; $this->clearField = $clearField; $this->accessor = $accessor ?: PropertyAccess::createPropertyAccessor(); } public function onPreSubmit(FormEvent $event) { $field = $event->getForm()->get($this->keepField); $submitData = $event->getData(); $erase = false; if ($this->clearField && isset($submitData[$this->clearField])) { $erase = self::asBool($submitData[$this->clearField]); } if ($erase) { $submitData[$this->keepField] = $field->getConfig()->getEmptyData(); $event->setData($submitData); return; } if (empty($submitData[$this->keepField])) { $submitData[$this->keepField] = $this->accessor->getValue( $event->getForm()->getData(), $field->getPropertyPath() ); $event->setData($submitData); } } private static function asBool($value) { return filter_var($value, FILTER_VALIDATE_BOOLEAN); } }
And of course we need a test case to verify this.
KeepValueListenerTest2.php
<?php
class KeepValueListenerTest extends \PHPUnit_Framework_TestCase { public function testFormSubmissionWithClearFieldSetRemovesTheValue() { $form = $this->createForm([]); $form->submit([ 'password' => 'test', 'clear_password' => '1', ]); $data = $form->getData(); $this->assertNull($data['password']); } }
Even if this example doesn’t fit your use case(s), it’s a great demonstration of how flexible the form component can be. Events make data and form modification extremely powerful, but there’s also things like data transformers and modification of existing form types with type extensions.
Stay in touch
Subscribe to our newsletter
By clicking and subscribing, you agree to our Terms of Service and Privacy Policy
All of the code for this blog post is available on gist.github.com.