muxtorov/yii2-cursor-pagination

Cursor-based pagination helper for Yii2 APIs

Maintainers

Package info

github.com/Muxtorov98/yii2-cursor-pagination

Type:yii2-extension

pkg:composer/muxtorov/yii2-cursor-pagination

Statistics

Installs: 1

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-04-06 05:29 UTC

This package is auto-updated.

Last update: 2026-04-06 05:57:03 UTC


README

Cursor-based pagination helper for Yii2 APIs with PHP 7.4 and 8+ support.

Install

composer require muxtorov/yii2-cursor-pagination

Config

config/params.php

return [
    'pagination' => [
        'maxLimit' => 1000,
        'defaultPerPage' => 20,
        'cursorParam' => 'cursor',
        'perPageParam' => 'per-page',
        'directionParam' => 'direction',
    ],
];

Simple usage

use Muxtorov\Yii2CursorPagination\CursorDataProvider;

$provider = new CursorDataProvider();

$data = $provider->getData(
    OrderResource::find()->where(['userId' => $userId]),
    ['createdAt', 'id']
);

Response shape:

[
    'items' => [...],
    '_links' => [
        'self' => '...',
        'next' => '...',
        'prev' => '...',
    ],
    '_meta' => [
        'perPage' => 20,
        'direction' => 'next',
        'hasNext' => true,
        'hasPrev' => false,
        'nextCursor' => '...',
        'prevCursor' => '...',
    ],
]

Query params

GET /orders?per-page=20
GET /orders?cursor=eyJpZCI6MTAwfQ&direction=next
GET /orders?cursor=eyJpZCI6MTAwfQ&direction=prev

Defaults:

  • cursor: current page cursor
  • per-page: page size
  • direction: next or prev

Recommended architecture

This package should stay generic.

Keep these inside your project:

  • BaseFilter
  • QueryCollectionExtensionInterface
  • DefaultFilter
  • factory classes like api\modules\v1\factories\*
  • project-specific query extensions

Keep this package responsible only for:

  • reading request cursor params
  • applying cursor condition
  • building _links
  • building _meta

That separation is important because BaseFilter and your extensions are application-specific orchestration, not package-level generic logic.

Full integration with BaseFilter pattern

If your project already uses this style:

  • BaseFilter
  • QueryCollectionExtensionInterface
  • Factory
  • Extension
  • Controller

then the correct approach is:

  1. Keep package generic.
  2. Inject or instantiate CursorDataProvider inside your local BaseFilter.
  3. Let factories decide which cursor columns to use.

BaseFilter integration example

Example local project class:

<?php declare(strict_types=1);

namespace app\components\base;

use app\components\base\search\interfaces\FilterInterface;
use app\components\base\search\interfaces\QueryCollectionExtensionInterface;
use app\components\base\search\interfaces\SortInterface;
use Muxtorov\Yii2CursorPagination\CursorDataProvider;
use Yii;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;

class BaseFilter
{
    private string $modelClass;
    private ActiveQuery $query;
    private ?string $filterClass;
    private ?string $sortClass;
    private array $extensionClass;
    private CursorDataProvider $dataProvider;
    private array $cursorColumns;

    public function __construct(
        string $modelClass,
        ?string $filterClass = null,
        ?string $sortClass = null,
        ?array $extensionClass = [],
        array $cursorColumns = ['id']
    ) {
        $this->modelClass = $modelClass;
        $this->query = $modelClass::find();
        $this->filterClass = $filterClass;
        $this->sortClass = $sortClass;
        $this->extensionClass = $extensionClass ?? [];
        $this->cursorColumns = $cursorColumns;
        $this->dataProvider = new CursorDataProvider();
    }

