A complete tutorial on HABTM relationships in CakePHP

in CakePHP/Tutorials & Samples

I recently had to play with CakePHP’s dreaded Has And Belongs To Many (HABTM) relationship and it was a nightmare. I couldn’t find a previous project that I created using this relationship and I couldn’t find any recent tutorials to help me. So, I decided to finish my project and then write a CakePHP tutorial on HABTM relationships in CakePHP. The source code is available for download here and there is a live demo for you to play with here. So, let’s get started…

According to the CakePHP Cookbook, the HABTM relationship is the most complicated relationship that exists. Here is a quick excerpt: “

This association is used when you have two models that need to be joined up, repeatedly, many times, in many different ways.

” In the CakePHP Cookbook, they talk about a HABTM relationship between Recipes and Ingredients but never give us a working sample. So, we will go ahead and build a fully functional application based on this example.

We will be creating an application that allows users to add/edit/delete recipes and ingredients. And, of course, users will be able to link any recipe to any ingredient. As usual, the focus of this tutorial is on the backend of CakePHP so I will be using the default CakePHP layout for my outputs. Below is a screenshot of what we will be building

cakephp-habtm

The Tables

So, let’s go ahead and create our database tables. In order to have a properly working relationship, we need to have a join table linking both models. So, for our example, we will need a Recipes table, an Ingredients table and a join table for linking Ingredients to Recipes. So here are the 3 database tables that we will need to create:

CREATE TABLE `recipes` (
  `id` int(11) NOT NULL auto_increment,
  `name` varchar(255) NOT NULL,
  `description` text NOT NULL,
  PRIMARY KEY  (`id`)
);
CREATE TABLE `ingredients` (
  `id` int(11) NOT NULL auto_increment,
  `name` varchar(255) NOT NULL,
  PRIMARY KEY  (`id`)
);
CREATE TABLE `ingredients_recipes` (
  `ingredient_id` int(11) NOT NULL,
  `recipe_id` int(11) NOT NULL,
  PRIMARY KEY  (`ingredient_id`,`recipe_id`)
);

The Models

Next, let’s create our models. We will need to have a Recipes model and an Ingredients model. We need to setup the HABTM relationship on both models so that any recipe can have any ingredient and vice versa. Let’s create the Ingredient:

<?php
class Ingredient extends AppModel {
    var $name = 'Ingredient';

    var $hasAndBelongsToMany = array(
        'Recipe' => array(
            'className' => 'Recipe',
            'joinTable' => 'ingredients_recipes',
            'foreignKey' => 'ingredient_id',
            'associationForeignKey' => 'recipe_id'
        ),
    );   

	public $validate = array(
		'name'         => array(
			'empty_validation'      => array(
				'rule'      => 'notEmpty',
				'message'   => 'Ingredient name can not be left empty'
			),
			'duplicate_validation'  => array(
				'rule'      => 'isUnique',
				'message'   => 'This Ingredient already Exists, Please enter a different ingredient'
			)
		)
	);	

}
?>

Then we will go ahead and create our Recipe model. In the case of the recipe, I require the name of the recipe to be unique just like the ingredients, but I want to also make sure that users provide a description for the recipe. Below is the source code:

<?php
class Recipe extends AppModel {
    var $name = 'Recipe';

    var $hasAndBelongsToMany = array(
        'Ingredient' => array(
            'className' => 'Ingredient',
            'joinTable' => 'ingredients_recipes',
            'foreignKey' => 'recipe_id',
            'associationForeignKey' => 'ingredient_id'
        ),
    );   

	public $validate = array(
		'name'         => array(
			'name_empty_validation'      => array(
				'rule'      => 'notEmpty',
				'message'   => 'Recipe name can not be left empty'
			),
			'duplicate_validation'  => array(
				'rule'      => 'isUnique',
				'message'   => 'This Recipe already Exists, Please enter a different recipe'
			)
		),
		'description' => array( 
			'desc_empty_validation'      => array(
				'rule'      => 'notEmpty',
				'message'   => 'Recipe description can not be left empty'
			),
		)
	);
	   
}
?>

Since we are using a join table to link both models, we can choose to define the HABTM relationship in one model or in both models if we want. The reason why I am adding the HABTM relationship on both models is that I want to be able to handle updates and deletes on both models. The fields of the HABTM relationship such as className, joinTable, foreinKey and associatedForeignKey are pretty easy to understand. As a minimum, I would recommend always setting these values explicity. If these are not specified, CakePHP will fall back to the default convention for table names and foreign keys. We will talk about the CakePHP conventions later..

Notice also that I have added simple validation rules in both the Recipe model and the Ingredient model. This way, we can also cover validation of HABTM in this tutorial.

The Join Table

