Mastering Parameter Validation in Elixir Phoenix Controllers
Parameter validation is one of the most critical aspects of building robust Elixir Phoenix APIs. Invalid parameters can lead to runtime errors, security vulnerabilities, and poor user experiences. This guide walks you through implementing elegant, reusable parameter validation patterns using Ecto.Changeset and a clean utility module.
Why Parameter Validation Matters
Every API endpoint receives external input. Without proper validation, this untrusted data can crash your application, expose sensitive information, or cause unexpected behavior. Parameter validation is your first line of defense, ensuring that only valid data reaches your business logic.
Good validation achieves several things: it prevents invalid data from corrupting your database, it provides clear error messages to clients, and it reduces the complexity of your controller and service code. Phoenix developers often struggle with ad-hoc validation scattered across controllers. A centralized validation utility eliminates duplication and ensures consistency across your application.
Understanding the Web.Utils.Validation Module
The Web.Utils.Validation module provides a lightweight abstraction over Ecto.Changeset, specifically designed for Phoenix controllers. Rather than creating database schemas just for validation, this module lets you define simple schema specifications and leverage Ecto's powerful validation machinery.
The module implements three core functions:
- new/2: Builds a changeset from a schema definition and parameters
- apply_custom_validations/2: Applies custom validation functions to a changeset
- run/1: Executes the validation and returns either validated parameters or errors
Defining Your Validation Schema
The schema format is simple and intuitive. Each field is a tuple with the field name and a list containing the type followed by optional configuration:
@schema [
field: [:string, required: true],
order: [:string, default: "desc"],
limit: [:integer, required: false]
]
The type (first element in the list) must be an Ecto type like :string, :integer, :boolean, or :float. Options like required: true and default: "value" control validation behavior. Fields without required: true are optional.
Building a Complete Controller Example
Let's build a practical example: a product listing endpoint with filtering and pagination. This demonstrates required fields, default values, and custom validation:
defmodule MyApp.ProductController do
use MyApp, :controller
alias MyApp.Repo
alias MyApp.Product
alias Web.Utils.Validation
@schema [
search: [:string, required: false],
order: [:string, default: "asc"],
limit: [:integer, default: 20],
offset: [:integer, default: 0]
]
def index(conn, params) do
with {:ok, validated_params} <- validate_params(params) do
products = fetch_products(validated_params)
render(conn, :index, products: products)
else
{:error, changeset} ->
conn
|> put_status(:unprocessable_entity)
|> render(:errors, changeset: changeset)
end
end
defp validate_params(params) do
custom_validators = [
&Ecto.Changeset.validate_inclusion(&1, :order, ["asc", "desc"]),
&validate_pagination/1
]
@schema
|> Validation.new(params)
|> Validation.apply_custom_validations(custom_validators)
|> Validation.run()
end
defp validate_pagination(changeset) do
changeset
|> Ecto.Changeset.validate_number(:limit, greater_than: 0, less_than_or_equal_to: 100)
|> Ecto.Changeset.validate_number(:offset, greater_than_or_equal_to: 0)
end
defp fetch_products(%{"search" => search, "order" => order, "limit" => limit, "offset" => offset}) do
Product
|> Product.search(search)
|> Product.order_by(order)
|> Repo.paginate(limit: limit, offset: offset)
end
end
This controller demonstrates several key patterns. The validation schema defines all accepted parameters with their types and constraints. The validate_params/1 function chains validation: basic type checking and required field validation happen in Validation.new/2, then custom validators add domain-specific rules.
Notice how the endpoint uses pattern matching in the happy path with with/1. This keeps error handling separate from the business logic. Invalid parameters automatically trigger the error clause, which returns a 422 status with detailed error messages.
Custom Validation Rules
Basic type validation only gets you so far. The second validator in our example, validate_pagination/1, ensures pagination parameters are within reasonable bounds. This prevents clients from requesting massive result sets or using negative offsets.
Custom validators are simply functions that accept a changeset and return a modified changeset. Ecto provides many built-in validators you can compose:
defp validate_custom_rules(changeset) do
changeset
|> Ecto.Changeset.validate_length(:username, min: 3, max: 20)
|> Ecto.Changeset.validate_format(:email, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/)
|> Ecto.Changeset.validate_number(:price, greater_than: 0)
end
You can also write domain-specific validators. For example, if you need to validate that a slug is unique or that a user has permission to perform an action:
defp validate_unique_username(changeset) do
case Repo.get_by(User, username: Ecto.Changeset.get_field(changeset, :username)) do
nil -> changeset
_user -> Ecto.Changeset.add_error(changeset, :username, "already exists")
end
end
Testing Parameter Validation
Testing validation is straightforward. You test both valid and invalid parameter combinations to ensure your validation rules work correctly. Here's a complete test example:
defmodule MyApp.ProductControllerTest do
use MyApp.ConnCase
describe "index" do
test "returns products with valid parameters", %{conn: conn} do
conn = get(conn, ~p"/api/products?search=laptop&order=asc&limit=10")
assert json_response(conn, 200)
end
test "uses default values when optional params omitted", %{conn: conn} do
conn = get(conn, ~p"/api/products")
assert json_response(conn, 200)
end
test "rejects invalid order parameter", %{conn: conn} do
conn = get(conn, ~p"/api/products?order=invalid")
response = json_response(conn, 422)
assert response["errors"]["order"]
end
test "rejects limit exceeding maximum", %{conn: conn} do
conn = get(conn, ~p"/api/products?limit=500")
response = json_response(conn, 422)
assert response["errors"]["limit"]
end
test "rejects negative offset", %{conn: conn} do
conn = get(conn, ~p"/api/products?offset=-5")
response = json_response(conn, 422)
assert response["errors"]["offset"]
end
test "coerces string parameters to correct types", %{conn: conn} do
conn = get(conn, ~p"/api/products?limit=25&offset=10")
assert json_response(conn, 200)
end
end
end
Notice how tests cover multiple scenarios: valid requests, default values, invalid enum values, constraint violations, and type coercion. This comprehensive testing ensures your validation rules work as expected.
Practical Benefits and Use Cases
This validation pattern provides concrete advantages for real-world applications. First, it eliminates boilerplate. Instead of writing manual type checking and error handling in every controller, you declare your schema once and reuse it.
Second, it catches errors early. Invalid parameters are rejected before reaching your database or business logic, preventing cascading failures. Third, it improves developer experience. Error messages from Ecto changesets are clear and automatically formatted for API responses.
Common use cases include: API endpoints with filtering and pagination, form submissions from web frontends, webhook handlers that need to validate external data, and batch operations that require multiple parameters. Any place where you accept external input benefits from this pattern.
Advanced Patterns
For more complex scenarios, you can combine multiple validators and create reusable validator functions. For example, if multiple controllers need the same pagination validation, extract it into a shared module:
defmodule MyApp.Validators do
alias Ecto.Changeset
def validate_pagination(changeset) do
changeset
|> Changeset.validate_number(:limit, greater_than: 0, less_than_or_equal_to: 100)
|> Changeset.validate_number(:offset, greater_than_or_equal_to: 0)
end
end
Then import and use it in your controllers. You can also nest validation logic when you have conditional requirements. If certain fields are only required when others have specific values, write custom validators that check these relationships.
Common Patterns and Pitfalls
One common mistake is treating validation as optional. Some developers skip validation for "internal" APIs, but this leads to bugs that surface later. Always validate, even internal endpoints.
Another pitfall is validating the same field multiple ways in different controllers. Standardize your validation rules. If an email field must match a specific pattern, define that pattern once in a validator function.
Finally, remember that Ecto's type coercion is powerful. It automatically converts "25" to 25 for integers, which matches query parameter behavior. Leverage this instead of fighting it.
Integrating with Your Application
To use this validation module in your Phoenix application, place it in lib/web/utils/validation.ex and add the following code:
defmodule Web.Utils.Validation do
@spec new(list(), map()) :: Ecto.Changeset.t()
def new(schema, params) do
types = for {field, [type | _]} <- schema, into: %{}, do: {field, type}
defaults =
for {field, [_type | opts]} when is_list(opts) <- schema,
into: %{},
do: {field, opts[:default]}
required =
for {field, [_type | opts]} when is_list(opts) <- schema,
opts[:required],
do: field
{defaults, types}
|> Ecto.Changeset.cast(params, Map.keys(types))
|> Ecto.Changeset.validate_required(required)
end
@spec apply_custom_validations(Ecto.Changeset.t(), [function()]) :: Ecto.Changeset.t()
def apply_custom_validations(%Ecto.Changeset{} = changeset, fns) do
Enum.reduce(fns, changeset, fn fun, acc -> fun.(acc) end)
end
@spec run(Ecto.Changeset.t()) :: {:error, Ecto.Changeset.t()} | {:ok, map()}
def run(%Ecto.Changeset{} = changeset) do
case Ecto.Changeset.apply_action(changeset, :insert) do
{:ok, map} -> {:ok, map}
{:error, _} = error -> error
end
end
end
The module is stateless and has no dependencies beyond Ecto, making it portable and easy to integrate into existing projects.
Conclusion
Parameter validation in Elixir Phoenix doesn't have to be complex. By centralizing validation logic in a reusable utility module, you create more robust, maintainable APIs. The Web.Utils.Validation module demonstrates how to leverage Ecto.Changeset for clean, composable validation patterns that work across your entire application.
Start by identifying your most common validation patterns, build custom validators for them, and watch how your controller code becomes simpler and more reliable. Your future self will thank you when validation catches a bug before it reaches production.