    public function getData(?array $params = []): array
    {
        foreach ($this->extensionClass as $extensionClass) {
            /** @var QueryCollectionExtensionInterface $ext */
            $ext = new $extensionClass();
            $ext->applyToCollection($this->query, $this->modelClass, $params);
        }

        if ($this->filterClass !== null) {
            /** @var FilterInterface $filter */
            $filter = new $this->filterClass();
            $filter->apply($this->query, $this->modelClass);
        }

        if ($this->sortClass !== null) {
            /** @var SortInterface $sort */
            $sort = new $this->sortClass();
            $sort->apply($this->query, Yii::$app->request->get('sort', ''));
        }

        return $this->dataProvider->getData($this->query, $this->cursorColumns);
    }
}

Why cursorColumns matter

If your extension sorts by multiple columns, cursor pagination must use the same order.

Bad:

$query->orderBy(['order' => SORT_ASC, 'id' => SORT_ASC]);
// but provider uses only 'id'

Good:

$query->orderBy(['order' => SORT_ASC, 'id' => SORT_ASC]);
// provider uses ['order', 'id']

Rule:

  • one sort column: use ['id'] or your real sort column
  • multiple sort columns: always pass all of them in the same order
  • last column should usually be unique like id

QueryCollectionExtensionInterface example

Your app-level interface can stay exactly as it is:

<?php declare(strict_types=1);

namespace app\components\base\search\interfaces;

use yii\db\ActiveQuery;

interface QueryCollectionExtensionInterface
{
    public function applyToCollection(
        ActiveQuery $query,
        string $modelClass,
        ?array $params = []
    ): void;
}

Extension example

Example for status list:

<?php declare(strict_types=1);

namespace api\modules\v1\extensions;

use app\components\base\search\interfaces\QueryCollectionExtensionInterface;
use yii\db\ActiveQuery;
use yii\db\ActiveRecord;

class StatusExtension implements QueryCollectionExtensionInterface
{
    public function applyToCollection(ActiveQuery $query, string $modelClass, ?array $params = []): void
    {
        /** @var ActiveRecord $modelClass */
        $table = $modelClass::tableName();

        $query->andWhere(["$table.is_api_visible_column" => true]);
        $query->orderBy([
            "$table.order" => SORT_ASC,
            "$table.id" => SORT_ASC,
        ]);
    }
}

For this extension your cursor columns must be:

['order', 'id']

Factory example

Example factory for status endpoint:

<?php declare(strict_types=1);

namespace api\modules\v1\factories;

use api\modules\v1\extensions\StatusExtension;
use api\modules\v1\resources\StatusResource;
use app\components\base\BaseFilter;

class StatusFilterFactory
{
    public function create(): BaseFilter
    {
        return new BaseFilter(
            StatusResource::class,
            null,
            null,
            [StatusExtension::class],
            ['order', 'id']
        );
    }

    public function getStatus(): array
    {
        return $this->create()->getData();
    }
}

Example factory for package endpoint:

<?php declare(strict_types=1);

namespace api\modules\v1\factories;

use api\modules\v1\extensions\PackageExtension;
use api\modules\v1\resources\PackageResource;
use app\components\base\BaseFilter;

class PackageFilterFactory
{
    public function create(): BaseFilter
    {
        return new BaseFilter(
            PackageResource::class,
            null,
            null,
            [PackageExtension::class],
            ['id']
        );
    }

    public function getPackages(): array
    {
        return $this->create()->getData();
    }
}

Controller example

<?php declare(strict_types=1);

namespace api\modules\v1\controllers;

use api\controllers\AbstractController;
use api\constants\ApiMessages;
use api\modules\v1\factories\StatusFilterFactory;
use api\components\Yii;

class StatusController extends AbstractController
{
    private StatusFilterFactory $filterFactory;

    public function __construct($id, $module, StatusFilterFactory $filterFactory, $config = [])
    {
        $this->filterFactory = $filterFactory;
        parent::__construct($id, $module, $config);
    }

    public function actionIndex(): array
    {
        return $this->success(
            Yii::t('api', ApiMessages::SUCCESS_RETRIEVED),
            $this->filterFactory->getStatus()
        );
    }
}

Starting a new API endpoint from zero

When you create a new list API, use this checklist.

1. Create resource

