CakePHP 5: Paginator fails silently with a complex query (Search Plugin + custom helpers)

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 plugin
  • FriendsOfCake/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 the paging 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.

I worry that this won’t be helpful… but once you have done all the searchManager and Trash massaging does the query look OK and can be succesfully executed in the DB before being paginated?

// before the call to paginate
debug($query);

Hello,

First, thank you again for taking the time to look into my issue.

I’ve done some more testing and I can now describe the problem more accurately. The issue seems to stem from how Paginator interacts with the SearchManager plugin when I use dynamic query conditions.

To be specific:

  • My query works perfectly with dynamic order and where clauses if I do not use SearchManager.
  • Conversely, the query also works fine with SearchManager if I do not add any dynamic order or where clauses.

The problem occurs only when I try to use all three together. I therefore suspect that I am not correctly integrating the SearchManager query object with the Paginator when these dynamic conditions are present.

As I mentioned, I have studied the CookBook docs, but as English is not my first language, it’s very possible I have missed a key detail on how to make them all work together correctly.

For now, to keep the project moving forward, I will stick to my plan and handle the pagination manually without the Paginator component.

I will definitely circle back to this. When I do find the correct way to implement this, I will post the solution here.

Thanks again for your support.