Development

Mastering Parameter Validation in Elixir Phoenix Controllers

November 27, 2024
7 min read

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.