muxtorov / yii2-cursor-pagination
Cursor-based pagination helper for Yii2 APIs
Package info
github.com/Muxtorov98/yii2-cursor-pagination
Type:yii2-extension
pkg:composer/muxtorov/yii2-cursor-pagination
Requires
- php: >=7.4
- yiisoft/yii2: ^2.0.13
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 cursorper-page: page sizedirection:nextorprev
Recommended architecture
This package should stay generic.
Keep these inside your project:
BaseFilterQueryCollectionExtensionInterfaceDefaultFilter- 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:
BaseFilterQueryCollectionExtensionInterfaceFactoryExtensionController
then the correct approach is:
- Keep package generic.
- Inject or instantiate
CursorDataProviderinside your localBaseFilter. - 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,
idlast
Summary
If you start a new API:
- create
Resource - create
Extension - define query order
- pass same order columns to
BaseFilter - return factory result from controller
- use
nextCursorandprevCursorfrom 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.