What's the Simple Straightforward Way to Save a Many-to-Many Relation

Many-to-many relations are ubiquitous in modern Web sites. I’ve now spend several days scrounging the Web for how to save M2M relations in Yii2. There’s an extreme paucity of documentation on something that should be part and parcel of any robust modern framework. (I’ve just spent hours drooling over CakePHP’s saveRelated and saveAssociated functions. I may be forced to go back there.) Surely, this functionality must have been front and center when planning Yii2. How did it ever get out the door without documentation of what should be the easiest thing in the world?

Just try finding any concrete functional examples of using the ActiveRecord link() behavior. Most “concrete examples” begin with “I’ve never used it and don’t really know how it works, but you could try this …”

Others say they deplore "magic methods, like link()" and proceed to give about 500 lines of code to save a simple record with an M2M relationship.

Can anyone either give me the simple, straightforward way to handling saving (both in create AND update, by the way, just like you have to do in real life) such related models? Or point me to where it’s documented (and the API documentation for link() is virtually worthless and most links to how to do it are 404s)?

By the way, CakePHP 3.0 starts you out, in their very first tutorial, with a bookmarker application with a M2M relation on tags. You end up saving that with ease as a first-time Cake user. Come on Yii2 – come on into the real world.

1 Like

Thanks for your response. Yes, I saw that article. All I could think of when I saw it was that, in 15 years of Web development, I can’t remember a single instance where I was creating both sides of a many-to-many relationship at the same time. In addition, what we have here is an illustration of how to link together two single models, a far, far cry from a many-to-many relationship. That is what is so characteristic of virtually every Yii2 tutorial I’ve seen – a far stretch from reality and hardly useful.

Now, try something really useful. You have a pre-existing table of authors. Now, you are creating a book in the Book model (book table) and you want to link it to three authors at the same time. That’s real world. And, oh yes, do that without 500 lines of code. Should be simple, yes?

Also, don’t forget, you have to be able to update that. I added the three authors, but I forgot one, and two of the ones that got added shouldn’t have been. That’s real world.

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

        ],

    ]);



I can’t guarantee my approach is the best. It just works.

If anybody consider it unsound I would greatly appreciate any feedback. I’m still learning how to use Yii2 properly.

@Vojtech: I really like your approach. It moves virtually all of the heavy lifting to the model (where it belongs). In this case, my problem is with Yii2, itself. Once again, this type of situation is ubiquitous; it occurs in virtually every Web site, usually multiple times. Look at the length and intricacy of the code you have so eloquently laid out. First of all, no one should have to go through that much when using a framework to do something that is common to most Web sites; that’s precisely why you use a framework like Yii2 – to do that kind of heavy lifting. (Once again, I point to CakePHP, where a simple saveAssociated or saveRelated subsumes all that intricate coding.) Second, where in the documentation, the Yii Book, does it explain how to do all that? It makes no attempt to talk about how to handle M2M relations (other than to define the relationship, which provides no functionality), and I can’t help but believe that it doesn’t do so because either (a) the developers themselves couldn’t work it out or (B) they knew that any attempt at explaining something so intricate, so involved, so elaborate would surely fail.

In their process of a complete re-write of the framework, the developers seem to have concentrated so much on implementing Composer, PSR formatting, and a host of other technicalities that they totally lost sight of utility and what frameworks are all about.

Can’t you just for Christ sakes use CakePHP and get it over with? :)

I am using a similar approach - and I do like that Yii doesn’t do all kinds of jazzy stuff behind my back.

I used CakePHP before Yii, so I am familiar with it.

And it was slow as molasses…

Tuck that code into a neat behavior or something and become DRY.

You’re wrong about Cake being “as slow as molasses.” Perhaps in v. 1.x it was, but then I imagine that Yii 1.0 was also. If you look at reviews of later versions of Cake, the performance has massively improved.

But you are right about one thing. I should and am going back to Cake. I got trapped in an investment trap. I spent so much time trying to learn Yii, trying to make it work, writing reams of code that I shouldn’t have to write because after all, it is supposed to be a framework. I’ve had to write dozens of behaviors (as you say) already because, well Yii just isn’t up to doing what contemporary Web sites demand. It’s time to cut my losses short and move on to something that actually works. Y’all have fun.

I simply fail to see how you can call Vojtech’s code for heavy lifting. Most of that code would be generated by Gii anyway. And if it wasn’t generated, then it’s really something that you can write easily.

I am not wrong about Cake being slow as molasses - that was one of the things that impressed me about Yii when I switched a couple of years ago: a massive difference in both speed and resource usage. There was just too much magic going on behind the scenes with CakePHP.

I very much prefer an honest framework, that is flexible to boot.

At least Yii 1.x has a ton of extensions which helps you deal with M2M relationships, if you’d rather not bother with it.

With that attitude of yours, I am sure that you’d be happier to get your CakePHP and eat it too, instead of putting up with the stupid Yii users, though. ;)


I can't guarantee my approach is the best. It just works.

If anybody consider it unsound I would greatly appreciate any feedback. I'm still learning how to use Yii2 properly. 

Hey! this is diamond stuff!

Could I ask about the form,

On the widget code I see you have authors, which I assume is the $authors from the bookView model?

So, do you just add that control to the _form.php for the book view? or do you create a new set of views for bookForm?

I think I just answered my own question, but it’s always good to have clarity. ;)

I’m glad you find my code useful.

By “authors” in the widget do you mean the “authors” property called by the widget ($form->field($model, ‘authors’)) or do you mean the list (‘data’ => $model->authorList,)?

Both are properties of the BookForm model. The authorList is actually inherited from the Book model. I haven’t posted that in the previous snippet but it could look like this:


public function getAuthorList()

{

    return ArrayHelper::map(Author::find()->all(), 'id', 'name');

}

Besides the actual widget there aren’t any special modification of neither the View nor the Controller. All the “magic” is done in the model. That’s the beauty of it.

Ah sorry I meant widget ($form->field($model, ‘authors’))

Yeah this is great! I forgot just how great inhertance can be. Thank you for your answer. :)

Just a little problem I am having understanding part of your code.

In the functions deleteOldAuthors() and afterFind() you refer to $this->bookAuthors in the foreach loops.

However, I don’t see this declared anywhere in your code?

Ok I think I got it, it’s a relational definition right? :)

Exactly. I was just about to post the answer. The relation would be defined in the parent Book model in a standard fashion:


public function getBookAuthors()

{

    return $this->hasMany(BookAuthor::className(), ['book_id' => 'id']);

}

DELETED: I was thread high-jacking. ;)

Hello!

When I create a new object of class BookForm, the property $isNewRecord is true.

Then, after parent::save($runValidation, $attributeNames) is executed, the $isNewRecord property of BookForm becomes false.

Then, in function addNewAuthors, $this->isNewRecord is evaluated to false and the code in else block is executed:

Then in_array($authorID, $this->_oldAuthors) throws an error, because $this->_oldAuthors is null.

The value of $this->isNewRecord must be saved in a variable, before parent::save($runValidation, $attributeNames) is executed.

Have a nice day!