PostgreSQL Data Layer Ash + Phoenix Framework Setup

The recommended path is Igniter. One command scaffolds a Phoenix 1.8 project with ash, ash_postgres, and ash_phoenix pre-installed and configured:

$ mix archive.install hex igniter_new
$ mix igniter.new app --with phx.new --install ash,ash_postgres,ash_phoenix --yes
$ cd app

Igniter generates the App.Repo wrapper, the application supervision tree, the config files, and the .formatter.exs imports for you. The files you end up with are the ones covered in PostgreSQL Data Layer Minimal Setup with these Phoenix-specific additions:

  • App.Repo is added to the application’s child list alongside AppWeb.Telemetry, Phoenix.PubSub, Finch, and AppWeb.Endpoint.

  • .formatter.exs imports [:phoenix, :ash, :ash_postgres, :ash_phoenix].

  • config/dev.exs, config/test.exs, and config/runtime.exs carry the usual Postgres credentials (see the minimal setup for the full listings).

Add AshPostgres to a Resource

As an example we add a minimal Product resource to our application. We will add more attributes later.

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer, (1)
    domain: App.Shop,
    otp_app: :app

  postgres do
    table "products" (2)
    repo App.Repo
  end

  attributes do
    uuid_primary_key :id (3)
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end
1 Tells Ash to use the AshPostgres.DataLayer for this resource.
2 Sets the name of the table in the database.
3 An AshPostgres resource always has to have at least one primary key attribute.

The matching domain with the code interfaces we will call from IEx:

lib/app/shop.ex
defmodule App.Shop do
  use Ash.Domain, otp_app: :app

  resources do
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
  end
end

Add the domain to config/config.exs:

config/config.exs
# [...]
config :app, :ash_domains, [App.Shop]
# [...]

The products table is not yet created. Not even the database is created. We do that in the next step.

Create the Database

Assuming that you have PostgreSQL installed and running on your system, you can now create the database with the mix ash_postgres.create command:

$ mix ash_postgres.create
The database for App.Repo has been created
Now is the first time you could actually start the Phoenix server with mix phx.server without getting an error.

Drop the Database

In case you need to drop (delete) the database you can use the mix ash_postgres.drop command:

$ mix ash_postgres.drop
The database for App.Repo has been dropped
Please re-run mix ash_postgres.create now in case you ran the drop command by accident while working this tutorial.

mix ash.codegen

mix ash.codegen scans your application for resources, keeps track of them and generates migrations if things (e.g. attributes) change. It now takes a migration name as a positional argument:

$ mix ash.codegen create_products
Running codegen for AshPostgres.DataLayer...
* creating priv/resource_snapshots/repo/extensions.json
* creating priv/repo/migrations/20260418183000_create_products_extensions_1.exs
* creating priv/repo/migrations/20260418183001_create_products.exs
* creating priv/resource_snapshots/repo/products/20260418183002.json

mix ash_postgres.migrate

Now run the migration:

$ mix ash_postgres.migrate

[info] == Running 20260418183000 App.Repo.Migrations.CreateProductsExtensions1.up/0 forward
[info] execute "CREATE OR REPLACE FUNCTION ash_elixir_or..."
[info] == Migrated 20260418183000 in 0.0s

[info] == Running 20260418183001 App.Repo.Migrations.CreateProducts.up/0 forward
[info] create table products
[info] == Migrated 20260418183001 in 0.0s

The first migration installs Ash’s SQL helper functions (ash_elixir_or, ash_required, uuid_generate_v7, and so on) via the ash-functions extension Igniter registers in App.Repo. The second one is the migration for our Product resource.

If you want to you can check the table with psql:

$ psql -h localhost -U postgres -d app_dev -c "\d products"

            Table "public.products"
 Column | Type | Collation | Nullable |         Default
--------+------+-----------+----------+--------------------------
 id     | uuid |           | not null | gen_random_uuid()
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

mix ash_postgres.rollback

Sometimes you want to undo a migration. You can do that with mix ash_postgres.rollback:

$ mix ash_postgres.rollback

[info] == Running 20260418183001 App.Repo.Migrations.CreateProducts.down/0 forward
[info] drop table products
[info] == Migrated 20260418183001 in 0.0s
In case you just did a rollback in this example you want to migrate again with mix ash_postgres.migrate before you continue.

Add Attributes to a Resource

Let’s add two attributes to the Product resource:

lib/app/shop/product.ex
defmodule App.Shop.Product do
  use Ash.Resource,
    data_layer: AshPostgres.DataLayer,
    domain: App.Shop,
    otp_app: :app

  postgres do
    table "products"
    repo App.Repo
  end

  attributes do
    uuid_primary_key :id
    attribute :name, :string, public?: true (1)
    attribute :price, :decimal, public?: true (2)
  end

  actions do
    default_accept [:name, :price]
    defaults [:create, :read, :update, :destroy]
  end
