Appearance
コーディング規約
全プロジェクト共通のコーディング規約
技術スタック
| カテゴリ | 技術 |
|---|---|
| Backend | Laravel 12 |
| Frontend | Vue 3 (Composition API) + Inertia.js |
| CSS | Tailwind CSS |
| UIコンポーネント | Headless UI |
| アイコン | Heroicons |
| Database | MySQL 8 |
| 認証 | Laravel Jetstream |
推奨ライブラリ
| ライブラリ | 用途 | URL |
|---|---|---|
| Headless UI | アクセシブルなUIプリミティブ(モーダル、ドロップダウン等) | https://headlessui.com |
| Heroicons | SVGアイコン(Tailwind公式) | https://heroicons.com |
PHP / Laravel
命名規則
| 対象 | 規則 | 例 |
|---|---|---|
| クラス | PascalCase | UserController, OrderService |
| メソッド | camelCase | getById(), createNew() |
| 変数 | camelCase | $totalAmount, $userList |
| 定数 | UPPER_SNAKE_CASE | MAX_RETRY_COUNT, DEFAULT_LIMIT |
| テーブル名 | snake_case(複数形) | users, orders, order_items |
| カラム名 | snake_case | created_at, total_price |
ディレクトリ構成
app/
├── Http/
│ ├── Controllers/ # コントローラー(薄く保つ)
│ ├── Middleware/ # ミドルウェア
│ └── Requests/ # FormRequest(バリデーション)
├── Models/ # Eloquentモデル
├── Services/ # ビジネスロジック(Eloquent直接操作)
└── Enums/ # 列挙型アーキテクチャ方針
Service + Eloquent 直接操作 を採用(Repositoryパターンは使わない)
Controller → Service → Model(Eloquent)- LaravelのEloquentは十分に抽象化されている
- Repositoryは過剰な抽象化になりがち
- シンプルさと開発速度を優先
コーディングルール
- Controllerは薄く: ビジネスロジックは Service クラスに分離
- FormRequest必須: バリデーションは必ず FormRequest で行う
- Eloquent推奨: 生SQLは極力避ける
- 型宣言必須: 引数・戻り値には型を明記
php
// Good
public function store(StoreOrderRequest $request): Order
{
return $this->orderService->create($request->validated());
}
// Bad
public function store($request)
{
// ...
}日本語化(ローカライズ)
すべてのプロジェクトで日本語対応を行う。
基本設定
.env:
env
APP_LOCALE=ja
APP_FALLBACK_LOCALE=ja
APP_FAKER_LOCALE=ja_JPディレクトリ構成
lang/
└── ja/
├── validation.php # バリデーションメッセージ
├── auth.php # 認証メッセージ
└── pagination.php # ページネーションバリデーションメッセージ
lang/ja/validation.php を作成し、日本語メッセージを設定する。
php
<?php
return [
'required' => ':attributeは必須です。',
'email' => ':attributeは有効なメールアドレスにしてください。',
'unique' => 'この:attributeは既に使用されています。',
'confirmed' => ':attributeが確認用と一致しません。',
'min' => [
'string' => ':attributeは:min文字以上にしてください。',
],
'max' => [
'string' => ':attributeは:max文字以下にしてください。',
],
// ... 他のメッセージ
'attributes' => [
'name' => '名前',
'email' => 'メールアドレス',
'password' => 'パスワード',
'password_confirmation' => 'パスワード(確認)',
],
];認証画面の日本語化
Breezeの認証画面(Login.vue, Register.vue, VerifyEmail.vue 等)は日本語に書き換える。
vue
<!-- resources/js/Pages/Auth/Login.vue -->
<Head title="ログイン" />
<InputLabel for="email" value="メールアドレス" />
<InputLabel for="password" value="パスワード" />
<PrimaryButton>ログイン</PrimaryButton>
<Link :href="route('password.request')">パスワードをお忘れですか?</Link>メール通知の日本語化
認証メール等のNotificationは日本語でカスタマイズする。
php
// app/Notifications/VerifyEmailNotification.php
<?php
namespace App\Notifications;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Notifications\Messages\MailMessage;
class VerifyEmailNotification extends VerifyEmail
{
public function toMail($notifiable): MailMessage
{
$verificationUrl = $this->verificationUrl($notifiable);
return (new MailMessage)
->subject('【サービス名】メールアドレスの確認')
->greeting($notifiable->name . ' 様')
->line('ご登録ありがとうございます。')
->line('以下のボタンをクリックして、メールアドレスの確認を完了してください。')
->action('メールアドレスを確認する', $verificationUrl)
->line('このリンクは60分間有効です。')
->salutation('運営事務局');
}
}Userモデルでカスタム通知を使用:
php
// app/Models/User.php
use App\Notifications\VerifyEmailNotification;
public function sendEmailVerificationNotification(): void
{
$this->notify(new VerifyEmailNotification);
}日本語化チェックリスト
新規プロジェクト作成時に確認:
- [ ]
.envのAPP_LOCALE=ja設定 - [ ]
lang/ja/validation.php作成 - [ ]
attributesで項目名を日本語化 - [ ] Login.vue, Register.vue を日本語化
- [ ] VerifyEmail.vue を日本語化
- [ ] ForgotPassword.vue, ResetPassword.vue を日本語化
- [ ] メール通知(VerifyEmailNotification)を日本語化
Serviceクラス
ビジネスロジックは Service クラスに集約する。
基本構成
php
namespace App\Services;
use App\Models\Order;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function create(array $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create($data);
// 関連処理...
return $order;
});
}
}トランザクション処理
以下の場合は必ずトランザクションを使用する:
- 複数テーブルへの書き込み: 親子関係のあるデータ作成
- 更新と作成の組み合わせ: 在庫減少 + 注文作成など
- 削除を伴う処理: 関連データの整合性を保つ必要がある場合
php
use Illuminate\Support\Facades\DB;
// 基本形
DB::transaction(function () {
// 処理
});
// 例外をキャッチして処理する場合
DB::beginTransaction();
try {
// 処理
DB::commit();
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}Serviceクラスの原則
- 1クラス1責務: OrderService, UserService のように分割
- Modelへの直接操作を避ける: Controller から Model を直接操作しない
- 例外は上位に投げる: Service 内で握りつぶさない
- 戻り値を明確に: Model, Collection, bool などを型宣言
ルーティング
ファイル構成
役割ごとにルートファイルを分割する。
routes/
├── web.php # 一般ユーザー向け(Inertia)
├── admin.php # 管理者向け(Inertia)
├── api.php # API(Axios用、JSON返却)
└── auth.php # 認証関連(Jetstream)ルートファイルの登録
bootstrap/app.php でルートを登録:
php
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
then: function () {
Route::middleware('web')
->prefix('admin')
->name('admin.')
->group(base_path('routes/admin.php'));
},
)URL設計
| 種別 | プレフィックス | 例 |
|---|---|---|
| 一般ユーザー | / | /dashboard, /profile |
| 管理者 | /admin | /admin/users, /admin/settings |
| API | /api | /api/notifications, /api/search |
ルート命名規則
php
// リソースルート(推奨)
Route::resource('users', UserController::class);
// 生成されるルート名
// users.index, users.create, users.store, users.show, users.edit, users.update, users.destroy
// カスタムルート
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::post('/users/{user}/activate', [UserController::class, 'activate'])->name('users.activate');Inertia と Axios の使い分け
基本方針
| 種別 | 用途 | 使用技術 |
|---|---|---|
| ページ遷移 | 画面全体の切り替え | Inertia(router.visit) |
| フォーム送信 | 作成・更新・削除 | Inertia(useForm) |
| 部分更新 | ページ遷移なしのデータ取得・更新 | Axios |
Inertia の使用(メイン)
vue
<script setup>
import { router, useForm } from '@inertiajs/vue3'
// ページ遷移
const goToUser = (id) => {
router.visit(`/users/${id}`)
}
// フォーム送信
const form = useForm({
name: '',
email: ''
})
const submit = () => {
form.post('/users')
}
// 部分リロード(同一ページ内でデータ更新)
const refresh = () => {
router.reload({ only: ['notifications'] })
}
</script>Axios の使用(部分更新のみ)
以下の場合のみ Axios を使用:
- リアルタイム検索: 入力中の検索候補取得
- 非同期バリデーション: メールアドレスの重複チェック等
- バックグラウンド処理: 通知の既読化、いいね等
- 無限スクロール: 追加データの取得
vue
<script setup>
import axios from 'axios'
import { ref } from 'vue'
const suggestions = ref([])
// 検索候補の取得(部分更新)
const search = async (query) => {
const { data } = await axios.get('/api/search', { params: { q: query } })
suggestions.value = data
}
// 通知の既読化(バックグラウンド処理)
const markAsRead = async (id) => {
await axios.post(`/api/notifications/${id}/read`)
}
</script>API エンドポイント設計
Axios用のAPIは routes/api.php に定義:
php
// routes/api.php
Route::middleware('auth:sanctum')->group(function () {
Route::get('/search', [SearchController::class, 'index']);
Route::get('/notifications', [NotificationController::class, 'index']);
Route::post('/notifications/{notification}/read', [NotificationController::class, 'markAsRead']);
});APIレスポンス形式
php
// 成功
return response()->json([
'data' => $users,
'meta' => [
'total' => $users->total(),
'per_page' => $users->perPage(),
]
]);
// エラー
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422);データベース設計
命名規則
| 対象 | 規則 | 例 |
|---|---|---|
| テーブル名 | snake_case(複数形) | users, orders, order_items |
| カラム名 | snake_case | created_at, total_price, user_id |
| 主キー | id | id(BIGINT UNSIGNED AUTO_INCREMENT) |
| 外部キー | {テーブル単数形}_id | user_id, order_id |
| 中間テーブル | アルファベット順で結合 | order_product, role_user |
| 真偽値 | is_/has_/can_ 接頭辞 | is_active, has_verified, can_edit |
| 日時 | _at 接尾辞 | created_at, deleted_at, published_at |
| 日付 | _on または _date 接尾辞 | birth_date, start_on |
必須カラム
すべてのテーブルに以下を含める:
php
$table->id(); // 主キー
$table->timestamps(); // created_at, updated_at
$table->softDeletes(); // deleted_at(必要に応じて)設計原則
- 正規化を基本とする: 第3正規形を目指す。パフォーマンス上必要な場合のみ非正規化
- 外部キー制約を設定: データ整合性を担保
php
$table->foreignId('user_id')->constrained()->cascadeOnDelete();- インデックスを適切に設定: 検索・ソートに使うカラムにはインデックス
php
$table->index('email');
$table->index(['status', 'created_at']); // 複合インデックス- NULL許容は最小限に: デフォルト値を設定できる場合は設定
php
// Good
$table->string('status')->default('pending');
$table->integer('count')->default(0);
// 本当にNULLが必要な場合のみ
$table->date('deleted_at')->nullable();- ENUM は使わない: 将来の変更が困難。代わりに文字列 + バリデーション、またはマスタテーブル
php
// Bad
$table->enum('status', ['draft', 'published', 'archived']);
// Good
$table->string('status', 20); // バリデーションで制御
// または
$table->foreignId('status_id')->constrained('statuses');マイグレーション
- 1ファイル1テーブル: テーブル作成は個別のマイグレーションで
- ロールバック可能に:
down()メソッドを必ず実装 - 本番環境では変更のみ: 既存カラムの削除・変更は新しいマイグレーションで
php
// マイグレーション例
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('order_number', 20)->unique();
$table->string('status', 20)->default('pending');
$table->decimal('total_amount', 10, 2)->default(0);
$table->text('notes')->nullable();
$table->timestamps();
$table->softDeletes();
$table->index(['status', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}型の選択
| 用途 | 推奨型 |
|---|---|
| 主キー・外部キー | bigIncrements / foreignId |
| 金額 | decimal(10, 2) ※ floatは使わない |
| 短い文字列 | string(長さ) |
| 長いテキスト | text |
| 真偽値 | boolean |
| 日時 | timestamp / dateTime |
| 日付のみ | date |
| JSON | json |
Vue.js / JavaScript
命名規則
| 対象 | 規則 | 例 |
|---|---|---|
| コンポーネント | PascalCase | UserCard.vue, LoginForm.vue |
| 変数・関数 | camelCase | isLoading, handleSubmit |
| 定数 | UPPER_SNAKE_CASE | API_BASE_URL |
| Props | camelCase | userId, isEditable |
| Emit | kebab-case | @update-value, @form-submit |
ディレクトリ構成
resources/js/
├── Components/
│ ├── Common/ # 共通コンポーネント
│ ├── Forms/ # フォーム部品
│ └── Tables/ # テーブル部品
├── Pages/ # 画面コンポーネント
├── Composables/ # 再利用可能なロジック
└── Stores/ # 状態管理(必要に応じて)コーディングルール
- Composition API使用: Options APIは使わない
- script setup推奨:
<script setup>を使用 - TypeScript風の型注釈: defineProps, defineEmitsで型を明記
vue
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
userId: {
type: Number,
required: true
},
initialData: {
type: Object,
default: () => ({})
}
})
const emit = defineEmits(['submit', 'cancel'])
const formData = ref({ ...props.initialData })
const isValid = computed(() => formData.value.name?.length > 0)
const handleSubmit = () => {
if (isValid.value) {
emit('submit', formData.value)
}
}
</script>Tailwind CSS
クラスの順序
- レイアウト(display, position, flex, grid)
- サイズ(width, height, padding, margin)
- 装飾(background, border, shadow)
- タイポグラフィ(font, text)
- その他(transition, cursor)
html
<!-- Good -->
<div class="flex items-center justify-between p-4 bg-white rounded-lg shadow text-gray-800">
<!-- Bad (順序がバラバラ) -->
<div class="text-gray-800 flex bg-white p-4 shadow items-center rounded-lg justify-between">共通クラス
デザインシステム(DESIGN_SYSTEM.md)で定義された共通クラスを使用する。
Git
ブランチ命名
feature/機能名 # 新機能
fix/バグ内容 # バグ修正
refactor/対象 # リファクタリングコミットメッセージ
[種別] 概要
種別:
- feat: 新機能
- fix: バグ修正
- refactor: リファクタリング
- docs: ドキュメント
- style: コードスタイル
- test: テスト例:
feat: ユーザー登録機能を追加
fix: ログイン時のバリデーションを修正
refactor: UserServiceのメソッド分割インフラ・外部サービス
必須サービス
| カテゴリ | サービス | 備考 |
|---|---|---|
| メール送信 | Resend | Laravel標準のMailに統合可能 |
| ドメイン管理 | Cloudflare | DNS・SSL・CDN一元管理 |
Resend(メール送信)
すべてのプロジェクトでメール送信には Resend を使用する。
理由
- シンプルなAPI
- 高い到達率
- Laravel との統合が容易
- 開発者フレンドリーなダッシュボード
Laravel での設定
bash
composer require resend/resend-laravel.env:
env
MAIL_MAILER=resend
RESEND_API_KEY=re_xxxxxxxxxx
MAIL_FROM_ADDRESS=noreply@example.com
MAIL_FROM_NAME="${APP_NAME}"config/services.php:
php
'resend' => [
'api_key' => env('RESEND_API_KEY'),
],使用例
php
use Illuminate\Support\Facades\Mail;
use App\Mail\WelcomeMail;
Mail::to($user->email)->send(new WelcomeMail($user));Cloudflare(ドメイン管理)
すべてのプロジェクトでドメイン管理には Cloudflare を使用する。
理由
- DNS・SSL・CDNを一元管理
- 無料SSLで証明書管理が不要
- DDoS対策が標準装備
- 高速なDNS解決
設定方針
| 項目 | 設定 |
|---|---|
| SSL/TLS モード | Flexible(サーバー側SSL不要) |
| Always Use HTTPS | ON |
| プロキシ | ON(オレンジ雲) |
| キャッシュ | 静的ファイルのみ |
Apache設定(Flexibleモード)
Cloudflare Flexible モードを使用する場合、サーバー側のSSL設定は不要。ポート80のみで設定する。
apache
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/html/project/public
<Directory /var/www/html/project/public>
Options -Indexes +FollowSymLinks
AllowOverride All
Require all granted
</Directory>
ErrorLog ${APACHE_LOG_DIR}/project_error.log
CustomLog ${APACHE_LOG_DIR}/project_access.log combined
</VirtualHost>注意
- HTTPSリダイレクトはApacheに設定しない(Cloudflare側で処理)
- Let's Encrypt は使用しない(Cloudflareが証明書を管理)
テスト
テストの考え方
テストピラミッド
/\
/ \ E2E(少)
/────\ 重要なユーザーフローのみ
/ \
/────────\ Feature/Integration(中)
/ \ APIエンドポイント、主要機能
/────────────\ Unit(多)
/ \ ビジネスロジック、ユーティリティ| 種類 | 対象 | 速度 | 量 |
|---|---|---|---|
| Unit | Service、ユーティリティ関数 | 速い | 多い |
| Feature | Controller、API | 中程度 | 中程度 |
| E2E | ユーザーフロー全体 | 遅い | 少ない |
テストを書く優先度
- 必ず書く: 決済、認証、重要なビジネスロジック
- 書くべき: CRUD操作、バリデーション、API
- 余裕があれば: UIコンポーネント、ヘルパー関数
使用ツール
| 種類 | ツール |
|---|---|
| PHP Unit/Feature | Pest(推奨)または PHPUnit |
| E2E | Playwright |
| Vue コンポーネント | Vitest + Vue Test Utils(必要に応じて) |
Laravel テスト(Pest)
セットアップ
bash
composer require pestphp/pest --dev
composer require pestphp/pest-plugin-laravel --dev
php artisan pest:installディレクトリ構成
tests/
├── Feature/ # 機能テスト(HTTP、DB使用)
│ ├── Auth/
│ ├── Api/
│ └── Admin/
├── Unit/ # ユニットテスト(単体、高速)
│ └── Services/
└── Pest.php # Pest設定Feature テスト例
php
// tests/Feature/Auth/RegistrationTest.php
<?php
use App\Models\User;
describe('ユーザー登録', function () {
it('新規ユーザーを登録できる', function () {
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertRedirect('/dashboard');
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
]);
});
it('メールアドレスが重複していると登録できない', function () {
User::factory()->create(['email' => 'test@example.com']);
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password123',
'password_confirmation' => 'password123',
]);
$response->assertSessionHasErrors('email');
});
});API テスト例
php
// tests/Feature/Api/OrderTest.php
<?php
use App\Models\User;
use App\Models\Order;
describe('注文API', function () {
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
it('注文一覧を取得できる', function () {
Order::factory()->count(3)->create(['user_id' => $this->user->id]);
$response = $this->getJson('/api/orders');
$response->assertOk()
->assertJsonCount(3, 'data');
});
it('注文を作成できる', function () {
$response = $this->postJson('/api/orders', [
'product_id' => 1,
'quantity' => 2,
]);
$response->assertCreated();
$this->assertDatabaseHas('orders', [
'user_id' => $this->user->id,
'quantity' => 2,
]);
});
it('他人の注文は取得できない', function () {
$otherUser = User::factory()->create();
$order = Order::factory()->create(['user_id' => $otherUser->id]);
$response = $this->getJson("/api/orders/{$order->id}");
$response->assertForbidden();
});
});Unit テスト例
php
// tests/Unit/Services/PriceCalculatorTest.php
<?php
use App\Services\PriceCalculator;
describe('PriceCalculator', function () {
it('税込価格を計算できる', function () {
$calculator = new PriceCalculator();
expect($calculator->withTax(1000))->toBe(1100);
expect($calculator->withTax(1500))->toBe(1650);
});
it('割引を適用できる', function () {
$calculator = new PriceCalculator();
expect($calculator->applyDiscount(1000, 10))->toBe(900);
expect($calculator->applyDiscount(1000, 25))->toBe(750);
});
it('割引率が100%を超えるとエラー', function () {
$calculator = new PriceCalculator();
expect(fn() => $calculator->applyDiscount(1000, 150))
->toThrow(InvalidArgumentException::class);
});
});E2E テスト(Playwright)
セットアップ
bash
npm init playwright@latestディレクトリ構成
e2e/
├── tests/
│ ├── auth.spec.ts
│ ├── order.spec.ts
│ └── admin.spec.ts
├── fixtures/
│ └── test-data.ts
└── playwright.config.tsE2E テスト例
typescript
// e2e/tests/auth.spec.ts
import { test, expect } from '@playwright/test';
test.describe('認証フロー', () => {
test('ログインできる', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/dashboard');
await expect(page.locator('h1')).toContainText('ダッシュボード');
});
test('間違ったパスワードではログインできない', async ({ page }) => {
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error-message')).toBeVisible();
});
});typescript
// e2e/tests/order.spec.ts
import { test, expect } from '@playwright/test';
test.describe('注文フロー', () => {
test.beforeEach(async ({ page }) => {
// ログイン
await page.goto('/login');
await page.fill('input[name="email"]', 'user@example.com');
await page.fill('input[name="password"]', 'password');
await page.click('button[type="submit"]');
});
test('商品を注文できる', async ({ page }) => {
await page.goto('/products');
// 商品を選択
await page.click('text=商品を見る >> nth=0');
await page.click('button:has-text("カートに追加")');
// カートへ
await page.click('a:has-text("カート")');
await page.click('button:has-text("注文する")');
// 確認
await expect(page.locator('.order-complete')).toContainText('注文が完了しました');
});
});テストの原則
1. AAA パターン
php
it('注文を作成できる', function () {
// Arrange(準備)
$user = User::factory()->create();
$product = Product::factory()->create(['price' => 1000]);
// Act(実行)
$order = $this->actingAs($user)
->postJson('/api/orders', ['product_id' => $product->id]);
// Assert(検証)
$order->assertCreated();
expect(Order::count())->toBe(1);
});2. テスト名は日本語で具体的に
php
// Good
it('在庫がない商品は注文できない', function () { });
it('管理者のみユーザーを削除できる', function () { });
// Bad
it('test order', function () { });
it('works correctly', function () { });3. 1テスト1検証
php
// Good - 分離されている
it('注文が作成される', function () {
// ...
expect(Order::count())->toBe(1);
});
it('在庫が減少する', function () {
// ...
expect($product->fresh()->stock)->toBe(9);
});
// Bad - 複数の検証が混在
it('注文処理', function () {
// ...注文、在庫、メール、ポイント全部チェック
});4. テストデータは Factory で
php
// Good
$user = User::factory()->create();
$user = User::factory()->admin()->create(); // 状態を使用
$users = User::factory()->count(10)->create();
// Bad - ハードコード
$user = User::create([
'name' => 'Test',
'email' => 'test@example.com',
// ...
]);テスト実行
bash
# 全テスト実行
php artisan test
# 特定のファイル
php artisan test tests/Feature/Auth/LoginTest.php
# 特定のテスト
php artisan test --filter="ログインできる"
# カバレッジ付き
php artisan test --coverage
# E2E テスト
npx playwright test
# E2E テスト(UIモード)
npx playwright test --uiテスト実行環境
| 環境 | テスト実行 | 目的 |
|---|---|---|
| 開発環境(ローカル) | ◎ 必須 | コード変更のたびに実行 |
| CI/CD(GitHub Actions) | ◎ 必須 | PR・pushのたびに自動実行 |
| ステージング | △ E2Eのみ | 本番に近い環境での最終確認 |
| 本番 | ✕ 実行しない | データ破損リスク |
テストの流れ
開発者のローカル
│ ① php artisan test
▼
GitHub に Push / PR
│ ② CI/CDで自動テスト(GitHub のサーバー上)
▼
テスト通過 → ステージング
│ ③ E2E・手動確認
▼
本番デプロイCI/CD でのテスト
CI/CD のテストはどこで動くか
GitHub Actions の場合、GitHub のクラウドサーバー上で一時的に環境が作られます。
┌─────────────────────────────────────────────┐
│ GitHub Actions Runner(一時的な仮想マシン) │
│ │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │ PHP │ │ MySQL (Docker) │ │
│ │ 8.3 │───▶│ テスト専用DB │ │
│ └─────────────┘ │ (空のDB) │ │
│ │ └─────────────────┘ │
│ ▼ │
│ php artisan test │
│ │
│ テスト完了後 → VM ごと破棄(何も残らない) │
└─────────────────────────────────────────────┘
本番サーバー・開発サーバーには一切影響なし- 実行場所: GitHub のクラウドサーバー(VM)
- データベース: Docker で一時的に作成
- テスト後: VM ごと完全に削除
- 本番への影響: なし
- 費用: プライベートリポジトリは月2,000分無料(小規模なら十分)
GitHub Actions 設定例
yaml
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_DATABASE: testing
MYSQL_ROOT_PASSWORD: password
ports:
- 3306:3306
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: xdebug
- name: Install Dependencies
run: composer install --no-progress --prefer-dist
- name: Run Tests
run: php artisan test --coverage
env:
DB_CONNECTION: mysql
DB_HOST: 127.0.0.1
DB_DATABASE: testing
DB_USERNAME: root
DB_PASSWORD: passwordその他
ログ
ログチャンネル設定
ログは Daily(日次ローテーション)を使用する。
.env:
env
LOG_CHANNEL=daily
LOG_LEVEL=debugconfig/logging.php のデフォルト設定で14日間保持される。
ログレベルの使い分け
| レベル | 用途 | 例 |
|---|---|---|
emergency | システム全体が使用不能 | データベース接続不可 |
alert | 即座に対応が必要 | ディスク容量枯渇 |
critical | 重大なエラー | 決済処理の失敗 |
error | エラー(処理は継続可能) | API呼び出し失敗、例外発生 |
warning | 警告(問題になりうる) | 非推奨機能の使用、リトライ発生 |
info | 重要な処理の記録 | ユーザー登録、注文完了、バッチ実行 |
debug | 開発時のデバッグ情報 | 変数の値、処理の流れ確認 |
実装例
php
use Illuminate\Support\Facades\Log;
// 重要な処理の記録
Log::info('User registered', ['user_id' => $user->id, 'email' => $user->email]);
// エラー発生時
try {
$this->paymentService->charge($order);
Log::info('Payment succeeded', ['order_id' => $order->id]);
} catch (\Exception $e) {
Log::error('Payment failed', [
'order_id' => $order->id,
'error' => $e->getMessage(),
]);
throw $e;
}
// 警告
Log::warning('API rate limit approaching', ['remaining' => $remaining]);
// デバッグ(本番では出力されない)
Log::debug('Processing order', ['data' => $orderData]);ログ出力の原則
- 本番環境では
LOG_LEVEL=info: debug は出力しない - コンテキスト情報を含める:
['user_id' => $id]のように配列で渡す - 機密情報は出力しない: パスワード、クレジットカード番号など
- 英語で記述: ログは英語で統一(検索・解析しやすい)
php
// Good
Log::info('Order created', ['order_id' => $order->id, 'amount' => $order->total]);
// Bad - 日本語
Log::info('注文が作成されました');
// Bad - コンテキストなし
Log::info('Order created');
// Bad - 機密情報
Log::info('User login', ['password' => $password]);ログを出力すべきタイミング
| タイミング | レベル |
|---|---|
| ユーザー登録・ログイン | info |
| 重要なデータ作成・更新・削除 | info |
| 決済処理の成功・失敗 | info / error |
| 外部API呼び出しの失敗 | error |
| バッチ処理の開始・終了 | info |
| 例外のキャッチ | error |
| リトライ処理 | warning |
| 開発時の変数確認 | debug |
エラーハンドリング
- ユーザー向けメッセージは日本語
- ログは英語
- 例外は適切にキャッチし、ユーザーに分かりやすいメッセージを表示
セキュリティ
- SQLインジェクション: Eloquent/クエリビルダを使用
- XSS: Bladeの二重波括弧(エスケープ出力)を使用。
{!! !!}(生出力)は極力避ける - CSRF: Laravelのデフォルト機能を使用
- 認可: Policy/Gateで権限チェック
ロゴ・Favicon
プロジェクト立ち上げ時に、Laravelのデフォルトロゴを差し替える。
ロゴ形式
SVG(ベクター形式)必須
- 拡大縮小で劣化しない
- CSSで色変更可能(
fill-current) - ファイルサイズが軽量
用意するファイル
| ファイル | 形状 | サイズ基準 | 用途 |
|---|---|---|---|
logo.svg | 横長(3:1〜4:1) | 高さ40px(h-10)で最適化 | ヘッダー、ログイン画面 |
logo-icon.svg | 正方形(1:1) | 32px以上で視認可能 | favicon、サイドメニュー |
Favicon セット
512x512 の正方形画像から以下を生成する。
public/
├── favicon.ico # 32x32(レガシーブラウザ)
├── favicon-16x16.png # ブラウザタブ
├── favicon-32x32.png # ブラウザタブ(高解像度)
├── favicon-96x96.png # 高解像度favicon
├── apple-touch-icon.png # 180x180(iOS ホーム画面)
├── web-app-manifest-192x192.png # PWA アイコン
├── web-app-manifest-512x512.png # PWA スプラッシュ
└── site.webmanifest # PWA設定Favicon ジェネレーター
Favicon ジェネレーター を使用すると、512x512 の画像から上記すべてを一括生成できます。
使用サイズ一覧
| 場所 | 使用ロゴ | 高さ | Tailwind |
|---|---|---|---|
| ヘッダー | logo.svg | 32-40px | h-8 ~ h-10 |
| ログイン画面 | logo.svg | 48px | h-12 |
| サイドメニュー上部 | logo.svg または logo-icon.svg | 32-40px | h-8 ~ h-10 |
| フッター | logo.svg | 24-32px | h-6 ~ h-8 |
| favicon | logo-icon.svg | 32px | - |
ApplicationLogo コンポーネントの差し替え
Breezeのデフォルトロゴを差し替える:
vue
<!-- resources/js/Components/ApplicationLogo.vue -->
<template>
<img src="/images/logo.svg" alt="サービス名" class="h-10 w-auto">
</template>または SVG を直接埋め込む:
vue
<template>
<svg viewBox="0 0 120 40" class="h-10 w-auto fill-current">
<!-- SVGパス -->
</svg>
</template>チェックリスト
新規プロジェクト作成時:
- [ ]
logo.svg(横長ロゴ)を作成 - [ ]
logo-icon.svg(正方形アイコン)を作成 - [ ] Favicon ジェネレーター で favicon セットを生成
- [ ]
public/に favicon ファイルを配置 - [ ]
site.webmanifestのサービス名を編集 - [ ]
ApplicationLogo.vueを差し替え - [ ] HTML の
<head>に favicon リンクを追加
AI コーディングルール
AI(Claude等)にコーディングを依頼する際のルール。
テスト
- 新しい機能には必ず Feature テストを作成する
- Service クラスには Unit テストを作成する
- テストがパスすることを確認してから完了とする
php artisan testで全テストが通ること
bash
# 完了前に必ず実行
php artisan testコード品質
- 既存のコードスタイルに従う
- 型宣言(引数・戻り値)を必ず付ける
- 1メソッド30行以内を目安にする
- マジックナンバーは定数化する
php
// Good
public function calculateTotal(Order $order): int
{
return $order->items->sum(fn($item) => $item->price * $item->quantity);
}
// Bad - 型なし、長すぎ
public function calculateTotal($order)
{
// 50行以上の処理...
}ファイル配置
| 種類 | 配置場所 |
|---|---|
| ビジネスロジック | app/Services/ |
| バリデーション | app/Http/Requests/ |
| 共通コンポーネント | resources/js/Components/Common/ |
| ページコンポーネント | resources/js/Pages/ |
完了前チェックリスト
コーディング完了前に必ず確認する:
- [ ]
php artisan testが通る - [ ]
npm run buildがエラーなく完了する - [ ] 既存機能が壊れていない(関連テストが通る)
- [ ] 不要な
console.log/dd()/dump()を削除した - [ ] 機密情報がハードコードされていない
禁止事項
以下は禁止とする:
| 禁止 | 理由 | 代替手段 |
|---|---|---|
| Controller に直接ビジネスロジック | 肥大化・テスト困難 | Service クラスに分離 |
| 生の SQL クエリ | SQLインジェクションリスク | Eloquent / クエリビルダ |
env() を config 以外で使用 | キャッシュ時に動作しない | config() を使用 |
any 型(TypeScript) | 型安全性が失われる | 適切な型を定義 |
@ts-ignore / @ts-nocheck | 型エラーの握りつぶし | 型を正しく修正 |
!important(CSS) | スタイル競合の原因 | 詳細度を調整 |
php
// 禁止: Controller にロジック
public function store(Request $request)
{
// 100行のビジネスロジック...
}
// 推奨: Service に分離
public function store(StoreOrderRequest $request)
{
$order = $this->orderService->create($request->validated());
return redirect()->route('orders.show', $order);
}コミット前の確認
bash
# 1. テスト実行
php artisan test
# 2. フロントエンドビルド
npm run build
# 3. 静的解析(導入している場合)
./vendor/bin/pint --testAIへの依頼時のヒント
効率的に依頼するためのヒント:
- 既存コードを参照させる: 「既存の UserController を参考にして」
- テストを明示的に依頼: 「テストも作成して、パスすることを確認して」
- 完了条件を明確に: 「
php artisan testが通ったら完了」 - スコープを限定: 「このファイルだけ修正して」