Laravel11 + Vue.js3 + TypeScript + ( Inertia.js + Vuetify ) 開発メモ

久しぶりにやると忘れてしまうので、個人用備忘録。

モデル作成・DB関連

モデルを作る

モデルの生成と、-aオプションはファクトリー・マイグレーション・シーダー・リクエスト・リソースコントローラー・ポリシーなどをまとめて生成

php artisan make:model Customer -a

マイグレーションファイルを編集し、以下を入力する

未処理のマイグレーションを全て実行

php artisan migrate

モデルのfillableに追記する

fillableプロパティは、マスアサインメント(Mass Assignment) という、複数のカラムに一度に値を設定する際に、どの属性を一括で変更できるかを指定する。このプロパティに指定したカラムのみが、ユーザーからの入力を受け取ることができる。

例えば、以下のようにfillableを設定した場合、nameemailのみがマスアサインメントに対応し、それ以外のカラムには直接値を設定できない。

class User extends Model
{
  protected $fillable = ['name', 'email'];
}

なぜfillableが必要か?

Laravel では、リクエストからのデータを直接モデルにインサートや更新する際に マスアサインメント を行うことができる。例えば、以下のようにユーザーが送信したデータを使って、Userモデルを更新する場合。

User::create($request->all());

このとき、$request->all()に含まれるすべてのデータがUserモデルのカラムにマッピングされるが、fillableを設定していないと、意図しないカラム(例えばパスワードや管理者権限など)にも値が割り当てられてしまうリスクがある。

ダミーデータを作る

Factoryを使う

Factoryファイルに生成したいデータを記述する。
参考: https://www.wantedly.com/companies/logical-studio/post_articles/916638

Laravel9以降では、fake()メソッドを使う。

特徴fake()$this->faker
導入バージョンLaravel9以降Laravel8以前
使用範囲グローバルFactoryクラス内部
簡潔さシンプル$thisが必要でやや冗長
インスタンス指定必要無し$this->fakerを参照

Factoryを書いた後に、DatabaseSeeder.phpにて呼び出す。

public function run(): void
{
  $this->call([
    CustomerSeeder::class
  ]);
}

CustomerSeeder.phprunメソッドに以下を追記する。

Customer::factory(1000)->create();

状況にあわせて以下のコマンドを入力する。

特定ファイルだけシーディングしたい場合

php artisan db:seed --class=UserSeeder

全テーブル削除してmigrateして、その後特定ファイルのシーディングする場合

php artisan migrate:fresh --seed --seeder=UserSeeder

全テーブル削除してmigrateして、その後シーディングする場合

php artisan migrate:fresh —seed

UserFactory.phpを作成した上で、DatabaseSeeder.phpに以下のように記述することで、ダミーデータを作成できる。

  • UserFactorydefinition() に記述されたデフォルトデータを基に、create() メソッドでユーザーを作成。
  • nameemail の値を引数として渡すことで、ファクトリーのデフォルト設定をカスタマイズ。
User::factory()->create([
	'name' => 'Test User',
	'email' => 'test@test.com',
]);

Factoryを使わない

DB::table('users')->insert() を使用して直接データベースにレコードを挿入する方法。
UserSeeder.phpを作成し、runメソッド内に以下を記述するか、DatabaseSeeder.phprunメソッドに以下を直接記述する。

複数insertしたい場合は、配列の配列([]内に更に[])を渡す。

DB::table('users')->insert([
    'name' => Str::random(10), // ランダムな10文字の名前
    'email' => Str::random(10).'@example.com', // ランダムなメールアドレス
    'password' => Hash::make('password'), // ハッシュ化されたパスワード
]);

どちらを使うか

項目Factoryを使う方法Factoryを使わない方法(直接挿入)
データ生成の自動化Factory で定義したルールに基づいて生成すべてを手動で指定する必要がある
カスタマイズの柔軟性create() の引数で一部の値を上書き可能挿入するすべての値を自分で記述する
読みやすさ簡潔で読みやすいフィールドごとに手動記述が必要でやや冗長
テストデータ向き主にテストやシーディング用に設計されている小規模で一時的な挿入に適している
  • 少数のレコードを手動で挿入する場合
    DB::table('users')->insert() がシンプルで素早く挿入できる。
  • テストや開発で大量のランダムデータが必要な場合
    Factoryを使うのがおすすめ。

ルーティング設定

モデルやダミーデータを作ったら、それをビューに渡すためにルーティングを設定する。アクセスがあると、ルーティングでリクエストが解決されて、コントローラーからビューにデータが渡される。

Restful

routes/web.phpに以下を追記する。

RESTfulなルーティングを実現するためにRoute::resourceメソッドを使用する。
また、middlewareで設定するauthはログイン認証を、verifiedはメールアドレス認証を意味する。

メールアドレス認証の仕組みでは、ユーザー登録時に登録メールアドレス宛てに確認メールを送信し、リンクをクリックすることで認証が完了する。この認証状況は、usersテーブルのemail_verified_atカラムで管理される。
ただし、初期設定では実装されていない。

Route::resource('customers', CustomerController::class)->middleware(['auth', 'verified']);

以下のコマンドでルーティングの確認を行う。

php artisan route:list

コントローラー関連

indexメソッド

一覧表示などの基本的なアクションは、indexメソッドで処理するのがLaravelのRESTful設計の標準。

Eloquent ORMを使用することで、データベース操作を簡潔なコードで行うことができる。例えば、Customerモデルを使ってデータを取得する場合、Customer::select('id', 'name')->get()Customer::paginate(10)を使って、データベースから必要な情報を取得し、それをビューに渡す。

