2022-07-17 / 2022-07-31 更新

使い倒し系Laravelコーディング規約 / 小中規模案件を爆速で回す

「このLaravelとあのLaravel、全く別物じゃないか。」

Laravelは自由度が高い。Eloquentなどハイレベルな機能も提供されているが低レイヤーのAPIも存在し実装クラスの配置にも制限はほぼ無い。これら自由度のおかげで他フレームワーク経験者もスムーズにLaravelに入門できる一方、現場でしばしば聞かれるのが冒頭に挙げた声だ。

自由度は重要だが状況(プロジェクトの規模や背景)をある程度固定した上でのルール導入もまた有用と考え、今回は 小中規模の案件 を対象に Laravelを使い倒す という観点で規約を作成した。

  • 小中規模のみ想定。設計のレイヤー化が生産性を大きく左右するような規模は想定しない。
    (この想定を超えるプロジェクトはそもそもLaravelを採用すべきでない)

  • フレームワークへのロックイン防止も考慮しない。
    (小中規模であれば、ここにコストを払うより必要に応じ使い捨てた方が早い)

以上のような 少々偏った 状況であれば Laravelを使い倒す 考え方が生産性を大いに向上させる。その方法論の規約化が本文章だ。

Laravelを使い倒す というコンセプトのためPHPや他のフレームワークに存在しない特有の設計や概念が多く登場する。それらの詳しい解説は割愛するが、最低限のコードや関連文書へのリンクは用意した。Laravelをより深く知るためのコンテンツとしても活用されることを期待している。改善の提案も 歓迎 しています。

プロジェクト開始方法・環境設定

  • 非推奨 公式ドキュメントに記載された composer create-project による開始は非推奨
  • 推奨 laravel/laravel やそのforkの git clone による開始を推奨
  • 任意 プロジェクトの「雛形」となるカスタマイズ版 laravel/laravel を必要に応じて管理
サンプル: プロジェクト開始方法
  • 非推奨 composer create-project による開始
    1
    $ composer create-project laravel/laravel .
  • 推奨 git clone による開始
    1
    2
    $ git clone https://github.com/laravel/laravel.git .
    $ composer install
  • 推奨 laravel/laravel のforkよりアプリケーションの開発を開始
    laravel/laravel を予め fork-of/laravel へforkした例
    1
    2
    3
    $ git clone https://github.com/fork-of/laravel.git .
    $ git remote add laravel https://github.com/laravel/laravel.git
    $ composer install
目的・ねらい
  1. composer create-project によるアプリケーション作成には次の課題がある
    • アプリケーションの初期状態は Packagistへの登録内容 に準ずる。
    • Packagistへの登録内容は随時更新されている。
    • 以上より いつ作成されたアプリケーションなのかによって初期状態が異なる ということが起こる。
    • laravel/laravel はマイナーバージョンアップに有用な仕様変更が含まれることもある。
    • composer create-project を行った後はこれらを自動的に取り込むことができない。
  2. これら課題に対し、アプリケーションを laravel/laravel のforkから開始することで次のようなメリットが生まれる。
    • 例えば git merge laravel/vX.Y.Z で開始後も取り込める。結果アプリケーションを最新に保てる。
    • 提供される機能は composer create-project と同等なので取り込む必要がない場合は特に何もしなくて良い。
    • マージ時のconflictの発生有無により(実際にマージを行わないとしても)Laravelメジャーバージョンアップ時の非互換を予見できる可能性がある。
  3. forkをカスタマイズしそれを雛形とすることで次のようなことが可能となる。
    • コードの補間や整形、静的解析、各種ツール、それらの設定、これらが全て済んだ状態よりプロジェクトを開始出来るようになる。
    • カスタマイズによるプロジェクトの迅速な立ち上げは、序文 必要に応じ使い捨て に対し特に有効に作用。
    • このカスタマイズに対し laravel/laravel をマージすることで雛形を最新に保つ。
  • 非推奨 Git管理外ファイルである .env での環境設定は行わない
  • 推奨 Git管理下かつデプロイに影響を与えない方法で環境設定
    • 禁止 ただし config/ 配下を(環境設定のみを目的に)変更してはいけない
サンプル: 環境変数の設定
  • 非推奨 .env より設定
    .env
    1
    2
    3
    APP_DEBUG=true
    APP_KEY=base64:u1x6gW5zQkAMXHIL/TAf/EprM60zNSg9I7g9eG2Cmjw=
    APP_ENV=local
  • 推奨 docker-compose.yml より設定
    docker-compose.yml
    1
    2
    3
    4
    5
    6
    7
    services:
    php:
    environment:
    - APP_DEBUG=${APP_DEBUG:-true}
    # アプリケーションキーは32バイトの文字列であれば何でも良い
    - APP_KEY=${APP_KEY:-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}
    - APP_ENV=local

Eloquent・クエリビルダ

  • 推奨 データベースアクセスは原則 Eloquent で実装
    • 必須 HTTPリクエストに対しレスポンスを返却する程度の処理はEloquentのみで実装
    • 任意 大量データ処理などEloquentでは深刻なパフォーマンス問題が発生する場合は クエリビルダ を使って良い
