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

Entities can use auto sort (by creation date) or manual sort (by position). Configure via the entity's settings:

PhoenixKitEntities.update_sort_mode(entity, "manual")

Field types

Category Types Notes
Basic text, textarea, email, url, rich_text Rich text is HTML-sanitized
Numeric number Accepts integers and floats
Boolean boolean Toggle/checkbox
Date date Date picker
Choice select, radio, checkbox Require options array
Media file, image Coming soon
Relations relation Coming 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):

Route LiveView Purpose
/admin/entitiesWeb.Entities List all entity definitions
/admin/entities/newWeb.EntityForm Create entity definition
/admin/entities/:id/editWeb.EntityForm Edit entity definition
/admin/entities/:name/dataWeb.DataNavigator Browse entity records
/admin/entities/:name/data/newWeb.DataForm Create record
/admin/entities/:name/data/:uuidWeb.DataForm Edit record
/admin/settings/entitiesWeb.EntitiesSettings Module 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:

Callback Value
module_key/0"entities"
module_name/0"Entities"
enabled?/0 Reads entities_enabled setting
enable_system/0 Sets entities_enabled to true
disable_system/0 Sets entities_enabled to false
permission_metadata/0 Icon: hero-cube-transparent
admin_tabs/0 Entities tab with dynamic entity children
settings_tabs/0 Settings tab under admin settings
children/0[PhoenixKitEntities.Presence]
css_sources/0[:phoenix_kit_entities]
route_module/0PhoenixKitEntities.Routes
get_config/0 Returns 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 by the parent PhoenixKit project. This repo provides PhoenixKitEntities.Migrations.V1 as a library module that the parent app's migrations call — there are no migrations to run in this repo directly.

# 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 the migration. If using PhoenixKit core migrations, they're created by V17. If standalone, run the PhoenixKitEntities.Migrations.V1 migration.

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: Most production deploys see the entity tables created by core PhoenixKit's versioned migration V17. The local PhoenixKitEntities.Migrations.V1 module provides an idempotent (IF NOT EXISTS) migration that's the source of truth for the test schema and for standalone host apps that don't use core's installer. The test helper creates the uuid_generate_v7() Postgres function directly when a test database is available.