Programming With Yii2: Blameable Behaviors

Final product image
What You’ll Be Creating

If you’re asking, “What’s Yii?” check out my earlier tutorial: Introduction to the Yii Framework, which reviews the benefits of Yii and includes an overview of what’s new in Yii 2.0, released in October 2014.

In this Programming With Yii2 series, I’m guiding readers in use of the newly upgraded Yii2 Framework for PHP. In this tutorial, I’ll guide you through another of Yii2’s interesting behaviors: helping automate the common web development task of assigning created by and updated by user_ids across the models in your web app using DRY coding and Yii2 BlameableBehavior. We’ll also create a log that records who updated the Status table for every change made.

For these examples, we’ll continue to imagine we’re building a framework for posting simple status updates, e.g. our own mini-Twitter.

Just a reminder, I do participate in the comment threads below. I’m especially interested if you have different approaches, additional ideas, or want to suggest topics for future tutorials.

What Is a Behavior?

Yii2 Behaviors are essentially mixins. Wikipedia describes mixins as “a class that contains a combination of methods from other classes. How such a combination is done depends on the language, but it is not by inheritance.”

Yii describes them this way:

Attaching a behavior to a component “injects” the behavior’s methods and properties into the component, making those methods and properties accessible as if they were defined in the component class itself.

Yii2 offers several built-in behaviors, most of which we’ll be documenting, including sluggable (see Programming With Yii2: Sluggable Behavior), blameable, and timestamp (upcoming, check the series page). Behaviors are an easy way to reuse common code across many of your data models without having to repeat the code in many places. Injecting a behavior into a model can often be done with just two lines of code. As the number of models in your application increases, behaviors become more and more useful.

What Is the Blameable Behavior?

Blameable makes it easy for us to implement the frequently needed task of assigning the current logged in user to inserts and updates in an ActiveRecord model, automatically setting the properties for created_by and updated_by.

In Programming With Yii2: Authorization With the Access Control Filter, we implemented our own blameable behavior in two parts. First, we created a migration to add a created_by field to our Status table:

<?php

use yii\db\Schema;
use yii\db\Migration;

