emit
は子コンポーネントから親コンポーネントへ イベントを発行する(発信する)ためのものです。
this.$emit()
を使ってイベントを発行します。このイベントは親コンポーネントに通知され、親はそのイベントを リスン(購読) することができます。@
(または v-on
)を使ってリスン(購読)し、そのイベントが発生したときに指定したメソッドを実行します。Child.vue
)子コンポーネントは、何らかのアクション(例えばボタンのクリック)でイベントを発行します。
<template>
<button @click="sendMessage">Send Message</button>
</template>
<script>
export default {
methods: {
sendMessage() {
// 'messageSent' イベントを親に送信する
this.$emit('messageSent', 'Hello from child');
}
}
}
</script>
Parent.vue
)親コンポーネントは、子コンポーネントが発行した messageSent
イベントを購読し、そのデータを受け取って処理します。
<template>
<Child @messageSent="handleMessage" />
</template>
<script>
import Child from './Child.vue';
export default {
components: {
Child
},
methods: {
handleMessage(message) {
console.log('Received from child:', message); // 'Hello from child' と表示される
}
}
}
</script>
this.$emit('messageSent', 'Hello from child')
を実行すると、親コンポーネントは @messageSent="handleMessage"
を使ってこのイベントを購読しているので、handleMessage
メソッドが呼ばれ、message
('Hello from child'
)を受け取ります。emit
は 子コンポーネント から 親コンポーネント へイベントを発行するために使います。@
(または v-on
)でリスン(購読)して、指定したメソッドを実行するという流れです。created
: コンポーネントがインスタンス化され、データのセットアップが完了した時点で呼ばれます。ページ遷移時にも再実行されますが、現在表示されているコンポーネントがすでに表示されていれば、再実行されない場合があります。mounted
: コンポーネントがDOMにマウントされ、画面に描画された後に呼ばれます。SPAでは、ルート遷移によって新しいコンポーネントがマウントされる際に実行されますが、ページ全体のリロードがないため、前のコンポーネントがアンマウントされることなく、新しいコンポーネントがマウントされます。beforeRouteEnter
: Vue Routerを使用している場合、ページ遷移前に実行されるライフサイクルメソッドです。このメソッドはコンポーネントがマウントされる前に呼ばれるため、コンポーネントのDOMにアクセスすることはできませんが、ページ遷移前に処理を挟みたい場合に使用します。beforeRouteLeave
: このメソッドは、ルートを離れるときに呼ばれ、次のルートに遷移する前に実行されます。コンポーネントのアンマウント前に処理をしたい場合に使います。beforeDestroy
/ destroyed
:
beforeDestroy
**はコンポーネントが破棄される直前に呼ばれますが、SPAの場合、ルートが遷移する前に表示されていたコンポーネントが破棄されます。destroyed
**はコンポーネントが完全に破棄された後に呼ばれます。VueのライフサイクルメソッドとVue Routerの組み合わせにより、コンポーネントのライフサイクルはルート遷移に密接に関連します。例えば、Vue Routerの<router-view>
を使用して、ルートが変更されるたびにコンポーネントが動的に切り替わるので、その際にコンポーネントのライフサイクルメソッドが呼ばれます。
created
→ mounted
が呼ばれます。beforeDestroy
→ destroyed
で破棄されます。beforeRouteEnter
やbeforeRouteLeave
はルート遷移時に処理を加えたい場合に有用です。<template>
<div>
<h1>{{ message }}</h1>
<button @click="changeMessage">Change Message</button>
</div>
</template>
<script>
export default {
data() {
return {
message: 'Initial message'
};
},
created() {
console.log('Component created');
},
mounted() {
console.log('Component mounted');
},
methods: {
changeMessage() {
this.message = 'Message updated';
}
},
beforeRouteEnter(to, from, next) {
console.log('Before route enter');
next();
},
beforeRouteLeave(to, from, next) {
console.log('Before route leave');
next();
}
};
</script>
created
→ mounted
が呼ばれます。beforeRouteEnter
→ created
→ mounted
が順番に呼ばれます。beforeRouteLeave
→ beforeDestroy
→ destroyed
の順で破棄されます。SPAでは、コンポーネントのライフサイクルが通常のページ遷移とは異なります。
ページ遷移時にページ全体がリロードされないため、コンポーネントの再マウントやアンマウントはルート遷移に基づいて行われ、created
やmounted
などのライフサイクルメソッドが再実行されます。Vue Router
と組み合わせて、コンポーネントのライフサイクルを細かく制御できるため、SPAでも効率的に状態管理や処理を行うことができます。
ref
を設定しておけば、親コンポーネントから子コンポーネントのメソッドやデータにアクセスできます。
ref
を設定
ref
を設定することで、親コンポーネントからその子コンポーネントを参照できるようになります。this.$refs
を使って、子コンポーネントのインスタンスにアクセスできます。ChildComponent.vue
)<template>
<div>
<p>{{ message }}</p>
<button @click="changeMessage">Change Message</button>
</div>
</template>
<script>
export default {
data() {
return {
message: 'Hello from child!'
}
},
methods: {
changeMessage() {
this.message = 'Message changed by parent!';
}
}
}
</script>
ParentComponent.vue
)<template>
<div>
<ChildComponent ref="childComp" />
<button @click="accessChild">Change Child Message</button>
</div>
</template>
<script>
import ChildComponent from './ChildComponent.vue';
export default {
components: {
ChildComponent
},
methods: {
accessChild() {
// 子コンポーネントのメソッドとデータにアクセス
console.log(this.$refs.childComp.message); // 'Hello from child!'
this.$refs.childComp.changeMessage(); // 子コンポーネントのメソッド呼び出し
console.log(this.$refs.childComp.message); // 'Message changed by parent!'
}
}
}
</script>
this.$refs.childComp
で、子コンポーネントのインスタンスにアクセスできます。this.$refs.childComp.message
で、子コンポーネントのdata
(ここではmessage
)にアクセスできます。this.$refs.childComp.changeMessage()
で、子コンポーネントのmethods
(ここではchangeMessage
)を呼び出すことができます。$refs
は親コンポーネントがマウントされた後でアクセス可能です。つまり、親コンポーネントがレンダリングされる前にアクセスしようとするとundefined
になることがあります。$refs
でアクセスできるのは、子コンポーネントのインスタンスやDOM要素ですが、一般的に子コンポーネントのインスタンスが返されます。ComposerはPHPの依存関係管理ツールです。これを使うことで、プロジェクトに必要なライブラリやパッケージを簡単に管理できます。以下では、composer install
やcomposer update
などの基本的なコマンドの使い方を説明します。
composer install
は、 ファイルに記述されたパッケージ情報を基に、必要な依存関係をインストールします。
初めてプロジェクトをセットアップする際に使用します。このコマンドが実行されると、依存関係がインストールされるとともに、 ファイルが生成されます。
ファイルは、インストールされたパッケージのバージョン情報を正確に記録し、他の開発者が同じ環境を再現できるようにします。
プロジェクトに変更がなければ、二回目以降にcomposer install
を実行しても、 のパッケージ情報は変更されません。
代わりに、 に記載されたバージョンのパッケージがインストールされます。
これにより、同じプロジェクトを複数人で共有する際に、全員が同じバージョンのパッケージを使用することが保証されます。
新しいパッケージをプロジェクトに追加したい場合は、composer require
コマンドを使用します。例えば、guzzlehttp/guzzle
というパッケージを追加したい場合、以下のコマンドを実行します。
composer require guzzlehttp/guzzle
このコマンドを実行すると、composer install
を実行する際に、追加したパッケージがインストールされます。
パッケージを最新のバージョンに更新したい場合は、composer update
コマンドを使用します。例えば、guzzlehttp/guzzle
を最新バージョンにアップデートしたい場合は、以下のコマンドを実行します。
composer update guzzlehttp/guzzle
このコマンドを実行すると、
のパッケージ情報が最新のものに更新され、 にも最新の情報が記載されます。このコマンドを使用することで、依存関係を最新に保つことができます。composer install
は に基づいてパッケージをインストールし、 が生成されます。composer install
を実行すると、 に基づいてパッケージがインストールされ、 の情報は変更されません。composer require
を使用し、 と が更新されます。composer update
を使用し、 と が更新されます依存関係を慎重に扱いたい場合、Composerには便利なコマンドや技術があります。これらを活用することで、依存関係の変更がプロジェクトに与える影響を最小限に抑えることができます。以下では、dry-run
やwhy
コマンドをはじめとする、慎重に依存関係を管理するためのコマンドやバックアップ方法について紹介します。
composer update
コマンドを実行する前に、変更内容を確認するための安全策として--dry-run
オプションを使用できます。このオプションを使うことで、実際にパッケージを更新せずに、どのパッケージがアップデートされるか、どのような変更が行われるかを確認できます。
composer update --dry-run
このコマンドを実行すると、依存関係の変更内容が表示されるので、問題が発生する前に確認できます。
composer why
コマンドは、特定のパッケージがどの依存関係によって要求されているかを調査するための便利なツールです。これを使うことで、特定のパッケージがプロジェクトにどのように影響を与えているかを確認できます。
composer why <package-name>
例えば、guzzlehttp/guzzle
が依存関係に含まれている場合、以下のコマンドでその詳細を確認できます。
composer why guzzlehttp/guzzle
これにより、依存関係がどのパッケージから要求されているのかを調べることができます。
composer show
コマンドを使用すると、現在インストールされているすべてのパッケージとそのバージョンを確認できます。このコマンドは、プロジェクトにどのパッケージがインストールされているかを把握するために役立ちます。
composer show
パッケージ名を指定して、そのパッケージの詳細情報を確認することもできます。
composer show <package-name>
これにより、インストールされているパッケージのバージョンや依存関係を詳細に確認できます。
依存関係を更新する前に、
および ファイルをバックアップすることを強くお勧めします。これにより、万が一の問題が発生した場合に元の状態に戻すことができます。cp composer.json composer.json.bak
cp composer.lock composer.lock.bak
これで、
や に問題が発生した場合に、バックアップファイルを元に戻すことができます。依存関係を慎重に扱いたい場合、以下のコマンドや技術が役立ちます。
composer update --dry-run
を使用して、更新前に変更内容を確認する。composer why
を使って、特定のパッケージがどの依存関係から要求されているかを調べる。composer show
を使って、インストールされているパッケージとそのバージョンを確認する。composer.json
とcomposer.lock
のバックアップを取り、必要に応じて元に戻せるようにする。Vue 3 では、v-model
を使用して親コンポーネントから子コンポーネントにデータを渡し、双方向データバインディングを実現できます。デフォルトでは v-model
は modelValue
を渡しますが、カスタムプロパティ名を使用することも可能です。
<template>
<ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />
</template>
<script setup>
import { ref } from 'vue';
const dialog = ref(false);
const goToCustomerDelete = () => {
console.log("削除を実行しました。");
};
</script>
上記では、dialog
という親コンポーネントのデータが子コンポーネントに渡され、さらに子コンポーネントがデータを更新することで親の状態も変更されます。
<script setup lang="ts">
import { ref, watch } from 'vue';
interface Props {
visible: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(event: "update:visible", value: boolean): void;
(event: "confirm"): void;
}>();
const localVisible = ref(props.visible);
watch(() => props.visible, (newVal) => {
localVisible.value = newVal;
});
const closeDialog = () => {
emit("update:visible", false);
};
const confirmDelete = () => {
emit("confirm");
closeDialog();
};
</script>
<template>
<v-dialog v-model="localVisible" max-width="400px">
<v-card>
<v-card-title class="text-h6">本当に削除しますか?</v-card-title>
<v-card-text>この操作は元に戻せません。</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn @click="closeDialog">キャンセル</v-btn>
<v-btn color="red-darken-1" class="text-none" variant="flat" @click="confirmDelete">削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
props
によるデータ受け取り
dialog
は、props.visible
として受け取ります。emit
によるデータ更新
emit("update:visible", 値)
を発火することで、親コンポーネントのデータを更新します。localVisible
を使用してローカルの状態を管理し、watch
で props.visible
の変更を反映しています。update:<プロパティ名>
のイベントv-model
を使用する場合、子コンポーネントは update:<プロパティ名>
イベントを発火させる必要があります。これにより、親コンポーネントのデータが更新されます。
<ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />
const emit = defineEmits<{
(event: "update:visible", value: boolean): void;
(event: "confirm"): void;
}>();
const closeDialog = () => {
emit("update:visible", false);
};
defineEmits
とイベントの型定義defineEmits
は、子コンポーネントから親コンポーネントへイベントを通知するために使用します。TypeScript を使用する場合、イベント名や引数の型を明示的に定義できます。
const emit = defineEmits<{
(event: "update:visible", value: boolean): void;
(event: "confirm"): void;
}>();
(event: "update:visible", value: boolean): void;
update:visible
イベントには、boolean
型の引数 value
を渡す必要があることを示しています。(event: "confirm"): void;
confirm
イベントは引数を必要としないことを示しています。emit("update:visible", true); // `update:visible` を発火し、true を渡す
emit("confirm"); // `confirm` を発火する
props
を使用します。v-model
と update:<プロパティ名>
を組み合わせます。emit
を使用します。defineEmits
を用いることで、イベントの型を明示的に定義し、コードの可読性と安全性を向上させます。③では編集・削除機能の実装を行う。①、②は以下からどうぞ。
編集画面に顧客情報(customer
)を渡す。この処理においては、CustomerController
の edit
メソッド内で以下のように実装されている。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function edit(Customer $customer)
{
return Inertia::render('Customers/Edit', [
'customer' => $customer
]);
}
}
このコードでは、編集対象の顧客データ($customer
)を Vue コンポーネントである Customers/Edit
に渡している。
なお、edit()
メソッドの処理に関しては、「開発メモ2」で解説した内容と同様。モデルのインジェクションを使用して、指定した customer
を自動的に取得し、編集画面に渡している。
Edit.vue
を以下のように作成する。基本的な構造は Create.vue
と同じだが、編集時に必要な処理を追加している。
ポイント
defineProps
で customer
を受け取るcustomerForm
の初期値を props
から受け取るcustomer
から受け取った情報を設定する。put
を用いて送信するPUT
メソッドを使用し、指定したルートにリクエストを送信する。<script setup lang="ts">
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import TextInput from '@/Components/TextInput.vue';
import TextArea from '@/Components/TextArea.vue';
import InputError from '@/Components/InputError.vue';
import { Core as YubinBangoCore } from "yubinbango-core2";
// customers のデータ構造を定義
type Customer = {
id: number;
last_name: string;
first_name: string;
last_name_kana: string;
first_name_kana: string;
postcode: string;
address: string;
tel: string;
birth: string;
gender: number;
memo: string;
};
const props = defineProps<{ customer: Customer }>();
const customerForm = useForm<Customer>({
id: props.customer.id,
last_name: props.customer.last_name,
first_name: props.customer.first_name,
last_name_kana: props.customer.last_name_kana,
first_name_kana: props.customer.first_name_kana,
postcode: props.customer.postcode,
address: props.customer.address,
tel: props.customer.tel,
birth: props.customer.birth,
gender: props.customer.gender,
memo: props.customer.memo,
})
const fetchAddress = () => {
new YubinBangoCore(String(customerForm.postcode), (value: any) => {
customerForm.address = value.region + value.locality + value.street
})
}
const updateCustomer = () => {
customerForm.put(route('customers.update', {
'customer' : customerForm.id
}))
}
</script>
<template>
<Head title="顧客編集" />
<AuthenticatedLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
>
顧客編集
</h2>
</template>
<div class="mb-10">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="py-5 overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
>
<v-container>
<v-row>
<v-col lg="3"></v-col>
<v-col md="12" lg="6" cols="12">
<form @submit.prevent="updateCustomer">
<InputError class="" :message="customerForm.errors.last_name" />
<InputError class="" :message="customerForm.errors.first_name" />
<div class="d-flex">
<TextInput
label="姓"
placeholder="例:山田"
id="last_name"
v-model="customerForm.last_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full"
required
autofocus
autocomplete="姓"
/>
<TextInput
label="名"
placeholder="例:太郎"
id="first_name"
v-model="customerForm.first_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full ml-2"
required
autocomplete="名"
/>
</div>
<InputError class="" :message="customerForm.errors.last_name_kana" />
<InputError class="" :message="customerForm.errors.first_name_kana" />
<div class="d-flex">
<TextInput
label="姓カナ"
placeholder="例:ヤマダ"
id="last_name_kana"
v-model="customerForm.last_name_kana"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full"
required
autocomplete="姓カナ"
/>
<TextInput
label="名カナ"
placeholder="例:タロウ"
id="first_name_kana"
v-model="customerForm.first_name_kana"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full ml-2"
required
autocomplete="名カナ"
/>
</div>
<InputError class="" :message="customerForm.errors.postcode" />
<TextInput
label="郵便番号"
placeholder="例:1234567"
id="postcode"
v-model="customerForm.postcode"
type="number"
icon="mdi-post-lamp"
class="block w-full"
required
autocomplete="郵便番号"
@change="fetchAddress"
/>
<InputError class="" :message="customerForm.errors.address" />
<TextInput
label="住所"
id="address"
v-model="customerForm.address"
type="text"
icon="mdi-home"
class="block w-full"
required
autocomplete="住所"
/>
<InputError class="" :message="customerForm.errors.tel" />
<TextInput
label="電話番号"
placeholder="例:09876543210"
id="tel"
v-model="customerForm.tel"
type="number"
icon="mdi-phone"
class="block w-full"
required
autocomplete="電話番号"
/>
<InputError class="" :message="customerForm.errors.birth" />
<TextInput
label="誕生日"
id="birth"
v-model="customerForm.birth"
type="date"
icon="mdi-cake"
class="block w-full"
required
autocomplete="誕生日"
/>
<InputError class="" :message="customerForm.errors.gender" />
<v-radio-group v-model="customerForm.gender" inline>
<template v-slot:label>
<div>性別</div>
</template>
<v-radio label="男性" :value="0"></v-radio>
<v-radio label="女性" :value="1" class="ml-2"></v-radio>
<v-radio label="不明" :value="2" class="ml-2"></v-radio>
</v-radio-group>
<InputError class="" :message="customerForm.errors.memo" />
<TextArea
label="メモ"
id="memo"
v-model="customerForm.memo"
icon="mdi-book-open-blank-variant-outline"
class="block w-full"
autocomplete="メモ"
/>
<v-btn :disabled="customerForm.processing" color="blue-darken-1" type="submit" class="text-none" rounded="xs" size="x-large" variant="flat" block>変更する</v-btn>
</form>
</v-col>
</v-row>
</v-container>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
$request->validated()
とすることで、UpdateCustomerRequest
クラスに定義されたバリデーションを通過したデータの配列を取得できる。その配列を update()
メソッドに渡すことで、対応するモデルのデータベースレコードを更新する。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function update(UpdateCustomerRequest $request, Customer $customer)
{
$customer->update($request->validated());
return to_route('customers.index')->with([
'message' => '変更が完了しました!',
'status' => 'success',
]);
}
}
と同じなので割愛。以下を参照。
詳細画面で作成しておいた『編集する』ボタンに、click
イベントを追加する。
以下のテンプレートでは、@click
属性に goToCustomerEdit
メソッドを紐付けている。
<template>
<v-btn color="blue-darken-1" class="text-none w-full" rounded="xs" size="x-large" variant="flat"
@click="goToCustomerEdit">編集する</v-btn>
</template>
goToCustomerEdit()
メソッドを作成し、指定された顧客ID(customerForm.id
)を用いて編集画面に遷移させる処理を実装する。
<script setup lang="ts">
const goToCustomerEdit = () => {
router.get(route('customers.edit', {
'customer': customerForm.id
}))
}
</script>
このコードにより、ボタンをクリックすると該当顧客の編集画面(customers.edit
ルート)に遷移するようになる。
詳細画面で作成しておいた『削除する』ボタンに、click
イベントを追加する。また、削除確認用のダイアログとして ConfirmDeleteDialog
コンポーネントを記述する。
以下のテンプレートでは、@click
イベントが発火すると dialog = true
となり、その状態が ConfirmDeleteDialog
コンポーネントに渡される。
<template>
<v-btn color="red-darken-1 w-full" class="text-none" rounded="xs" size="x-large" variant="flat" @click="dialog = true">
削除する</v-btn>
<ConfirmDeleteDialog v-model:visible="dialog" @confirm="goToCustomerDelete" />
</template>
script setup
内で以下の実装を行う。
dialog
を ref
で定義して、ダイアログの開閉状態を管理する。goToCustomerDelete()
メソッドを作成し、削除対象の顧客ID(customerForm.id
)を利用して削除リクエストを送信する。<script setup lang="ts">
const dialog = ref<boolean>(false);
const goToCustomerDelete = () => {
customerForm.delete(route('customers.destroy', {
'customer' : customerForm.id
}))
dialog.value = false
}
</script>
削除操作を行う際に確認ダイアログを表示するコンポーネント ConfirmDeleteDialog.vue
を作成する。このコンポーネントは以下の機能を提供する。
visible
プロパティを基に、ダイアログを制御する。confirm
イベントを親コンポーネントに通知する。<script setup lang="ts">
import { ref, watch } from 'vue';
interface Props {
visible: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(event: "update:visible", value: boolean) :void
(event: "confirm") :void
}>()
const localVisible = ref(props.visible)
watch(() => props.visible,
(newVal) => {
localVisible.value = newVal
})
const closeDialog = () => {
emit("update:visible", false)
}
const confirmDelete = () => {
emit("confirm")
closeDialog()
}
</script>
<template>
<v-dialog v-model="localVisible" max-width="400px">
<v-card>
<v-card-title class="text-h6">本当に削除しますか?</v-card-title>
<v-card-text>この操作は元に戻せません。</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<!-- キャンセルボタン -->
<v-btn @click="closeDialog">キャンセル</v-btn>
<!-- 削除確認ボタン -->
<v-btn color="red-darken-1" class="text-none" variant="flat" @click="confirmDelete">削除</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
顧客情報を管理する際、削除フラグ(delete_flg
)を用いた論理削除を採用することで、データの物理削除を防ぎ、安全性を確保する方法をとる。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
public function destroy(Customer $customer)
{
$customer->delete_flg = 1;
$customer->save();
return to_route('customers.index')->with([
'message' => '削除が完了しました!',
'status' => 'success',
]);
}
}
論理削除の仕組みを実装したが、現在、顧客一覧には delete_flg
に関係なく全てのデータが取得されている。
これを改善し、削除フラグが 0(未削除)のデータのみを取得するようにする。
一時的に Controller
の index
メソッドに条件を記述する方法もあるが、削除済みデータを基本的に扱わないので、Customer
モデルに グローバルスコープ を追加する。
これにより、モデルを通じたデータ操作全般で、自動的に delete_flg = 0
の条件が適用されるようになる。
以下のコマンドを実行してスコープクラスを作成する。
php artisan make:scope CustomerActiveScope
作成されたクラス内の apply()
メソッドに、削除済みデータを除外する条件を記述する。
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class CustomerActiveScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('delete_flg', 0);
}
}
スコープをモデルに登録する。ここでは、ScopedByアトリビュート を使用する。
<?php
namespace App\Models;
use App\Models\Scopes\CustomerActiveScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([CustomerActiveScope::class])]
class Customer extends Model
{
//略
}
補足: booted()
メソッドを使用する場合
ScopedByアトリビュートを使用せず、booted()
メソッドをオーバーライドして登録する方法もある。
<?php
namespace App\Models;
use App\Models\Scopes\CustomerActiveScope;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
/**
* モデルの起動時にグローバルスコープを追加
*/
protected static function booted(): void
{
static::addGlobalScope(new CustomerActiveScope);
}
}
これで、Customer
モデルを使用する全てのクエリで、delete_flg = 0
の条件が自動的に適用される。
選択肢 | メリット | デメリット |
---|---|---|
グローバルスコープ | ・コードの重複を減らせる ・安全性が向上する ・一貫した挙動を実現 | ・柔軟性が低下する ・必要に応じてスコープを無効化する手間 |
ローカルスコープ | ・必要なときだけ適用可能 ・柔軟性が高い | ・コードの記述量が増える |
クエリに直接記述 | ・完全に制御可能 | ・冗長になりやすい ・誤りが起きやすい |
②では登録・詳細機能の実装を行う。①は以下からどうぞ。
View側から作成する。
を作成し、投稿画面を以下の様に作成。
<script setup lang="ts">
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm } from '@inertiajs/vue3';
import TextInput from '@/Components/TextInput.vue';
import TextArea from '@/Components/TextArea.vue';
import InputError from '@/Components/InputError.vue';
import { Core as YubinBangoCore } from "yubinbango-core2";
// customers のデータ構造を定義
type Customer = {
last_name: string;
first_name: string;
last_name_kana: string;
first_name_kana: string;
postcode: string;
address: string;
tel: string;
birth: string;
gender: number;
memo: string;
};
const customerForm = useForm<Customer>({
last_name: "",
first_name: "",
last_name_kana: "",
first_name_kana: "",
postcode: "",
address: "",
tel: "",
birth: "",
gender: 0,
memo: "",
})
const fetchAddress = () => {
new YubinBangoCore(String(customerForm.postcode), (value: any) => {
customerForm.address = value.region + value.locality + value.street
})
}
const storeCustomer = () => {
customerForm.post(route('customers.store'))
}
</script>
<template>
<Head title="顧客登録" />
<AuthenticatedLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
>
顧客登録
</h2>
</template>
<div class="mb-10">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="py-5 overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
>
<v-container>
<v-row>
<v-col lg="3"></v-col>
<v-col md="12" lg="6" cols="12">
<form @submit.prevent="storeCustomer">
<InputError :message="customerForm.errors.last_name" />
<InputError :message="customerForm.errors.first_name" />
<div class="d-flex">
<TextInput
label="姓"
placeholder="例:山田"
id="last_name"
v-model="customerForm.last_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full"
required
autofocus
/>
<TextInput
label="名"
placeholder="例:太郎"
id="first_name"
v-model="customerForm.first_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full ml-2"
required
/>
</div>
<InputError :message="customerForm.errors.last_name_kana" />
<InputError :message="customerForm.errors.first_name_kana" />
<div class="d-flex">
<TextInput
label="姓カナ"
placeholder="例:ヤマダ"
id="last_name_kana"
v-model="customerForm.last_name_kana"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full"
required
/>
<TextInput
label="名カナ"
placeholder="例:タロウ"
id="first_name_kana"
v-model="customerForm.first_name_kana"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full ml-2"
required
/>
</div>
<InputError :message="customerForm.errors.postcode" />
<TextInput
label="郵便番号"
placeholder="例:1234567"
id="postcode"
v-model="customerForm.postcode"
type="number"
icon="mdi-post-lamp"
class="block w-full"
required
@change="fetchAddress"
/>
<InputError :message="customerForm.errors.address" />
<TextInput
label="住所"
id="address"
v-model="customerForm.address"
type="text"
icon="mdi-home"
class="block w-full"
required
/>
<InputError :message="customerForm.errors.tel" />
<TextInput
label="電話番号"
placeholder="例:09876543210"
id="tel"
v-model="customerForm.tel"
type="number"
icon="mdi-phone"
class="block w-full"
required
/>
<InputError :message="customerForm.errors.birth" />
<TextInput
label="誕生日"
id="birth"
v-model="customerForm.birth"
type="date"
icon="mdi-cake"
class="block w-full"
required
/>
<InputError :message="customerForm.errors.gender" />
<v-radio-group v-model="customerForm.gender" inline>
<template v-slot:label>
<div>性別</div>
</template>
<v-radio label="男性" :value="0"></v-radio>
<v-radio label="女性" :value="1" class="ml-2"></v-radio>
<v-radio label="不明" :value="2" class="ml-2"></v-radio>
</v-radio-group>
<InputError :message="customerForm.errors.memo" />
<TextArea
label="メモ"
id="memo"
v-model="customerForm.memo"
icon="mdi-book-open-blank-variant-outline"
class="block w-full"
/>
<v-btn :disabled="customerForm.processing" color="blue-darken-1" type="submit" class="text-none" rounded="xs" size="x-large" variant="flat" block>登録する</v-btn>
</form>
</v-col>
</v-row>
</v-container>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
開発メモ①に引き続き、vuetifyにてUIは構築し、formはInertiaのuseFormを用いる。
form.errors.****とすることで、バリデーションエラーをpropsから取得できる。
郵便番号入力はyubinbango-core2というライブラリを用いた。
、 はデフォルトをものをvuetifyに書き換えて使用。 は新規に作成。
v-radioはコンポーネント化検討中。これらのコンポーネントは以下のgithubを参照。
CustomersモデルはRestful設計に基づいて作成しているので新たにルーティング設定は必要ない。
確認する場合は、php artisan route:listにて一覧表示する。
php artisan route:list
~略~
POST customers …………………customers.store › CustomerController@store
~略~
この表示となるので、投稿ボタン押下時にはcustomers.storeが走るようにし、 のstore()メソッドに処理を記述する。
以下の様にstore()メソッドに追記する。
最初にdd()を用いて から投稿をテストすると送られてくるデータを見れる。
確認できたらdd()メソッドは消しておく。
<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
/**
* Store a newly created resource in storage.
*/
public function store(StoreCustomerRequest $request)
{
dd($request->all()); //確認後、コメントアウトする
Customer::create([
'last_name' => $request->last_name,
'first_name' => $request->first_name,
'last_name_kana' => $request->last_name_kana,
'first_name_kana' => $request->first_name_kana,
'postcode' => $request->postcode,
'address' => $request->address,
'tel' => $request->tel,
'birth' => $request->birth,
'gender' => $request->gender,
'memo' => $request->memo,
]);
return to_route('customers.index')->with([
'message' => '登録が完了しました!',
'status' => 'success',
]);
}
}
create()メソッドを用いる場合は、モデルで$fillableを設定する(開発メモ①参照・または$guraded)。
$fillableに記述されている属性だけが、create()やupdate()で代入・保存される。
メソッド | 説明 | 使い方 | 特徴 |
---|---|---|---|
create() | 新しいレコードを一度に挿入する。配列を使って一括代入。 | $customer = Customer::create([ ‘name’ => ‘John Doe’, ‘email’ => ‘john@example.com’ ]); | 一度の呼び出しで新規レコードを挿入。 $fillable が必要。 |
save() | モデルインスタンスを保存(新規挿入または既存のレコードを更新)。 | $customer = new Customer(); $customer->name = ‘John Doe’; $customer->save(); | 既存のインスタンスに対して挿入または更新。 |
update() | 既存のレコードを更新。配列で一括更新。 | $customer = Customer::find(1); $customer->update([ ‘phone’ => ‘987-654-3210’ ]); | 一括更新専用。既存レコードの更新のみ。 |
firstOrCreate() | 条件に一致するレコードがあれば返し、一致しなければ新規作成。 | $customer = Customer::firstOrCreate([ ‘email’ => ‘john@example.com’ ], [‘name’ => ‘John Doe’]); | 条件が一致すれば既存レコードを、なければ新規作成。 |
updateOrCreate() | 条件に一致するレコードがあれば更新し、一致しなければ新規作成。 | $customer = Customer::updateOrCreate([ ‘email’ => ‘john@example.com’ ], [‘phone’ => ‘987-654-3210’]); | 条件一致で更新、なければ新規作成。 |
find() | 主キー(通常はID)を指定してレコードを取得。 | $customer = Customer::find(1); | 主キーでレコードを検索、見つからなければnull を返す。 |
findOrFail() | 主キーでレコードを取得。見つからない場合は例外をスロー。 | $customer = Customer::findOrFail(1); | 見つからなければ例外が発生。 |
get() | 条件に一致する複数のレコードを取得。 | $customers = Customer::where(‘status’, ‘active’)->get(); | 複数レコードを取得、コレクションを返す。 |
投稿後は顧客一覧ページに戻る。Inertia::renderはページ描画用なので基本的にredirect()か、to_route()を用いる(to_route()はLaravel9以降)。
更に、with()を用いてフラッシュメッセージを渡している(フラッシュメッセージは に追記が必要なので後述)。
formRequestクラスでバリデーションを行う。
以下の記事がとてもわかりやすいので参考に。
authorizeはデフォルトでfalseになっているので、trueに変更し、rulesに記述するバリデーションチェックをできるようにする。
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCustomerRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'last_name' => ['required', 'max:50'],
'first_name' => ['required', 'max:50'],
'last_name_kana' => ['required', 'regex:/^[ァ-ヾ]+$/u', 'max:50'],
'first_name_kana' => ['required', 'regex:/^[ァ-ヾ]+$/u', 'max:50'],
'postcode' => ['required', 'max:7'],
'address' => ['required', 'max:100'],
'tel' => ['required', 'max:20'],
'birth' => ['required', 'date'],
'gender' => ['required'],
'memo' => ['max:1000'],
];
}
}
は、バリデーションエラー時に生成されるエラーメッセージの中で、フィールド名を分かりやすく表示するために使う。
attributes
配列に、必要な入力項目名を日本語で追記する。
たとえば、新しいフォームでcompany
という項目がある場合、‘company’ => ‘会社名’と記述する。
'attributes' => [
'password' => 'パスワード',
'name' => '名',
'title' => '件名',
'gender' => '性別',
'age' => '年齢',
'contact' => 'お問い合わせ内容',
'caution' => '注意事項',
'content' => '本文',
'memo' => 'メモ',
'price' => '料金',
'kana' => 'カナ',
'tel' => '電話番号',
'email' => 'メールアドレス',
'postcode' => '郵便番号',
'address' => '住所',
'birth' => '誕生日',
'gender' => '性別',
'last_name' => '姓',
'first_name' => '名前',
'last_name_kana' => '姓カナ',
'first_name_kana' => '名カナ',
],
このミドルウェアは、サーバーサイドのデータをフロントエンド側(Viewファイル)に渡す際の「共通データの管理」を行う。
Laravelでは、すべてのInertiaリクエストがこのミドルウェアを通過し、share()
メソッドを使用して共有データを設定する。この共有データはInertiaのprops
としてVueやReactなどで参照できるようになる。
フラッシュメッセージは、コントローラーでwith()
を使用してセッションに保存される。with()
はセッションに一時的なデータ(フラッシュデータ)を保存し、次のリクエスト時に自動的に破棄される仕組み。
public function store(StoreCustomerRequest $request)
{
// データの保存処理
// ...
// フラッシュメッセージをセッションに保存
return to_route('customers.index')->with([
'message' => '登録が完了しました!',
'status' => 'success',
]);
}
ここでwith('message', 'メッセージ')
を使うと、セッションにmessage
というキーでメッセージが保存される。
セッションに保存されたデータ(フラッシュメッセージなど)をフロントエンドに渡すために、share
メソッドを使用する。
以下では、セッションのmessageやstatus
をflash
というキーにまとめてInertiaのprops
に追加している。
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function share(Request $request): array
{
return [
...parent::share($request),
'auth' => [
'user' => $request->user(),
],
'flash' => [
'message' => fn() => $request->session()->get('message'),
'status' => fn() => $request->session()->get('status'),
],
];
}
}
このコードでは、
session('message')
を呼び出し、セッションに保存されたフラッシュデータを取得。props
に flash
キーとして追加。props.flash.message
としてアクセスできるようになる。Inertia.jsはLaravelのセッションやBladeテンプレートを直接使わず、フロントエンドにすべてのデータをprops
として渡す。そのため、セッションデータ(フラッシュメッセージ)もprops
に追加し、クライアント側で動的に表示する必要がある。
フロントエンドでフラッシュメッセージを表示するには、Inertia.jsのusePage()
を使用する。
に を作成する。
vuetifyのv-snackbarを用いる。v-ifでは動かなかったため、v-modelを用いて、statusがsuccessであれば表示するようにしている。
<script setup lang="ts">
import { usePage } from '@inertiajs/vue3';
import { ref } from 'vue';
const flash = usePage().props.flash;
// 初期状態でSnackbarを表示するかどうかを設定
const isSnackbarVisible = ref(flash?.status === 'success');
</script>
<template>
<v-snackbar v-model="isSnackbarVisible" color="success" location="top center" timeout="2500">
{{ flash?.message }}
</v-snackbar>
</template>
flashプロパティの型が存在しないと怒られるので、
に追記。import { PageProps as InertiaPageProps } from '@inertiajs/core';
import { AxiosInstance } from 'axios';
import { route as ziggyRoute } from 'ziggy-js';
import { PageProps as AppPageProps } from './';
declare global {
interface Window {
axios: AxiosInstance;
}
/* eslint-disable no-var */
var route: typeof ziggyRoute;
}
declare module 'vue' {
interface ComponentCustomProperties {
route: typeof ziggyRoute;
}
}
declare module '@inertiajs/core' {
interface PageProps extends InertiaPageProps, AppPageProps {
flash?: { //=========ここを追記=========
message?: string; //=========ここを追記=========
status?: string; //=========ここを追記=========
}
}
}
顧客登録画面への遷移ボタンと、FlashMessage.vueコンポーネントを表示するため を編集する。
importし、適当な場所に配置する。
全ページで表示する場合は、共通レイアウトのファイルで配置すれば良い。
<script setup lang="ts">
import FlashMessage from '@/Components/FlashMessage.vue';
</script>
<template>
<FlashMessage />
</template>
InertiaのLinkコンポーネントを用いてroute指定する。
<template>
<Link :href="route('customers.create')"><v-btn color="blue" class="w-full">顧客登録</v-btn></Link>
</template>
全てできたら、実際に登録画面へ移動し、入力して登録ボタンを押してみる。
一覧画面に戻り、フラッシュメッセージが表示されたら完了。
store()メソッドにて顧客情報を取得し、View側へ渡す。
の<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreCustomerRequest;
use App\Http\Requests\UpdateCustomerRequest;
use App\Models\Customer;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\Request;
class CustomerController extends Controller
{
/**
* Show the form for editing the specified resource.
*/
public function edit(Customer $customer)
{
return Inertia::render('Customers/Edit', [
'customer' => $customer
]);
}
}
動作の流れ(内部ロジック)
/customers/{customer}
のように定義されていると、{customer}
に渡されるパラメータがモデルのid
とみなされる。Customer
モデルに対応するEloquentクエリを発行する。
Customer::where('id', $id)->firstOrFail()
と同じ処理が裏で行われる。404 Not Found
エラーを自動で返す。顧客詳細画面を構成するVueコンポーネントを作成する。
編集する・削除するボタンは配置のみ。実装は後述。
<script setup lang="ts">
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm, router } from '@inertiajs/vue3';
import DisplayTextField from '@/Components/DisplayTextField.vue';
// customers のデータ構造を定義
type Customer = {
id: number;
last_name: string;
first_name: string;
last_name_kana: string;
first_name_kana: string;
postcode: string;
address: string;
tel: string;
birth: string;
gender: number;
memo: string;
};
const props = defineProps<{ customer: Customer }>();
const customerForm = useForm<Customer>({
id: props.customer.id,
last_name: props.customer.last_name,
first_name: props.customer.first_name,
last_name_kana: props.customer.last_name_kana,
first_name_kana: props.customer.first_name_kana,
postcode: props.customer.postcode,
address: props.customer.address,
tel: props.customer.tel,
birth: props.customer.birth,
gender: props.customer.gender,
memo: props.customer.memo,
})
// const goToCustomerEdit = () => {
// router.get(route('customers.edit', {
// 'customer': customerForm.id
// }))
//}
// const deleteCustomer = () => {
// customerForm.delete(route('customers.destroy', {
// 'customer' : customerForm.id
// }))
// }
</script>
<template>
<Head title="顧客詳細" />
<AuthenticatedLayout>
<template #header>
<h2
class="text-xl font-semibold leading-tight text-gray-800 dark:text-gray-200"
>
顧客詳細
</h2>
</template>
<div class="mb-10">
<div class="mx-auto max-w-7xl sm:px-6 lg:px-8">
<div
class="py-5 overflow-hidden bg-white shadow-lg sm:rounded-lg dark:bg-gray-800"
>
<v-container>
<v-row>
<v-col lg="3"></v-col>
<v-col md="12" lg="6" cols="12">
<div class="d-flex">
<DisplayTextField
label="姓"
id="last_name"
v-model="customerForm.last_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full"
readonly
/>
<DisplayTextField
label="名"
id="first_name"
v-model="customerForm.first_name"
type="text"
icon="mdi-card-bulleted-outline"
class="block w-full ml-2"
readonly
/>
</div>
<!-- 〜〜一部略〜〜 -->
<!-- 〜〜一部略〜〜 -->
<!-- 〜〜一部略〜〜 -->
<DisplayTextField
label="郵便番号"
id="postcode"
v-model="customerForm.postcode"
type="number"
icon="mdi-post-lamp"
class="block w-full"
/>
<!-- 〜〜一部略〜〜 -->
<!-- 〜〜一部略〜〜 -->
<!-- 〜〜一部略〜〜 -->
<div class="mb-5">
<v-list-item-subtitle>性別</v-list-item-subtitle>
<v-list-item-title v-if="customerForm.gender === 0">男性</v-list-item-title>
<v-list-item-title v-if="customerForm.gender === 1">女性</v-list-item-title>
<v-list-item-title v-if="customerForm.gender === 2">その他</v-list-item-title>
</div>
<v-textarea
label="メモ"
id="memo"
v-model="customerForm.memo"
icon="mdi-book-open-blank-variant-outline"
class="block w-full"
variant="plain"
readonly
/>
</v-col>
</v-row>
<v-row>
<v-col lg="3"></v-col>
<v-col md="12" lg="6" cols="12">
<v-btn color="blue-darken-1" class="text-none w-full" rounded="xs" size="x-large" variant="flat">編集する</v-btn>
</v-col>
</v-row>
<v-row>
<v-col lg="3"></v-col>
<v-col md="12" lg="6" cols="12">
<v-btn color="red-darken-1 w-full" class="text-none" rounded="xs" size="x-large" variant="flat">削除する</v-btn>
</v-col>
</v-row>
</v-container>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
v-modelを用いて表示しつつ、readonlyを用いて、表示のみとしている。
<script setup lang="ts">
const props = withDefaults(
defineProps<{
label?: string;
type?: string;
icon?: string;
}>(),
{
type: 'text', // type のデフォルト値
icon: 'mdi-home', // icon のデフォルト値
},
);
const model = defineModel<string>({ required: true });
</script>
<template>
<v-text-field :label="label" v-model="model" :type="type" variant="plain" class="text-field-display" readonly/>
</template>
<style scoped>
.text-field-display .v-input__control {
border: none; /* 囲いをなくす */
background: none; /* 背景を透明に */
color: black; /* 文字色を黒に */
cursor: default; /* クリック可能でない見た目 */
pointer-events: none; /* 完全な表示専用 */
}
</style>
顧客一覧画面(
)に詳細画面へのリンクを追加する。goToCustomerShow()と、tbody内trタグへ@clickとclassを追記。更にstyleも追記する。
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
// 詳細画面へ遷移
const goToCustomerShow = (id: number) => {
router.get(route('customers.show', { customer: id }))
}
</script>
<template>
<tbody>
<tr
v-for="item in customers.data" :key="item.id" @click="goToCustomerShow(item.id)" class="hoverable-row">
<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>
</template>
<style scoped>
.custom-table .hoverable-row:hover {
background-color: #e6f7ff;
cursor: pointer;
}
</style>
Inertiaのrouterを用いてページ遷移を行う。
久しぶりにやると忘れてしまうので、個人用備忘録。
モデルの生成と、-aオプションはファクトリー・マイグレーション・シーダー・リクエスト・リソースコントローラー・ポリシーなどをまとめて生成
php artisan make:model Customer -a
マイグレーションファイルを編集し、以下を入力する
未処理のマイグレーションを全て実行
php artisan migrate
fillable
プロパティは、マスアサインメント(Mass Assignment) という、複数のカラムに一度に値を設定する際に、どの属性を一括で変更できるかを指定する。このプロパティに指定したカラムのみが、ユーザーからの入力を受け取ることができる。
例えば、以下のようにfillable
を設定した場合、name
とemail
のみがマスアサインメントに対応し、それ以外のカラムには直接値を設定できない。
class User extends Model
{
protected $fillable = ['name', 'email'];
}
Laravel では、リクエストからのデータを直接モデルにインサートや更新する際に マスアサインメント を行うことができる。例えば、以下のようにユーザーが送信したデータを使って、User
モデルを更新する場合。
User::create($request->all());
このとき、$request->all()
に含まれるすべてのデータがUser
モデルのカラムにマッピングされるが、fillable
を設定していないと、意図しないカラム(例えばパスワードや管理者権限など)にも値が割り当てられてしまうリスクがある。
Factoryファイルに生成したいデータを記述する。
参考: https://www.wantedly.com/companies/logical-studio/post_articles/916638
Laravel9以降では、fake()メソッドを使う。
特徴 | fake() | $this->faker |
---|---|---|
導入バージョン | Laravel9以降 | Laravel8以前 |
使用範囲 | グローバル | Factoryクラス内部 |
簡潔さ | シンプル | $thisが必要でやや冗長 |
インスタンス指定 | 必要無し | $this->fakerを参照 |
Factoryを書いた後に、
にて呼び出す。public function run(): void
{
$this->call([
CustomerSeeder::class
]);
}
runメソッドに以下を追記する。
の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
の definition()
に記述されたデフォルトデータを基に、create()
メソッドでユーザーを作成。name
や email
の値を引数として渡すことで、ファクトリーのデフォルト設定をカスタマイズ。User::factory()->create([
'name' => 'Test User',
'email' => 'test@test.com',
]);
DB::table('users')->insert()
を使用して直接データベースにレコードを挿入する方法。
を作成し、runメソッド内に以下を記述するか、 のrunメソッドに以下を直接記述する。
複数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()
がシンプルで素早く挿入できる。モデルやダミーデータを作ったら、それをビューに渡すためにルーティングを設定する。アクセスがあると、ルーティングでリクエストが解決されて、コントローラーからビューにデータが渡される。
に以下を追記する。
RESTfulなルーティングを実現するためにRoute::resource
メソッドを使用する。
また、middleware
で設定するauth
はログイン認証を、verified
はメールアドレス認証を意味する。
メールアドレス認証の仕組みでは、ユーザー登録時に登録メールアドレス宛てに確認メールを送信し、リンクをクリックすることで認証が完了する。この認証状況は、users
テーブルのemail_verified_at
カラムで管理される。
ただし、初期設定では実装されていない。
Route::resource('customers', CustomerController::class)->middleware(['auth', 'verified']);
以下のコマンドでルーティングの確認を行う。
php artisan route:list
一覧表示などの基本的なアクションは、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()メソッドから渡された値を受け取り、 で表示する。
一度、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を使用したページネーションを実装する。
ディレクトリに を作成し、以下を入力する。
<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>
を以下のように編集。
<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>
ページネーションをもう少しカスタマイズしたい方は以下の記事が参考になる。
名前とカナで顧客検索ができる機能の実装を行う。
Customerモデルにローカルスコープを記述する。
以下はscopeSearchByNameというローカルスコープを記述し、これをコントローラーで呼び出す。
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Customer extends Model
{
/** @use HasFactory<\Database\Factories\CustomerFactory> */
use HasFactory;
protected $fillable = ['last_name', 'first_name', 'last_name_kana', 'first_name_kana', 'postcode', 'address', 'tel', 'birth', 'gender', 'memo', 'delete_flg'];
public function scopeSearchByName($query, $keyword)
{
if ($keyword) {
return $query->where(function ($query) use ($keyword) {
$query->where('last_name', 'LIKE', '%' . $keyword . '%')
->orWhere('first_name', 'LIKE', '%' . $keyword . '%')
->orWhere('last_name_kana', 'LIKE', '%' . $keyword . '%')
->orWhere('first_name_kana', 'LIKE', '%' . $keyword . '%');
});
}
return $query;
}
}
ローカルスコープを使用することで、特定の条件を簡単にクエリに適用することができ、コードの可読性や再利用性が向上する。
スコープメソッド名はscope
で始め、その後にキャメルケースで続ける。
scopeという文字を除いたメソッド名でローカルスコープを記述し、引数を指定する。例えば以下のコードでは、山田というワードで検索をかけ、dd()でデバッグしている。実際に山田の検索結果が得られれば成功。
を以下の様に、class CustomerController extends Controller
{
public function index(Request $request)
{
$customers = Customer::searchByName('山田')
->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);
dd($customers)
return Inertia::render('Customers/Index', [
'customers' => $customers,
]);
}
}
実際には入力された値が渡るようにしたいので、以下のように修正する。
class CustomerController extends Controller
{
public function index(Request $request)
{
$keyword = $request->searchKeyword ?? ''; //=====ここを追記======
$customers = Customer::searchByName($keyword) //=====引数を$keywordに======
->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);
//dd($customers)
return Inertia::render('Customers/Index', [
'customers' => $customers,
]);
}
}
次にView側、
Enterキーでも検索を実行させたいので、InertiaのuseFormを用いることにした。
検索用のTextFieldにはVuetifyを用いる。buttonかEnterキーを押下すると、searchByName()が走り、getメソッドにてフォーム送信される。パラメータはsearchKeywordとして送られる(コントローラー側で$request->searchKeywordで取得する)。
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
// 型定義
type SearchForm = {
searchKeyword?: string;
};
// searchKeywordをリアクティブにする
const searchForm = useForm<SearchForm>({
searchKeyword: ""
})
// 動的にクエリパラメータを生成
const queryParams = computed(() => {
const params:Record<string, string> = {}
if (searchForm.searchKeyword) {
params.searchKeyword = searchForm.searchKeyword;
}
return params;
});
const searchByName = () => {
searchForm.get(route('customers.index', queryParams.value))
}
</script>
<template>
<form @submit.prevent class="w-100">
<v-text-field density="compact" label="顧客検索" variant="solo" hide-details single-line v-model="searchForm.searchKeyword">
<template #append-inner>
<button @click="searchByName" type="submit" class="icon-button">
<v-icon>mdi-magnify</v-icon>
</button>
</template>
</v-text-field>
</form>
</template>
次に、ページネーションでページ移動すると検索結果が消えてしまうのでその修正と、検索結果画面において、検索ワードも消えないように保持を行う。
を以下の様に編集する。
class CustomerController extends Controller
{
public function index(Request $request)
{
$keyword = $request->searchKeyword ?? '';
$customers = Customer::searchByName($keyword)
->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);
->withQueryString(); //=====ここを追記======
//dd($customers)
return Inertia::render('Customers/Index', [
'customers' => $customers,
'keyword' => $keyword //=====ここを追記======
]);
}
}
withQueryString()をつけることによって、ページネーションでページ移動した際もクエリが保持される。
$keywordをView側に渡したので、 に以下を追記する。これで検索ワードを保持してくれる。
<script setup lang="ts">
import { onMounted } from 'vue';
const props = defineProps<{
customers: LaravelPagination<Customer>;
keyword: string; //=====ここを追記======
}>();
onMounted(() => {
searchForm.searchKeyword = props.keyword //=====ここを追記======
})
</script>
次に、最終来院日でのソート機能を実装する。index()に以下を追記する。
クエリパラメータでsortValが送られてきて、それを取得する。ascかdescかに応じてorderBy()でソートをかける。
最終的に$sortを返却する。
public function index(Request $request)
{
$keyword = $request->searchKeyword ?? '';
$sort = $request->sortVal ?? ''; //=====ここを追記======
$customers = Customer::searchByName($keyword)
->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')
->when($sort === 'asc', function ($query) { //=====ここを追記======
$query->orderBy('created_at', 'asc')->orderBy('id', 'asc'); //=====ここを追記======
}, function ($query) { //=====ここを追記======
$query->orderBy('created_at', 'desc')->orderBy('id', 'desc'); //=====ここを追記======
}) //=====ここを追記======
->paginate(10)
->withQueryString();
return Inertia::render('Customers/Index', [
'customers' => $customers,
'keyword' => $keyword,
'sort' => $sort //=====ここを追記======
]);
}
View側での処理を行う。queryParams()は既存のものを使い、追記する。
queryParams()では顧客検索のクエリパラメータも同時に送れるようにし、保持できるようにしている。
<script setup lang="ts">
// 型定義
type SortForm = {
sortVal: 'asc' | 'desc';
};
// sortValをリアクティブにする
const sort = useForm<SortForm>({
sortVal: "desc"
})
// ソートアイコンの切り替え
const getSortIcon = () => {
return sort.sortVal === 'asc' ? "mdi-menu-up" : "mdi-menu-down";
}
// 動的にクエリパラメータを生成
const queryParams = computed(() => {
const params:Record<string, string> = {}
if (searchForm.searchKeyword) {
params.searchKeyword = searchForm.searchKeyword;
}
if (sort.sortVal) { // =======既存のqueryParamsに追記する=======
params.sortVal = sort.sortVal; // =======既存のqueryParamsに追記する=======
} // =======既存のqueryParamsに追記する=======
return params;
});
// ソート押下時処理
const sortHandler = () => {
sort.sortVal = sort.sortVal === "asc" ? "desc" : "asc";
sort.get(route('customers.index', queryParams.value))
}
</script>
<template>
<!-- 省略 -->
<thead>
<tr>
<th class="text-left">ID</th>
<th class="text-right">名前</th>
<th class="text-right">かな</th>
<th class="text-right">
<button @click="sortHandler"> <!-- =======buttonを追加======= -->
最終来院日
<v-icon>{{ getSortIcon() }}</v-icon> <!-- =======buttonを追加======= -->
</button> <!-- =======buttonを追加======= -->
</th>
</tr>
</thead>
<!-- 省略 -->
</template>
完成系のコードは以下。
<script setup lang="ts">
import { onMounted, computed } from 'vue';
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout.vue';
import { Head, useForm, Link } from '@inertiajs/vue3';
import dayjs from 'dayjs';
import { LaravelPagination } from '@/types/laravel';
import Pagination from '@/Components/Pagination.vue';
// customers のデータ構造を定義
type Customer = {
id: number;
full_name: string;
full_name_kana: string;
created_at: string;
};
// フォームの型を定義
type SearchForm = {
searchKeyword?: string;
};
type SortForm = {
sortVal: 'asc' | 'desc';
};
// props管理
// LaravelPagination<Customer> を使用
const props = defineProps<{
customers: LaravelPagination<Customer>;
keyword: string;
sort: 'asc' | 'desc';
}>();
// フォームデータ管理
const searchForm = useForm<SearchForm>({
searchKeyword: ""
})
const sort = useForm<SortForm>({
sortVal: "desc"
})
// ソートアイコンの切り替え
const getSortIcon = () => {
return sort.sortVal === 'asc' ? "mdi-menu-up" : "mdi-menu-down";
}
// 動的にクエリパラメータを生成
const queryParams = computed(() => {
const params:Record<string, string> = {}
if (searchForm.searchKeyword) {
params.searchKeyword = searchForm.searchKeyword;
}
if (sort.sortVal) {
params.sortVal = sort.sortVal;
}
return params;
});
// GET送信用関数
const searchByName = () => {
searchForm.get(route('customers.index', queryParams.value))
}
const sortHandler = () => {
sort.sortVal = sort.sortVal === "asc" ? "desc" : "asc";
sort.get(route('customers.index', queryParams.value))
}
// 画面マウント後処理
onMounted(() => {
//検索ワードを保持する
searchForm.searchKeyword = props.keyword
sort.sortVal = props.sort
})
</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>
<v-col cols="1"></v-col>
<v-col cols="3" class="d-flex">
<form @submit.prevent class="w-100">
<v-text-field density="compact" label="顧客検索" variant="solo" hide-details single-line v-model="searchForm.searchKeyword">
<template #append-inner>
<button @click="searchByName" type="submit" class="icon-button">
<v-icon>mdi-magnify</v-icon>
</button>
</template>
</v-text-field>
</form>
</v-col>
</v-row>
<v-row class="justify-center">
<v-col cols="12" md="10" lg="10" xl="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">
<button @click="sortHandler">
最終来院日
<v-icon>{{ getSortIcon() }}</v-icon>
</button>
</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" />
</v-row>
</v-container>
</div>
</div>
</div>
</AuthenticatedLayout>
</template>
続きは②で。
メモとして残しております。設計書です。
Laravelデフォルトのものを使用。使わないカラムもありますが、そのままいきます。
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
name | string | ![]() | ||
string | ![]() | |||
email_verified_at | timestamp | |||
password | string | ![]() | ||
remember_token | string | |||
created_at | timestamp | |||
updated_at | timestamp |
顧客
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
last_name | string | ![]() | ||
first_name | string | ![]() | ||
last_name_kana | string | ![]() | ||
first_name_kana | string | ![]() | ||
postcode | string | ![]() | ||
address | string | ![]() | ||
tel | string unique | ![]() | ![]() | |
birth | date | ![]() | ||
gender | tinyInteger | ![]() | ||
memo | text | |||
delete_flg | boolean | ![]() | ||
created_at | timestamp | |||
updated_at | timestamp |
従業員
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
last_name | string | ![]() | ||
first_name | string | ![]() | ||
last_name_kana | string | ![]() | ||
first_name_kana | string | ![]() | ||
license | string | |||
delete_flg | boolean | ![]() | ||
created_at | timestamp | |||
updated_at | timestamp |
施術メニュー
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
name | string | ![]() | ||
price | integer | ![]() | ||
time | integer | ![]() | ||
genre_id | bigInt | ![]() | ![]() | |
own_pay_flg | boolean | ![]() | ||
hidden_flg | boolean | ![]() | ||
created_at | timestamp | |||
updated_at | timestamp |
施術メニューがマッサージか、鍼灸か機械かなど
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
name | string | ![]() |
メニューごと(1部位や1メニューごと1レコード)
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
history_id | bigInt | ![]() | ![]() | |
menu_id | bigInt | ![]() | ![]() | |
body_part | string | ![]() | ||
memo | text | |||
created_at | timestamp | |||
updated_at | timestamp |
施術全体情報(誰が誰に対して行なった 詳細はmenu_historyテーブルで)
カラム名 | 型 | PK / FK | UNIQUE | NOT NULL |
---|---|---|---|---|
id | bigInt | ![]() | ![]() | ![]() |
customer_id | bigInt | ![]() | ![]() | |
staff_id | bigInt | ![]() | ![]() | |
date | datetime | ![]() | ||
memo | text | |||
created_at | timestamp | |||
updated_at | timestamp |
No. | 名前 | URL | method | route-name | Vue | Controller |
---|---|---|---|---|---|---|
1 | 顧客一覧 | /customers | get | customers.index | Customers/Index | CustomerController@index |
2 | 顧客登録画面 | /customers/create | get | customers.create | Customers/Create | CustomerController@create |
3 | 顧客登録 | /customers | post | customers.store | – | CustomerController@store |
4 | 顧客詳細画面 | /customers/{customer} | get | customers.show | Customers/Show | CustomerController@show |
5 | 顧客編集画面 | /customers/{customer}/edit | get | customers.edit | Customers/Edit | CustomerController@edit |
6 | 顧客更新 | /customers/{customer} | put | customers.update | – | CustomerController@update |
7 | 顧客削除 | /customers/{customer} | delete | customers.destroy | – | CustomerController@destroy |
8 | 顧客検索結果 | /customers | get | api.searchCustomers | – | Api\searchCustomerController@index |
9 | スタッフ一覧 | /staffs | get | staff.index | Staff/Index | StaffController@index |
10 | スタッフ登録画面 | /staffs/create | get | staff.create | Staff/Create | StaffController@create |
11 | スタッフ登録 | /staffs | post | staff.store | – | StaffController@store |
12 | スタッフ詳細画面 | /staffs/{staff} | get | staff.show | Staff/Show | StaffController@show |
13 | スタッフ編集画面 | /staffs/{staff}/edit | get | staff.edit | Staff/Edit | StaffController@edit |
14 | スタッフ更新 | /staffs/{staff} | put | staff.update | – | StaffController@update |
15 | スタッフ削除 | /staffs/{staff} | delete | staff.destroy | – | StaffController@destroy |
16 | メニュー一覧 | /menus | get | menu.index | Menu/Index | MenuController@index |
17 | メニュー登録画面 | /menus/create | get | menu.create | Menu/Create | MenuController@create |
18 | メニュー登録 | /menus | post | menu.store | – | MenuController@store |
19 | メニュー詳細画面 | /menus/{menu} | get | menu.show | Menu/Show | MenuController@show |
20 | メニュー編集画面 | /menus/{menu}/edit | get | menu.edit | Menu/Edit | MenuController@edit |
21 | メニュー更新 | /menus/{menu} | put | menu.update | – | MenuController@update |
22 | メニュー削除 | /menus/{menu} | delete | menu.destroy | – | MenuController@destroy |
23 | ジャンル一覧 | /genre | get | genre.index | Genre/Index | GenreController@Index |
24 | ジャンル登録画面 | /genres/create | get | genre.create | Genre/Create | GenreController@create |
25 | ジャンル登録 | /genres/create | post | genre.store | – | GenreController@store |
26 | ジャンル編集画面 | /genres/{genre}/edit | get | genre.edit | Genre/Edit | GenreController@edit |
27 | ジャンル更新 | /genres/{genre} | put | genre.update | – | GenreController@update |
28 | ジャンル削除 | /genres/{genre} | delete | genre.destroy | – | GenreController@destroy |
29 | 施術情報登録画面 | /histories/create | get | history.create | History/Create | HistoryController@create |
30 | 施術情報登録 | /histories | post | history.store | – | HistoryController@store |
31 | 施術履歴一覧画面 | /histories | get | history.index | History/Index | HistoryController@index |
32 | 施術履歴詳細画面 | /histories/{histories} | get | history.show | History/Show | HistoryController@show |
33 | 施術履歴編集画面 | /histories/{histories}/edit | get | history.edit | Hisotry/Edit | HistoryController@edit |
34 | 施術履歴更新 | /histories | put | history.update | – | HistoryController@update |
35 | 施術情報削除 | /histories | delete | history.destroy | – | HistoryController@destroy |
36 | 分析画面 | /analysis | get | analysis | Analysis | AnalysisController@index |
37 | 分析結果取得 | /api/analysis | get | api.analysis | – | Api\AnalysisController@index |
メモ
施術登録
日付・顧客名・メニューを一覧表示(ジャンル・金額)・施術部位(入力・追加できるようにする)・小計
]]>ちょうどNext.jsを触り出した頃、友人から事業用のホームページ制作を依頼されました。
現在注目を集めているNext.jsを使って制作すれば、学習にもつながり非常に魅力的だと感じましたが、予算が限られていたため、できるだけコストを抑えて制作する必要がありました。
Next.jsを使用する場合、可能な限りサーバーサイドレンダリング(SSR)を活用したいところですが、SSRを実現するにはVPSなどのNode.jsを動作させる環境が必要となります。
しかし、コスト面を考慮すると、レンタルサーバー上で静的サイト生成(SSG)を用いる方法が適していると判断しました。
さらに、お問い合わせフォームも無料で利用できるサービスが見当たらなかったため、以下の記事を参考に、レンタルサーバー上で動作するPHPを用いて一からお問い合わせフォームを構築することにしました。
Next.js + React Hook Form + バニラPHPでお問い合わせメールを送る
上記の記事を、より実践的な形に落とし込んだのが当記事です。参考にさせていただいた記事に敬意を表します
Next.jsを使用してお問い合わせフォームを作成します。フォームにはreact-hook-form
を使用し、入力バリデーションや送信処理を簡潔に行います。
今回のプロジェクトでは、以下のようなフォルダ構成を採用しました。
.env.development
.env.production
src/
└── app/
├── components/
│ ├── Modal.tsx
│ └── Contact.tsx
├── complete/
│ └── page.tsx
├── error/
│ └── page.tsx
└── page.tsx
必要最低限の構成となっております。プロジェクトでは各々のルールで作成してください
まず、必要なパッケージをインストールします。
npm install react-hook-form axios
以下は、Contact.tsx
に記述するお問い合わせフォームのコンポーネントです。
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useForm, SubmitHandler } from "react-hook-form";
import axios from "axios";
import Modal from "./Modal";
type Inputs = {
name: string;
email: string;
message: string;
submit: any;
};
export default function Contact() {
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<Inputs>();
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Inputs | null>(null);
const handleConfirmSubmit = (data: Inputs) => {
setFormData(data);
setIsModalOpen(true);
};
const onSubmit: SubmitHandler<Inputs> = async () => {
if (!formData) return;
try {
await axios.post(
process.env.NEXT_PUBLIC_FETCH_URL!,
JSON.stringify(formData),
{
headers: {
"Content-Type": "application/json",
},
}
);
router.push("/contact/complete");
} catch (error) {
router.push("/contact/error");
} finally {
setIsModalOpen(false);
}
};
return (
<div className="container">
<div className="inquiry">
<form id="mailForm" onSubmit={handleSubmit(handleConfirmSubmit)}>
<div className="column2">
<label htmlFor="name">
<span className="required">必須</span>
お名前
</label>
<div className="inputWrap">
<input
type="text"
id="name"
placeholder="お名前"
{...register("name", {
required: "お名前を入力してください。",
maxLength: {
value: 20,
message: "20文字以下で入力してください。",
},
})}
/>
{errors.name?.message && (
<p className="errorMessage">{errors.name?.message}</p>
)}
</div>
</div>
<div className="column2">
<label htmlFor="email">
<span className="required">必須</span>
メール
</label>
<div className="inputWrap">
<input
type="email"
id="email"
placeholder="メールアドレス"
{...register("email", {
required: "メールアドレスを入力してください。",
maxLength: {
value: 50,
message: "50文字以下で入力してください。",
},
pattern: {
value:
/^[a-zA-Z0-9_.+-]+@([a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]*\.)+[a-zA-Z]{2,}$/,
message: "正しいメールアドレスを入力してください。",
},
})}
/>
{errors.email?.message && (
<p className="errorMessage">{errors.email?.message}</p>
)}
</div>
</div>
<div className="column2">
<label htmlFor="message">
<span className="required">必須</span>
メッセージ
</label>
<div className="inputWrap">
<textarea
id="message"
rows={10}
placeholder="お問い合わせ内容"
{...register("message", {
required: "メッセージを入力してください。",
maxLength: {
value: 1000,
message: "1000文字以下で入力してください。",
},
})}
></textarea>
{errors.message?.message && (
<p className="errorMessage">{errors.message.message}</p>
)}
</div>
</div>
<div className="column2">
<input
type="submit"
value="内容を確認する"
id="sendBtn"
name="sendBtn"
/>
</div>
<Modal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onConfirm={handleSubmit(onSubmit)}
formData={formData}
isLoading={isSubmitting}
/>
</form>
</div>
<style jsx>{`
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
margin: 0 auto;
}
.required {
color: red;
}
.inputWrap {
margin-top: 8px;
}
.errorMessage {
color: red;
font-size: 12px;
}
input[type="text"],
input[type="email"],
input[type="phone"],
textarea {
width: 100%;
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
background-color: #f6eddb;
box-sizing: border-box;
flex: 2;
}
input[type="submit"] {
display: inline-block;
padding: 10px 20px;
margin: 0 auto;
font-size: 16px;
font-weight: bold;
color: #ffffff;
background-color: #e9a320;
border: none;
border-radius: 4px;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: all 0.2s ease-in-out;
width: 50%;
box-sizing: border-box;
}
input[type="submit"]:hover {
background-color: #a33400;
box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
}
.column2 {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.inquiry {
background-color: #fff;
border-radius: 20px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 20px;
width: 700px;
box-sizing: border-box;
margin: 0 auto;
}
.inquiry .inner {
max-width: 700px;
margin: 0 auto;
}
.inquiry label {
display: flex;
flex: 0.5;
align-items: center;
}
.inputWrap {
flex: 2;
margin-bottom: 15px;
}
.required {
display: inline-block;
padding: 2px 8px;
background-color: red;
color: #fff;
border-radius: 8px;
margin-right: 4px;
font-size: 12px;
}
`}</style>
</div>
);
}
以下は、Modal.tsx
に記述するお問い合わせフォームのモーダルコンポーネントです。
import { useEffect } from "react";
type ModalProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
formData: {
name: string;
email: string;
message: string;
} | null;
isLoading: boolean;
};
const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
onConfirm,
formData,
isLoading,
}) => {
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
if (!isOpen || !formData) return null;
return (
<div className="modalBackdrop">
<div className="modal">
<h2 className="modalTitle">この内容で送信します</h2>
<div className="inquiry">
<div className="column2">
<label>お名前</label>
<div className="inputWrap">
<input type="text" id="name" value={formData.name} disabled />
</div>
</div>
<div className="column2">
<label>メール</label>
<div className="inputWrap">
<input type="email" id="email" value={formData.email} disabled />
</div>
</div>
<div className="column2">
<label>メッセージ</label>
<div className="inputWrap">
<textarea
id="message"
rows={5}
value={formData.message}
disabled
></textarea>
</div>
</div>
<div className="modalActions">
<button
className="modalButton"
onClick={onConfirm}
disabled={isLoading}
>
{isLoading ? "送信中..." : "送信する"}
</button>
<button
className="modalButton cancel"
onClick={onClose}
disabled={isLoading}
>
キャンセル
</button>
</div>
</div>
</div>
<style jsx>{`
.modalBackdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
padding: 20px;
border-radius: 10px;
width: 700px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
z-index: 1001;
}
.modalTitle {
font-size: 20px;
margin-bottom: 16px;
text-align: center;
}
.modalActions {
display: flex;
margin-top: 20px;
}
.modalButton {
display: inline-block;
padding: 10px 20px;
margin: 0 5px 20px 5px;
font-size: 16px;
font-weight: bold;
color: #ffffff;
background-color: #e9a320;
border: none;
border-radius: 4px;
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
cursor: pointer;
transition: all 0.2s ease-in-out;
width: 100%;
box-sizing: border-box;
}
.modalButton:disabled {
background-color: #ccc;
cursor: not-allowed;
box-shadow: none;
opacity: 0.6;
pointer-events: none;
transition: none;
}
.column2 {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.inquiry label {
display: block;
flex: 0.5;
}
.inquiry input[type="text"],
.inquiry input[type="email"],
.inquiry textarea {
width: 100%;
font-size: 1rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
color: gray;
background-color: rgb(233, 233, 233);
flex: 2;
}
.inputWrap {
flex: 2;
margin-bottom: 15px;
}
.modalButton:hover {
background-color: #a33400;
box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
}
.modalButton.cancel {
background-color: #e74c3c;
color: white;
}
.modalButton.cancel:hover {
background-color: #a33400;
box-shadow: 0px 8px 8px rgba(0, 0, 0, 0.25);
}
`}</style>
</div>
);
};
export default Modal;
送信完了後に表示されるcomplete/page.tsx
、エラー時のerror/page.tsx
は適当に作っておきます。
export default function CompletePage(){
return (
<>
送信完了しました
</>
)
}
export default function ErrorPage() {
return (
<>
エラーが発生しました
</>
);
}
インポートと依存関係
useState
: ローカル状態(モーダルの開閉状態やフォームデータ)を管理。型定義
type Inputs = {
name: string;
email: string;
message: string;
submit: any;
};
フォームで扱う入力データの型を定義しています。
フォーム管理
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<Inputs>();
useForm
フックを使用してフォームの状態を管理。register
でフォームフィールドを登録し、handleSubmit
で送信時の処理をラップします。errors
にはバリデーションエラーが格納され、isSubmitting
はフォーム送信中の状態を示します。ローカル状態の管理
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState<Inputs | null>(null);
isModalOpen
: モーダルの開閉状態を管理。formData
: ユーザーが入力したフォームデータを一時的に保存。送信前の確認処理
const handleConfirmSubmit = (data: Inputs) => {
setFormData(data);
setIsModalOpen(true);
};
ユーザーがフォームを送信しようとした際に呼び出され、入力データをformData
に保存し、確認モーダルを開きます。
最終送信処理
const onSubmit: SubmitHandler<Inputs> = async () => {
if (!formData) return;
try {
await axios.post(
process.env.NEXT_PUBLIC_FETCH_URL!,
JSON.stringify(formData),
{
headers: {
"Content-Type": "application/json",
},
}
);
router.push("/contact/complete");
} catch (error) {
router.push("/contact/error");
} finally {
setIsModalOpen(false);
}
};
onSubmit
関数は、実際にデータをサーバーに送信する処理を担当。process.env.NEXT_PUBLIC_FETCH_URL
: データ送信先のURLを環境変数として設定。NEXT_PUBLIC_
で始まる環境変数はクライアントサイドでもアクセス可能。/contact/complete
ページへ遷移。/contact/error
ページへ遷移。レンダリング部分
handleConfirmSubmit
が呼び出され、モーダルが表示されます。Modal
コンポーネントをレンダリングし、モーダルの開閉や送信確認を制御。インポートと依存関係
useEffect
: モーダルの開閉に応じて、背景のスクロールを制御。型定義
type ModalProps = {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
formData: {
name: string;
email: string;
message: string;
} | null;
isLoading: boolean;
};
isOpen
: モーダルが開いているかどうかの状態。onClose
: モーダルを閉じるための関数。onConfirm
: 確認後に実行される送信関数。formData
: 表示するフォームデータ。isLoading
: 送信中の状態を示すフラグ。スクロール制御
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [isOpen]);
モーダルが開いているときに背景のスクロールを無効化し、閉じると元に戻します。クリーンアップ関数で確実にスクロールを元に戻すようにしています。
レンダリング条件
if (!isOpen || !formData) return null;
モーダルが開いていない、またはフォームデータが存在しない場合は何もレンダリングしません。
モーダルの内容
disabled
状態で表示。onConfirm
が呼ばれ、最終送信処理が実行されます。isLoading
がtrue
の場合、ボタンは無効化され「送信中…」と表示されます。onClose
が呼ばれ、モーダルが閉じられます。Contact
コンポーネント内で使用されている環境変数NEXT_PUBLIC_FETCH_URL
について説明します。
process.env.NEXT_PUBLIC_FETCH_URL!
NEXT_PUBLIC_FETCH_URL
は、フォームデータを送信するAPIのエンドポイントURLを指定する環境変数です。Contact
コンポーネント内で使用されており、.envに記述されているURLに対してフォームデータが送信されます。この環境変数は、プロジェクトのルートにある .env
ファイルで定義されますが、今回は開発環境と本番環境で異なるAPIエンドポイントを使用するため、.env.production
と .env.development
の2つのファイルに分けて設定します。.env.development
ファイルで設定を行います。project_name
は各自書き換えて下さい。NEXT_PUBLIC_FETCH_URL=http://localhost:8888/project_name/api/mail
.env.production
ファイルを作成し、以下のように設定します。https://example.com/
をベースにしています。実際には、本番用のURLを指定します。NEXT_PUBLIC_FETCH_URL=https://example.com/api/mail
NEXT_PUBLIC_
で始まる環境変数は、クライアントサイド(ブラウザ)でもアクセス可能です。これにより、フロントエンドコードから直接参照できます。NEXT_PUBLIC_
プレフィックスを付けずにサーバーサイドでのみ使用します。これにより、以下のバックエンド実装後、ローカル開発環境でメールが送れるようになります。
バックエンドでは、さくらレンタルサーバー上で動作するPHPスクリプトを用いて、受け取ったフォームデータをメール送信します。
PHPMailerを利用してSMTP経由でメールを送信することで、信頼性の高いメール配信を実現します。また、ローカル開発環境ではMAMPを使用します。
ローカル環境での開発のMAMP内ディレクトリ構成は次のようにしました。
MAMP/
└── htdocs/
├── project_name/
└── api/
├── .env
└── mail/
└── index.php
MAMPのデフォルトドキュメントルートはhtdocs
ディレクトリです。開発中のPHPプロジェクトをhtdocs
内に配置することで、ローカルホストからアクセス可能になります。
Composer公式サイトのコードを参考にしてグローバルにインストールします。
php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');"
php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;"
php composer-setup.php
php -r "unlink('composer-setup.php');"
sudo mv composer.phar /usr/local/bin/composer
composer --version
正常にインストールされていれば、Composerのバージョン情報が表示されます。
プロジェクトのapi
ディレクトリに移動します。
cd /Applications/MAMP/htdocs/project_name/api
注: /Applications/
とproject_name
は実際のパスに置き換えてください。
Composerの初期設定を行います。以下のコマンドを実行してcomposer.json
を作成します。
composer init
プロンプトに従ってプロジェクト情報を入力します。必要に応じてデフォルトの設定を使用できます。
以下のコマンドを実行してPHPMailerをインストールします。
composer require phpmailer/phpmailer
以下のコマンドを実行してdotenvライブラリをインストールします。
composer require vlucas/phpdotenv
以下のコマンドでvendor
ディレクトリが正しく作成されていることを確認してください。
ls vendor
autoload.php
などのファイルが表示されれば成功です。
以下は、mail/index.php
です。
<?php
require '/Applications/MAMP/htdocs/project_name/api/vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
header('Content-Type: application/json; charset=UTF-8');
// プリフライトリクエストへの対応
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
http_response_code(200);
exit;
}
// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
$dotenv = Dotenv\Dotenv::createImmutable("/Applications/MAMP/htdocs/project_name/");
$dotenv->load();
// POSTリクエストの処理
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$subject = "ホームページよりお問合せがありました";
$to = 'exmaple@example.com';
$url1 = 'http://localhost:3000';
$url2 = 'http://exmaple.com';
$allowedUrls = array($url1, $url2);
$isAllowed = false;
foreach ($allowedUrls as $allowedUrl) {
if (strncmp($_SERVER['HTTP_REFERER'], $allowedUrl, strlen($allowedUrl)) === 0) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
header("HTTP/1.1 404 Not Found");
exit;
}
$json = file_get_contents("php://input");
$contents = json_decode($json, true);
error_log(print_r($contents, true));
// バリデーションの初期化
$errors = array();
// 必須項目のチェック
if (!isset($contents["name"]) || empty(trim($contents["name"]))) {
$errors[] = "名前が必要です。";
}
if (!isset($contents["email"]) || empty(trim($contents["email"]))) {
$errors[] = "メールアドレスが必要です。";
} elseif (!filter_var($contents["email"], FILTER_VALIDATE_EMAIL)) {
$errors[] = "有効なメールアドレスを入力してください。";
}
if (!isset($contents["message"]) || empty(trim($contents["message"]))) {
$errors[] = "メッセージが必要です。";
}
if (!empty($errors)) {
// バリデーションエラーがある場合
http_response_code(400);
echo json_encode([
"status" => "validationError",
"errors" => $errors
], JSON_PRETTY_PRINT);
exit;
}
// htmlspecialchars() を使用して入力データをエスケープ
$escapedName = htmlspecialchars(trim($contents["name"]), ENT_QUOTES, 'UTF-8');
$escapedEmail = htmlspecialchars(trim($contents["email"]), ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars(trim($contents["message"]), ENT_QUOTES, 'UTF-8');
// エスケープ済みデータを使用して新しい配列を作成
$safeContents = array(
"name" => $escapedName,
"email" => $escapedEmail,
"message" => $escapedMessage
);
// エスケープ済みデータをJSON形式で保存
file_put_contents("contact.json", json_encode($safeContents, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
// 管理者へのメール送信処理
$mail = new PHPMailer(true);
try {
// SMTP設定
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USERNAME'];
$mail->Password = $_ENV['SMTP_PASSWORD'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $_ENV['SMTP_PORT'];
$mail->CharSet = 'UTF-8';
// 送信者情報
$mail->setFrom('exmaple@example.com', 'UserName');
// 受信者情報
$mail->addAddress($to);
// メール内容
$mail->isHTML(false);
$mail->Subject = $subject;
$mail->Body = "ホームページより以下のお問い合わせがありました" . "\n" . "\n" .
"お名前:" . $safeContents["name"] . "\n" .
"メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
$safeContents["message"] . "\n";
// 管理者へのメール送信
$mail->send();
$arr["status"] = "sendOk";
// お問い合わせした人にも確認メールを送る
$mail->clearAddresses();
$mail->addAddress($safeContents["email"]);
$mail->Subject = "お問い合わせありがとうございました";
$mail->Body = $safeContents["name"] . " 様" . "\n" .
"お問い合わせいただきありがとうございました。" . "\n" .
"以下の内容でお問い合わせを受け付けました。" . "\n" . "\n" .
"お名前:" . $safeContents["name"] . "\n" .
"メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
$safeContents["message"] . "\n" .
"追ってご連絡いたします。";
// 送信者への確認メール送信
$mail->send();
} catch (Exception $e) {
error_log("Message could not be sent. Mailer Error: {$mail->ErrorInfo}");
$arr["status"] = "sendError";
}
print json_encode($arr, JSON_PRETTY_PRINT);
} else {
header("HTTP/1.1 400 Bad Request");
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>エラー</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f8d7da; color: #721c24; padding: 20px; }
h1 { color: #721c24; }
</style>
</head>
<body>
<h1>不正なリクエスト</h1>
<p>申し訳ありませんが、正しいリクエストメソッドが使用されていません。POSTリクエストのみが許可されています。</p>
</body>
</html>
<?php
exit;
}
?>
依存関係の読み込みと名前空間の設定
<?php
require '/Applications/MAMP/htdocs/project_name/api/vendor/autoload.php';
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require
: Composerで管理されている依存関係を読み込みます。autoload.php
は、プロジェクト内のすべての依存ライブラリを自動的に読み込む役割を果たします。use
: PHPMailerのクラスを名前空間からインポートし、コード内で簡単に利用できるようにします。ヘッダーの設定とCORS対応
header('Content-Type: application/json; charset=UTF-8');
// プリフライトリクエストへの対応
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
http_response_code(200);
exit;
}
// CORSヘッダーを設定
header("Access-Control-Allow-Origin: *");
Content-Type
: レスポンスの内容タイプをJSONに設定します。OPTIONS
メソッドでプリフライトリクエストが行われます。これに対して、許可するオリジン、メソッド、ヘッダーを指定し、200ステータスコードで応答します。Access-Control-Allow-Origin
: すべてのオリジンからのリクエストを許可します。セキュリティ要件に応じて、特定のオリジンに制限することも検討してください。CORSの設定は、必要最低限のオリジンに限定することが推奨されます。*
を使用するとすべてのオリジンからのリクエストを許可するため、セキュリティリスクが高まる可能性があります。
環境変数の読み込み
$dotenv = Dotenv\Dotenv::createImmutable("/Applications/MAMP/htdocs/project_name/");
$dotenv->load();
Dotenv
: .env
ファイルから環境変数を読み込みます。これにより、データベースの資格情報やSMTPの設定などをコード内にハードコーディングする必要がなくなります。createImmutable
: 環境変数を変更不可として読み込みます。POSTリクエストの処理
1.リファラの検証
if ($_SERVER['REQUEST_METHOD'] == 'POST') {
$subject = "ホームページよりお問合せがありました";
$to = 'exmaple@example.com';
$url1 = 'http://localhost:3000';
$url2 = 'http://exmaple.com';
$allowedUrls = array($url1, $url2);
$isAllowed = false;
foreach ($allowedUrls as $allowedUrl) {
if (strncmp($_SERVER['HTTP_REFERER'], $allowedUrl, strlen($allowedUrl)) === 0) {
$isAllowed = true;
break;
}
}
if (!$isAllowed) {
header("HTTP/1.1 404 Not Found");
exit;
}
// 以下略...
}
HTTP_REFERER
)が許可されたURL($allowedUrls
)に含まれているかを確認します。これにより、指定されたオリジンからのリクエストのみを受け付けます。リファラの検証は基本的なセキュリティ対策ですが、完全ではありません。可能であれば、トークンベースの認証やその他のセキュリティメカニズムを併用することを検討してください。
2.入力データの取得とバリデーション
$json = file_get_contents("php://input");
$contents = json_decode($json, true);
error_log(print_r($contents, true));
// バリデーションの初期化
$errors = array();
// 必須項目のチェック
if (!isset($contents["name"]) || empty(trim($contents["name"]))) {
$errors[] = "名前が必要です。";
}
if (!isset($contents["email"]) || empty(trim($contents["email"]))) {
$errors[] = "メールアドレスが必要です。";
} elseif (!filter_var($contents["email"], FILTER_VALIDATE_EMAIL)) {
$errors[] = "有効なメールアドレスを入力してください。";
}
if (!isset($contents["message"]) || empty(trim($contents["message"]))) {
$errors[] = "メッセージが必要です。";
}
if (!empty($errors)) {
// バリデーションエラーがある場合
http_response_code(400);
echo json_encode([
"status" => "validationError",
"errors" => $errors
], JSON_PRETTY_PRINT);
exit;
}
php://input
ストリームから生のPOSTデータを取得し、JSON形式としてデコードします。name
、email
、message
の各フィールドが存在し、空でないことを確認します。filter_var
関数を用いて、メールアドレスの形式が有効かを検証します。入力データのバリデーションは基本的なセキュリティ対策ですが、さらに詳細な検証やサニタイズを行うことで、セキュリティを強化できます。
3.データのエスケープとJSON形式での保存
// htmlspecialchars() を使用して入力データをエスケープ
$escapedName = htmlspecialchars(trim($contents["name"]), ENT_QUOTES, 'UTF-8');
$escapedEmail = htmlspecialchars(trim($contents["email"]), ENT_QUOTES, 'UTF-8');
$escapedMessage = htmlspecialchars(trim($contents["message"]), ENT_QUOTES, 'UTF-8');
// エスケープ済みデータを使用して新しい配列を作成
$safeContents = array(
"name" => $escapedName,
"email" => $escapedEmail,
"message" => $escapedMessage
);
// エスケープ済みデータをJSON形式で保存
file_put_contents("contact.json", json_encode($safeContents, JSON_UNESCAPED_UNICODE) . "\n", FILE_APPEND);
htmlspecialchars
関数を使用して、入力データ内の特殊文字をエスケープし、XSS(クロスサイトスクリプティング)攻撃を防止します。XSS対策: htmlspecialchars
によるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことで、セキュリティを強化できます。
reCAPTCHAの導入: スパムや自動化された悪意のあるリクエストを防ぐために、GoogleのreCAPTCHAなどのCAPTCHAソリューションを導入することを強く推奨します。これにより、フォームのセキュリティが大幅に向上します。
contact.json
ファイルに追記します。この方法は簡易的な実装であり、実際の運用環境ではデータベースの使用や適切なファイル管理を検討してください。JSONファイルへの直接保存は、スケーラビリティやセキュリティの面で制約があります。
ブラウザからJSONへのアクセスを禁じて下さい。以下はapi/mail/.htaccessの記述例です。
<Files "contact.json">
Order allow,deny
Deny from all
</Files>
4.PHPMailerを用いたメール送信
// 管理者へのメール送信処理
$mail = new PHPMailer(true);
try {
// SMTP設定
$mail->isSMTP();
$mail->Host = $_ENV['SMTP_HOST'];
$mail->SMTPAuth = true;
$mail->Username = $_ENV['SMTP_USERNAME'];
$mail->Password = $_ENV['SMTP_PASSWORD'];
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = $_ENV['SMTP_PORT'];
$mail->CharSet = 'UTF-8';
// 送信者情報
$mail->setFrom('exmaple@example.com', 'UserName');
// 受信者情報
$mail->addAddress($to);
// メール内容
$mail->isHTML(false);
$mail->Subject = $subject;
$mail->Body = "ホームページより以下のお問い合わせがありました" . "\n" . "\n" .
"お名前:" . $safeContents["name"] . "\n" .
"メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
$safeContents["message"] . "\n";
// 管理者へのメール送信
$mail->send();
$arr["status"] = "sendOk";
// お問い合わせした人にも確認メールを送る
$mail->clearAddresses();
$mail->addAddress($safeContents["email"]);
$mail->Subject = "お問い合わせありがとうございました";
$mail->Body = $safeContents["name"] . " 様" . "\n" .
"お問い合わせいただきありがとうございました。" . "\n" .
"以下の内容でお問い合わせを受け付けました。" . "\n" . "\n" .
"お名前:" . $safeContents["name"] . "\n" .
"メールアドレス:" . $safeContents["email"] . "\n" . "\n" .
$safeContents["message"] . "\n" .
"追ってご連絡いたします。";
// 送信者への確認メール送信
$mail->send();
} catch (Exception $e) {
error_log("Message could not be sent. Mailer Error: {$mail->ErrorInfo}");
$arr["status"] = "sendError";
}
print json_encode($arr, JSON_PRETTY_PRINT);
.env
ファイルからSMTPサーバーの設定を読み込み、SMTPを使用してメールを送信します。セキュリティを考慮し、パスワードなどの機密情報は環境変数で管理します。sendError
としてクライアントに返します。メール送信のセキュリティ: SMTP認証情報は厳重に管理し、コード内にハードコーディングしないようにしましょう。
reCAPTCHAの検証後にメール送信: reCAPTCHAを導入した場合、ユーザーがCAPTCHAを正しく完了した後にのみメール送信処理を実行するようにします。
5.不正なリクエストへの対応
} else {
header("HTTP/1.1 400 Bad Request");
header('Content-Type: text/html; charset=UTF-8');
?>
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>エラー</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f8d7da; color: #721c24; padding: 20px; }
h1 { color: #721c24; }
</style>
</head>
<body>
<h1>不正なリクエスト</h1>
<p>申し訳ありませんが、正しいリクエストメソッドが使用されていません。POSTリクエストのみが許可されています。</p>
</body>
</html>
<?php
exit;
}
?>
エラーメッセージはユーザーにとって分かりやすいものであると同時に、詳細な内部情報を漏らさないように注意します。
htmlspecialchars
によるエスケープは基本的な対策ですが、出力時にも適切なエスケープ処理を行うことや、コンテンツセキュリティポリシー(CSP)の導入など、さらなるXSS対策を講じることが重要です。セキュリティ向上のため、SMTPの認証情報などの機密情報は環境変数として管理します。
SMTP_HOST=******.sakura.ne.jp
SMTP_USERNAME=********@*********
SMTP_PASSWORD=*******
SMTP_PORT=587
PHPスクリプト内では、Dotenv
を使用して.env
ファイルから環境変数を読み込みます。
Next.jsの開発ディレクトリで、npm run dev
を実行して開発サーバーを立ち上げます。また、MAMPを起動してサーバーを動作させます。
次に、作成したメールフォームから実際にメール送信を試みます。このとき、フォームが正しく機能していれば、送信後に complete/page.tsx
が表示されるはずです。このページが表示されれば、メール送信処理が成功していることを示しています。
送信後、メールが届いていない場合は、迷惑メールフォルダも必ず確認してください。場合によっては、そちらに格納されていることもあります。
Next.jsでプロジェクトを静的サイト生成(SSG)用にビルドするためには、まずnext.config.js
ファイルを適切に設定する必要があります。
特に、重要な設定項目が output: 'export'
です。この設定により、プロジェクトは静的なHTMLファイルとしてビルドされ、/out
ディレクトリに書き出されます。
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "export",
trailingSlash: true,
};
export default nextConfig;
output: "export"
は、Next.jsのプロジェクトを完全な静的サイトとしてビルドするための設定です。
この設定により、サーバーサイドレンダリング(SSR)を行わずに、HTMLファイルとしてすべてのページが生成されます。
生成された静的なファイルは、ホスティングサービスやレンタルサーバーにそのままアップロードすることができます。
trailingSlash: true
を設定すると、生成されたURLの末尾にスラッシュ(/
)が自動的に追加されます。
例えば、/about
というページがあった場合、/about/
というURLになります。
この設定をすることで、特定のサーバー環境やSEO上の理由で推奨されるスラッシュ付きURLに統一できます。
まず、SSHクライアント(例: ターミナル、PuTTY)を使用してさくらレンタルサーバに接続します。
ssh ユーザー名@初期ドメイン
ユーザーのホームディレクトリに composer
をインストールする手順は以下の通りです。
1.ユーザーのホームディレクトリに移動
cd ~
2.「bin」というディレクトリを作成
mkdir bin
3.Composerをインストール(ダウンロード)
curl -sS https://getcomposer.org/installer | php
4.ダウンロードしたファイルをリネームして「bin」ディレクトリに移動
mv composer.phar bin/composer
5.確認
composer -V
さくらインターネットのレンタルサーバーでは、~/bin
が既に PATH
に含まれているため、上記の手順で composer
コマンドをどこからでも実行できるようになります。追加で PATH
を設定する必要はありません。
生成された静的ファイルをレンタルサーバにアップロードします。SFTPまたはSCPを使用して、out
ディレクトリ内を/home/username/www/project_name/
にアップロードします。
私はGUIクライアント(Cyberduck)を用いましたが、なんでも構いません。
次に、PHPファイルをアップロードします。vendor
ディレクトリは含めずにアップロードを行い、後でサーバー上でComposerを使って必要な依存パッケージをインストールします。
ローカルのMAMP/htdocs/project_name/api/
を/home/username/www/project_name/api/
へアップロードします。
次に、PHPプロジェクト内でcomposer install
コマンドを実行し、依存パッケージをインストールします。
cd /home/username/www/project_name/api/
composer install --no-dev --optimize-autoloader
オプションの説明
--no-dev
--optimize-autoloader
実際にホームページにアクセスして、メールを送信してみましょう。
この記事では、Next.jsを用いたフロントエンドとPHPを用いたバックエンドで構成されたお問い合わせフォームの作成方法を解説しました。react-hook-form
によるフォーム管理、PHPMailerを用いたメール送信、環境変数の管理など、セキュアで効率的な実装手法を取り入れることで、信頼性の高いお問い合わせフォームを構築できます。
さくらレンタルサーバーへのデプロイ手順も網羅したので、実際の運用に向けてぜひ参考にしてください。