目的・狙い

Eloquentのデメリットとして一般的に次のようなものが挙げられる。

  • N+1問題の発生
  • パフォーマンスの劣化
  • メモリ利用料の増大

しかし HTTPリクエストに対しレスポンスを返却する程度の処理 に限っては次の通り問題にはなりにくい。

  • N+1問題の発生
    • クエリビルダであっても実装によっては発生する。
    • 解決方法が join()with() かの違いのみ。
    • join() よりも with() の方がコードは簡潔。
  • パフォーマンスの劣化
    • Eloquentとクエリビルダとではパフォーマンスに5〜8倍の差があるが HTTPリクエストに対しレスポンスを返却する程度の処理 (fetch行数が大きくない場合)では体感できる差は発生しない。
  • メモリ利用料の増大
    • 前項と同じく小さな誤差。
    • Eloquentが追加で利用するメモリは アクセサのキャッシュ などパフォーマンスに寄与する面もある

一方Eloquentには次のようなメリットがある。

サンプル: レコードの取得
  • 禁止 テーブルを参照しクエリビルダでレコードを特定
    1
    2
    $user = DB::table('users')->where('id', 1)->first();
    // $user-> /* コード補完が効かない */
  • 必須 モデルを参照しEloquentでモデルを特定
    1
    2
    $user = User::find(1);
    // $user-> /* 後述例のアノテーションによりコード補完が効く */
サンプル: レコードの検索
  • 禁止 テーブルを参照しクエリビルダからレコードを取得
    1
    DB::table('users')->where('name', 'taylorotwell')->first();
  • 必須 モデルを参照しEloquentビルダからモデルを取得
    1
    User::query()->where('name', 'taylorotwell')->first();
サンプル: リレーションの取得
  • サンプル中のリレーション構造
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    class User extends Model
    {
    // Userは複数のPostを持つ
    public function posts()
    {
    $this->hasMany(Post::class);
    }
    }

    class Post extends Model
    {
    // PostはUserに所属する
    public function user()
    {
    $this->belongsTo(User::class);
    }
    }
  • 非推奨 Eloquentリレーションを利用せず実装側でリレーションを管理
    1
    2
    $user = User::find($user_id);
    $posts = Post::query()->where('user_id', $user_id)->get();
  • 非推奨 クエリビルダと join() を使った取得
    1
    2
    3
    4
    $postsWithUser = DB::table('users')
    ->where('user_id', $user_id)
    ->join('posts', 'users.id', '=', 'user_id')
    ->get();
  • 推奨 Eloquentリレーションから取得
    1
    $posts = User::find(1)->posts;
  • 基本的にはEloquentリレーション推奨だが性能は join() が有利。データ量(目安として1万件超)に応じて適切な使い分けが必要
サンプル: N+1問題への対処
  • 前項のリレーションを前提に 複数のPostに対するUserへのN+1問題を 考える
  • 禁止 N+1問題の発生例
    1
    2
    3
    4
    5
    $posts = Post::all();
    foreach($posts as $post) {
    // **N+1問題** $postsの件数だけクエリされる
    $post->user->name;
    }
  • 非推奨 クエリビルダと join() を使った取得
    1
    2
    3
    $postsWithUser = DB::table('posts')
    ->join('user', 'users.id', '=', 'user_id')
    ->get();
  • 推奨 Eagerロード による取得
    1
    $posts = Post::query()->with('user')->get();
  • 前項と同様適切な使い分けが必要
  • 必須 Eloquentモデルに @property アノテーションでカラム情報を付与
    • 必須 マイグレーションをcommitする際はそれに対応するアノテーションの修正を含める
  • 推奨 アノテーションは Laravel IDE Helper Generator 等で自動化
サンプル: アノテーションとその自動化
  • アノテーションの例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    /**
    * @property int $id
    * @property string $name
    * ...
    * @property \Illuminate\Support\Carbon|null $created_at
    * @property \Illuminate\Support\Carbon|null $updated_at
    */
    class User extends Authenticatable
    {
    /* ... */
    }
  • Laravel IDE Helper Generatorによるアノテーション付与の自動化
    1
    2
    3
    4
    # Laravel IDE Helper Generatorのインストール
    $ composer require --dev barryvdh/laravel-ide-helper
    # 設定ファイルをGit管理
    $ php artisan vendor:publish --provider="Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider" --tag=config
    config/ide-helper.php
    1
    2
    3
    4
    5
    6
    7
    <?php
    return [
    /* ... */
    'post_migrate' => [
    'ide-helper:models --write', // デフォルト設定を変更
    ],
    ]
  • 必須 リレーションは利用の有無を問わず参照と被参照の両方を定義
  • 推奨 リレーションは @comment でアノテーション
目的・ねらい
  • たとえ利用が無かったとしても Documentation as Code として設計を明示する。
  • Laravel IDE Helper Generator はリレーションメソッドの @comment アノテーションよりモデルへのアノテーションを生成する
