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 case | Items? |
|---|---|
| USP list (icon + title + text, n×) | yes |
| FAQ entries | yes |
| Testimonials | yes |
| Image gallery | yes |
| Single hero (title + image) | no — top-level fields are enough |
| Blog listing from the DB | no — 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
{
"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"}
]
}
]
}| Field | Required | Purpose |
|---|---|---|
type | yes | Always "items" |
placeholder | no | Label of the items section in the properties panel |
menu | yes | Sub-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:
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.
$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):
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— thepositioncolumn 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 likeHello%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:
<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:
| Action | HTTP method | Action |
|---|---|---|
| Create/save an item | POST | save_widget_item (against /api/backend/pagebuilder) |
| Reorder items | POST | sort_widget_items |
| Delete an item | DELETE | widget_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 = idorbase_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
- widget_menu field types —
itemsas a field type - getWidgetContent() — loading items + multi-language fallback
- Widget Anatomy — where
$widget_array['item_id']comes from - Plugins › Content Constructs — alternative with a custom table
- Pagebuilder API for items: API Reference,
backend/pagebuildersection (save_widget_item,sort_widget_items,widget_item)