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.1"}

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 publishing tables (phoenix_kit_publishing_groups, _posts, _versions, _contents) are created by PhoenixKit's core versioned migrations (V59). Run mix phoenix_kit.install in the host app and they're set up automatically — no module-owned migration to invoke.

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 (4 tables)

Group (1) ──→ (many) Post (1) ──→ (many) Version (1) ──→ (many) Content

phoenix_kit_publishing_groups — Content containers

ColumnTypePurpose
uuidUUIDv7PK
namestringDisplay name
slugstringURL identifier (unique)
modestring"timestamp" or "slug" — locked at creation
statusstring"active" or "trashed"
positionintegerDisplay ordering
dataJSONBtype, item_singular/plural, icon, comments/likes/views_enabled
title_i18nJSONBTranslatable group title (keyed by language code)
description_i18nJSONBTranslatable 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.

ColumnTypePurpose
uuidUUIDv7PK
group_uuidUUIDv7FK → groups
slugstringURL path segment (slug mode, unique per group)
modestring"timestamp" or "slug"
post_datedateURL date segment (timestamp mode)
post_timetimeURL time segment (timestamp mode, unique per group+date)
active_version_uuidUUIDv7FK → versions — the live version (null = unpublished)
trashed_atutc_datetimeSoft delete timestamp (null = active)
created_by_uuidUUIDv7FK → users (audit)
updated_by_uuidUUIDv7FK → 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.

ColumnTypePurpose
uuidUUIDv7PK
post_uuidUUIDv7FK → posts
version_numberintegerSequential (v1, v2, ...), unique per post
statusstring"draft" / "published" / "archived"
published_atutc_datetimeWhen this version was first published
created_by_uuidUUIDv7FK → users (audit)
dataJSONBfeatured_image_uuid, tags, seo, description, allow_version_access, notes, created_from

phoenix_kit_publishing_contents — Per-language title + body

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

ColumnTypePurpose
uuidUUIDv7PK
version_uuidUUIDv7FK → versions
languagestringLanguage code (unique per version)
titlestringPost title in this language
contenttextMarkdown/PHK body in this language
url_slugstringPer-language URL slug (for localized URLs)
statusstringReserved for future per-language overrides (unused by UI)
dataJSONBReserved for future per-language overrides (unused by UI)

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
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

ModuleRole
PhoenixKit.Modules.PublishingMain context/facade — delegates to all submodules
Publishing.DBStorageDirect Ecto queries for all CRUD operations
Publishing.ListingCache:persistent_term cache with sub-microsecond reads
Publishing.RendererMDEx markdown + PHK component rendering with ETS cache
Publishing.PageBuilderXML parser (Saxy) for <Image>, <Hero>, etc. components
Publishing.StaleFixerReconciles DB/cache state, auto-cleans empty posts
Publishing.PresencePhoenix.Presence for collaborative editor locking

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

RouteLiveViewPurpose
/admin/publishingIndexGroups overview
/admin/publishing/new-groupNewCreate group
/admin/publishing/edit-group/:groupEditGroup settings
/admin/publishing/:groupListingPosts list with status tabs
/admin/publishing/:group/newEditorCreate post
/admin/publishing/:group/:uuid/editEditorEdit post
/admin/publishing/:group/previewPreviewLive preview
/admin/settings/publishingSettingsCache 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

KeyDefaultDescription
publishing_enabledfalseEnable/disable module
publishing_public_enabledtrueShow public routes
publishing_default_language_no_prefixfalseOmit the locale prefix from default-language public URLs; prefixed requests 301-redirect
publishing_posts_per_page20Listing pagination
publishing_memory_cache_enabledtrueListing cache toggle
publishing_render_cache_enabledtrueRender cache global toggle
publishing_render_cache_enabled_<slug>truePer-group render cache

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

PackagePurpose
phoenix_kitModule behaviour, Settings, Auth, Cache, shared components
phoenix_live_viewAdmin LiveView pages
mdexMarkdown rendering (comrak)
saxyXML parsing for PHK components
obanBackground translation and migration workers

License

MIT