PhoenixKit Publishing

A standalone PhoenixKit plugin module that provides a database-backed content management system with multi-language support, collaborative editing, and dual URL modes.

Installation

Add to your parent app's mix.exs:

{:phoenix_kit_publishing, "~> 0.5"}

Or for local development:

{:phoenix_kit_publishing, path: "../phoenix_kit_publishing"}

Then run mix deps.get and mix phoenix_kit.install. The module is auto-discovered by PhoenixKit at startup — no additional config needed. The installer also adds the necessary Tailwind CSS @source directive so all styles render correctly.

Database Setup

The 7 publishing tables — phoenix_kit_publishing_groups, _posts, _versions, _contents (core V135 baseline) and _categories, _post_categories, _post_views (core V159) — are created by PhoenixKit's core versioned migrations. Run mix phoenix_kit.install in the host app and they're set up automatically. This module also owns their future shape through its own versioned chain, PhoenixKitPublishing.Migrations (migration_module/0), which mix phoenix_kit.update discovers and drives alongside core's own chain — no separate command to run. Its current version (V1) is a pure adoption of the shape core already creates: it changes nothing on an existing install beyond stamping a version marker.

Enable the Module

Via admin UI: navigate to Admin > Modules > Publishing > toggle on.

Or via code:

PhoenixKit.Modules.Publishing.enable_system()

Features

URL Modes

Timestamp Mode (default)

Posts addressed by publication date and time. Ideal for news, announcements, changelogs.

/{language}/{group-slug}/{YYYY-MM-DD}/{HH:MM}

Slug Mode

Posts addressed by semantic slug. Ideal for documentation, guides, evergreen content.

/{language}/{group-slug}/{post-slug}

Single-language mode omits the language segment automatically.

Architecture

Database Schema (7 tables)

Group (1) ──→ (many) Post (1) ──→ (many) Version (1) ──→ (many) Content
Group (1) ──→ (many) Category (self-referencing tree)
Post (many) ──→ (many) Category via PostCategory
Post (1) ──→ (many) PostView (one row per day)

phoenix_kit_publishing_groups — Content containers

Column Type Purpose
uuid UUIDv7 PK
name string Display name
slug string URL identifier (unique)
mode string "timestamp" or "slug" — locked at creation
status string "active" or "trashed"
position integer Display ordering
data JSONB type, item_singular/plural, icon, comments/likes/views_enabled, media_folder_uuid (see Media folders)
title_i18n JSONB Translatable group title (keyed by language code)
description_i18n JSONB Translatable group description (keyed by language code)

phoenix_kit_publishing_posts — Routing shell

Posts hold URL identity and point to their live version. No content or metadata — that lives on versions.

Column Type Purpose
uuid UUIDv7 PK
group_uuid UUIDv7 FK → groups
slug string URL path segment (slug mode, unique per group)
mode string "timestamp" or "slug"
post_date date URL date segment (timestamp mode)
post_time time URL time segment (timestamp mode, unique per group+date)
active_version_uuid UUIDv7 FK → versions — the live version (null = unpublished)
trashed_at utc_datetime Soft delete timestamp (null = active)
created_by_uuid UUIDv7 FK → users (audit)
updated_by_uuid UUIDv7 FK → users (audit)

Publishing = setting active_version_uuid. Trashing = setting trashed_at.

phoenix_kit_publishing_versions — Source of truth

Each post has one or more versions. The version holds all metadata that applies across languages.

