All notable changes to this project are documented here. The format follows Keep a Changelog, and this project adheres to Semantic Versioning.

[0.1.0] - 2026-08-21

Initial release.

Added

  • use DynamicRoutes, resolver: MyApp.Pages — installs dynamic routing into a Phoenix router by overriding the call/2 that Phoenix.Router leaves overridable for exactly this.
  • dynamic_routes/2 — declares the internal route dynamic paths dispatch to. Its position in the router does not matter.
  • DynamicRoutes.Resolver — the one-callback behaviour a host application implements to claim paths.
  • DynamicRoutes.resolution/1 and dynamic?/1 — what the resolver returned, and whether this request was routed dynamically.
  • DynamicRoutes.Cache — a bounded, expiring ETS cache with invalidate/0 and invalidate/1.

Fixed — first review pass

  • A percent-encoded path bypassed declared routes, and their pipelines. Plug never decodes path segments, Phoenix's own call/2 decodes immediately before matching, and route_info/4 does not decode at all — so asking it about a raw path asked a different question than the router answered a moment later. /admin behind an auth pipeline returned 403 while /%61dmin missed the lookup, reached the resolver, and was served as a database page with the pipeline never run. The same bug shadowed any non-ASCII declared route with no attacker involved, since a browser sends /café as /caf%C3%A9. The path is now decoded once, and used for the lookup, the resolver and the cache key alike.
  • The internal route was a live public URL. /__dynamic__/anything matched as a declared route and reached the controller having never been through the resolver, with resolution/1 returning nil — which the documented controller body renders. A request that did not come through the rewrite is now a 404.
  • The cache discarded the answer it had just paid for. Clearing everything on overflow fails at exactly the traffic it exists to survive: a scanner walking random paths keeps the table permanently full, so real pages are re-resolved on every request — measured at 91× database amplification. Replaced with two generations, where an answer found in the previous one is promoted, so anything actually in use survives rotation.
  • Cache reads raised while the owner process was restarting, turning a request that should have been merely slower into a 500. Every ETS call is guarded now, not just the writes.
  • A second dynamic_routes/2 declaration was silently dead — both compiled and every rewrite went to the first. It now refuses to start.
  • The internal path was memoised across recompiles, so changing :path in development left every dynamic request 404ing against the path that had just been deleted, until the VM restarted. Not memoised when code reloading is on.
  • defoverridable call: 2 was missing after the definition, so another library overriding call/2 on top got a clause that could never match. The comment explaining its absence was wrong about why.
  • Paths longer than :cache_max_key_bytes are resolved but not cached.

Fixed — verification pass

  • The code-reloading guard was dead code. It read a config key nothing sets — not the library, not Phoenix, not the docs — so the staleness it was written to prevent was live in every installation. The memo is gone entirely: it saved a filter over a compiled-in list, which is not worth a correctness hazard.
  • The duplicate-declaration check raised at request time. A router with two declarations compiled, booted, served ordinary traffic, and then produced a bare 500 on every dynamic page — the only pages the library exists for. It is a compile-time fact, and is now checked in @after_compile.
  • A reader arriving mid-rotation resolved and threw the answer away, because the :badarg catch sits above the insert. It retries once.
  • Promotion left the old copy in place, so a hot key occupied two slots against a bound expressed in entries.
  • The supervisor used the default three-restarts-in-five-seconds. The cache is a pure optimisation, so a burst of crashes taking the whole application down with it is the wrong trade.

Documentation corrected

The resolver's docs claimed its argument was already percent-decoded — it was not, until this release. The README's central claim was false as written for encoded paths. Nothing mentioned that a LiveView cannot be a target, that precedence is per method as well as path, that dynamic pages answer every method, or that a forward/2 prefix cannot host them. All now stated.

Design notes

  • Declared routes always win. The router is asked first, through Phoenix.Router.route_info/4, and only its misses reach the resolver. That is what makes declaration order stop mattering, and it means a database row can never shadow a route somebody deployed.
  • The path is restored before the controller runs. Matching a database-backed URL means temporarily replacing conn.path_info; leaving that in place would make every canonical URL the controller builds name an internal path no client ever requested. DynamicRoutes.Dispatcher sits between the route and the controller and undoes it after the pipeline has run.
  • Caching is in v1 rather than deferred. The resolver is consulted on requests that matched nothing, which on a public site means every scanner probing for paths that do not exist. Misses are cached as well as hits, and the cache is bounded, because its keys are chosen by whoever is making the request.
  • The log names the real controller. Phoenix logs the dispatch before the dispatcher can correct anything, so the route carries :mfa metadata and a dynamic request reads as Processing with MyAppWeb.PageController.show/2 rather than naming an internal module.