Yii2 & PHP

Why your Yii2 site is slow and how to find out

#Yii2 #PHP #MySQL #Performance

I have looked at a lot of slow Yii2 applications. They are slow for a small set of repeating reasons, and the fix is almost never "add Redis" or "upgrade the server." The fix is understanding what the application is actually doing on each request.

Here is how I approach a slow Yii2 site when I encounter one.

Step 1: Turn on the debug toolbar

If you are not running the Yii2 debug module in development, you are debugging blind. Enable it:

// config/web.php (development only)
if (YII_DEBUG) {
    $config['bootstrap'][] = 'debug';
    $config['modules']['debug'] = [
        'class' => 'yii\debug\Module',
        'allowedIPs' => ['127.0.0.1', '::1'],
    ];
}

Now every page load shows you the number of database queries, total time, memory usage, and the full query log. This single step identifies the problem in most cases.

The four common culprits

1. The N+1 query problem

You load a list of 50 posts and for each one you call $post->author->name. That is 51 queries: one for the list, one for each author. The debug toolbar will show you 51 near-identical SELECT statements.

The fix is eager loading:

// Instead of this:
$posts = Post::find()->all();

// Do this:
$posts = Post::find()->with(['author', 'category', 'tags'])->all();

One query becomes three: posts, all related authors, all related categories. For 50 posts, that is 3 queries instead of 151.

2. Missing indexes

Your query looks fast in development with 200 rows. In production with 200,000 rows, the same query takes 4 seconds because it is doing a full table scan. The query log shows you the SQL; running EXPLAIN on it shows you whether an index is being used.

EXPLAIN SELECT * FROM post
WHERE status = 'published'
  AND category_id = 5
ORDER BY published_at DESC
LIMIT 20;

If you see type: ALL in the output, that is a full table scan. Add a composite index on the columns in your WHERE clause and ORDER BY.

3. Loading too much data

ActiveRecord makes it easy to load entire model instances when you only need two fields. Post::find()->all() selects every column including the full HTML content field for every row. In a list view you might only need the title, slug, and excerpt.

// Select only what you need
$posts = Post::find()
    ->select(['id', 'title', 'slug', 'excerpt', 'published_at'])
    ->where(['status' => 'published'])
    ->orderBy(['published_at' => SORT_DESC])
    ->limit(20)
    ->asArray()  // returns plain arrays, skips model overhead
    ->all();

4. PHP itself is the bottleneck

Sometimes the queries are fine and the application logic is slow. PHP's built-in profiling is not great, but for Yii2 applications the debug toolbar timeline usually shows you which component is taking the most time. If you need more detail, Xdebug with a profiler like Webgrind gives you a full call graph.

Production profiling without Xdebug

You cannot always run Xdebug on production. Instead, log slow requests using Yii2's logging component:

// Add to config
'log' => [
    'targets' => [
        [
            'class' => 'yii\log\FileTarget',
            'levels' => ['profile'],
            'logFile' => '@runtime/logs/profile.log',
        ],
    ],
],

Then wrap the expensive part of your code:

Yii::beginProfile('buildProductList');
$products = $this->productService->getList($filters);
Yii::endProfile('buildProductList');

Where to look next

Once you have identified the slow part, the solution is usually one of: eager loading, an index, a cache, or a query rewrite. In that order. Caching a slow query hides the problem and makes it harder to fix later. Fix the query first; cache it if the fix is not enough.

For pages where the content changes rarely (a blog index, a product list), HTTP caching with proper Cache-Control headers or Yii2's page cache component will often give you a 10x improvement at almost no cost:

public function behaviors(): array
{
    return [
        [
            'class' => PageCache::class,
            'only' => ['index'],
            'duration' => 300, // 5 minutes
            'variations' => [Yii::$app->request->get('page', 1)],
        ],
    ];
}

Start with the debug toolbar. Fix the N+1 queries. Add the missing indexes. Then cache if you still need it.