シンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle のご紹介 2023
メリークリスマスイブ!🎅🎁
Symfony Advent Calendar 2023 の24日目の記事です!🎄✨
Twitter (X) でもちょいちょいSymfonyネタを呟いてます。よろしければ フォロー お願いします🤲
ttskch/paginator-bundle
ダイマです。拙作のシンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle
を紹介させてください。
こんな感じの美しいページネーションが簡単に実装できます。
僕自身が実務でページネーションを実装するにあたって、既存のどのバンドルにも満足できなかったので自作しました。
最初のバージョンを公開してからもう3年半ぐらい経っていて未だに実務でバリバリ使ってるんですが、自分でもとても満足しているので、ぜひみなさんにも使ってみてほしい所存です!
デモ
こちらで実際に動いているサンプルを触れます。
特徴
- 超軽量
- 型安全(PHPStan level max)
- SymfonyとTwig以外には一切依存なし
- にもかかわらず、ほとんどのSymfonyプロジェクトで採用されているDoctrine ORMとの連携がめっちゃ簡単
- 他にも任意のデータ構造を(自分でコールバックを書くことで)ページネート可能
- ページャー部分のHTMLはTwigで簡単にカスタマイズ可能
- 見出し部分を簡単にソートのためのリンクにできる(ここのHTMLもTwigでカスタマイズ可能)
- 検索フォームとの連動も簡単
- Bootstrap 4/5ベースの美しいテンプレートをプリセット
という感じのナイスなバンドルです。
- バンドル固有のコードが少なく済んで、いざとなったらいつでも捨てて別の実装に移行できる
- だけどやりたいことは全部できる
というのを意識して作っています。
いつでも捨てられるというのが特に大事だと思っていて、実際に「バンドルを使わずに自分で実装したとしてもどうせ書かないといけないようなもの」だけしか提供していません。
もし普段からページネーションを自力で書いているという方がいたら、試しに使ってみていただけると嬉しいです!
動作要件
最新バージョンである v6 系は
- PHP: ^8.0
- Symfony: ^5.0|^6.0|^7.0
です。
インストール
普通にComposerでインストールして、bundles.php
に追加してください。
$ composer require ttskch/paginator-bundle
// config/bundles.php
return [
// ...
Ttskch\PaginatorBundle\TtskchPaginatorBundle::class => ['all' => true],
];
Doctrine ORMとあわせて使う
ttskch/paginator-bundle
自体はDoctrine ORMにロックインはしていませんが、Doctrine ORMとあわせて使うためのユーティリティをプリセットとして提供しているので、それを使えばとても少ない変更で簡単にページネーションを導入できます。
例えば、以下のようなよくある一覧画面の場合なら、ttskch/paginator-bundle
導入前後のコードはこんな感じになります。
Before
// FooController.php
use Symfony\Component\HttpFoundation\Response;
public function index(FooRepository $fooRepository): Response
{
return $this->render('index.html.twig', [
'foos' => $fooRepository->findAll(),
]);
}
{# index.html.twig #}
<table>
<thead>
<tr>
<th>Id</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
{% for foo in foos %}
<tr>
<td>{{ foo.id }}</td>
<td>{{ foo.name }}</td>
<td>{{ foo.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
After
// FooController.php
use Symfony\Component\HttpFoundation\Response;
use Ttskch\PaginatorBundle\Counter\Doctrine\ORM\QueryBuilderCounter;
use Ttskch\PaginatorBundle\Criteria\Criteria;
use Ttskch\PaginatorBundle\Paginator;
use Ttskch\PaginatorBundle\Slicer\Doctrine\ORM\QueryBuilderSlicer;
/**
* @param Paginator<\Traversable<array-key, Foo>, Criteria> $paginator
*/
public function index(FooRepository $fooRepository, Paginator $paginator): Response
{
$qb = $fooRepository->createQueryBuilder('f');
$paginator->initialize(new QueryBuilderSlicer($qb), new QueryBuilderCounter($qb), new Criteria('id'));
return $this->render('index.html.twig', [
'foos' => $paginator->getSlice(),
]);
}
{# index.html.twig #}
<table>
<thead>
<tr>
<th>{{ ttskch_paginator_sortable('id', 'Id') }}</th>
<th>{{ ttskch_paginator_sortable('name', 'Name') }}</th>
<th>{{ ttskch_paginator_sortable('email', 'Email') }}</th>
</tr>
</thead>
<tbody>
{% for foo in foos %}
<tr>
<td>{{ foo.id }}</td>
<td>{{ foo.name }}</td>
<td>{{ foo.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ ttskch_paginator_pager() }}
以下の部分
$paginator->initialize(new QueryBuilderSlicer($qb), new QueryBuilderCounter($qb), new Criteria('id'));
で、$paginator
が内部的に handleRequest()
を実行して、URLクエリパラメータをもとにページネーションを実行してくれます。
QueryBuilderSlicer
と QueryBuilderCounter
はそれぞれ、
- 現在のページの
Foo
エンティティのコレクションを作成するための処理 -
Foo
エンティティの総数を算出するための処理
を担っています。
また、Criteria
は、URLクエリパラメータとして送信されたページネーションの情報(ページ番号や1ページあたりの表示件数など)をオブジェクトの形で保持してくれるやつです。コンストラクタ引数に渡している 'id'
は、デフォルトのソート項目です。
これらが ttskch/paginator-bundle
が用意してくれているDoctrine ORM用のユーティリティです。
ビュー側では、
-
ttskch_paginator_sortable()
関数でソートのためのリンクを出力 -
ttskch_paginator_pager()
関数でページャーを出力
しています。
ビューのテンプレートを変更する
ビューのテンプレートは設定で自由に変更できます。
# config/packages/ttskch_paginator.yaml
ttskch_paginator:
template:
pager: 'your/own/pager.html.twig'
sortable: 'your/own/sortable.html.twig'
プリセットされているBootstrap 5スタイルのページャーを使いたいだけなら、以下のようにすればOKです。
# config/packages/ttskch_paginator.yaml
ttskch_paginator:
template:
pager: '@TtskchPaginator/pager/bootstrap5.html.twig'
検索フォームとあわせて使う
検索フォームとあわせて使う場合は、リポジトリクラスなどに QueryBuilder
の組み立てを書いて、それをコールバックとして渡します。
このとき、URLクエリパラメータとして送信された検索条件やページネーション情報をいちいちリクエストオブジェクトから取り出したりする必要はありません。先ほど登場した Criteria
というオブジェクトがそれらを保持する役割を担っていて、コントローラで $paginator->initialize()
したときにURLクエリパラメータから値が適切にセットされます。
デフォルトの Criteria
はページネーション情報だけしか持たないため、これを継承して検索条件の情報を持つようにした独自の FooCriteria
を作るところから始めます。
今回は例として、文字列で検索するための query
というプロパティを追加してみます。
// FooCriteria.php
use Ttskch\PaginatorBundle\Criteria\AbstractCriteria;
class FooCriteria extends AbstractCriteria
{
public ?string $query = null;
public function __construct(string $sort)
{
parent::__construct($sort);
}
public function getFormTypeClass(): string
{
return FooSearchType::class;
}
}
続いて、この FooCriteria
に対応する FooFormType
を作ります。ttskch/paginator-bundle
が提供しているデフォルトの CriteriaType
を継承することで、ページネーション情報に関する処理を気にせず、自分が追加した検索項目のことだけを考えればいいようになっています。
// FooSearchType.php
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SearchType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Ttskch\PaginatorBundle\Form\CriteriaType;
class FooSearchType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('query', SearchType::class)
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => FooCriteria::class,
// symfony/security-csrf がインストールされている環境では以下が必要
// 'csrf_protection' => false,
]);
}
public function getParent(): string
{
return CriteriaType::class;
}
}
次に、リポジトリクラスなどに FooCriteria
をもとに QueryBuilder
を組み立てるメソッドを作ります。
// FooRepository.php
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
/**
* @extends ServiceEntityRepository<Foo>
*/
class FooRepository extends ServiceEntityRepository
{
// ...
private function createQueryBuilderFromCriteria(FooCriteria $criteria): QueryBuilder
{
return $this->createQueryBuilder('f')
->orWhere('f.name like :query')
->orWhere('f.email like :query')
->setParameter('query', '%'.str_replace('%', '\%', $criteria->query).'%')
;
}
}
$criteria->query
をLIKE句に食わせて検索しているだけですね。
この QueryBuilder
を組み立てる処理だけはアプリ固有なので自分で書く必要がどうしてもありますが、これをもとに Slicer
および Counter
を作る処理には ttskch/paginator-bundle
が提供するユーティリティを活用できます。
// FooRepository.php
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\ORM\QueryBuilder;
use Ttskch\PaginatorBundle\Counter\Doctrine\ORM\QueryBuilderCounter;
use Ttskch\PaginatorBundle\Slicer\Doctrine\ORM\QueryBuilderSlicer;
/**
* @extends ServiceEntityRepository<Foo>
*/
class FooRepository extends ServiceEntityRepository
{
// ...
/**
* @return \Traversable<array-key, Foo>
*/
public function sliceByCriteria(FooCriteria $criteria): \Traversable
{
$qb = $this->createQueryBuilderFromCriteria($criteria);
$slicer = new QueryBuilderSlicer($qb);
return $slicer->slice($criteria);
}
public function countByCriteria(FooCriteria $criteria): int
{
$qb = $this->createQueryBuilderFromCriteria($criteria);
$counter = new QueryBuilderCounter($qb);
return $counter->count($criteria);
}
private function createQueryBuilderFromCriteria(FooCriteria $criteria): QueryBuilder
{
return $this->createQueryBuilder('f')
->orWhere('f.name like :query')
->orWhere('f.email like :query')
->setParameter('query', '%'.str_replace('%', '\%', $criteria->query).'%')
;
}
}
こんな感じで、検索条件適用済みの QueryBuilderを
渡して Slicer
および Counter
を作り、それらの処理を FooCriteria
を渡して実行するようにすればOKです。
最後にコントローラの実装です。
$paginator->initialize()
に先ほど作ったリポジトリクラスのメソッドを渡します。
Criteria
が持つ getFormTypeClass()
メソッドによって対応する FormType
クラスが自動で判別される(ここでは FooFormType
)ので、$paginator->getForm()
によって handleRequest()
済みの FooFormType
の Form
オブジェクトが取得できます。
// FooController.php
use Symfony\Component\HttpFoundation\Response;
use Ttskch\PaginatorBundle\Paginator;
/**
* @param Paginator<\Traversable<array-key, Foo>, FooCriteria> $paginator
*/
public function index(FooRepository $fooRepository, Paginator $paginator): Response
{
$paginator->initialize(
$fooRepository->sliceByCriteria(...),
$fooRepository->countByCriteria(...),
new FooCriteria('id'),
);
return $this->render('index.html.twig', [
'foos' => $paginator->getSlice(),
'form' => $paginator->getForm()->createView(),
]);
}
あとはビューにフォームを設置すれば完了です。
{# index.html.twig #}
+ {{ form(form, {action: path('foo_index'), method: 'get'}) }}
+
<table>
<thead>
<tr>
<th>{{ ttskch_paginator_sortable('id', 'Id') }}</th>
<th>{{ ttskch_paginator_sortable('name', 'Name') }}</th>
<th>{{ ttskch_paginator_sortable('email', 'Email') }}</th>
</tr>
</thead>
<tbody>
{% for foo in foos %}
<tr>
<td>{{ foo.id }}</td>
<td>{{ foo.name }}</td>
<td>{{ foo.email }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{{ ttskch_paginator_pager() }}
おわりに
というわけで、拙作のシンプルでカスタマイズしやすい最強のページネーションバンドル ttskch/paginator-bundle
を紹介させていただきました。
繰り返しになりますが、もし普段からページネーションを自力で書いているという方がいたら、試しに使ってみていただけると嬉しいです!
感想やIssue/PRなどもお待ちしてます!
Symfony Advent Calendar 2023、明日は @kuni__94 さんです!お楽しみに!
Discussion