Diagnosing Pagination Gaps in CakePHP 4.x: A Step‑by‑Step Guide
When CakePHP 4.x pagination shows missing rows or empty pages, the problem often lies in query builders, NULL ordering, or cached results. Follow these checks to locate and fix the issue.
04 Aug 2026, 20:22 UTC

Problem Statement
In a CakePHP 4.x application, the paginator may return fewer rows than expected, skip records, or display empty pages even though the underlying table contains data. The issue often appears only on certain pages or after applying filters, making it difficult to trace without inspecting the generated SQL.
Cause / Indicator Table
| Possible Cause | Typical Indicator |
|---|---|
Missing or incorrect contain associations | Inner joins drop rows when related tables have no matching records, reducing the result set. |
| Custom query conditions or callbacks that alter the paginator’s query | The count query returns a different number than the data query; LIMIT/OFFSET may appear in the count SQL. |
| Ordering on a column that can contain NULL values | Pages shift unpredictably because NULLs are sorted as lowest or highest, causing unstable page boundaries. |
| Stale query or result cache | Pagination behaves inconsistently after recent INSERT/UPDATE/DELETE operations. |
Ordered Diagnostic Checks
- Verify the paginator configuration
Ensure you do not modify the query object after calling
paginate. Run the action with debug output to see the final query.// In a controller action public function index() { $query = $this->Articles->find(); // Do NOT modify $query after paginate $articles = $this->Paginator->paginate($query); $this->set(compact('articles')); }Check the debug log (or
Debugger::dump($this->Paginator->getQuery());) to confirm that the count and data queries share the same WHERE, JOIN, and ORDER BY clauses, differing only by LIMIT/OFFSET.Where to run: In the controller action during a request; requires normal application permissions.
Risk: None – this is a read‑only inspection.
- Inspect the generated SQL in a database client
Copy the count and data queries from the debug output and execute them manually.
-- Count query (example) SELECT COUNT(*) AS `count` FROM `articles` `Articles` WHERE `status` = 'published'; -- Data query (example) SELECT `Articles`.`id`, `Articles`.`title` FROM `articles` `Articles` WHERE `status` = 'published' ORDER BY `Articles`.`id` ASC LIMIT 10 OFFSET 0;Verify that the count matches the total rows you expect. If the count is lower, the WHERE/JOIN clauses are filtering out rows.
Where to run: Any SQL client with access to the application database; requires read permission.
Limitation: Manual execution does not capture application‑level query builders; compare only the raw SQL.
- Check
containusageIf you need related data, add the association before pagination. Missing related rows cause inner joins to drop parent rows.
$query = $this->Articles->find()->contain(['Authors']); $articles = $this->Paginator->paginate($query);If the related table may lack matching rows, switch to a LEFT JOIN:
$query->contain([['Authors', 'joinType' => 'LEFT']]);Where to run: In the controller before the paginate call.
Risk: LEFT JOIN can increase rows if the related table has multiple matches; consider using
distinctor grouping if duplicates appear. - Examine custom finders or query callbacks
Custom finders must return a query object that preserves the original SELECT list and ordering. Avoid adding
select,limit, oroffsetinside the finder.// Inside a custom finder method public function findActive(Query $query, array $options): Query { return $query->where(['status' => 'active']); // DO NOT ->select(...) or ->limit(...) here }After applying the finder, run the debug query check again to ensure the count and data queries remain aligned.
Where to run: In the model or table class where the finder is defined.
Risk: Modifying the SELECT list can cause the count query to count different columns, leading to mismatched totals.
- Validate ordering with NULLs
If ordering on a column that can be NULL (e.g.,
published_at), add a secondary ordering key or use COALESCE to make the sort deterministic.$query->order(['published_at' => 'DESC', 'id' => 'ASC']); // or $query->order(['COALESCE(published_at, '1970-01-01')' => 'DESC']);Re‑run pagination and verify that page boundaries are stable across multiple requests.
Where to run: In the controller before paginate.
Limitation: COALESCE with a constant may affect semantic ordering; test with realistic data.
- Disable caching temporarily
If you suspect stale query or result cache, turn off caching for the request and retest.
use Cake\Core\Configure; Configure::write('Cache.disable', true);After disabling, clear any existing caches with
bin/cake cache clear_alland repeat the pagination test.Where to run: In the controller’s
beforeFilteror in a bootstrap script; requires permission to modify configuration.Risk: Disabling cache may increase load; re‑enable after testing.
- Confirm no query modifications after paginate
Search the controller action for any code that alters the query variable after the paginate call. Such changes break the count query.
// BAD: modifying after paginate $query = $this->Articles->find(); $articles = $this->Paginator->paginate($query); $query->where(['featured' => 1]); // alters query after paginateRefactor to keep all query building before paginate:
$query = $this->Articles->find()->where(['status' => 'published']); $articles = $this->Paginator->paginate($query); // No further changes to $queryWhere to run: In the controller action; read‑only inspection.
Fixes Tied to Findings
- Missing
contain– Add the association before paginate; usejoinType => 'LEFT'when related data may be absent. - Count/Data mismatch from custom query logic – Remove any
select,limit, oroffsetfrom the query before paginate. Apply filters via the paginator’soptions(conditions,whitelist) instead. - NULL ordering instability – Add a secondary ordering column or wrap the ordered column in
COALESCEto produce a deterministic sort. - Cache interference – Clear caches with
bin/cake cache clear_allduring development, or disable caching temporarily as shown above. - Post‑paginate modifications – Ensure all query building happens before the paginate call; treat the paginated result as read‑only.
Escalation Criteria
If pagination remains inconsistent after completing the checks:
- Enable full query logging (
DebugKitor database logs) and compare the exact SQL executed for count and data queries. - Test the same query in a fresh CakePHP 4.x sandbox to rule out application‑level overrides or plugins.
- Review any third‑party plugins that hook into
Paginatoror the query builder for unintended side effects. - Post a minimal reproducible example (including the controller action, model finder, and debug SQL) on the CakePHP issue tracker or community forums for further assistance.
Practical Check List
- Run
bin/cake cache clear_allbefore each test cycle. - Use
DebugKitpanel to view the paginator’s final query and execution time. - Run the count and data queries in a SQL client and confirm the counts match.
- Ensure all
containcalls usejoinType => 'LEFT'when related rows may be missing. - Verify that no query‑altering code appears after the paginate call in the controller.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.