Skip to content

Widget items / repeater ​

Widgets can carry any number of sub-items — lists of similar entries that the editor adds, sorts, and deletes from the properties panel. The pattern uses the items field type inside widget_menu and stores the data in the separate page_widget_item table. This page covers definition, persistence, and rendering.

When to use items? ​

Use caseItems?
USP list (icon + title + text, n×)yes
FAQ entriesyes
Testimonialsyes
Image galleryyes
Single hero (title + image)no — top-level fields are enough
Blog listing from the DBno — load via getWidgetContent(), not editor items

Items are for content the editor maintains inside the Pagebuilder. When content comes from an external table (blog, shop), getWidgetContent() is the right route — see getWidgetContent().

Items definition in widget_menu ​

json
{
  "widget_title":    "USP Icon List",
  "widget_icon":     "icon.png",
  "widget_template": "uspIconList",
  "widget_menu": [
    {
      "type":        "items",
      "placeholder": "Icon boxes",
      "menu": [
        {"row": "title",    "type": "text",     "placeholder": "Title"},
        {"row": "subtitle", "type": "text",     "placeholder": "Icon class (fa-...)"},
        {"row": "text",     "type": "textarea", "placeholder": "Description"}
      ]
    }
  ]
}
FieldRequiredPurpose
typeyesAlways "items"
placeholdernoLabel of the items section in the properties panel
menuyesSub-field types — same structure as widget_menu above

items itself has no row — the sub-field values land in the separate page_widget_item table, not in a column of page_row_col_widget.

The sub-menu array accepts the same field types as the outer widget_menu (text, textarea, filemanager, static/DB select). Only one item cannot contain further sub-items — items are not nestable.

The page_widget_item table ​

Items are stored per widget instance, with multi-language and sort support:

sql
CREATE TABLE page_widget_item (
  id             INT AUTO_INCREMENT PRIMARY KEY,
  widget_id      INT NOT NULL,             -- FK → page_row_col_widget.base_id
  base_id        INT,                       -- for language variants (base = main language)
  language_short VARCHAR(5),
  position       INT DEFAULT 0,             -- for drag & drop ordering
  title          VARCHAR(255),
  subtitle       VARCHAR(255),
  text           MEDIUMTEXT,
  link           VARCHAR(500),
  image          VARCHAR(500),              -- JSON with media ID
  image2         VARCHAR(500),              -- only in page_widget_item!
  logo           VARCHAR(500),
  pname          VARCHAR(255),              -- only in page_widget_item!
  dateStart      DATETIME,                  -- only in page_widget_item!
  dateEnd        DATETIME                   -- only in page_widget_item!
);

Columns only in page_widget_item

image2, pname, dateStart, dateEnd exist only here, not in the top-level page_row_col_widget. Using them inside the outer widget_menu list (without the items wrapper) throws no error, but the value is not stored.

Widget ID vs. base ID ​

