Hello everyone,
I am trying to correctly implement server-side pagination for a Tabulator grid using CakePHP 5’s built-in Paginator. My goal is to use the core features of the framework as intended, because it would be a shame to have to build a manual pagination system just for this case.
However, I’m getting stuck on a persistent issue with complex queries.
My stack is:
- CakePHP 5.x
FriendsOfCake/Search
pluginFriendsOfCake/Trash
behavior
The Problem
My controller action works perfectly when I paginate a simple query: _
$this->paginate($this->Applicationforms->find())_` succeeds.
As soon as I build the full query by chaining contain
, where
(from helpers that translate Tabulator’s filter format), and the Search
plugin’s finder, the call to $this->paginate($query)
fails “silently”:
- No
Exception
is thrown. - A
Warning: Trying to access array offset on null
is generated because thepaging
request attribute is not being set by the Paginator. - The JSON response is therefore corrupted by this PHP Warning, causing the
JSON.parse
to fail on the client side.
I have thoroughly read the official documentation on both the Controller-side Pagination and the PaginatorHelper, but they don’t seem to cover this specific, complex interaction.
The Question
What is the idiomatic and robust way in CakePHP 5 to make the Paginator work with a query that uses both the FriendsOfCake/Search
plugin’s finder (for business rules) and dynamic where
/order
clauses built from complex URL parameters (from Tabulator)?
Is there a known incompatibility or a specific way to chain these finders and clauses that I am missing?
Or, as a last resort, is the community consensus that for such complex queries, it’s better to bypass $this->paginate()
and build a manual pagination data structure (e.g., getting the count manually, then running a separate limited query)?
The Code
Here are the relevant code snippets for analysis.
1. src/Controller/ApplicationformsController.php
(the problematic list
action)
public function list()
{
$this->request->allowMethod('get');
// NOTE: The following code fails, generating a Warning on the getAttribute('paging') line
$usr = $this->getLoggedInUserEntity();
$qParams = $this->getRequest()->getQueryParams();
// 1. Choose base finder (for Trash Behavior)
$finder = $usr->isSuperUser() ? 'withTrashed' : 'all';
$query = $this->Applicationforms->find($finder);
// 2. Apply business rule filters via FriendsOfCake/Search plugin
$searchPluginParams = [
// serchBehavior4userRole() is a helper that returns a field name like 'user_id'
$this->serchBehavior4userRole($usr) => $usr->id
];
$query->find('search', ['search' => $searchPluginParams]);
// 3. Apply UI filters and sorting from Tabulator via custom helpers
$query->where($this->getFilterFromTabulator($qParams))
->contain([
'Yesnos', 'Departments', 'Users', 'Contracttypes', 'Hiringreasons',
'Budgetfeatures', 'Professionalcategories', 'Worktimes',
'Applicationformstatuses.Validationstatuses'
])
->order(array_merge(
$this->getOrderFromTabulator($qParams),
$this->defaultTableOrder4user()
));
// 4. Configure Paginator
$this->paginate = [
'limit' => $qParams['size'] ?? 50,
'sortableFields' => [
'id', 'jobtitle', 'begin_at', 'applicantname',
'Departments.code', 'Users.email', 'Contracttypes.code', 'Hiringreasons.code',
'Applicationformstatuses.valid_percentage', 'Validationstatuses.name',
],
];
// 5. Execute pagination (this is where the failure occurs)
$paginatedResults = $this->paginate($query);
// The following code generates the error because $this->request->getAttribute('paging') is null
$pagingParams = $this->request->getAttribute('paging')['Applicationforms'];
// ... formatting and building the JSON response ...
}
2. src/Model/Table/ApplicationformsTable.php
(initialize
method)
public function initialize(array $config): void
{
parent::initialize($config);
$this->setTable('applicationforms');
$this->setDisplayField('jobtitle');
$this->setPrimaryKey('id');
$this->addBehavior('Timestamp');
$this->addBehavior('Trash.Trash'); // Trash behavior is loaded
// Associations
$this->belongsTo('Departments', ['foreignKey' => 'department_id', 'joinType' => 'INNER']);
$this->belongsTo('Users', ['foreignKey' => 'user_id', 'joinType' => 'INNER']);
// ... other associations ...
$this->hasOne('Applicationformstatuses', ['foreignKey' => 'applicationform_id']);
// Add Search behavior
$this->addBehavior('Search.Search');
// Configure Search Manager
$this->searchManager()
->value('id')
->add('q', 'Search.Like', [ /* ... options for a global search ... */ ])
->add(self::SEARCH_AF_BELONGING_TO_ADMID, 'Search.Callback', [
'callback' => function ($query, $args, $filter) {
// ... complex filtering logic based on user rights ...
}
]);
// ... other search filters ...
}
3. src/Controller/AppController.php
(the parsing helpers)
// Note: These functions are simplified examples of the current logic
protected function getOrderFromTabulator(array $options): array
{
$result = [];
if (empty($options['sorters'])) {
return $result;
}
foreach ($options['sorters'] as $sort) {
// The helper ensures it returns a correct format like ['Users.name' => 'asc']
$result[$sort['field']] = $sort['dir'];
}
return $result;
}
protected function getFilterFromTabulator(array $options): array
{
$result = [];
if (empty($options['filters'])) {
return $result;
}
foreach ($options['filters'] as $filter) {
// The helper ensures it returns a correct format like ['Users.name LIKE' => '%john%']
$result[$filter['field'] . ' LIKE'] = '%' . $filter['value'] . '%';
}
return $result;
}
Thank you in advance for your help and insights. I’m convinced there’s a subtle interaction I’m just not seeing.