end
1 A :name attribute of type :string.
2 A :price attribute of type :decimal. Prefer :decimal over :float for money so rounding errors do not creep in.

Also add a matching get_product_by_name code interface:

lib/app/shop.ex
# [...]
    resource App.Shop.Product do
      define :create_product, action: :create
      define :list_products, action: :read
      define :get_product_by_id, action: :read, get_by: :id
      define :get_product_by_name, action: :read, get_by: :name (1)
      define :update_product, action: :update
      define :destroy_product, action: :destroy
    end
# [...]
1 A get_product_by_name helper that looks up a product by its :name.

Generate and run the migration:

$ mix ash.codegen add_product_attributes
Running codegen for AshPostgres.DataLayer...
* creating priv/repo/migrations/20260418184000_add_product_attributes.exs
* creating priv/resource_snapshots/repo/products/20260418184001.json

$ mix ash_postgres.migrate
[info] == Running 20260418184000 App.Repo.Migrations.AddProductAttributes.up/0 forward
[info] alter table products
[info] == Migrated 20260418184000 in 0.0s

Check the table again:

$ psql -h localhost -U postgres -d app_dev -c "\d products"

              Table "public.products"
 Column |  Type   | Collation | Nullable |         Default
--------+---------+-----------+----------+--------------------------
 id     | uuid    |           | not null | gen_random_uuid()
 name   | text    |           |          |
 price  | numeric |           |          |
Indexes:
    "products_pkey" PRIMARY KEY, btree (id)

Time to add two entries into the products table (a Banana and a Pineapple):

$ iex -S mix
iex(1)> App.Shop.create_product!(%{name: "Banana", price: Decimal.new("0.10")})

[debug] QUERY OK db=3.9ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id"
["4d7e383b-ce7b-44d0-818c-290eaa8b0532", "Banana", Decimal.new("0.1")] (1)
%App.Shop.Product{
  __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
  id: "4d7e383b-ce7b-44d0-818c-290eaa8b0532",
  name: "Banana",
  price: Decimal.new("0.10")
}
iex(2)> App.Shop.create_product!(%{name: "Pineapple", price: Decimal.new("0.50")})

[debug] QUERY OK db=0.6ms
INSERT INTO "products" ("id","name","price") VALUES ($1,$2,$3)
RETURNING "price","name","id"
["e854a911-4cda-4693-bd49-db200b675ded", "Pineapple", Decimal.new("0.5")]
%App.Shop.Product{
  __meta__: #Ecto.Schema.Metadata<:loaded, "products">,
  id: "e854a911-4cda-4693-bd49-db200b675ded",
  name: "Pineapple",
  price: Decimal.new("0.50")
}
iex(3)>
1 In development mode you see these SQL debugging messages. Logger.configure(level: :info) at the top of the session hides them.

After pressing Ctrl-C two times to exit the iex session we can check the table again:

$ psql -h localhost -U postgres -d app_dev -c "select * from products"
                  id                  |   name    | price
--------------------------------------+-----------+-------
 4d7e383b-ce7b-44d0-818c-290eaa8b0532 | Banana    |  0.10
 e854a911-4cda-4693-bd49-db200b675ded | Pineapple |  0.50
(2 rows)

Congratulations! You just created your first Ash + Phoenix application with a PostgreSQL database.

Never forget to run mix ash.codegen <migration_name> and mix ash_postgres.migrate after you change a resource. Otherwise the changes will not be reflected in the database.

Test Setup

Igniter wires up the test database helper for you. The generated test/test_helper.exs already calls Ecto.Adapters.SQL.Sandbox.mode(App.Repo, :manual), and the mix test alias runs ash_postgres.create --quiet and ash_postgres.migrate --quiet before each test run. Run:

$ mix test
.....
Finished in 0.06 seconds (0.02s async, 0.04s sync)
5 tests, 0 failures

If you need a shared App.DataCase for tests that exercise the repo, copy the one Phoenix generates in stock projects (or regenerate with mix phx.gen.schema in a throwaway scratch app and lift the file).

Building the Phoenix CRUD by hand

A full phx.gen.html-style generator for Ash does not ship with the framework, but the controller pattern is short once ash_phoenix is installed. Given how much this piece changes between ash_phoenix releases, we keep the detailed example in the ash_phoenix documentation rather than pin it here. The short version:

  • AshPhoenix.Form.for_create/3 and AshPhoenix.Form.for_update/3 build forms compatible with the <.simple_form> Phoenix component, using domain: App.Shop (the old api: keyword is no longer valid in Ash 3).

  • AshPhoenix.Form.submit/1 returns {:ok, record} or {:error, form} and is what you call from your create/2 and update/2 actions.

  • A one-line resources "/products", ProductController in lib/app_web/router.ex wires up the seven RESTful routes.

When in doubt, prefer a LiveView: mix ash_phoenix.live (from the ash_phoenix archive) generates the same UI as a LiveView and is the direction the Ash team is moving in.