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
/** @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
| Method | What 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
| Call | What 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.
// core/App/routes/web.php
use Boshnik\PageBlocks\Facades\Route;
Route::get('mugs', [App\Http\Controllers\MugsController::class, 'index']);// 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.
// 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 purchaseYour 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.
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 parsingCallback 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\Moneyis the only place where conversion to and from a human-readable form happens. - A payment driver must verify its own signature.
PaymentServicechecks 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.