class m150128_003709_extend_status_table_for_created_by extends Migration
{
    public function up()
    {
      $tableOptions = null;
      if ($this->db->driverName === 'mysql') {
          $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
      }
      $this->addColumn('{{%status}}','created_by',Schema::TYPE_INTEGER.' NOT NULL');
      $this->addForeignKey('fk_status_created_by', '{{%status}}', 'created_by', '{{%user}}', 'id', 'CASCADE', 'CASCADE');     
    }

Second, we assigned the created_by field to the the current user_id in the StatusController’s create action:

public function actionCreate()
    {
        $model = new Status();

        if ($model->load(Yii::$app->request->post())) {
          $model->created_by = Yii::$app->user->getId();

Implementing the Blameable behavior will do this automatically for us and can be easily added to all of the ActiveRecord models in a web application.

Implementing the Blameable Behavior in the Status Model

Extending the Status Table

First, we need to extend the Status table with a migration once more to support an updated_by field.

Jeff$ ./yii migrate/create extend_status_table_for_updated_by
Yii Migration Tool (based on Yii v2.0.2)

Create new migration '/Users/Jeff/Sites/hello/migrations/m150209_200619_extend_status_table_for_updated_by.php'? (yes|no) [no]:yes
New migration created successfully.

Here’s the migration code we will use:

<?php

use yii\db\Schema;
use yii\db\Migration;

class m150209_200619_extend_status_table_for_updated_by extends Migration
{
    public function up()
    {
      $tableOptions = null;
      if ($this->db->driverName === 'mysql') {
          $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
      }
      $this->addColumn('{{%status}}','updated_by',Schema::TYPE_INTEGER.' NOT NULL');
      $this->addForeignKey('fk_status_updated_by', '{{%status}}', 'updated_by', '{{%user}}', 'id', 'CASCADE', 'CASCADE');     
    }

    public function down()
    {
      $this->dropForeignKey('fk_status_updated_by','{{%status}}');
      $this->dropColumn('{{%status}}','updated_by');
    }
}

If you try to run this migration with existing data in your database, you’ll get an error when trying to create the foreign key index, because the updated_by is 0 and doesn’t exist in the user table. 

hello Jeff$ ./yii migrate/up
Yii Migration Tool (based on Yii v2.0.2)

Total 1 new migration to be applied:
    m150209_200619_extend_status_table_for_updated_by

Apply the above migration? (yes|no) [no]:yes
*** applying m150209_200619_extend_status_table_for_updated_by
    > add column updated_by integer NOT NULL to table {{%status}} ... done (time: 0.042s)
    > add foreign key fk_status_updated_by: {{%status}} (updated_by) references {{%user}} (id) ...Exception 'yii\db\IntegrityException' with message 'SQLSTATE[23000]: Integrity constraint violation: 1452 Cannot add or update a child row: a foreign key constraint fails (`hello`.`#sql-22f_1d0`, CONSTRAINT `fk_status_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE)
The SQL being executed was: ALTER TABLE `status` ADD CONSTRAINT `fk_status_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE'

in /Users/Jeff/Sites/hello/vendor/yiisoft/yii2/db/Schema.php:532

Error Info:
Array
(
    [0] => 23000
    [1] => 1452
    [2] => Cannot add or update a child row: a foreign key constraint fails (`hello`.`#sql-22f_1d0`, CONSTRAINT `fk_status_updated_by` FOREIGN KEY (`updated_by`) REFERENCES `user` (`id`) ON DELETE CASCADE ON UPDATE CASCADE)
)

We could work around this by updating the data manually in the migration and then adding a foreign key. However, since this is a test platform, it’s easiest just to migrate down three steps—dropping the Status table and its test data—and then migrate up again:

hello Jeff$ ./yii migrate/down 3
Yii Migration Tool (based on Yii v2.0.2)

Total 3 migrations to be reverted:
    m150128_233458_extend_status_table_for_slugs
        m150128_003709_extend_status_table_for_created_by
        m141201_013120_create_status_table

Revert the above migrations? (yes|no) [no]:yes
*** reverting m150128_233458_extend_status_table_for_slugs
    > drop column slug from table {{%status}} ... done (time: 0.009s)
*** reverted m150128_233458_extend_status_table_for_slugs (time: 0.013s)

*** reverting m150128_003709_extend_status_table_for_created_by
    > drop foreign key fk_status_created_by from table {{%status}} ... done (time: 0.010s)
    > drop column created_by from table {{%status}} ... done (time: 0.008s)
*** reverted m150128_003709_extend_status_table_for_created_by (time: 0.019s)

*** reverting m141201_013120_create_status_table
    > drop table {{%status}} ... done (time: 0.001s)
*** reverted m141201_013120_create_status_table (time: 0.002s)

Migrated down successfully.

hello Jeff$ ./yii migrate/up 4
Yii Migration Tool (based on Yii v2.0.2)

Total 4 new migrations to be applied:
    m141201_013120_create_status_table
        m150128_003709_extend_status_table_for_created_by
        m150128_233458_extend_status_table_for_slugs
        m150209_200619_extend_status_table_for_updated_by

Apply the above migrations? (yes|no) [no]:yes
*** applying m141201_013120_create_status_table
    > create table {{%status}} ... done (time: 0.007s)
*** applied m141201_013120_create_status_table (time: 0.010s)

*** applying m150128_003709_extend_status_table_for_created_by
    > add column created_by integer NOT NULL to table {{%status}} ... done (time: 0.007s)
    > add foreign key fk_status_created_by: {{%status}} (created_by) references {{%user}} (id) ... done (time: 0.008s)
*** applied m150128_003709_extend_status_table_for_created_by (time: 0.016s)

*** applying m150128_233458_extend_status_table_for_slugs
    > add column slug string NOT NULL to table {{%status}} ... done (time: 0.007s)
*** applied m150128_233458_extend_status_table_for_slugs (time: 0.008s)

*** applying m150209_200619_extend_status_table_for_updated_by
    > add column updated_by integer NOT NULL to table {{%status}} ... done (time: 0.007s)
    > add foreign key fk_status_updated_by: {{%status}} (updated_by) references {{%user}} (id) ... done (time: 0.007s)
*** applied m150209_200619_extend_status_table_for_updated_by (time: 0.015s)

Migrated up successfully.

Adding the BlameableBehavior to the Status Model

Next, we’ll attach the BlameableBehavior to our Status model. In models/Status.php we add the BlameableBehavior after Sluggable:

class Status extends \yii\db\ActiveRecord
{
      const PERMISSIONS_PRIVATE = 10;
      const PERMISSIONS_PUBLIC = 20;  
  
      public function behaviors()
          {
              return [
                  [
                      'class' => SluggableBehavior::className(),
                      'attribute' => 'message',
                      'immutable' => true,
                      'ensureUnique'=>true,
                  ],
                  [
                      'class' => BlameableBehavior::className(),
                      'createdByAttribute' => 'created_by',
                      'updatedByAttribute' => 'updated_by',
                  ],
              ];
          }

We also have to include the Blameable behavior at the top of our model:

<?php

namespace app\models;

use Yii;
use yii\behaviors\SluggableBehavior;
use yii\behaviors\BlameableBehavior;

Then, we remove the required rule for created_by in the model rules:

public function rules()
    {
        return [
            [['message', 'created_at', 'updated_at','created_by'], 'required'],

Like this:

public function rules()
    {
        return [
            [['message', 'created_at', 'updated_at'], 'required'],

This allows the validation to succeed and continue on to the behaviors.

We can also comment out or delete the StatusController’s created_by assignment in the create action:

public function actionCreate()
    {
        $model = new Status();

        if ($model->load(Yii::$app->request->post())) {
          //$model->created_by = Yii::$app->user->getId();

Once all these changes are complete, we can write a new Status post:

Our Create Status form awaiting implementation of Blameable Behavior

And we can peek into the table view with PHPMyAdmin and see the created_by and updated_by settings:

The Status Table After Blameable Behavior Updates

Logging Updates to the Status Table

When a Status post is created, we’ll always know who created the first entry. But, with Blameable Behaviors, we’ll only know who last updated the record.

Let’s walk through a simple log implementation to record the id of the person who makes every update. Then you could easily see a history of updaters or extend this to be a full revision log.

Creating the Table for StatusLog

First, we need to create a migration for the StatusLog:

hello Jeff$ ./yii migrate/create create_status_log_table
Yii Migration Tool (based on Yii v2.0.2)

Create new migration '/Users/Jeff/Sites/hello/migrations/m150209_204852_create_status_log_table.php'? (yes|no) [no]:yes

New migration created successfully.

Then, we code the migration to include relational fields for the Status table id and User updated_by fields:

<?php

use yii\db\Schema;
use yii\db\Migration;

class m150209_204852_create_status_log_table extends Migration
{
    public function up()
    {
          $tableOptions = null;
          if ($this->db->driverName === 'mysql') {
              $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB';
          }

          $this->createTable('{{%status_log}}', [
            'id' => Schema::TYPE_PK,
            'status_id' => Schema::TYPE_INTEGER.' NOT NULL',
            'updated_by' => Schema::TYPE_INTEGER.' NOT NULL',
            'created_at' => Schema::TYPE_INTEGER . ' NOT NULL',
          ], $tableOptions);          
          $this->addForeignKey('fk_status_log_id', '{{%status_log}}', 'status_id', '{{%status}}', 'id', 'CASCADE', 'CASCADE');     
          $this->addForeignKey('fk_status_log_updated_by', '{{%status_log}}', 'updated_by', '{{%user}}', 'id', 'CASCADE', 'CASCADE');     
      }


    public function down()
    {
      $this->dropForeignKey('fk_status_updated_by','{{%status_log}}');
      $this->dropForeignKey('fk_status_id','{{%status_log}}');
      $this->dropColumn('{{%status_log}}','updated_by');
    }
}

Next, we run the migration:

hello Jeff$ ./yii migrate/up 
Yii Migration Tool (based on Yii v2.0.2)

Total 1 new migration to be applied:
    m150209_204852_create_status_log_table

Apply the above migration? (yes|no) [no]:yes
*** applying m150209_204852_create_status_log_table
    > create table {{%status_log}} ... done (time: 0.008s)
    > add foreign key fk_status_log_id: {{%status_log}} (status_id) references {{%status}} (id) ... done (time: 0.008s)
    > add foreign key fk_status_log_updated_by: {{%status_log}} (updated_by) references {{%user}} (id) ... done (time: 0.008s)
*** applied m150209_204852_create_status_log_table (time: 0.028s)

Migrated up successfully.

The fastest way to create a model for StatusLog (and CRUD files so we can easily browse the table) is with Yii2’s code generator, Gii. You’ve seen me use it in past tutorials.

Visit http://localhost:8888/hello/gii and create the model with these settings:

Gii Code Generator for Status Log Model

Here are the CRUD settings:

Gii Code Generator for Status Log CRUD Files

Next, we extend the AfterSave event in the Status model:

public function afterSave($insert,$changedAttributes)
        {
            parent::afterSave($insert,$changedAttributes);
            // when insert false, then record has been updated
            if (!$insert) {
              // add StatusLog entry
              $status_log = new StatusLog;
              $status_log->status_id = $this->id;
              $status_log->updated_by = $this->updated_by;
              $status_log->created_at = time();
              $status_log->save();
            } 
        }

This method calls the default parent functionality for afterSave but then creates a new StatusLog entry whenever there is an update to a Status row:

Theoretically, we could also extend BlameableBehavior, but since you have to ensure there is a log model for every ActiveRecord model you use it with, it seemed easier to build this functionality into Status.

If you update a couple of Status records, you can then browse the StatusLog using Gii’s CRUD. The image below shows two changes made by Status.id 1.

Status Log CRUD Browser - The Update Log

If you want to go further, it should be relatively straightforward to extend this to a revision table complete with previous and new status text to support rollback functionality.

What’s Next?

I hope you’ve enjoyed learning about Yii2 Behaviors and Blameable. Next, we’ll explore Timestamp Behaviors, which reduce the amount of code you need to write with each new model for the common operation of creating timestamps for inserts and updates.

Watch for upcoming tutorials in my Programming With Yii2 series as I continue diving into different aspects of the framework. You may also want to check out my Building Your Startup With PHP series which is using Yii2’s advanced template as I build a real world application.

I welcome feature and topic requests. You can post them in the comments below or email me at my Lookahead Consulting website.

If you’d like to know when the next Yii2 tutorial arrives, follow me @reifman on Twitter or check my instructor page. My instructor page will include all the articles from this series as soon as they are published. 

Related Links

  • The Yii2 Definitive Guide: Behaviors
  • Yii2 Documentation: Blameable Behavior
  • Yii2 Developer Exchange, my own Yii2 resource site

Leave a Reply

Your email address will not be published. Required fields are marked *