Column Type Purpose
uuid UUIDv7 PK
post_uuid UUIDv7 FK → posts
version_number integer Sequential (v1, v2, ...), unique per post
status string "draft" / "published" / "archived"
published_at utc_datetime When this version was first published
created_by_uuid UUIDv7 FK → users (audit)
data JSONB featured_image_uuid, tags, seo, description, allow_version_access, notes, created_from, media_folder_uuid (the post's folder, on every version — see Media folders)

phoenix_kit_publishing_contents — Per-language title + body

One row per language per version. All languages share the version's status and metadata.

Column Type Purpose
uuid UUIDv7 PK
version_uuid UUIDv7 FK → versions
language string Language code (unique per version)
title string Post title in this language
content text Markdown/PHK body in this language
url_slug string Per-language URL slug (for localized URLs)
status string Reserved for future per-language overrides (unused by UI)
data JSONB Reserved for future per-language overrides (unused by UI)

phoenix_kit_publishing_categories — Hierarchical per-group taxonomy

WordPress-parity categories. slug is unique per group (not globally).

Column Type Purpose
uuid UUIDv7 PK
group_uuid UUIDv7 FK → groups (ON DELETE CASCADE)
parent_uuid UUIDv7 FK → categories, self-referencing (ON DELETE SET NULL — deleting a parent lifts children to the root)
name string Display name
slug string URL segment, unique per group
name_i18n JSONB Per-language display-name overrides
description string Optional description
position integer Display ordering

phoenix_kit_publishing_post_categories — Post ↔ category assignment

Many-to-many, post-level (not per-version) — WordPress semantics. Composite primary key (post_uuid, category_uuid); both FKs cascade.

phoenix_kit_publishing_post_views — Per-day view counters

One (post_uuid, view_date) row incremented in place; no per-request rows, no reader PII. Composite primary key (post_uuid, view_date); post_uuid FK cascades. Queried schemaless (no Ecto schema module) via Publishing.Views.

All tables use UUIDv7 primary keys. Language fallback chain: requested language → site default → first available.

Module Structure

lib/phoenix_kit_publishing/
publishing.ex # Main facade (PhoenixKit.Module behaviour)
groups.ex # Group CRUD
posts.ex # Post operations
versions.ex # Version management
translation_manager.ex # Language/translation ops
db_storage.ex # Database CRUD layer
listing_cache.ex # In-memory listing cache
renderer.ex # Markdown + component rendering
page_builder.ex # PHK XML component system
stale_fixer.ex # Data consistency repair
media_folders.ex # Group media folders + ready-made host hooks
media_adoption.ex # One-time filing of existing post media
media_reorganizer.ex # Plan source for core's media reorganizer
presence.ex # Collaborative editing presence
pubsub.ex # Real-time broadcasting
routes.ex # Admin route definitions
schemas/ # Ecto schemas (4 files)
web/ # LiveViews, controller, templates
workers/ # Oban background jobs

Core Modules

Module Role
PhoenixKit.Modules.Publishing Main context/facade — delegates to all submodules
Publishing.DBStorage Direct Ecto queries for all CRUD operations
Publishing.ListingCache :persistent_term cache with sub-microsecond reads
Publishing.Renderer MDEx markdown + PHK component rendering with ETS cache
Publishing.PageBuilder XML parser (Saxy) for <Image>, <Hero>, etc. components
Publishing.StaleFixer Reconciles DB/cache state, auto-cleans empty posts
Publishing.Presence Phoenix.Presence for collaborative editor locking
Publishing.MediaFolders One media folder per group; files picked in the editor go there
Publishing.MediaAdoption Files the media posts already use into their group's folder
Publishing.MediaReorganizer Group folders for mix phoenix_kit.media.reorganize

IEx / CLI Usage

alias PhoenixKit.Modules.Publishing
# Groups
{:ok, _} = Publishing.add_group("Documentation", mode: "slug")
{:ok, _} = Publishing.add_group("Company News", mode: "timestamp")
Publishing.list_groups()
# Posts
{:ok, post} = Publishing.create_post("docs", %{title: "Getting Started"})
{:ok, post} = Publishing.read_post("docs", "getting-started")
{:ok, _} = Publishing.update_post("docs", post, %{"content" => "# Updated"})
# Translations
{:ok, _} = Publishing.add_language_to_post("docs", post_uuid, "es")
:ok = Publishing.delete_language("docs", post_uuid, "fr")
# Versions
{:ok, v2} = Publishing.create_version_from("docs", post_uuid, 1)
:ok = Publishing.publish_version("docs", post_uuid, 2)
# Cache
Publishing.regenerate_cache("docs")
Publishing.invalidate_cache("docs")

Admin Routes

Route LiveView Purpose
/admin/publishing Index Groups overview
/admin/publishing/new-group New Create group
/admin/publishing/edit-group/:group Edit Group settings
/admin/publishing/:group Listing Posts list with status tabs
/admin/publishing/:group/new Editor Create post
/admin/publishing/:group/:uuid/edit Editor Edit post
/admin/publishing/:group/preview Preview Live preview
/admin/settings/publishing Settings Cache config

Public Routes

Multi-language mode:

/{language}/{group-slug} # Group listing
/{language}/{group-slug}/{post-slug} # Slug-mode post
/{language}/{group-slug}/{post-slug}/v/{version} # Versioned post
/{language}/{group-slug}/{date}/{time} # Timestamp-mode post

Single-language mode omits the /{language} segment.

When publishing_default_language_no_prefix is enabled, the default-language URL also drops its prefix (e.g. /blog instead of /en/blog), and requests to the prefixed form 301-redirect to the canonical prefixless URL.

Fallback Behavior

How Dispatch Works (and how it interacts with host routes)

Public URLs are dynamic — the group slug is a database row, not a compile-time literal — so these routes can't be declared normally. Publishing registers its catch-all under an internal prefix and overrides the host router's call/2 (RouterDispatch): on each GET/HEAD, if the first non-locale path segment matches a known group slug, the path is rewritten to the internal prefix and Phoenix matches it there. Otherwise the request passes through untouched.

Two consequences worth knowing:

Reserved Route Prefixes

Another PhoenixKit module can claim a top-level segment by implementing PhoenixKit.Module.reserved_route_prefixes/0 (added in phoenix_kit 1.7.170):

@impl PhoenixKit.Module
def reserved_route_prefixes, do: ["shop"]

RouterDispatch.known_group?/1 consults PhoenixKit.ModuleRegistry.all_reserved_route_prefixes/0 and refuses to claim a reserved segment even when a group with that exact slug exists in publishing's own data. On phoenix_kit older than 1.7.170 the callback is absent and nothing is reserved.

Reserve a prefix only if your module actually renders that route — a reservation removes the path from publishing's dispatch, and if nothing takes over, the result is a 404. phoenix_kit_legal reserved "legal" in its 0.1.6 without shipping a renderer and 404'd public legal pages on every host app; it was reverted in 0.1.7, and legal pages are once again served here as an ordinary group. If your module stores its content as publishing posts, letting publishing render them is usually the right call — you inherit languages, translations, canonical/og:*/hreflang, and the editor for free.

Caching

Listing Cache

Uses :persistent_term for near-zero-cost reads. Invalidated on post create/update, status change, translation add, or version create.

Publishing.regenerate_cache("my-blog")
Publishing.find_cached_post("my-blog", "post-slug")

Render Cache

ETS-based with 6-hour TTL and content-hash keys. Toggled globally or per-group:

# Global toggle
PhoenixKit.Settings.update_setting("publishing_render_cache_enabled", "true")
# Per-group toggle
PhoenixKit.Settings.update_setting("publishing_render_cache_enabled_docs", "false")
# Manual clear
PhoenixKit.Modules.Publishing.Renderer.clear_group_cache("docs")
PhoenixKit.Modules.Publishing.Renderer.clear_all_cache()

Content Format

Posts use Markdown with optional PHK components:

# My Post Title
Regular **Markdown** content with all GitHub-flavored features.
<Image file_id="019a6f96-..." alt="Description" />
<Hero variant="centered">
<Headline>Welcome</Headline>
<CTA primary="true" action="/signup">Get Started</CTA>
</Hero>
<EntityForm entity="contact" />

Supported components: Image, Hero, CTA, Headline, Subheadline, Video, EntityForm.

Settings

Key Default Description
publishing_enabled false Enable/disable module
publishing_public_enabled true Show public routes
publishing_default_language_no_prefix false Omit the locale prefix from default-language public URLs; prefixed requests 301-redirect
publishing_posts_per_page 20 Listing pagination
publishing_memory_cache_enabled true Listing cache toggle
publishing_render_cache_enabled true Render cache global toggle
publishing_render_cache_enabled_<slug> true Per-group render cache
publishing_media_folder_uuid — The module's media folder, written by the ready-made media hook (see below)

Media folders

Off by default: a host that configures nothing keeps today's behaviour and no folder is created. To keep each group's media in its own folder — Publishing/News, Publishing/Legal — add the ready-made hooks:

config :phoenix_kit_publishing,
attachments_parent_folder: {PhoenixKit.Modules.Publishing.MediaFolders, :module_folder},
attachments_folder_name: {PhoenixKit.Modules.Publishing.MediaFolders, :folder_name}

For a folder per post inside its group's — Publishing/News/spring-fair, a timestamp post's named by its date and time — add:

config :phoenix_kit_publishing, :post_media_folders, true

Or point either hook key at your own function (core's Storage.ResourceFolders convention): parent_for(:group, actor_uuid, group) answers {:ok, folder_uuid} or nil for the media root; name_for(subject, actor_uuid) — a group, or a post with post folders on — answers {:ok, name} or nil for publishing-group-<uuid> / publishing-post-<uuid>. A group or post keeps its folder when it is renamed later.

Then, once:

mix phoenix_kit_publishing.media.adopt # dry run: what would be filed
mix phoenix_kit_publishing.media.adopt --apply # file it

(In a release: PhoenixKit.Modules.Publishing.MediaAdoption.run(actor_uuid, apply?: true).)

This files every file a group's posts use — featured, OG and audio slots, <Image>/<Audio>/<Showcase> components, baked /file/<uuid>/… URLs — into the group's folder, or each post's. A file with no folder is moved in; a file that already lives in another folder stays there and is linked in (with post folders, one in the group's own folder moves down into the post's). A file several posts use lives in the first post's folder and is linked into the others'. File URLs do not change.

From then on every file picked in the post editor lands in the post's (or group's) folder by itself, and mix phoenix_kit.media.reorganize moves the group folders when you change the hooks later — post folders travel with their group's (it also reports a group or post whose files are outside its folder, the folder of a trashed group or post, and a name hook that can't be called). With the ready-made hooks, even its dry run may create the Publishing folder if it is missing: the parent hook creates it on first use.

