Yii2 Optimization & Caching and ActiveRecord
September 27, 2019

Yii2 Optimization & Caching and ActiveRecord

The Yii2 framework is all-in-all an excellent framework for building complex websites. Working with the framework can, however, be quite frustrating at times mostly due to a lack of documentation and examples. Of course if more people used the framework, we'd have more good documentation and examples available, so it's a kind of chicken and the egg problem. I have been working with the YII framework since the original version, but I am definitely not an expert. This post is mostly a collection of notes for myself on my understanding of how to implement caching to speed up your website. It is especially unfortunate that this aspect of the documentation is lacking because without proper caching, your YII2 site will become slow and "old" very quickly, leading you, the publisher/developer, to seek other, better frameworks when in fact the solution was simple (as usual) but just undocumented.

My own research initially lead me to the following great resources, I recomment you read them first - I will not cover these well-documented topics myself. So please read these first if you are new to caching:

  1. The "Caching" chapter in the official guide
  2. "How To Use Caching with the Yii Framework To Improve Performance" by Marco Troisi

Prerequisites

Install a Cache Storage Engine

(1) Choose a supported cache storage engine, install it on your server and make sure it's working. Good choices would be MemCached (what I use) or ApcCache - both are easy to set up and very performant.

Below is a summary of steps required to get MemCached working with Apache, taken from "How to Install Memcached on Ubuntu 18.04 & 16.04 LTS" by Rahul - but you can google for other guides such as "Installing PHP-7 with Memcached".

$ sudo apt-get update
$ sudo apt-get install memcached

To configure, edit /etc/memcached.conf - I only chose to increase the amount of memory available to memcached from the default of 64MB.

To verify, run echo "stats settings" | nc localhost 11211

Also install the required PHP extension:

$ sudo apt-get install -y php-memcached 
$ sudo service apache2 restart

You can verify that it's working using phpinfo().

Register a Cache Component

This goes into your application configuration file, in my case /config/web.php:

