MENU

問い合わせ


    【Laravel+Vue.jsで作る管理画面をECSで動かす 第4回】案件管理と一覧画面の作り込み:検索・並べ替え・ページング

    管理画面で最もよく使われるのは一覧画面です。件数が数十件のうちは困りませんが、数百・数千件になると「探せない」「表示が遅い」という不満が出てきます。今回は、顧客にひもづく「案件」を追加し、キーワード検索・ステータスでの絞り込み・並べ替え・ページ送りを備えた一覧画面を Laravel 12 と Vue 3 で作ります。

    「一覧画面に検索と並べ替えを付けたら、データが増えたときに急に遅くなった。どこに気をつければよかったのか分からない…」

    結論から言うと、一覧画面は「関連データの読み込み方(N+1 問題の対策)」「検索条件の入力チェック」「ページング」の3点を最初から押さえておけば、件数が増えても遅くなりにくく、安全に作れます。画面側は Inertia 2 の部分リロードを使うと、条件を変えたときに一覧のデータだけを取り直せます。

    前回(第3回:ロールと Policy)では、管理者・担当者・閲覧のみの権限を実装しました。今回はそこに案件(projects)を追加し、一覧画面を作り込みます。

    目次

    案件一覧の検索・並べ替え・ページングで作るもの

    案件は「どの顧客の、何の仕事か」を表すデータで、必ず1つの顧客に属します。一覧画面には次の機能を付けます。

    機能内容URL の例
    キーワード検索案件名・顧客名・顧客コードの部分一致/projects?q=保守
    絞り込みステータス(見込み・進行中・完了・中止・失注)?status=in_progress
    並べ替え案件名・顧客名・ステータス・受注金額・納期・登録日時。昇順/降順?sort=amount&direction=desc
    ページング1ページ 10/20/50 件、検索条件を引き継いでページ送り?per_page=10&page=2

    検索条件はすべて URL のクエリ文字列(? 以降)に載せます。こうしておくと、ブラウザの「戻る」で直前の条件に戻れ、検索結果の URL を同僚に送って同じ画面を見てもらうこともできます。

    なお、案件の登録・編集画面は第2回の顧客と同じ作りになるため、この連載では一覧に絞ります。

    projects テーブルとリレーションを作る

    マイグレーション:外部キーで顧客にひもづける

    database/migrations/2025_06_24_000000_create_projects_table.php(up 部分)

        public function up(): void
        {
            Schema::create('projects', function (Blueprint $table): void {
                $table->id();
                // 案件は必ず1つの顧客に属する。案件が残っている顧客は削除できない(restrictOnDelete)
                $table->foreignId('customer_id')->constrained()->restrictOnDelete();
                $table->string('name', 200);
                $table->string('status', 20)->default('prospect')->index();
                $table->unsignedBigInteger('amount')->nullable();  // 受注金額(円)
                $table->date('due_date')->nullable()->index();     // 納期
                $table->timestamps();
            });
        }

    foreignId('customer_id')->constrained() は、customers テーブルの id を参照する外部キー(=存在しない顧客を指せないようにするデータベースの制約)を作ります。restrictOnDelete() を付けたので、案件が残っている顧客はデータベースの側で削除できません。第2回で「関連データがある顧客の削除」を課題として挙げましたが、今回、顧客の削除処理にも「案件があれば削除せずにメッセージを返す」チェックを追加しました。

    絞り込みや並べ替えに使う status と due_date にはインデックス(=検索を速くするための索引)を付けています。

    ステータスは第3回のロールと同じく、PHP の enum(app/Enums/ProjectStatus.php)で定義しました。画面の選択肢用に、値と日本語の表示名の組を返す options() も持たせています。

    モデル:belongsTo と hasMany

    app/Models/Project.php(リレーション部分)

        /** @return BelongsTo<Customer, $this> */
        public function customer(): BelongsTo
        {
            return $this->belongsTo(Customer::class);
        }

    app/Models/Customer.php(追加部分)

        /** @return HasMany<Project, $this> */
        public function projects(): HasMany
        {
            return $this->hasMany(Project::class);
        }

    「案件は顧客に属する(belongsTo)」「顧客は複数の案件を持つ(hasMany)」という関係をモデルに書いておくと、$project->customer->name のように関連データをたどれるようになります。

    N+1 問題を with() と preventLazyLoading で防ぐ

    N+1 問題とは

    一覧で20件の案件を表示し、各行で $project->customer->name を参照すると、何も工夫しない場合は「案件20件を取る1回」+「顧客を1件ずつ取る20回」の合計21回のクエリが実行されます。このように、件数に比例してクエリが増える現象を N+1 問題と呼びます。件数が少ない開発中は気づきにくく、本番でデータが増えてから遅くなる典型的な原因です。

    with('customer') を付けると、Laravel は「案件をまとめて取る1回」+「必要な顧客をまとめて取る1回」で済ませます(Eager Loading=関連データの先読み)。

    📰 出典:Laravel 12.x Eloquent: Relationships(Eager Loading)

    with() のし忘れを開発中に気づけるようにする

    app/Providers/AppServiceProvider.php(boot に追加)

            // 本番以外では、with() し忘れた関連データの遅延読み込み(N+1 の原因)を例外にして気づけるようにする
            Model::preventLazyLoading(! $this->app->isProduction());

    preventLazyLoading を有効にすると、with() で先読みしていない関連データを参照した時点で例外が発生します。開発中とテスト中だけ有効にし、本番では例外にしない(画面が止まらない)設定です。筆者の環境でも、コントローラーから with() を外してテストを実行すると、LazyLoadingViolationException で失敗することを確認しました。

    📰 出典:Laravel 12.x Eloquent: Relationships(Preventing Lazy Loading)

    検索条件を ProjectIndexRequest で検証する

    一覧の検索条件は URL に載るため、利用者が自由に書き換えられます。特に並べ替えの列名をそのまま SQL に渡すと、存在しない列でエラーになったり、見せるべきでない列で並べ替えられたりします。第2回の登録フォームと同じく、FormRequest で検証します。

    app/Http/Requests/ProjectIndexRequest.php(抜粋)

    class ProjectIndexRequest extends FormRequest
    {
        public function authorize(): bool
        {
            return $this->user()?->can('viewAny', Project::class) ?? false;
        }
    
        /**
         * @return array<string, ValidationRule|array<mixed>|string>
         */
        public function rules(): array
        {
            return [
                'q' => ['nullable', 'string', 'max:100'],
                'status' => ['nullable', Rule::enum(ProjectStatus::class)],
                'sort' => ['nullable', Rule::in(Project::SORTABLE)],
                'direction' => ['nullable', Rule::in(['asc', 'desc'])],
                'per_page' => ['nullable', 'integer', Rule::in([10, 20, 50])],
            ];
        }
    
        /**
         * 検証済みの値に既定値を補って返す
         *
         * @return array{q: string, status: string, sort: string, direction: string, per_page: int}
         */
        public function filters(): array
        {
            $validated = $this->validated();
    
            return [
                'q' => (string) ($validated['q'] ?? ''),
                'status' => (string) ($validated['status'] ?? ''),
                'sort' => (string) ($validated['sort'] ?? 'created_at'),
                'direction' => (string) ($validated['direction'] ?? 'desc'),
                'per_page' => (int) ($validated['per_page'] ?? 20),
            ];
        }
    
        // attributes() と messages()(日本語の項目名・メッセージ)は省略
    }
    • Rule::in(Project::SORTABLE):並べ替えに使える列を、モデルに定数で書いた一覧(案件名・顧客・ステータス・受注金額・納期・登録日時)に限定します。
    • Rule::enum(ProjectStatus::class):ステータスは enum に定義された値だけを受け付けます。
    • per_page を 10・20・50 に限定:?per_page=100000 のような指定で、大量のデータを一度に読み込ませないためです。

    不正な値が来た場合は、第2回と同じく 302 で前の画面に戻り、エラーメッセージが表示されます。認可は第3回と同じく Policy(ProjectPolicy。一覧は全ロールが閲覧可)を使っています。

    クエリスコープとコントローラー

    キーワード検索と並べ替えのスコープ

    app/Models/Project.php(スコープ部分)

        /**
         * キーワード検索:案件名 または 顧客名・顧客コード の部分一致
         *
         * @param  Builder<Project>  $query
         */
        public function scopeSearch(Builder $query, ?string $keyword): void
        {
            if ($keyword === null || $keyword === '') {
                return;
            }
    
            // LIKE の特殊文字(% と _)をエスケープして、入力をそのまま文字として検索する
            $like = '%'.addcslashes($keyword, '%_\\').'%';
    
            $query->where(function (Builder $q) use ($like): void {
                $q->where('name', 'like', $like)
                    ->orWhereHas('customer', fn (Builder $c) => $c
                        ->where('name', 'like', $like)
                        ->orWhere('code', 'like', $like));
            });
        }
    
        /**
         * 並べ替え。$column は ProjectIndexRequest で SORTABLE に含まれることを検証済み
         *
         * @param  Builder<Project>  $query
         */
        public function scopeSortBy(Builder $query, string $column, string $direction): void
        {
            if ($column === 'customer') {
                // 顧客名で並べ替え(サブクエリで顧客名を引く。JOIN しないので select 列が崩れない)
                $query->orderBy(
                    Customer::select('name')->whereColumn('customers.id', 'projects.customer_id'),
                    $direction,
                );
            } else {
                $query->orderBy($column, $direction);
            }
    
            // 同じ値が並んだときにページをまたいで順序が揺れないよう、最後に id で並べる
            $query->orderBy('id', $direction);
        }

    scope で始まるメソッドは、Project::query()->search('保守') のように呼び出せます。

    📰 出典:Laravel 12.x Eloquent(Local Scopes)

    気をつけたのは2点です。

    • LIKE の特殊文字のエスケープ:部分一致検索の % は「任意の文字列」を意味します。キーワードに % が入ると全件に一致してしまうため、addcslashes() で文字として扱うようにしています。
    • 並べ替えの最後に id を加える:受注金額が同じ案件が複数あると、データベースは並び順を保証しません。ページをまたいで同じ案件が2回出たり、出なかったりするのを防ぐため、最後に id で並べます。

    コントローラー:with() と withQueryString()

    app/Http/Controllers/ProjectController.php

    class ProjectController extends Controller
    {
        public function index(ProjectIndexRequest $request): Response
        {
            $filters = $request->filters();
    
            $projects = Project::query()
                ->select(['id', 'customer_id', 'name', 'status', 'amount', 'due_date', 'created_at'])
                // N+1 対策:顧客をまとめて1回のクエリで読み込む(必要な列だけ)
                ->with('customer:id,code,name')
                ->search($filters['q'])
                ->when($filters['status'] !== '', fn ($query) => $query->where('status', $filters['status']))
                ->sortBy($filters['sort'], $filters['direction'])
                ->paginate($filters['per_page'])
                // ページ送りのリンクに検索条件(?q=...&sort=...)を引き継ぐ
                ->withQueryString()
                ->through(fn (Project $project): array => [
                    'id' => $project->id,
                    'name' => $project->name,
                    'customer' => $project->customer->only(['id', 'code', 'name']),
                    'status' => $project->status->value,
                    'status_label' => $project->status->label(),
                    'amount' => $project->amount,
                    'due_date' => $project->due_date?->format('Y-m-d'),
                ]);
    
            return Inertia::render('projects/Index', [
                'projects' => $projects,
                'filters' => $filters,
                // 選択肢は検索のたびに変わらないので、部分リロード(only: ['projects', 'filters'])では送らない
                'statuses' => fn () => ProjectStatus::options(),
            ]);
        }
    }

    withQueryString() を付けないと、2ページ目へのリンクが ?page=2 だけになり、検索条件が消えてしまいます。

    📰 出典:Laravel 12.x Pagination(Appending Query String Values)

    statuses を fn () => ... のように関数で渡しているのは、後述の部分リロードで「必要なときだけ計算する」ためです。

    Vue の一覧画面:DataTable と部分リロード

    部分リロードで一覧だけを取り直す

    resources/js/pages/projects/Index.vue(script 部分の抜粋)

    <script setup lang="ts">
    // import 文と breadcrumbs・columns(列の定義)は省略
    
    const props = defineProps<{
        projects: Paginated<ProjectRow>;
        filters: ProjectFilters;
        statuses: SelectOption[];
    }>();
    
    // 絞り込みフォームの状態(初期値はサーバーが検証・補完した filters)
    const form = reactive({
        q: props.filters.q,
        status: props.filters.status,
        per_page: props.filters.per_page,
    });
    
    // 検索条件を URL のクエリにして一覧だけを取り直す(Inertia 2 の部分リロード)
    const reload = (overrides: Partial<ProjectFilters> = {}) => {
        const query = {
            q: form.q,
            status: form.status,
            sort: props.filters.sort,
            direction: props.filters.direction,
            per_page: form.per_page,
            ...overrides,
        };
    
        router.get(
            route('projects.index'),
            // 空の条件は URL に載せない(page も付けないので、条件を変えると1ページ目に戻る)
            Object.fromEntries(Object.entries(query).filter(([, value]) => value !== '')),
            {
                only: ['projects', 'filters'],
                preserveState: true,
                preserveScroll: true,
                replace: true,
            },
        );
    };
    
    // キーワードは入力のたびに送らず、入力が 300ms 止まってから検索する
    watch(
        () => form.q,
        useDebounceFn(() => reload(), 300),
    );
    watch(
        () => [form.status, form.per_page],
        () => reload(),
    );
    
    const onSort = (key: string) => {
        const direction = props.filters.sort === key && props.filters.direction === 'asc' ? 'desc' : 'asc';
        reload({ sort: key, direction });
    };
    </script>

    router.get() の only: ['projects', 'filters'] が部分リロードの指定です。サーバーは指定された props だけを返し、関数で渡した statuses は計算すらしません。preserveState で入力中の検索ボックスの状態を保ち、replace でブラウザの履歴を1文字ごとに増やさないようにしています。キーワードの入力は、@vueuse/core(スターターキットに同梱)の useDebounceFn で 300ミリ秒待ってから送ります。

    📰 出典:Inertia.js v2 Partial reloads

    DataTable.vue:並べ替えの見出しとページ送りを部品にする

    resources/js/components/DataTable.vue(見出しと行の部分)

                <thead class="text-muted-foreground border-b">
                    <tr>
                        <th
                            v-for="column in columns"
                            :key="column.key"
                            class="py-2"
                            :class="column.align === 'right' ? 'text-right' : ''"
                            :aria-sort="column.sortable && sort === column.key ? (direction === 'asc' ? 'ascending' : 'descending') : undefined"
                        >
                            <button
                                v-if="column.sortable"
                                type="button"
                                class="hover:text-foreground inline-flex items-center gap-1"
                                @click="emit('sort', column.key)"
                            >
                                {{ column.label }}
                                <ArrowUp v-if="sort === column.key && direction === 'asc'" class="size-3" />
                                <ArrowDown v-else-if="sort === column.key && direction === 'desc'" class="size-3" />
                                <ArrowUpDown v-else class="size-3 opacity-40" />
                            </button>
                            <span v-else>{{ column.label }}</span>
                        </th>
                    </tr>
                </thead>
                <tbody>
                    <tr v-for="row in paginator.data" :key="row.id" class="border-b">
                        <td v-for="column in columns" :key="column.key" class="py-2" :class="column.align === 'right' ? 'text-right' : ''">
                            <!-- 列ごとの表示を親から差し替えられる(例:#cell-customer) -->
                            <slot :name="`cell-${column.key}`" :row="row">
                                {{ cell(row, column.key) }}
                            </slot>
                        </td>
                    </tr>
                </tbody>

    DataTable は列の定義(columns)と Laravel のページング結果(paginator)を受け取り、並べ替えの見出しをクリックすると sort イベントを親に知らせます。どの列で並べ替えるかを決めるのは親(とサーバー)です。ページ送りのリンクにも同じ only を渡し、ページを移るときも一覧だけを取り直します。

    一覧画面からは、次のように使います。顧客名や金額のように表示を工夫したい列だけ、スロット(=部品の一部を差し替える仕組み)で書き換えます。

    resources/js/pages/projects/Index.vue(テンプレートの抜粋)

                <DataTable
                    :columns="columns"
                    :paginator="projects"
                    :sort="filters.sort"
                    :direction="filters.direction"
                    :only="['projects', 'filters']"
                    @sort="onSort"
                >
                    <template #cell-customer="{ row }">
                        <span class="text-muted-foreground font-mono text-xs">{{ row.customer.code }}</span>
                        {{ row.customer.name }}
                    </template>
                    <template #cell-status="{ row }">{{ row.status_label }}</template>
                    <template #cell-amount="{ row }">{{ row.amount === null ? '-' : yen.format(row.amount) }}</template>
                    <template #cell-due_date="{ row }">{{ row.due_date ?? '-' }}</template>
                </DataTable>

    テストでクエリ数と不正な条件を確かめる

    tests/Feature/ProjectIndexTest.php(抜粋)

        public function test_invalid_query_is_rejected(): void
        {
            $this->actingAs(User::factory()->create())
                ->from('/projects')
                ->get('/projects?sort=password&direction=up&per_page=1000&status=unknown')
                ->assertRedirect('/projects')
                ->assertSessionHasErrors(['sort', 'direction', 'per_page', 'status']);
        }
    
        public function test_query_count_does_not_grow_with_rows(): void
        {
            $user = User::factory()->create();
            $countQueries = function () use ($user): int {
                $count = 0;
                DB::listen(function () use (&$count): void {
                    $count++;
                });
                $this->actingAs($user)->get('/projects?per_page=50')->assertOk();
    
                return $count;
            };
    
            Project::factory()->count(5)->create();
            $few = $countQueries();
    
            Project::factory()->count(45)->create();
            $many = $countQueries();
    
            // 5件でも50件でもクエリ数は同じ(顧客を1件ずつ読みに行く N+1 が起きていない)
            $this->assertSame($few, $many);
        }

    DB::listen() は、実行された SQL を1件ずつ受け取れる仕組みです。表示件数が5件と50件でクエリの数が同じなら、件数に比例して増える N+1 は起きていません。

    このほか、キーワード検索(% を入力しても全件一致にならないことを含む)、絞り込みと並べ替え、ページ送りでの条件の引き継ぎ、部分リロード、案件がある顧客の削除防止のテストを追加し、全体で52件になりました。

    動作確認の方法

    docker compose run --rm --no-deps app php artisan test
    docker compose run --rm --no-deps node sh -c "npm run build"
    docker compose up -d mysql app
    docker compose exec app php artisan migrate:fresh --seed   # 顧客50件・案件500件(ローカルのDBを作り直すので注意)

    シーダーは、顧客50件のそれぞれに案件を10件ずつ、合計500件作ります。筆者の環境では次のことを確認しました。

    • 全52件のテストが成功し、npm run build も成功
    • 一覧が「全 500 件中 1〜20 件目」で表示され、キーワード「保守」+ステータス「進行中」+受注金額の降順+10件表示で絞り込める。次ページのリンクに条件がすべて残る
    • 顧客名での並べ替えと、3ページ目への移動ができる
    • ?sort=password のような不正な並べ替えでは「並べ替え項目の値が正しくありません。」が表示される
    • tinker で DB::enableQueryLog() を使い、50件表示のときのクエリが「件数」「案件」「顧客」の3回であることを確認

    つまずきやすい点・セキュリティ上の注意

    • 並べ替えの列名を検証せずに使わない:orderBy($request->sort) のように利用者の入力をそのまま渡すと、存在しない列でエラー画面が出たり、画面に出していない列(金額や内部のフラグなど)で並べ替えて中身を推測されたりするおそれがあります。必ず許可した列の一覧で検証します。
    • 部分一致検索は件数が増えると遅くなる:LIKE '%保守%' のような前後の部分一致は、通常のインデックスが効きません。数万件程度までは問題になりにくいですが、それ以上の件数や全文検索が必要な場合は、全文検索インデックスや検索エンジンの導入を検討します。
    • with() した列の指定に外部キーを含める:with('customer:id,code,name') のように列を絞るときは、id を必ず含めます。含めないと顧客と案件を結び付けられず、顧客が空になります。同じ理由で、案件側の select() にも customer_id が必要です。
    • ページを大きくしすぎない:「全件表示したい」という要望には、表示件数を増やすのではなく CSV 出力など別の機能で応えるのが安全です。

    発注者向けメモ:一覧画面の要望は最初に列挙して優先度をつける

    一覧画面は、運用が始まってから要望が膨らみやすい画面です。「この列でも並べ替えたい」「この条件でも絞り込みたい」「CSVで出したい」「前回の検索条件を覚えておいてほしい」と、1つずつは小さく見えても、積み重なると大きな工数になります。

    最初に、次の項目を一覧にして優先度をつけておくのが工数管理のコツです。

    • 表示する列:どの列を、どの順で出すか。金額など、ロールによって隠したい列はあるか
    • 検索・絞り込みの条件:キーワードの対象項目、期間指定、複数条件の組み合わせ(AND か OR か)
    • 並べ替えできる列と初期の並び順
    • 1ページの件数と、想定するデータ件数:数百件なのか、数十万件なのかで作り方が変わります
    • CSV出力・一括操作:必要なら、出力件数の上限や権限も決めます

    開発会社には、次のように確認してみてください。

    • 「データが今の10倍に増えても、一覧画面の表示速度は保てますか?そのためにどんな対策をしていますか?」
    • 「検索条件や並べ替えを URL で共有できますか?ブラウザの『戻る』で条件は残りますか?」
    • 「一覧に列や検索条件を1つ追加する場合、どのくらいの工数になりますか?」
    • 「表示速度の確認は、本番に近い件数のデータで行いますか?」

    まとめと次回予告

    第4回では、顧客にひもづく案件を追加し、検索・並べ替え・ページングを備えた一覧画面を作りました。

    • 案件は外部キーで顧客にひもづけ、案件がある顧客は削除できないようにした
    • with() で関連データを先読みし、preventLazyLoading で N+1 を開発中に検知。テストでクエリ数が件数に比例しないことを確かめる
    • 検索条件は ProjectIndexRequest で検証し、並べ替えの列・表示件数・ステータスは許可した値だけを受け付ける
    • withQueryString() でページ送りに条件を引き継ぎ、Inertia 2 の部分リロード(only)で一覧だけを取り直す

    次回は「請求と非同期処理:キューとスケジューラをローカルで動かす」です。案件にひもづく請求を追加し、請求書の発行を裏側で順番に処理するキューと、月次の締め処理・支払期限の超過チェックを定期実行するスケジューラを、Docker の開発環境で動かします。

    この連載の記事一覧

    この記事は連載「Laravel+Vue.jsで作る管理画面をECSで動かす」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次