Skip to content

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.

php
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:

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 ​

php
/** @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.

MethodWhat 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 ​

php
$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 ​

php
$products = /* ... */;

$favorites->warm('pbshop.product', $products->pluck('id')->all());

// the buttons in the loop below no longer touch the database

Without 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 ​

php
[
    '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.

EventWhenParameters
pbFavoritesAddedan object was savedmodel, model_class, item_id, user_id, guest
pbFavoritesRemovedan object was droppedthe same
pbFavoritesRefusedthe click was refusedthe same plus error — a lexicon key
pbFavoritesClearedthe list was clearedmodel (empty — all of it), user_id, guest
pbFavoritesMergeda guest list moved in on sign-inuser_id, moved, dropped

Three ways to subscribe, all equal.

A closure — right where you register the provider:

php
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:

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:

php
// 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:

php
$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:

ColumnWhat
model_typethe full class name — whatever modelClass() returned
model_idthe object id
user_idsigned-in owner; NULL on a guest row
tokenguest token; NULL on a signed-in owner's row
dataoptional, for your own fields
created_atinteger, 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:

bash
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 Fenom

schema-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.

pbFavorites — bookmarks on any model, free for PageBlocks