CakePHP will automagically create a join Model based on the join table and the Models that are being joined together. Accoring to the CakePHP convention, the joinTable should be named ingredients_recipes (which happens to be the name that we gave our join table). That's because, according to the CakePHP convention, the table name consists of plural model names involved in the HABTM relationship, and the name should always be in alphabetical order. So, I goes before R, therefore ingredients_recipes instead of recipes_ingredients. Not following  this convention may still work but makes things much more complicated for you (trust me, I've gone that route already…) CakePHP is a framework that is all about conventions. The cakePHP convention for naming stuff is very important. Bad things can happen when the CakePHP convention is not followed… That is why it is highly recommended to follow the CakePHP convention.

So, in our case, an automatic Join Model called IngredientsRecipe will be created for us. Having this automatically-generated model is quite handy if you are interested in the joinTable data and becomes even more powerful when you have some additional fields in the joinTable, which you need queried or saved. In the Recipes controller you could do:

$this->Recipe->IngredientsRecipe->find('all');

And this would return all entries in the join table.

The Controllers

Now lets cover the controllers. As mentioned above, we will be covering the basic CRUD (Create, Read, Update, Delete) functions for both models. Below is the controller for the Ingredients Controller

<?php
class IngredientsController extends AppController {
    public $uses    = array('Ingredient', 'Recipe');
	
	public $paginate = array(
        'limit' => 25,
    	'order' => array('Ingredient.name' => 'asc' ) 
    );
	
    function index() {
        $ingredients = $this->paginate('Ingredient');
		$this->set(compact('ingredients'));
    }
	
   function add() {
        if ($this->request->is('post')) {
				
			$this->Ingredient->create();
			if ($this->Ingredient->save($this->request->data)) {
				$this->Session->setFlash(__('The ingredient has been created'));
				$this->redirect(array('action' => 'index'));
			} else {
				$this->Session->setFlash(__('The ingredient could not be created. Please, try again.'));
			}	
        }

        $recipes    = $this->Recipe->find('list');
        $this->set('recipes',$recipes);
    }
	
	function edit($id) {
        if (!$id) {
			$this->Session->setFlash('Please provide a ingredient id');
			$this->redirect(array('action'=>'index'));
		}

		$ingredient = $this->Ingredient->findById($id);
		if (!$ingredient) {
			$this->Session->setFlash('Invalid Ingredient ID Provided');
			$this->redirect(array('action'=>'index'));
		}

		if ($this->request->is('post') || $this->request->is('put')) {
			$this->Ingredient->id = $id;
			if ($this->Ingredient->save($this->request->data)) {
				$this->Session->setFlash(__('The ingredient has been updated'));
				$this->redirect(array('action' => 'edit',$id));
			}else{
				$this->Session->setFlash(__('Unable to update your ingredient.'));
			}
		}

		if (!$this->request->data) {
			$this->request->data = $ingredient;
		}
			
        $recipes    = $this->Recipe->find('list');
        $this->set('recipes',$recipes);
    }
	
	
    function delete($id) {
        $this->Ingredient->id = $id;
        $this->Ingredient->delete();
		$this->Session->setFlash('Ingredient has been deleted.');
        $this->redirect(array('controller'=>'Ingredients','action'=>'index'));
    }
}
?>

And below is the code for the Recipes controller:

<?php
class RecipesController extends AppController {
    public $uses    = array('Recipe', 'Ingredient');
	
	public $paginate = array(
        'limit' => 25,
    	'order' => array('Recipe.name' => 'asc' ) 
    );
	
    function index() {
        $recipes = $this->paginate('Recipe');
		$this->set(compact('recipes'));
    }
	
    function add() {
        if ($this->request->is('post')) {
				
			$this->Recipe->create();
			if ($this->Recipe->save($this->request->data)) {
				$this->Session->setFlash(__('The recipe has been created'));
				$this->redirect(array('action' => 'index'));
			} else {
				$this->Session->setFlash(__('The recipe could not be created. Please, try again.'));
			}	
        }

        $ingredients    = $this->Ingredient->find('list');
        $this->set('ingredients',$ingredients);
    }
	
	function edit($id) {
        if (!$id) {
			$this->Session->setFlash('Please provide a recipe id');
			$this->redirect(array('action'=>'index'));
		}

		$recipe = $this->Recipe->findById($id);
		if (!$recipe) {
			$this->Session->setFlash('Invalid Recipe ID Provided');
			$this->redirect(array('action'=>'index'));
		}

		if ($this->request->is('post') || $this->request->is('put')) {
			$this->Recipe->id = $id;
			if ($this->Recipe->save($this->request->data)) {
				$this->Session->setFlash(__('The recipe has been updated'));
				$this->redirect(array('action' => 'edit',$id));
			}else{
				$this->Session->setFlash(__('Unable to update your recipe.'));
			}
		}

		if (!$this->request->data) {
			$this->request->data = $recipe;
		}
			
        $ingredients    = $this->Ingredient->find('list');
        $this->set('ingredients',$ingredients);
    }
	
