公開日 2026-04-04

LaravelでLivewire一覧画面を作る(検索・並び替え・ページネーション)

Laravel 13 の fresh app に Livewire 4 を追加し、検索・並び替え・ページネーション付き一覧画面を独立構成で作る。

目次

  1. 前提環境
  2. 1. ゴールと非対象
  3. 2. 先に全体像をつかむ
  4. 3. fresh app を作成して起動する
  5. 4. Livewire と一覧データの土台を作る
  6. 5. 一覧 component とページを作る
  7. 6. サンプルデータを入れて確認する
  8. 7. コードのポイント
  9. #[Url] と WithPagination で状態を URL へ残す
  10. 検索や並び替えを変えたときは resetPage() を呼ぶ
  11. Blade 側は wire:model.live とカスタムページネーションを最小でつなぐ
  12. 8. 詰まりやすい点
  13. 9. まとめ

Blade の延長で一覧画面を改善したい人向けに、Laravel 13 の fresh app へ Livewire 4 を追加し、検索・並び替え・ページネーションまでを 1 本で通す手順をまとめます。開始地点は空ディレクトリです。既存の記事を先に読んでいなくても進められます。

Livewire は、Blade を保ったままサーバー側の状態を UI へ反映できる Laravel 向けの仕組みです。この記事ではその中でも、一覧画面で効きやすい検索・並び替え・ページネーションに絞ります。

補助導線として、先に読んでおくと入りやすい記事は次の 2 本です。

どちらも未読のままで問題ありません。以下の手順だけで完走できます。

前提環境

  • Windows 11
  • WSL2(Ubuntu)
  • VS Code(Remote - WSL)
  • Docker Desktop
  • composer:2 コンテナ
  • Laravel 13
  • Livewire 4
  • SQLite

以降のコマンドは、特記がない限り WSL 側ターミナルで実行します。

1. ゴールと非対象

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

  • /books に Livewire 付きの一覧画面を表示できる
  • 検索入力に応じて一覧を絞り込める
  • 列見出しのボタンで並び替えを切り替えられる
  • 2 ページ目へ進んでも URL に状態を残せる
  • #[Url]WithPaginationresetPage() の役割が分かる

今回は一覧改善に集中します。次の内容は扱いません。

  • 認証、認可
  • 作成 / 編集 / 削除フォーム
  • Volt や page component
  • テスト、CI、デプロイ
  • Inertia / React / Vue との比較

2. 先に全体像をつかむ

実装の流れは 6 段です。

  1. fresh app を作って Laravel を起動する
  2. Livewire を追加する
  3. Book model、migration、Livewire component を作る
  4. 一覧ページと route を作る
  5. BookSeeder でサンプルデータを入れる
  6. route:listtinker、ブラウザで確認する

作業ディレクトリは ~/projects/laravel-livewire-search-sort-pagination-demo にそろえます。

mkdir -p ~/projects/laravel-livewire-search-sort-pagination-demo
cd ~/projects/laravel-livewire-search-sort-pagination-demo
code .

Laravel プロジェクトは composer:2 コンテナから作成します。ホスト側へ PHP や Composer を直接入れずに済み、作業環境をそろえやすいからです。

docker run --rm -u "$(id -u):$(id -g)" -v "$(pwd):/app" -w /app composer:2 create-project laravel/laravel .

3. fresh app を作成して起動する

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

.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 画面が見えれば準備完了です。

4. Livewire と一覧データの土台を作る

まず Livewire を追加します。

docker compose exec app composer require livewire/livewire

次に、一覧で使う Book model と migration を作ります。

docker compose exec app php artisan make:model Book -m

