6 things to know about embedded forms in Symfony

When using symfony, if you use it long enough, pretty soon you will need to use embedded forms. The down side of this is, embedded forms can be problematic at best. Seeing as I have spent a lot of time getting embedded forms working just right, let me show you some of the things I have learned, so that maybe I can spare you from the same fate as our friend on the right.

1. save() is only called on the root form

That’s right! Only the root form has save() called. So if there’s other logic you want to run, you will want to override the saveEmbeddedForm method and call that code before. Oversimplification ahead: when you save a form with embedded forms, it calls $this->getObject()->save(), then it calls saveEmbeddedForms, which, for each embedded form, calls $form->saveEmbeddedForms() and then calls $form->getObject()->save(). This is critical to know, as it will save you a lot of headaches later on.

2. Extend classes for simplicity and security

When you’re working on embedding a form into a related object’s form, do yourself a favor and always extend your form to remove unneeded fields such as id, and the foreign key field. It will make your life much simpler.

For example, here’s the configure() command from my embedded version of the media registry form in my majaxDoctrineMediaPlugin:

The only reason I’m bringing the embedded form in is to be able to assign photos, videos, and audio clips to galleries, so all I really care about is $this['galleries_list']. Here’s how I use it then, in the Video form:

You see there how we pass the instance that is related to our object to the embedded form? That’s why we can unset all of the foreign keys… because it already knows about the relation! Removing the foreign keys keeps it simpler, and improves security.

3. Embedded many-to-many relations are TRICKY

Ok, well, they are if you don’t know what I’m about to tell you. But then you will. Remember when we were talking about save() and saveEmbeddedForms() and how saveEmbeddedForms() doesn’t call the embedded form’s save() function? Well, this is why many-to-many relations break down in embedded forms. The function to save those relations are never called. Even better, because the embedded forms are never officially bound, they don’t even store the data to run those functions. To solve this, I use a two-prong attack.

First, I trick it into allowing us to access the values passed through bind:

Then, after being tricked into thinking it’s a real form, we close the deal by forcing it to run the function Doctrine built to save our many-to-many relationship:

4. I do it MY SELF

While watching the screens roll by in #symfony (on irc.freenode.org), I often see people having this problem or that problem with related forms. Invariably, they will be using the embedRelation command. To be honest… I don’t know what this does. I do know people seem to have lots of trouble with it though! I know, it saves time. I know, it makes it easy. I know, it’s a stock function, so you should use it, instead of expending more effort.

