Laravel Filamentでお手軽に作る管理画面とそのためのテーブル設計

はじめに

この記事は【カヤック】面白法人グループ Advent Calendar 2024の12日目の記事です。

こんにちは、カヤックボンド所属のサーバーサイドエンジニアの朝倉と申します。

本記事のテーマはLaravel filamentを用いた管理画面作成です。
管理画面を簡単に作れるFilamentの基本的な使い方と要件をちゃんと決めずに見切り発車したらぶつかってしまうFilamentならではの問題を書いていきたいと思います。

Laravel Filamentとは

filamentphp.com

まずPHPのフレームワークとしてLaravelがあり、その拡張パッケージがFilamentです。
管理画面を作るための機能を豊富に持っています。
また、今回は触れませんがサーバーサイドの記述のみでフロントの動的な制御をする機能も用意されています。

環境構築

環境構築についてはこちらを参考にさせていただきました。

depart-inc.com

環境構築してログイン直後の画面がこちら。

ここにサンプルを作っていきます。

今回作るもの

今回はヘルスケア記録の管理画面を作っていきます。
最近はいろんな機器にヘルスケアを取得する機能がついており、それを入力して有効活用できるようにします。
また、先日人間ドックの結果が戻ってきましたがいろいろ悪くなってきたのでちょっとしっかりせねばならんな、という背景もあります。
みなさんもご自愛ください。

要件はざっくり下記の通りです。

  • ヘルスケア記録(体重とか血圧とか)を管理画面上で入力・閲覧できるようにする。
  • 現時点でのユーザーは私一人だが、将来的には家族の健康管理にも使えるように複数ユーザーに対応できるようにする。

テーブル設計

そこまで複雑なことはしないのでマイグレーションファイルの内容をそのまま貼ってしまいます。

  • ユーザーテーブル(application_users)

ユーザー情報のテーブルです。
ちょっと冗長な名前ですが、usersテーブルはFilamentの管理画面ユーザーのテーブルとしてデフォルトで作られるのでそれと被らないようにしました。
もちろん管理画面ユーザーテーブルの方の名前変更(admin_usersとか)もできるのですが、記事の本筋から離れるので今回はこれで進めます。

<?php

    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('application_users', function (Blueprint $table) {
            $table->id();
            $table->string('name')->comment('名前');
            $table->unsignedInteger('gender')->comment('性別');
            $table->date('birth_date')->comment('生年月日');
            $table->timestamps();
        });
    }

※必要な箇所のみ抜粋しています。
 シンタックスハイライトの都合<?phpを書いてその次にいきなりup()メソッドが来ていますが適宜読み替えてください。
 以下のソースコードも同様です。

  • 記録項目マスターテーブル(record_types)

体重とか血圧とか、何を記録するかを管理するテーブルです。

<?php

    public function up(): void
    {
        Schema::create('record_types', function (Blueprint $table) {
            $table->id();
            $table->string('name')->comment('項目名');
            $table->timestamps();
        });
    }
  • ヘルスケア記録テーブル(healthcare_records)

ユーザーが入力したヘルスケア記録を保存するテーブルです。

<?php

    public function up(): void
    {
        Schema::create('healthcare_records', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('application_user_id')->comment('ユーザーID');
            $table->unsignedInteger('type')->comment('記録項目');
            $table->unsignedInteger('value')->comment('記録値');
            $table->dateTime('recorded_at')->comment('記録日時');
            $table->timestamps();
        });
    }

ユーザー管理機能

ユーザー一覧画面作成

LaravelおなじみのコマンドでModelファイルを作ります。

sail artisan make:model ApplicationUser

続いてFilamentでの画面生成に必要なResourceファイルを作ります。
下記コマンドで一覧、詳細、作成、編集画面に必要なファイルが作成されます。

sail artisan make:filament-resource ApplicationUser --view

ここまで進めるとメニューに"Application User"が表示されユーザー一覧画面を表示できるようになります。

データも無いし表示の設定もしていないので何も表示されません。

ユーザー作成画面

管理画面で一人一人ユーザーを作っていくのは手間ですが、いったんユーザーは私一人なのでそのまま作っていきます。
先ほどのコマンドで生成された下記ファイルを編集します。
/app/Filament/Resources/ApplicationUserResource.php
ここにデフォルトで記載されているform()メソッドに入力したい項目を追記します。

<?php

    protected static ?string $label = 'ユーザー情報';// ページ名の設定

    public static function form(Form $form): Form
    {
        return $form
            ->schema([
                Forms\Components\TextInput::make('name')
                    ->label(__('名前'))
                    ->required(),
                Forms\Components\Select::make('gender')
                    ->label(__('性別'))
                    ->options([
                        1 => '男性',
                        2 => '女性',
                        3 => 'その他',
                    ])
                    ->required(),
                Forms\Components\DatePicker::make('birth_date')
                    ->label(__('生年月日'))
                    ->required(),
            ]);
    }

