フレームワークや外部ライブラリ禁止のプロジェクトで、Laravelのページネーション paginate(5) のような機能を作ってみるという案件です。
どこまでやるのか
1.PDOStatementをラッピングし、paginate()メソッド追加。
2.paginate() でLengthAwarePaginatorを返す。
3.LengthAwarePaginator::links() でページネーション出力。
前提条件
- PHP8.2以降(筆者の環境はPHP8.4.8)
- PDOが使える状態(デフォルトで有効)
- PDOでDB接続可能な状態(筆者の環境はMySQL9.3.0)
- WEBサーバーでPHPが利用可能な状態(筆者の環境はnginx1.26.x)
※次のリポジトリをクローンしてdockerコンテナを構築すると上記条件をクリアできます。
今回作成するファイルセットは少し量が多いので、ファイルセットをGithubリポジトリにアップしておきました。
リポジトリをクローンして、色々といじってみてください。
PDOStatementラッパー作成
PDOStatementクラスを継承したラッパークラスを作成ます。
この中に「paginate()」メソッドを追加します。
LengthAwarePaginatorを生成して返す際に、検索結果のレコードを表示件数分に絞って渡します。
▼「libs/DBStatement.php」
<?php
namespace Libs;
use Libs\LengthAwarePaginator;
use PDOStatement;
/**
* PDOStatement のラッパー
*
* pagination対応が目的
*/
class DBStatement extends PDOStatement
{
public function paginate(int $perPage = 10, int $currentPage = 0): LengthAwarePaginator|null
{
$page = (int) ($_GET["page"] ?? null);
$lastPage = (int) ceil($this->rowCount() / $perPage);
$page = $page > $lastPage ? $lastPage : $page;
if ($currentPage === 0) {
$currentPage = $page > 0 ? $page : 1;
}
$records = [];
$offset = $perPage * ($currentPage - 1);
$total = $this->rowCount();
foreach (array_slice($this->fetchAll(), $offset, $perPage) as $row) {
$records[] = $row;
}
return new LengthAwarePaginator($records, $total, $perPage, $currentPage);
}
}
PDOStatementラッパーの適用設定
上記で作成したクラスを有効にするためには、「PDO::setAttribute()」メソッドを使って指定します。
▼「libs/DB.php」
<?php
namespace Libs;
use Libs\DBStatement;
use Libs\Log;
use PDO;
class DB {
public bool $status = false;
public PDO|null $dbh = null;
public function __construct() {
$this->connectToDatabase();
}
public function __destruct() {
$this->dbh = null;
$this->status = false;
}
public function connectToDatabase(): void {
try {
$dsn = sprintf(
"%s:host=%s;dbname=%s;port=%d",
"mysql", // PDO driver: https://www.php.net/manual/ja/pdo.drivers.php
"mysql", // host
"laravel", // database
3306 // port
);
$this->dbh = new PDO(
$dsn,
"root", // db user
"pass" // db password
);
$this->dbh->exec("SET NAMES utf8");
$this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->dbh->setAttribute(PDO::ATTR_STATEMENT_CLASS, [DBStatement::class]); // ←ここで設定
$this->status = true;
} catch (\Throwable $e) {
Log::exception($e);
throw $e;
}
}
/**
* @param string $sql
* @param array<string, int|float|string> $params = []
*/
public function raw(string $sql, array $params = []): DBStatement {
try {
$statement = $this->dbh->prepare($sql);
$statement->execute($params);
return $statement;
} catch (\Throwable $e) {
Log::exception($e);
throw $e;
}
}
}
LengthAwarePaginatorクラス
DBStatementクラスから返されるPaginator本体です。
ArrayObjectのラッパーなので、foreach等のループでそのまま使えます。
▼「libs/LengthAwarePaginator.php」
<?php
namespace Libs;
use Libs\HttpQueryString;
use ArrayObject;
class LengthAwarePaginator extends ArrayObject
{
public int $onEachSide = 3;
public int $lastPage = 1;
public string $queryString = "";
public string|null $fragment = null;
public function __construct(
public array $records,
public int $total,
public int $perPage = 10,
public int $currentPage = 1,
) {
$this->setData();
}
protected function setData(): void {
foreach($this->records as $record) {
$this->append($record);
}
unset($this->records);
$this->lastPage = (int) ceil($this->total / $this->perPage);
if ($this->currentPage > $this->lastPage) {
$this->currentPage = $this->lastPage > 0 ? $this->lastPage : 1;
}
$this->setQueryString();
}
protected function setQueryString(): void
{
$queries = (new HttpQueryString)->get() ?? [];
unset($queries["page"]);
$queryString = http_build_query($queries);
$queryString = strlen($queryString) > 0 ? $queryString . "&" : "";
$this->queryString = $queryString;
}
public function onEachSide(int $onEachSide): self
{
$this->onEachSide = $onEachSide;
return $this;
}
public function fragment(string|null $fragment = null): self
{
$this->fragment = $fragment;
return $this;
}
public function links(): void
{
view("pagination", ["total" => $this->total, "linkItems" => $this->linkItems()])->render();
}
public function getPageUrl(int $page): string
{
return "?" . $this->queryString . "page=" . $page . (is_null($this->fragment) ? "" : "#" . $this->fragment);
}
public function linkItems(): array
{
// Previous
$linkItems = [new LinkItem([
"url" => $this->currentPage === 1 ? null : $this->getPageUrl($this->currentPage - 1),
"label" => "« Previous",
"active" => false,
])];
// Each pages
for ($i = 1; $i <= $this->lastPage; $i++) {
if ($i < $this->currentPage - $this->onEachSide - 1 || $i > $this->currentPage + $this->onEachSide + 1) {
continue;
}
if ($i === $this->currentPage - $this->onEachSide - 1 || $i === $this->currentPage + $this->onEachSide + 1) {
$linkItems[] = new LinkItem(["url" => null, "label" => "...", "active" => false]);
continue;
}
$linkItems[] = new LinkItem([
"url" => $this->getPageUrl($i), "label" => (string) $i, "active" => $i === $this->currentPage]);
}
// Next
$linkItems[] = new LinkItem([
"url" => $this->currentPage >= $this->lastPage ? null : $this->getPageUrl($this->currentPage + 1),
"label" => "Next »",
"active" => false,
]);
return $linkItems;
}
}
LinkItemクラス
Paginationビューに渡すリンクアイテムのクラスです。
保持するプロパティは
- url : リンク先
- label : リンクテキスト
- active : 現在のページかどうか
▼「Libs/LinkItem.php」
<?php
namespace Libs;
use Libs\DynamicProperty;
class LinkItem extends DynamicProperty {
}
「Libs\DynamicProperty」クラスは以前の記事で作成したものです。
テンプレートエンジン
指定されたパスのビューファイルを「require」するだけです。
テンプレート変数は、「render()」メソッド内の変数としてセットしておけば、「require」したPHPファイル側でそのまま利用できます。
▼「Libs\View.php」
<?php
namespace Libs;
class View
{
public static string $templateRoot;
public function __construct(
public string $templatePath,
public array $params = [],
) {
}
/**
* @param string $_KEY_
* @param array<string, mixed> $_PARAMS_
*/
public static function make(string $key, array $params): static
{
$path = static::getTemplatePath($key);
if (! is_readable($path)) {
throw new \Exception("Template [{$path}] is not readable.");
}
return new static($path, $params);
}
protected static function setTemplateRoot(): void
{
static::$templateRoot = realpath(__DIR__ . "/../app/Views");
}
protected static function getTemplatePath(string $key): string
{
if (! isset(static::$templateRoot)) {
static::setTemplateRoot();
}
$keys = explode('.', $key);
$file = array_pop($keys);
return sprintf(
"%s/%s/%s.view.php",
static::$templateRoot,
implode('/', $keys),
$file
);
}
public function render(): void
{
if (! is_readable($this->templatePath)) {
throw new \Exception("Template [{$this->templatePath}] is not readable.");
}
// テンプレート変数との衝突を極力避ける為の変数名: $_K_ / $_V_
foreach ($this->params as $_K_ => $_V_) {
${$_K_} = $_V_;
}
require $this->templatePath;
}
}
Paginationビュー
Paginationビューファイルです。
▼「app/Views/pagination.view.php」
<p><?php echo $total ?> found</p>
<nav>
<ul class="pagination">
<?php
foreach($linkItems as $item) {
if(is_null($item->url)) {
?>
<li class="page-item disabled">
<span class="page-link"><?php echo $item->label ?></span>
</li>
<?php
} elseif($item->active) {
?>
<li class="page-item active">
<a class="page-link" href="<?php echo $item->url ?>">
<?php echo $item->label ?> <span class="sr-only">(current)</span>
</a>
</li>
<?php
} else {
?>
<li class="page-item">
<a class="page-link" href="<?php echo $item->url ?? "" ?>">
<?php echo $item->label ?>
</a>
</li>
<?php
}
}
?>
</ul>
</nav>
使用例
他にもいくつかファイルがありますが、あまり重要ではないので説明は端折ります。
Githubリポジトリのファイルを見てみてください。
使用例は次のような感じです。
<?php
require __DIR__ . "/../libs/loader.php";
use Libs\DB;
// 1ページ5件で取得
$users = (new DB)->raw("SELECT * FROM users")->paginate(5);
// ループ処理
foreach ($users as $user) {
echo $user["name"];
}
// Paginationリンク出力
$user->links();
DBレコードの準備として、Laravelでダミーデータを作成してみます。
データベース「laravel」の「users」テーブルに100件のレコードを作成します。
php artisan tinker
\App\Models\User::factory()->count(100)->create();

