Eager Loading with CakePHP’s contain(): One Call, Zero Per‑Row Queries
Eager loading with CakePHP’s contain() lets you fetch related data in a fixed number of queries. Learn how to use contain() for Articles, Authors and Comments, avoid common pitfalls, and verify the SQL output with DebugKit.
04 Sept 2026, 07:38 UTC

Why contain() Matters for Performance
When you query Articles and need related Authors and Comments, CakePHP’s ORM can load all of that data in a handful of SQL statements instead of one extra query per article. The contain() method tells the ORM to eager‑load named associations at query time, replacing the default lazy‑loading behaviour that would otherwise hit the database inside your view or model code.
How contain() Works Under the Hood
In CakePHP 3.x through 5.x the ORM treats associations differently based on their type:
- belongsTo / hasOne – fetched with a
LEFT JOINin the main query. - hasMany / belongsToMany – fetched with a separate
SELECTthat usesIN (…)to pull all related rows in a single batch.
For nested associations (e.g. Comments.Users) each hasMany level adds one more batched query. The total number of SQL statements does not grow with the number of rows returned, only with the depth of containment.
Worked Example: Articles, Authors, and Comments
Assume the following schema and table classes (standard CakePHP conventions):
articlestable:id,author_id,title,bodyauthorstable:id,namecommentstable:id,article_id,body,created
In ArticlesTable::initialize() we declare the associations:
public function initialize(array $config): void
{
parent::initialize($config);
$this->belongsTo('Authors');
$this->hasMany('Comments');
}
Now fetch articles with their authors and comments in a single controller action:
// src/Controller/ArticlesController.php
public function index(): void
{
$articles = $this->Articles->find()
->contain([
'Authors',
'Comments' => function ($q) {
return $q->orderBy(['Comments.created' => 'DESC']);
}
])
->limit(10);
$this->set(compact('articles'));
}
When this runs, DebugKit will show two SQL statements: one LEFT JOIN for Authors and one SELECT that pulls all Comments for the 10 articles returned. No additional queries occur when you access $article->author or $article->comments in the view.
Filtering and Ordering with contain()
contain() can accept options per association. For example, to only include comments from the last week:
$articles = $this->Articles->find()
->contain([
'Comments' => [
'conditions' => ['Comments.created >' => date('Y-m-d H:i:s', strtotime('-7 days'))]
]
]);
When you need to filter the parent set based on a child table (e.g. only articles that have at least one comment), use matching() instead of contain(). matching() performs an INNER JOIN that can reduce the result set, whereas contain() simply loads related rows without affecting the parent query.
Pagination and contain()
The Paginator component accepts the same query object you built with contain(). Sorting on a belongsTo column works because the join is part of the main query. Sorting on a hasMany column, however, is not supported directly: the paginator cannot order by a column that is loaded via the batched query. If you need that behaviour, consider a subquery strategy or a custom finder that joins the child table explicitly.
Common Mistakes and How to Avoid Them
- Omitting FK columns in select() – If you call
select(['id', 'title'])and thencontain('Authors'), theauthor_idcolumn is missing, so the association resolves tonull. Always include the foreign key when narrowing the selected fields. - Assuming lazy loading when contain() fails – If you forget to add an association to
contain(), accessing$article->authorwill trigger a lazy query (if lazy loading is enabled) or returnnull(if lazy loading is disabled). Verify with DebugKit that the query count matches expectations. - Using
order()instead oforderBy()in newer versions –order()was deprecated in CakePHP 4.4 and removed in 5.x. UseorderBy()ororder()only if you are on 3.x. - Mixing
limit()with a hasMany join strategy – ALEFT JOINstrategy for ahasManyassociation can truncate results whenlimit()is applied. CakePHP automatically switches to the batchedINstrategy for paginated queries, but make sure you are not forcing a join strategy withstrategy('join'). - Sorting on a contained hasMany column – The paginator cannot sort by a column that is loaded via the batched query. If you need such sorting, use a custom finder that performs the appropriate join.
Verifying the Query Count
After running your action, open the DebugKit SQL panel. The number of statements should match the pattern:
- One
LEFT JOINfor eachbelongsTo/hasOneassociation. - One
SELECTperhasMany/belongsToManylevel (or more if you have nested containment).
For the example above, you should see two statements regardless of how many articles are returned. If you see one query per article, contain() was not applied or lazy loading was enabled.
Limitations to Keep in Mind
- Filtering by child data –
contain()cannot filter parent rows by child content. Usematching()orinnerJoinWith()for that. - Deep nested containment – Each
hasManylevel adds a new batched query. If you need to load many levels, be aware the query count grows linearly with depth, not with the number of rows. - Custom finders and strategies – Some custom finders may override the default eager‑loading behaviour. Verify the generated SQL if you use a finder that modifies the query structure.
Practical Takeaway
Whenever you know the view or service layer will need related data, explicitly call contain() at the point of the query. It documents your data requirements, keeps the number of database round‑trips predictable, and prevents accidental lazy queries that can explode in large result sets.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.