January 22, 2020

Craft CMS: How To Query Craft Entries Based On Multiple Category Relations

Mitchell Kimbrough
Mitchell Kimbrough
Founder & CEO

(Also known as conjoined category searching among other things.)

So you need to pull entries out of Craft CMS where those entries have all of a set of categories belonging to them? We did too recently, for our client, Here Comes The Guide. We needed to pull wedding venues out of Craft but only if they belonged to a given region AND a given venue type AND had a specific alcohol policy. All three conditions had to apply and all three conditions were stored in Craft as categories to which the wedding venue entries belonged.

I got a little anxious when I started looking around for documentation or support topics about this problem. I couldn't initially get it to work. The details out there were a little sparse. To make matters worse, my code was going to live in a plugin rather than in a twig template. If you have or may have this functional need, then this post is for you. Hopefully, it gives you some more detail about a useful capability inside Craft CMS.

First, your Craft query will use the relatedTo() parameter. Second, it will pass multiple values in that param through an array. Third, the first element in the array is an 'and' string. Craft calls this Multiple Relation Criteria. Here's the Craft 3 version and here's the Craft 2 version. The syntax is the same on both Craft 2 and 3. So don't trip.

Imagine you have a bunch of wedding venues as our client does. Some are located in Northern California and some are located in Southern California. Imagine you want to pull only Northern California entries and only if they are barns. Imagine also that you only want to pull Northern California barns that allow BYOB.

Pull by category number (ick!) #

You have a few choices in your twig template. You can pull by category id number in which case your query is more simple. This will work just fine. Say Northern California is category 33, barns are category 22587 and BYOB is category 3070. This would work:

{% set entries = craft.entries.section('wedding-venues').relatedTo(
	['and', {'element': 33}, {'element': 22587}, {'element': 3070}]
).all() %}

The [] brackets indicate an array in twig. The first element in that array is the 'and' trigger. This tells Craft to do a conjoined relatedTo query. Then the remaining array elements are themselves associative arrays. The 'element' string is the key and the number is the value, the category number.

You could do this too to be even more precise:

{% set entries = craft.entries.section('wedding-venues').relatedTo(
	['and', {'targetElement': 33}, {'targetElement': 22587}, {'targetElement': 3070}]
).all() %}

That query is saying, give me wedding venues that belong to all of those categories where the relationship is assigned in the wedding venue entry. So if you were editing a wedding venue listing in the Craft CP for San Francisco Barn Farm you would have category fields that allowed you to choose region, type, and alcohol policy choices. Those are all categories, and they are considered target elements by Craft. My first query merely indicated 'element' which meant I didn't care if the category assignment originated in the wedding venue entry or in the region entry, etc.

Pull by category object (yay!) #

The above code is crazy. On a complex site when could you ever hardcode a template with the category numbers? If you could you would hit the database a lot less. That's lovely. But it's not realistic. In the real world you would pull more like this:

{% set region = craft.categories.slug('northern-california').one() %}
{% set type = craft.categories.slug('barn').one() %}
{% set alcohol = craft.categories.slug('byob').one() %}

{% set entries = craft.entries.section('wedding-venues').relatedTo(
	['and', {element: region}, {element: type}, {element: alcohol}]
).all() %}

So you grab the categories by slug. Then you pass that Craft object into your entries query. Of course my example still has you hardcoding junk. You'd more likely pass values in the url; maybe as segments like this:

{% set region = craft.categories.slug(craft.app.request.getSegment(1)).one() %}
{% set type = craft.categories.slug(craft.app.request.getSegment(2)).one() %}
{% set alcohol = craft.categories.slug(craft.app.request.getSegment(3)).one() %}

{% set entries = craft.entries.section('wedding-venues').relatedTo(
	['and', {element: region}, {element: type}, {element: alcohol}]
).all() %}

You shouldn't expect each URL segment to be present so you'd want to get smart with conditionals and such, but onward!

The PHP version for plugin development #

So all of the above still holds. The syntax from twig to PHP just needs to be translated. I'll also show you a fun little quirk I found that you can exploit for plugin developer laziness.

Grab your cats...

$region	= Category::find()
	->slug('northern-california')
	->one();

$type = Category::find()
	->slug('barn')
	->one();

$alcohol = Category::find()
	->slug('byob')
	->one();

Then build your query...

$entry = Entry::find()
	->section('wedding-venues');

...continue building on to that $entry query and do some more logicky stuff...

$entry->relatedTo(
	array(
		'and',
		array('element' => $region),
		array('element' => $type),
		array('element' => $alcohol)));

...then hit the DB...

$entries = $entry->all();

You can mess around with this query if you want to. You can pass the whole array as a targetElement and omit the element designation on each array element.

$entry->relatedTo(
	array(
		'targetElement'	=> array('and', $region, $type, $alcohol)));

You can pass category ids as well...

$entry->relatedTo(
	array(
		'targetElement'	=> array('and', 33, 33587, 3070)));

I hope this helps you out. I had to do trial and error for a while before I finally got the behavior I needed.

Remember, all of the above queries pull wedding venue entries out of the Craft CMS DB where the entries have all three categories assigned: Northern California, Barns, BYOB.

Good luck!

Mitchell Kimbrough
Mitchell Kimbrough Founder & CEO