公開日 2026-04-02

Laravelで検索・並び替え・ページネーション付き一覧を作る

Laravel 13 の fresh app から Book 一覧を作り、title 検索、sort 切り替え、ページネーションを Blade と withQueryString() で確認する。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. デモを作成して Laravel を起動する
  4. 3. Book model と migration を作る
  5. 4. books.index route と BookController@index を作る
  6. 5. 一覧 Blade を作る
  7. 6. 一覧確認用のサンプルデータを tinker で流し込む
  8. 7. 画面で動作確認する
  9. 8. よくある詰まり
  10. 8000 番ポートが使われていて起動できない
  11. tinker が unexpected T_ARRAY_CAST で止まる
  12. ページャが巨大な矢印で崩れる
  13. ページ移動すると検索条件が消える
  14. sort に知らない値が来たとき挙動が不安定になる
  15. 9. まとめ

空ディレクトリから 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. ゴールと非対象

この記事で到達する状態は次のとおりです。

  • /booksBook 一覧を表示できる
  • 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

HOMEXDG_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_URLhttp://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

今回は価格計算の話に広げないため、priceunsignedInteger にしています。通貨計算や 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 が見えていれば、一覧画面の入口はできています。

実行結果は次のとおりです。

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 に変えた場合は読み替えます。

  1. sort を 価格が高い順 にすると、高い本から並ぶ
  2. Laravel と入れて送信すると、LaravelテストLaravel入門 に絞られる
  3. 件数が 7 件ある状態では 2 ページ目リンクが出る
  4. 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 件だけに絞られます。

search=Laravel&sort=price_desc の結果画面

2 ページ目へのリンクも表示され、条件を保ったまま移動できます。

2 ページ目リンクが出ている一覧画面

8. よくある詰まり

8000 番ポートが使われていて起動できない

docker compose up -dBind for 0.0.0.0:8000 failed: port is already allocated が出たら、compose.yml の左側だけ 8001:8000 に変えます。.envAPP_URL も同じ番号にそろえてください。

tinkerunexpected 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 一覧を作り、searchsortpaginate(5)withQueryString() を一通り確認しました。一覧画面だけを先に独立して確認しておくと、あとで CRUD や認証を足すときにも手元の確認軸を保ちやすくなります。

次の一歩はいくつかあります。

シリーズ 10/16

このシリーズ

Laravelの基本を最初から通す

  1. 1. Laravel入門(Route / Controller / View / Model 最小構成)
  2. 2. Laravelで最小CRUDを作る(一覧 / 作成 / 編集 / 削除)
  3. 3. Laravelで品質ゲートを敷く(PHPUnit / Larastan / Pint / GitHub Actions)
  4. 4. Laravelで認証を足す(Breeze 最小導入)
  5. 5. Laravelで認可を入れる(Policyで自分の本だけ編集できるようにする)
  6. 6. LaravelでQueueを始める(database queue + worker 最小構成)
  7. 7. Laravelでスケジューラを動かす(Command + Scheduler 最小構成)
  8. 8. Laravelでファイルアップロードを扱う(Storage + validation)
  9. 9. Laravelでリレーションを扱う(User / Book / Category の基本)
  10. 10. Laravelで検索・並び替え・ページネーション付き一覧を作る 現在の記事
  11. 11. LaravelでLivewireを始める
  12. 12. LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)
  13. 13. LaravelでSanctum認証APIを作る
  14. 14. LaravelでBlade / Livewire / Inertia をどう使い分けるか
  15. 15. LaravelでInertia + Vue.js を始める
  16. 16. LaravelでInertia + React を始める