Quick start
This page is for working inside MODX templates only. No IDE, no git, no controllers: install, call a snippet, replace a chunk.
Output products
Install the package and write this in a template:
[[!pbProducts]]That is all. Published products appear in the order the manager arranged them — a card with an image, a title, a price, an "Add to cart" button and a favourites heart.
The exclamation mark is required: without it MODX caches the output, and the first buyer who adds something to the cart shows their own state to everyone else.
What to configure once
Strictly speaking — nothing. A fresh install opens the storefront at /shop, puts a "Demo" section with three products in it, and enables one payment method: the offline "Cash on delivery". An order goes through end to end, with the manager confirming payment in the payments grid.
After that you change it to suit:
| Setting | Why change it |
|---|---|
pbshop_route_prefix | The catalogue prefix, shop by default. An empty value switches the storefront off entirely. If the site already has a /shop page, the storefront will shadow it — change the prefix |
pbshop_currency | The default currency. A product with no price in it is printed without a price — which is not the same as "zero" |
pbshop_catalog_limit | How many products per catalogue or section page, twelve by default. Zero means all at once. Does not affect snippets: they have their own &limit |
| Payment methods | The other fourteen ship disabled, and that is not an oversight: an enabled acquirer with someone else's keys sends money to someone else's shop_id. Enable the one you have a contract for |
| The "Demo" section | Delete it along with its products once you have your own. Every alias starts with demo- |
Products, categories, options and variants are managed in the pbShop section of the manager. The field constructor comes from PageBlocks, so your own field is added with the mouse and needs no columns.
Five snippets
[[!pbProducts]] catalogue products
[[!pbProduct? &id=`mug-logo`]] a single product as a card
[[!pbCategories]] catalogue sections
[[!pbCart]] the cart: contents, discount, total
[[!pbOrder? &uuid=`…`]] an order as a cardpbProducts, pbCategories and pbCart work with no parameters at all: the first prints published products, the second prints the catalogue roots — that is, the catalogue itself (&parent descends one level) — and the third prints the cart of whoever showed up.
pbProduct and pbOrder cannot do without a parameter and do not pretend otherwise: there is nothing to guess which product or whose order you meant. A call without &id or &uuid prints what is missing.
Cart and order: uncached calls only
And they do not work while pageblocks_http_cache is on: the response cache is keyed by address without cookies, so one buyer's contents printed into the markup would reach everyone else. In that case the snippet refuses out loud and explains what to do — a silent empty cart would just look like an empty cart.
Change the markup
Two ways, and the first is usually enough.
Edit the default chunk
On install the component creates its chunks as ordinary records in the manager tree, under the pbShop category:
| Chunk | What it prints |
|---|---|
pbShop.card | a product card in a list — also used by pbProduct |
pbShop.list | the wrapper for product and category lists |
pbShop.categoryCard | a section in a list |
pbShop.cartRow | a cart line |
pbShop.cartRows | the cart wrapper with total and discount |
pbShop.orderCard | an order card |
Open it, edit it, save — the change is live immediately. Updating the component will not overwrite your edit: the hash of the shipped content is stored at install time, and only untouched records are rewritten. If you edited it, the log gets a line saying the default chunk changed while yours did not, and yours stays as it is.
Storefront page chunks are separate: pbShop.catalog, pbShop.category, pbShop.product, pbShop.cart, pbShop.order, pbShop.layout and others. You need those when you are reworking a whole page rather than a card.
The "static" checkbox needs a MODX system setting
The static-file field on our chunks is filled in from the start — tick the box and you take the chunk into your own git as a file. But MODX only writes that file if tpl is allowed in static_elements_allowed_extensions. An empty setting means "no static elements at all": the source is declared immutable and the manager answers with a field error. Verified on the staging site on 2026-10-01 — the setting is empty there, and no file was created either from the manager or through the API.
Name your own chunk in the call
[[!pbProducts? &tpl=`myCard`]]The value of &tpl understands four forms — the ones the MODX audience already knows:
card a chunk from the database
file:chunks/card a file under pageblocks_elements_path
@FILE chunks/card the same thing, a pdoTools habit
@INLINE <b>{$title}</b> inlineThere is no fallback between sources
Say file: and only a file is looked up. Not found — a readable notice appears in the markup instead of a quiet search for the same name in the database: an implicit fallback makes behaviour non-deterministic and impossible to debug.
What the call does not have, and will not: a separate path parameter (customPath and friends). The source is named inside the value — otherwise &tpl and &tplWrapper in one call could not take templates from different places.
What a card receives
pbShop.card gets a flat set of placeholders. The model is deliberately not passed in: otherwise the templater would have to know about currencies and about a composite product's own price being a technicality.
| Placeholder | What it holds |
|---|---|
{$title} | the title |
{$url} | the card address; empty when the storefront is off |
{$image} | the image path as the manager saved it. The image.tpl block resizes it through Glide: {include 'file:image' src=$image alt=$title w=400 h=400} |
{$formatted} | the price as a string, "from 1990 ₽" for a composite product. Empty means "not sold in this currency" |
{$old_formatted} | the old price, for strikethrough. Empty when there is no discount: the product does the comparison, the template only prints what is non-empty |
{$alias} | the alias, also the key for the cart and favourites buttons |
{$product_id} | the identifier |
{$in_stock} | whether there is anything to ship |
{$variable} | whether the product is composite: those pick a variant on the card instead of going straight to the cart |
{$idx}, {$first}, {$last} | the row number and its edges — for "every third in a row" |
Images
A product, a section and a variant store an image path (image), and PageBlocks' Glide does the resizing — the component carries no wrapper of its own. In templates it is one shared block:
{include 'file:image' src=$image alt=$title w=400 h=400}
{include 'file:image' src=$product->image alt=$product->title w=800 h=800 crop=0}It emits a webp of the requested size (q=82) with width/height attributes and loading="lazy". Those attributes are not decoration: without them the browser does not know the height before the image loads, and the list jumps under the cursor exactly when the buyer is aiming at a card. crop=0 fits the whole image instead of cropping to the centre.
A product with no image gets a frame of the same aspect ratio reading "No photo". Not nothing: a card without the frame sits higher than its neighbours and the grid row falls apart.
The shipped demo products come without photos
The three products in the "Demo" section show the placeholder — the component ships no stock images: that is extra package weight and someone else's licence inside your shop. Add your own and resizing starts working by itself.
A broken file does not break the page, and does not fix itself either
A missing path, a zero-length file, a HEIC named .jpg — Glide writes one line to the log and returns the original address. The page is intact, the image is simply not resized: check the MODX log, it says what is wrong with the file.
Parameters
The same ones across every list. Learn them here and you will write a call to any other snippet in the family without opening the documentation.
| Parameter | Meaning |
|---|---|
&tpl | the item chunk; omitted — the component's default chunk |
&tplWrapper | the wrapper chunk; 0 — no wrapper |
&tplEmpty | the chunk for an empty list; omitted — empty output |
&limit | how many to output; 0 — all |
&offset | how many to skip |
&page | a page number instead of an offset |
&sort | a named storefront preset: cheap, expensive, new, name. An unknown value means the default order, not a refusal |
&sortby | the field to sort by; several, comma-separated |
&sortdir | asc or desc |
&where | extra conditions, JSON |
&parent | the section: alias or id |
&outputSeparator | the separator between rows |
&toPlaceholder | put the output in a placeholder instead of printing it in place |
&showLog | debugging; printed only to a manager-authenticated user |
pbProduct takes &id (alias or number) instead of a section, pbOrder takes &uuid.
Ordering on the catalogue and section pages themselves uses the same presets in the URL: ?sort=cheap. The row of links is printed by sorting.tpl, the data arrives in $sorting. A sorted page is closed off with <meta robots="noindex,follow"> — it is the same products in a different order, a duplicate of the section; pagination stays open, since page two carries different products.
Sorting works on table columns only
A field added by the constructor lives in a JSON column, and ordering by it does not sort — it fails with an SQL error. So &sortby with such a field refuses before the query and lists what is allowed: id, alias, title, price, old_price, stock, weight, menuindex, published_at, created_at, updated_at.
When something did not work
The snippet does not go silent. Mistype a chunk name and the call site says Chunk "myCard" not found; mistype a section and it says the section was not found; break the &where JSON and it says what failed to parse. Empty output means exactly one thing: there is nothing to output.
Setting names never appear in the notice — they go to the MODX log. A buyer has no business knowing how the shop is wired inside.
Next
- For developers — if you need your own page fed with shop data without snippets. One project happily lives both ways at once.