I also know that my forms work! I’m a huge fan of Doing What Works(tm) and Getting The Job Done(tm). It may take an extra 3 minutes to extend your form class (point #2), and hack it to make your many-to-many relations work (point 3), but it’s a whole heck of a lot better than slamming your head into the wall (or keyboard) repeatedly.

5. Post validators only fire on the root form

Another reason to simply consider your embedded form a different beast than the form on it’s own, is that any post validators you have set to run in the embedded form will not be run! However, you can access your embedded form’s data in the root form’s post validator.

Here’s an example of how to make such a validator:

6. embedMergeForm

This is something picked up from Roland Tapken. It’s really quite beautiful. Before embedMergeForm, you had two options when it came to bringing two forms together. On one hand, you have mergeForm, which looks nice, but for all intents and purposes, without a lot of extra work, doesn’t work. On the other, you have embedForm, which works for many situations, but in some circumstances (the admin generator) it will produce horrible results visually. embedMergeForm is the key! It combines the beauty of mergeForm with the mostly functional setup of embedForm.

Just to make it real easy, here’s the code I use. Place this in your lib/form/BaseFormDoctrine.class.php:

  /**
   * Embeds a form like "mergeForm" does, but will still
   * save the input data.
   */
  public function embedMergeForm($name, sfForm $form)
  {
    // This starts like sfForm::embedForm
    $name = (string) $name;
    if (true === $this->isBound() || true === $form->isBound())
    {
      throw new LogicException('A bound form cannot be merged');
    }
    $this->embeddedForms[$name] = $form;
 
    $form = clone $form;
    unset($form[self::$CSRFFieldName]);
 
    // But now, copy each widget instead of the while form into the current
    // form. Each widget ist named "formname|fieldname".
    foreach ($form->getWidgetSchema()->getFields() as $field => $widget)
    {
      $widgetName = "$name-$field";
      if (isset($this->widgetSchema[$widgetName]))
      {
        throw new LogicException("The forms cannot be merged. A field name '$widgetName' already exists.");
      }
 
      $this->widgetSchema[$widgetName] = $widget;                           // Copy widget
      $this->validatorSchema[$widgetName] = $form->validatorSchema[$field]; // Copy schema
      $this->setDefault($widgetName, $form->getDefault($field));            // Copy default value
 
      if (!$widget->getLabel())
      {
        // Re-create label if not set (otherwise it would be named 'ucfirst($widgetName)')
        $label = $form->getWidgetSchema()->getFormFormatter()->generateLabelName($field);
        $this->getWidgetSchema()->setLabel($widgetName, $label);
      }
    }
 
    // And this is like in sfForm::embedForm
    $this->resetFormFields();
  }
 
  /**
   * Override sfFormDoctrine to prepare the
   * values: FORMNAME|FIELDNAME has to be transformed
   * to FORMNAME[FIELDNAME]
   */
  public function updateObject($values = null)
  {
    if (is_null($values))
    {
      $values = $this->values;
      foreach ($this->embeddedForms AS $name => $form)
      {
        foreach ($form AS $field => $f)
        {
          if (isset($values["$name-$field"]))
          {
            // Re-rename the form field and remove
            // the original field
            $values[$name][$field] = $values["$name-$field"];
            unset($values["$name-$field"]);
          }
        }
      }
    }
 
    // Give the request to the original method
    parent::updateObject($values);
  }

14 Comments

  1. Arialdo Martini

    Interesting post.
    I’m working on an alternative approach, which you can find described here.

    http://arialdomartini.wordpress.com/2011/04/01/how-to-kill-symfony%E2%80%99s-forms-and-live-well/

    I hope you can find it useful or at least inspiring ;)

    Reply

  2. Florian

    made my day, thx :smile:

    Reply

  3. chryssalid

    Thanks for reminding the tips. Sometimes one forgot things like the one described at section 1.

    Reply

  4. Shuro

    Point 3 saved the rest of my Workday….Okay, only a few hours left, but better than nothing.

    Reply

    • nisdec

      /sign /sign /sign /sign

      I already spent a whole day on this. And without this nice tricky hack I would have spent another 2 days on that.

      Thank you so much for this.

      Reply

      • Jacob Mather

        Glad you found this helpful!

        Reply

  5. nisdec

    @Point 3: For Propel you just need a little modification:

    public function saveEmbeddedForms($con = null, $forms = null)
    {
    if(!isset($this->getObject()->markForDeletion))
    {
    $this->saveGalleriesList($con);
    }
    parent::saveEmbeddedForms($con, $forms);
    }

    Reply

    • Jacob Mather

      Wonderful, thank you so much for adding to the other side of the equation. I stopped using Propel when I upgraded to symfony 1.4.

      Reply

  6. Pedro Bueno

    Ty SOOO much!!
    Your embedMergeForm saves my life =D
    o/

    Reply

    • Jacob Mather

      Thank you. I wish I could take credit for it, but that belongs to Roland Tapken

      Reply

  7. Mike

    Jacob:

    I am correcting the embedMergeForm to take field position into account(when useFields and setPosition is used):

    Instead of using
    foreach ($form->getWidgetSchema()->getFields() as $field => $widget)

    use the getPositions to set widgets, like so:


    foreach ($form->getWidgetSchema()->getPositions() as $field)
    {
    $widgetName = "$name-$field";

    if (isset($this->widgetSchema[$widgetName]))
    {
    throw new LogicException("The forms cannot be merged. A field name '$widgetName' already exists.");
    }

    $this->widgetSchema[$widgetName] = $form->widgetSchema[$field];
    // Copy widget
    $this->validatorSchema[$widgetName] = $form->validatorSchema[$field];
    // Copy schema
    $this->setDefault($widgetName, $form->getDefault($field));
    // Copy default value

    if (!$form->widgetSchema[$field]->getLabel())
    {
    // Re-create label if not set (otherwise it would be named 'ucfirst($widgetName)')
    $label = $form->getWidgetSchema()->getFormFormatter()->generateLabelName($field);
    $this->getWidgetSchema()->setLabel($widgetName, $label);
    }

    $prev = $widgetName;
    }

    and replace

    Reply

    • Jacob Mather

      I haven’t tried this myself yet but this looks good.

      However it also looks like your comment got cut off — care to add the rest? :)

      Reply

      • Mike

        Sorry, I believe I was finished, “and replace” must have been something I was typing before copy&paste.. Basically just replace the original foreach with

        foreach ($form->getWidgetSchema()->getPositions() as $field)

        and then replace all ‘$widget’ references within the foreach with:

        $form->widgetSchema[$field]

        Reply

  8. Valentas

    #5: You can set up Validation engine to cascade embeded form validation. What You need is to create a separate Form type. Refer to symfony docs at: http://symfony.com/doc/current/reference/forms/types/form.html

    Reply

Leave a Reply

*

twitter