File: README.md
Peace of mind from prototype to production.
Getting started
See the official site at https://www.phoenixframework.org/.
Install the latest version of Phoenix by following the instructions at https://phoenix.hexdocs.pm/installation.html#phoenix.
Documentation
API documentation is available at https://phoenix.hexdocs.pm.
Phoenix.js documentation is available at https://phoenix.hexdocs.pm/js.
Contributing
We appreciate any contribution to Phoenix. Check our CODE_OF_CONDUCT.md and CONTRIBUTING.md guides for more information. We usually keep a list of features and bugs in the issue tracker.
Generating a Phoenix project from unreleased versions
You can create a new project using the latest Phoenix source installer (the phx.new Mix task) with the following steps:
- Remove any previously installed
phx_newarchives so that Mix will pick up the local source code. This can be done withmix archive.uninstall phx_newor by simply deleting the file, which is usually in~/.mix/archives/. - Copy this repo via
git clone https://github.com/phoenixframework/phoenixor by downloading it - Run the
phx.newMix task from within theinstallerdirectory, for example:
cd phoenix/installer
mix phx.new dev_app --devThe --dev flag will configure your new project's :phoenix dep as a relative path dependency, pointing to your local Phoenix checkout:
defp deps do
[{:phoenix, path: "../..", override: true},To create projects outside of the installer/ directory, add the latest archive to your machine by following the instructions in installer/README.md
Building from source
To build the documentation:
MIX_ENV=docs mix docsTo build Phoenix:
mix deps.get
mix compileTo build the Phoenix installer:
mix deps.get
mix compile
mix archive.buildTo build Phoenix.js:
mix assets.buildImportant links
Visit Phoenix's sponsor, DockYard, for expert Phoenix Consulting
Copyright and License
Copyright (c) 2014, Chris McCord.
Phoenix source code is licensed under the MIT License.
File: guides/authn_authz/api_authentication.md
API Authentication
Requirement {: .tip}
This guide expects that you have gone through the
mix phx.gen.authguide.
This guide shows how to add API authentication on top of mix phx.gen.auth. Since the authentication generator already includes a token table, we use it to store API tokens too, following the best security practices.
We will break this guide in two parts: augmenting the context and the plug implementation. We will assume that the following mix phx.gen.auth command was executed:
$ mix phx.gen.auth Accounts User usersIf you ran something else, it should be trivial to adapt the names.
Adding API functions to the context
Our authentication system will require two functions. One to create the API token and another to verify it. Open up lib/my_app/accounts.ex and add these two new functions:
## API
@doc """
Creates a new api token for a user.
The token returned must be saved somewhere safe.
This token cannot be recovered from the database.
"""
def create_user_api_token(user) do
{encoded_token, user_token} = UserToken.build_email_token(user, "api-token")
Repo.insert!(user_token)
encoded_token
end
@doc """
Fetches the user by API token.
"""
def fetch_user_by_api_token(token) do
with {:ok, query} <- UserToken.verify_api_token_query(token),
%User{} = user <- Repo.one(query) do
{:ok, user}
else
_ -> :error
end
endThe new functions use the existing UserToken functionality to store a new type of token called "api-token". Because this is an email token, if the user changes their email, the tokens will be expired.
Also notice we called the second function fetch_user_by_api_token, instead of get_user_by_api_token. Because we want to render different status codes in our API, depending if a user was found or not, we return {:ok, user} or :error. Elixir's convention is to call these functions fetch_*, instead of get_* which would usually return nil instead of tuples.
To make sure our new functions work, let's write tests. Open up test/my_app/accounts_test.exs and add this new describe block:
describe "create_user_api_token/1 and fetch_user_by_api_token/1" do
test "creates and fetches by token" do
user = user_fixture()
token = Accounts.create_user_api_token(user)
assert Accounts.fetch_user_by_api_token(token) == {:ok, user}
assert Accounts.fetch_user_by_api_token("invalid") == :error
end
endIf you run the tests, they will actually fail. Something similar to this:
1) test create_user_api_token/1 and fetch_user_by_api_token/1 creates and fetches by token (Demo.AccountsTest)
test/demo/accounts_test.exs:380
** (UndefinedFunctionError) function Demo.Accounts.UserToken.verify_api_token_query/1 is undefined or private. Did you mean:
* verify_change_email_token_query/2
* verify_magic_link_token_query/1
* verify_session_token_query/1
code: assert Accounts.fetch_user_by_api_token(token) == {:ok, user}
stacktrace:
(demo 0.1.0) Demo.Accounts.UserToken.verify_api_token_query("sTpJg7rt-KQ9gZ7xLMtn2keusGk9N2JpPwkXDx7LmHU")
(demo 0.1.0) lib/demo/accounts.ex:325: Demo.Accounts.fetch_user_by_api_token/1
test/demo/accounts_test.exs:383: (test)If you prefer, try looking at the error and fixing it yourself. The explanation will come next.
The UserToken module contains functions for verifying different tokens. Right now, there is no verify_api_token_query/1, but we can implement it similar to the existing functions. How long the API token should be valid is going to depend on your application and how sensitive it is in terms of security. For this example, let's say the token is valid for 365 days.
Open up lib/my_app/accounts/user_token.ex, and add a new function, like this:
@doc """
Checks if the API token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any.
The given token is valid if it matches its hashed counterpart in the
database and the user email has not changed. This function also checks
if the token is being used within 365 days.
"""
def verify_api_token_query(token) do
case Base.url_decode64(token, padding: false) do
{:ok, decoded_token} ->
hashed_token = :crypto.hash(@hash_algorithm, decoded_token)
query =
from token in by_token_and_context_query(hashed_token, "api-token"),
join: user in assoc(token, :user),
where:
token.inserted_at > ago(^@api_token_validity_in_days, "day") and
token.sent_to == user.email,
select: user
{:ok, query}
:error ->
:error
end
endNote that we also added a @api_token_validity_in_days module attribute at the top of the file:
@magic_link_validity_in_minutes 15
@change_email_validity_in_days 7
@session_validity_in_days 60
+ @api_token_validity_in_days 365Now tests should pass and we are ready to move forward!
API authentication plug
The last part is to add authentication to our API.
When we ran mix phx.gen.auth, it generated a MyAppWeb.UserAuth module with several plugs, which are small functions that receive the conn and customize our request/response life-cycle. Open up lib/my_app_web/user_auth.ex and add this new function:
def fetch_current_scope_for_api_user(conn, _opts) do
with [<>] <-
get_req_header(conn, "authorization"),
true <- String.downcase(bearer) == "bearer",
{:ok, user} <- Accounts.fetch_user_by_api_token(token) do
assign(conn, :current_scope, Scope.for_user(user))
else
_ ->
conn
|> send_resp(:unauthorized, "No access for you")
|> halt()
end
endOur function receives the connection and checks if the "authorization" header has been set with "Bearer TOKEN", where "TOKEN" is the value returned by Accounts.create_user_api_token/1. In case the token is not valid or there is no such user, we abort the request.
Finally, we need to add this plug to our pipeline. Open up lib/my_app_web/router.ex and you will find a pipeline for API. Let's add our new plug under it, like this:
pipeline :api do
plug :accepts, ["json"]
plug :fetch_current_scope_for_api_user
endNow you are ready to receive and validate API requests. Feel free to open up test/my_app_web/user_auth_test.exs and write your own test. You can use the tests for other plugs as templates!
Your turn
The overall API authentication flow will depend on your application.
If you want to use this token in a JavaScript client, you will need to slightly alter the UserSessionController to invoke Accounts.create_user_api_token/1 and return a JSON response including the token.
If you want to provide APIs for 3rd-party users, you will need to allow them to create tokens, and show the result of Accounts.create_user_api_token/1 to them. They must save these tokens somewhere safe and include them as part of their requests using the "authorization" header.
File: guides/authn_authz/authn_authz.md
Introduction to Auth
Authentication (authn) and authorization (authz) are two important concepts in security. Authentication is the process of verifying the identity of a user or system, while authorization is the process of granting or denying access to resources based on the user's identity and permissions.
Phoenix comes with built-in support for both. Generally speaking, developers use the mix phx.gen.auth generator to scaffold their authn and authz. Third-party libraries such as Ueberauth can be used either as complementary systems or by itself.
Overall we have the following guides:
mix phx.gen.auth - An introduction to the
mix phx.gen.authgenerator and its security considerations.Scopes - Scopes are the mechanism Phoenix v1.8 introduced to manage access to resources based on the user's identity and permissions.
API Authentication - An additional guide that shows how to expand
mix phx.gen.authcode to support token-based API authentication.
File: guides/authn_authz/mix_phx_gen_auth.md
mix phx.gen.auth
The mix phx.gen.auth command generates a flexible, pre-built authentication system into your Phoenix app. This generator allows you to quickly move past the task of adding authentication to your codebase and stay focused on the real-world problem your application is trying to solve. It supports the following features:
- User registration with account confirmation by email
- Log in with magic links
- Opt-in password authentication
- "Sudo mode", also known as privileged authentication, where the user must confirm their identity before performing sensitive actions
Getting started
Before running this command, consider committing your work as it generates multiple files.
Let's start by running the following command from the root of our app:
$ mix phx.gen.auth Accounts User users
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Y/n] YThe first argument is the context module followed by the schema module
and its plural name (used as the schema table name). The example above
will generate an Accounts context module with two schemas inside:User and UserToken. The context module helps us group all of the
different schemas related to authentication. You may name the context
and schema according to your preferences.
The authentication generators support Phoenix LiveView, for enhanced UX,
so you should answer Y here. You may also answer n for a controller
based authentication system. Either approach will create the same context
and schemas, using the same table names and route paths.
Since this generator installed additional dependencies in mix.exs,
let's fetch those:
$ mix deps.getNow run the pending repository migrations:
$ mix ecto.migrateLet's run the tests to make sure our new authentication system works as expected.
$ mix testAnd finally, let's start our Phoenix server and try it out (note the new Register and Log in links at the top right of the default page).
$ mix phx.serverDeveloper responsibilities
Since Phoenix generates this code into your application instead of building these modules into Phoenix itself, you now have complete freedom to modify the authentication system, so it works best with your use case. The one caveat with using a generated authentication system is it will not be updated after it's been generated. Therefore, as improvements are made to the output of mix phx.gen.auth, it becomes your responsibility to determine if these changes need to be ported into your application. Security-related and other important improvements will be explicitly and clearly marked in the CHANGELOG.md file and upgrade notes.
Generated code
The following are notes about the generated authentication system.
Forbidding access
The generated code ships with an authentication module with a handful of plugs that fetch the current user, require authentication and so on. For instance, in an app named MyApp which had mix phx.gen.auth Accounts User users run on it, you will find a module named MyAppWeb.UserAuth with plugs such as:
fetch_current_scope_for_user- fetches the current user information if available and stores it as:current_scopeassignrequire_authenticated_user- must be invoked afterfetch_current_scope_for_userand requires that a current user exists and is authenticatedredirect_if_user_is_authenticated- used for the few pages that must not be available to authenticated users (only generated for controller based authentication)require_sudo_mode- used for pages that contain sensitive operations and enforces recent authentication
Scopes
The generated code includes a scope module. For an app named MyApp which had mix phx.gen.auth Accounts User users run on it, you will find the following module at lib/my_app/accounts/scope.ex:
defmodule MyApp.Accounts.Scope do
# ...
alias MyApp.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
endThe scope data structure is stored in the assigns and available to your Controllers and LiveViews. As your application grows in complexity, this data structure can store important metadata such as the teams, companies, or organizations the user belongs to, permissions, telemetry information such as IP address and so forth.
Furthermore, future Phoenix generator invocations will automatically pass this data structure from your Controllers and LiveViews to most of your context operations, making sure that future data is scoped to the current user/team/company/organization. Scopes are essential to enforce the user can only access data they own. You can learn more about them in the Scopes guide.
Password hashing
The password hashing mechanism defaults to bcrypt for Unix systems and pbkdf2 for Windows systems. Both systems use the Comeonin interface.
The password hashing mechanism can be overridden with the --hashing-lib option. The following values are supported:
bcrypt- bcrypt_elixirpbkdf2- pbkdf2_elixirargon2- argon2_elixir
We recommend developers to consider using argon2, which is the most robust of all 3. The downside is that argon2 is quite CPU and memory intensive, and you will need more powerful instances to run your applications on.
For more information about choosing these libraries, see the Comeonin project.
There are similar :on_mount hooks for LiveView based authentication.
Notifiers
The generated code is not integrated with any system to send SMSes or emails for confirming accounts, resetting passwords, etc. Instead, it simply logs a message to the terminal. It is your responsibility to integrate with the proper system after generation.
Note that if you generated your Phoenix project with mix phx.new, your project is configured to use Swoosh mailer by default. To view notifier emails during development with Swoosh, navigate to /dev/mailbox.
Concurrent tests
The generated tests run concurrently if you are using a database that supports concurrent tests, which is the case of PostgreSQL.
More about `mix phx.gen.auth`
Check out mix phx.gen.auth for more details, such as using a different password hashing library, customizing the web module namespace, generating binary id type, configuring the default options, and using custom table names.
Security considerations
Tracking sessions
All sessions and tokens are tracked in a separate table. This allows you to track how many sessions are active for each account. You could even expose this information to users if desired.
Note that whenever the password changes (either via reset password or directly), all tokens are deleted, and the user has to log in again on all devices.
User Enumeration attacks
A user enumeration attack allows someone to check if an email is registered in the application. The generated authentication code does not attempt to protect from such attacks. For instance, when you register an account, if the email is already registered, the code will notify the user the email is already registered.
If your application is sensitive to enumeration attacks, you need to implement your own workflows, which tends to be very different from most applications, as you need to carefully balance security and user experience.
Furthermore, if you are concerned about enumeration attacks, beware of timing attacks too. For example, registering a new account typically involves additional work (such as writing to the database, sending emails, etc) compared to when an account already exists. Someone could measure the time taken to execute those additional tasks to enumerate emails. This applies to all endpoints (registration, login, etc.) that may send email, in-app notifications, etc.
Confirmation and credential pre-stuffing attacks
The generated functionality ships with an account confirmation mechanism, where users have to confirm their account, typically by email. Furthermore, to prevent security issues, the generated code does forbid users from using the application if their accounts have not yet been confirmed. If you want to change this behavior, please refer to the "Mixing magic link and password registration" section of mix phx.gen.auth.
Case sensitiveness
The email lookup is made to be case-insensitive. Case-insensitive lookups are the default in MySQL and MSSQL. In SQLite3 we use COLLATE NOCASE in the column definition to support it. In PostgreSQL, we use the citext extension.
Note citext is part of PostgreSQL itself and is bundled with it in most operating systems and package managers. mix phx.gen.auth takes care of creating the extension and no extra work is necessary in the majority of cases. If by any chance your package manager splits citext into a separate package, you will get an error while migrating, and you can most likely solve it by installing the postgres-contrib package.
Additional resources
Migrating to Phoenix v1.8 magic links and sudo mode
Phoenix v1.8 added new features and simplified the authentication code. Developers are not required to migrate to the new generators, although we recommend setting up your own scope, as defined in the Scopes guide.
If you generated your authentication code with mix phx.gen.auth in Phoenix v1.7 or earlier and you want to migrate to the new generators, you can use the following pull requests as reference:
- Pull request for migrating LiveView based Phoenix 1.7
phx.gen.authto magic links - Pull request for migrating controller based Phoenix 1.7
phx.gen.authto magic links
Keep in mind that the new authentication system fully removes registering an account with password, which simplifies both the user experience and the generated code. Therefore, when migrating, you should not change your existing migration files, instead, you must make the hashed_password column optional by setting null: true. Also, when migrating to the new system and removing features like "Forgot your password?", you must set the hashed_password of all accounts that have not been confirmed to nil, after making the column nullable, to avoid credential stuffing attacks. For this reason, we recommend deploying the migrated authentication system during low-traffic periods, where ideally no user who has just registered an account would have their password nullified. If those trade-offs are not acceptable, you can add magic links on top of your existing authentication system without a complete migration, as discussed here.
Initial implementation
The following links describe the original implementation of the authentication system, the default up to Phoenix v1.7:
- JosΓ© Valim's blog post - An upcoming authentication solution for Phoenix
- Berenice Medel's blog post on generating LiveViews for authentication (rather than conventional Controllers & Views) - Bringing Phoenix Authentication to Life
- Original design spec
- Pull request on bare Phoenix app
File: guides/authn_authz/scopes.md
Scopes
A scope is a data structure used to keep information about the current request or session, such as the current user, the user's organization/company, permissions, and so on. Think of a scope as a container with information required by the majority of pages in your application. A scope can also hold request metadata, such as IP addresses.
Scopes play an important role in security. OWASP lists "Broken access control" as a top-10 security risk. Most application data is private for a user, a team, or an organization. Your database CRUD operations must be properly scoped to the current user/team/organization. Phoenix generators such as mix phx.gen.html, mix phx.gen.json, and mix phx.gen.live automatically use your custom scopes.
Scopes are flexible. You can have more than one scope in your application and choose the specific scope when invoking a generator. When you run mix phx.gen.auth, it will automatically generate a scope for you, but you may also add your own.
This guide will:
- Show how
mix phx.gen.authgenerates a scope for you - Discuss how generators, such as
mix phx.gen.context, rely on scopes for security - How to define your own scope from scratch and all valid options
- Augment the built-in scope with additional scopes
phx.gen.auth
The task mix phx.gen.auth will generate a default scope. This scope ties the generated resources to the currently authenticated user. Let's see it in action:
$ mix phx.gen.auth Accounts User usersThe scope code is the same for the --live and --no-live variants of the generator.
Looking at the generated scope file lib/my_app/accounts/scope.ex, we can see that it defines a struct with a single user field, and a function for_user/1 that, if given a User struct, returns a new %Scope{} for that user.
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
defstruct user: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
endThe scope is automatically fetched by the fetch_current_scope_for_user plug that is injected into the :browser pipeline:
# router.ex
...
pipeline :browser do
...
plug :fetch_current_scope_for_user
end# user_auth.ex
def fetch_current_scope_for_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_scope, Scope.for_user(user))
endSimilarly, for LiveViews, there is a pre-defined mount_current_scope hook that ensures
the scope is available:
# user_auth.ex
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
user =
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end
Scope.for_user(user)
end)
endIntegration of scopes in the Phoenix generators
If a default scope is defined in your application's config, the generators will build scoped resources by default. The generated LiveViews / Controllers will automatically pass the scope to the context functions. mix phx.gen.auth automatically sets its scope as default, if there is not already a default scope defined:
# config/config.exs
config :my_app, :scopes,
user: [
default: true,
...
]We will look at the individual options in the next section.
Now let's look at the code generated once a default scope is set. We will use mix phx.gen.live as an example, but the ideas and the overall code will be similar to mix phx.gen.html and mix phx.gen.json too:
$ mix phx.gen.live Blog Post posts title:string body:textThis creates a new Blog context, with a Post resource. To ensure the scope is available, for LiveViews the routes in your router.ex must be added to a live_session that ensures the user is authenticated. In this case, within the aptly named required_authenticated_user section:
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{MyAppWeb.UserAuth, :ensure_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
+ live "/posts", PostLive.Index, :index
+ live "/posts/new", PostLive.Form, :new
+ live "/posts/:id", PostLive.Show, :show
+ live "/posts/:id/edit", PostLive.Form, :edit
end
post "/users/update-password", UserSessionController, :update_password
endAlthough the router has a
scopemacro, the routerscopeandcurrent_scopeare ultimately distinct features which have similar purposes: to narrow down access to parts of our application, each acting at distinct layers (one at the router, the other at the data layer).
Now, let's look at the generated LiveView (lib/my_app_web/live/post_live/index.ex):
defmodule MyAppWeb.PostLive.Index do
use MyAppWeb, :live_view
alias MyApp.Blog
...
@impl true
def mount(_params, _session, socket) do
Blog.subscribe_posts(socket.assigns.current_scope)
{:ok,
socket
|> assign(:page_title, "Listing Posts")
|> stream(:posts, Blog.list_posts(socket.assigns.current_scope))}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
post = Blog.get_post!(socket.assigns.current_scope, id)
{:ok, _} = Blog.delete_post(socket.assigns.current_scope, post)
{:noreply, stream_delete(socket, :posts, post)}
end
@impl true
def handle_info({type, %MyApp.Blog.Post{}}, socket)
when type in [:created, :updated, :deleted] do
{:noreply, stream(socket, :posts, Blog.list_posts(socket.assigns.current_scope), reset: true)}
end
endNote that every function from the Blog context that we call gets the current_scope assign passed in as the first argument. The list_posts/1 function then uses that information to properly filter posts:
# lib/my_app/blog.ex
def list_posts(%Scope{} = scope) do
Repo.all(from post in Post, where: post.user_id == ^scope.user.id)
endThe LiveView even subscribes to scoped PubSub messages and automatically updates the rendered list whenever a new post is created or an existing post is updated or deleted, while ensuring that only messages for the current scope are processed.
Defining scopes
The Phoenix generators use your application's config to discover the available scopes. A scope is defined by the following options:
config :my_app, :scopes,
user: [
default: true,
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: MyApp.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]In this example, the scope is called user and it is the default scope that is automatically used when running mix phx.gen.schema, mix phx.gen.context, mix phx.gen.live, mix phx.gen.html and mix phx.gen.json. A scope needs a module that defines a struct, in this case MyApp.Accounts.Scope. Those structs are used as first argument to the generated context functions, like list_posts/1.
default- a boolean that indicates if this scope is the default scope. There can only be one default scope defined.module- the module that defines the struct for this scope.assign_key- the key where the scope struct is assigned to the socket or conn.access_path- a list of keys that define the path to the identifying field in the scope struct. The generators generate code likewhere: schema_key == ^scope.user.id.route_prefix- (optional) a path template string for how resources should be nested. For example,/organizations/:orgwould generate routes like/organizations/:org/posts. The parameter segment (:org) will be replaced with the appropriate scope access value in templates and LiveViews.route_access_path- (optional) list of keys that define the path to the field used in route generation (ifroute_prefixis set). This is particularly useful for user-friendly URLs where you might want to use a slug instead of an ID. If not specified, it defaults toEnum.drop(scope.access_path, -1)oraccess_pathif the former is empty. For example, if theaccess_pathis[:organization, :id], it defaults to[:organization], assuming that the value atscope.organizationimplements thePhoenix.Paramprotocol.schema_key- the foreign key that ties the resource to the scope. New scoped schemas are created with a foreign key field namedschema_keyof typeschema_typeto theschema_tabletable.schema_type- the type of the foreign key field in the schema. Typically:idor:binary_id.schema_migration_type(optional) - the type of the foreign key column in the database. Used for the generated migration. It defaults to the default migration foreign keytype.schema_table- the name of the table where the foreign key points to.test_data_fixture- a module that is automatically imported into the context test file. It must have aNAME_scope_fixture/0function that returns a unique scope struct for context tests, in this caseuser_scope_fixture/0.test_setup_helper- the name of a function that is registered assetupcallback in LiveView / Controller tests. The function is expected to be imported in the test file. Usually, this is ensured by putting it into theMyAppWeb.ConnCasemodule.
While the mix phx.gen.auth automatically generates a scope, scopes can also be defined manually. This can be useful, for example, to retrofit an existing application with scopes or to define scopes that are not tied to a user.
For this example, we will implement a custom scope that gives each session its own scope. While this might not be useful in most real-world applications as created resources would be inaccessible as soon as the session ends, it is a good example to understand how scopes work. See the following section for an example on how to augment an existing scope with organizations (teams, companies, or similar).
First, let's define our scope module lib/my_app/scope.ex:
defmodule MyApp.Scope do
defstruct id: nil
def for_id(id) do
%MyApp.Scope{id: id}
end
endNext, we define a plug in our router that assigns a scope to each request:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
+ plug :assign_scope
end
+
+ defp assign_scope(conn, _opts) do
+ if id = get_session(conn, :scope_id) do
+ assign(conn, :current_scope, MyApp.Scope.for_id(id))
+ else
+ id = System.unique_integer()
+
+ conn
+ |> put_session(:scope_id, id)
+ |> assign(:current_scope, MyApp.Scope.for_id(id))
+ end
+ endFor tests, we'll also define a fixture module test/support/fixtures/scope_fixtures.ex:
defmodule MyApp.ScopeFixtures do
alias MyApp.Scope
def session_scope_fixture(id \\ System.unique_integer()) do
%Scope{id: id}
end
endAnd then add a setup helper to our test/support/conn_case.ex:
defmodule MyAppWeb.ConnCase do
...
def put_scope_in_session(%{conn: conn}) do
id = System.unique_integer()
scope = MyApp.ScopeFixtures.session_scope_fixture(id)
conn =
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:scope_id, id)
%{conn: conn, scope: scope}
end
endFinally, we configure the scope in our application's config/config.exs:
config :my_app, :scopes,
session: [
default: true,
module: MyApp.Scope,
assign_key: :current_scope,
access_path: [:id],
schema_key: :session_id,
schema_type: :id,
schema_migration_type: :bigint,
schema_table: nil,
test_data_fixture: MyApp.ScopeFixtures,
test_setup_helper: :put_scope_in_session
]Setting schema_table to nil means that the generated resources don't have a foreign key to the scope, but instead a normal bigint column that directly stores the scope's id.
We can now generate a new resource, for example with phx.gen.html:
$ mix phx.gen.html Post posts title:stringWhen you now visit http://localhost:4000/posts, and create a new post, you will see that it is only visible to the current session. If you open a private browser window and visit the same URL, the previously created post is not visible. Similarly, if you create a new post in the private window, it is not visible in the other window. If you try to copy the URL of a post created in one session and access it in another, you will get an Ecto.NoResultsError error, which is automatically converted to 404 when the debug_errors setting is disabled.
Augmenting scopes
Let's assume that you used mix phx.gen.auth to generate a scope tied to users. But now you also create a new organization entity, where users can be members of:
defmodule MyApp.Accounts.Organization do
use Ecto.Schema
import Ecto.Changeset
@derive {Phoenix.Param, key: :slug}
schema "organizations" do
field :name, :string
field :slug, :string
...
many_to_many :users, MyApp.Accounts.User, join_through: "organizations_users"
timestamps(type: :utc_datetime)
end
endFirst, we'd adjust our scope struct to also include the organization:
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts.User
alias MyApp.Accounts.Organization
- defstruct user: nil
+ defstruct user: nil, organization: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
+
+ def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
+ %{scope | organization: organization}
+ end
endLet's also assume that the current organization is part of the URL path, like http://localhost:4000/organizations/foo/posts. Then, we'd adjust our router to fetch the organization from the path and assign it to the scope:
# router.ex
pipeline :browser do
...
plug :fetch_current_scope_for_user
+ plug :assign_org_to_scope
end# user_auth.ex
def assign_org_to_scope(conn, _opts) do
current_scope = conn.assigns.current_scope
if slug = conn.params["org"] do
org = MyApp.Accounts.get_organization_by_slug!(current_scope, slug)
assign(conn, :current_scope, MyApp.Accounts.Scope.put_organization(current_scope, org))
else
conn
end
endFor LiveViews, we'll also need to add a new :on_mount hook and add it to live_session's on_mount option in the router:
# router.ex
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [
{MyAppWeb.UserAuth, :mount_current_scope},
+ {MyAppWeb.UserAuth, :assign_org_to_scope}
] do
...
end
end# user_auth.ex
def on_mount(:assign_org_to_scope, %{"org" => slug}, _session, socket) do
socket =
case socket.assigns.current_scope do
%{organization: nil} = scope ->
org = MyApp.Accounts.get_organization_by_slug!(socket.assigns.current_scope, slug)
Phoenix.Component.assign(socket, :current_scope, Scope.put_organization(scope, org))
_ ->
socket
end
{:cont, socket}
end
def on_mount(:assign_org_to_scope, _params, _session, socket), do: {:cont, socket}This way, if a route is defined like live /organizations/:org/posts, the assign_org_to_scope plug would fetch the organization from the path and assign it to the scope. This code assumes that get_organization_by_slug!/2 raises an Ecto.NoResultsError which would be automatically converted to 404, but you could also handle the error explicitly and, for example, set an error flash and redirect to another page, like a dashboard. The get_organization_by_slug!/2 function should also rely on the current scope to filter the organizations to those the user has access to.
Then, we are ready to define a new scope in our application's config/config.exs to generate resources scoped to the organization:
config :my_app, :scopes,
user: [
...
],
organization: [
module: MyApp.Accounts.Scope,
assign_key: :current_scope,
access_path: [:organization, :id],
route_prefix: "/organizations/:org",
route_access_path: [:organization, :slug],
schema_key: :org_id,
schema_type: :id,
schema_table: :organizations,
test_data_fixture: MyApp.AccountsFixtures,
test_setup_helper: :register_and_log_in_user_with_org
]For the generated tests, we'll also need to define a fixture in test/support/fixtures/accounts_fixtures.ex and extend our test/support/conn_case.ex:
defmodule MyApp.AccountsFixtures do
...
def organization_scope_fixture(scope \\ user_scope_fixture()) do
org = organization_fixture(scope)
Scope.put_organization(scope, org)
end
enddefmodule MyAppWeb.ConnCase do
...
def register_and_log_in_user_with_org(context) do
%{conn: conn, user: _user, scope: scope} = register_and_log_in_user(context)
%{conn: conn, scope: MyApp.AccountsFixtures.organization_scope_fixture(scope)}
end
endNow that our scope configuration includes the route_prefix, we can generate resources scoped to the organization, and all paths will be automatically generated with the correct organization slug:
$ mix phx.gen.live Blog Post posts title:string body:text --scope organizationThis shows that scopes are quite flexible, allowing you to keep a well-defined data structure, even when your application grows.
Most of the time, your application will have a single scope module, like in this example. But sometimes, you might want to create a new scope module, for example to completely separate a user-facing scope from an admin scope, where also the context functions are supposed to only be called by one of the two.
Scope helpers
When working with more complex scopes, it is often useful to create some helper functions, which can conveniently be added to the scope module:
defmodule MyApp.Accounts.Scope do
alias MyApp.Accounts
alias MyApp.Accounts.{User, Organization}
defstruct user: nil, organization: nil
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
def put_organization(%__MODULE__{} = scope, %Organization{} = organization) do
%{scope | organization: organization}
end
def for(opts) when is_list(opts) do
cond do
opts[:user] && opts[:org] ->
user = user(opts[:user])
org = org(opts[:org])
user
|> for_user()
|> put_organization(org)
opts[:user] ->
user = user(opts[:user])
for_user(user)
opts[:org] ->
%__MODULE__{organization: org(opts[:org])}
end
end
defp user(id) when is_integer(id) do
Accounts.get_user!(id)
end
defp user(email) when is_binary(email) do
Accounts.get_user_by_email(email)
end
defp org(id) when is_integer(id) do
Accounts.get_organization!(id)
end
defp org(slug) when is_binary(slug) do
Accounts.get_organization_by_slug!(slug)
end
endThen, you can alias the Scope module in your project's .iex.exs:
alias MyApp.Accounts.ScopeAnd when working with scoped context functions, you can just do:
iex> MyApp.Blog.list_posts(Scope.for(user: 1, org: "foo"))
...
iex> MyApp.Accounts.list_api_tokens(Scope.for(user: "[email protected]"))
...File: guides/data_modelling/contexts.md
1. Intro to Contexts
Phoenix guides are broken into several major sections. The main building blocks are outlined under the "Core Concepts" section, where we explored the request life-cycle, wired up controller actions through our routers, and learned how Ecto allows data to be validated and persisted. Now it's time to tie it all together by writing web-facing features that interact with our greater Elixir application.
When building a Phoenix project, we are first and foremost building an Elixir application. Phoenix's job is to provide a web interface into our Elixir application. Naturally, we compose our applications with modules and functions, but we often assign specific responsibilities to certain modules and give them names: such as controllers, routers, and live views.
However, the most important part of your web application is often where we encapsulate data access and data validation. We call these modules contexts. They often talk to a database, using Ecto, or APIs, using an HTTP client such as Req. By giving these modules a name, we help developers identify these patterns and talk about them. At the end of the day, contexts are just modules, as are your controllers, views, etc.
If you have used mix phx.gen.html, mix phx.gen.json, or mix phx.gen.live, you have already used contexts. For example, run the following generator in a Phoenix application:
$ mix phx.gen.live Post posts title body:textThe command above will output a few files, among them, a MyApp.Posts.Post schema in lib/my_app/posts/post.ex, which outlines how the resource is represented in the database, and a context module named MyApp.Posts that encapsulates all the database access to said schema. The MyApp.Posts module centralizes all functionality related to posts, instead of scattering logic around controllers, LiveViews, etc.
Contexts are also useful to nest resources. For example, if you are adding comments to your posts, you can colocate their schemas, since comments belong to posts, like this:
$ mix phx.gen.live Posts Comment comments post_id:references:posts body:textThe first argument to the generator above is the context module, instructing Phoenix to colocate the comments functionality with posts. There is also a post_id attribute which specifies a foreign key reference to the posts table. As your application grows, contexts help you group related schemas, instead of having several dozens of schemas with no insights on how they relate to each other.
Developers may also use contexts to intentionally name parts of their application. For example, mix phx.gen.auth requires a context name to be explicitly given. It is often invoked as:
$ mix phx.gen.auth Accounts User usersor, using whatever name you prefer, such as:
$ mix phx.gen.auth Identity Client clientsThe generated Accounts (or Identity) context will encapsulate all functionality for managing users (or clients) and their tokens. You could, if you wanted, name the context Users too, but given account/identity management has well defined name and boundary in most applications, giving it an explicit name makes its purposes clear. And, at the end of the day, they are just plain modules.
In this guide, we will use these ideas to build out our web application. Our goal is to build an ecommerce system where we can showcase products, allow users to add products to their cart, and complete their orders. We will do so by intentionally designing and naming our contexts. Opposite to other Phoenix guides, these guides are meant to be read in order.
Our ecommerce application
Let's start an application from scratch to build our ecommerce, using Phoenix Express. We will call the application hello.
For macOS/Ubuntu:
$ curl https://new.phoenixframework.org/hello | shFor Windows PowerShell:
curl.exe -fsSO https://new.phoenixframework.org/hello.bat; .\hello.batIf those commands do not work, see the Installation Guide and then run mix phx.new:
$ mix phx.new helloFollow any of the steps printed on the screen and open up the generated hello project in your editor.
We are ready to move to the next chapter.
File: guides/data_modelling/cross_context_boundaries.md
4. Cross-context Boundaries
Now that we have the beginnings of our product catalog features, let's begin to work on the other main features of our application β carting products from the catalog. In order to properly track products that have been added to a user's cart, we'll need a new place to persist this information, along with point-in-time product information like the price at time of carting. This is necessary so we can detect product price changes in the future. We know what we need to build, but now we need to decide where the cart functionality lives in our application.
If we take a step back and think about the isolation of our application, the exhibition of products in our catalog distinctly differs from the responsibilities of managing a user's cart. A product catalog shouldn't care about the rules of our shopping cart system, and vice-versa. There's a clear need here for a separate context to handle the new cart responsibilities. Let's call it ShoppingCart.
Let's create a ShoppingCart context to handle basic cart duties. Before we write code, let's imagine we have the following feature requirements:
- Add products to a user's cart from the product show page
- Store point-in-time product price information at time of carting
- Store and update quantities in cart
- Calculate and display sum of cart prices
From the description, it's clear we need a Cart resource for storing the user's cart, along with a CartItem to track products in the cart. With our plan set, let's get to work.
Adding authentication
Most of the cart functionality is tied to a specific user. Therefore, in order to allow each user to manage their own cart (and only their own carts), we must be able to authenticate users. To do so, we will use Phoenix's built-in mix phx.gen.auth generator to scaffold a solution for us:
mix phx.gen.auth Accounts User usersYou will see output similar to the following:
An authentication system can be created in two different ways:
- Using Phoenix.LiveView (default)
- Using Phoenix.Controller only
Do you want to create a LiveView based authentication system? [Yn]Type n followed by Return key,
you will see output similar to:
...
* creating lib/hello/accounts/scope.ex
...
* injecting config/config.exs
...
Please re-fetch your dependencies with the following command:
$ mix deps.get
Remember to update your repository by running migrations:
$ mix ecto.migrate
Once you are ready, visit "/users/register"
to create your account and then access "/dev/mailbox" to
see the account confirmation email.After following the instructions to re-fetch dependencies and migrating the database, we can start the server with mix phx.server and re-visit the home page http://localhost:4000/. There, we should see new registration and login links at the top of the page. On the registration page, create a new user. In development, a confirmation email is sent to the dev mailbox, which is accessible at http://localhost:4000/dev/mailbox. After clicking the confirmation link, you should be successfully logged in.
One of the benefits of mix phx.gen.auth is that it also generates a scope file at lib/hello/accounts/scope.ex. In a nutshell, authentication tells us who a user is based on their email address, but it doesn't tell us the resources the user owns or has access to. In order to do so, we need authorization. Scopes help us tie generated resources, such as the Cart we will create, to users. Let's open up the file:
defmodule Hello.Accounts.Scope do
...
alias Hello.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
endWe can see that it is simply a struct with a user field. The authentication system ensures that the current_scope assign is accordingly set with the current user. Let's see it in practice.
Generating scoped resources
Let's generate our new context:
$ mix phx.gen.context ShoppingCart Cart carts
* creating lib/hello/shopping_cart/cart.ex
* creating priv/repo/migrations/20250205203128_create_carts.exs
* creating lib/hello/shopping_cart.ex
* injecting lib/hello/shopping_cart.ex
* creating test/hello/shopping_cart_test.exs
* injecting test/hello/shopping_cart_test.exs
* creating test/support/fixtures/shopping_cart_fixtures.ex
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrateWe generated our new context ShoppingCart, with a new ShoppingCart.Cart schema. Open up the generated schema and migration files and you will see it has automatically included a user_id field, thanks to the scope. Furthermore, when we explore the code later on, we will learn all queries to the carts table have been properly scoped.
With our cart in place, let's generate our cart items. This time we will pass the --no-scope flag, because we will associate cart_items to carts and the carts are already scoped to the user:
$ mix phx.gen.context ShoppingCart CartItem cart_items \
cart_id:references:carts product_id:references:products \
price_when_carted:decimal quantity:integer --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/shopping_cart/cart_item.ex
* creating priv/repo/migrations/20250205213410_create_cart_items.exs
* injecting lib/hello/shopping_cart.ex
* injecting test/hello/shopping_cart_test.exs
* injecting test/support/fixtures/shopping_cart_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrateWe generated a new resource inside our ShoppingCart named CartItem. This schema and table will hold references to a cart and product, along with the price at the time we added the item to our cart, and the quantity the user wishes to purchase. Let's touch up the generated migration file in priv/repo/migrations/*_create_cart_items.ex:
create table(:cart_items) do
- add :price_when_carted, :decimal
+ add :price_when_carted, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
- add :cart_id, references(:carts, on_delete: :nothing)
+ add :cart_id, references(:carts, on_delete: :delete_all)
- add :product_id, references(:products, on_delete: :nothing)
+ add :product_id, references(:products, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
- create index(:cart_items, [:cart_id])
create index(:cart_items, [:product_id])
+ create unique_index(:cart_items, [:cart_id, :product_id])We used the :delete_all strategy again to enforce data integrity. This way, when a cart or product is deleted from the application, we don't have to rely on application code in our ShoppingCart or Catalog contexts to worry about cleaning up the records. This keeps our application code decoupled and the data integrity enforcement where it belongs β in the database. We also added a unique constraint to ensure a duplicate product is not allowed to be added to a cart. As with the product_categories table, using a multi-column index lets us remove the separate index for the leftmost field (cart_id). With our database tables in place, we can now migrate up:
$ mix ecto.migrate
16:59:51.941 [info] == Running 20250205203342 Hello.Repo.Migrations.CreateCarts.change/0 forward
16:59:51.945 [info] create table carts
16:59:51.952 [info] == Migrated 20250205203342 in 0.0s
16:59:51.988 [info] == Running 20250205213410 Hello.Repo.Migrations.CreateCartItems.change/0 forward
16:59:51.988 [info] create table cart_items
16:59:52.000 [info] create index cart_items_product_id_index
16:59:52.001 [info] create index cart_items_cart_id_product_id_index
16:59:52.002 [info] == Migrated 20250205213410 in 0.0sOur database is ready to go with new carts and cart_items tables, but now we need to map that back into application code. You may be wondering how we can mix database foreign keys across different tables and how that relates to the context pattern of isolated, grouped functionality. Let's jump in and discuss the approaches and their tradeoffs.
Cross-context data
So far, we've done a great job isolating the two main contexts of our application from each other, but now we have a necessary dependency to handle.
Our Catalog.Product resource serves to keep the responsibilities of representing a product inside the catalog, but ultimately for an item to exist in the cart, a product from the catalog must be present. Given this, our ShoppingCart context will have a data dependency on the Catalog context. With that in mind, we have two options. One is to expose APIs on the Catalog context that allows us to efficiently fetch product data for use in the ShoppingCart system, which we would manually stitch together. Or we can use database joins to fetch the dependent data. Both are valid options given your tradeoffs and application size, but joining data from the database when you have a hard data dependency is just fine for a large class of applications and is the approach we will take here.
Now that we know where our data dependencies exist, let's add our schema associations so we can tie shopping cart items to products. First, let's make a quick change to our cart schema in lib/hello/shopping_cart/cart.ex to associate a cart to its items:
schema "carts" do
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :items, Hello.ShoppingCart.CartItem
timestamps(type: :utc_datetime)
endNow that our cart is associated to the items we place in it, let's set up the cart item associations inside lib/hello/shopping_cart/cart_item.ex:
schema "cart_items" do
field :price_when_carted, :decimal
field :quantity, :integer
- field :cart_id, :id
- field :product_id, :id
+ belongs_to :cart, Hello.ShoppingCart.Cart
+ belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
end
@doc false
def changeset(cart_item, attrs) do
cart_item
- |> cast(attrs, [:price_when_carted, :quantity])
+ |> cast(attrs, [:quantity])
- |> validate_required([:price_when_carted, :quantity])
+ |> validate_required([:quantity])
+ |> validate_number(:quantity, greater_than_or_equal_to: 0, less_than: 100)
endFirst, we replaced the cart_id field with a standard belongs_to pointing at our ShoppingCart.Cart schema. Next, we replaced our product_id field by adding our first cross-context data dependency with a belongs_to for the Catalog.Product schema. Here, we intentionally coupled the data boundaries because it provides exactly what we need: an isolated context API with the bare minimum knowledge necessary to reference a product in our system. Second, we remove price_when_carted from the list of permitted attributes to ensure any price provided by user input is discarded. Finally, we added a new validation to our changeset. With validate_number/3, we ensure any quantity provided by user input is between 0 and 100.
With our schemas in place, we can start integrating the new data structures and ShoppingCart context APIs into our web-facing features.
Adding Cart functions
As we mentioned before, the context generators are only a starting point for our application. We can and should write well-named, purpose built functions to accomplish the goals of our context. We have a few new features to implement. First, we need to ensure every user of our application is granted a cart if one does not yet exist. From there, we can then allow users to add items to their cart, update item quantities, and calculate cart totals. Let's get started!
Because we used mix phx.gen.auth, we already have a real authentication system in place. We can use the current_scope assign to access the currently authenticated user. Let's add a new plug to lib/hello_web/router.ex that assigns a cart if there is an authenticated user:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {HelloWeb.LayoutView, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
+ plug :fetch_current_cart
end
+ alias Hello.ShoppingCart
+
+ defp fetch_current_cart(%{assigns: %{current_scope: scope}} = conn, _opts) when not is_nil(scope) do
+ if cart = ShoppingCart.get_cart(scope) do
+ assign(conn, :cart, cart)
+ else
+ {:ok, new_cart} = ShoppingCart.create_cart(scope, %{})
+ assign(conn, :cart, new_cart)
+ end
+ end
+
+ defp fetch_current_cart(conn, _opts), do: connWe added a new :fetch_current_cart plug which either finds a cart for the user UUID or creates a cart for the current user and assigns the result in the connection assigns. We'll need to implement our ShoppingCart.get_cart/1, but let's add our routes first.
We'll need to implement a cart controller for handling cart operations like viewing a cart, updating quantities, and initiating the checkout process, as well as a cart items controller for adding and removing individual items to and from the cart. The authentication system already generated different router scopes that have different authentication requirements:
...
## Authentication routes
scope "/", HelloWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/user/register", UserRegistrationController, :new
post "/user/register", UserRegistrationController, :create
end
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
get "/user/settings", UserSettingsController, :edit
put "/user/settings", UserSettingsController, :update
get "/user/settings/confirm-email/:token", UserSettingsController, :confirm_email
end
...As you can see, the registration route has a :redirect_if_user_is_authenticated plug, which means it will redirect to the home page if the user is already authenticated. The user settings routes use a :require_authenticated_user plug, which means they will redirect to the log in page if the user is not authenticated. These plugs are defined in the lib/hello_web/user_auth.ex module.
For our cart routes, we want to only allow access to authenticated users. Add the following routes to your router in lib/hello_web/router.ex:
scope "/", HelloWeb do
pipe_through :browser
get "/", PageController, :index
resources "/products", ProductController
end
+ scope "/", HelloWeb do
+ pipe_through [:browser, :require_authenticated_user]
+
+ resources "/cart_items", CartItemController, only: [:create, :delete]
+
+ get "/cart", CartController, :show
+ put "/cart", CartController, :update
+ endWe added a resources declaration for a CartItemController, which will wire up the routes for a create and delete action for adding and removing individual cart items. Next, we added two new routes pointing at a CartController. The first route, a GET request, will map to our show action, to show the cart contents. The second route, a PUT request, will handle the submission of a form for updating our cart quantities.
With our routes in place, let's add the ability to add an item to our cart from the product show page. Create a new file at lib/hello_web/controllers/cart_item_controller.ex and key this in:
defmodule HelloWeb.CartItemController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def create(conn, %{"product_id" => product_id}) do
case ShoppingCart.add_item_to_cart(conn.assigns.current_scope, conn.assigns.cart, product_id) do
{:ok, _item} ->
conn
|> put_flash(:info, "Item added to your cart")
|> redirect(to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error adding the item to your cart")
|> redirect(to: ~p"/cart")
end
end
def delete(conn, %{"id" => product_id}) do
{:ok, _cart} = ShoppingCart.remove_item_from_cart(conn.assigns.current_scope, conn.assigns.cart, product_id)
redirect(conn, to: ~p"/cart")
end
endWe defined a new CartItemController with the create and delete actions that we declared in our router. For create, we call a ShoppingCart.add_item_to_cart/3 function which we'll implement in a moment. If successful, we show a flash successful message and redirect to the cart show page; else, we show a flash error message and redirect to the cart show page. For delete, we'll call a remove_item_from_cart function which we'll implement on our ShoppingCart context and then redirect back to the cart show page. We haven't implemented these two shopping cart functions yet, but notice how their names scream their intent: add_item_to_cart and remove_item_from_cart make it obvious what we are accomplishing here. It also allows us to spec out our web layer and context APIs without thinking about all the implementation details at once.
Let's implement the new interface for the ShoppingCart context API in lib/hello/shopping_cart.ex:
+ alias Hello.Catalog
- alias Hello.ShoppingCart.Cart
+ alias Hello.ShoppingCart.{Cart, CartItem}
alias Hello.Accounts.Scope
+ def get_cart(%Scope{} = scope) do
+ Repo.one(
+ from(c in Cart,
+ where: c.user_id == ^scope.user.id,
+ left_join: i in assoc(c, :items),
+ left_join: p in assoc(i, :product),
+ order_by: [asc: i.inserted_at],
+ preload: [items: {i, product: p}]
+ )
+ )
+ end
def create_cart(%Scope{} = scope, attrs) do
with {:ok, cart = %Cart{}} <-
%Cart{}
|> Cart.changeset(attrs, scope)
|> Repo.insert() do
broadcast(scope, {:created, cart})
- {:ok, cart}
+ {:ok, get_cart(scope)}
end
end
+
+ def add_item_to_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
+ true = cart.user_id == scope.user.id
+ product = Catalog.get_product!(product_id)
+
+ %CartItem{quantity: 1, price_when_carted: product.price}
+ |> CartItem.changeset(%{})
+ |> Ecto.Changeset.put_assoc(:cart, cart)
+ |> Ecto.Changeset.put_assoc(:product, product)
+ |> Repo.insert(
+ on_conflict: [inc: [quantity: 1]],
+ conflict_target: [:cart_id, :product_id]
+ )
+ end
+
+ def remove_item_from_cart(%Scope{} = scope, %Cart{} = cart, product_id) do
+ true = cart.user_id == scope.user.id
+
+ {1, _} =
+ Repo.delete_all(
+ from(i in CartItem,
+ where: i.cart_id == ^cart.id,
+ where: i.product_id == ^product_id
+ )
+ )
+
+ {:ok, get_cart(scope)}
+ endWe started by implementing get_cart/1 which fetches our cart and joins the cart items, and their products so that we have the full cart populated with all preloaded data. Next, we modified our create_cart function to use get_cart to reload the cart contents.
Next, we wrote our new add_item_to_cart/3 function which accepts a scope, a cart struct and a product id. We proceed to fetch the product with Catalog.get_product!/1, showing how contexts can naturally invoke other contexts if required. You could also have chosen to receive the product as argument and you would achieve similar results. Then we used an upsert operation against our repo to either insert a new cart item into the database, or increase the quantity by one if it already exists in the cart. This is accomplished via the on_conflict and conflict_target options, which tells our repo how to handle an insert conflict.
Finally, we implemented remove_item_from_cart/3 where we simply issue a Repo.delete_all call with a query to delete the cart item in our cart that matches the product ID. Finally, we reload the cart contents by calling get_cart/1.
With our new cart functions in place, we can now expose the "Add to cart" button on the product catalog show page. Open up your template in lib/hello_web/controllers/product_html/show.html.heex and make the following changes:
...
<.button variant="primary" navigate={~p"/products/#{@product}/edit?return_to=show"}>
<.icon name="hero-pencil-square" /> Edit product
</.button>
+ <.button href={~p"/cart_items?product_id=#{@product.id}"} method="post">
+ Add to cart
+ </.button>
...The link function component from Phoenix.Component accepts a :method attribute to issue an HTTP verb when clicked, instead of the default GET request. With this link in place, the "Add to cart" link will issue a POST request, which will be matched by the route we defined in router which dispatches to the CartItemController.create/2 function.
Let's try it out. Start your server with mix phx.server and visit a product page. If we try clicking the add to cart link, we'll be greeted by an error page. If you are authenticated the following logs should be visible in the console:
[info] POST /cart_items
[debug] Processing with HelloWeb.CartItemController.create/2
Parameters: %{"_method" => "post", "product_id" => "1", ...}
Pipelines: [:browser, :require_authenticated_user]
[debug] QUERY OK source="user_tokens" db=2.4ms idle=1340.8ms
...
[debug] QUERY OK source="cart_items" db=2.5ms
INSERT INTO "cart_items" ...
[info] Sent 302 in 24ms
[info] GET /cart
[debug] Processing with HelloWeb.CartController.show/2
Parameters: %{}
Pipelines: [:browser, :require_authenticated_user]
[debug] QUERY OK source="user_tokens" db=1.6ms idle=430.2ms
...
[debug] QUERY OK source="carts" db=1.9ms idle=1798.5ms
...
[info] Sent 500 in 18ms
[error] ** (UndefinedFunctionError) function HelloWeb.CartController.init/1 is undefined (module HelloWeb.CartController is not available)
...It's working! Kind of. If we follow the logs, we see our POST to the /cart_items path. Next, we can see our ShoppingCart.add_item_to_cart function successfully inserted a row into the cart_items table, and then we issued a redirect to /cart. Before our error, we also see a query to the carts table, which means we're fetching the current user's cart. So far so good. We know our CartItem controller and new ShoppingCart context functions are doing their jobs, but we've hit our next unimplemented feature when the router attempts to dispatch to a nonexistent cart controller. Let's create the cart controller, view, and template to display and manage user carts.
Create a new file at lib/hello_web/controllers/cart_controller.ex and key this in:
defmodule HelloWeb.CartController do
use HelloWeb, :controller
alias Hello.ShoppingCart
def show(conn, _params) do
render(conn, :show, changeset: ShoppingCart.change_cart(conn.assigns.current_scope, conn.assigns.cart))
end
endWe defined a new cart controller to handle the get "/cart" route. For showing a cart, we render a "show.html" template which we'll create in a moment. We know we need to allow the cart items to be changed by quantity updates, so right away we know we'll need a cart changeset. Fortunately, the context generator included a ShoppingCart.change_cart/1 function, which we'll use. We pass it our cart struct which is already in the connection assigns thanks to the fetch_current_cart plug we defined in the router.
Next, we can implement the view and template. Create a new view file at lib/hello_web/controllers/cart_html.ex with the following content:
defmodule HelloWeb.CartHTML do
use HelloWeb, :html
alias Hello.ShoppingCart
embed_templates "cart_html/*"
def currency_to_str(%Decimal{} = val), do: "$#{Decimal.round(val, 2)}"
endWe created a view to render our show.html template and aliased our ShoppingCart context so it will be in scope for our template. We'll need to display the cart prices like product item price, cart total, etc, so we defined a currency_to_str/1 which takes our decimal struct, rounds it properly for display, and prepends a USD dollar sign.
Next we can create the template at lib/hello_web/controllers/cart_html/show.html.heex:
<Layouts.app flash={@flash}>
<.header>
My Cart
<:subtitle :if={@cart.items == []}>Your cart is empty</:subtitle>
</.header>
<.form :let={f} for={@changeset} action={~p"/cart"}>
<.inputs_for :let={%{data: item} = item_form} field={f[:items]}>
<.input field={item_form[:quantity]} type="number" label={item.product.title} />
{currency_to_str(ShoppingCart.total_item_price(item))}
</.inputs_for>
<.button>Update cart</.button>
</.form>
**Total**: {currency_to_str(ShoppingCart.total_cart_price(@cart))}
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>We started by showing the empty cart message if our preloaded cart.items is empty. If we have items, we use the form component provided by our HelloWeb.CoreComponents to take our cart changeset that we assigned in the CartController.show/2 action and create a form which maps to our cart controller update/2 action. Within the form, we use the inputs_for component to render inputs for the nested cart items. This will allow us to map item inputs back together when the form is submitted. Next, we display a number input for the item quantity and label it with the product title. We finish the item form by converting the item price to string. We haven't written the ShoppingCart.total_item_price/1 function yet, but again we employed the idea of clear, descriptive public interfaces for our contexts. After rendering inputs for all the cart items, we show an "update cart" submit button, along with the total price of the entire cart. This is accomplished with another new ShoppingCart.total_cart_price/1 function which we'll implement in a moment. Finally, we added a back component to go back to our products page.
We're almost ready to try out our cart page, but first we need to implement our new currency calculation functions. Open up your shopping cart context at lib/hello/shopping_cart.ex and add these new functions:
def total_item_price(%CartItem{} = item) do
Decimal.mult(item.product.price, item.quantity)
end
def total_cart_price(%Cart{} = cart) do
Enum.reduce(cart.items, 0, fn item, acc ->
item
|> total_item_price()
|> Decimal.add(acc)
end)
endWe implemented total_item_price/1 which accepts a %CartItem{} struct. To calculate the total price, we simply take the preloaded product's price and multiply it by the item's quantity. We used Decimal.mult/2 to take our decimal currency struct and multiply it with proper precision. Similarly for calculating the total cart price, we implemented a total_cart_price/1 function which accepts the cart and sums the preloaded product prices for items in the cart. We again make use of the Decimal functions to add our decimal structs together.
Now that we can calculate price totals, let's try it out! Visit http://localhost:4000/cart and you should already see your first item in the cart. Going back to the same product and clicking "add to cart" will show our upsert in action. Your quantity should now be two. Nice work!
Our cart page is almost complete, but submitting the form will yield yet another error.
[info] POST /cart
...
[error] ** (UndefinedFunctionError) function HelloWeb.CartController.update/2 is undefined or privateLet's head back to our CartController at lib/hello_web/controllers/cart_controller.ex and implement the update action:
def update(conn, %{"cart" => cart_params}) do
case ShoppingCart.update_cart(conn.assigns.current_scope, conn.assigns.cart, cart_params) do
{:ok, _cart} ->
redirect(conn, to: ~p"/cart")
{:error, _changeset} ->
conn
|> put_flash(:error, "There was an error updating your cart")
|> redirect(to: ~p"/cart")
end
endWe started by plucking out the cart params from the form submit. Next, we call our existing ShoppingCart.update_cart/2 function which was added by the context generator. We'll need to make some changes to this function, but the interface is good as is. If the update is successful, we redirect back to the cart page, otherwise we show a flash error message and send the user back to the cart page to fix any mistakes. Out-of-the-box, our ShoppingCart.update_cart/2 function only concerned itself with casting the cart params into a changeset and updates it against our repo. For our purposes, we now need it to handle nested cart item associations, and most importantly, business logic for how to handle quantity updates like zero-quantity items being removed from the cart.
Head back over to your shopping cart context in lib/hello/shopping_cart.ex and replace your update_cart/2 function with the following implementation:
def update_cart(%Scope{} = scope, %Cart{} = cart, attrs) do
true = cart.user_id == scope.user.id
changeset =
cart
|> Cart.changeset(attrs, scope)
|> Ecto.Changeset.cast_assoc(:items, with: &CartItem.changeset/2)
Repo.transact(fn ->
with {:ok, cart} <- Repo.update(changeset),
{_count, _cart_items} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id and i.quantity == 0)) do
{:ok, cart}
end
end)
|> case do
{:ok, cart} ->
broadcast_cart(scope, {:updated, cart})
{:ok, cart}
{:error, reason} ->
{:error, reason}
end
endWe started much like how our out-of-the-box code started β we take the cart struct and cast the user input to a cart changeset, except this time we use Ecto.Changeset.cast_assoc/3 to cast the nested item data into CartItem changesets. Remember the <.inputs_for /> call in our cart form template? That hidden ID data is what allows Ecto's cast_assoc to map item data back to existing item associations in the cart.
Once the changeset is ready, we wrap everything in Repo.transact/2 so the operations run safely as one. Inside the transaction, we update the cart using Repo.update/1. If the update succeeds, we follow up with a cleanup step using Repo.delete_all/2 to remove any cart items with zero quantity. Running both steps in the same transaction prevents partial updates and keeps the cart data accurate. Finally, we broadcast the updated cart so that any connected LiveViews can instantly show the changes.
Let's head back to the browser and try it out. Add a few products to your cart, update the quantities, and watch the values changes along with the price calculations. Setting any quantity to 0 will also remove the item. You can also try logging out and registering a new user to see how the carts are scoped to the current user. Pretty neat!
File: guides/data_modelling/faq.md
6. FAQ
Here we list frequently asked questions about contexts.
When to use code generators?
In this guide, we have used code generators for schemas, contexts, controllers, and more. If you are happy to move forward with Phoenix defaults, feel free to rely on generators to scaffold large parts of your application. When using Phoenix generators, the main question you need to answer is: does this new functionality (with its schema, table, and fields) belong to one of the existing contexts or a new one?
This way, Phoenix generators guide you to use contexts to group related functionality, instead of having several dozens of schemas laying around without any structure. And remember: if you're stuck when trying to come up with a context name, you can simply use the plural form of the resource you're creating.
How do I structure code inside contexts?
You may wonder how to organize the code inside contexts. For example, should you define a module for changesets (such as ProductChangesets) and another module for queries (such as ProductQueries)?
One important benefit of contexts is that this decision does not matter much. The context is your public API, the other modules are private. Contexts isolate these modules into small groups so the surface area of your application is the context and not all of your code.
So while you and your team could establish patterns for organizing these private modules, it is also our opinion that it is completely fine for them to be different. The major focus should be on how the contexts are defined and how they interact with each other (and with your web application).
Think about it as a well-kept neighbourhood. Your contexts are houses, you want to keep them well-preserved, well-connected, etc. Inside the houses, they may all be a little bit different, and that's fine.
Returning Ecto structures from context APIs
As we explored the context API, you might have wondered:
If one of the goals of our context is to encapsulate Ecto Repo access, why does
create_user/1return anEcto.Changesetstruct when we fail to create a user?
Although Changesets are part of Ecto, they are not tied to the database, and they can be used to map data from and to any source, which makes it a general and useful data structure for tracking field changes, perform validations, and generate error messages.
For those reasons, %Ecto.Changeset{} is a good choice to model the data changes between your contexts and your web layer - regardless if you are talking to an API or the database.
Finally, note that your controllers and views are not hardcoded to work exclusively with Ecto either. Instead, Phoenix defines protocols such as Phoenix.Param and Phoenix.HTML.FormData, which allow any library to extend how Phoenix generates URL parameters or renders forms. Conveniently for us, the phoenix_ecto project implements those protocols, but you could as well bring your own data structures and implement them yourself.
File: guides/data_modelling/in_context_relationships.md
3. In-context Relationships
Our basic catalog features are nice, but let's take it up a notch by categorizing products. Many ecommerce solutions allow products to be categorized in different ways, such as a product being marked for fashion, power tools, and so on. Starting with a one-to-one relationship between product and categories will cause major code changes later if we need to start supporting multiple categories. Let's set up a category association that will allow us to start off tracking a single category per product, but easily support more later as we grow our features.
For now, categories will contain only textual information. Our first order of business is to decide where categories live in the application. We have our Catalog context, which manages the exhibition of our products. Product categorization is a natural fit here. Phoenix is also smart enough to generate code inside an existing context, which makes adding new resources to a context a breeze. Run the following command at your project root:
Sometimes it may be tricky to determine if two resources belong to the same context or not. In those cases, prefer distinct contexts per resource and refactor later if necessary. Otherwise you can easily end up with large contexts of loosely related entities. Also keep in mind that the fact two resources are related does not necessarily mean they belong to the same context, otherwise you would quickly end up with one large context, as the majority of resources in an application are connected to each other. To sum it up: if you are unsure, you should prefer separate modules (contexts).
mix phx.gen.context Catalog Category categories \
title:string:unique --no-scopeYou will see the following output in your terminal:
You are generating into an existing context.
The Hello.Catalog context currently has 7 functions and 1 file in its directory.
* It's OK to have multiple resources in the same context as long as they are closely related.
...
Would you like to proceed? [Yn]Type y followed by the Return key.
You should see output similar to:
* creating lib/hello/catalog/category.ex
* creating priv/repo/migrations/20250203192325_create_categories.exs
* injecting lib/hello/catalog.ex
* injecting test/hello/catalog_test.exs
* injecting test/support/fixtures/catalog_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrateThis time around, we used mix phx.gen.context, which is just like mix phx.gen.html, except it doesn't generate the web files for us. Since we already have controllers and templates for managing products, we can integrate the new category features into our existing web form and product show page. We can see we now have a new Category schema alongside our product schema at lib/hello/catalog/category.ex, and Phoenix told us it was injecting new functions in our existing Catalog context for the category functionality. The injected functions will look very familiar to our product functions, with new functions like create_category, list_categories, and so on. Before we migrate up, we need to do a second bit of code generation. Our category schema is great for representing an individual category in the system, but we need to support a many-to-many relationship between products and categories. Fortunately, ecto allows us to do this simply with a join table, so let's generate that now with the ecto.gen.migration command:
mix ecto.gen.migration create_product_categoriesYou will see output confirming the migration file was created:
* creating priv/repo/migrations/20250203192958_create_product_categories.exsNext, let's open up the new migration file and add the following code to the change function:
defmodule Hello.Repo.Migrations.CreateProductCategories do
use Ecto.Migration
def change do
create table(:product_categories, primary_key: false) do
add :product_id, references(:products, on_delete: :delete_all)
add :category_id, references(:categories, on_delete: :delete_all)
end
create index(:product_categories, [:product_id])
create unique_index(:product_categories, [:category_id, :product_id])
end
endWe created a product_categories table and used the primary_key: false option since our join table does not need a primary key. Next we defined our :product_id and :category_id foreign key fields, and passed on_delete: :delete_all to ensure the database prunes our join table records if a linked product or category is deleted. By using a database constraint, we enforce data integrity at the database level, rather than relying on ad-hoc and error-prone application logic.
Next, we created indexes for our foreign keys, one of which is a unique index to ensure a product cannot have duplicate categories. Note that we do not necessarily need single-column index for category_id because it is in the leftmost prefix of multicolumn index, which is enough for the database optimizer. Adding a redundant index, on the other hand, only adds overhead on write.
With our migrations in place, we can migrate up.
mix ecto.migrateYou will see the following output confirming migration success:
18:20:36.489 [info] == Running 20250222231834 Hello.Repo.Migrations.CreateCategories.change/0 forward
18:20:36.493 [info] create table categories
18:20:36.508 [info] create index categories_title_index
18:20:36.512 [info] == Migrated 20250222231834 in 0.0s
18:20:36.547 [info] == Running 20250222231930 Hello.Repo.Migrations.CreateProductCategories.change/0 forward
18:20:36.547 [info] create table product_categories
18:20:36.557 [info] create index product_categories_product_id_index
18:20:36.560 [info] create index product_categories_category_id_product_id_index
18:20:36.562 [info] == Migrated 20250222231930 in 0.0sNow that we have a Catalog.Product schema and a join table to associate products and categories, we're nearly ready to start wiring up our new features. Before we dive in, we first need real categories to select in our web UI. Let's quickly seed some new categories in the application. Add the following code to your seeds file in priv/repo/seeds.exs:
for title <- ["Home Improvement", "Power Tools", "Gardening", "Books", "Education"] do
{:ok, _} = Hello.Catalog.create_category(%{title: title})
endWe simply enumerate over a list of category titles and use the generated create_category/1 function of our catalog context to persist the new records. We can run the seeds with mix run:
mix run priv/repo/seeds.exsThe output in the terminal confirms the seeds.exs executed successfully:
[debug] QUERY OK db=3.1ms decode=1.1ms queue=0.7ms idle=2.2ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Home Improvement", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=1.2ms queue=1.3ms idle=12.3ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Power Tools", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=1.1ms queue=1.1ms idle=15.1ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Gardening", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]
[debug] QUERY OK db=2.4ms queue=1.0ms idle=17.6ms
INSERT INTO "categories" ("title","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["Books", ~N[2025-02-03 19:39:53], ~N[2025-02-03 19:39:53]]Perfect. Before we integrate categories in the web layer, we need to let our context know how to associate products and categories. First, open up lib/hello/catalog/product.ex and add the following association:
+ alias Hello.Catalog.Category
schema "products" do
field :description, :string
field :price, :decimal
field :title, :string
field :views, :integer
+ many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete
timestamps(type: :utc_datetime)
endWe used Ecto.Schema's many_to_many macro to let Ecto know how to associate our product to multiple categories through the "product_categories" join table. We also used the on_replace: :delete option to declare that any existing join records should be deleted when we are changing our categories.
With our schema associations set up, we can implement the selection of categories in our product form. To do so, we need to translate the user input of catalog IDs from the front-end to our many-to-many association. Fortunately Ecto makes this a breeze now that our schema is set up. Open up your catalog context and make the following changes:
+ alias Hello.Catalog.Category
- def get_product!(id), do: Repo.get!(Product, id)
+ def get_product!(id) do
+ Product
+ |> Repo.get!(id)
+ |> Repo.preload(:categories)
+ end
def create_product(attrs) do
%Product{}
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.insert()
end
def update_product(%Product{} = product, attrs) do
product
- |> Product.changeset(attrs)
+ |> change_product(attrs)
|> Repo.update()
end
def change_product(%Product{} = product, attrs \\ %{}) do
- Product.changeset(product, attrs)
+ categories = list_categories_by_id(attrs["category_ids"])
+ product
+ |> Repo.preload(:categories)
+ |> Product.changeset(attrs)
+ |> Ecto.Changeset.put_assoc(:categories, categories)
end
+ def list_categories_by_id(nil), do: []
+ def list_categories_by_id(category_ids) do
+ Repo.all(from c in Category, where: c.id in ^category_ids)
+ endFirst, we added Repo.preload to preload our categories when we fetch a product. This will allow us to reference product.categories in our controllers, templates, and anywhere else we want to make use of category information. Next, we modified our create_product and update_product functions to call into our existing change_product function to produce a changeset. Within change_product we added a lookup to find all categories if the "category_ids" attribute is present. Then we preloaded categories and called Ecto.Changeset.put_assoc to place the fetched categories into the changeset. Finally, we implemented the list_categories_by_id/1 function to query the categories matching the category IDs, or return an empty list if no "category_ids" attribute is present. Now our create_product and update_product functions receive a changeset with the category associations all ready to go once we attempt an insert or update against our repo.
Next, let's expose our new feature to the web by adding the category input to our product form. To keep our form template tidy, let's write a new function to wrap up the details of rendering a category select input for our product. Open up your ProductHTML view in lib/hello_web/controllers/product_html.ex and key this in:
def category_opts(changeset) do
existing_ids =
changeset
|> Ecto.Changeset.get_change(:categories, [])
|> Enum.map(& &1.data.id)
for cat <- Hello.Catalog.list_categories() do
[key: cat.title, value: cat.id, selected: cat.id in existing_ids]
end
endWe added a new category_opts/1 function which generates the select options for a multiple select tag we will add soon. We calculated the existing category IDs from our changeset, then used those values when we generate the select options for the input tag. We did this by enumerating over all of our categories and returning the appropriate key, value, and selected values. We marked an option as selected if the category ID was found in those category IDs in our changeset.
With our category_opts function in place, we can open up lib/hello_web/controllers/product_html/product_form.html.heex and add:
...
<.input field={f[:views]} type="number" label="Views" />
+ <.input field={f[:category_ids]} type="select" multiple options={category_opts(@changeset)} />
<.button>Save Product</.button>We added a category_select above our save button. Now let's try it out. Next, let's show the product's categories in the product show template. Add the following code to the list in lib/hello_web/controllers/product_html/show.html.heex:
<.list>
...
+ <:item title="Categories">
+
+ <li :for={cat <- @product.categories}>{cat.title}</li>
+
+ </:item>
</.list>Now if we start the server with mix phx.server and visit http://localhost:4000/products/new, we'll see the new category multiple select input. Enter some valid product details, select a category or two, and click save.
Title: Elixir Flashcards
Price: 5.000000
Views: 0
Categories:
Education
BooksIt's not much to look at yet, but it works! We added relationships within our context complete with data integrity enforced by the database. Not bad. Let's keep building!
File: guides/data_modelling/more_examples.md
5. Bringing It Home
With our Catalog and ShoppingCart contexts, we're seeing first-hand how our well-considered modules and function names are yielding clear and maintainable code. Our last order of business is to allow the user to initiate the checkout process. We won't go as far as integrating payment processing or order fulfillment, but we'll get you started in that direction. This will be a great opportunity to put what we have learned so far in practice.
Like before, we need to decide where code for completing an order should live. Is it part of the catalog? Clearly not, but what about the shopping cart? Shopping carts are related to orders β after all, the user has to add items in order to purchase any products β but should the order checkout process be grouped here?
If we stop and consider the order process, we'll see that orders involve related, but distinctly different data from the cart contents. Also, business rules around the checkout process are much different than carting. For example, we may allow a user to add a back-ordered item to their cart, but we could not allow an order with no inventory to be completed. Additionally, we need to capture point-in-time product information when an order is completed, such as the price of the items at payment transaction time. This is essential because a product price may change in the future, but the line items in our order must always record and display what we charged at time of purchase. For these reasons, we can start to see that ordering can stand on its own with its own data concerns and business rules.
Naming wise, Orders clearly defines our context, so let's get started by again taking advantage of the context generators. Note that the user scope generated by mix phx.gen.auth is marked as default scope (in your config/config.exs), therefore we don't need to specify it in our command. There can be different scopes in an application, in which case the --scope option can be used when running the generators. Run the following command in your console:
$ mix phx.gen.context Orders Order orders total_price:decimal
* creating lib/hello/orders/order.ex
* creating priv/repo/migrations/20250209214612_create_orders.exs
* creating lib/hello/orders.ex
* injecting lib/hello/orders.ex
* creating test/hello/orders_test.exs
* injecting test/hello/orders_test.exs
* creating test/support/fixtures/orders_fixtures.ex
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrateWe generated an Orders context. The order is automatically scoped to the current user and added a total_price column. With our starting point in place, let's open up the newly created migration in priv/repo/migrations/*_create_orders.exs and make the following changes:
def change do
create table(:orders) do
- add :total_price, :decimal
+ add :total_price, :decimal, precision: 15, scale: 6, null: false
add :user_id, references(:users, type: :id, on_delete: :delete_all)
timestamps(type: :utc_datetime)
end
endLike we did previously, we gave appropriate precision and scale options for our decimal column which will allow us to store currency without precision loss. We also added a not-null constraint to enforce all orders to have a price.
The orders table alone doesn't hold much information, but we know we'll need to store point-in-time product price information of all the items in the order. For that, we'll add an additional struct for this context named LineItem. Line items will capture the price of the product at payment transaction time. Please run the following command:
$ mix phx.gen.context Orders LineItem order_line_items \
price:decimal quantity:integer \
order_id:references:orders product_id:references:products --no-scope
You are generating into an existing context.
...
Would you like to proceed? [Yn] y
* creating lib/hello/orders/line_item.ex
* creating priv/repo/migrations/20250209215050_create_order_line_items.exs
* injecting lib/hello/orders.ex
* injecting test/hello/orders_test.exs
* injecting test/support/fixtures/orders_fixtures.ex
Remember to update your repository by running migrations:
$ mix ecto.migrateWe used the phx.gen.context command to generate the LineItem Ecto schema and inject supporting functions into our orders context. Like before, let's modify the migration in priv/repo/migrations/*_create_order_line_items.exs and make the following decimal field changes:
def change do
create table(:order_line_items) do
- add :price, :decimal
+ add :price, :decimal, precision: 15, scale: 6, null: false
add :quantity, :integer
add :order_id, references(:orders, on_delete: :nothing)
add :product_id, references(:products, on_delete: :nothing)
timestamps(type: :utc_datetime)
end
create index(:order_line_items, [:order_id])
create index(:order_line_items, [:product_id])
endWith our migration in place, let's wire up our orders and line items associations in lib/hello/orders/order.ex:
schema "orders" do
field :total_price, :decimal
- field :user_id, :id
+ belongs_to :user, Hello.Accounts.User
+ has_many :line_items, Hello.Orders.LineItem
+ has_many :products, through: [:line_items, :product]
timestamps(type: :utc_datetime)
endWe used has_many :line_items to associate orders and line items, just like we've seen before. Next, we used the :through feature of has_many, which allows us to instruct ecto how to associate resources across another relationship. In this case, we can associate products of an order by finding all products through associated line items. Next, let's wire up the association in the other direction in lib/hello/orders/line_item.ex:
schema "order_line_items" do
field :price, :decimal
field :quantity, :integer
- field :order_id, :id
- field :product_id, :id
+ belongs_to :order, Hello.Orders.Order
+ belongs_to :product, Hello.Catalog.Product
timestamps(type: :utc_datetime)
endWe used belongs_to to associate line items to orders and products. With our associations in place, we can start integrating the web interface into our order process. Open up your router lib/hello_web/router.ex and add the following line:
scope "/", HelloWeb do
pipe_through [:browser, :require_authenticated_user]
resources "/cart_items", CartItemController, only: [:create, :delete]
get "/cart", CartController, :show
put "/cart", CartController, :update
+ resources "/orders", OrderController, only: [:create, :show]
endWe wired up create and show routes for our generated OrderController, since these are the only actions we need at the moment. With our routes in place, we can now migrate up:
$ mix ecto.migrate
17:14:37.715 [info] == Running 20250209214612 Hello.Repo.Migrations.CreateOrders.change/0 forward
17:14:37.720 [info] create table orders
17:14:37.755 [info] == Migrated 20250209214612 in 0.0s
17:14:37.784 [info] == Running 20250209215050 Hello.Repo.Migrations.CreateOrderLineItems.change/0 forward
17:14:37.785 [info] create table order_line_items
17:14:37.795 [info] create index order_line_items_order_id_index
17:14:37.796 [info] create index order_line_items_product_id_index
17:14:37.798 [info] == Migrated 20250209215050 in 0.0sBefore we render information about our orders, we need to ensure our order data is fully populated and can be looked up by a current user. Open up your orders context in lib/hello/orders.ex and adjust your get_order!/2 to include a preload:
def get_order!(%Scope{} = scope, id) do
- Repo.get_by!(Order, id: id, user_id: scope.user.id)
+ Order
+ |> Repo.get_by!(id: id, user_id: scope.user.id)
+ |> Repo.preload([line_items: [:product]])
endTo complete an order, our cart page can issue a POST to the OrderController.create action, but we need to implement the operations and logic to actually complete an order. Like before, we'll start at the web interface. Create a new file at lib/hello_web/controllers/order_controller.ex and key this in:
defmodule HelloWeb.OrderController do
use HelloWeb, :controller
alias Hello.Orders
def create(conn, _) do
case Orders.complete_order(conn.assigns.current_scope, conn.assigns.cart) do
{:ok, order} ->
conn
|> put_flash(:info, "Order created successfully.")
|> redirect(to: ~p"/orders/#{order}")
{:error, _reason} ->
conn
|> put_flash(:error, "There was an error processing your order")
|> redirect(to: ~p"/cart")
end
end
endWe wrote the create action to call an as-yet-implemented Orders.complete_order/2 function. Our code is technically "creating" an order, but it's important to step back and consider the naming of your interfaces. The act of completing an order is extremely important in our system. Money changes hands in a transaction, physical goods could be automatically shipped, etc. Such an operation deserves a better, more obvious function name, such as complete_order. If the order is completed successfully we redirect to the show page, otherwise a flash error is shown as we redirect back to the cart page.
Here is also a good opportunity to highlight that contexts can naturally work with data defined by other contexts too. This will be especially common with data that is used throughout the application, such as the cart here (but it can also be the current user or the current project, and so forth, depending on your project).
Now we can implement our Orders.complete_order/2 function. To complete an order, our job will require a few operations:
- A new order record must be persisted with the total price of the order
- All items in the cart must be transformed into new order line items records
with quantity and point-in-time product price information - After successful order insert (and eventual payment), items must be pruned
from the cart
From our requirements alone, we can start to see why a generic create_order function doesn't cut it. Let's implement this new function in lib/hello/orders.ex:
alias Hello.Orders.LineItem
alias Hello.ShoppingCart
def complete_order(%Scope{} = scope, %ShoppingCart.Cart{} = cart) do
true = cart.user_id == scope.user.id
line_items =
Enum.map(cart.items, fn item ->
%{
product_id: item.product_id,
price: item.product.price,
quantity: item.quantity
}
end)
order_changeset =
Ecto.Changeset.change(%Order{}, %{
user_id: scope.user.id,
total_price: ShoppingCart.total_cart_price(cart),
line_items: line_items
})
Repo.transact(fn ->
with {:ok, order} <- Repo.insert(order_changeset),
{:ok, _cart} <- ShoppingCart.prune_cart_items(scope, cart) do
{:ok, order}
end
end)
|> case do
{:ok, order} ->
broadcast_order(scope, {:created, order})
{:ok, order}
{:error, reason} ->
{:error, reason}
end
endWe started by mapping the %ShoppingCart.CartItem{}'s in our shopping cart into a map of order line items structs. The job of the order line item record is to capture the price of the product at payment transaction time, so we reference the product's price here. Next, we create a bare order changeset with Ecto.Changeset.change/2 and associate our user UUID, set our total price calculation, and place our order line items in the changeset. With a fresh order changeset ready to be inserted, we now make use of Repo.transact/2 to execute our operations in a database transaction. We start by inserting the order, followed by a step that prunes all items from the userβs cart. The function wrapped inside Repo.transact/2 must either return {:ok, result} or error, which halts and rolls back the transaction. Running the transaction will execute these operations in sequence, and we return the result to the caller once completed.
To close out our order completion, we need to implement the ShoppingCart.prune_cart_items/1 function in lib/hello/shopping_cart.ex:
def prune_cart_items(%Scope{} = scope, %Cart{} = cart) do
{_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
{:ok, get_cart(scope)}
endOur new function accepts the cart struct and issues a Repo.delete_all which accepts a query of all items for the provided cart. We return a success result by simply reloading the pruned cart to the caller. With our context complete, we now need to show the user their completed order. Head back to your order controller and add the show/2 action:
def show(conn, %{"id" => id}) do
order = Orders.get_order!(conn.assigns.current_scope, id)
render(conn, :show, order: order)
endWe added the show action to pass our conn.assigns.current_scope to get_order! which authorizes orders to be viewable only by the owner of the order. Next, we can implement the view and template. Create a new view file at lib/hello_web/controllers/order_html.ex with the following content:
defmodule HelloWeb.OrderHTML do
use HelloWeb, :html
embed_templates "order_html/*"
endNext we can create the template at lib/hello_web/controllers/order_html/show.html.heex:
<Layouts.app flash={@flash}>
<.header>
Thank you for your order!
<:subtitle>
**Email: **{@current_scope.user.email}
</:subtitle>
</.header>
<.table id="items" rows={@order.line_items}>
<:col :let={item} label="Title">{item.product.title}</:col>
<:col :let={item} label="Quantity">{item.quantity}</:col>
<:col :let={item} label="Price">
{HelloWeb.CartHTML.currency_to_str(item.price)}
</:col>
</.table>
**Total price:**
{HelloWeb.CartHTML.currency_to_str(@order.total_price)}
<.button navigate={~p"/products"}>Back to products</.button>
</Layouts.app>To show our completed order, we displayed the order's user, followed by the line item listing with product title, quantity, and the price we "transacted" when completing the order, along with the total price.
Our last addition will be to add the "complete order" button to our cart page to allow completing an order. Add the following button to the <.header> of the cart show template in lib/hello_web/controllers/cart_html/show.html.heex:
<.header>
My Cart
+ <:actions>
+ <.button href={~p"/orders"} method="post">
+ Complete order
+ </.button>
+ </:actions>
</.header>We added a link with method="post" to send a POST request to our OrderController.create action. If we head back to our cart page at http://localhost:4000/cart and complete an order, we'll be greeted by our rendered template:
Thank you for your order!
User uuid: 08964c7c-908c-4a55-bcd3-9811ad8b0b9d
Title Quantity Price
Metaprogramming Elixir 2 $15.00
Total price: $30.00We haven't added payments, but we can already see how our ShoppingCart and Orders context splitting is driving us towards a maintainable solution. With our cart items separated from our order line items, we are well equipped in the future to add payment transactions, cart price detection, and more.
Great work!