The Anatomy of Recently Viewed (A Craft CMS Plugin)

At the beginning of 2019 I created a few Craft CMS plugins. Here I'll be taking a deep dive into the reason why I created each of them and how they work.

What Is Recently Viewed?

Recently Viewed keeps track of Craft elements which a user has seen within their current session. It then allows these elements to be surfaced via an intuitive, fluent interface. It also supports custom element types provided by other plugins.

You can find it in the Craft Plugin Store.

Where Did The Idea Come From?

A lot of the things I build are prompted by seeing other people ask for something in a public channel. This plugin was created after a Craft developer asked the community if something similar existed yet. Once the comment had received a few replies confirming that there wasn't an existing solution for them I knew that a nicely crafted solution would have at least one willing customer.

Why Did I Decide To Build It?

This was my first commercial plugin and I wanted to learn about the ecosystem of paid-for Craft plugins. This plugin will also come in useful for my own projects.

Recently viewed products will probably be the most regular application of this plugin. So I imagine its success might grow over time depending on the adoption of Craft commerce. I also wanted to keep the plugin cheap as it's pretty simple in nature. I don't expect it to make any noticeable revenue any time soon.

How Does It Work?

The plugin has two core pieces of functionality: tracking and filtering.

Tracking

The tracking portion simply attaches element ids to the current user's session in order to create a list of their recently viewed content. This is achieved using a global service with a function that can be called from a twig template or plugin. As well as logging the element in the user's view history this function also ensures that duplicates are removed and correct ordering is maintained.

The tracking portion allows a developer to pass either an element id directly or any object which implements the craft\base\ElementInterface. This ensures an error will be thrown if random, non-element objects are passed into the tracking functions which could break the filtering code used later.

A last minute addition to the plugin also allows elements to be automatically tracked if the element is linked to the URL being visited by the user. I expected this to be quite difficult, maybe needing a search across all elements to find those matching the current URL. However Craft came to the rescue by providing all the info I needed within the URLManager component:

Event::on(View::class, View::EVENT_AFTER_RENDER_TEMPLATE, function(Event $e) {
    if ($this->getSettings()->autoTrack) {
        $urlManager = Craft::$app->getUrlManager();
        $matchedEntry = $urlManager->getMatchedElement();
        if (!is_null($matchedEntry) && is_a($matchedEntry, ElementInterface::class)) {
            self::$plugin->queries->track($matchedEntry);
        }
    }
});

Filtering

I tried a few different approaches to try to get the functionality that I wanted. My first attempt used a global service with functions for each of the default element types in Craft which would curate element queries based on the current user's view history. This quickly became unwieldy and wouldn't work well with custom element types so I scrapped that idea.

Incidentally, this is why the main service class in the plugin is called Queries - it was originally full of element queries and I just forgot to change its name to something more appropriate.

I decided to see if I could find a class in the Craft codebase which was used during all element queries that I could potentially subclass to inject my filtering logic. After a bit of research I found that all element queries created in both twig templates and plugins all use craft\elements\db\ElementQuery as their base.

I spent a couple of hours figuring out how any ElementQuery subclasses (EntryQuery etc) interacted with their parent class and how element queries are mapped to database requests. This took a bit of figuring out as I was essentially tracing execution paths through code on GitHub to build up my understanding. It became clear that ElementQuery provided all of the common event firing and hook points for the all the child element types.

This gave me an idea: if I can hook into some of the ElementQuery base functionality I should be able to apply some custom logic to queries for all element types, even if they're custom.

So I needed to figure out how to get my custom logic integrated with the ElementQuery class. Subclassing wasn't an option as all of the existing subclasses referred to ElementQuery using its full namespace.

Another option was to use Yii Behaviours. These would allow custom functionality to be injected and executed in response to events which are already being fired by the class they're attached to. If there was an event being fired by ElementQuery that occurred when any of the element subclasses were about to execute their DB query I should be able to adjust it before it is dispatched in order to apply my filtering.

It didn't take me too long from this point to formulate my plan:

  • Create a behaviour to attach to ElementQuery
  • Use my plugin base class to attach the behaviour
  • Hook into the EVENT_BEFORE_PREPARE event fired by ElementQuery
  • Adjust ElementQuery's local query and subquery variables to add my custom SQL WHERE filtering based on user history
  • Apply any custom SQL ordering
  • Allow the rest of the functionality to proceed normally

Attaching the behaviour at runtime turned out to be easy:

Event::on(ElementQuery::class, ElementQuery::EVENT_DEFINE_BEHAVIORS, function(DefineBehaviorsEvent $event) {
    $event->behaviors[] = RecentlyViewedBehavior::class;
});

The SQL took a little bit of fiddling to get right, mainly due to the need to order the results to match an arbitrary list of ids:. I handled MySQL and Postgres differently to make use of the MySQL specific FIELD function:

//MYSQL

$this->owner->subQuery->orderBy([new \yii\db\Expression(
    'FIELD (elements.id, ' . $idList . ')'
)]);
$this->owner->query->orderBy([new \yii\db\Expression(
    'FIELD (elements.id, ' . $idList . ')'
)]);

//Postgres

$allCases = '';
$count = 1;
foreach(array_reverse($recentIds) as $anId){
    $allCases .= 'WHEN elements.id=' . $anId . ' THEN ' . $count . ' ';
    $count++;
}
$this->owner->subQuery->orderBy([new \yii\db\Expression(
    'CASE ' . $allCases . ' END'
)]);
$this->owner->query->orderBy([new \yii\db\Expression(
    'CASE ' . $allCases . ' END'
)]);

At this point my element queries were being filtered by the list of recently viewed element ids collected during the tracking phase. However this was true for all queries. I needed a way to turn this functionality on and off on a per-query basis.

As I had integrated my filtering directly into normal element queries I also needed this on/off switch to be deeply integrated too. I achieved this by adding another function to my Behaviour which was already being attached to the ElementQuery class. This function simply toggled the recently viewed filtering for the query. I called this function recentlyViewed() and because it was a being injected into ElementQuery which is the base class for all element query types it means that all element queries, default or custom, will inherit this function.

In the following example craft.entries returns an EntryQuery object which subclasses ElementQuery. The section() function exists on EntryQuery as it's specific to Entries, but recentlyViewed() doesn't exist on the EntryQuery class; it has been injected into the parent ElementQuery class by my plugin.

{% set recents = craft.entries.section('blogPosts').recentlyViewed().all() %}

With that on/off switch in place the plugin was now able to optionally filter any element queries appropriately whilst also playing nicely with any additional filtering or ordering that the user might want to apply - all via the existing element query fluent interface.

Future Plans

I don't have anything specific planned for the future of this plugin. It does what it needs to do and it does it really well.

There's potentially an argument for pulling a user's viewed history out of their session and storing it separately, maybe referenced by a cookie - that would allow it to persist beyond a user's normal session lifetime - but I think it's unlikely that you'd want to time out a user's session data whilst maintaining their view history. Let me know if you think otherwise.


Read Next



2024 Goals
Write Things