PhoenixKitEntities

Dynamic content types for PhoenixKit. Define custom entities (like "Product", "Team Member", "FAQ") with flexible field schemas — no database migrations needed per entity.

Table of Contents

What this provides

Quick start

Add to your parent app's mix.exs:

{:phoenix_kit_entities, "~> 0.1"}

Run mix deps.get and start the server. The module appears in:

Enable the system:

PhoenixKitEntities.enable_system()

Create your first entity:

{:ok, entity} = PhoenixKitEntities.create_entity(%{
name: "product",
display_name: "Product",
display_name_plural: "Products",
icon: "hero-cube",
created_by_uuid: admin_user.uuid,
fields_definition: [
%{"type" => "text", "key" => "name", "label" => "Name", "required" => true},
%{"type" => "number", "key" => "price", "label" => "Price"},
%{"type" => "textarea", "key" => "description", "label" => "Description"},
%{"type" => "select", "key" => "category", "label" => "Category",
"options" => ["Electronics", "Clothing", "Food"]}
]
})

Create data records:

{:ok, record} = PhoenixKitEntities.EntityData.create(%{
entity_uuid: entity.uuid,
title: "iPhone 15",
status: "published",
created_by_uuid: admin_user.uuid,
data: %{
"name" => "iPhone 15",
"price" => 999,
"description" => "Latest iPhone model",
"category" => "Electronics"
}
})

Dependency types

Local development (path:)

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

Changes to the module's source are picked up automatically on recompile.

Git dependency (git:)

{:phoenix_kit_entities, git: "https://github.com/BeamLabEU/phoenix_kit_entities.git"}

After updating the remote: mix deps.update phoenix_kit_entities, then mix deps.compile phoenix_kit_entities --force + restart the server.

Hex package

{:phoenix_kit_entities, "~> 0.1.0"}

Project structure

lib/
phoenix_kit_entities.ex # Main module (schema + PhoenixKit.Module behaviour)
phoenix_kit_entities/
entity_data.ex # Data record schema and CRUD
field_type.ex # Field type struct
field_types.ex # Field type registry (12 types)
form_builder.ex # Dynamic form generation + validation
events.ex # PubSub broadcast/subscribe
presence.ex # Phoenix.Presence for editing
presence_helpers.ex # FIFO locking, session tracking
routes.ex # Admin + public route definitions
sitemap_source.ex # Sitemap integration
components/
entity_form.ex # Embeddable public form component
controllers/
entity_form_controller.ex # Public form submission handler
migrations/
v1.ex # Migration module (called by parent app)
mirror/
exporter.ex # Entity/data export to JSON
importer.ex # Entity/data import from JSON
storage.ex # File storage for mirror
mix_tasks/
export.ex # mix phoenix_kit_entities.export
import.ex # mix phoenix_kit_entities.import
web/
entities.ex # Entity list LiveView (inline template)
entity_form.ex # Entity definition builder LiveView
data_navigator.ex # Data record browser LiveView
data_form.ex # Data record form LiveView (handles new/show/edit)
entities_settings.ex # Module settings LiveView
hooks.ex # Shared LiveView hooks

Entity definitions

Entity definitions are blueprints for custom content types. Each entity has a name, display names, and a JSONB array of field definitions.

# List all entities
PhoenixKitEntities.list_entities()
# Get by name
PhoenixKitEntities.get_entity_by_name("product")
# Create
{:ok, entity} = PhoenixKitEntities.create_entity(%{...})
# Update
{:ok, entity} = PhoenixKitEntities.update_entity(entity, %{status: "published"})
# Delete (cascades to all data records)
{:ok, entity} = PhoenixKitEntities.delete_entity(entity)

Name constraints

Status workflow

Entities support three statuses: draft, published, archived.

Entity data records

Data records are instances of an entity definition. Field values are stored in a JSONB data column.

alias PhoenixKitEntities.EntityData
# List records for an entity
EntityData.list_by_entity(entity.uuid)
# Filter by status
EntityData.list_by_entity_and_status(entity.uuid, "published")
# Search by title
EntityData.search_by_title("iPhone", entity.uuid)
# Get by slug
EntityData.get_by_slug(entity.uuid, "iphone-15")
# CRUD
{:ok, record} = EntityData.create(%{...})
{:ok, record} = EntityData.update(record, %{...})
{:ok, record} = EntityData.delete(record)

Manual ordering

Both entity definitions (the list at /admin/entities) and data records within an entity carry an integer position column managed by core's V81 / V108 migrations. Two reorder surfaces are exposed:

Each entity carries a sort_mode setting ("auto" — by creation date — or "manual" — by position). Configure via:

PhoenixKitEntities.update_sort_mode(entity, "manual")

The DataNavigator admin LV auto-flips an entity to "manual" on the first drag (otherwise the visible reorder would snap back on the next refresh) and emits a Logger.warning so ops can see the implicit setting change. To opt entities-list reorder out for non-admin pages, gate the LV call site on Scope.admin?/1 — the context API itself is not auth-gated.