$config = [
    'components' => [
        'cache' => [
            'class' => 'yii\caching\MemCache',
            'servers' => [
                [
                    'host' => '127.0.0.1',
                    'port' => 11211,
                    'weight' => 100,
                ],
            ],
            'useMemcached' => true,
        ],
        ...

Configure your db connection definition

A simple step to improving performance is to simply enable ActiveRecord schema caching. Really, this should be on by default. So your /config/db.php (if that is where you store your db configuration) might look like:

return [
    'class' => 'yii\db\Connection',
    'dsn' => 'mysql:host=127.0.0.1;port=3306;dbname=XXX',
    'username' => 'XXX',
    'password' => 'XXX',
    'charset' => 'utf8',
    'enableSchemaCache' => true,
    'schemaCacheDuration' => 600,
    'schemaCache' => 'cache',
    'enableQueryCache'=> true,
    'queryCacheDuration' => 3600,
];

Data Caching with ActiveRecord

Caching with AR findAll() or one() etc.

Caching is very straightforward with AR:

$dependency = new CDbCacheDependency('SELECT MAX(id) FROM authors');
$authors = Author::model()->cache(1000, $dependency)->with('book')->findAll();

(the above example is taken from How To Use Caching with the Yii Framework To Improve Performance)

Yii 2 now requires closures to wrap the query. AR does a query eventually so you can put that in the closure. In an AR class, get the db and wrap the query you want to use. The closure has a signature function($db) and you usually need to access more variables, so add use($variable) to make variables visible within the closure.
From: JimHansen on stackoverflow

Another example:

$db = Yii::$app->db;// or Category::getDb()
$dep = new DbDependency();
$dep->sql = 'SELECT count(*) FROM category';
$result = $db->cache(function ($db) use ($id) {
    return Category::find()->where(['id' => $id])->all();
}, CACHE_TIMEOUT, $dep);

Caching a raw query

$db = Client::getDb();
$result = $db->cache(function ($db) use ($id) {
    $query = new \yii\db\Query;
    $query->select("client.*")
        ->from('client')
        ->where(['client.id' => $id])
        ->one();
    $command = $query->createCommand();
    $result  = $command->queryAll();
    return $result;
});

ActiveQuery ->all() and ->one()

You can easily enable caching all queries for a given table by following the example below. Say we have a "client" table and we wish to cache client data for 10 minutes. First add a "ClientQuery.php" file in the same directory as your "Client.php" model, and set the $queryCacheDuration to whatever seconds:

<?php

namespace app\models\application;

/**
 * This is the ActiveQuery class for [[Client]].
 *
 * @see Client
 */
class ClientQuery extends \yii\db\ActiveQuery
{
	public $queryCacheDuration = 600;
}

In this instance we are setting a cache timeout of 10 minutes (600 seconds). You can also set an "infinite" timeout with 0: public $queryCacheDuration = 0;

Then add a find() method to your model that refers to the ClientQuery:

	/**
	 * @inheritdoc
	 * @return ClientQuery the active query used by this AR class.
	 */
	public static function find()
	{
		return new ClientQuery(get_called_class());
	}

Alternatively, you can override the all() and one() routines and configure cache usage there (again, in your ClientQuery.php file):

<?php

namespace app\models\application;

/**
 * This is the ActiveQuery class for [[Client]].
 *
 * @see Client
 */
class ClientQuery extends \yii\db\ActiveQuery
{
    public function all($db = null)
    {
        $this->cache(60);
        return parent::all($db);
    }

    public function one($db = null)
    {
        $this->cache(60);
        return parent::one($db);
    }
}

Caching a relation-based query

Controller action (without caching):

$model = $this->findModel($id);
$issues = $model->getCurrentIssues()
    ->orderBy('ID DESC')
    ->all();
$retVal = $this->renderAjax('issues/issues', [
    'model' => $model,
    'issues' => $issues
]);

$model->getCurrentIssues() is a relation in the model:

public function getCurrentIssues()
{
    return $this->hasMany(Issue::class, ['SiteID' => 'ID'])
            ->onCondition(['Deleted' => 0])
            ->andOnCondition(['in', 'IssueStatusID', (new \yii\db\Query())->select('ID')->from('issue_status')->where(['Code' => ['REOPENED', 'CONFIRMED', 'NEW']])]);
}

The optimized controller action with caching:

$db = Yii::$app->db;
$model = $this->findModel($id);
$issues = Issue::getDb()->cache(function ($db) use($model) {
    return $model->getPastIssues()
    ->orderBy('ID DESC')
    ->all();
}, 60, null);

Note the use of use to "import" variables from the parent scope.

Caching in a Search Model

The example below is given by quangxue in response to an excellent github question:

I would rather use fragment caching because it saves more time...if you still want to use query caching with data provider, you can do so in a search model (the one generated by the crud code generator). You may use code like the following:
$db->cache(function () use ($dataProvider) {
    $dataProvider->prepare();  // trigger DB query
    // or if you want to db AR queries, you can do like this:
    // $customers = Customer::find()->all();
});

return $dataProvider;

Caching in a model relation

You may think that you can configure a model relation to be cached. An attempt to this end might look like this:

public function getStatic() {
    return \yii::$app->db->cache(function($db) {
        return $this->hasOne(StaticDataNew::className(), ['menu_id' => 'id']);
    });
}

This is actually not supported. As qiangxue writes:

There's no such usage. What the cache() method does is to turn on and off query cache when calling the callback. Note that hasOne() doesn't perform any DB query. So this has no effect.

Fragment Caching

This is very well explained in the official guide. An example using various options could be:

$dependency = [
    'class' => 'yii\caching\DbDependency',
    'sql' => 'SELECT MAX(updated_at) FROM post',
];

if ($this->beginCache($id, [
    'duration' => 3600,
    'enabled' => Yii::$app->request->isGet,
    'variations' => [Yii::$app->language],
    'dependency' => $dependency])) {

    // ... generate content here ...

    $this->endCache();
}

Page Caching

This is also explained in detail in the official guide. You can easily cache a whole page by adding a behavior to your controller as the illustrated in the following example:

class SiteController extends \app\controllers\ControllerBase
{
	public function behaviors()
	{
		return [
			[
				'class' => 'yii\filters\PageCache',
				'only' => ['view'],
				'duration' => 60,
				'variations' => [
					Yii::$app->request->get('id')
				],
			],
		];
	}

The duration is in seconds.

You restrict the cache to certain controller actions (in this case actionView) using the "only" property.

You can restrict caching by certain conditions using the "enabled" property, for example: 'enabled' => Yii::$app->request->isGet && Yii::$app->user->isGuest,

A "variation" ensures that a given cached value is unique to a specific call. If you omit the necessary variations, the cache will not work properly and just return the same value for different page calls. In our example above the "view" action has a parameter "id" so this is added as a variation. Another variation could be \Yii::$app->language,.

"Dependencies" can be added to automatically clear the cache if something changes in the database. For example the following would invalidate the given cache if a new post is added:

'dependency' => [
    'class' => 'yii\caching\DbDependency',
       'sql' => 'SELECT COUNT(*) FROM post',
],

Of course, keep in mind that adding dependencies can significantly decrease your cache performance, instead of always doing a query such as SELECT Title FROM post WHERE ID = 123 you are doing SELECT COUNT(*) FROM post and if that changes, only then you will be running the previous select. Better, of course, but not a huge improvement - especially for such simple queries.

Let's say we cache a page with a GridView - but we also include a "refresh" button in the grid toolbar. It would make sense to truly refresh the view when the user clicks the "refresh" button. To achieve this functionality, change cache behavior as shown in this example:

public function behaviors()
	{
		return [
			'productionEstimatesPageCache' => function () {
				return new class extends \yii\filters\PageCache{
					public function init(){
						parent::init();
						$this->only = ['production-estimates'];
						$this->duration = 7200;
						$this->variations = [
						   Yii::$app->request->get('siteId'),
						];
						if(Yii::$app->request->get('refresh') == 1)
							Yii::$app->cache->delete($this->calculateCacheKey());
					}
					//public function beforeCacheResponse(){
					//    return Yii::$app->request->get('refresh') != 1;
					//}
				};
			},

Comment out the "beforeCacheResponse()" bit if you wish to skip creating a new cache after invalidation.

The "refresh" button on the gridview can follow the example below:

echo GridView::widget([
    'toolbar' => [
        [
            'content'=>
                Html::a('<i class="glyphicon glyphicon-repeat"></i>',
                    [
                        'production-estimates',
                        'siteId' => $siteId,
                        'refresh' => 1
                    ],
                    [
                        'data-pjax'=>1,
                        'class'=>'btn btn-default',
                        'style' => 'float: right',
                        'title'=>Yii::t('kvgrid', 'Reset Grid')
                    ]),
            'options' => ['style' => 'float: right; margin-left: 10px;'],
        ],
        ...

This example was taken from an answer on stackoverflow.

Option to Clear a Specific PageCache for a Particular Action in PHP Code

Consider a page that you enable comments for, for example a blog post page. You could deal with this using Dynamic Content. Quoting from the official guide:

When using fragment caching, you may encounter the situation where a large fragment of content is relatively static except at one or a few places. For example, a page header may display the main menu bar together with the name of the current user. Another problem is that the content being cached may contain PHP code that must be executed for every request (e.g. the code for registering an asset bundle). Both problems can be solved by the so-called dynamic content feature.
A dynamic content means a fragment of output that should not be cached even if it is enclosed within a fragment cache. To make the content dynamic all the time, it has to be generated by executing some PHP code for every request, even if the enclosing content is being served from cache...

This is a valid approach but what if you also wish to cache the "dynamic content" (in our example the comments) and invalidate that cache only if someone adds a comment? Then "dynamic content" approach will not work because it will completely prevent the comments from being cached.

This is possible using Tag Dependency as noted in this StackOverflow answer by rob006!

Continuing with our example, let's assume that our view action takes an id as a parameter: public function actionView($id) {.... The id could be the identifier of the blog post being shown or whatever. We then set caching in our controller as usual:

public function behaviors()
{
    return [
        ...
        'actionViewPageCache' => [
            'class' => 'yii\filters\PageCache',
            'only' => ['view'],
            'duration' => 300,
            'variations' => [
                Yii::$app->request->get('id')
            ],
            'dependency' => new \yii\caching\TagDependency([
                'tags' => [
                    Yii::$app->requestedRoute,
                    Yii::$app->request->get('id'),
                ],
            ]),
        ],
    ];
}

We add "id" as a variation since each page call includes it as a parameter. You may not need to. We then set a TagDependency that includes the route and the id. Then, in our "add comment" action we can invalidate the page cache for the specific post the comment is being added to:

public function actionAddComment() {
    $blogPostId = Yii::$app->request->get('BlogPostID');
    $blogPostCommentModel = new BlogPostComment();
    $blogPostCommentModel->BlogPostID = $blogPostId;
    $posted = Yii::$app->request->post('BlogPostCommentText');
    $post = ['BlogPostCommentText' => $posted];

    // Invalidate the blog post page cache:
    \yii\caching\TagDependency::invalidate(Yii::$app->cache, [
        'application/blog-post/view',
        $blogPostId
    ]);
    
    if ($blogPostCommentModel->load($post)) {
        $errorMessage = $blogPostCommentModel->add();
        if (empty($errorMessage) || $errorMessage == 'OK')
        {
            handle error and return a response...
}

Bonus: Controller Action to Completely Clear the Cache

Call the following controller action to clear your schema and data cache from a browser (in part from a question on stackoverflow):

public function actionClearCache($id='cache')
{
    Yii::$app->db->schema->refresh();

    if (!isset(Yii::$app->{$id}) || !(Yii::$app->{$id} instanceof Cache)) {
        $msg = Yii::t('Invalid cache to flush: {cache}', ['cache'=>$id]);
        throw new InvalidParamException($msg);
    }

    /* @var $cache \yii\caching\Cache */
    $cache = Yii::$app->{$id};
    if ($cache->flush()) {
        $msg = Yii::t('app', 'Successfully flushed cache `{cache}`', ['cache'=>$id]);
        Yii::$app->session->setFlash('success', $msg);
    } else {
        $msg = Yii::t('app', 'Problem while flushing cache `{cache}`', ['cache'=>$id]);
        Yii::$app->session->setFlash('danger', $msg);
    }

    return $this->actionIndex();
}

Further Reading:

The "Performance Tuning" chapter in the Yii2 guide contains many good pointers on how to further optimize your Yii2 application.

References:

Yii2 Optimization & Caching and ActiveRecord
Share this