To load items, the PHP widget needs the base ID of the widget (for multi-language instances, that's the ID of the main-language instance). It lives at $widget_array['item_id'] or $widget_array['base_id'] — both are aliases.

php
$widgetBaseId = (int) ($widget_array['item_id'] ?? $widget_array['base_id'] ?? 0);

Loading items in getWidgetContent() ​

Standard pattern with a multi-language fallback (from uspIconList/bootstrap.php):

php
public static function getWidgetContent($widget_array, $backend = false)
{
    $widget_content_array = [];
    if ($backend) return $widget_content_array;

    $baseLanguage    = $GLOBALS['baseLanguage'];
    $currentLanguage = $GLOBALS['current_language'];
    $widgetBaseId    = (int) $widget_array['item_id'];

    // Current language
    $q = query("SELECT * FROM page_widget_item
                WHERE widget_id = $widgetBaseId
                  AND language_short LIKE '$currentLanguage'
                ORDER BY position ASC");

    // Fallback to the main language if the current one is empty
    if (is_current_lang_main($currentLanguage) != 1 && num_rows($q) == 0) {
        $q = query("SELECT * FROM page_widget_item
                    WHERE widget_id = $widgetBaseId
                      AND language_short LIKE '$baseLanguage'
                    ORDER BY position ASC");
    }

    $items = [];
    while ($row = fetch_assoc($q)) {
        $items[] = [
            'title' => rawurldecode($row['title']),
            'icon'  => rawurldecode($row['subtitle']),
            'text'  => rawurldecode($row['text']),
        ];
    }

    $widget_content_array['items'] = $items;
    return $widget_content_array;
}

Real source: _public/extensions/core/backend/uspIconList/widgets/uspIconList/bootstrap.php.

Key points ​

  • ORDER BY position ASC — the position column is set by drag & drop in the editor. Without ORDER, items appear in insert order (usually wrong).
  • rawurldecode() on text fields — the Pagebuilder stores strings URL-encoded to carry HTML entities cleanly through the layers. Without decoding, the Vue template receives strings like Hello%20World.
  • files::loadURL($row['image']) for image fields — the media ID becomes an absolute URL.
  • Multi-language: current language first, then fallback to baseLanguage. This avoids empty widgets in secondary languages when only the main language is maintained.

Rendering items in the Vue template ​

The widget template receives items under widget.content.items:

vue
<template>
  <div class="usp-icon-list">
    <div v-for="item in widget.content?.items" :key="item.title" class="usp-box">
      <i :class="['fa', item.icon]"></i>
      <h3>{{ item.title }}</h3>
      <div v-html="item.text"></div>
    </div>
  </div>
</template>

<script setup lang="ts">
defineProps<{
  widget: {
    data?: Record<string, any>
    content?: { items?: Array<{ title: string; icon: string; text: string }> }
  }
}>()
</script>

Key for v-for: by convention item.id or item.title. If several items might share a title, use a unique combination, or set $item['_key'] = 'item_' . $row['id'] in PHP before returning.

CRUD API for items ​

Item management goes through the Pagebuilder backend:

ActionHTTP methodAction
Create/save an itemPOSTsave_widget_item (against /api/backend/pagebuilder)
Reorder itemsPOSTsort_widget_items
Delete an itemDELETEwidget_item

Details on the Pagebuilder API actions live in the API Reference — the Pagebuilder properties panel calls these actions automatically when the editor adds, sorts, or deletes items.

Multi-language handling ​

Items with multiple languages follow the same base_id/language_short pattern as other core tables:

  • Main language: base_id = id or base_id IS NULL, language_short = 'de' (or the configured base language)
  • Language variant: base_id = {main-language item ID}, language_short = 'en', …

The Pagebuilder automatically creates a variant record with base_id set when saving a translation. When loading in getWidgetContent(), just fetch the matching language_short row (see the code example above) — no manual merge logic required.

Structure items in the main language, translate variants only

position, image, image2, and other structural fields are often only set in the main language — language variants inherit the structure. Pragmatics: the widget PHP reads position directly from the language-variant row and expects correct ordering there. A well-disciplined editor copies items into the language variant in identical order.

Common issues ​

Forgetting ORDER BY position ASC

Without explicit ordering, the order of SELECT * FROM page_widget_item is not guaranteed. In practice, items come back in insert order — and drag & drop reordering only takes partial effect.

$widget_array['widget_id'] instead of item_id

widget_id is the row ID of the widget instance (one per placement on a page) — not the base ID you need for items. For item queries, use item_id or base_id.

Forgetting rawurldecode

Title/text/subtitle are stored URL-encoded. Without decoding, entities like %20, %C3%A4 land unfiltered in the Vue template.

Items in the wrong table

Repeater items always live in page_widget_item — never in a plugin-local table. If you need a custom table (e.g. because the repeater carries 20 fields): build a plugin-local table construct and manage it via the backend/item API. See Plugins › Content Constructs.

Items aren't nestable

An items field cannot contain another items sub-field inside menu. For two-level repeaters, either place a second widget alongside, or store the sub-structure as JSON in a text field (and parse it client-side).

See also ​