Easier Eager Loading with Collections

The first place you'll want to apply collection support in Craft 4 is anywhere you're using eager loading. The result will be one block of code that you can use for both eager-loaded and lazy-loaded elements, reducing errors in your code, as well as code duplication.

Image

In Craft 4 we have access to col­lec­tions, pow­ered by Laravel’s Col­lec­tions class.

Col­lec­tions are a wrap­per for work­ing with data arrays. Think about it as an array with spe­cial meth­ods avail­able that make it eas­i­er to work with the data.

Col­lec­tions sup­port in Craft comes in a cou­ple of dif­fer­ent forms. First, all eager-loaded ele­ments are now returned as Col­lec­tion arrays by default. Sec­ond, there’s a new collect() method avail­able for ele­ment queries. This returns the results of the query as a Lar­avel Collection. 

The first place you’ll want to apply col­lec­tion sup­port is any­where you’re using eager load­ing. The result will be one block of code that you can use for both eager-loaded and lazy-loaded ele­ments, reduc­ing errors in your code, as well as code duplication.

For this arti­cle, we’ll focus soley on how to use col­lec­tions sup­port with eager-loaded ele­ments in Craft CMS 4

Always Eager-Ready #

Since ver­sion 2.6, Craft returns eager-loaded ele­ments as arrays, and we have to iter­ate over them as a stan­dard array. 

When eager load­ing an asset ele­ment, you’re prob­a­bly famil­iar with hav­ing to access the image itself via the index (entry.asset[0]) instead of via .one() since that .one() method isn’t avail­able on a stan­dard array.

You have to update your Twig code if you decide to eager load an ele­ment and it makes some code not reusable because if a query isn’t eager-load­ing the ele­ment, you can’t access it as an array via the index. 

So, how does the addi­tion of Lar­avel Col­lec­tions help with eager loading?

The code is more straight­for­ward and reusable.

Craft 4 returns all eager-loaded ele­ments as col­lec­tions instead of stan­dard data arrays. Because of this change, we don’t need to have a spe­cial case using an array index to access the element.

Let’s look at an exam­ple with a matrix field and iter­at­ing over the block. With­out eager-load­ing, our query looks like this:

