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