Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 102 additions & 2 deletions dev_docs/guides/2026-02-24-module-system-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ PhoenixKit's module system is a plugin architecture that lets feature modules se

**Three components power the system:**

- **`PhoenixKit.Module`** — the behaviour contract (5 required + 8 optional callbacks)
- **`PhoenixKit.Module`** — the behaviour contract (5 required + 9 optional callbacks)
- **`PhoenixKit.ModuleRegistry`** — GenServer + `:persistent_term` registry; zero-cost reads
- **`PhoenixKit.ModuleDiscovery`** — zero-config beam file scanning; finds modules without config

Expand Down Expand Up @@ -261,6 +261,26 @@ Module containing route macros injected into the router at compile time. **Defau

Semantic version string. **Default:** `"0.0.0"`. Useful for external packages.

### `migration_module/0 :: module() | nil`

Returns the versioned migration coordinator module for this plugin. **Default:** `nil`

When set, `mix phoenix_kit.update` will auto-detect the module's migration state, compare `migrated_version_runtime()` with `current_version()`, and generate + run migration files if the database is behind.

```elixir
def migration_module, do: MyModule.Migration
```

The coordinator module must implement:
- `current_version/0` — returns the latest migration version (integer)
- `migrated_version_runtime/1` — reads the current DB version; accepts `[prefix: "public"]` options (safe to call outside migration context)
- `up/1` — runs migrations; accepts `[prefix: "public", version: target]` options
- `down/1` — rolls back migrations; accepts `[prefix: "public", version: target]` options

Version is tracked via a SQL comment on a designated table (e.g., `COMMENT ON TABLE phoenix_kit_my_items IS '2'`). Each version is an immutable module (e.g., `MyModule.Migration.Postgres.V01`) that the coordinator calls via `Module.concat/1`.

See `PhoenixKitDocumentCreator.Migration` for a production example.

---

## Folder Structure Convention
Expand Down Expand Up @@ -435,6 +455,42 @@ end

Routes are generated at compile time via `compile_plugin_admin_routes/0` in `integration.ex`. A recompile is required after adding a new external module.

### Navigation Paths in Templates

Tab struct `path` fields use a relative convention (`"my-module"` → core prepends `/admin/`). But `href` attributes in HEEx templates and `redirect/2` calls are raw — the browser or Phoenix sends the path as-is. These must go through `PhoenixKit.Utils.Routes.path/1`, which handles the URL prefix and locale prefix.

**Create a Paths module** for your module to centralize all path construction:

```elixir
defmodule PhoenixKitAnalytics.Paths do
alias PhoenixKit.Utils.Routes

@base "/admin/analytics"

def index, do: Routes.path(@base)
def show(id), do: Routes.path("#{@base}/#{id}")
def settings, do: Routes.path("#{@base}/settings")
end
```

Then in templates and LiveViews:

```elixir
alias PhoenixKitAnalytics.Paths

# Template href:
<a href={Paths.show(@item.id)}>View</a>

# Server-side redirect:
redirect(socket, to: Paths.index())
```

This gives the same "single point of change" benefit that Tab structs have for sidebar registration — if the admin path changes, you update `@base` in one file instead of every template.

**Why not use relative paths in templates?**

Tab struct `path` fields go through `Tab.resolve_path/2` at registration time. Template `href` attributes and `redirect(to:)` calls do not — they're raw HTML/Phoenix. Relative paths like `"my-module/items"` would be resolved by the browser relative to the current URL, which breaks when locale segments (e.g., `/ja/`) are in the path.

---

## Enable / Disable Patterns
Expand Down Expand Up @@ -523,7 +579,51 @@ end
config :phoenix_kit, :modules, [PhoenixKitAnalytics]
```

**5. Routes require recompile** after adding the dependency (standard Phoenix constraint).
**5. Create a Paths module** to centralize all navigation paths (see [Navigation Paths in Templates](#navigation-paths-in-templates)):

```elixir
defmodule PhoenixKitAnalytics.Paths do
alias PhoenixKit.Utils.Routes
@base "/admin/analytics"

def index, do: Routes.path(@base)
def show(id), do: Routes.path("#{@base}/#{id}")
end
```

**6. Routes require recompile** after adding the dependency (standard Phoenix constraint).

---

## JavaScript in External Modules

External modules **cannot inject into the parent app's asset pipeline** (`app.js`, `esbuild`, `node_modules`). All JavaScript must be delivered as **inline `<script>` tags** in LiveView templates.

### Inline hooks pattern

PhoenixKit's `app.js` collects hooks from `window.PhoenixKitHooks` when creating the LiveSocket. Inline `<script>` tags in `<body>` execute before deferred `<head>` scripts, so hooks are registered in time.

```elixir
defmodule MyModule.Web.Components.MyScripts do
use Phoenix.Component