# Reorder entity definitions
:ok = PhoenixKitEntities.reorder_entities([uuid_b, uuid_a, uuid_c], actor_uuid: admin.uuid)
# Reorder data records within one entity
:ok = PhoenixKitEntities.EntityData.reorder(entity.uuid, [r3.uuid, r1.uuid, r2.uuid], actor_uuid: admin.uuid)

Field types

CategoryTypesNotes
Basictext, textarea, email, url, rich_textRich text is HTML-sanitized
NumericnumberAccepts integers and floats
BooleanbooleanToggle/checkbox
DatedateDate picker
Choiceselect, radio, checkboxRequire options array
Mediafile, imageComing soon
RelationsrelationComing soon

Each field definition is a map with:

%{
"type" => "text", # Required
"key" => "title", # Required, unique per entity
"label" => "Title", # Required
"required" => true, # Optional, default false
"default" => "", # Optional
"options" => ["A", "B"], # Required for select/radio/checkbox
"validation" => %{...} # Optional validation rules
}

Use the helper functions:

alias PhoenixKitEntities.FieldTypes
FieldTypes.text_field("name", "Full Name", required: true)
FieldTypes.select_field("category", "Category", ["Tech", "Business"])
FieldTypes.boolean_field("featured", "Featured", default: true)

Admin UI

Admin routes are registered via PhoenixKitEntities.Routes (returned by route_module/0):

RouteLiveViewPurpose
/admin/entitiesWeb.EntitiesList all entity definitions
/admin/entities/newWeb.EntityFormCreate entity definition
/admin/entities/:id/editWeb.EntityFormEdit entity definition
/admin/entities/:name/dataWeb.DataNavigatorBrowse entity records
/admin/entities/:name/data/newWeb.DataFormCreate record
/admin/entities/:name/data/:uuidWeb.DataFormEdit record
/admin/settings/entitiesWeb.EntitiesSettingsModule settings

Multi-language support

Multilang is auto-enabled when PhoenixKit has 2+ languages configured. Both the entity definition (labels/description) and each data record support translations.

Entity definition translations

Translatable fields: display_name, display_name_plural, description.

Storage: entity.settings["translations"] JSONB.

%{
"translations" => %{
"es-ES" => %{
"display_name" => "Producto",
"display_name_plural" => "Productos",
"description" => "Catálogo de productos"
}
}
}

Only fields that differ from the primary language need to be stored — missing keys fall back to the primary column value on read.

Editing (admin UI): the entity create/edit form renders language tabs above the translatable fields. No opt-in required — tabs appear automatically when the Languages module has 2+ languages.

API:

alias PhoenixKitEntities, as: Entities
# Read
Entities.get_entity_translations(entity)
# => %{"es-ES" => %{"display_name" => "Producto", ...}}
Entities.get_entity_translation(entity, "es-ES")
# => %{"display_name" => "Producto", "display_name_plural" => "Productos", ...}
# Write (empty string removes a per-field override)
Entities.set_entity_translation(entity, "es-ES", %{"display_name" => "Producto"})
# Remove all translations for a language
Entities.remove_entity_translation(entity, "es-ES")

Reading translated metadata

Every query function accepts an optional lang: keyword. When provided, the returned struct has display_name / display_name_plural / description resolved to that locale (missing fields fall back to primary):

Entities.list_entities(lang: "es-ES")
Entities.list_active_entities(lang: "es-ES")
Entities.get_entity(uuid, lang: "es-ES")
Entities.get_entity!(uuid, lang: "es-ES")
Entities.get_entity_by_name("product", lang: "es-ES")
Entities.list_entity_summaries(lang: "es-ES") # sidebar/navigation summaries

Without lang:, raw primary-language column values are returned (backward compatible).

Manual resolution is also available:

resolved = Entities.resolve_language(entity, "es-ES")
resolved_list = Entities.resolve_languages(entities, "es-ES")

Data record translations

Field values inside entity_data.data use a nested JSONB structure with a primary-language marker:

%{
"_primary_language" => "en-US",
"en-US" => %{"_title" => "Hello", "body" => "..."},
"es-ES" => %{"_title" => "Hola"} # overrides only
}

The _title key carries the translated title (the DB title column still stores the primary-language title). All EntityData query functions accept lang: for automatic resolution:

alias PhoenixKitEntities.EntityData
EntityData.get!(uuid, lang: "es-ES")
EntityData.list_by_entity(entity_uuid, lang: "es-ES")
EntityData.search_by_title("Hola", entity_uuid, lang: "es-ES")
EntityData.published_records(entity_uuid, lang: "es-ES")
EntityData.get_by_slug(entity_uuid, "my-slug", lang: "es-ES")

See lib/phoenix_kit_entities/OVERVIEW.md § "Multi-Language Support" for the full translation API (title translations, primary-language changes, compact-mode tabs).

