Handling Related Models in Yii Forms

August 10, 2010
The Yii Book If you like my writing on the Yii framework, you'll love "The Yii Book"!

Normally two Models in an MVC architecture are related to each other, such as Employees and Departments (to use the classic example), where each employee is in one department and each department has multiple employees. Although Yii does a great job of auto-generating most of the code you need, including the form used to create and update a Model, the Yii-generated form won’t properly represent the related Model. In this post I’ll walk you through what you need to do to make your forms work properly for related Models.

Looking at the employees example, you would likely have a departmentId value in the Employees table: a foreign key (FK) to store the id value from the Departments table, thereby representing the department that the employee is in. That’s perfect. The form generated by Yii, though, will create a text input for creating and updating the employee’s department:

<div>
 <?php echo CHtml::activeLabelEx($model,'departmentId'); ?>
 <?php echo CHtml::activeTextField($model,'departmentId'); ?>
 <?php echo CHtml::error($model,'departmentId'); ?>
 </div>

That’s not appropriate, of course. The department value for the employee must be a number and you can’t expect the user to know the primary key numbers of the different departments. It’d be most appropriate to turn that text input into a drop-down menu. That’s not hard to do:

<div>
<?php echo $form->labelEx($model,'departmentId'); ?>
<?php echo $form->dropDownList($model, 'departmentId', CHtml::listData(
Departments::model()->findAll(), 'id', 'name'),
array('prompt' => 'Select a Department')
); ?>
<?php echo $form->error($model,'departmentId'); ?>
</div>

And that’s all you need to do to get this to work. The dropDownList() method creates a SELECT menu. It’s associated with the departmentId attribute of this Model. The drop-down menu’s data has to come from CHtml::listData(). That part of the code says to fetch every Department, and to use the department’s id value for the menu value and its name value for the visible label. The final argument indicates a default prompt to give the menu. Since the Employees Model has a departmentId attribute, the framework will be able to handle errors, pre-select the right value on an update, and so forth. If you change the attribute label for departmentId in protected/models/Employees.php, you can make the prompt say “Department” instead. No problem.

A more complicated situation exists when there’s a many-to-many relationship between two Models, such as Posts and Categories, in a blog site. Because of the many-to-many relationship between these two, neither Posts nor Categories would have a foreign key to the other. Instead, a junction table (and Model) would be used. Let’s call that PostsCategories. The table itself would only need two columns: postId and categoryId.For each category that a post is associated with, there would be a record in this table.

On the form for adding (or updating) a blog post, you’d need to be able to select multiple Categories:

<div>
<?php echo $form->labelEx($model,'categories'); ?>
<?php echo $form->dropDownList($model, 'categories', CHtml::listData(
Categories::model()->findAll(), 'id', 'category'), array('multiple'=>'multiple', 'size'=>5)
); ?>
<?php echo $form->error($model,'categories'); ?>
</div>

That dropDownList() method will create a drop-down of size 5 (five items will be shown), populated using the list of Categories, and the user will be able to select multiple options. If you were to run this code, though, you’d get an error as Posts doesn’t have a categories attribute. But that’s easy to change…

In the Model definition, you identify the relationship to other Models within the relations() method. The relationship between Posts and PostsCategories could be represented as:

// protected/models/Posts
public function relations() {
    return array(
        'categories' => array(self::HAS_MANY, 'Categories', 'postId')
    );
}

Now Posts has a categories attribute, meaning that the above form code will work. In fact, you can also add categories to the attributeLabels() array to give this relationship a new label that would be used in the form. You can even add a rule to make sure this part of the form validates. This is an important enough concept that I want to repeat it: when you create a relation, that relation becomes an attribute of the Model.

In order for the creation of a new Post to work, you’ll need to update the Controller to handle the PostsCategories. Within the actionCreate() method (of PostsController), after the Post has been saved, loop through each category and add that record to PostsCategories. The beginning of actionCreate() would look like:

public function actionCreate() {
    $model=new Posts;
    if(isset($_POST['Posts'])) {
        $model->attributes=$_POST['Posts'];
        if($model->save()) {
            foreach ($_POST['Posts']['categories'] as $categoryId) {
                $postCategory = new PostsCategories;
                $postCategory->postId = $model->id;
                $postCategory->categoryId = $categoryId;
                if (!$postCategory->save()) print_r($postCategory->errors);
            }

Now, when a new Post is created, one new record is created in PostsCategories for each selected category.

We’re almost done getting this working, except we now need to think about the update process. There are two problems with what we’ve got so far: getting the form to select existing PostsCategories and handling updates of PostsCategories. The form has already been created and populated, but to get it to indicate existing selections, we need to pass along the PostsCategories values. You might think that you can just fetch all the PostsCategories records where postId equals the Model’s id (in other words, perform a with(‘PostsCategories’)-> retrieval), but you can’t. For the drop-down menu in the form to preselect the right values, the form needs to access an array of values, not an array of objects. To achieve that, add some code to the loadModel() method of the PostsController. This method is called for the update, delete, and view actions and just returns a single Model. The Model is loaded using:

$this->_model=Posts::model()->findbyPk($_GET['id']);

After that, within the loadModel() method, you can add this code to fetch the associated PostsCategories:

$criteria=new CDbCriteria;
$criteria->condition='postId=:postId';
$criteria->select = 'categoryId';
$criteria->params=array(':postId'=>$_GET['id']);
$postCategories = PostsCategories::model()->findAll($criteria);

That code selects all of the categoryId values from PostsCategories where the postId value equals this post’s id. Next, we need to turn those results into an array:

$categories = array();
foreach ($postCategories as $category) {
    $categories[] = $category->categoryId;
}

Now, $categories is an array of numeric values, one for each category associated with the post. Just add this to the loaded Model:

$this->_model->categories = $categories;

And now the form will automatically select the existing categories for this post.

The final step is to update the PostsCategories when the form is submitted (and the post is updated). This could be tricky, because the user could add or remove categories, or make no changes to the categories at all. The easiest way to handle all possibilities is to clear out the existing values (for this post) in the PostsCategories table, and then add them in anew. To do that, in actionUpdate() of the PostsController, you would have:

if($model->save()) {
    $criteria=new CDbCriteria;
    $criteria->condition='postId=:postId';
    $criteria->params=array(':postId'=>$model->id);
    PostsCategories::model()->deleteAll($criteria);

Then you use the same foreach loop as in actionCreate() to repopulate the table.

And that’s it. It takes a little thought getting all this going but these changes seem to work fine for me and I haven’t come up with a simpler solution yet. I hope this helps you with your next Yii-based project. As always, thanks for reading and let me know if you have any questions or comments.