Folders are only ever looked up and created in the site's media library (Media), never in a person's own library. If a configured hook cannot be called, the adoption step refuses to plan; if one fails while applying, that group is left unfiled rather than filed at the media root.

Trashing a group's folder trashes the posts' pictures {: .warning}

After adoption, a file that used to have no folder lives in its group's (or post's) folder. Moving Publishing/<group>, a post's folder, or Publishing itself to the trash in the media browser trashes every file whose home is inside it — including one that is also shown somewhere else by URL (a page, another module) — and those pictures stop showing until the folder is restored from the trash. A file that is only linked into the folder keeps its own home and is not affected. Rename or move these folders freely; trash them only together with their group or post.

Removing this module

There is deliberately no automated uninstall. PhoenixKitPublishing.Migrations.down/1 never drops any of the 7 tables or a row in them, for any target version — a host that merely removes this dependency from mix.exs has not consented to deleting every content group, post, version, per-language content row, category, category assignment, and view counter, and a migration whose result depended on which packages happen to be compiled in would be nondeterministic. Removing the data is therefore a deliberate, manual operator step:

-- Only after removing :phoenix_kit_publishing from mix.exs, and only if you
-- actually want every group, post, version, content row, category,
-- assignment, and view counter gone for good.
--
-- One statement, on purpose: posts <-> versions is a genuine FK cycle
-- (posts.active_version_uuid points forward to the live version,
-- versions.post_uuid points back to the owning post), so dropping the tables
-- one at a time fails with "other objects depend on it". A single DROP TABLE
-- listing all 7 resolves the cycle itself, without naming any constraint —
-- which matters on a host whose constraints were renamed.
DROP TABLE
phoenix_kit_publishing_post_views,
phoenix_kit_publishing_post_categories,
phoenix_kit_publishing_categories,
phoenix_kit_publishing_contents,
phoenix_kit_publishing_versions,
phoenix_kit_publishing_posts,
phoenix_kit_publishing_groups;

Dropping phoenix_kit_publishing_groups last also removes the pkpub_schema:<N> version marker, which is a COMMENT on that table — no separate step is needed.

If you want to keep the tables (e.g. you plan to reinstall the module later) but stop this chain from tracking them, clear the version marker instead:

COMMENT ON TABLE phoenix_kit_publishing_groups IS NULL;

Testing

Unit tests run without a database. Integration and controller tests require PostgreSQL:

createdb phoenix_kit_publishing_test
mix test

Integration tests are automatically excluded when the database is unavailable. Controller tests run through a minimal Phoenix.Endpoint + Router + Layouts shipped under test/support/ — see AGENTS.md for details.

Dependencies

Package Purpose
phoenix_kit Module behaviour, Settings, Auth, Cache, shared components
phoenix_live_view Admin LiveView pages
mdex Markdown rendering (comrak)
saxy XML parsing for PHK components
oban Background translation and migration workers

License

MIT