Paginating Filtered Queries Safely in CakePHP: sortableFields, maxLimit, and Out-of-Range Pages
CakePHP's paginator handles paging mechanics, but defaults leave you open to unbounded limits and arbitrary sort columns. Learn to paginate filtered queries with sortableFields, maxLimit, and deliberate out-of-range handling.
22 Sept 2025, 17:07 UTC

Letting users page, sort, and size a result set through query parameters is convenient — until someone requests ?limit=100000 or sorts by a column you never indexed. CakePHP's paginator gives you the paging mechanics for free, but the defaults do not protect you from unbounded limits or arbitrary sort columns. This guide shows how to paginate a filtered ORM query, lock down sorting and page size, and handle out-of-range pages deliberately.
Version note: the examples target CakePHP 4.x, where the setting is called sortableFields. In CakePHP 3.x the same concept is sortWhitelist. In CakePHP 5 the PaginatorComponent was removed in favor of pagination built into the controller — check your composer.json and the migration guide for your major version before copying configuration keys.
What you need before starting
- A CakePHP 4.x application with a table that has enough rows to page (the examples use an
Articlestable with aCommentsassociation). - Access to the controller and its template.
- No special permissions are required — pagination is pure application code.
Step 1: Load the paginator and set safe defaults
In your controller, load the component and declare a $paginate property. Setting defaults at the class level means every paginated action in the controller inherits the guardrails.
// src/Controller/ArticlesController.php\npublic function initialize(): void\n{\n parent::initialize();\n $this->loadComponent('Paginator');\n}\n\nprotected $paginate = [\n 'limit' => 20,\n 'maxLimit' => 100,\n 'sortableFields' => ['id', 'title', 'created'],\n 'order' => ['Articles.created' => 'desc'],\n];Three of these settings do the defensive work:
limit— default page size when the request does not specify one.maxLimit— hard cap on the?limit=query parameter. Without it, a client can force a page size large enough to hurt your database.sortableFields— an allowlist of columns users may sort by via?sort=. Any other sort parameter is ignored. This prevents sorting on unindexed or sensitive columns and avoids leaking which columns exist.
Step 2: Paginate a filtered query, not the whole table
paginate() accepts any ORM query, so apply filters and associations before pagination. The paginator runs a separate count query against your filtered query, so only matching rows are counted and fetched.
public function index()\n{\n $query = $this->Articles->find()\n ->where(['Articles.published' => true])\n ->contain(['Comments']);\n\n // Optional per-action override of the class defaults:\n $settings = ['limit' => 10];\n\n $articles = $this->paginate($query, $settings);\n $this->set(compact('articles'));\n}The second argument to paginate() merges over the $paginate property, so maxLimit and sortableFields from the class property still apply here.
One caveat: if your query already calls limit(), offset(), or uses certain group()/distinct() combinations, the paginator's count query can return surprising totals. If your page counts look wrong, test the count with your real filters — $query->count() in a debug session shows what the paginator will see.
Step 3: Render navigation and sortable headers in the view
The component stores paging metadata in the request, which the view's Paginator helper reads. Sortable headers generated with sort() automatically respect your allowlist — clicking a column not in sortableFields has no effect.
<!-- templates/Articles/index.php -->\n<table>\n <tr>\n <th><?= $this->Paginator->sort('title') ?></th>\n <th><?= $this->Paginator->sort('created', 'Published on') ?></th>\n </tr>\n <?php foreach ($articles as $article): ?>\n <tr>\n <td><?= h($article->title) ?></td>\n <td><?= h($article->created) ?></td>\n </tr>\n <?php endforeach; ?>\n</table>\n\n<div class=\"paginator\">\n <?= $this->Paginator->prev('« Previous') ?>\n <?= $this->Paginator->numbers() ?>\n <?= $this->Paginator->next('Next »') ?>\n</div>\n<p><?= $this->Paginator->counter('Page {{page}} of {{pages}}, {{count}} total') ?></p>Step 4: Decide what an out-of-range page means
When a user requests ?page=999 on a three-page result set, paginate() throws a NotFoundException, which CakePHP renders as a 404 by default. That is often fine. If you would rather redirect to page 1 (common for admin lists where filters change the total), catch it:
use Cake\\Http\\Exception\\NotFoundException;\n\npublic function index()\n{\n $query = $this->Articles->find()\n ->where(['Articles.published' => true]);\n\n try {\n $articles = $this->paginate($query);\n } catch (NotFoundException $e) {\n return $this->redirect(['action' => 'index', '?' => ['page' => 1]]);\n }\n\n $this->set(compact('articles'));\n}Pick one policy per endpoint and document it — silently redirecting on a public archive can confuse crawlers, while a 404 on an admin dashboard annoys users who bookmarked a page that no longer exists.
Verify the guardrails actually work
Do not trust the configuration; exercise it. With the development server running (bin/cake server), check each behavior in a browser or with curl:
- Filtering survives paging: request page 1 and page 2 of
/articlesand confirm both show only published articles, with no overlap between pages. - Sort allowlist: request
?sort=password_hash&direction=asc(or any column not insortableFields). The row order should be unchanged — the parameter is ignored, not applied. - Limit cap: request
?limit=10000. The page should contain at most 100 rows (yourmaxLimit), and the counter output should reflect the capped count. - Out-of-range page: request
?page=9999and confirm you get your chosen behavior — a 404 or a redirect to page 1.
Limitations to keep in mind
- Configuration key names differ across major versions;
sortableFieldsis 4.x terminology. Confirm against your installed version. - The count query runs on every request. On very large tables with expensive
whereclauses, the count itself can be the bottleneck — consider caching the total or using a simpler counter query via thefinderpagination option. - Pagination state lives in the URL query string, so any redirect or form submission that drops query parameters resets the user's page.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.