Rails×Laravel バリデーション実装ガイド|FormObjectとFormRequestで学ぶ設計思想

RailsからLaravelを眺める

※本記事は「RailsからLaravelを眺める」シリーズの第6回です。Rails出身の私がLaravelを触りながら、Railsと比較して違いを整理していく連載になります。


はじめに

Rails と Laravel はどちらも強力なバリデーション機構を備えていますが、Railsは“モデル中心”/Laravelは“リクエスト中心”というアーキテクチャの前提が違います。Railsの王道はモデルレベルの validateserrors、Laravelの王道は FormRequest による事前検証です。

この記事では、基本の形から実務的な応用(FormObject/FormRequest)までをコード例付きで整理します。


基本の形:Railsはモデルに書く/LaravelはFormRequestに書く

Rails(モデル中心・最小例)

# app/models/user.rb
class User < ApplicationRecord
  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, uniqueness: true
  validates :age,   numericality: { greater_than_or_equal_to: 18 }
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @user = User.new(user_params)
    if @user.save
      redirect_to @user
    else
      render :new, status: :unprocessable_entity
    end
  end

  private
  def user_params
    params.require(:user).permit(:name, :email, :age)
  end
end

Railsでは「モデルが常に正しい状態を持つこと」を重視。valid? / errors によって一元的に扱えます。

Laravel(リクエスト中心・最小例)

php artisan make:request StoreUserRequest
// app/Http/Requests/StoreUserRequest.php
class StoreUserRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'name'  => 'required|string|max:50',
            'email' => 'required|email|unique:users,email',
            'age'   => 'required|integer|min:18',
        ];
    }
}
// app/Http/Controllers/UserController.php
class UserController extends Controller
{
    public function store(StoreUserRequest $request)
    {
        $user = User::create($request->validated());
        return redirect()->route('users.show', $user);
    }
}

Laravelは「リクエストがコントローラに到達する前に検証される」ため、常に正しい入力を前提にできます。


Rails:FormObjectで“入力単位”の責務を切り出す

単票のFormObject

# app/forms/user_signup_form.rb
class UserSignupForm
  include ActiveModel::API

  attr_accessor :name, :email, :age

  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :age,   numericality: { greater_than_or_equal_to: 18 }

  def save
    return false unless valid?
    User.create!(name:, email:, age:)
  end
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
  def create
    @form = UserSignupForm.new(user_params)
    if @form.save
      redirect_to root_path, notice: "Signed up"
    else
      render :new, status: :unprocessable_entity
    end
  end

  private
  def user_params
    params.require(:user).permit(:name, :email, :age)
  end
end

複数モデルをまとめるFormObject

# app/forms/registration_form.rb
class RegistrationForm
  include ActiveModel::API
  attr_accessor :name, :email, :bio, :twitter

  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :bio,   length: { maximum: 200 }, allow_blank: true

  def save
    return false unless valid?
    ActiveRecord::Base.transaction do
      user = User.create!(name:, email:)
      Profile.create!(user:, bio:, twitter:)
    end
    true
  rescue ActiveRecord::RecordInvalid => e
    errors.add(:base, e.record.errors.full_messages.join(", "))
    false
  end
end

ステップフォーム(段階的検証)

class WizardForm
  include ActiveModel::API
  attr_accessor :email, :password, :address

  validates :email,    presence: true, on: :step1
  validates :password, presence: true, length: { minimum: 12 }, on: :step1
  validates :address,  presence: true, on: :step2

  def valid_for?(step)
    valid?(step)
  end
end

Laravel:FormRequestで“HTTP入力”を先に正す

基本的なFormRequest

class StorePostRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'title' => 'required|string|max:120',
            'slug'  => 'required|string|alpha_dash|unique:posts,slug',
            'tags'  => 'array',
            'tags.*'=> 'string|max:20|distinct',
        ];
    }
}

入力前後のフックと追加検証

class StorePostRequest extends FormRequest
{
    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug($this->input('slug'))]);
    }

    protected function passedValidation(): void
    {
        $this->replace(['title' => trim($this->title)]);
    }

    public function after(): array
    {
        return [function (\Illuminate\Validation\Validator $v) {
            if ($this->tooManyTags()) {
                $v->errors()->add('tags', 'タグが多すぎます。');
            }
        }];
    }
}

条件付き・配列の検証

// 条件付き
'has_appointment'  => 'required|boolean',
'appointment_date' => 'exclude_if:has_appointment,false|required|date',