サンプル: リレーション定義とアノテーション
  • 両方向からの定義と @comment によるアノテーション
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    // laravel-ide-helperによる自動生成 / 「全投稿」「投稿主」は `@comment` より自動生成
    /**
    * @property-read Collection|\App\Models\Post[] $posts 全投稿
    * @property-read \App\Models\User $posts 投稿主
    */
    class User extends Model
    {
    /**
    * @comment 全投稿
    */
    public function posts()
    {
    $this->hasMany(Post::class);
    }
    }

    // laravel-ide-helperによる自動生成 / 「投稿主」は `@comment` より生成される
    /**
    * @property-read \App\Models\User $user 投稿主
    */
    class Post extends Model
    {
    /**
    * @comment 投稿主
    */
    public function user()
    {
    $this->belongsTo(User::class);
    }
    }

マイグレーション・テーブル構成

  • 推奨 開発初期など 共有環境が存在しない段階 ではマイグレーション運用を簡略化。
    • 必須 down()は存在自体が誤解の元となるためメソッド削除。
    • 禁止 既存テーブルに修正を加えるためのマイグレーションを実装しない。
    • 必須 既存テーブルに修正を加える際は元となるマイグレーション自体を修正。
    • 任意 以上と同等の運用を実現する例えばLaravel-Dacapoなどのツールを導入しても良い
  • 必須 以上はあくまで開発初期の運用であり 共有環境が用意された時点 でLaravel標準の運用へ切り替える
    • 任意 切り替えた後も down() の実装は任意。 down() の実装に注意を払うよりそれ自体が決して必要とならないマイグレーション運用やブランチ運用に務めるべき。
サンプル: マイグレーションファイルの内容
  • 禁止 down() は実装しない(例示はコメントアウトだが実際には削除)
    database/migrations/2014_10_12_000000_create_users_table.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    return new class() extends Migration {
    public function up()
    {
    Schema::create('users', function (Blueprint $table) {
    $table->id();
    /* ... */
    $table->timestamps();
    });
    }

    /*
    public function down()
    {
    Schema::dropIfExists('users');
    }
    */
    };
  • 禁止 既存テーブルに対する修正マイグレーション
    database/migrations/2022_07_10_000000_add_votes_to_users.php
    1
    2
    3
    Schema::table('users', function (Blueprint $table) {
    $table->integer('votes'); // 既存テーブルを変更する新規マイグレーション
    });
  • 必須 既存テーブルに修正を加える際は元のマイグレーションを修正
    database/migrations/2014_10_12_000000_create_users_table.php
    1
    2
    3
    4
    5
    6
    Schema::create('users', function (Blueprint $table) {
    $table->id();
    /* ... */
    $table->timestamps();
    $table->integer('votes'); // 新規作成せず既存のマイグレーションを修正
    });

前項の規約を採用した場合、特に複数人での開発時のデータベース運用に制約が発生する。それらを解決するための規約を以下に示す。

  • 必須 マイグレーションの適用は常に migrate:fresh --seed を想定
    • 禁止 マイグレーション管理外リソースを作成してはならない
    • 推奨 主要な機能全てにアクセス可能な十分な初期データをシーダとして実装
目的・ねらい
  • マイグレーションの適用は常に migrate:fresh --seed を想定する
    • 適用済マイグレーションに対する修正は migrate では反映されない
    • down() が実装されないため migrate:refesh は使えない
  • マイグレーション管理外リソースを作成してはならない
    • 作成しても migrate:fresh により削除される
  • 主要な機能全てにアクセス可能な十分な初期データをシーダとして実装
    • 日常的にデータが初期化されたとしても速やかに各機能へアクセスする手段として
  • 推奨 カラムへのコメント付与
    • 任意 id / created_at など意味が自明なカラムは任意
    • 必須 意味の把握にプロジェクト固有の知識が必要なカラムは必須
