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/3add_error/3get_field/2validate_required/2validate_length/3validate_format/3validate_number/3validate_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.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.