Skip to content

Content Constructs

Every plugin entry point needs a content construct — it determines how the backend UI for that entry is rendered. There are three variants: table, template, and formonly. This page explains when to use which and which properties in bootstrap.php to set.

Overview

ConstructUIWhen?
tablePrimeVue DataTable (list + edit modal)Standard CRUD for a DB table — listing, sorting, search, edit/delete for free
templateCustom Vue component (layout/index.vue)Custom UI without a standardized table — dashboards, editors, integration panels
formonlySingle edit modal without a listSingle-record config (site settings, master data)

Multiple constructs per plugin are allowed. The blog plugin, for instance, registers three table entries side by side (posts, categories, default page).

The 10 core properties

All properties are arrays with one entry per construct. In the plugin's install(), you fill them in parallel — index [0] of one array belongs to index [0] of every other.

PropertyPurpose
$content_construct"table", "template", or "formonly"
$content_tableDB table (only for table) — otherwise ""
$content_titlesUI label (e.g. "Blog posts")
$content_columnsComma list of visible columns in the list (only for table)
$content_buttonRow buttons (JSON array, see Buttons)
$action_buttonHeader action button, e.g. "New" (JSON object)
$header_buttonsAdditional header buttons (JSON array)
$content_replaceColumn renaming for display (JSON object)
$url_rewrites_backendURL slug → /admin/{slug}
$modal_editsModal field config (JSON object)
$backend_widget_basePath to the widget entry point (typically core/backend/{plugin})

table — CRUD out of the box

The most-used construct. A plugin registers a DB table and gets a complete admin UI: DataTable with pagination, search, sorting, edit modal, and delete dialog.

Minimal example

php
public function install()
{
    $this->url_rewrites_backend = ["my-articles"];
    $this->content_construct    = ["table"];
    $this->content_table        = ["my_articles"];
    $this->content_titles       = ["My Articles"];
    $this->content_columns      = ["title,category,created_at"];

    // Edit button (pencil) + delete button (minus)
    $this->content_button = ['[
        {"button_id":"2","type":"edit","style":"default","icon":"pencil","title":"Edit",
         "submit_text":"Save",
         "editable_rows":[
            {"placeholder":"Title","column":"title","type":"text"},
            {"placeholder":"Body","column":"body","type":"ckeditor"}
         ],
         "multilanguage":"1"},
        {"button_id":"1","type":"delete","style":"danger","icon":"minus","title":"Delete",
         "submit_text":"Delete",
         "support_text":"Really delete this entry?"}
    ]'];

    // "New article" button in the header
    $this->action_button = ['{"button_id":"3","type":"insert","style":"primary",
        "icon":"plus","title":"New article","submit_text":"Save",
        "editable_rows":[
           {"placeholder":"Title","column":"title","type":"text"},
           {"placeholder":"Body","column":"body","type":"ckeditor"}
        ]}'];

    $this->backend_widget_base = ["core/backend/my-plugin"];
    $this->content_replace     = ['{}'];
    $this->header_buttons      = ['[]'];
    $this->modal_edits         = ['{}'];
}

That's enough. Without any further work, your plugin has:

  • a list under /admin/my-articles (pagination, search, sort per column),
  • an "Edit" button per row with a modal form,
  • a "Delete" button with a confirmation dialog,
  • a "New article" button in the header,
  • a complete REST API under /api/backend/item?table=my_articles&id=... (GET/POST/PATCH/DELETE).

Multi-language

Set "multilanguage":"1" in the button JSON so the edit modal renders language tabs. Prerequisite: the DB table has base_id and language_short columns. The core endpoints take care of per-language INSERT and the merge logic on retrieval.

Details: Migrations.

content_columns vs. editable_rows

Two independent concepts:

  • content_columns (a property of install()): list of columns visible in the DataTable. Format: "col1,col2,col3" — a simple comma-separated string.
  • editable_rows (inside the button JSON): which columns are editable and which input type renders. Field types: text, textarea, ckeditor, select, image, checkbox, …

A plugin can display columns that aren't editable (e.g. show a created_at timestamp in the list but hide it in the edit modal).

Field types for editable_rows

typeRenders asExample extra fields
textSingle-line input
textareaMulti-line text
ckeditorRich-text editor
selectDropdown from a DB tableselect_value: {table, value, title}
imageMedia Manager button
checkboxToggle
number, date, timeNative inputs

