A while ago, I wrote a post entitled “Faux Form Serialization“. It was an overly-complex solution to a very easy problem. Today, I am writing about a much better implementation for solving the same problem.

What is this concept? You’ve heard of MongoDB right? CouchDB? BeullerDB…? Of course you have. You’ve been tired of writing migrations to add and remove columns that are constantly changing, and at some point have had the thought

“By golly, I should just slap an options field on this honkey!”

You’re exactly right, and this is what you should do.

Doctrine already has a lovely array field type. Similarly, it serializes an array into a longtext database column and unserializes upon hydration. Well that sounds useful!

Exposing these fields can be tricky though. We have this lovely form framework with all its pretty widgets and validators, and the ability to embed them! Yet, since this isn’t a true related object, we can’t do this out of the box… or can we?!

Example Time

Your sfGuardUser object has a laundry list of silly auxiliary fields, including items such as “how often do you zumba?” and “what animal produces your favorite milk?”. You refuse to write migrations for these things. Instead, you’ll save them all to a single field of type array called extra_profile_fields. Good for you! Create a form:

// lib/form/sfGuardUserExtraProfileFieldsForm.class.php
class sfGuardUserExtraProfileFieldsForm extends BaseForm
{
   public function configure()
   {
      $this->setWidgets(array(
        'zumba_frequency' => new sfWidgetFormChoice(array('choices' => array('Not at all', 'Sometimes', 'Every Single Day', 'I NEVER STOP'))),
        'favorite_animal_milk' => new sfWidgetFormInput(),
      ));

      $this->setValidators(array(
        'zumba_frequency' => new sfValidatorChoice(...),
        'favorite_animal_milk' => new sfValidatorString(...),
      ));
   }
}

Now, use my very special sfWidgetFormEmbeddedForm and sfValidatorEmbeddedForm

// lib/form/doctrine/sfDoctrineGuardPlugin/sfGuardUserForm.class.php
class sfGuardUserForm extends PluginsfGuardUserForm
{
    public function configure()
    {
       ....
       $form = new sfGuardUserExtraProfileFieldsForm();
       $this->widgetSchema['extra_profile_fields']    = new sfWidgetFormEmbeddedForm(array('form' => $form));
       $this->validatorSchema['extra_profile_fields'] = new sfValidatorEmbeddedForm(array('form' => $form));
    }

Wow, is it that easy? YES IT IS. The form will render expectedly, with validators and all. However, make sure you pass the same form object to both the widget and the validator! If you, for instance, pass a new sfGuardUserExtraProfileFieldsForm instance to each, you’ll have two separate forms, and errors in your form will not be rendered.

Extra Fun: Using Archivers

Let’s say you wanted the options to save as YAML, JSON, or a PHP-serialized string. As long as you use the “array” column type in doctrine, none of this is necessary. But if you need this flexibility, I’ve got your back.

The ArchiverInterface specifies a class with three methods: sleep, wake, and isAsleep. Essentially, it will convert from a certain array to a format and back again. Here are some sample archivers for your pleasure alone. Pass the class name as the “archiver” option to widget and validator, and you’re all set!

Hopefully you guys will enjoy this and use this as much as I have. And please don’t hesitate to submit comments, suggestions, and questions.

I thought some of you symfony developers out there might appreciate the following form validators. They have been incredibly convenient, and were incredibly simple to write. I hope they come in handy:

  1. sfValidatorPhone: Validates a phone number using regex
  2. sfValidatorZip: Validates a zip code using regex
  3. sfValidatorUrl: Validates a url using regex
  4. sfValidatorCCExpirationDate (1.2, 1.4): validates the month/year on a credit card. Goes well with sfWidgetFormCCExpirationDate.
  5. sfValidatorCreditCardNumber: validates a credit card number with an optional card type parameter.
  6. sfValidatorCoupon: Used in payment forms to validate a coupon

What other useful validators should I include?

***** UPDATE *****

Symfony versions 1.3 and 1.4 broke backwards compatibility with some of these validators. I’ve added a link to each symfony version to fix this.

I just posted to the Centresource code blog about form testing in Symfony. Check it out:

Testing forms in Symfony can be a pain. The form framework form tester is an awesome tool, but it is a pain to fill out each form field for every form test you want to run. It is tedius, looks messy, and can be hard to maintain. I wrote a helper class to put filling in of unimportant form fields out of site and out of mind

Read the rest here.

Symfony makes good use of the flat-string array syntax, a sudo-serialization technique.  This is great for objects that may require different options based on other column values (such as a “type” column). The synax works like this:

this=that

key=value

something=Something Else

This flat string is then parsed into an associative array with the sfToolkit::stringToArray() method.  Using this, we can translate these values into the form framework by creating an OptionsForm class.  The class will parse the values into individual fields, and provide widgets for the users to edit.  When bound, the form will collapse these values back into a flat string.

The first step is to create an option form that extends the sfForm class:

  class sfOptionsForm extends sfForm

Next, allow the options string (or array) to be passed in via the constructor:

  public function __construct($defaults)
  {
    // convert options string to array
    if(!is_array($defaults))
    {
      $defaults = sfToolkit::stringToArray($defaults);
    }
    $this->option_defaults = $defaults;
    return parent::__construct();
  }

Then, override the setup method to create basic widgets and validators.

  public function setup()
  {
    foreach ($this->option_defaults as $field => $default)
    {
      $this->widgetSchema[$field] = new sfWidgetFormInput();
      $this->validatorSchema[$field] = new sfValidatorString(array('required' => false));
    }
    $this->setDefaults($this->option_defaults);
  }

That’s it! I would recommend extending this form in order to customize your widgets, but this is enough to get you started. Now, you need to add some methods to the form in order for the binding to be successful. In this example, we are using sfOptionsForm for an object field called “options”. In our parent form, we need to set this same field to our new form.

  $this->embedForm('options', new sfOptionsForm($this->getObject()->getOptions());

The trickiest part of this whole process occurs when the form is bound. In order for this to function properly, we need to override our parent form’s bind() method.

  public function bind(array $taintedValues = null, array $taintedFiles = null)
  {
     // do special binding for options if options exist
     if (array_key_exists('options', $taintedValues) && array_key_exists('options', $this->embeddedForms))
     {
       // Flattens the option form into a string
       $taintedValues['options'] = $this->embeddedForms['options']->getFlattenedValues($taintedValues['options']);

       // unset imbedded form, replace field with a string widget / validator
       unset($this['options']);
       $this->widgetSchema['options'] = new sfWidgetFormInput();
       $this->validatorSchema['options'] = new sfValidatorString();
     }
     $ret = parent::bind($taintedValues, $taintedFiles);
     return $ret;
  }

There are a few snags with this method. One occurs when a form does not validate on save. Since we unset the embedded form, we have to re-embed the form if the form is invalid. This can be rectified by adding code after the parent::bind() method in our code above.

  public function bind(array $taintedValues = null, array $taintedFiles = null)
  {
    // ...
    $ret = parent::bind($taintedValues, $taintedFiles);
    {
       $this->isBound = false;  // form must be unbound in order to embed a form
       $this->embedForm('options', new sfOptionsForm($this->getObject()->getOptions());  // re-embed the form
    }
    return $ret;
  }

And there you have it! You now have a dynamic form-building interface. In order for your dynamic forms to be ironclad, there are still a few hidden snags you should be aware of. First, you need to make sure default options always get passed to the sfOptionsForm class. If an empty string is passed, the embedded form will be empty. Secondly, you need to take into consideration what happens when new fields are added, or the type of your object is switched. I usually implement a check to ensure the proper options are being passed for the dynamic form type.

To see these forms in action, install the csDoctrineSlideshowPlugin, or view the code straight from the repo.