上記リポジトリ内の使用例を実行してみます。
▼「public/index.php」
<!doctype html>
<html>
<head>
<title>Pure PHP Paginator with PDOStatement Wrapper</title>
<meta charset="utf-8">
</head>
<body>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<div class="card">
<div class="card-body">
<?php
require __DIR__ . "/../libs/loader.php";
use Libs\DB;
$sql = "SELECT * FROM users";
$perPage = 3;
$onEachSide = 1;
$currentPage = $_GET["page"] ?? 1;
$users = (new DB)->raw($sql)->paginate($perPage)->onEachSide($onEachSide);
$lastPage = $users->lastPage;
?>
<p>
<?php
echo "Page {$currentPage}/{$lastPage}:" . PHP_EOL;
?>
</p>
<ul class="list-group">
<?php
foreach ($users as $user) {
?>
<li class="list-group-item">
<?php echo sprintf("%3d: %s <%s>", $user["id"], $user["name"], $user["email"]) ?>
</li>
<?php
}
?>
</ul>
<?php
$users->links();
?>
</div>
</div>
</body>
</html>
CLI上で実行してみます。
php public/index.php
▼実行結果:
<!doctype html>
<html>
<head>
<title>Pure PHP Paginator with PDOStatement Wrapper</title>
<meta charset="utf-8">
</head>
<body>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<div class="card">
<div class="card-body">
<p>
Page 1/34:
</p>
<ul class="list-group">
<li class="list-group-item">
1: Mae Funk <jaqueline72@example.com> </li>
<li class="list-group-item">
2: Miss Kathlyn Robel <roel.carroll@example.com> </li>
<li class="list-group-item">
3: Prof. Orland Little <sallie00@example.com> </li>
</ul>
<p>100 found</p>
<nav>
<ul class="pagination">
<li class="page-item disabled">
<span class="page-link">« Previous</span>
</li>
<li class="page-item active">
<a class="page-link" href="?page=1">
1 </a>
</li>
<li class="page-item">
<a class="page-link" href="?page=2">
2 </a>
</li>
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
<li class="page-item">
<a class="page-link" href="?page=2">
Next » </a>
</li>
</ul>
</nav>
</div>
</div>
</body>
</html>
WEBブラウザで表示すると次のようになります。

ここで用意したクラスはこの記事用に簡素化しているので、
自分用にカスタマイズしてみてください。
コメント