public function index()
{
  $customers = Customer::select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at'
    )->get();
  
  return Inertia::render('Customers/Index', [
    'customers' => $customers
  ]);
  
  /* Inertiaを使わないなら */
  return view('customers.index', compact('customers'));
  /* Inertiaを使わないなら */
}

ビュー関連

Index.vue

コントローラーのindex()メソッドから渡された値を受け取り、Customer/Index.vueで表示する。
一度、console.logで出力してみる。

<script setup lang="ts">
  import { onMounted } from 'vue';

  const props = defineProps<{
    customers: {id: number, name: string }[]
  }>()
  onMounted(() => {
    console.log(props.customers);
  })
</script>

<template>

</template>

VuetifyのDataTableを使うと簡単にソートやページネーション機能もつけれるが、学習にならないので今回はVuetifyのTableコンポーネントを使って行う。ちなみに、DataTableを使ったコードとレイアウト例は以下。

<script setup lang="ts">
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head } from '@inertiajs/vue3';
  import { DataTableHeader } from '@/types/vuetify'; //型ファイル呼び出し

  const props = defineProps<{
    customers: {id: number, full_name: string, full_name_kana: string, created_at:string}[]
  }>()

  const headers:DataTableHeader[] = [
    { title: 'ID', key: 'id' },
    { title: '名前', key: 'full_name' },
    { title: 'かな', key: 'full_name_kana' },
    { title: '最終来院日', key: 'created_at' },
  ];
</script>

<template>
	<Head title="Dashboard" />

	<AuthenticatedLayout>
		<template #header>
			<h2
				class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
			>
				顧客関連
			</h2>
		</template>

		<div>
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="overflow-hidden bg-white shadow-sm sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row>
              <v-col cols="12">
                <v-data-table
                  :items="customers"
                  :headers="headers"
                  item-key="id"
                >
                </v-data-table>
              </v-col>
            </v-row>
          </v-container>
				</div>
			</div>
		</div>
	</AuthenticatedLayout>
</template>

一覧表示する

Controllerのget()paginate()メソッドに変更する。括弧内は1ページに表示する件数。

  public function index()
  {
    $customers = Customer::select(
        'id',
        DB::raw("CONCAT(last_name, ' ', first_name) AS full_name"),
        DB::raw("CONCAT(last_name_kana, ' ', first_name_kana) AS full_name_kana"),
        'created_at'
    )->paginate(10);
    
    return Inertia::render('Customers/Index', [
      'customers' => $customers,
    ]);
  }

ページネーションを作成する

Inertia.jsを使用したページネーションを実装する。

resources/js/ComponentsディレクトリにPagination.vueを作成し、以下を入力する。

<script setup lang="ts">
import { Link } from '@inertiajs/vue3';

const props = defineProps<{
  links: {
    url: string | null;
    label: string;
    active: boolean;
  }[];
}>()

</script>
<template>
  <div v-if="links.length > 3">
      <div class="flex flex-wrap -mb-1">
        <template v-for="(link, p) in links" :key="p">
          <div v-if="link.url === null"
            class="mr-1 mb-1 px-4 py-3 text-sm leading-4 text-gray-400 border rounded"
            v-html="link.label"
          />
          <Link v-else
            class="mr-1 mb-1 px-4 py-3 text-sm leading-4 border rounded hover:bg-blue-300 focus:border-indigo-500"
            :class="{ 'bg-blue-700 text-white': link.active }"
            :href="link.url"
            v-html="link.label"
          />
        </template>
      </div>
  </div>
</template>

Customers/Index.vueを以下のように編集。

<script setup lang="ts">
  import { onMounted } from 'vue';
  import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
  import { Head } from '@inertiajs/vue3';
  import dayjs from 'dayjs';
  import { LaravelPagination } from '@/types/laravel';
  import Pagination from '@/Components/Pagination.vue'; //Paginationをimport

  type Customer = {
    id: number;
    full_name: string;
    full_name_kana: string;
    created_at: string;
  };
  
  const props = defineProps<{
    customers: LaravelPagination<Customer>;
  }>();
</script>

<template>
	<Head title="Dashboard" />

	<AuthenticatedLayout>
		<template #header>
			<h2
				class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
			>
				顧客関連
			</h2>
		</template>

		<div class="">
			<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
				<div
					class="overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
				>
          <v-container>
            <v-row class="justify-center">
              <v-col cols="10">
                <v-table density="compact">
                  <thead>
                    <tr>
                      <th class="text-left">ID</th>
                      <th class="text-right">名前</th>
                      <th class="text-right">かな</th>
                      <th class="text-right">最終来院日</th>
                    </tr>
                  </thead>
                  <tbody>
                    <tr
                      v-for="item in customers.data" :key="item.id">
                      <td>{{ item.id }}</td>
                      <td class="text-right">{{ item.full_name }}</td>
                      <td class="text-right">{{ item.full_name_kana }}</td>
                      <td class="text-right">{{ dayjs(item.created_at).format("YYYY-MM-DD") }}</td>
                    </tr>
                  </tbody>
                </v-table>
              </v-col>
              <Pagination class="my-6" :links="customers.links" /> <!-- Paginationを追記 -->
            </v-row>
          </v-container>
				</div>
			</div>
		</div>
	</AuthenticatedLayout>
</template>

ページネーションをもう少しカスタマイズしたい方は以下の記事が参考になる。

検索機能を実装する

顧客件数が増えてくるとページも増えてきて追いきれなくなる。
そこで、検索機能を実装する。


to be continued…