How To Add Featured Search Results To Craft CMS
There are a few business cases where you might want to cause featured entries to bubble up to the top of your search results page on Craft. We had this request from IDEO a few years ago. When someone went to ideo.com and searched for "Tim Brown" (he's the chairman), IDEO wanted his bio page listing at the very top of the results. There's tons of content on the site about Mr. Brown. IDEO wanted his main bio page to show at the top.
Our client Here Comes The Guide wanted something similar. HCTG is a directory of listings of wedding venues and wedding professionals. They sell premium listings on their website. They have a paid level where their clients can buy featured results on the search pages. When we moved their site from Craft 2 to Craft 3 recently we needed to simplify and clean up this featured search listing behavior. It can actually be a knotty little problem. Here's how we did it.
First, we created a Landing Pages section in Craft. This would contain the individual paid listings in the form of optimized landing pages. Then we began with our first attempt which was pretty much by the book. We wrote a plugin that took in search parameters, hit the database, and tried to find listings that matched the criteria within some percentage of certainty. Troubleshooting this ended up being onerous and brittle mainly because there were so many search criteria and 80% of those criteria were considered valid and for sale. We reached a point of sunk costs and realized we were spinning our wheels trying to code queries for all the exception cases. So we moved on to our second and final approach.
The approach that we landed on and that we stuck with used Preparse. This plugin is triggered when an entry is saved. It passes the entry object across to itself and allows twig parsing to take place, writing the result into a field in the entry that was saved. (Incidentally, we employed this same concept in a plugin for ExpressionEngine called Preparse back in the day. I'm very happy to see the idea still alive and well.)
So we had a Landing Pages section and in that section, we had a Preparse field. Preparse allows you to store your twig code in a template if you want. Preparse would fire, it would call to the twig template and that template would parse through the entry and compose a full search result formatted URL. That URL would be saved in the Preparse field. Then on the search results page, we would have a filter on the entries query to pull any landing pages whose Preparse field value matched the URL of the search results page we were on. Here's an example:
Imagine you are searching for a wedding venue in Central California and you want only venues that offer outdoor ceremony space. You would end up with this URL.
https://www.herecomestheguide.com/wedding-venues/central-california/results?region[]=central-california&use[]=outdoor-ceremony
There are two paid listings for those search criteria: Cypress Ridge Pavilion and The Pines Resort. Each of those relies on their Preparse field. The value in that field matches the search results URL. We ignore the domain so that this functionality can work on our local machines and on our dev domains.
/wedding-venues/central-california/results?region[]=central-california&use[]=outdoor-ceremony
The business rules for paid landing pages were already pretty complex and we expect that complexity to grow over time. So we built up an initial architecture that could handle that complexity reliably. We didn't want all of our parsing code to live in one template. That would be a rat's nest of nested conditionals. So we divided the Preparse templates by entry type. There are currently 9 entry types in our section. So we have a quick twig snippet that routes Preparse to the right parsing template depending on entry type. We store the templates in our '_macros/getFeatured" template directory.
{% spaceless %}
{% include '_macros/getFeatured/entryType-' ~ element.type %}
{% endspaceless %}
Here's a snippet of one of those templates. Notice how we re-fetch the entry that we are working on. Sometimes all of the category assignments don't come through on the initial "element" Preparse hands us.
Basically we look in the entry for each one of our relevant search criteria. If we find a value there, we write that to the string we are going to parse as the final result of the Preparse template. This string becomes the value of the Preparse field.
{% set entry = craft.entries.status(null).id(element.id).one() %}
{% set query = '/' ~ entry.siteSection %}
{% set query = query ~ '/' ~ entry.primaryCategory.level(1).one().slug %}
{% set query = query ~ '/results?' %}
{% set args = '' %}
{% if entry.primaryCategory %}
{% set args = args ~ '®ion%5B%5D=' ~ entry.primaryCategory.level(2).one().slug %}
{% endif %}
{% if entry.uses %}
{% set filters = entry.uses.all() %}
{% if filters|length %}
{% for filter in filters %}
{% set args = args ~ '&use%5B%5D=' ~ filter.slug %}
{% endfor %}
{% endif %}
{% endif %}
Now we have our search string saved to our Preparse field. The name of that field is "getFeaturedByQueryString". Here's the template code you will find in the search results template:
{% set query = craft.app.request.url %}
{% set landing = craft.entries.section('landingPages').getFeaturedByQueryString(query).one() %}
So simple! We have taken a highly complex and business-critical website feature and simplified it down into something that can be easily and reliably maintained over the coming years.
So what happens when the business logic changes? All affected entries have to be resaved in order to pick up the new logic. All of the values in "getFeaturedByQueryString" need to be updated. Craft CMS to the rescue! When you save a section in Craft all the entries belonging to that section get resaved. Be careful you don't bring down your server during peak traffic time though. This feature sometimes eats CPUs.
I think I can hear your objection already. "But what happens if a paid listing should be featured for more than one search URL?" This is a valid objection of course, but it sort of obsesses about creating more complexity than is really necessary. Keep the architecture simple. Just have the client charge for and create separate listings for each query type they want to sell. It may sound cumbersome, but it is so much more simple to understand than burying the complexity deep in a plugin. Believe me on this one. I've been doing this stuff for 20 years.