Resource sections (PRO)
Custom fields on the standard MODX resource form — the replacement for a pile of TVs.
You create a section, put fields in it, and the section appears on every resource form. PageBlocks → Constructor → Objects, with the context set to the resource.
A section
| Setting | What it does |
|---|---|
| Name | The caption of the tab or panel |
| Context | pbResource — this section belongs to the resource form |
| Placement | tab, panel or default — how it appears on the form |
| Description | Text above the fields |
| Position | Order among the sections |
| Permissions | Which users and groups see it |
| Published | An unpublished section is not rendered |
Placement
| Value | Where the fields land |
|---|---|
tab | A new tab of its own on the resource form |
panel | A collapsible panel |
default | Among the standard fields, without a container |
tab is the safe default for anything with more than two fields. default is for the one or two fields that belong next to the resource's own — a switch that changes how the page behaves.
Where the values live
In site_content.properties, as JSON. Not in a new column, not in a TV table.
That matters in two directions:
- adding a field needs no migration, and removing one leaves the data behind rather than dropping a column;
- the value travels with the resource — duplicating a resource copies it, because MODX copies
propertiesitself.
properties is not hydrated by a plain query
Resource::find($id)->seo_title is null even when the value is there. The field lives inside the JSON, and only the resource form unpacks it.
In code read it explicitly:
$page = Resource::find($id);
$title = $page->properties['seo_title'] ?? '';In a template on the current page the fields are already unpacked, because the controller hydrates them.
Tables and galleries are the exception
tablefield and galleryfield never reach properties. That is not an oversight but a difference in the nature of the value: a text field has one value — a string; a table has as many as the editor typed in, each with its own set of fields; a gallery has as many as were uploaded. Sets like that do not belong in the resource's JSON — you could not sort them, paginate them or expose them through the API.
So both types keep their records in tables of their own and link back to the resource through three columns:
| Column | What is in it |
|---|---|
model_type | the resource's class_key, MODX\Revolution\modDocument for a plain page |
model_id | the resource's id |
field_id | the id of the field itself in the constructor |
| Field type | Where the records go |
|---|---|
tablefield | the table of the model chosen on the table; pb_table_data by default |
galleryfield | pb_files |
Saving the section skips both on purpose: their records are already in the database. The grid and the uploader write them with their own requests as you work, so they need no Save — unlike every other field, which reaches the resource exactly at that moment.
Select by field_id, not by the morph
field_id points at one single field of one section, so model_id + field_id is enough: rows of the neighbouring field on the same page will not slip into the result. model_type, meanwhile, is stored both short (modDocument) and fully qualified (MODX\Revolution\modDocument) — a condition on it silently drops half the rows.
A new resource has nothing to anchor to
Until the resource is saved it has no id, and sections are not rendered on the form at all. Create the page, save it, and only then fill in the table and the gallery.
A table on the resource form
What it is for
Some pages are not text but a list of uniform records: promo slides, product specs, a tour itinerary day by day, price tiers, questions and answers, the team, attached documents.
MODX usually solves this one of two ways, and both are awkward. Either a TV with separators — the editor types Berlin||Dresden||Leipzig, the template splits the string, and adding a picture to an item is already impossible. Or a resource per item — and then the tree grows a hundred pages nobody ever opens.
A table field gives you what neither of them has: every row has its own fields of their own types (number, date, image, richtext, select), rows are reordered by dragging, they can be published and unpublished one by one, and on the front end they can be sorted, filtered and paginated.
Setting one up
- PageBlocks → Constructor → Tables — create a table and describe its fields. This is the row template:
title,text,image,price. - PageBlocks → Constructor → Objects — create a section with the
pbResourcecontext and thetabplacement. - Add a field of type Table to the section, give it a name and pick the table from step 1.
Field settings
| Setting | What it does |
|---|---|
| Table | Which table constructor to show |
| Read only | Rows are listed only: no create button, no row menu, no editing |
| Sort by | Which column to order by, menuindex by default |
| Direction | Ascending or descending |
| Rows per page | 20 by default |
The width is forced to the full row and cannot be changed — a grid in a narrow column is useless.
Example: a tour itinerary
A table TourDay with fields day (number), title (text), text (richtext) and image (image). A section "Itinerary", context pbResource, placement tab. It holds one field: caption "Days", name days, type Table, table TourDay.
The editor opens the tour page, switches to the "Itinerary" tab and adds days as grid rows. On the front end:
[[!pbList?
&model=`PbTableData`
&parent=`[[*id]]`
&where=`[["field_id","=","12"]]`
&tpl=`chunk:tourDay`
&orderBy=`menuindex`
&limit=`30`
]]The tourDay chunk receives the row as item:
<div class="tour-day">
<h3>Day {$item->day}. {$item->title}</h3>
{if $item->image}<img src="/{$item->image}" alt="{$item->title|escape}">{/if}
{$item->text}
</div>12 is the field's id, visible in the id column of the constructor's field grid. model=PbTableData is right as long as the table uses the default model; if it was given a table of its own through UI migrations, put that model's name there instead.
The remaining parameters are in pbList: &limit, &loadmore, &scopes, your own conditions in &where.
What not to expect
{$modx->resource->days} comes back empty
The resource has no property named after the table field: the rows live in their own table and nothing on the resource unpacks them. And empty looks exactly like a typo in the name — there will be no error. Read them with a query, as above. The same goes for galleries.
A gallery on the resource form
What it is for
A single image field covers the cover shot. Anything larger — a photo report, a portfolio, certificates, the documents attached to a page — is a set of files with an order and captions. Building it out of ten image_1 … image_10 fields is pointless: the order cannot be changed, spare fields sit empty, and the eleventh photo needs a migration.
A gallery gives you one uploader for the whole page: files are dropped in batches, the order is set by dragging, every file has its own caption and description. The files can be anything, not only pictures: video and PDF sit next to photos, and the type column tells them apart.
Field settings
| Setting | What it does |
|---|---|
| Source | The MODX media source to upload into |
| Path | The folder inside that source |
| Thumbnails | Which sizes to generate, described as JSON |
Path understands the {resource_id}, {user_id}, {alias} and {id} placeholders. On a resource form the first one is the useful one:
/gallery/{resource_id}/Every page's files land in a folder of their own, and the disk shows what belongs to what. Without the placeholder every gallery on the site piles into one directory, and a year later it holds ten thousand files.
Thumbnails are described like this:
{"webp": {"w": 1200, "h": 800, "q": 85, "zc": "1", "f": "webp"}}That yields 1200×800 WebP images in a webp folder next to the originals.
Example: a photo report
A section "Photos", context pbResource, placement tab. The field: caption "Photo report", name photos, type Gallery, the default source, path /gallery/{resource_id}/.
Gallery files cannot be listed with a snippet: pbList walks models under PageBlocks\App\Models\, and PbFile is a framework model. So the query is written by hand — in a controller or a snippet — and the template gets a ready list:
use Boshnik\PageBlocks\Models\PbFile;
$photos = PbFile::query()
->where('model_id', $modx->resource->id)
->where('field_id', 18)
->published()
->orderBy('menuindex')
->get();<div class="gallery">
{foreach $photos as $photo}
{if $photo->type === 'image'}
<img src="/{$photo->url}" alt="{$photo->title ?: $photo->name}">
{/if}
{/foreach}
</div>18 is again the field's id from the constructor grid. The useful columns of a row are url, title, description, type, width, height, menuindex. url is stored without a leading slash — hence the /{$photo->url} in the example.
An uploaded file is published straight away, so the published() scope only filters out what the editor unpublished by hand.
Branch on type
A gallery holds any kind of file, not only images: video and PDF live happily next to photos. Without the type check an <img> with a PDF inside ends up in your markup.
Duplicating and deleting a resource
Duplicating a resource copies both. MODX copies properties itself — that is the ordinary section fields — and PageBlocks repeats the table rows and gallery records with the new model_id on OnResourceDuplicate. What gets copied is the records, not the files on disk: the new gallery row points at the same file.
Deleting a resource leaves the records alone. Deletion in MODX is soft and the page can be restored from the trash together with its tables and gallery. But once the trash is emptied the rows stay in their tables anchored to an id that no longer exists: invisible on the site, still taking up space. Clean them up by hand, by model_id.
Saving
Values are collected on OnBeforeDocFormSave and written into the resource. Nothing is needed from you — the section is enough.
For the user-side equivalent see User fields; the mechanism is the same with a different context.