September 1, 2023

Craft CMS entry query scopes with Yii behaviors

I posed this idea in the Craft CMS GitHub discussion board for additional thoughts and contributions. It's worth visiting it to see if any new findings or considerations have been mentioned!

Update 2023-09-10: Thanks to both Brandon Kelly and @wsydney76 for supplying recommendations to optimize the original code examples. Those optimizations are included below the original code blocks.

Behaviors are a powerful way to extend your Craft entries, assets, users, and more.

Andrew Welch wrote about behaviors in 2020 and there's a great example of using them to hook into the beforeSave() event and creating computed propeties like getHappyName() when fetching users via Twig (or PHP).

Here's how Yii defines behaviors:

Behaviors, also known as mixins, allow you to enhance the functionality of an existing component class without needing to change the class's inheritance. 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.

That means we can use behaviors for all sorts of functionalty. Not least of which is something I've grown to love about Laravel: local query scopes. Here's how those work:

A user model:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    /**
     * Scope a query to only include popular users.
     */
    public function scopePopular(Builder $query): void
    {
        $query->where('votes', '>', 100);
    }
}

Querying with a scope:

use App\Models\User;

$users = User::popular()->get();

Now we have a tidy, reusable scope we can call from PHP, a Blade template, a Livewire component, etc.

Setting it up in Craft

You'll need a module to set this functionality up. I won't go into those setup details, but here's approximately what your module code might look like.

You'll notice we're extending EntryQuery, but this could be AssetQuery, UserQuery, (or others) depending on what you're querying.

See Element Queries for more details and the full API at your disposal when creating your scope.

The module class:

Update 2023-09-10: The code I posted has been updated with recommendations from Brandon Kelly.

<?php

namespace modules\foo;

use yii\base\Event;
use craft\elements\db\EntryQuery;
use craft\events\DefineBehaviorsEvent;

// This is the class we'll be storing our scopes in
use modules\foo\behaviors\QueryBehavior;

class Module extends \yii\base\Module
{
    public function init()
    {
        // Setup an event that will bind our behavior to Craft's EntryQuery class
        // You might substitute EntryQuery for AssetQuery if you're working with assets, for example.
        Event::on(
            EntryQuery::class,
            EntryQuery::EVENT_DEFINE_BEHAVIORS,
            function (DefineBehaviorsEvent $event) {
                $event->behaviors[] = QueryBehavior::class;
            }
        );
    }
}

The behavior class:

Update 2023-09-10: The code I posted has been updated with recommendations from wsydney76.

<?php

namespace modules\foo\behaviors;

use craft\elements\db\EntryQuery;

class QueryBehavior extends \yii\base\Behavior
{
    /**
     * Scope the query to entries that have a rating of 4 or higher.
     */
    public function topRated(): EntryQuery
    {
        /**
         * This is an instance of EntryQuery, allowing you full access
         * to that API for your filtering
         *
         * @var EntryQuery $query
         */
        $query = $this->owner;

        // Use ->andWhere() to ensure preceeding ->where() clauses are not overwritten
        return $query->andWhere(['>=', 'field_rating', 4]);
    }
}

Now we can begin using our behavior to create commonly-used scopes when query entries. For instance:

With Twig:

{% set entries = craft.entries.section('posts').topRated().all() %}

With PHP:

use craft\elements\Entry;

Entry::find()->section('posts')->topRated()->all();

And because we return the instance of the EntryQuery from within our scope, you can continue to chain these scopes:

use craft\elements\Entry;

Entry::find()
  ->section('posts')
  ->topRated()
  ->limit(5)
  ->orderBy('title')
  ->all();

Be careful, though!

While this is a nice way to centralize your commonly scoped queries, it might be ambiguous which queries these work for. For instance, tacking on our ->topRated() scope might not work for entries within the "pages" section.

It's also not entirely apparent where these are defined at first glance. Keep that in mind when you're working with a team or handing off your project to someone!