Skip to content

For developers ​

This page is for working in an IDE, with files, in git. You do not need snippets: everything they can do is available through a direct call, because that is what they are written on top of.

The service ​

php
/** @var \Boshnik\PbShop\PbShop $shop */
$shop = $modx->services->get('pbshop');

$products = $shop->products()
    ->inCategory('mugs')
    ->sortBy('price', 'desc')
    ->limit(12)
    ->page(2)
    ->get();

products() returns a fresh query object on every call: it carries state — section, limit, ordering — and a shared one would mean the second list on a page inherits the first one's conditions.

Catalogue ​

MethodWhat it does
inCategory($category)the section: a model, an id or an alias. Zero means products outside any category
uncategorized()only what sits directly under the catalogue
limit(?int)the limit; zero and negative mean "no limit"
offset(int) / page(int)an offset or a page number
sortBy(string $field, string $dir = 'asc')ordering; several fields comma-separated
where(array)conditions on table columns
withFilter(array $query, string $base)option filtering from the address
filter()the filter itself — a template needs its blocks, active state and reset link
currency(string)which currency row prices are counted in
get()a collection of models
first()one model or null
lines()ready-made rows for a template or JSON
total() / pages()how many in total and how many pages
refusal()why the list is empty when it is not empty for lack of products
builder(bool $paged = true)the Eloquent builder as it is

builder() is public on purpose: if you need a condition that is not here, it is better to add one clause to our query than to assemble a query from scratch and diverge from us on eager loading and on what counts as published.

categories() works the same way: under(), all(), limit(), offset(), sortBy(), get(), first(), total(), refusal().

Everything else ​

CallWhat it returns
$shop->product($id)a product by alias or id
$shop->category($id)a category by alias or id
$shop->order($uuid)an order by uuid — the same key the buyer opens it with
$shop->cart()CartService: contents, quantity, promo code
$shop->currentCart()the cart of whoever showed up: by cookie for a guest, by account for a member
$shop->orders()OrderService: creation, renewal, expiry, cancellation
$shop->payments()PaymentService: creating a payment, parsing a callback, checking the amount, refunds
$shop->delivery()DeliveryService: methods for the current contents, pricing, carrier lookups
$shop->stock()StockService: reservation, release, clearing abandoned holds
$shop->favorites(), $shop->compare()favourites and comparison
$shop->promocodes()code validation and discount calculation
$shop->stockAlerts()back-in-stock subscriptions
$shop->drivers()the payment driver registry
$shop->fiscal()the cash register; poll() picks up unfinished receipts

Services are handed over ready-made rather than constructed by the caller: half of them depend on each other — an order knows about stock, delivery, payments and promo codes — and assembling that wiring in every controller means assembling it differently one day.

A refusal is not an exception

inCategory('nope') neither throws nor dies: the query remembers the reason and refusal() hands it back. A list is a page, and a typo deserves a notice rather than a 500. The caller prints it: for a snippet that is text instead of the list, in your controller it is whatever you decide.

Route, controller, template ​

Not a single snippet call. Routes are registered in the site-owned core/App/routes/; the component puts nothing there.

php
// core/App/routes/web.php
use Boshnik\PageBlocks\Facades\Route;

Route::get('mugs', [App\Http\Controllers\MugsController::class, 'index']);
php
// core/App/Http/Controllers/MugsController.php
namespace App\Http\Controllers;

use Boshnik\PageBlocks\Http\Request;
use Boshnik\PageBlocks\Http\Response;

class MugsController
{
    public function index(Request $request): Response
    {
        $shop = modx()->services->get('pbshop');

        $products = $shop->products()
            ->inCategory('mugs')
            ->currency('EUR')
            ->withFilter($request->query(), '/mugs')
            ->limit(24)
            ->page((int) $request->get('page', 1));

        if ($products->refusal() !== '') {
            return (new Response())->abort(404);
        }

        return (new Response())->html(view('file:mugs', [
            'products' => $products->get()->all(),
            'filter' => $products->filter()?->blocks() ?? [],
            'pages' => $products->pages(),
        ]));
    }
}

The template is ordinary Fenom from pageblocks_elements_path. You do not need the component's default chunks, but they are available: {$product->line($currency)} returns the same flat set the snippet draws its card from.

Events ​

Everything the site extends the shop with. Listeners are registered in the site-owned core/App/config/pbshop.php, not as MODX system events: those have to be reattached by hand in the manager after every deploy.

php
// core/App/config/pbshop.php
return [
    'listeners' => [
        \Boshnik\PbShop\Events\Dispatcher::ORDER_PAID => [
            App\Listeners\NotifyWarehouse::class,
        ],
    ],
];

A listener is a class with a handle(array $params, string $event) method, or any callable. An exception in a listener does not bring the payment down: it is logged and the remaining listeners still run.

pbShopPaymentCreate    pbShopPaymentSuccess    pbShopPaymentFail    pbShopPaymentRefund
pbShopOrderCreate      pbShopOrderPaid         pbShopOrderExtend    pbShopOrderExpire
pbShopOrderCancel      pbShopOrderStatus       pbShopOrderDelivered
pbShopWaybillCreate    pbShopWaybillStatus
pbShopStockAlertCreate pbShopStockAlertConfirm pbShopStockAlertReady

$modx->invokeEvent() is fired in addition — for third-party plugins — but it only receives scalars: objects become {$key}_id.

A listener's return value almost never matters

There is one exception: pbShopStockAlertReady. A listener returning false takes the mailing upon itself, and the component then sends no letter of its own — otherwise a shop with its own mailing would send the buyer two letters about one event.

JSON ​

Cart, comparison, favourites, delivery and checkout already answer with JSON — these are the very routes the built-in storefront uses:

POST   /pay/cart              cart contents (not GET: the response cache ignores cookies)
POST   /pay/cart/add          add a product or a variant
POST   /pay/cart/{id}         line quantity
DELETE /pay/cart/{id}         remove a line
POST   /pay/cart/promo        promo code; an empty code removes it
POST   /pay/delivery          delivery methods for the current contents
POST   /pay/checkout          checkout
POST   /pay/quick             one-click purchase

Your own frontend takes them as they are. The catalogue and the product page return HTML rather than JSON: to get them as data, write your own route and return $products->lines() — the same array that goes into a template.

Tests ​

Tests are standalone scripts: no PHPUnit, no MODX frontend to boot. Each exits with 1 on failure.

bash
php tests/catalog-query.php       # section, limit, ordering, filtering
php tests/snippet-output.php      # the snippet parameter vocabulary
php tests/default-chunks.php      # the default chunk update policy
php tests/templates-compile.php   # every built-in template compiles under Fenom
php tests/yookassa-callback.php   # gateway callback parsing

Callback signatures are checked against the gateway's reference implementation — verify a new one the same way. Business logic (filtering, stock reservation, discounts, delivery tariffs) is tested on stubs: no database needed.

Boundaries worth knowing ​

  • Site-specific rules never move into the component. pbShop is installed on other people's projects; the client's logic lives in the site-owned core/App/.
  • Amounts are always integers in minor units. Support\Money is the only place where conversion to and from a human-readable form happens.
  • A payment driver must verify its own signature. PaymentService checks the amount on top and never takes the callback's amount on trust.
  • The site owns the schedule. dueForRenewal(), renew(), expire(), fiscal()->poll() and the back-in-stock mailing are called by your cron.

pbShop — a shop component for PageBlocks