class ProductResource extends \app\models\Product
{
}

2. Create extension

class ProductExtension implements QueryCollectionExtensionInterface
{
    public function applyToCollection(ActiveQuery $query, string $modelClass, ?array $params = []): void
    {
        $table = $modelClass::tableName();

        $query->andWhere(["$table.is_active" => 1]);
        $query->orderBy([
            "$table.created_at" => SORT_DESC,
            "$table.id" => SORT_DESC,
        ]);
    }
}

3. Create factory

class ProductFilterFactory
{
    public function create(): BaseFilter
    {
        return new BaseFilter(
            ProductResource::class,
            \app\components\base\search\DefaultFilter::class,
            null,
            [ProductExtension::class],
            ['created_at', 'id']
        );
    }

    public function getProducts(?array $params = []): array
    {
        return $this->create()->getData($params);
    }
}

4. Create controller action

class ProductController extends AbstractController
{
    private ProductFilterFactory $filterFactory;

    public function __construct($id, $module, ProductFilterFactory $filterFactory, $config = [])
    {
        $this->filterFactory = $filterFactory;
        parent::__construct($id, $module, $config);
    }

    public function actionIndex(): array
    {
        return $this->success(
            'OK',
            $this->filterFactory->getProducts()
        );
    }
}

5. Call the API

First request:

GET /v1/products?per-page=20

Next page:

GET /v1/products?per-page=20&cursor=PASTE_NEXT_CURSOR&direction=next

Previous page:

GET /v1/products?per-page=20&cursor=PASTE_PREV_CURSOR&direction=prev

Real response example

{
  "status": true,
  "message": "OK",
  "status_code": 200,
  "data": {
    "items": [
      {
        "id": 120,
        "name": "Example"
      }
    ],
    "_links": {
      "self": "http://api.test/v1/products?per-page=1",
      "next": "http://api.test/v1/products?per-page=1&cursor=WzEyMF0&direction=next",
      "prev": "http://api.test/v1/products?per-page=1&cursor=WzEyMF0&direction=prev"
    },
    "_meta": {
      "perPage": 1,
      "direction": "next",
      "hasNext": true,
      "hasPrev": false,
      "nextCursor": "WzEyMF0",
      "prevCursor": "WzEyMF0"
    }
  }
}

Common mistakes

1. Provider class name and file name mismatch

Wrong:

CustomerDataProvider.php
class CursorDataProvider

Use matching names:

CursorDataProvider.php
class CursorDataProvider

2. Query order does not match cursor columns

Wrong:

$query->orderBy(['created_at' => SORT_DESC, 'id' => SORT_DESC]);
$provider->getData($query, ['id']);

Correct:

$query->orderBy(['created_at' => SORT_DESC, 'id' => SORT_DESC]);
$provider->getData($query, ['created_at', 'id']);

3. Returning 404 for empty paginated list

For collection endpoints prefer:

  • 200 OK
  • empty items
  • valid _meta

Avoid 404 for an empty page.

4. Non-unique sort column

Wrong:

['created_at']

Better:

['created_at', 'id']

api recommendation

For api, the best structure is:

  • package: only CursorDataProvider
  • project: BaseFilter
  • project: QueryCollectionExtensionInterface
  • project: all factories
  • project: all extensions

That gives you:

  • reusable package
  • project-specific flexibility
  • clean separation
  • easy copy-paste onboarding for new APIs

Practical mapping for current api

  • StatusFilterFactory: use ['order', 'id']
  • PackageFilterFactory: use ['id']
  • OrderFilterFactory: use real query sort columns, usually ['id'] or ['created_at', 'id']
  • any list sorted by custom priority: include priority column first, id last

Summary

If you start a new API:

  1. create Resource
  2. create Extension
  3. define query order
  4. pass same order columns to BaseFilter
  5. return factory result from controller
  6. use nextCursor and prevCursor from response

This package is intentionally small so it can plug into your existing Yii2 architecture without forcing you to move project-specific abstractions into the package.