    function delete($id) {
        $this->Recipe->id = $id;
        $this->Recipe->delete();
		$this->Session->setFlash('Recipe has been deleted.');
        $this->redirect(array('controller'=>'Recipes','action'=>'index'));
    }
}
?>

In both cases, the functions are pretty easy to follow and the only thing I need to highlight is the fact that I always call a find(‘list’) function in each of these controllers for their belongsTo counterpart. So, in the case of Recipes, I provide a list of Ingredients, and in the case of Ingredients Controller, I provide a list of Recipes. This is necessary later on at the view level, which is why I set the variable to make it available to the view.

The Views

In the case of both Ingredients and Recipes, there is a view for all the basic actions that I can perform. These are Create, Edit and Index. Because the view elements are pretty simple to follow, I will only show the code for one action: add for a Recipe. Below is the code:

<!-- app/View/Recipes/add.ctp -->
<div class="users form">

<?php echo $this->Form->create('Recipe');?>
    <fieldset>
        <legend><?php echo __('Add Recipe'); ?></legend>
        <?php echo $this->Form->input('name');
		echo $this->Form->input('description');
		echo $this->Form->input('Recipe.Ingredient', array(	'multiple' => true));
		
		echo $this->Form->submit('Add Recipe', array('class' => 'form-submit',  'title' => 'Click here to add the recipe') ); 
?>
    </fieldset>
<?php echo $this->Form->end(); ?>
</div>
<?php echo $this->Html->link( "List Recipes",   array('controller'=>'recipes','action'=>'index'),array('escape' => false) ); ?>
<br/>			
<?php echo $this->Html->link( "Add A New Recipe",   array('controller'=>'recipes','action'=>'add'),array('escape' => false) ); ?>
<br/>
<?php echo $this->Html->link( "List Ingredients",   array('controller'=>'ingredients','action'=>'index'),array('escape' => false) ); ?>
<br/>	
<?php echo $this->Html->link( "Add A New Ingredient",   array('controller'=>'ingredients','action'=>'add'),array('escape' => false) ); ?>

This is a pretty standard CakePHP form code. The only important observation is that you will notice that I added a multiple select element for displaying all the ingredients that can possibly be matched with my recipe. Since I just set the flag of multiple to true, CakePHP will use the default form element for displaying multiple fields, which is a multi-select drop-down box. Here is the line of code that I am talking about:

echo $this->Form->input('Recipe.Ingredient', array(	'multiple' => true));

If I wanted to have mutliple checkboxes instead, all I have to do is change the multiple option from true to checkbox. Here is an example below:

echo $this->Form->input('Recipe.Ingredient', array(	'multiple' => 'checkbox'));

This is one of the nice features of CakePHP in that, with just this one line of code, it automatically knows that you want to display checkboxes or a multi-select drop-down form elemnt for all possible ingredients that can belong to the current recipe.

Testing it all out

The above is code is functional code and it will work. However, if you were to run the application, you will notice that the relational updates never get saved. In other words, when you click on a checkbox to indicate that there is a relationship between an Ingredient and a Recipe, the checkboxes are not being saved once you hit save. More importantly, nothing is being written in the relational database ingredients_recipes. This is because we need to tell CakePHP to handle our HABTM relationship before saving the data. Luckily, CakePHP has a beforeSave() function that allows you to manipulate the data before you save it. That is why we will use the beforeSave function on both Models so that they update our HABTM relationship before saving the model. Here is the code that we will need to add to the Recipe and Ingredient model:

public function beforeSave($options = array()) {

		// save our HABTM relationships
		foreach (array_keys($this->hasAndBelongsToMany) as $model){
				if(isset($this->data[$this->name][$model])){
						$this->data[$model][$model] = $this->data[$this->name][$model];
						unset($this->data[$this->name][$model]);
				}
		}
	}

This function updates the HABTM relationship between the current model and all other models that it has an HABTM relationship with. Since CakePHP is an Object-Oriented framework, it is more efficient to copy this code into the AppModel class so that both the Recipe and Ingredient model can both inherit the function from their parent class. So, below is what the AppModel class now looks like:

<?php

App::uses('Model', 'Model');

class AppModel extends Model {

 public function beforeSave($options = array()) {

		// save our HABTM relationships
		foreach (array_keys($this->hasAndBelongsToMany) as $model){
				if(isset($this->data[$this->name][$model])){
						$this->data[$model][$model] = $this->data[$this->name][$model];
						unset($this->data[$this->name][$model]);
				}
		}
	}
}
?>

