空ディレクトリから Laravel 13 の Book 一覧を作り、title 検索、sort 切り替え、ページネーションを Blade で確認する手順です。既存の CRUD 記事や認証記事には依存しません。
今回のゴールは 3 つです。
titleを部分一致で検索できるsortを切り替えるquery stringを保ったままページ移動できる
本の追加 / 編集 / 削除、認証、Livewire、API 化、テスト、CI は扱いません。今回は「一覧画面の最小改善」だけを切り出します。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop(WSL 連携有効)
composer:2コンテナ- Laravel 13
- SQLite
以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。
1. ゴールと非対象
この記事で到達する状態は次のとおりです。
/booksにBook一覧を表示できるtitle検索とsort切り替えを URL とフォームの両方から試せるpaginate(5)で 2 ページ目が出る- 検索条件と
sort条件を保ったままページ移動できる
今回は一覧画面に集中します。次の内容は扱いません。
- 本の追加 / 編集 / 削除
- 認証、認可
- relation 条件や複数列検索
- Livewire、API、SPA
- テストや CI
2. デモを作成して Laravel を起動する
開始位置は次のディレクトリです。
mkdir -p ~/projects/laravel-search-sort-pagination-demo
cd ~/projects/laravel-search-sort-pagination-demo
code .
プロジェクト作成は composer:2 コンテナから実行します。ホスト側へ PHP や Composer を直接入れずに進めるためです。
docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .
次に compose.yml を作成します。
services:
app:
image: composer:2
user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
working_dir: /app
environment:
HOME: /tmp
XDG_CONFIG_HOME: /tmp/.config
volumes:
- ./:/app
ports:
- "8000:8000"
command: php artisan serve --host=0.0.0.0 --port=8000
HOME と XDG_CONFIG_HOME を入れているのは、あとで使う php artisan tinker 内の PsySH が /.config/psysh へ書こうとして notice を出しにくくするためです。
.env の該当部分は次のとおりです。
APP_URL=http://localhost:8000
DB_CONNECTION=sqlite
DB_DATABASE=/app/database/database.sqlite
続いてコンテナを起動します。
export LOCAL_UID="$(id -u)"
export LOCAL_GID="$(id -g)"
docker compose up -d
ブラウザで http://localhost:8000 を開き、Laravel の初期 welcome 画面が見えれば準備完了です。
手元で 8000 番ポートが埋まっていると、Bind for 0.0.0.0:8000 failed: port is already allocated で起動に失敗します。その場合は compose.yml の左側だけ 8001:8000 に変え、APP_URL も http://localhost:8001 にそろえてから起動し直してください。
3. Book model と migration を作る
まずは一覧に表示する books テーブルを作ります。
docker compose exec app php artisan make:model Book -m
生成された migration の timestamp 部分は各自で異なります。database/migrations/*_create_books_table.php を次の内容に置き換えてください。
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->string('author');
$table->unsignedInteger('price');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('books');
}
};
app/Models/Book.php は次の内容に更新してください。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Book extends Model
{
protected $fillable = [
'title',
'author',
'price',
];
}
ここまでできたら migration を流します。
docker compose exec app php artisan migrate
今回は価格計算の話に広げないため、price は unsignedInteger にしています。通貨計算や decimal の設計は別の記事に回します。
4. books.index route と BookController@index を作る
次は一覧画面の route と controller を作ります。まずコントローラーを生成します。
docker compose exec app php artisan make:controller BookController
app/Http/Controllers/BookController.php を次の内容に更新してください。
<?php
namespace App\Http\Controllers;
use App\Models\Book;
use Illuminate\Http\Request;
use Illuminate\View\View;
class BookController extends Controller
{
public function index(Request $request): View
{
$search = trim((string) $request->query('search', ''));
$sort = (string) $request->query('sort', 'latest');
$sortOptions = [
'latest' => ['id', 'desc'],
'oldest' => ['id', 'asc'],
'title_asc' => ['title', 'asc'],
'title_desc' => ['title', 'desc'],
'price_asc' => ['price', 'asc'],
'price_desc' => ['price', 'desc'],
];
if (! array_key_exists($sort, $sortOptions)) {
$sort = 'latest';
}
[$sortColumn, $sortDirection] = $sortOptions[$sort];
$books = Book::query()
->when($search !== '', function ($query) use ($search) {
$query->where('title', 'like', "%{$search}%");
})
->orderBy($sortColumn, $sortDirection)
->paginate(5)
->withQueryString();
return view('books.index', [
'books' => $books,
'search' => $search,
'sort' => $sort,
]);
}
}
routes/web.php は次の内容に更新してください。
<?php
use App\Http\Controllers\BookController;
use Illuminate\Support\Facades\Route;
Route::redirect('/', '/books');
Route::get('/books', [BookController::class, 'index'])->name('books.index');
route を確認します。
docker compose exec app php artisan route:list --name=books
books.index が見えていれば、一覧画面の入口はできています。
実行結果は次のとおりです。
ここでは次の 4 点だけ押さえます。
searchは空文字なら絞り込まないsortは固定の候補だけを受けるpaginate(5)で 2 ページ目を作るwithQueryString()で現在の GET 条件をページャへ引き継ぐ
orderBy() に request の値をそのまま渡すと、意図しない列名を受け取れる形になります。今回は配列で sort の候補を固定し、そこから [column, direction] を取り出す形にしておくと判断がぶれません。
5. 一覧 Blade を作る
コントローラーだけでは URL からしか試せません。GET フォームとページャを一覧画面へ置き、ブラウザから操作できるようにします。今回は Tailwind や npm run dev を前提にしないため、Laravel 既定の links() ではなく、Paginator から URL を取り出す素朴なページャにします。
resources/views/books/index.blade.php を次の内容で作成してください。
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Book一覧</title>
<style>
body {
font-family: sans-serif;
margin: 40px auto;
max-width: 960px;
}
table {
border-collapse: collapse;
width: 100%;
margin-top: 24px;
}
th,
td {
border: 1px solid #d0d7de;
padding: 12px;
text-align: left;
}
.toolbar {
display: flex;
gap: 12px;
align-items: end;
flex-wrap: wrap;
}
.toolbar label {
display: flex;
flex-direction: column;
gap: 4px;
}
input,
select,
button {
font: inherit;
padding: 8px 12px;
}
.result-count {
margin-top: 16px;
}
.pagination {
margin-top: 24px;
}
.pagination-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.pagination a,
.pagination span {
border: 1px solid #d0d7de;
border-radius: 6px;
color: inherit;
display: inline-block;
padding: 8px 12px;
text-decoration: none;
}
.pagination-current {
background: #eef2ff;
border-color: #c7d2fe;
color: #1e3a8a;
font-weight: 700;
}
.pagination-disabled {
color: #6b7280;
}
</style>
</head>
<body>
<h1>Book一覧</h1>
<form method="get" action="{{ route('books.index') }}" class="toolbar">
<label>
タイトル検索
<input type="text" name="search" value="{{ $search }}">
</label>
<label>
並び替え
<select name="sort">
<option value="latest" @selected($sort === 'latest')>新しい順</option>
<option value="oldest" @selected($sort === 'oldest')>古い順</option>
<option value="title_asc" @selected($sort === 'title_asc')>タイトル昇順</option>
<option value="title_desc" @selected($sort === 'title_desc')>タイトル降順</option>
<option value="price_asc" @selected($sort === 'price_asc')>価格が安い順</option>
<option value="price_desc" @selected($sort === 'price_desc')>価格が高い順</option>
</select>
</label>
<button type="submit">絞り込む</button>
</form>
<p class="result-count">{{ $books->total() }}件</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>タイトル</th>
<th>著者</th>
<th>価格</th>
</tr>
</thead>
<tbody>
@forelse ($books as $book)
<tr>
<td>{{ $book->id }}</td>
<td>{{ $book->title }}</td>
<td>{{ $book->author }}</td>
<td>{{ number_format($book->price) }}円</td>
</tr>
@empty
<tr>
<td colspan="4">該当する本はありません。</td>
</tr>
@endforelse
</tbody>
</table>
<nav class="pagination" aria-label="ページネーション">
<div class="pagination-links">
@if ($books->onFirstPage())
<span class="pagination-disabled">前へ</span>
@else
<a href="{{ $books->previousPageUrl() }}">前へ</a>
@endif
@for ($page = 1; $page <= $books->lastPage(); $page++)
@if ($page === $books->currentPage())
<span class="pagination-current">{{ $page }}</span>
@else
<a href="{{ $books->url($page) }}">{{ $page }}</a>
@endif
@endfor
@if ($books->hasMorePages())
<a href="{{ $books->nextPageUrl() }}">次へ</a>
@else
<span class="pagination-disabled">次へ</span>
@endif
</div>
</nav>
</body>
</html>
Blade では次の 3 点で query string とページャを扱います。
- フォームを
method="get"にして URL へ条件を残す value="{{ $search }}"と@selected(...)で現在値を戻す$books->previousPageUrl()、$books->url($page)、$books->nextPageUrl()で移動先を組み立てる
Laravel 既定の links() は Tailwind 前提の HTML を出すため、このような素の Blade へそのまま置くとページャだけ見た目が崩れやすくなります。今回は HTML と CSS を記事内で閉じる構成のため、Paginator から URL を取り出す素朴なページャです。
ページ数が多い画面では、表示するページ番号を絞る工夫がほしくなります。今回は paginate(5) で 2 ページ目までを確認する最小例に絞っているため、1 から lastPage() までをそのまま並べています。
query string の保持は withQueryString() が担う部分です。ページャの HTML を自前で書いても、コントローラー側で withQueryString() を付けていれば ?search=...&sort=... を保ったままページ移動できます。
6. 一覧確認用のサンプルデータを tinker で流し込む
検索とページネーションを確認しやすいよう、一覧に 7 件入れておきます。先頭の delete() を入れているため、同じコマンドを再実行しても重複しにくい形です。
docker compose exec app php artisan tinker --execute="App\Models\Book::query()->delete();
collect([
['title' => 'Laravel入門', 'author' => '山田太郎', 'price' => 2800],
['title' => 'PHP実践', 'author' => '鈴木花子', 'price' => 3200],
['title' => 'Web設計メモ', 'author' => '佐藤次郎', 'price' => 1800],
['title' => 'APIデザイン', 'author' => '高橋健', 'price' => 3600],
['title' => 'Docker現場入門', 'author' => '田中葵', 'price' => 2500],
['title' => 'Laravelテスト', 'author' => '伊藤誠', 'price' => 3000],
['title' => '検索UI設計', 'author' => '中村光', 'price' => 2100],
])->each(fn (array \$attributes) => App\Models\Book::query()->create(\$attributes));"
--execute="..." をダブルクォートで囲む都合上、$attributes は Bash に展開されないよう \$attributes と書きます。ここをエスケープしないと、unexpected T_ARRAY_CAST の parse error で止まります。
ここで Book::query()->createMany(...) は使えません。createMany() は relation メソッドのため、Book::query() に対して実行すると Call to undefined method Illuminate\Database\Eloquent\Builder::createMany() で止まります。
今回は model 自体へ複数件を流し込むだけなので、collect(...)->each(...create()) で 1 件ずつ作成します。
7. 画面で動作確認する
ブラウザで http://localhost:8000/books を開き、次の順に確認してください。8001 に変えた場合は読み替えます。
- sort を
価格が高い順にすると、高い本から並ぶ Laravelと入れて送信すると、LaravelテストとLaravel入門に絞られる- 件数が 7 件ある状態では 2 ページ目リンクが出る
- sort を付けたままページ移動しても条件が消えない
URL で直接確認するなら、次の例が使いやすい形です。
http://localhost:8000/books?search=Laravel&sort=price_desc
この URL で Laravelテスト が Laravel入門 より先に並べば、検索と並び替えの両方が効いている状態です。
続いて、検索を外して 価格が高い順 のまま 2 ページ目へ移動してみてください。ページャのリンク自体には page=2 が付き、現在の GET 条件も一緒に持ち回ります。ここで条件が消える場合は、withQueryString() が抜けています。
価格が高い順 を選ぶと、次のように高い本から並びます。
search=Laravel&sort=price_desc を付けると、Laravel を含む 2 件だけに絞られます。
2 ページ目へのリンクも表示され、条件を保ったまま移動できます。
8. よくある詰まり
8000 番ポートが使われていて起動できない
docker compose up -d で Bind for 0.0.0.0:8000 failed: port is already allocated が出たら、compose.yml の左側だけ 8001:8000 に変えます。.env の APP_URL も同じ番号にそろえてください。
tinker が unexpected T_ARRAY_CAST で止まる
--execute="..." の中で使う $attributes を \$attributes とエスケープしているか確認してください。Bash が先に展開すると、PsySH 側では壊れた PHP が渡ります。
ページャが巨大な矢印で崩れる
Laravel 既定の links() は Tailwind 用の HTML を返します。この記事のように Tailwind を読み込まない Blade へそのまま置くと、Previous / Next の SVG だけが大きく表示されやすくなります。本文どおりに previousPageUrl()、url($page)、nextPageUrl() を使う形へ戻してください。
ページ移動すると検索条件が消える
ページャの HTML が自前実装でも links() でも、コントローラー側で withQueryString() を付けないと query string は引き継がれません。BookController@index 側を確認してください。
sort に知らない値が来たとき挙動が不安定になる
$sortOptions に無い値は latest へ戻す形にしておくと、URL を直接書き換えて無効な sort 値が来ても、予期しない列名や順序で orderBy() を組み立てずに済みます。
9. まとめ
今回は、空ディレクトリから Book 一覧を作り、search、sort、paginate(5)、withQueryString() を一通り確認しました。一覧画面だけを先に独立して確認しておくと、あとで CRUD や認証を足すときにも手元の確認軸を保ちやすくなります。
次の一歩はいくつかあります。
- 本の追加 / 編集 / 削除まで広げたい場合は Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)
- ログイン必須にしたい場合は Laravelで認証を足す(Breeze 最小導入)
- relation を含む一覧へ広げたい場合は Laravelでリレーションを扱う(User / Book / Category の基本)