※本記事は「RailsからLaravelを眺める」シリーズの第6回です。Rails出身の私がLaravelを触りながら、Railsと比較して違いを整理していく連載になります。
はじめに
Rails と Laravel はどちらも強力なバリデーション機構を備えていますが、Railsは“モデル中心”/Laravelは“リクエスト中心”というアーキテクチャの前提が違います。Railsの王道はモデルレベルの validates
と errors
、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
- バリデーションを FormObject に移す
- コントローラは
@form = XxxForm.new(params)
→@form.save
に変更 - モデルには最小限の
validates
を残す - ビューは
form_with model: @form
でOK
Laravel:$request->validate() → FormRequest
php artisan make:request
で作成- 既存のルールを
rules()
/messages()
に移植 - コントローラ引数を FormRequest 型に変更し
validated()
を利用
まとめ
- Rails は「モデルを守る」思想。モデル直書きが基本で、必要に応じて FormObject を導入すると責務を分離できる。
- Laravel は「リクエストを守る」思想。FormRequest が基本形で、認可・前処理・検証・後処理を一括管理できる。
Rails出身者がLaravelに触れると「なぜモデルに書かない?」と戸惑う一方で、実務で使うと「コントローラがスッキリし、リクエスト単位で柔軟に設計できる」メリットを実感します。
逆にLaravelからRailsに来た人は「モデルに集約されている」ことに驚くでしょう。
結局はどちらが正しいかではなく、責務をどこに置くのかという設計判断の問題です。両方の流儀を理解しておくことで、フレームワーク選択やアプリ設計の幅がぐっと広がります。
コメント