サンプル: マイグレーションからのコメント付与
  • コメント付与の例(付与の有無サンプル)
    database/migrations/2014_10_12_000000_create_users_table.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    Schema::create('users', function (Blueprint $table) {
    $table->id(); // フレームワークの標準機能の範囲内の利用(任意)
    $table->text('email')->unique(); // 名前より意味が容易に推測可能(任意)
    $table->timestamp('last_login_at')->nullable()
    ->comment('最終リクエスト日時'); // 意味は自明だが仕様の理解が必要(必須)
    $table->timestamps(); // フレームワークの標準機能の範囲内の利用(任意)
    $table->softDeletes()
    ->comment("「アカウント無効化」を論理削除で実装"); // フレームワーク標準だが用途が固有(必須)
    }
  • リレーションと外部キー制約
    • 推奨 リレーションに関連する命名(テーブル名・カラム名)はEloquentの規約に準ずる
      • 任意 規約が要件やドメインと合致しない場合は準拠しなくても良い
    • 必須 リレーションの参照テーブルには外部キー制約を付与
    • 必須 リレーションの参照列にはにはインデックスを作成
  • Eloquentの規約に準拠した場合のカラム定義
    • 必須 規約準拠を明確に示すためそれ用のメソッドを使う
    • 必須 参照はテーブルではなくモデルに対して行う
サンプル: リレーションの定義
  • 禁止 テーブルやカラムを直接参照しており規約準拠も明示されていない
    1
    $table->foreign('user_id')->references('id')->on('users')->index();
  • 禁止 規約準拠は明示されているがインデックスが作成されてない
    1
    $table->foreignIdFor(User::class)->constrained();
  • 必須 規約準拠を明示しインデックスを作成
    1
    $table->foreignIdFor(\App\Models\User::class)->index()->constrained();

シーディング・モデルファクトリ

  • 必須 ダミー値や乱数の生成はLaravelが提供するfakerインスタンスを使う
  • 必須 db:seed 開始直後にfakerインスタンスのseedを固定値で初期化
目的・ねらい

乱数元とそのシード値を固定することでダミーデータに再現性を持たせる。

シーダやファクトリが修正されない限りいつシーディングを実行しても生成されるデータは同一となり、次のような問題が回避できる。

  • 複数人で開発している際の各環境でのデータの差異
  • ダミーデータのランダム性が原因となるフレイキーテスト
サンプル
  • 禁止 faker以外の方法でダミーデータを生成
    database/factories/UserFactory.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public function definition()
    {
    return [
    'uuid' => uuid_create(UUID_TYPE_RANDOM),
    'name' => substr(str_shuffle("ABCDEFGHJKLMNPQRSTUVWXYZ"), 0, 5),
    'token' => sha1(uniqid()),
    'rank' => mt_rand(1, 10),
    ];
    }
  • 必須 ダミーデータはfakerで生成
    database/factories/UserFactory.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    public function definition()
    {
    return [
    'uuid' => fake()->uuid(),
    'name' => fake()->name(),
    'token' => sha1(fake()->lexify('?????????????')),
    'rank' => fake()->numberBetween(1, 10),
    ];
    }
  • 必須 シーディング開始時にfakerのシード値を固定
    database/seeders/DatabaseSeeder.php
    1
    2
    3
    4
    5
    public function run(): void
    {
    fake()->seed(42);
    /* ... */
    }
  • 禁止 外部キー制約の参照列の値をモデルファクトリで直接設定しない
サンプル
  • 禁止 ファクトリの中でリレーションを確立(動作が他のモデルの存在に依存)
    database/factories/PostFactory.php
    1
    2
    3
    4
    5
    6
    7
    public function definition()
    {
    return [
    'user_id' => fake()->randomElement(User::pluck('id')), // NG
    'category_id' => 1, // NG
    ];
    }
  • 必須 既存のモデルは参照せず参照先モデルの同時作成をデフォルト動作とする
    database/factories/PostFactory.php
    1
    2
    3
    4
    5
    6
    7
    public function definition()
    {
    return [
    'user_id' => User::factory(), // OK
    'category_id' => Category::factory(), // OK
    ];
    }
    database/seeders/PostSeeder.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public function run()
    {
    // 投稿と所属先のユーザーやカテゴリを同時に作成
    $post = Post::factory()->create();

    // 既に存在するユーザーやカテゴリに所属する投稿を作成
    $user = User::query()->latest('id');
    $category = Category::query()->latest('id');
    $post = Post::factory()
    ->for($user)
    ->for($category)
    ->create();
    }

サービスコンテナ・ファサード

  • ファサードの利用可否は次の通り
  • サービスコンテナの利用可否は以下の通り
  • 禁止 app() 等によるサービスコンテナの直接利用は禁止。
    • コンストラクタインジェクションやメソッドインジェクションを使う。
  • 推奨 ファサードとサービスコンテナとで同じ機能が実現できる場合どちらを利用するか統一。
サンプル: Bladeテンプレート内での認証
  • 禁止 ファサードによる認証
    1
    2
    3
    @if (Auth::check())
    ログインしています。
    @endunless
  • 必須 Bladeディレクティブでの認証
    1
    2
    3
    @auth
    ログインしています。
    @endauth
サンプル: ファサードは完全修飾クラス名を利用
  • 禁止 エイリアスを経由した利用
    1
    $user = \Auth::user();
  • 必須 完全修飾クラス名の利用
    1
    2
    3
    use Illuminate\Support\Facades\Auth;

    $user = Auth::user();
サンプル: ファサードとコンストラクタインジェクション
  • 非推奨 同等の機能が異なる方法で混在して使われている
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    // 全く同じ機能が提供される契約とファサード
    use Illuminate\Contracts\Filesystem\Filesystem;
    use Illuminate\Support\Facades\Storage;

    class StorageService
    {
    public function __construct(
    private readonly Filesystem $storage,
    ) {
    parent::__construct();
    }

    public function size(string $path)
    {
    // サービスコンテナから注入されたインスタンスを利用
    return = $this->storage->size($destination);
    }

    public function put(string $path, string $content)
    {
    // ファサードを利用
    Storage::put($path, $content);
    }
    }

ルーティング

  • 必須 次の場合を除き名前付きルート必須
  • 禁止 次の場合を除きクロージャによるアクション実装は禁止
    • フォールバックルート
  • 必須 アプリケーション内でのURLの生成するはルート名を使う
サンプル
  • ルート名・クロージャ・URL生成
    routes/web.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    Route::controller(CommentController::class)->group(function () {
    // 必須: GETを代表とみなし底に対して命名
    Route::get('/comments/{comment}', 'show')->name('comments.show');
    // 任意: 同一URIでメソッドが異なるのみなので任意
    Route::put('/comments/{comment}', 'update');
    });

    // 任意: JavaScriptからの参照(アプリケーション内から参照されない)
    Route::get('/api/ping', PingController::class);
    // 任意: サービス外からの参照(アプリケーション内から参照されない)
    Route::get('/twitter/callback', TwitterCallback::class);
    // 任意: リダイレクトルート
    Route::redirect('/legacy-url.php', '/correct-url', 301);

    // 任意: フォールバックルートは命名任意、クロージャ可
    Route::fallback(fn () => abort(404));
  • 必須 ルート名よりURLを生成
    resources/views/welcone.blade.php
    1
    2
    3
    <a href="{{ route('comments.show', ['comment' => $comment]) }}">
    コメント表示
    </a>
  • 禁止 URLを直接記述
    resources/views/welcone.blade.php
    1
    2
    3
    <a href="/comments/{{ $comment->id }}">
    コメント表示
    </a>
  • 必須 パラメータの文字列バリデーションは正規表現制約またはそのヘルパで実装
  • 必須 パラメータからモデルを特定する場合モデル結合ルートで実装
    • 必須 モデル結合ルートのパラメータ名はモデル名のローワーキャメルケース
    • 推奨 結合は明示的に宣言
  • 必須 URL上の数値を受け取る場合アクション側ではintでタイプヒント
サンプル
  • パラメータ名・バリデーション
    routes/web.php
    1
    2
    3
    Route::get('/posts/{post}', PostController::class) // パラメータ名はモデル名
    ->whereNumber('post') // 書式チェックはルーティングの段階で行う
    ->name('posts.show');
  • モデル結合ルート
    app/Http/Controllers/User/PostController.php
    1
    2
    3
    4
    5
    // ($foo) や (int $foo) ではなく (Model $foo) として受け取る
    public function __invoke(Post $post)
    {
    /* ... */
    }
  • 結合の明示的な宣言
    app/Providers/RouteServiceProvider.php
    1
    2
    3
    4
    public function boot()
    {
    Route::model('user', User::class);
    }
  • 数値の受け取り
    routes/web.php
    1
    2
    3
    Route::get('/list/pages/{page}', ListController::class)
    ->whereNumber('page') // 書式(数値)の確認はルータが行い
    ->name('list');
    app/Http/Controllers/ListController.php
    1
    2
    3
    4
    5
    // アクションではintで受け取る
    public function __invoke(int $page)
    {
    /* ... */
    }

HTTPリクエスト

本稿は HTTPリクエスト に関する規約。フォームリクエストは別途定める。

  • 禁止 $request->foo による値の取得は禁止
  • 非推奨 $request->input('foo') / $request->all() などメソッドとを区別しない手段は非推奨
    • ペイロードは $request->post('foo') 、クエリパラメータは $request->query('foo') と言ったように取得元に応じて適切に使い分ける。
サンプル
  • 禁止・非推奨 動的プロパティによる取得 / GET,POSTを区別しない取得
    1
    2
    3
    4
    5
    6
    // ページング機能つきの投稿一覧画面 / 投稿に対するコメントも同じURLでPOSTで受け取る
    public function list(Request $request) {
    $page = $request->page; // ページング番号
    $post_id = $request->input('post_id'); // コメントする投稿
    $comment = $request->input('comment'); // コメント
    }
  • 推奨 GET, POSTを区別し取得
    1
    2
    3
    4
    5
    public function list(Request $request) {
    $page = $request->query('page'); // ページング番号はURLパラメータで渡される
    $post_id = $request->post('post_id'); // コメント情報はPOSTパラメータで渡される
    $comment = $request->post('comment'); // コメント情報はPOSTパラメータで渡される
    }

認可

サンプル
  • 禁止 認可処理をアクションへ直接実装
    app/Http/Controllers/User/PostController.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    use App\Http\Controllers\Controller;

    class PostController extends Controller
    {
    public function edit(Post $post)
    {
    abort_unless($post->user_id === Auth::id(), 403);
    /* ... */
    }
    }
  • 必須 認可処理はポリシーを実装しアクションからはそれを利用
    app/Models/Policies/PostPolicy.php
    1
    2
    3
    4
    public function edit(User $user, Post $post): bool
    {
    return $post->user_id === $user->id;
    }
    app/Http/Controllers/User/PostController.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    use App\Http\Controllers\Controller;

    class PostController extends Controller
    {
    public function edit(Post $post)
    {
    $this->authorize('edit', $post);
    /* ... */
    }
    }

バリデーション・フォームリクエスト

  • 必須 次に該当するバリデーションはフォームリクエストで実装
    • 入力値の書式やファイルアップロード結果、その組わせのみで完結する処理
    • モデル結合ルートの解決結果やそのリレーションを使った処理
    • 認証結果やそのリレーションを使った処理
    • 更新対象テーブル単独で完結する処理(テーブル内の値の重複チェックなど)
  • 禁止 前項以外の処理をフォームリクエストで行ってはならない
  • 必須 フォームリクエストへのアノテーション
    • バリデーション対象パラメータは @property-read でアノテーション
      • FormRequest内で値を加工する場合、加工後の型を定義
      • モデル結合ルートへのリクエストを前提としている場合、結合されるモデル
      • 認証を伴うリクエストは認証結果として期待されるモデル
サンプル
  • バリデーションの実装
    app/Http/Requests/PostRequest.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    use Illuminate\Support\Facades\Gate;

    /**
    * @property-read string $content 本文
    * @property-read null|array<null|string> $tags タグ
    * @property-read App\Models\Post|null $post
    * @method App\Models\User user()
    */
    class PostRequest extends FormRequest
    {
    public function authorize(): bool
    {
    // 更新: PUT /posts/{post}
    // モデル結合ルート(明示的な結合)より `$this->post` の型が保証される
    if($this->post) {
    return Gate::authorize('update', $post);
    }

    // 作成: `POST /posts`
    // 新規作成を同じFormRequestで処理する場合 `$this->post` はnullable
    return Gate::authorize('create', $post);
    }

    public function rules(Request $request): array
    {
    return [
    // このルールにより $this->tags が配列であることが保証される
    'tags.*' => [
    'nullable',
    ],
    // このルールにより $this->content の存在が保証される
    'content' => [
    'required',
    ],
    ];
    }
    }

    public function withValidator($validator)
    {
    $validator->after(function ($validator) {
    // Illuminate\Http\Request::user() は本来mixedが返却されるが、
    // クラス冒頭の `@method` により認証済モデルの型は絞り込みが行われている。
    // その絞り込み結果に基づくコード補完や静的解析が可能
    if ($this->user()->is_suspended) {
    $validator->errors()->add('content', '投稿は制限されています。');
    }
    });
    }
  • バリデーション済リクエストのアクションからの利用
    app/Http/Controllers/PostController.php
    1
    2
    3
    4
    5
    6
    7
    public function update(PostRequest $request, Post $post)
    {
    // バリデーションにより存在が保証されやアノテーション済の動的プロパティを利用
    $post->content = $request->content;
    $post->save();
    /* ... */
    }

JsonAPI(APIリソース)

  • 必須 JsonAPI形式のレスポンスは APIリソース で生成。
  • 必須 実装は App\Http\Resources 配下としクラス名には元となるモデル名とサフィックス Resource を含める。
  • 必須 公式ドキュメント記載 $this->foo ではなく $this->resource->foo の形で実装。
  • 必須 APIリソースの生成元となる型を @property-read でアノテーション。
  • 必須 日付や日時は次の何れかの形式。
    • ISO 8601: now()->toIso8601String()
    • UnixTime: now()->getTimestamp()
    • UnixTimeMS: now()->getTimestampMs()
  • 禁止 parent::toArray() 等による返却の生成は禁止
目的・ねらい
  • JsonAPI形式のレスポンスはAPIリソースで生成。
  • 実装は App\Http\Resources 配下としクラス名には元となるモデル名とサフィックス Resource を含める。
    • 実装を特定のディレクトリを集約し名称に規則性を設けることでAPI仕様を一元管理。
  • 公式ドキュメント記載 $this->foo ではなく $this->resource->foo の形で実装。
    • $this->foo はLaravel側で $this->resource->foo委任されている。
  • APIリソースの生成元となる型を @property-read でアノテーション。
    • 前項と併せ $this->resource へ型付けによりコード補完や静的解析を利用。
  • 日付や日時は次の何れかの形式。
    • JavaScriptを含むあらゆる処理系で確実にパース可能。
  • parent::toArray() 等による返却の生成は禁止
    • Eloquent側でJSONを生成 することによりAPIの仕様と実装とが異なる場所で管理されるのを避ける。
    • テーブル定義が変更された際APIの仕様が意図せず変わってしまうことを防ぐ。特に $hidden の指定漏れによる機密データの漏洩を確実に防ぐ。
サンプル
  • APIリソース実装例
    app/Http/Resources/PostResource.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    /**
    * @property-read Post $resource
    */
    class PostResource extends JsonResource
    {
    public function toArray($request): array
    {
    return [
    'id' => $this->resource->id,
    'title' => $this->resource->name,
    'created_at' => $this->resource->created_at->toIso8601String(),
    'updated_at' => $this->resource->created_at->toIso8601String(),
    ];
    }
    }
  • アクションからの返却
    app/Http/Controllers/User/PostController.php
    1
    2
    3
    4
    public function show(Post $post) 
    {
    return new PostResource($post);
    }

ログ

  • 禁止 ログ出力先の制御はアプリケーションからは行わない
  • 必須 ログ出力先の制御は環境変数から行えるようにする。
    • config/logging.php では default チャンネルの設定は変更せず環境変数 LOG_CHANNEL で制御
  • 任意 本番環境でのアラートなど高度なフィルタ要件が存在する場合 stack チャンネルの設定を変更しても良い
目的・ねらい

次のような要件やインフラ変更に容易に対応。

  • アプリケーションの冗長化やコンテナ化を行うケース
    例えばコンテナ化の場合 LOG_CHANNELstderr に変更するのみでログは永続化される。
  • 同一アプリケーションを異なるログ要件で稼働させるケース
    タスクスケジューラやキューワーカーを別個で稼働させる等。
  • エラー発生時のアラート設定
    特に外部サービスへの通知などはローカル環境での開発に制限が生じるためこれら要件はアプリケーションは関知すべきでない。

Bladeテンプレート

  • 必須 resources/views/ 配下のディレクトリ構成
    ディレクトリ名 用途 説明
    components/ Bladeコンポーネント 標準構成
    components/layouts/ レイアウト(コンポーネントを利用する場合) 本規約独自
    layouts/ レイアウト(テンプレート継承を利用する場合) 本規約独自
    errors/ エラー(主にHTTPエラー)時の出力画面 標準構成
    emails/ メール本文を生成するためのテンプレート ドキュメント
    pages/ ページレンダリングの起点となるテンプレート 本規約独自

メール送信

目的・ねらい
  • メール送信はLaravelのメール機能のみで実装
    • 全てのメール送信を環境変数のみで制御可能な状態とする。
    • 以上より、特にローカル環境で array / log ドライバや MailHog / MailCatcher 等のツールを活用しインシデントを防止。
  • ShouldQueue を実装しデフォルトでキューを利用
    • Mailableに対するテストは実装側でのキューの利用有無に応じて使うべきアサーション assertSent() / assertQueued() が変わる。
    • ShouldQueue を実装してもその動作を環境変数で抑制することは可能。逆は不可能。
    • 以上より ShouldQueue / assertQueued() で予め統一することでキューの利用有無を変更する度にテストを修正する必要がなくなる。
    • 実際の利用有無は実装でなく環境変数 QUEUE_CONNECTION で制御。
    • デフォルト設定sync なので何も設定しない場合キューは使われない
サンプル: メール送信のキュー制御
  • 必須 キューの有無に関わらず ShouldQueue は常に実装
    app/Mail/WelcomeMail.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // use Illuminate\Bus\Queueable;
    use Illuminate\Contracts\Queue\ShouldQueue;

    class WelcomeMail extends Mailable
    implements ShouldQueue // 必ず指定
    {
    // アプリケーションからはキュー制御は行わないのであれば不要
    /* use Queueable; */

    /* ... */
    }
  • 必須 アサーションは assertQueued() に統一
    tests/Feature/Http/Controllers/UserControllerTest.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * @test
    */
    public function testAssertMailSent()
    {
    Mail::fake();

    /* ... */

    // アサーションは `assertQueued()` に統一
    Mail::assertQueued(WelcomeMail::class);
    }

ファイルストレージ

以上の規約に準拠する場合ファイルストレージは次の通り設計。

  • 必須 アプリケーションからは default のみ参照
  • 必須 default の設定は変更せず FILESYSTEM_DISK 環境変数で利用先を制御。
  • 必須 デフォルト設定に存在しないドライバを利用する場合を除き config/filesystems.php に予め用意されたドライバを使う。
  • 必須 ローカル環境では public ではなく local を使う
  • 推奨 storage:link が生成する public/storagepublic/public に変更
目的・ねらい
  • アプリケーションからは default のみ参照
  • default の設定は変更せず FILESYSTEM_DISK 環境変数で利用先を制御
  • デフォルト設定に存在しないドライバを利用する場合を除き config/filesystems.php に予め用意されたドライバを使う。
    • プロジェクト固有設定や標準外ストレージの存在によるメンテコスト増大を防止
    • 異なる環境(例:ローカル&本番)であっても環境変数の変更のみで対応
  • public ディスクは使わず local ディスクを使う
    • local からは public 相当領域を参照できる。逆はできない。
    • パーミッション(publicアクセスの可否)はインフラ側で制御。
  • storage:link が生成する public/storagepublic/public に変更
    • local ディスクのpubliic/ 配下に設置したファイルはWebサーバ上の /storage/ に公開 がデフォルトの動作だが、本変更でパスとURLとが一致することで次のような構成が可能となる
      • ローカル環境 - FILESYSTEM_DISK=local
        disk://public/ 設置したファイル は storage/app/public/ へ配置されWebサーバからはシンボリックリンクを経由し /public/ を起点に閲覧可能
      • 本番環境 - FILESYSTEM_DISK=s3
        disk://public/ 配下に設置したファイルは例えば s3://AWS_BUCKET/public/ へ配置されそれをオリジンとする CloudFront Behavior により /public/ を起点に閲覧可能
      • ローカル環境と全く同じパス構成をCDNやロードバランサへ設定可能とする
参考コード・参考コマンド
  • ストレージ設定 storage:link 時のリンク作成先を変更する例
    config/filesystems.php
    1
    2
    3
    4
    5
    6
    7
    return [
    /* ... */
    'links' => [
    // public_path('storage') => storage_path('app/public'),
    public_path('public') => storage_path('app/public'),
    ],
    ]
  • storage:link は変更せず本規約自体をcommitする例
    1
    2
    3
    4
    5
    # 本規約の変更に対応するリンクを作成
    $ ln -s ../storage/app/public public/public
    # 作成したリンクをcommit
    $ git add public/public
    $ git commit

タスクスケジューラー

  • 推奨 cronではなくタスクスケジューラを使う
  • 推奨 要件を満たせる限り、スケジュール定義には onOneServer() / withoutOverlapping() 双方を指定
目的・ねらい
  • 推奨 cronではなくタスクスケジューラを使う
    • バッチ処理のスケジューリングはアプリケーションの実装や設計と密結合することが多い。
    • バッチ処理の追加や修正(Git管理内)を行った際は前提となるcron等の設定(Git管理外)を別途でデプロイしなければならない、という状況を避ける
  • 推奨 要件を満たせる限り、スケジュール定義には onOneServer() / withoutOverlapping() 双方を指定する
    • タスクスケジューラを冗長化する状況を想定(バッチ処理の排他制御)
    • 排他制御の必要のないタスク、並列化すべきタスク、これらへの指定は当然不要
サンプル: 並列化を考慮したタスクスケジュール
  • onOneServer() / withoutOverlapping() を利用する例としない例
    app/Console/Kernel.php
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    protected function schedule(Schedule $schedule)
    {
    // 1時間に1回: 一時ファイルをクリーンアップ / **全サーバで実行**
    $schedule->cron('0 * * * *')->exec('tmpwatch 240 /path/to/tmp')
    ->withoutOverlapping(); // `onOneServer()` **不要**

    // 1日1回: 投稿数の集計 / 並列化の有無を問わず **1回だけ** 実行
    $schedule->cron('0 0 * * *')->command(CountPostsCommand::class)
    ->withoutOverlapping()->onOneServer(); // `onOneServer()` **必要**

    // 毎分(常時): ヘルスチェック成功を監視サーバへ通知 / 全サーバで常時実行
    $schedule->cron('* * * * *')->command(HealthCheckCommand::class)
    ->pingOnSuccess($healthCheckUrl)
    ; // `onOneServer()` / `withoutOverlapping()` **双方不要**
    }

フロントエンド

  • 推奨 デプロイの際にフロントエンドのビルドが必要な場合、リソースは resources/ 配下(又は更に下)に配置。
目的・ねらい

依存を局所化することが目的だが特にアプリケーションをコンテナ化する際のビルド時間の短縮に寄与する。フロントエンドのビルドに必要なアセットを専用ディレクトリ配下に閉じることでDockerレイヤーキャッシュのヒット率向上を見込む。つまりフロントエンドとは関係のないファイルしか更新されていない場合にも再ビルドが行われてしまうのを防止する。

テスト

  • 必須 vendor/laravel に依存するテストは Feature に配置
    • 必須 Tests\TestCase を又はそのサブクラスを継承
    • 禁止 Tests\TestCase の実装を修正しない
  • 必須 vendor/laravel に依存しないテストを Unit に配置
    • 禁止 Tests\TestCaseIlluminate\Foundation\Testing\TestCase を継承してはならない
  • 必須 Tests\Feature\ Test\Unit\App\ 配下とで名前空間の階層名を揃える
  • 必須 テスト対象となるクラス名にサフィックス Test を付与したものがテストクラス名。
    • $this->get() / $this->post() 等の疑似リクエストはコントローラーを対象としたテストと見做す
サンプル: テスト対象とテストの配置・テスト作成コマンド
  • 必須 テストクラスの配置例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    app
    └─ Services
    ├─ FizzBuzzService.php # FizzBuzz計算を行うクラス
    └─ StorageService.php # ストレージアクセスを行うクラス
    tests
    ├─ Feature
    │└─ Services # app配下の階層とあわせる
    │ └─ StorageServiceTest.php # Laravelの機能を利用するためFeature
    └─ Unit
    └─ Services # app配下の階層とあわせる
    └─ FizzBuzzServiceTest.php # Laravelの機能を利用しないのでUnit
  • 参考 make:test によるテスト作成
    1
    2
    3
    4
    # `Tests\TestCase` の継承と適切なディレクトリ配置が行われる
    $ ./artisan make:test Services/StorageServiceTest
    # `--unit` でUnit配下へ作成され継承元クラスも変更される
    $ ./artisan make:test Services/FizzBuzzServiceTest --unit

参考記事・関連記事

更新履歴
  • 2022年7月31日
    • 「Eloquent・クエリビルダ」リレーション実装目的を変更 #19
    • Eloquent利用に関する補足やサンプルコードを充実化 #20