With this, the HABTM relationship is properly established and any of the two models will always check for their corresponding relationships before saving. For those interested, below is the CakePHP array structure for the HABTM table:

Array
	(
	[Recipe] => Array
		(
			[name] => Some sweet dish
			[] => This awesome dish is yummy
		)
	[Ingredient] => Array
		(
		[Ingredient] => Array
			(
				[0] => 1
				[1] => 3
			)
		)
	)

In the case of the delete function, CakePHP will autoMagically delete all associations to the deleted model inside the join table. (This could be a good thing or a bad thing depending on how you look at it…) That’s all there is to HABTM relationships in CakePHP

Tags:

Mifty Yusuf is a Montreal-based software developer who enjoys playing with new web technologies as well as comic books and illustrations. He beleives that, no matter what the question is, the answer is always Batman!

18 Comments

  1. Hi Mifty, it’s necesary that the three tables are in the same database. Because I have a recipes and ingredients_recipes in a database and ingredients in other database, In the view add I can see the same image that you, but when I save the relation isn’t save HABTM even I use the callback beforeSave but nothing, Do you have any idea,plis?

    • Hi Isaac,
      I have never tested to see if all tables must belong to the same database. However, based on how the CakePHP config file for databases works, it only allows you to select one database. So, i think that there is a restriction that all must be on the same database.

  2. Hi Mifty, it’s me again well I’m tried put in the tree tables in the same database and my application isn’t work, In the table Ingredients I use a tree structure could be that. I download your code and I tried on my computer and works fine, I copy some of your code but isnt’ work. I don’t have idea. Thanks.

  3. Once again you’ve come to the rescue Miffy. This is just what I needed to resolve my struggle over HABTM.
    Keep up the good work: if this is what you can produce when you are bored then I hope you are bored to tears more often 🙂

  4. One question: in my tables I have a check with several assessments in a many to many relationship.
    On the join table (assessments_checks) I have a field to indicate whether the person being tested passed that particular assessment on that check.
    How can I reflect that on the page and allow the tester to mark a particular assessment as pass or fail? (Fieldname is ass in the table below)
    CREATE TABLE IF NOT EXISTS `assessments_checks` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `assessment_id` int(11),
    `check_id` int(11),
    `pass` char(1) NOT NULL,
    `created` timestamp NULL DEFAULT NULL,
    `modified` timestamp NULL DEFAULT NULL,
    PRIMARY KEY (`id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8 AUTO_INCREMENT=1 ;

  5. Hey Mifty,

    Have you tried or had any luck to edit multiple records’ habtm relationships? For example:

    Students_Teachers

    I want a single form where I could have checkboxes on the habtm relationship of each student:

    Student.1.name
    Student.1.age
    Student.1.Teachers: option1
    Student.1.Teachers: option2…

    Student.2.name
    Student.2.age
    Student.2.Teachers: option1
    Student.2.Teachers: option2…

    I can do it if I have a url like: myurl/students/edit/1. I adjusted my query to not pull just one, but all. And my name and age field work, but, for example if student 2 only habt teacher option 1 all the check boxes are still blank…? any ideas?

  6. Hi everyone,
    Although the code above works perfectly well, I recently ran into troubles with the HABTM association on a new project. After spending 2 frustrating days to understand why CakePHP was not behaving as I had expected, I discovered that the setup of the view is very important depending on the version of Cake that you are using. In the above tutorial, the following code works in the view:

            echo $this-&gt;Form-&gt;input('Recipe.Ingredient', array(   'multiple' =&gt; true));
    

    However, in my new project, I had to replace this by:

            echo $this-&gt;Form-&gt;input('Ingredient', array(   'multiple' =&gt; true));
    

    otherwise the HABTM relationship would not work.I think that this is because CakePHP is extremely picky about how your data is passed to the controller and different versions of Cake do things in differently. Anyways, I thought that I would share this in case it could save someone else 2 days of frustrations…

  7. In your view use “echo $this->Form->input(‘Ingredient’, array( ‘multiple’ => ‘checkbox’));” and then you can use “saveAll” in your controllers and then Cake will save the related data for you. So, you will not need your beforeSave functions.

  8. Out of the box error:
    Fatal error: Call to a member function is() on a non-object in /var/www/development/admin/app/controllers/ingredients_controller.php on line 16

    line 16 = if ($this->request->is(‘post’)) {

    perhaps “request” is a function not defined in my version of cakephp 1.3 ?
    where would I edit this?

  9. the ingredients save was get good work.db also good…
    but when i added the ingredients and go to the view page of recipe.
    from the recipes page can not get the added data of ingredients.

Leave a Reply

Your email address will not be published.

*

Latest from CakePHP

Go to Top