// 配列
'users' => 'required|array|min:1',
'users.*.email' => 'required|email|distinct',

同一要件を FormObject(Rails)/FormRequest(Laravel)で

要件

  • name: 必須/最大50
  • email: 必須/ユニーク
  • age: 18+
  • phone: 任意(あれば10〜11桁)

Rails(FormObject)

class SignupForm
  include ActiveModel::API
  attr_accessor :name, :email, :age, :phone

  validates :name,  presence: true, length: { maximum: 50 }
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :age,   numericality: { greater_than_or_equal_to: 18 }
  validates :phone, allow_blank: true,
                    format: { with: /\A\d{10,11}\z/, message: "は10〜11桁で入力してください" }

  def save
    return false unless valid?
    User.create!(name:, email:, age:, phone:)
  end
end

Laravel(FormRequest)

class StoreUserRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'name'  => 'required|string|max:50',
            'email' => 'required|email|unique:users,email',
            'age'   => 'required|integer|min:18',
            'phone' => 'nullable|regex:/^\d{10,11}$/',
        ];
    }

    public function messages(): array
    {
        return ['phone.regex' => '電話番号は10〜11桁で入力してください。'];
    }
}

使い勝手と責務の違い

観点Rails(モデル/+FormObject)Laravel(FormRequest)
基本スタイルモデルに集約(valid? / errorsリクエストに集約(事前自動検証)
入力単位の設計FormObjectでユースケース単位に切り出しルート/アクション単位で標準化
ビュー連携form_with model: で ActiveModel準拠をバインドBladeの @error、JSONは422+標準整形
前後処理任意実装(自前フック)prepareForValidation / passedValidation / after
Strong Parameters必須(明示許可)不要(validated() が許可済み入力)
読み筋モデルを見れば制約が把握しやすいモデルだけでは分からない→Requestを参照

現場で効く実装パターン

Rails:FormObject+サービス層

class RegistrationForm
  include ActiveModel::API
  attr_accessor :name, :email, :plan

  validates :name, presence: true
  validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  validates :plan,  inclusion: { in: %w[free pro enterprise] }

  def save
    return false unless valid?
    RegisterUser.call(name:, email:, plan:)
  rescue RegisterUser::Error => e
    errors.add(:base, e.message)
    false
  end
end

Laravel:FormRequest“全部載せ”

class StoreTeamRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Team::class);
    }

    protected function prepareForValidation(): void
    {
        $this->merge(['slug' => Str::slug($this->input('slug'))]);
    }

    public function rules(): array
    {
        return [
            'team_name' => 'required|string|min:1',
            'users'     => 'required|array|min:1',
            'users.*.email' => 'required|email|distinct',
            'has_appointment' => 'required|boolean',
            'appointment_date' => 'exclude_if:has_appointment,false|required|date',
        ];
    }

    public function after(): array
    {
        return [function (\Illuminate\Validation\Validator $v) {
            if ($this->usersCountTooLarge()) {
                $v->errors()->add('users', 'チーム人数が上限を超えています。');
            }
        }];
    }
}

段階的に寄せる・移行する

Rails:モデル直書き → FormObject

  1. バリデーションを FormObject に移す
  2. コントローラは @form = XxxForm.new(params)@form.save に変更
  3. モデルには最小限の validates を残す
  4. ビューは form_with model: @form でOK

Laravel:$request->validate() → FormRequest

  1. php artisan make:request で作成
  2. 既存のルールを rules()messages() に移植
  3. コントローラ引数を FormRequest 型に変更し validated() を利用

まとめ

  • Rails は「モデルを守る」思想。モデル直書きが基本で、必要に応じて FormObject を導入すると責務を分離できる。
  • Laravel は「リクエストを守る」思想。FormRequest が基本形で、認可・前処理・検証・後処理を一括管理できる。

Rails出身者がLaravelに触れると「なぜモデルに書かない?」と戸惑う一方で、実務で使うと「コントローラがスッキリし、リクエスト単位で柔軟に設計できる」メリットを実感します。
逆にLaravelからRailsに来た人は「モデルに集約されている」ことに驚くでしょう。

結局はどちらが正しいかではなく、責務をどこに置くのかという設計判断の問題です。両方の流儀を理解しておくことで、フレームワーク選択やアプリ設計の幅がぐっと広がります。


参考リンク

コメント

タイトルとURLをコピーしました