{% set entries = craft.entries()  
 .section('blog')  
 .limit(25)
 .all() %}

 {# ... #}

{% for entry in entries %}
	{% for block in entry.body.all() %}  
		 {% include ["matrix/" ~ block.type, "matrix/default"] %}  
	{% endfor %}
{% endfor %}
 

We call .all() on the ele­ment query and then again on the matrix block to exe­cute the query on each loop.

But we are good devel­op­ers, and we want to avoid the [[n+1]] per­for­mance issue, so we eager-load the matrix field, so it’s ready and wait­ing for us when it’s time to iter­ate over the blocks.

{% set entries = craft.entries()  
 .section('blog')
 .with(['body'])
 .limit(25)
 .all() %}

 {# ... #}

{% for entry in entries %}
	{% for block in entry.body.all() %}  
		 {% include ["matrix/" ~ block.type, "matrix/default"] %}  
	{% endfor %}
{% endfor %}
 

If we run the code as-is, you’re prob­a­bly famil­iar with the error that is returned:

Since we are eager-load­ing the Matrix block called body, Craft returns an array for the Matrix data instead of a Matrix iter­able object. So, we can’t use .all() since that method isn’t avail­able on an array. 

So we have to remove it.

{% set entries = craft.entries()  
 .section('blog')
 .with(['body'])
 .limit(25)
 .all() %}

 {# ... #}

{% for entry in entries %}
	{% for block in entry.body %}  
		 {% include ["matrix/" ~ block.type, "matrix/default"] %}  
	{% endfor %}
{% endfor %}
 

Arguably, not the biggest code issue; how­ev­er, this block of code is only func­tion­al when the Matrix field is eager-loaded. In instances where it’s okay to lazy-load, we’ll need to use a dif­fer­ent ver­sion of this code block. So, again, it’s not the worst thing, but it also reduces our reusable code.

We can make our code uni­ver­sal whether we’re eager-load­ing or not by adopt­ing Col­lec­tions. We do that by adopt­ing the .collect() method on all ele­ment queries where we used .all() pre­vi­ous­ly and on matrix blocks.

{% set entries = craft.entries()  
 .section('blog')
 .limit(25)
 .collect() %}

 {# ... #}

{% for entry in entries %}
	{% for block in entry.body.collect() %}  
		 {% include ["matrix/" ~ block.type, "matrix/default"] %}  
	{% endfor %}
{% endfor %}
 

Even if we eager-load the Matrix block data, the .collect() works just fine since Craft 4 returns all eager-loaded ele­ments as Col­lec­tions anyway.

The Matrix block code is usable no mat­ter if it’s eager-loaded or not. Win-win!

{% set entries = craft.entries()  
 .section('blog')
 .with(['body'])
 .limit(25)
 .collect() %}

 {# ... #}

{% for entry in entries %}
	{% for block in entry.body.collect() %}  
		 {% include ["matrix/" ~ block.type, "matrix/default"] %}  
	{% endfor %}
{% endfor %}
 

Eager-loaded Assets #

Let’s look at an exam­ple with an asset, which is anoth­er place we have to change our code depend­ing on whether we are eager-load­ing or not.

When not eager-load­ing an asset ele­ment, we would typ­i­cal­ly call the one() method and then .url to get the asset URL (in this exam­ple, we’re dis­play­ing an image).

<img src="{{ entry.teaserImage.one().url() }}" alt="{{ entry.title }}"/>  

But if we want to eager-load that image, we need to adjust the Twig code to sup­port eager load­ing. Craft returns an array for the eager-loaded data, so we can’t use .one() any­more. That means access­ing the array’s data via an index and then tack­ing on the .url() method to get the URL of the request­ed asset.

<img src="{{ entry.teaserImage[0].url() }}" alt="{{ entry.title }}"/>

But the goal here is to cre­ate one block of code we can use whether or not we are eager-load­ing the ele­ment or not. Just like with the Matrix ele­ment ear­li­er, we can do that for this asset ele­ment, too.

So what used to be this for eager-loaded Assets in Craft 3:

{% set entries = craft.entries()  
 .section('blog')  
 .with(['body', 'teaserImage'])  
 .limit(25)  
 .all() %}

 {% for entry in entries %}
	{% if entry.teaserImage %}  
		 <img src="{{ entry.teaserImage[0].url() }}" alt="{{ entry.title }}"/>  
	{% endif %}
 {% endfor %}

Is now this for all instances of this asset ele­ment, whether it is eager-loaded or not.

{% for entry in entries %}
	{% if entry.teaserImage %}  
		<img src="{{ entry.teaserImage.collect().first().url() }}" alt="{{ entry.title }}"/>  
	{% endif %}
{% endfor %}

This block of code works whether we eager-load the asset ele­ment or not because:

  • We are con­vert­ing it to a Lar­avel col­lec­tion (even if it’s eager-loaded and Craft returns it as a Col­lec­tion by default)
  • then, we’re call­ing the Lar­avel Col­lec­tions first() method on it to get the first ele­ment in the array. It is func­tion­al­ly the same as adding the array index on eager-loaded assets in Craft 3 and prior.
  • Because we now have a sin­gle asset, we can call the spe­cial .url() method that Craft pro­vides to gen­er­ate the URL for the asset.

Here, the key is call­ing collect() on both the main ele­ment query and again on the asset query, so we are always work­ing with Lar­avel Col­lec­tions no mat­ter the sit­u­a­tion: eager-loaded or lazy-loaded.

There’s no rea­son you can’t use the array index in all instances, as long as you call .collect() on the ele­ment query, but I find it’s nicer and more Craft-Twig-like to use the chained methods.