714 words, 4 min read

Developers coming from Laravel are used to FormRequest classes that encapsulate request validation and authorization. A typical FormRequest contains validation rules, optional authorization logic, and automatically provides validated input to the controller.

Phoenix takes a slightly different approach. Instead of request-focused validation objects, validation is typically handled using Ecto changesets. This approach moves validation closer to the data model and keeps controllers thin.

This article explains how validation works in Phoenix and how to implement reusable custom validation rules similar to Laravel.

Validation with Ecto changesets

In Phoenix, validation is usually implemented inside an Ecto changeset. A changeset handles three responsibilities:

  • casting incoming parameters
  • validating data
  • collecting validation errors

A typical schema with validations looks like this:

defmodule MyApp.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
end
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name])
|> validate_required([:email, :name])
|> validate_format(:email, ~r/@/)
end
end

Incoming request data is passed to the changeset through a context function:

def create_user(attrs) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end

The controller then handles the result:

def create(conn, params) do
case Accounts.create_user(params) do
{:ok, user} ->
json(conn, user)
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: changeset.errors})
end
end

This already provides most of the functionality developers expect from Laravel FormRequests.

Writing custom validation rules

Custom validation logic in Phoenix is implemented as functions that operate on a changeset. These functions can be private helpers or reusable validation utilities.

Field-level custom validation

The most common tool for custom rules is validate_change/3.

defp validate_company_email(changeset) do
validate_change(changeset, :email, fn :email, email ->
if String.ends_with?(email, "@company.com") do
[]
else
[email: "must be a company email"]
end
end)
end

You can include this in a changeset pipeline:

def changeset(user, attrs) do
user
|> cast(attrs, [:email])
|> validate_required([:email])
|> validate_company_email()
end

If the validation fails, an error is added to the changeset.

Reusable validation helpers

If validation logic should be reused across schemas, it can be extracted into a module.

defmodule MyApp.Validations do
import Ecto.Changeset
def validate_company_email(changeset, field) do
validate_change(changeset, field, fn ^field, email ->
if String.ends_with?(email, "@company.com") do
[]
else
[{field, "must be a company email"}]
end
end)
end
end

Usage inside a changeset:

import MyApp.Validations
def changeset(user, attrs) do
user
|> cast(attrs, [:email])
|> validate_required([:email])
|> validate_company_email(:email)
end

This pattern is similar to reusable validation rules in Laravel.

Cross-field validation

Some rules depend on multiple fields. In these cases, the changeset can be inspected directly.

For example, validating a date range:

def validate_date_range(changeset) do
start_date = get_field(changeset, :start_date)
end_date = get_field(changeset, :end_date)
if start_date && end_date && Date.compare(start_date, end_date) == :gt do
add_error(changeset, :start_date, "must be before end date")
else
changeset
end
end

Used inside a changeset:

def changeset(event, attrs) do
event
|> cast(attrs, [:start_date, :end_date])
|> validate_required([:start_date, :end_date])
|> validate_date_range()
end

Database-backed validation

Some validation rules depend on the database. For example, checking whether an email already exists.

While this can be implemented manually, the preferred approach is to rely on database constraints.

|> unique_constraint(:email)

This requires a unique index in the database and prevents race conditions that can occur with manual checks.

Key building blocks

Custom validation in Ecto is built on a few core functions:

  • validate_change/3
  • add_error/3
  • get_field/2
  • validate_required/2
  • validate_length/3
  • validate_format/3
  • validate_number/3
  • validate_inclusion/3

Most complex validation logic can be composed from these primitives.

Comparing Laravel and Phoenix validation

Laravel focuses validation around the HTTP request, while Phoenix places validation closer to the data layer.

Laravel:

FormRequest
Validator
Controller

Phoenix:

Controller
Context
Changeset

This design makes validation reusable across:

  • HTTP APIs
  • Phoenix HTML forms
  • LiveView forms
  • background jobs
  • internal application logic

By attaching validation to the data structure instead of the request, Phoenix ensures consistent validation regardless of where data enters the system.

Conclusion

Phoenix does not provide a direct equivalent to Laravel FormRequests, but Ecto changesets offer a powerful and flexible alternative.

Validation rules live alongside the data structure, are easily composable, and can be reused across different parts of the application. Custom rules are implemented as simple functions that transform changesets, making them easy to test and reuse.

For developers moving from Laravel, the key mindset shift is moving validation from the request layer to the data layer. Once adopted, this pattern results in clean controllers, reusable validation logic, and consistent data integrity across the entire application.