これで作成画面が作成されます。

各項目を入力して作成ボタンを押下すればユーザーデータが作成されます。
なお、form()メソッドは編集画面と詳細画面とも共用なのでこれ一つでこれらの画面がすべて機能するようになります。

ユーザー一覧画面作り込み

ユーザーデータを作成しましたが表示設定をしていないので一覧画面には何も表示されません。
今度は/app/Filament/Resources/ApplicationUserResource.phpのtable()メソッドに追記していきます。

<?php
    public static function table(Table $table): Table
    {
        $genders = [// form()でも同じこと書いてるのでちゃんとやるなら別の場所に定義しないとですね
            1 => '男性',
            2 => '女性',
            3 => 'その他',
        ];
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('id')
                    ->label(__('管理ID')),
                Tables\Columns\TextColumn::make('name')
                    ->label(__('名前')),
                Tables\Columns\TextColumn::make('gender')
                    ->label(__('性別'))
                    ->formatStateUsing(function ($state) use ($genders) {
                        return $genders[$state];
                    }),
                Tables\Columns\TextColumn::make('birth_date')
                    ->label(__('生年月日')),
            ])
            ->filters([
                //
            ])
            ->actions([
                Tables\Actions\ViewAction::make(),
                Tables\Actions\EditAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }

ご覧の通りなのですがform()と似たような記載であり、make()にカラム名を渡してその値を表示しています。
Resourceファイル作成時にもモデル名を指定したように、Filamentではテーブルごとにページを作ってそのテーブルを対象にCRUD機能を実装します。
UpdateとDeleteについては今回は触れませんが、実はこの時点で実装済みとなっています。
実際に作って試してみてください。

上記で作成されたユーザー一覧画面がこちら。

簡素ではありますが登録した情報はすべて表示できているのでユーザー一覧画面としての要件は満たせているでしょう。

記録項目マスター管理機能

カラムは一つしか無いし新しいこともしないので割愛させてもらいます。
下記の画面を作りました。

ヘルスケア記録管理機能

ヘルスケア記録一覧とりあえず作成

ヘルスケア記録の一覧画面も同様に作れますが、

ユーザーIDと記録項目が別テーブルのIDを表示しているだけなので分かりづらいです。
ここは参照元のテーブルにある名前を表示させましょう。

ヘルスケア記録一覧作り込み

他のテーブルの値を参照する方法はいろいろありますが、今回は一覧表示時にテーブル結合をすることにします。
下記のファイルに追記します。
app/Filament/Resources/HealthcareRecordResource.php

<?php

    public static function getEloquentQuery(): Builder
    {
        return parent::getEloquentQuery()
            ->leftJoin('application_users', 'application_users.id', 'healthcare_records.application_user_id')
            ->leftJoin('record_types', 'record_types.id', 'healthcare_records.type')
            ->select(
                'healthcare_records.*',
                'application_users.name as application_user_name',
                'record_types.name as type_name'
            );
    }

これで一覧画面表示時にテーブル結合できます。
ここで定義したカラム名をtable()で参照すると

ユーザー名と記録項目名が表示されるようになりました。

一覧画面フィルタ処理

ヘルスケア記録をどんどん登録していきます。

なんだか見づらいですね。
例えば体重の遷移を見るために体重だけを表示できる機能が欲しいです。
フィルタ機能を作りましょう。
フィルタ機能はtable()のfilters()を編集します。
app/Filament/Resources/HealthcareRecordResource.php

<?php

    public static function table(Table $table): Table
    {
        $applicationUsers = ApplicationUser::get()->pluck('name', 'id')->toArray();
        $recordTypes = RecordType::get()->pluck('name', 'id')->toArray();
        return $table
            ->columns([
                Tables\Columns\TextColumn::make('application_user_name')
                    ->label(__('ユーザーID')),
                Tables\Columns\TextColumn::make('type_name')
                    ->label(__('記録項目')),
                Tables\Columns\TextColumn::make('value')
                    ->label(__('記録値')),
                Tables\Columns\TextColumn::make('recorded_at')
                    ->label(__('記録日時')),
            ])
            ->filters([
                Tables\Filters\Filter::make('application_user_id')
                    ->label(__('ユーザー名'))
                    ->form([
                        Forms\Components\Select::make('application_user_id')
                            ->label(__('ユーザー名'))
                            ->options($applicationUsers)
                    ])->query(function (Builder $query, $data) {
                        return $query->when(!empty($data['application_user_id'] || $data['application_user_id'] === "0"), function (Builder $query) use ($data) {
                            return $query->where(function ($query) use ($data) {
                                $query->orWhere('application_user_id', intval($data['application_user_id']));
                            });
                        });
                    }),
                Tables\Filters\Filter::make('type')
                    ->label(__('記録項目'))
                    ->form([
                        Forms\Components\Select::make('type')
                            ->label(__('記録項目'))
                            ->options($recordTypes)
                    ])->query(function (Builder $query, $data) {
                        return $query->when(!empty($data['type'] || $data['type'] === "0"), function (Builder $query) use ($data) {
                            return $query->where(function ($query) use ($data) {
                                $query->orWhere('type', intval($data['type']));
                            });
                        });
                    }),
                Tables\Filters\Filter::make('display_between')
                    ->label(__('表示日範囲検索'))
                    ->form([
                        Forms\Components\DateTimePicker::make('start_at')
                            ->label(__('記録日時(始点)')),
                        Forms\Components\DateTimePicker::make('end_at')
                            ->label(__('記録日時(終点)'))
                    ])->query(function (Builder $query, $data) {
                        if ($data['start_at'] && $data['end_at']) {
                            return $query->where('recorded_at', '>=', $data['start_at'])
                                ->where('recorded_at', '<=', $data['end_at']);
                        }
                        if ($data['start_at']) {
                            return $query->when($data['start_at'], function (Builder $query, $input) {
                                return $query->where('recorded_at', '>=', $input);
                            });
                        }
                        if ($data['end_at']) {
                            return $query->when($data['end_at'], function (Builder $query, $input) {
                                return $query->where('recorded_at', '<=', $input);
                            });
                        }
                    }),
            ], layout: Tables\Enums\FiltersLayout::AboveContent)
            ->actions([
                Tables\Actions\ViewAction::make(),
                Tables\Actions\EditAction::make(),
            ])
            ->bulkActions([
                Tables\Actions\BulkActionGroup::make([
                    Tables\Actions\DeleteBulkAction::make(),
                ]),
            ]);
    }

修正後の画面がこちら。

体重だけで絞り込めるようになりました。
ついでにユーザー名と記録日時でも絞り込みできるようにしてみました。
ユーザー名は増えてくるとリストから選ぶのが大変なのでテキスト入力で部分一致させるという案もありますが、今回はユーザーは一人なのでこのままにしておきます。

補足 getEloquentQuery()の修正

この後の手順で詳細・編集画面を見る必要は無いのですが、この時点でこれらを開こうとすると下記エラーが発生します。

SQLSTATE[23000]: Integrity constraint violation: 1052 Column 'id' in where clause is ambiguous

Filamentの詳細・編集画面の表示時にはwhere `id` = 1 limit 1のクエリが実行されるのですが、テーブル結合しているのでどのテーブルのidか特定できずエラー、となります。
その解決策として下記のように修正してください。

<?php

    public static function getEloquentQuery(): Builder
    {
        $url = $_SERVER['REQUEST_URI'];
        $pattern = '/(\d+|edit)$/';

        // 一覧表示のbuilder(テーブル結合済み)
        $builder = parent::getEloquentQuery()
            ->leftJoin('application_users', 'application_users.id', 'healthcare_records.application_user_id')
            ->leftJoin('record_types', 'record_types.id', 'healthcare_records.type')
            ->select(
                'healthcare_records.*',
                'application_users.name as application_user_name',
                'record_types.name as type_name'
            );
        
        // フィルタ設定済みURLの直接入力(末尾が数字の場合があり得るので先にチェック)
        if (strpos($url, 'tableFilters') !== false) {
            return $builder;
            // URLの末尾が数値またはeditの場合は詳細、編集画面(テーブル結合するとエラー)
        } elseif (preg_match($pattern, $url)) {
            return parent::getEloquentQuery();
        } else {
            // 上記に当てはまらなければ一覧画面
            return $builder;
        }
    }

URIを見てテーブル結合するかしないかを制御しています。
これでエラーは解消するのですが、もっと綺麗に解決できないかと思っています。
要検討です。

一覧画面を作り直したいなと思ったら問題発覚

ヘルスケア記録一覧も見れるようになったのでこれで完了、と言いたいところですが、改めて確認したらまだ見づらいなと思ってしまいました。
一行に一項目しか表示できないのが冗長な気がします。
また、各項目の推移を見るためにフィルタを作りましたが、そもそも下記のような見せ方にすればいいのではと気づきました。

これならフィルタ設定するまでもなく項目ごとに縦に見ていけば推移の確認ができます。
早速作り直していきたいのですが、この一覧画面をFilamentで作るには大きな課題があります。
それは記録項目がカラムになってしまったという点です。
上の方で書いたFilamentの基本が問題になってきます。

ご覧の通りなのですがform()と似たような記載であり、make()にカラム名を渡してその値を表示しています。
Resourceファイル作成時にもモデル名を指定したように、Filamentではテーブルごとにページを作ってそのテーブルを対象にCRUD機能を実装します。

healthcare_recordsのtypeカラムで持っていた記録項目を別個のカラムとして表示しようとしている点がFilamentの基本に反しているというわけです。
どうやって解決するか、がんばってテーブル結合して解決することもできるかもしれないですが、今回はFilamentに合わせて簡単な実装での解決を目指します。

Entity-Attribute-Valueについて

少し寄り道します。
今回のhealthcare_recordsテーブルはデータベース設計のアンチパターンの一つ、EAV(Entity-Attribute-Value)に近いものになります。
EAVはEntityID、属性名(Attribute)、値(Value)の三つで構成されたテーブル設計のことを指します。
EntityIDが無いので厳密には違うのですが(逆にそれがあれば各ヘルスケア記録が紐づいて日毎のレコードとして一覧画面に表示しやすくなるとも言えます)、typeとvalueが属性名と値に当たります。
EVAのデメリットの一つとしてスキーマ情報がわからない(どんな属性がありどんな値になっているかわからない)、詳細な定義もDB側ではできないという点があります。
入力については作成画面のバリデーションで制御ができますが、そもそもどんな属性(身長や心拍数)がどんな値(小数、整数など)で入っているのが正なのかはドキュメントに頼ることになります。
また、Valueのカラムの型は一つに定める必要がありますので心拍数は整数型でいいのに身長に合わせて浮動小数点型にしなければならずデータ量が大きくなるという問題もあります。
(とはいえ精度の細かい方に合わせてhealthcare_recordsのvalueはfloatにすべきだったのでは?という指摘があるかと思いますが、その通りです。今気づきました)
EAVについてはこちらのサイトが詳しいのでご参考までに。

zenn.dev

一覧画面に合わせて新規テーブル作成

さて、本題に戻って問題解決に挑みます。
再三の記載になりますが、Filamentの基本はテーブルを対象にそのカラムの値を作成・表示します。
なので理想の一覧画面に合わせてテーブルを作ってしまいます。
下記の内容でテーブルを作成します。

<?php

    public function up(): void
    {
        Schema::create('all_healthcare_records', function (Blueprint $table) {
            $table->id();
            $table->unsignedInteger('application_user_id')->comment('ユーザーID');
            $table->float('height')->comment('身長');
            $table->float('weight')->comment('体重');
            $table->float('waist')->comment('腹囲');
            $table->integer('systolic_blood_pressure')->comment('最高血圧');
            $table->integer('diastolic_blood_pressure')->comment('最低血圧');
            $table->integer('heart_rate')->comment('心拍数');
            $table->float('body_temperature')->comment('体温');
            $table->date('recorded_at')->comment('記録日');
            $table->timestamps();
        });
    }

Filamentでの一覧画面を作りやすくするためにすべての属性を個別のカラムにすることにしました。
先ほどのサイトにもシングルテーブル継承として記載されている対策です。
このタイミングで身長、体重、体温をfloat型に変更しました。
また、記録日時も今回は一日一回の記録として記録日に変更してdate型にしました。
出来上がった一覧画面がこちら。

目指していた形にはなりました。
ですが、この解決策で本当に良かったでしょうか?
例えばもっと記録項目を増やしたくなったらどうでしょう?
血糖値、歩数、消費カロリー、摂取カロリー、睡眠時間などなど、ヘルスケア記録として登録できそうなものはまだまだあります。
際限無くカラムを追加していくことには下記の懸念事項があります。

  • 一つのテーブルの持つデータの数が多くなりすぎる(1行のデータサイズが大きくなりパフォーマンスに影響が出る)
  • Filamentとしては一覧画面が横に長くなりすぎて見づらい
  • 今回は管理画面のみで対応しているが、APIなどでデータのやり取りをするとなるとパラメータが多く煩雑になる

どうするのが最適かは私も分からないですしケースバイケースだろうなとは思います。
この記事の目的としてはFilamentに合わせて手軽に管理画面を作ることなので実装のしやすさ優先で進めました。
しかし、実際に運用するサービスとなるとパフォーマンスは無視できません。
また、管理画面ありきで他の機能で使いづらいテーブルになっても本末転倒でしょう。
管理画面だけでなくサービス全体を見て判断したいところです。

まとめ

今回はLaravel Filamentを使った管理画面作成について書かせていただきました。
御覧いただいたように基本的な機能は本当に手軽に作れてしまします。
ただし、テーブルを基に画面の表示を行っていくという基本から外れると実装難易度が大きく上がったり、そもそも実装できないということになります。
要件定義やテーブル設計の時点でFilamentのことを意識して、Filamentの機能を活かしきれるか、そもそもFilamentで要件を満たせるかはよく考えたいところだなと思います。

以上、ご覧いただきありがとうございました。
Filament採用の際の参考になれば幸いです。

カヤックボンドでは一緒に技術力を高め合えるエンジニアを募集しています!

kayac.bond