Database-driven top-level URLs in Phoenix, without your own routes getting shadowed.
Phoenix routes are compile-time macros, so a URL that lives in a database
row cannot be declared. The usual workaround is a catch-all at /, which
then swallows every route declared after it — Phoenix matches in declaration
order and there is no fall-through. The standard advice is to put your
specific routes first and be careful. This makes the ordering stop mattering.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use DynamicRoutes, resolver: MyApp.Pages
scope "/", MyAppWeb do
pipe_through :browser
dynamic_routes MyAppWeb.PageController, :show
get "/settings", SettingsController, :edit
end
end/settings still reaches SettingsController even though the catch-all is
written above it, and /about reaches PageController if MyApp.Pages
says it should.
How it works
A declared route always wins. On each request the router is asked — through
Phoenix.Router.route_info/4, its own public lookup — whether anything
matches. If something does, the request is passed straight through and
Phoenix behaves exactly as it always would.
Only when nothing matches, on a request that was going to be a 404 anyway,
is the resolver consulted. If it claims the path, conn.path_info is
rewritten to an internal route, super/2 runs the real Phoenix pipeline —
plugs, telemetry, error handling, all of it — and the path is put back
before the controller sees it.
Nothing here reimplements routing. The router does the matching, twice, and the second time it is matching a route that was declared normally.
Setting it up
Three pieces.
A resolver — see DynamicRoutes.Resolver:
defmodule MyApp.Pages do
@behaviour DynamicRoutes.Resolver
@impl true
def resolve([slug]) do
case MyApp.Content.page_by_slug(slug) do
nil -> :pass
page -> {:match, page}
end
end
def resolve(_path), do: :pass
enduse DynamicRoutes in the router, after use MyAppWeb, :router — it
overrides the call/2 that defines, which has to exist first.
dynamic_routes/2 inside the scope and pipeline the dynamic pages
should run through.
Then the controller:
def show(conn, _params) do
page = DynamicRoutes.resolution(conn)
render(conn, :show, page: page)
endThe resolver already loaded it, so there is nothing to look up again.
Cache invalidation
Resolver answers are cached, including the misses — a public site is asked
for paths that do not exist far more often than for ones that do. Call
invalidate/0 when pages change:
def publish(page) do
{:ok, page} = Repo.update(changeset)
DynamicRoutes.invalidate()
{:ok, page}
endIn a multi-node deployment that clears the local node only; broadcast it if
you run more than one. See DynamicRoutes.Cache for the settings, including
turning the cache off in development so edits show up without a restart.
What this does not do
Dynamic paths do not appear in ~p sigils, route helpers, or
mix phx.routes — they are data, and the compiler has never seen them.
Build those URLs from the same records the resolver reads. (The internal
route does appear in all three, labelled with the controller it dispatches
to. Requesting it directly is a 404.)
The target must be a controller, not a LiveView. live/3 compiles to a
route whose private carries the live-session metadata that
Phoenix.LiveView.Plug matches on, and there is no way to reconstruct that
from here — a LiveView target raises. If your dynamic pages are LiveViews,
this is not the tool.
Precedence is per method and path, not per path. A declared post "/x"
does not stop a resolver claiming GET /x, where plain Phoenix would 404.
Dynamic pages answer every method, because the internal route is
declared with match :*. Under plain Phoenix a get-only page would 404 a
POST; here it reaches the controller. If that matters, check
conn.method there.
A forward/2 prefix cannot host dynamic paths. route_info/4 matches
the forward itself, so everything beneath it counts as declared and the
forwarded plug's own 404 wins.
Summary
Functions
Installs dynamic routing into a Phoenix router.
How many resolver answers are currently cached.
Whether this request was routed dynamically.
Declares the route that dynamic paths are dispatched to.
Drops every cached resolver answer.
Drops the cached answer for one path.
What the resolver returned for this request.
Functions
Installs dynamic routing into a Phoenix router.
Must come after use MyAppWeb, :router (or use Phoenix.Router), because
it overrides the call/2 those define.
Options
:resolver— required, a module implementingDynamicRoutes.Resolver.
@spec cache_size() :: non_neg_integer()
How many resolver answers are currently cached.
@spec dynamic?(Plug.Conn.t()) :: boolean()
Whether this request was routed dynamically.
Declares the route that dynamic paths are dispatched to.
Place it inside the scope and pipeline the dynamic pages should run through. Position within the router does not matter — declared routes are matched first regardless — so put it wherever it reads best.
scope "/", MyAppWeb do
pipe_through :browser
dynamic_routes MyAppWeb.PageController, :show
endThe controller module must be fully qualified, even inside a scope with an alias: it travels as route metadata rather than as the route's plug, and Phoenix only expands aliases for the latter.
Options
:path— the internal path segment, default"__dynamic__". Change it only if that genuinely collides with a real URL of yours.:as— the route helper name, default:dynamic_routes.
@spec invalidate() :: :ok
Drops every cached resolver answer.
Call it whenever the set of dynamic paths changes. Clears the local node only.
Drops the cached answer for one path.
DynamicRoutes.invalidate(["blog", "my-post"])
DynamicRoutes.invalidate("/blog/my-post")
@spec resolution(Plug.Conn.t()) :: term() | nil
What the resolver returned for this request.
nil when the request reached the controller through a declared route, so
a controller serving both can tell the difference.