As announced in a previous post, symfony 1.2 is able to automatically save objects from deep nested forms. I gave a simple example in the announcement post, but some people asked me for a real project example. So here it is.

The project

Let's take a classified website. The website is composed of ads. Each ad is described with some generic information (like a title, a description, ...) and some more specific ones based on the ad type (like the number of beds or the year of construction for a house, or the make, the model, or the color for a car).

So, the model schema is composed of a main demo_ad table and some type tables (demo_ad_type_house and demo_ad_type_car) to host the detailed information for the ad:

# config/schema.yml
propel:
  demo_ad:
    id:              ~
    title:           { type: varchar(255), required: true }
    description:     { type: longvarchar, required: true }
    price:           float
    type:            { type: varchar(255), required: true }

  demo_ad_type_house:
    id:              ~
    demo_ad_id:      { type: integer, foreignTable: demo_ad, foreignReference: id, required: true }
    square_footage:  { type: integer, required: true }
    nb_beds:         { type: integer, required: true }
    nb_baths:        { type: integer, required: true }
    year:            { type: varchar(255), required: true }

  demo_ad_type_car:
    id:              ~
    demo_ad_id:      { type: integer, foreignTable: demo_ad, foreignReference: id, required: true }
    make:            { type: varchar(255), required: true }
    model:           { type: varchar(255), required: true }
    year:            { type: varchar(255), required: true }
    color:           { type: varchar(255), required: true }

To make it more real, let's add some initial data:

[yml]
# data/fixtures/ads.yml
DemoAd:
  house_1:
    title: Farm
    description: |
      250 acres with irrigation, several shares of water rights, creek, spring and a well.
    price: 2225000
    type: house
  car_1:
    title: Honda Accor
    description: |
      Honda accord fully loaded, power windows, sun roof, new timing belt, new brakes, a/c , cd player.
    price: 6900
    type: car

DemoAdTypeHouse:
  house_1_desc:
    demo_ad_id: house_1
    square_footage: 4500
    nb_beds:  4
    nb_baths: 3
    year:     1910

DemoAdTypeCar:
  car_1_desc:
    demo_ad_id: car_1
    make:  honda
    model: accor
    year:  2002
    color: green

Project Initialization

If you want to follow along, create a new symfony project the usual way:

$ mkdir classifieds
$ cd classifieds
$ symfony generate:project classifieds

Then create the two files we have described above (config/schema.yml and data/fixtures/ads.yml), configure the database, build the model, and feed the database with the initial data:

$ ./symfony configure:database "mysql:host=localhost;dbname=classifieds" root mYsEcret
$ mysqladmin -uroot -pmYsEcret create classifieds
$ ./symfony propel:build-all-load

In this post, we will create the backend of the application to demonstrate all the power of the new admin generator bundled with symfony 1.2.

$ ./symfony generate:app --escaping-strategy=on --csrf-secret=Unique$ecret1 backend

Then create the backend module to list, create, edit, and delete the ads:

$ ./symfony propel:generate-admin backend DemoAd

NOTE The propel:generate-admin automatically adds a route to the routing.yml configuration file.

The ad module is now ready to be used as shown on these screenshots:

Ads list action

Ads edit action

Project Customization

As you can see for yourself on the screenshots, it is not quite finished yet.

The type column, which is stored as a string in the database, need to be changed from an input text box to a select box in the form.

Instead of hardcoding the possible types in the form, declare them as a simple property of the DemoAdPeer class, so it can be reused later on in the project:

[php]
// lib/model/DemoAdPeer.php
class DemoAdPeer extends BaseDemoAdPeer
{
  static public $types = array('house' => 'house', 'car' => 'car');
}

It is now easy to change the type widget of DemoAdForm from a text input to a choice:

[php]
// lib/form/DemoAdForm.class.php
class DemoAdForm extends BaseDemoAdForm
{
  public function configure()
  {
    $this->widgetSchema['type'] = new sfWidgetFormChoice(array('choices' => DemoAdPeer::$types));
    $this->validatorSchema['type'] = new sfValidatorChoice(array('choices' => array_keys(DemoAdPeer::$types)));
  }
}

Ads edit action with type

When editing an ad, we want to edit the main information but also the detailed ones. So, we need to embed the specific description form in the main form.

As there is no database relation between the ad and the type, we need to create a custom method in the DemoAd model to get the DemoAdType* object:

[php]
// lib/model/DemoAd.php
class DemoAd extends BaseDemoAd
{
  public function getTypeObject()
  {
    // if no type has been defined yet, there is no type object
    if (!$this->getType())
    {
      return null;
    }

    // the type class depends on the ad type
    $class = sprintf('DemoAdType%s', ucfirst($this->getType()));
    $peer = constant($class.'::PEER');

    // get the type object related to the current ad
    $criteria = new Criteria();
    $criteria->add(constant($peer.'::DEMO_AD_ID'), $this->getId());

    // if there is none, create a new one associated with this ad
    if (is_null($type = call_user_func(array($peer, 'doSelectOne'), $criteria)))
    {
      $type = new $class();
      $type->setDemoAd($this);
    }

    return $type;
  }
}

Now for the fun part. Embed the type form into the main ad form if there is one:

[php]
// lib/form/DemoAdForm.class.php
class DemoAdForm extends BaseDemoAdForm
{
  public function configure()
  {
    $this->widgetSchema['type'] = new sfWidgetFormChoice(array('choices' => DemoAdPeer::$types));
    $this->validatorSchema['type'] = new sfValidatorChoice(array('choices' => array_keys(DemoAdPeer::$types)));

    // only embed if there is a type object (edit vs create)
    if ($this->getObject()->getType())
    {
      $this->embedForm('desc', $this->getTypeForm());
    }
  }

  public function getTypeForm()
  {
    $class = sprintf('DemoAdType%sForm', ucfirst($this->object->getType()));

    return new $class($this->object->getTypeObject());
  }
}

If you refresh your browser now, you will have an exception because the embedded form has a select box to choose the ad to which it is linked to. To render this select box, symfony needs a text representation of an Ad:

[php]
class DemoAd extends BaseDemoAd
{
  public function __toString()
  {
    return $this->getTitle();
  }

  // ...
}

Ads edit action with the embed form

As we don't want people to be able to change the link between the ad and the type, we need to disable the corresponding widget in the type form classes:

[php]
// lib/form/DemoAdTypeCarForm.class.php
class DemoAdTypeCarForm extends BaseDemoAdTypeCarForm
{
  public function configure()
  {
    unset($this['demo_ad_id']);
  }
}

// lib/form/DemoAdTypeHouseForm.class.php
class DemoAdTypeHouseForm extends BaseDemoAdTypeHouseForm
{
  public function configure()
  {
    unset($this['demo_ad_id']);
  }
}

Final ads edit action with the embed form

That's all there is to it. You can now change the main ad columns or the specific ones and when saving the form, symfony will save everything back to the database. And this has been possible with the admin generator without customizing anything. It just works!

Final ads edit action with the embed save

Final ads edit action with the embed form errors

Of course, you will find some edge cases that need to be worked on, but hopefully you are now able to customize it further.