Providers and the service
This page is for developers: how to plug your own model in, what the service can do, and how the table is laid out.
Registering your own model
Two things: a provider class and one line of registration.
use Boshnik\PbFavorites\Contracts\FavoriteProvider;
class ArticleFavorites implements FavoriteProvider
{
public function modelClass(): string
{
return Article::class;
}
/** @param array<int, int> $ids @return array<int, int> */
public function visible(array $ids): array
{
return Article::query()
->whereIn('id', $ids)
->where('published', 1)
->pluck('id')
->map(static fn($id) => (int) $id)
->all();
}
/** @param array<int, int> $ids @return array<int, array<string, mixed>> */
public function lines(array $ids, array $context = []): array
{
$lines = [];
foreach (Article::query()->whereIn('id', $ids)->get() as $article) {
$lines[(int) $article->id] = [
'title' => $article->title,
'url' => '/blog/' . $article->alias,
'image' => $article->image,
];
}
return $lines;
}
}Register it from your component's bootstrap.php or from the site's core/App/config/*.php:
use Boshnik\PbFavorites\Support\Registry;
Registry::register('blog.article', new ArticleFavorites());That is all. From here [[!pbFavoriteButton? &model=blog.article &id=42]] and the other two snippets work.
The methods take an array, not a single id
This is not a matter of taste. Twenty bookmarks on a page become twenty queries with a one-id method; that is exactly why warming appeared in the original implementation. Write whereIn, not a loop with a query inside.
Why the class is declared rather than derived
modelClass() returns the name explicitly even though models have getMorphClass(). On AbstractDataModel that returns static::class, on BaseModel a short class name. Depending on which base somebody else's model extends means eventually writing two different spellings of one class into a single table.
The model key
Validated against ^[a-z][a-z0-9_.-]*$: lowercase latin, digits, dot, dash, underscore.
The dot separates the area (pbshop.product, blog.article). One site hosts a shop, a blog and a directory, and an ownerless product will collide sooner or later. A second provider for a taken key is not registered and is logged: silently letting the last one win would make the contents of favourites depend on component load order.
The service
/** @var \Boshnik\PbFavorites\PbFavorites $favorites */
$favorites = modx()->services->get('pbfavorites');The methods work out the owner themselves — a signed-in user from the session, a guest from the cookie. They can be called from anywhere, templates included.
| Method | What it does |
|---|---|
all(?string $model = null, array $context = [], array $options = []) | the list: count, items, groups, ids |
count(?string $model = null) | how many are saved and visible |
has(string $model, int $id) | is it saved — for the button |
warm(string $model, array $ids) | warm a page: one query for every button |
watchers(string $model, int $id) | how many people saved the object |
warmWatchers(string $model, array $ids) | the same for a whole page at once |
toggle(string $model, int $id) | toggle; issues a guest token if there is none |
clear(?string $model = null) | clear; without a model, the whole list |
forget(string $model, int $id) | drop bookmarks of a deleted object |
purge(int $days = 180) | drop abandoned guest rows |
register(string $model, FavoriteProvider $provider) | same as Registry::register() |
listen(string $event, $listener) | attach a listener, see "Events" below |
Slicing and ordering live here, not only in the snippet
$favorites->all('pbshop.product', [], [
'limit' => 20,
'offset' => 0,
'sortby' => 'price',
'sortdir' => 'desc',
]);These are the very &limit, &offset, &sortby and &sortdir that [[!pbFavorites]] uses: the snippet only parses them, the service computes them. The rule is simple and covered by a test — everything a template call can do is available from a direct call. Otherwise a developer who needs a slice either repeats it locally or calls a snippet from a controller.
sortby orders by a row field, and the row is assembled by the provider: a product has price, a cargo request does not. A field the rows do not have leaves the order alone and goes into the log — a silently shuffled list is worse than a refusal.
The slice applies to items
count, groups and ids stay whole: they describe what is in the list, not how many rows got printed. Otherwise &limit=10 would leave the eleventh card's button unlit.
Warming is the one thing worth remembering
$products = /* ... */;
$favorites->warm('pbshop.product', $products->pluck('id')->all());
// the buttons in the loop below no longer touch the databaseWithout warming, has() fills the cache one row at a time — a query per button. The snippet cannot do it for you: it sees its own object, not the list.
What all() returns
[
'count' => 7,
'items' => [ /* flat list, newest first */ ],
'groups' => [
'pbshop.product' => ['count' => 5, 'items' => [...]],
'blog.article' => ['count' => 2, 'items' => [...]],
],
'ids' => [
'pbshop.product' => [12, 34, ...],
'blog.article' => [7, 9],
],
]items to print a list, groups to split it into sections, ids to light up buttons where there are two dozen cards and full rows are not needed.
Every row carries model and item_id on top of the provider's fields.
Events
You do not have to patch the component to hang your own logic on bookmarks.
| Event | When | Parameters |
|---|---|---|
pbFavoritesAdded | an object was saved | model, model_class, item_id, user_id, guest |
pbFavoritesRemoved | an object was dropped | the same |
pbFavoritesRefused | the click was refused | the same plus error — a lexicon key |
pbFavoritesCleared | the list was cleared | model (empty — all of it), user_id, guest |
pbFavoritesMerged | a guest list moved in on sign-in | user_id, moved, dropped |
Three ways to subscribe, all equal.
A closure — right where you register the provider:
use Boshnik\PbFavorites\Events\Dispatcher;
Dispatcher::listen(Dispatcher::ADDED, function (array $params, string $event) {
Rating::award($params['user_id'], 'favorite', $params['item_id']);
});A class — in core/App/config/pbfavorites.php:
return [
'listeners' => [
'pbFavoritesAdded' => [\App\Listeners\AwardForFavorite::class],
'pbFavoritesRefused' => \App\Listeners\SuggestCleanup::class,
],
];A class only needs a handle(array $params, string $event) method.
A MODX plugin — invokeEvent() is called after the listeners, so an ordinary plugin works too. Plugins get scalars only: arrays and objects in $scriptProperties are useless to them and break event caching.
Why listeners and not MODX system events
Same as in pbShop and pbAuth: a system event has to be attached by hand in the manager after every deploy, and on an SFTP-only server that step silently gets forgotten. Listeners live in git with the rest of the site's code.
A refusal is its own event
pbFavoritesRefused fires on pbfav_err_limit as well. Someone who hit the limit is someone the site can offer a clean-up to; there is nowhere else to learn about them, and the snippet's answer is only seen by the visitor.
An exception inside a listener goes to the log and does not break the click: the row is already in the database, and refusing after the write would mean a button that "does not work" on a saved object.
What stays with the site
The component does not decide three things for you.
When to clean up abandoned rows. purge() is called by the site's cron, not by the package:
// bin/favorites-purge.php
modx()->services->get('pbfavorites')->purge(180);It counts from the creation time, not the modification time: a bookmark is never edited — it is either added or removed. Rows of signed-in owners are never touched at any age: they have an owner who will come back.
Whether to drop bookmarks of a deleted object. For a request that lives five days, yes — otherwise its bookmarks outlive it by years:
$favorites->forget('mt.cargo', $cargo->id);For a product, no: something withdrawn from sale comes back, and the bookmark should come back with it. That is why the call is not inside the package: whoever deletes decides whether it is forever.
Where the list page lives. The resource and its address are yours, see the quick start.
The table
pb_favorites:
| Column | What |
|---|---|
model_type | the full class name — whatever modelClass() returned |
model_id | the object id |
user_id | signed-in owner; NULL on a guest row |
token | guest token; NULL on a signed-in owner's row |
data | optional, for your own fields |
created_at | integer, creation time |
Both owner columns being nullable is not sloppiness: MySQL lets any number of NULLs into a unique index but not two equal values. "One bookmark per pair" rests on that, for guests and signed-in users alike.
Indexes: uniq_user_object, uniq_token_object (both unique), idx_user_type_time, idx_object, idx_created.
⚠️ The migration lands on an existing table
If pb_favorites already exists on the site — your own, created before the package — the migration neither creates nor overwrites it, it grows it: adds token and data, drops NOT NULL from user_id, completes the indexes. Every step is checked separately, so a repeated run is safe.
A rollback does not drop the table: where the site created it, it is older than the component and outlives it. Only the columns the package added are removed.
This is not caution for its own sake — it is exactly what happened: on a live project a table with this name appeared two days before favourites shipped in pbShop, with a different schema and real data in it.
Migrating from pbShop
The second migration moves pb_shop_favorites into pb_favorites if the old table exists; if it does not, it stays quiet — normal for a site without a shop. It does not drop the old table: the package may be installed before the shop is upgraded, and dropping it before the transfer would erase customers' bookmarks.
Signed-in owners' rows move over completely and work immediately. Guest rows move too, but the cookie changed along with the move (pbshop_wish → pb_wish), so the browser will no longer present the old token — such a row lives until purge() and then leaves as abandoned.
Routes moved from /pay/favorites/* to /favorites/*.
The guest mechanics are not ours
The token, its format, lifetime, cookie flags, issuing and clearing — that is PageBlocks\Support\GuestToken (alfa24). The package carries no copy of it: before the move this trick had been written three times inside pbShop — cart, compare, favourites — and the three copies had already drifted apart on lifetime.
What is left for the package is declaring its own: the name pb_wish and 180 days (Support\Owner). The name is registered in GuestCookies from bootstrap.php — otherwise the HTTP cache cannot tell two guests apart and hands the second one the first one's page.
If you are writing your own component with guest state
Take GuestToken and register your cookie name in GuestCookies — those are exactly the two lines that were missing to stop the cache handing out other people's carts.
The page cache
Changing the list flushes the PageBlocks response cache. The service does it by hand, which matters if you ever edit it: insertOrIgnore and mass deletes bypass Eloquent events, and therefore the automatic flush in BaseModel. Without the explicit call a guest who clicked the button would get their own page back from the cache — with the old button state on it.
Tests
Plain scripts, no database required, exit 1 on failure:
php tests/registry.php # the model allow-list
php tests/service.php # order, slice, snippet parity, events
php tests/schema-compat.php # schema compatibility with the live table
php tests/templates-compile.php # compiling the templates with real Fenomschema-compat.php is the important one. It compares the definitions of two migrations and fails if a column gets renamed: on the live site that would mean Unknown column instead of growing the table, and there is nowhere to see it coming — the migration passes, after all, the table does exist.
service.php guards parity: it runs arrange() and compares the names the snippet's parameter parsing hands over with the ones the service understands. Let them drift apart and &limit stops working in templates, silently.
Templates and the platform
Default chunks are resolved by the platform's View::bundled(), not by the package: a default deliberately stays out of the general &tpl resolution — otherwise pageblocks_elements_path or pageblocks_file_elements_only would make it unreachable, and a snippet with no parameters would stop rendering anything.
The file name equals the record name (pbFavorites.row → pbFavorites.row.tpl), and the pbFavorites source is registered in bootstrap.php with a single ElementSources::register() line.
A platform with a source argument on bundled() is required
The path to bundled files used to be pinned to the PageBlocks folder, so a satellite could not call bundled() for its own defaults. Because of that pbFavorites had a second default resolver, and it disagreed with the first one: with pageblocks_file_elements_only = 1 and an existing pbFavorites.row record the output went blank. The package no longer carries its own copy, so on a platform without that argument bootstrap.php disables the component and logs why — instead of empty blocks on the storefront.