def my_scripts(assigns) do
~H"""
<script>
window.PhoenixKitHooks = window.PhoenixKitHooks || {};
window.PhoenixKitHooks.MyHook = {
mounted() { /* ... */ }
};
</script>
"""
end
end
```

**Rules:**
- Register hooks on `window.PhoenixKitHooks` — PhoenixKit spreads this object into the LiveSocket
- Pages using hooks must use **full page load** (`redirect/2`, not `navigate/2`) so the inline script executes
- For large vendor libraries, ship minified files in `priv/static/vendor/` and load via `<script src>` — the install task copies them to the parent app

---

Expand Down
106 changes: 106 additions & 0 deletions lib/mix/tasks/phoenix_kit.update.ex
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,9 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do
# Handle interactive migration execution
run_interactive_migration_update(opts)

# Run migrations for registered PhoenixKit modules (e.g. Document Creator)
run_module_migrations(opts)

# Show migration status summary
show_migration_status(prefix)
end
Expand Down Expand Up @@ -799,6 +802,109 @@ if Code.ensure_loaded?(Igniter.Mix.Task) do
""")
end

# Run versioned migrations for all registered PhoenixKit modules that
# implement `migration_module/0`. Generates an incremental migration file
# in the parent app for each module that needs updating, then runs migrations.
defp run_module_migrations(opts) do
prefix = Keyword.get(opts, :prefix, "public")

modules =
try do
discover_module_migrations()
rescue
_ -> []
end

Enum.each(modules, fn {name, migration_mod} ->
try do
current = migration_mod.migrated_version_runtime(prefix: prefix)
target = migration_mod.current_version()

if current < target do
Mix.shell().info("\n⏳ #{name}: V#{pad_version(current)} → V#{pad_version(target)}")
generate_module_migration(name, migration_mod, current, target, prefix)

# Run the newly generated migration
Mix.Task.reenable("ecto.migrate")
Mix.Task.run("ecto.migrate")

Mix.shell().info("✅ #{name} migrated to V#{pad_version(target)}")
else
Mix.shell().info("✅ #{name}: V#{pad_version(current)} (up to date)")
end
rescue
error ->
Mix.shell().info("⚠️ #{name} migration check failed: #{Exception.message(error)}")
end
end)
end

# Discover modules with migrations via beam file scanning.
# Works without the full app started — scans beam files directly.
defp discover_module_migrations do
PhoenixKit.ModuleDiscovery.discover_external_modules()
|> Enum.flat_map(fn mod ->
if Code.ensure_loaded?(mod) and function_exported?(mod, :migration_module, 0) do
case mod.migration_module() do
nil -> []
migration_mod -> [{safe_module_name(mod), migration_mod}]
end
else
[]
end
end)
end

defp safe_module_name(mod) do
if function_exported?(mod, :module_name, 0), do: mod.module_name(), else: inspect(mod)
rescue
_ -> inspect(mod)
end

defp generate_module_migration(name, migration_mod, current, target, prefix) do
migrations_dir = Path.join(["priv", "repo", "migrations"])
File.mkdir_p!(migrations_dir)

slug =
name
|> String.downcase()
|> String.replace(~r/[^a-z0-9]+/, "_")
|> String.trim("_")

mod_name = inspect(migration_mod)
timestamp = Calendar.strftime(DateTime.utc_now(), "%Y%m%d%H%M%S")

filename =
"#{timestamp}_#{slug}_update_v#{pad_version(current)}_to_v#{pad_version(target)}.exs"

app_module =
Mix.Project.config()[:app]
|> to_string()
|> Macro.camelize()

class_name =
"#{slug |> Macro.camelize()}UpdateV#{pad_version(current)}ToV#{pad_version(target)}"

content = """
defmodule #{app_module}.Repo.Migrations.#{class_name} do
@moduledoc false
use Ecto.Migration

def up do
#{mod_name}.up(prefix: "#{prefix}", version: #{target})
end

def down do
#{mod_name}.down(prefix: "#{prefix}", version: #{current})
end
end
"""

path = Path.join(migrations_dir, filename)
File.write!(path, content)
Mix.shell().info(" Created migration: #{path}")
end

# Show current installation status and available updates
defp show_status(opts) do
prefix = opts[:prefix] || "public"
Expand Down
13 changes: 11 additions & 2 deletions lib/phoenix_kit/module.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ defmodule PhoenixKit.Module do
- `children/0` - Supervisor child specs (default: `[]`)
- `route_module/0` - Module providing route macros (default: `nil`)
- `version/0` - Module version string (default: `"0.0.0"`)
- `migration_module/0` - Module implementing versioned migrations (default: `nil`).
When set, `mix phoenix_kit.update` will automatically run this module's migrations
alongside the core PhoenixKit migrations.
"""

@typedoc "Permission metadata for the module"
Expand All @@ -103,6 +106,7 @@ defmodule PhoenixKit.Module do
@callback children() :: [Supervisor.child_spec() | module() | {module(), term()}]
@callback route_module() :: module() | nil
@callback version() :: String.t()
@callback migration_module() :: module() | nil

@optional_callbacks [
get_config: 0,
Expand All @@ -112,7 +116,8 @@ defmodule PhoenixKit.Module do
user_dashboard_tabs: 0,
children: 0,
route_module: 0,
version: 0
version: 0,
migration_module: 0
]

defmacro __using__(_opts) do
Expand Down Expand Up @@ -149,14 +154,18 @@ defmodule PhoenixKit.Module do
@impl PhoenixKit.Module
def version, do: "0.0.0"

@impl PhoenixKit.Module
def migration_module, do: nil

defoverridable get_config: 0,
permission_metadata: 0,
admin_tabs: 0,
settings_tabs: 0,
user_dashboard_tabs: 0,
children: 0,
route_module: 0,
version: 0
version: 0,
migration_module: 0
end
end
end
17 changes: 17 additions & 0 deletions lib/phoenix_kit/module_registry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,23 @@ defmodule PhoenixKit.ModuleRegistry do
|> Enum.reject(&is_nil/1)
end

@doc """
Collect modules that have versioned migrations.

Returns a list of `{module_name, migration_module}` tuples for all registered
modules that implement `migration_module/0` and return a non-nil value.
"""
@spec modules_with_migrations() :: [{String.t(), module()}]
def modules_with_migrations do
all_modules()
|> Enum.flat_map(fn mod ->
case safe_call(mod, :migration_module, nil) do
nil -> []
migration_mod -> [{safe_call(mod, :module_name, inspect(mod)), migration_mod}]
end
end)
end

@doc "Find a registered module by its key string."
@spec get_by_key(String.t()) :: module() | nil
def get_by_key(key) when is_binary(key) do
Expand Down