Even if the documentation on this subject is quite complete, I will write an example of how to do it including a control witch validates the number of elements saved.

First I’ll give you only the code and after that (for those who are interested) I’ll give some notes about it. Or you could download/pull the github repository to test it. I’ve also added some unit test that I’ll use for new versions of cakephp. Please do clone this code and feel free to fork, repair, etc.

NEW! you can now pull a docker container to easily run the code. And you cant also test it in the LIVE DEMO

The database model

post habtm tags

Read the naming conventions

The Model

Post.php


  App::uses('AppModel', 'Model');
/**
 * Post Model
 *
 * @property Tag $Tag
 */
class Post extends AppModel {

/**
 * Display field
 *
 * @var string
 */
  public $displayField = 'name';

/**
 * Validation rules
 *
 * @var array
 */
  public $validate = array(
    'name' => array(
      'notempty' => array(
        'rule' => array('notempty'),
      ),
    ),
    'Tag' => array(
      'multiple' => array(
        'rule' => array('multiple', array('min' => 1)),
        'message' => 'You need to select at least one tag',
        'required' => true,
      ),
    ),
  );

/**
 * hasAndBelongsToMany associations
 *
 * @var array
 */
  public $hasAndBelongsToMany = array(
    'Tag' => array(
      'className' => 'Tag',
      'joinTable' => 'posts_tags',
      'foreignKey' => 'post_id',
      'associationForeignKey' => 'tag_id',
      'unique' => 'keepExisting',
    )
  );
  
/**
 * Transforms the data array to save the HABTM relation
 */
  public function beforeSave($options = array()){
    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]);
      }
    }
    return true;
  }

}

Tag.php


App::uses('AppModel', 'Model');
/**
 * Tag Model
 *
 */
class Tag extends AppModel {

/**
 * Display field
 *
 * @var string
 */
  public $displayField = 'label';

}

PostController.php


App::uses('AppController', 'Controller');
/**
 * Posts Controller
 *
 * @property Post $Post
 */
class PostsController extends AppController {

/**
 * index method
 *
 * @return void
 */
  public function index() {
    $this->Post->recursive = 0;
    $this->set('posts', $this->paginate());
  }

/**
 * view method
 *
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function view($id = null) {
    $this->Post->id = $id;
    if (!$this->Post->exists()) {
      throw new NotFoundException(__('Invalid post'));
    }
    $this->set('post', $this->Post->read(null, $id));
  }

/**
 * add method
 *
 * @return void
 */
  public function add() {
    if ($this->request->is('post')) {
      $this->Post->create();
      $this->Post->validator()->remove('Tag');
      if ($this->Post->save($this->request->data)) {
        $this->Session->setFlash(__('The post has been saved'));
        return $this->redirect(array('action' => 'index'));
      } else {
        $this->Session->setFlash(__('The post could not be saved. Please, try again.'));
      }
    }
    $tags = $this->Post->Tag->find('list');
    $this->set(compact('tags'));
  }
  
  /**
 * add method
 *
 * @return void
 */
  public function add_with_validation() {
    if ($this->request->is('post')) {
      $this->Post->create();
      if ($this->Post->save($this->request->data)) {
        $this->Session->setFlash(__('The post has been saved'));
        return $this->redirect(array('action' => 'index'));
      } else {
        $this->Session->setFlash(__('The post could not be saved. Please, try again.'));
      }
      
    }
    $tags = $this->Post->Tag->find('list');
    $this->set(compact('tags'));
  }

/**
 * edit method
 *
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function edit($id = null) {
    $this->Post->id = $id;
    if (!$this->Post->exists()) {
      throw new NotFoundException(__('Invalid post'));
    }
    if ($this->request->is('post') || $this->request->is('put')) {
      if ($this->Post->save($this->request->data)) {
        $this->Session->setFlash(__('The post has been saved'));
        return $this->redirect(array('action' => 'index'));
      } else {
        $this->Session->setFlash(__('The post could not be saved. Please, try again.'));
      }
    } else {
      $this->request->data = $this->Post->read(null, $id);
    }
    $tags = $this->Post->Tag->find('list');
    $this->set(compact('tags'));
  }

/**
 * delete method
 *
 * @throws MethodNotAllowedException
 * @throws NotFoundException
 * @param string $id
 * @return void
 */
  public function delete($id = null) {
    if (!$this->request->is('post')) {
      throw new MethodNotAllowedException();
    }
    $this->Post->id = $id;
    if (!$this->Post->exists()) {
      throw new NotFoundException(__('Invalid post'));
    }
    if ($this->Post->delete()) {
      $this->Session->setFlash(__('Post deleted'));
      return $this->redirect(array('action' => 'index'));
    }
    $this->Session->setFlash(__('Post was not deleted'));
    return $this->redirect(array('action' => 'index'));
  }
}

Post/add.ctp


<div class="posts form">
<p>
  This form allows you to add a new Post and select multiple Tags for this post.
  Here I'm using the basic "multiple select" input, so  you'll need to Ctrl + Click
  to select multiple Tags.
</p>
<?php echo $this->Form->create('Post'); ?>
  <fieldset>
    <legend><?php echo __('Add Post'); ?></legend>
  <?php
    echo $this->Form->input('name');
    echo $this->Form->input('Post.Tag',array('label'=>'Tags', 'type'=>'select', 'multiple'=>true));
  ?>
  </fieldset>
<?php echo $this->Form->end(__('Submit')); ?>
</div>
<div class="actions">
  <h3><?php echo __('Actions'); ?></h3>
  <ul>
    <li><?php echo $this->Html->link(__('List Posts'), array('action' => 'index')); ?></li>
  </ul>
</div>

Where’s the magic?

If you look closely, you’ll find that almost everything is in the Model. The add() method on the Controller is just a simple (baked) method used to save the Post model and its associated Tags.

Basically to save a HABTM relation you need the data to be structured like this:


Array
  (
  [Post] => Array
    (
      [name] => my test post
    )
  [Tag] => Array
    (
    [Tag] => Array
      (
        [0] => 1
        [1] => 3
      )
    )
  )

(Why does the Tag array needs to be formatted like that??? I really have no idea.)

If you want to validate the number of tags that can be added to a post, you could use the “multiple validator”. But in order to be able to use the multiple validator, the data must be structured like this:


Array
  (
  [Post] => Array
    (
      [name] => my other test post
      [Tag] => Array
        (
        [0] => 2
        [1] => 4
      )
    )
  )

(Again: Why does the Tag array needs to be formatted like that??? I really have no idea.)

As you can see, the HABTM relationship needs the data in a different way that the validator, clearly it is a contradiction between the validation and the data saving. The validator needs the Tag array inside the Post, and the save method needs the Tags at the same level of the Post. Surely you could transform the data inside the controller, but where’s the fun in that? Let’s better use the before save callback, because maybe you have multiple HABTM relations and you want to preserve a skinny controller and a fat model. In this case I used the before save on the Post Model, but you could also define it in the AppModel so it will work for all models.

And that’s it!. Now if you try to save the Post without choosing any tag, you should see something like this:

Example data validation on HABTM relation

This might not be the cleanest way, but I couldn’t find a better way to do it (without using js validation of course). And the documentation doesn’t say anything about this, so….

Hope this helps!

Hey! if you want to give it a try. I have created a repository so you could pull, test, correct, improve this code. Just go to the repository on github.