Public URL resolution

PhoenixKitEntities.EntityData exposes locale-aware URL builders for public-facing links (replaces the hand-wired "/#{record.slug}" pattern that silently drops locale prefixes on non-default routes).

Pattern resolution chain

  1. entity.settings["sitemap_url_pattern"] — per-entity override (e.g. "/blog/:slug")
  2. Router introspection — explicit route (live "/pages/:slug", ...) or catchall (/:entity_name/:slug)
  3. Per-entity setting sitemap_entity_<name>_pattern
  4. Global setting sitemap_entities_pattern (with :entity_name / :slug / :id placeholders)
  5. Fallback /<entity_name>/:slug

Placeholders: :slug (falls back to the record UUID when the slug is nil) and :id (the UUID).

Locale prefix policy

Matches PhoenixKit.Utils.Routes.path/2:

Helpers

alias PhoenixKitEntities.EntityData
EntityData.public_path(entity, record)
# => "/products/my-item"
EntityData.public_path(entity, record, locale: "es-ES")
# => "/es/products/my-item"
EntityData.public_path(entity, record, locale: "en-US") # primary language
# => "/products/my-item"
EntityData.public_url(entity, record, base_url: "https://shop.example.com")
# => "https://shop.example.com/products/my-item"
# Batch usage — pre-build the routes cache once
cache = PhoenixKitEntities.UrlResolver.build_routes_cache()
Enum.map(records, &EntityData.public_path(entity, &1, locale: locale, routes_cache: cache))

If :base_url is omitted, public_url/3 falls back to the site_url setting.

Public forms

Entities can expose public submission forms. Enable in entity settings, then embed:

<EntityForm entity_slug="contact" />

Or use the controller endpoint. Public forms include:

Filesystem mirroring

Export and import entity definitions and data as JSON files:

mix phoenix_kit_entities.export
mix phoenix_kit_entities.import

Or programmatically:

PhoenixKitEntities.Mirror.Exporter.export_all(path)
PhoenixKitEntities.Mirror.Importer.import_all(path)

Events & PubSub

Subscribe to real-time events:

alias PhoenixKitEntities.Events
# Entity lifecycle
Events.subscribe_to_entities()
# Receives: {:entity_created, uuid}, {:entity_updated, uuid}, {:entity_deleted, uuid}
# Data lifecycle (all entities)
Events.subscribe_to_all_data()
# Receives: {:data_created, entity_uuid, data_uuid}, etc.
# Data lifecycle (specific entity)
Events.subscribe_to_entity_data(entity_uuid)

Available callbacks

This module implements PhoenixKit.Module with these callbacks:

CallbackValue
module_key/0"entities"
module_name/0"Entities"
enabled?/0Reads entities_enabled setting
enable_system/0Sets entities_enabled to true
disable_system/0Sets entities_enabled to false
permission_metadata/0Icon: hero-cube-transparent
admin_tabs/0Entities tab with dynamic entity children
settings_tabs/0Settings tab under admin settings
children/0[PhoenixKitEntities.Presence]
css_sources/0[:phoenix_kit_entities]
route_module/0PhoenixKitEntities.Routes
get_config/0Returns enabled status, limits, stats

Mix tasks

# Export all entities and data to JSON
mix phoenix_kit_entities.export
# Import entities and data from JSON
mix phoenix_kit_entities.import

Database

Database tables and migrations are managed entirely by core PhoenixKit. The entities tables are created by core's V17 migration and evolved by V40 / V58 / V67 / V74 / V81. The host app runs PhoenixKit.Migrations.up() once and gets everything — no module-owned DDL.

# Two tables:
# phoenix_kit_entities — entity definitions (blueprints)
# phoenix_kit_entity_data — data records (instances)
# Both use UUIDv7 primary keys

Testing

# Create test database
createdb phoenix_kit_entities_test
# Run all tests
mix test
# Run only unit tests (no DB needed)
mix test --exclude integration

Troubleshooting

Module not appearing in admin

  1. Verify the dependency is in mix.exs and mix deps.get was run
  2. Check PhoenixKitEntities.enabled?() returns true
  3. Run PhoenixKitEntities.enable_system() if needed

"entities_enabled" setting not found

The settings are seeded by core's V17 migration. Run PhoenixKit.Migrations.up() in the host app to create them.

Entity name validation fails

Names must be snake_case, start with a letter, 2-50 characters. Examples: product, team_member, faq_item. Invalid: Product, 123abc, a.

Changes not taking effect after editing

Force a clean rebuild: mix deps.clean phoenix_kit_entities && mix deps.get && mix deps.compile phoenix_kit_entities --force && mix compile --force

Note: The entities tables are owned by core PhoenixKit. V17 creates them; V40 / V58 / V67 / V74 / V81 evolve them. The test suite builds its schema by running PhoenixKit.Migrations.up() against an isolated test repo — the same call the host app makes — so test schema and production schema cannot drift apart.