Full field-type reference: Widgets › widget_menu field types — field types are identical between plugin modals and widget forms.

template — custom UI

For anything that doesn't fit a standard table: dashboards, visual editors, integration configuration. The plugin supplies its own layout/index.vue where the UI is built freely.

Minimal example

php
public function install()
{
    $this->url_rewrites_backend = ["my-dashboard"];
    $this->content_construct    = ["template"];
    $this->content_table        = [""];                   // empty for template
    $this->content_titles       = ["My Dashboard"];
    $this->content_columns      = [""];
    $this->content_button       = ['[]'];
    $this->action_button        = ['[]'];
    $this->content_replace      = ['{}'];
    $this->header_buttons       = ['[]'];
    $this->modal_edits          = ['{}'];
    $this->backend_widget_base  = ["core/backend/my-plugin"];
}

Next to this, the plugin needs:

Real examples in the repo: menueditor, design, taskmanager, updatemanager, plugin_manager, dashboard.

Fetching data

A template plugin has three ways to load data:

  1. onLoad() returns JSON directly in the page response — once, on page load.
  2. Plugin-local API endpoint (backend/my-plugin) — for interactive actions. See API Endpoints.
  3. Generic backend/item API — when DB tables must be written without an active table construct.

formonly — single-record form

For configs where only one record exists: site settings, master data, branding config. No list, no "New" button — just a modal that edits the single record.

php
public function install()
{
    $this->url_rewrites_backend = ["branding"];
    $this->content_construct    = ["formonly"];
    $this->content_table        = ["branding_settings"];
    $this->content_titles       = ["Branding"];
    $this->content_columns      = [""];
    $this->content_button       = ['[]'];
    $this->action_button        = ['{"button_id":"1","type":"edit","style":"primary",
        "icon":"pencil","title":"Edit branding","submit_text":"Save",
        "editable_rows":[
           {"placeholder":"Logo","column":"logo","type":"image"},
           {"placeholder":"Primary color","column":"primary_color","type":"text"}
        ]}'];
    $this->content_replace     = ['{}'];
    $this->header_buttons      = ['[]'];
    $this->modal_edits         = ['{}'];
    $this->backend_widget_base = ["core/backend/branding"];
}

Prefer template

In practice, single-record configs are often better served by a template construct with a custom index.vue — more UI freedom, CodeMirror editors, previews, and so on. formonly is the fastest variant but limited in flexibility.

Combined: multiple constructs

A plugin can have multiple entry points. The blog plugin registers three table constructs side by side — each with its own table, buttons, and URL slug:

php
$this->url_rewrites_backend = ["news", "news-categories", "news-standard-page"];
$this->content_construct    = ["table", "table", "table"];
$this->content_table        = ["blog", "news_category", "news_page"];
$this->backend_widget_base  = ["core/backend/blog", "core/backend/blog", "core/backend/blog"];
$this->content_titles       = ['Blog posts', "Blog categories", "Main page for blog entries"];
$this->content_columns      = ['title', "category", "content_file"];

$this->content_button = [
    '[...]',   // Buttons for the blog table
    '[...]',   // Buttons for the news_category table
    '[...]',   // Buttons for the news_page table
];
// etc.

The admin sidebar then shows three separate menu entries — each loading its own list under /admin/news, /admin/news-categories, /admin/news-standard-page.

Real source: _public/extensions/core/backend/blog/bootstrap.php.

Common issues

Arrays of unequal length

All content_* arrays plus url_rewrites_backend must be the same length. If, say, the third construct is missing an entry in content_button, installBackend() aborts mid-loop — only two plugin_backend rows get created.

Don't omit content_table for template

Even with "template", content_table needs an entry — "" is enough, but it cannot be missing. installBackend() reads $this->content_table[$i] by index; missing indices return null and produce empty plugin_backend columns plus a PHP notice in the log.

JSON strings, not PHP arrays

$content_button, $action_button, $header_buttons, $content_replace, and $modal_edits are JSON strings$this->content_button = [['button_id' => 2, ...]] won't work, it serializes to Array. Keep two levels in mind: the outer PHP array contains one single string per construct; that string itself is a JSON array of button objects.

See also