Разработчику
Эта страница — для работы в IDE, с файлами, в git. Сниппеты вам не нужны: всё, что они умеют, доступно прямым вызовом, потому что сами они над этим вызовом и написаны.
Сервис
/** @var \Boshnik\PbShop\PbShop $shop */
$shop = $modx->services->get('pbshop');
$products = $shop->products()
->inCategory('mugs')
->sortBy('price', 'desc')
->limit(12)
->page(2)
->get();products() отдаёт новый объект запроса на каждый вызов: у него есть состояние — раздел, предел, порядок, — и общий на всех означал бы, что второй список на странице получает условия первого.
Каталог
| Метод | Что делает |
|---|---|
inCategory($category) | раздел: модель, id или псевдоним. Ноль — товары вне категорий |
uncategorized() | только то, что лежит прямо под каталогом |
limit(?int) | предел; ноль и отрицательное значат «без предела» |
offset(int) / page(int) | смещение или номер страницы |
sortBy(string $field, string $dir = 'asc') | порядок; несколько полей через запятую |
where(array) | условия по колонкам таблицы |
withFilter(array $query, string $base) | подбор по опциям из адреса |
filter() | сам фильтр — шаблону нужны его блоки, отмеченность и сброс |
currency(string) | в какой валюте считать цены строк |
get() | коллекция моделей |
first() | одна модель или null |
lines() | готовые строки для шаблона и JSON |
total() / pages() | сколько всего и сколько страниц |
refusal() | почему список пуст, если пуст не потому, что товаров нет |
builder(bool $paged = true) | построитель Eloquent как он есть |
builder() публичный намеренно: если нужно условие, которого здесь нет, лучше дописать его к нашему запросу, чем собрать запрос с нуля и разойтись с нами в предзагрузке и в том, что считать выложенным.
categories() устроен так же: under(), all(), limit(), offset(), sortBy(), get(), first(), total(), refusal().
Постраничность
Support\Pagination считает окно страниц, а адрес приносит замыкание — у каталога это адрес текущего выбора фильтра (CatalogFilter::current()) плюс номер:
$pagination = new Pagination($products->total(), $limit, $page);
if ($pagination->beyond()) {
return (new Response())->abort(404);
}
$base = $products->filter()?->current() ?? '/mugs';
$data = $pagination->toArray(fn(int $n) => $n <= 1 ? $base : $base . '?page=' . $n);Три решения, которые стоит унаследовать, а не переигрывать: у первой страницы адрес без ?page=1 (иначе у одной страницы два адреса и две записи в кеше ответов), номер за пределом — 404, а не пустой список (иначе это сколько угодно адресов с одинаковым пустым содержимым), и адрес страницы собирает фильтр, а не вызывающий — он держит справочный порядок параметров, от которого зависит ключ кеша.
Остальное
| Вызов | Что отдаёт |
|---|---|
$shop->product($id) | товар по псевдониму или id |
$shop->category($id) | категорию по псевдониму или id |
$shop->order($uuid) | заказ по uuid — тот же ключ, по которому его открывает покупатель |
$shop->cart() | CartService: состав, количество, промокод |
$shop->currentCart() | корзину того, кто пришёл: по cookie у гостя, по учётной записи у вошедшего |
$shop->orders() | OrderService: создание, продление, истечение, отмена |
$shop->payments() | PaymentService: создание платежа, разбор колбэка, сверка суммы, возврат |
$shop->delivery() | DeliveryService: способы под состав, расчёт, справочники служб |
$shop->stock() | StockService: резерв, возврат, освобождение брошенного |
$shop->favorites(), $shop->compare() | избранное и сравнение |
$shop->promocodes() | проверка кода и расчёт скидки |
$shop->stockAlerts() | подписки «сообщить о поступлении» |
$shop->drivers() | реестр платёжных драйверов |
$shop->fiscal() | касса 54-ФЗ; poll() добирает недопечатанные чеки |
Сервисы отдаются готовыми, а не создаются вызывающим: у половины из них есть зависимости друг от друга — заказ знает про склад, доставку, платежи и промокоды, — и собирать эту связку в каждом контроллере значит однажды собрать её иначе.
Отказ — не исключение
inCategory('нет-такого') не бросает и не падает: запрос запоминает причину, и её отдаёт refusal(). Список — это страница, и на опечатку она обязана ответить надписью, а не пятисотой. Печатает надпись вызывающий: у сниппета это текст вместо списка, у вашего контроллера — то, что вы решите.
Роут, контроллер, шаблон
Ни одного вызова сниппета. Маршруты регистрируются в site-owned core/App/routes/, компонент туда ничего не кладёт.
// 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('RUB')
->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(),
]));
}
}Шаблон — обычный Fenom из pageblocks_elements_path. Дефолтные чанки компонента вам не нужны, но доступны: {$product->line($currency)} отдаёт тот же плоский набор, которым рисует карточку сниппет.
События
Всё, чем сайт расширяет магазин. Слушатели регистрируются в site-owned core/App/config/pbshop.php, а не системными событиями MODX: те приходится руками прикреплять в менеджере после каждого деплоя.
// core/App/config/pbshop.php
return [
'listeners' => [
\Boshnik\PbShop\Events\Dispatcher::ORDER_PAID => [
App\Listeners\NotifyWarehouse::class,
],
],
];Слушатель — класс с методом handle(array $params, string $event) либо любой callable. Исключение в слушателе не роняет платёж: оно логируется, остальные слушатели отрабатывают.
pbShopPaymentCreate pbShopPaymentSuccess pbShopPaymentFail pbShopPaymentRefund
pbShopOrderCreate pbShopOrderPaid pbShopOrderExtend pbShopOrderExpire
pbShopOrderCancel pbShopOrderStatus pbShopOrderDelivered
pbShopWaybillCreate pbShopWaybillStatus
pbShopStockAlertCreate pbShopStockAlertConfirm pbShopStockAlertReady$modx->invokeEvent() вызывается дополнительно — для сторонних плагинов, — но получает только скаляры: объекты превращаются в {$key}_id.
Ответ слушателя почти нигде ничего не значит
Исключение одно: pbShopStockAlertReady. Слушатель, вернувший false, берёт рассылку на себя, и своего письма компонент тогда не шлёт — иначе магазин со своей рассылкой отправил бы покупателю два письма об одном.
JSON
Корзина, сравнение, избранное, доставка и оформление уже отвечают JSON — это те самые маршруты, которыми пользуется встроенная витрина:
POST /pay/cart состав корзины (не GET: кеш ответов не видит cookie)
POST /pay/cart/add положить товар или вариант
POST /pay/cart/{id} количество строки
DELETE /pay/cart/{id} убрать строку
POST /pay/cart/promo промокод; пустой код — убрать
POST /pay/delivery способы доставки под состав
POST /pay/checkout оформление
POST /pay/quick покупка в один кликСвой фронтенд берёт их как есть. Каталог и карточка отдают HTML, а не JSON: чтобы получить их данными, напишите свой роут и верните $products->lines() — это тот же массив, что уезжает в шаблон.
Тесты
Тесты — самостоятельные скрипты, без PHPUnit и без поднятия фронта MODX. Каждый возвращает exit 1 при провале.
php tests/catalog-query.php # раздел, предел, порядок, подбор
php tests/snippet-output.php # словарь параметров сниппетов
php tests/default-chunks.php # политика обновления дефолтных чанков
php tests/templates-compile.php # все встроенные шаблоны собираются Fenom
php tests/yookassa-callback.php # разбор колбэка шлюзаПодписи колбэков сверяются с эталонной реализацией шлюза — новую подпись проверяйте тем же способом. Бизнес-логика (фильтр, резерв склада, скидка, тарифы доставки) тестируется на заглушках: базы для этого не нужно.
Границы, которые стоит знать
- Правила конкретной площадки в компонент не едут. pbShop ставят на чужие проекты; логика заказчика живёт в site-owned
core/App/. - Суммы везде целые в минорных единицах.
Support\Money— единственное место, где происходит перевод в человекочитаемое и обратно. - Драйвер платежа обязан проверять подпись сам.
PaymentServiceсверяет сумму сверху и сумму из колбэка на веру не принимает. - Расписание ведёт сайт.
dueForRenewal(),renew(),expire(),fiscal()->poll()и рассылку подписок зовёт ваш крон.