Packages
Database-driven top-level URLs in Phoenix without your declared routes getting shadowed — declared routes always win, order stops mattering.
Current section
Files
Jump to
Current section
Files
dynamic_routes
README.md
README.md
# dynamic_routes
Database-driven top-level URLs in Phoenix — **without your own routes getting
shadowed.**
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use DynamicRoutes, resolver: MyApp.Pages
scope "/", MyAppWeb do
pipe_through :browser
dynamic_routes MyAppWeb.PageController, :show # written FIRST
get "/settings", SettingsController, :edit # still matches
end
end
```
`/settings` reaches `SettingsController`. `/about` reaches `PageController`,
if `MyApp.Pages` says there is a page there. Declaration order stops mattering.
## The problem
Phoenix routes are compile-time macros, so a URL living 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.
This is not a niche complaint. **Beacon**, the Phoenix LiveView CMS, documents
it as a limitation the developer must hand-manage:
> *"put all specific routes first"*
>
> *"any route after the `prefix` may match a published page"*
That is the state of the art: order your routes carefully and hope nobody adds
one in the wrong place. This package makes the ordering irrelevant.
## Installation
```elixir
def deps do
[{:dynamic_routes, "~> 0.1"}]
end
```
## 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 passes straight through and
Phoenix behaves exactly as it always would.
The path is percent-decoded before that question is asked, because Phoenix
decodes before matching and `route_info/4` does not. Skipping that step makes
the two disagree on every encoded request — and `/%61dmin` then slips past the
pipeline guarding `/admin`.
Only when nothing matches, on a request that was heading for a 404 anyway, is
the resolver consulted. If it claims the path:
1. `conn.path_info` is rewritten to an internal route.
2. `super/2` runs the real Phoenix pipeline — plugs, telemetry, error
handling, all of it.
3. The path is restored **before the controller sees it**, so canonical URLs
the controller builds name what the client actually requested.
Nothing here reimplements routing. The router does the matching, twice, and
the second time it is matching a route that was declared normally.
That precedence — code beats data — is the deliberate part. If a database row
could shadow a declared route, deploying a route would be a gamble on the
contents of a table, and a page called `settings` would take down your
settings page.
## Setting it up
**A resolver.** It is asked only about paths no declared route matched, and
answers `:pass` or `{:match, term}`:
```elixir
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
end
```
**The controller** gets whatever the resolver returned, so it does not look
the record up a second time:
```elixir
def show(conn, _params) do
page = DynamicRoutes.resolution(conn)
render(conn, :show, page: page)
end
```
`use DynamicRoutes` must come *after* `use MyAppWeb, :router` — it overrides
the `call/2` that defines.
## Caching is not optional
The resolver runs on requests that matched no declared route. On a public site
that includes every scanner probing for `/wp-login.php`, forever. Without a
cache that is a database query per probe.
So answers are cached — **including the misses**, which are the common case —
with a TTL and a hard size limit. The keys are paths chosen by whoever is
making the request, so an unbounded cache would be a memory-exhaustion bug
with a public trigger.
Invalidate when pages change:
```elixir
DynamicRoutes.invalidate() # everything
DynamicRoutes.invalidate("/blog/post") # one path
```
This clears the local node. Broadcast it if you run more than one.
```elixir
config :dynamic_routes,
cache_ttl: :timer.minutes(5),
cache_max_size: 10_000,
cache: true
```
Set `cache: false` in development so editing a page shows up on reload.
## What it does not do
Dynamic paths do not appear in `~p` sigils, route helpers, or
`mix phx.routes`. They are data; the compiler has never seen them. Build those
URLs from the same records the resolver reads. The *internal* route does
appear in all three — requesting it directly is a 404.
**The target must be a controller, not a LiveView.** `live/3` compiles to a
route carrying live-session metadata that `Phoenix.LiveView.Plug` matches on,
and there is no way to reconstruct it from here. If your dynamic pages are
LiveViews, this is not the tool — which is worth saying plainly, given that
the motivating example above is a LiveView CMS.
**Precedence is per method and path.** A declared `post "/x"` does not stop a
resolver claiming `GET /x`.
**Dynamic pages answer every method.** The internal route is `match :*`, so a
`POST` to a dynamic page reaches the controller rather than 404ing. Check
`conn.method` if that matters.
**A `forward/2` prefix cannot host dynamic paths** — everything beneath it
counts as declared, so the forwarded plug's own 404 wins.
## Notes
The controller module passed to `dynamic_routes/2` 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.
Declaring it inside a scope works: the rewrite reads the path the route
actually ended up at from the router's own route table, rather than assuming
the one it was configured with.
Dynamic requests log exactly like declared ones —
`Processing with MyAppWeb.PageController.show/2` — rather than naming the
internal dispatcher, so a dynamically routed request is not a mystery in
production logs.
## Licence
MIT.