I’ve recently worked with M2M relation in one of my projects. I’ve slightly modified my code to the given example.
Let’s assume we have following tables and auto-generated models by Gii: Book, Author and BookAuthor
I want to pick Authors when creating/updating Book record. In the view I use Kartik’s Select2 widget (http://demos.krajee.com/widget-details/select2). The widget returns an array of IDs of Authors.
In order to work with selected Authors I have BookForm model which extends Book model and stores the array of Authors in public property (in similar way as Search models do). For the sake of brevity I’m leaving out rules() and other unimportant methods:
class BookForm extends Book
{
/**
* @var array list of Authors used by the form
*/
public $authors = [];
/**
* @var array|null list of old Authors (as loaded from DB)
* This is `null` if the record [[isNewRecord|is new]].
*/
private $_oldAuthors;
/**
* Overrides parent method.
* Saves the current record and updates related BookAuthor records
* @inheritdoc
*/
public function save($runValidation = true, $attributeNames = null)
{
$transaction = Yii::$app->db->beginTransaction();
try {
if (!parent::save($runValidation, $attributeNames)) {
return false;
}
$this->addNewAuthors();
$this->deleteOldAuthors();
$transaction->commit();
} catch (\Exception $e) {
$transaction->rollBack();
throw $e;
}
return true;
}
/**
* Created new BookAuthor records that are not included in the old list of Authors
*
* @throws Exception update failed
*/
protected function addNewAuthors()
{
$newAuthors = [];
if ($this->isNewRecord) {
$newAuthors = $this->authors;
} else {
// Check which Authors are new (not included in $this->_oldAuthors)
foreach ($this->authors as $authorID) {
if (!in_array($authorID, $this->_oldAuthors)) {
$newAuthors[] = $authorID;
}
}
}
// Add new records
foreach ($newAuthors as $authorID) {
$bookAuthor = new BookAuthor();
$bookAuthor->book_id = $this->id;
$bookAuthor->author_id = $authorID;
if (!$bookAuthor->save()) {
throw new Exception('Failed to save related records.');
}
}
}
/**
* Deletes related BookAuthor records that are not included in the current list of Authors
*
* @throws Exception update failed
*/
protected function deleteOldAuthors()
{
foreach ($this->bookAuthors as $bookAuthor) {
if (!in_array($bookAuthor->author_id, $this->authors) &&
$bookAuthor->delete() === false) {
throw new Exception('Failed to save related records.');
}
}
}
/**
* Overrides parent method.
* Populates authors property with IDs of related records and makes a copy
* to the internal _oldAuthors property.
*/
public function afterFind()
{
foreach ($this->bookAuthors as $bookAuthor) {
$this->authors[] = $bookAuthor->author_id;
}
$this->_oldAuthors = $this->authors;
parent::afterFind();
}
}
Here are excerpts from the BookController (which is quite plain):
/**
* Creates a new Book model.
* If creation is successful, the browser will be redirected to the 'view' page.
* @return mixed
*/
public function actionCreate()
{
$model = new BookForm();
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
} else {
return $this->render('create', [
'model' => $model,
]);
}
}
/**
* Updates an existing Book model.
* If update is successful, the browser will be redirected to the 'view' page.
* @param integer $id
* @return mixed
* @throws NotFoundHttpException if the model cannot be found
*/
public function actionUpdate($id)
{
if (($model = BookForm::findOne($id)) === null) {
throw new NotFoundHttpException('The requested page does not exist.');
}
if ($model->load(Yii::$app->request->post()) && $model->save()) {
return $this->redirect(['view', 'id' => $model->id]);
} else {
return $this->render('update', [
'model' => $model,
]);
}
}
And here is the widget:
$form->field($model, 'authors')->widget(Select2::classname(), [
'name' => 'Authors',
'data' => $model->authorList,
'options' => [
'placeholder' => 'Select authors ...',
'multiple' => 'true'
],
'pluginOptions' => [
'allowClear' => true
],
]);