What is pbFavorites?
pbFavorites adds bookmarks on any model to sites built on PageBlocks 3.x. A product, a freight request, an article, a company, a person: the model is named in the call, not baked into the component.
The package is free and does not require a shop — that is the whole point. Bookmarking is universal: a blog wants it, so does a service catalogue, a company directory, a classifieds board. Favourites grew up inside pbShop and only knew products; now it lives on its own and knows anything.
This is not a standalone application. Without PageBlocks the component logs an error and switches off: tables, routing, controllers, file templates, migrations, the template engine and — importantly here — the anonymous-session mechanics all come from there.
Requires PageBlocks alfa24 or newer
Guest ownership by cookie (Support\GuestToken) has lived in the framework since alfa24. On anything older the package switches off and says so in the log rather than working "for signed-in users only": favourites quietly cut in half are worse than favourites turned off — the button is there, the click goes through, the list stays empty.
What the component does, and what it does not
This is the central idea, and the rest of the documentation is split along it.
The component stores and toggles. The table, uniqueness of the owner-object pair, guests by token, merging on login, the list cap, cleaning up abandoned rows, the counter, warming a page.
The model's provider answers three questions, because only the owner of those rows can:
| Question | For a product | For a freight request |
|---|---|---|
| What counts as visible | published (published()) | not expired and not withdrawn |
| How to render a row | "from" price, stock, add-to-cart | route, date, weight |
| What the URL is | built from the main category, which may not exist | built from the request number |
Without that split, universal favourites either print deleted items or grow a second copy of the visibility rule — and that copy will eventually drift from the first.
One result, two paths
// Templater — a MODX template
[[!pbFavoriteButton? &model=`pbshop.product` &id=`[[+id]]`]]
// Templater — Fenom in a file template
{'!pbFavoriteButton' | snippet: ['model' => 'pbshop.product', 'id' => $product->id]}
// Developer — a direct call, no snippet involved
$favorites = modx()->services->get('pbfavorites');
$favorites->has('pbshop.product', $product->id);Neither path is "more correct" and neither goes through the other: a templater is never required to open an IDE, a developer is never required to use snippets.
Parity goes both ways and is covered by a test: slicing and ordering (&limit, &offset, &sortby, &sortdir) are computed by the service and merely parsed by the snippet — so the package has no capability that only exists in a template.
A site hangs its own logic on bookmarks with events — saved, dropped, refused, cleared, merged on sign-in — rather than by editing the component.
The parameter vocabulary is the same as pbShop's
&tpl, &tplWrapper, &tplEmpty, &limit, &offset, &sortby, &sortdir, &outputSeparator, &toPlaceholder, &showLog. The package invents no names of its own and breeds no synonyms: whoever learned the call in the shop writes the call in favourites without opening the docs.
⚠️ The model registry is security, not convenience
The short key (pbshop.product) arrives from the browser: it is in the request body and in the button's attribute. Without an allow-list anyone can send model=modxUser&id=1, get a row in the table, and some day an output provider will print from it something it must not print.
So a model must be registered by a component together with its provider, and an unknown key is a refusal — not "let's store the row and sort it out later". Outside the key is short; in the database the full class name is stored, so a key from a request never turns into an arbitrary class, and the model resolves unambiguously.
Guests are first-class
"Please log in first" on a button is a refusal on click — exactly what favourites must avoid: people bookmark before they decide to create an account.
A guest is identified by a token in the pb_wish cookie (180 days). On login the list moves onto the account and duplicates are dropped. Merging does not check the cap: throwing away bookmarks somebody added by hand for the sake of a round number is worse than a list of a hundred and one.
The token is issued on the first action only. A page by itself never sets the cookie — otherwise every passer-by would get one and the HTTP cache would stop working across the whole site.
Where it came from
The package is assembled from two working implementations rather than written from scratch, and each half is there for a reason:
| Source | What it contributed |
|---|---|
| universal favourites from a live project (freight, transport, companies, consultants, people) | the morph table, the model allow-list, warming a page in one query, the watchers counter, insertOrIgnore against a two-tab race |
| pbShop | guests by token, the cap, merging on login, cleaning up abandoned rows |
Neither covered the other: the first had no guests, the second had nothing but products.
Where to go next
- Quick start — install it, print a button, build a list page. No PHP.
- For developers — the provider contract, the registry, the service, events, the schema, cron.