生成された database/migrations/*_create_books_table.php は次の内容に更新します。timestamp 部分は実行時刻によって変わります。

<?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',
    ];
}

ここで Livewire component を作ります。

docker compose exec app php artisan make:livewire BookTable --class

Livewire 4 の既定は single-file component です。php artisan make:livewire BookTable だけを実行すると 1 ファイル構成になります。今回は query string とページネーションの責務を PHP クラス側で追いやすくするため、--class を付けて class-based component にします。

5. 一覧 component とページを作る

app/Livewire/BookTable.php を次の内容に更新します。

<?php

namespace App\Livewire;

use App\Models\Book;
use Illuminate\Contracts\View\View;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;

class BookTable extends Component
{
    use WithPagination;

    #[Url(except: '')]
    public string $search = '';

    #[Url(except: 'created_at')]
    public string $sortField = 'created_at';

    #[Url(except: 'desc')]
    public string $sortDirection = 'desc';

    public function updatingSearch(): void
    {
        $this->resetPage();
    }

    public function sortBy(string $field): void
    {
        $allowedFields = ['title', 'author', 'price', 'created_at'];

        if (! in_array($field, $allowedFields, true)) {
            return;
        }

        if ($this->sortField === $field) {
            $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
        } else {
            $this->sortField = $field;
            $this->sortDirection = 'asc';
        }

        $this->resetPage();
    }

    public function render(): View
    {
        $books = Book::query()
            ->when($this->search !== '', function ($query) {
                $query->where(function ($subQuery) {
                    $keyword = '%' . $this->search . '%';

                    $subQuery->where('title', 'like', $keyword)
                        ->orWhere('author', 'like', $keyword);
                });
            })
            ->orderBy($this->sortField, $this->sortDirection)
            ->paginate(5);

        return view('livewire.book-table', [
            'books' => $books,
        ]);
    }
}

resources/views/livewire/book-table.blade.php を次の内容で作成します。

<div>
    <form class="toolbar" wire:submit.prevent>
        <label>
            検索
            <input
                type="search"
                wire:model.live.debounce.300ms="search"
                placeholder="タイトルまたは著者名で検索"
            >
        </label>
    </form>

    <table>
        <thead>
            <tr>
                <th>
                    <button type="button" wire:click="sortBy('title')">
                        タイトル
                    </button>
                </th>
                <th>
                    <button type="button" wire:click="sortBy('author')">
                        著者
                    </button>
                </th>
                <th>
                    <button type="button" wire:click="sortBy('price')">
                        価格
                    </button>
                </th>
                <th>
                    <button type="button" wire:click="sortBy('created_at')">
                        登録日
                    </button>
                </th>
            </tr>
        </thead>
        <tbody>
            @forelse ($books as $book)
                <tr>
                    <td>{{ $book->title }}</td>
                    <td>{{ $book->author }}</td>
                    <td>{{ number_format($book->price) }}円</td>
                    <td>{{ $book->created_at->format('Y-m-d') }}</td>
                </tr>
            @empty
                <tr>
                    <td colspan="4">該当する本はありません。</td>
                </tr>
            @endforelse
        </tbody>
    </table>

    <div class="pager">
        {{ $books->links('pagination.book-pagination') }}
    </div>
</div>

resources/views/pagination/book-pagination.blade.php を次の内容で作成します。

@if ($paginator->hasPages())
    <nav class="pagination" role="navigation" aria-label="ページネーション">
        <p class="pagination__summary">
            {{ $paginator->firstItem() }}-{{ $paginator->lastItem() }} / {{ $paginator->total() }}
        </p>

        <div class="pagination__controls">
            @if ($paginator->onFirstPage())
                <span class="pagination__button pagination__button--disabled" aria-disabled="true">
                    前へ
                </span>
            @else
                <button
                    type="button"
                    class="pagination__button"
                    wire:click="previousPage"
                    wire:loading.attr="disabled"
                    rel="prev"
                >
                    前へ
                </button>
            @endif

            <span class="pagination__status">
                {{ $paginator->currentPage() }} / {{ $paginator->lastPage() }} ページ
            </span>

            @if ($paginator->hasMorePages())
                <button
                    type="button"
                    class="pagination__button"
                    wire:click="nextPage"
                    wire:loading.attr="disabled"
                    rel="next"
                >
                    次へ
                </button>
            @else
                <span class="pagination__button pagination__button--disabled" aria-disabled="true">
                    次へ
                </span>
            @endif
        </div>
    </nav>
@endif

ページ本体として 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>Books</title>
    @livewireStyles
    <style>
        body {
            font-family: sans-serif;
            margin: 0;
            background: #f7f7f7;
            color: #222;
        }

        main {
            max-width: 960px;
            margin: 40px auto;
            padding: 24px;
            background: #fff;
            border-radius: 16px;
            box-shadow: 0 12px 32px rgba(15, 23, 42, 0.08);
        }

        h1 {
            margin-top: 0;
        }

        .toolbar {
            margin-bottom: 16px;
        }

        input {
            width: 100%;
            max-width: 360px;
            margin-top: 8px;
            padding: 10px 12px;
            border: 1px solid #cbd5e1;
            border-radius: 8px;
        }

        table {
            width: 100%;
            border-collapse: collapse;
        }

        th,
        td {
            padding: 12px;
            border-bottom: 1px solid #e2e8f0;
            text-align: left;
        }

        button {
            border: 0;
            background: transparent;
            font: inherit;
            cursor: pointer;
            padding: 0;
            color: #0f172a;
            font-weight: 700;
        }

        .pager {
            margin-top: 20px;
        }

        .pagination {
            display: flex;
            flex-wrap: wrap;
            justify-content: space-between;
            align-items: center;
            gap: 12px;
        }

        .pagination__summary,
        .pagination__status {
            color: #475569;
            font-size: 14px;
        }

        .pagination__controls {
            display: flex;
            align-items: center;
            gap: 12px;
        }

        .pagination__button {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 72px;
            padding: 8px 14px;
            border: 1px solid #cbd5e1;
            border-radius: 999px;
            background: #fff;
            color: #0f172a;
            font-weight: 600;
        }

        .pagination__button[disabled],
        .pagination__button--disabled {
            border-color: #e2e8f0;
            background: #f8fafc;
            color: #94a3b8;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
    <main>
        <h1>Livewireで本の一覧を操作する</h1>
        <p>検索、並び替え、ページネーションを Livewire でまとめて確認します。</p>
        <livewire:book-table />
    </main>

    @livewireScripts
</body>
</html>

routes/web.php は次の内容にします。

<?php

use Illuminate\Support\Facades\Route;

Route::redirect('/', '/books');
Route::view('/books', 'books.index')->name('books.index');

6. サンプルデータを入れて確認する

一覧画面は 2 ページ目がないとページネーションを確認しづらいので、8 件のサンプルデータを用意します。実検証では、長い配列を php artisan tinker --execute='...' へそのまま渡すと PsySH parse error が出ました。記事では再現しやすさを優先し、Seeder で投入します。

まず Seeder を作成します。

docker compose exec app php artisan make:seeder BookSeeder

database/seeders/BookSeeder.php を次の内容に更新します。

<?php

namespace Database\Seeders;

use App\Models\Book;
use Illuminate\Database\Seeder;

class BookSeeder extends Seeder
{
    public function run(): void
    {
        $rows = [
            ['title' => 'Laravel Essentials', 'author' => 'Ken Aoki', 'price' => 3200, 'created_at' => now()->subDays(8), 'updated_at' => now()->subDays(8)],
            ['title' => 'Livewire Hands-On', 'author' => 'Hanako Sato', 'price' => 3600, 'created_at' => now()->subDays(7), 'updated_at' => now()->subDays(7)],
            ['title' => 'PHP Design Notes', 'author' => 'Ryo Tanaka', 'price' => 2800, 'created_at' => now()->subDays(6), 'updated_at' => now()->subDays(6)],
            ['title' => 'Docker Dev Guide', 'author' => 'Minoru Kato', 'price' => 4100, 'created_at' => now()->subDays(5), 'updated_at' => now()->subDays(5)],
            ['title' => 'SQLite Start', 'author' => 'Miku Ishikawa', 'price' => 2400, 'created_at' => now()->subDays(4), 'updated_at' => now()->subDays(4)],
            ['title' => 'Laravel Testing Intro', 'author' => 'Ichiro Yamada', 'price' => 3900, 'created_at' => now()->subDays(3), 'updated_at' => now()->subDays(3)],
            ['title' => 'Practical Livewire', 'author' => 'Ken Aoki', 'price' => 4300, 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2)],
            ['title' => 'Eloquent Cookbook', 'author' => 'Takumi Nakamura', 'price' => 3500, 'created_at' => now()->subDay(), 'updated_at' => now()->subDay()],
        ];

        Book::query()->delete();
        Book::query()->insert($rows);
    }
}

ここまでできたら migration と seed を流します。

docker compose exec app php artisan migrate
docker compose exec app php artisan db:seed --class=BookSeeder

route を確認します。

docker compose exec app php artisan route:list --name=books

books.index が見えていれば、一覧画面への入口が確認できます。

次に tinker で件数を確認します。

docker compose exec app php artisan tinker --execute='echo \App\Models\Book::query()->count(), PHP_EOL;'

8 と表示されれば、Seeder の投入は完了です。

最後にブラウザで http://localhost:8000/books を開いて確認します。ポートを 8001:8000 に変えた場合は http://localhost:8001/books を開いてください。

  • 1 ページ目に 5 件出る
  • 2 ページ目へ進める
  • 検索欄へ Ken などを入れると一覧が絞り込まれる
  • 列見出しを押すと並び順が変わる

検索欄、列見出し、ページャがそろった状態は次のとおりです。

検索欄、列見出し、ページャが見えている /books の一覧画面

7. コードのポイント

#[Url]WithPagination で状態を URL へ残す

use Livewire\Attributes\Url;
use Livewire\WithPagination;

class BookTable extends Component
{
    use WithPagination;

    #[Url(except: '')]
    public string $search = '';

    #[Url(except: 'created_at')]
    public string $sortField = 'created_at';

    #[Url(except: 'desc')]
    public string $sortDirection = 'desc';

searchsortFieldsortDirection を URL 同期対象にしているので、一覧の状態をリロード後も維持しやすくなります。WithPagination を入れると ?page=2 のようなページ番号も Livewire 側で扱えます。

検索や並び替えを変えたときは resetPage() を呼ぶ

public function updatingSearch(): void
{
    $this->resetPage();
}

public function sortBy(string $field): void
{
    if ($this->sortField === $field) {
        $this->sortDirection = $this->sortDirection === 'asc' ? 'desc' : 'asc';
    } else {
        $this->sortField = $field;
        $this->sortDirection = 'asc';
    }

    $this->resetPage();
}

検索条件や並び替え条件が変わった直後に 2 ページ目のままだと、該当件数が減ったときに空ページへ移ることがあります。条件更新時に resetPage() を呼んでおくと、このずれを避けやすくなります。

Blade 側は wire:model.live とカスタムページネーションを最小でつなぐ

<input
    type="search"
    wire:model.live.debounce.300ms="search"
    placeholder="タイトルまたは著者名で検索"
>

<div class="pager">
    {{ $books->links('pagination.book-pagination') }}
</div>

<button
    type="button"
    class="pagination__button"
    wire:click="nextPage"
    wire:loading.attr="disabled"
    rel="next"
>
    次へ
</button>

wire:model.live.debounce.300ms にすると、入力が止まってから 300ms 後に検索条件が反映されます。ページネーションは Tailwind 前提の既定 view をそのまま使わず、pagination.book-pagination へ切り出しました。これで手書き CSS のページでも崩れにくく、wire:click="nextPage"previousPage だけで Livewire の遷移を保てます。

8. 詰まりやすい点

  • @livewireStyles@livewireScripts を忘れると、初期 HTML は出ても Livewire の更新が動きません。
  • orderBy() に request 由来の文字列をそのまま渡すと、意図しない列名を受け取れる形になります。今回のように許可列を固定してください。
  • tinker --execute へ長い配列を直接埋め込むと、実検証では PsySH parse error になりました。大量データは Seeder のほうが安定します。
  • Tailwind を入れていないページで $books->links() の既定 view をそのまま使うと、ページネーションが崩れることがあります。今回は pagination.book-pagination を指定するのが対策です。
  • 8000 番ポートが使用中の環境では、compose.yml の公開ポートを 8001:8000 などへ変更し、.envAPP_URL も同じ番号へそろえてください。番号がずれると確認 URL もずれます。

9. まとめ

fresh app から始めても、Livewire を使えば Blade 主線のまま一覧画面へ検索、並び替え、ページネーションを足せます。今回の要点は、#[Url] で状態を URL へ残し、resetPage() でページ番号のずれを防ぎ、Blade 側は wire:model.livewire:click の最小構成に留めることです。

次に Laravel 側を広げるなら、LaravelでLivewireを始める と見比べて役割の差を整理するか、次に着手する記事メモ にある API 側の候補へ進むと流れをつなぎやすくなります。

シリーズ 12/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 を始める