CQRS and Domain
|
This content was generated with the assistance of AI and should be verified against the official documentation of each platform before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
Since PrestaShop 1.7.x the back office has been rebuilt on top of a small domain layer in which every change of state is
a command and every read is a query. Controllers, form handlers, console commands and, from 9.0, the Admin API do not
touch ObjectModel classes directly: they build a command or a query object, hand it to a bus, and a handler does the
work. This page explains the vocabulary, the rules the core follows, how the buses work in 9.2 (they run on Symfony
Messenger, which replaced the Tactician library in 9.0), how to list everything the core offers, and how to build, run and
expose your own commands and queries from a module. It is the page that makes the rest of the developer pages easier to
read: grids, forms, the Admin API resources and the product page are all built from these pieces.
The page was written against PrestaShop 9.2.0 (released 2026-09-30, Symfony 6.4.46, PHP 8.5.12 in the official image) and checked on 2026-10-27. Every command, query and bus detail below was read from the 9.2.0 sources, and the module code was written and executed on a local 9.2.0 installation: the module installs, its commands and queries run through the buses, the handlers appear in the inventory commands, and the failure paths were exercised. The running example is Pocket Coffee’s roast date: a command that records the date a product was roasted and a query that reads it back with the number of days since the roast, for the "roast date badge" shown on the product page. The page that builds the badge itself is the Module Basics page; here the focus is the domain and the buses.
Why CQRS in PrestaShop
CQRS means Command Query Responsibility Segregation: the model that changes data (commands) is separated from the model
that reads it (queries). PrestaShop did not adopt it for performance. The reason, stated in the project’s architecture
decision records and developer documentation, is migration: while the back office moved page by page from legacy
controllers to Symfony, the new pages needed a way to read and change data without calling ObjectModel classes such as
Product or Category directly, and without creating a second source of truth next to them. Commands and queries are that
boundary. A controller knows only the command class and its constructor arguments; whether the handler behind it uses an
ObjectModel today or Doctrine tomorrow is invisible to the caller.
Three practical benefits follow, and they are visible in 9.2.
-
One entry point per use case. "Toggle a tax rules group", "add a carrier" or "set a product’s stock" is one class with typed, validated input, used identically by the back-office form, the Admin API and a console script.
-
The Admin API is generated from the same classes. An API resource declares which command or query serves each HTTP operation, so the API cannot drift from the back office rules (see the API section below).
-
Testable domain code. Commands, queries, results and value objects are plain PHP objects without global state, so they are tested without a database; handlers are tested with doubles for their services.
The cost is boilerplate: a single operation is at least a command, a handler and (for creations) an id value object. The core accepts that cost for its own pages; a module that only needs to save one setting does not have to use CQRS, and the module pages show the lighter alternatives. Use it when the operation will be called from more than one place, when it has rules worth testing, or when you want it in the Admin API.
Vocabulary
| Piece | Role and rules | Where it lives |
|---|---|---|
Command |
Describes one action that changes state ("set the roast date of product 1"). It carries only the input of the action and performs nothing. Input is primitives or value objects; the constructor validates it, so an existing command is always valid. Immutable. |
|
Command handler |
Executes exactly one command, orchestrating services rather than holding the logic itself. Returns nothing on success (the exception is creations, which return the new id as a value object) and throws a typed exception on failure. |
|
Query |
Describes one read ("get the roast date of product 1"). Performs nothing, changes nothing. |
|
Query handler |
Executes one query and returns a typed result object, never an array and never an |
|
Query result (DTO) |
A data transfer object with getters: the complete answer of one query ( |
|
Value object |
A small immutable object defined by its value, not an identity, that validates itself on construction ( |
|
Domain service and repository |
The logic and the persistence that handlers delegate to, so that two handlers never duplicate code or depend on each other. They validate entities and translate legacy exceptions to domain ones. |
|
Domain exception |
A typed exception specific to a domain ( |
|
The core groups everything by domain: 9.2.0 has 58 domain folders under src/Core/Domain that contain commands or
queries (Product, Order, Customer, Cart, Shipment, Category, Carrier, Tax rules group, Address, Attribute group and so
on), each with the same sub-folder layout (Command, CommandHandler, Exception, Query, QueryHandler,
QueryResult, ValueObject). Copying that layout in a module keeps the code recognizable to anyone who has read the core.
Two rules of the core are worth stating because they are what make the pattern useful. Every constructor validates, so there is no "invalid command" state to check later; and a command or query receives primitive types (int, float, string, bool, array) or value objects rather than entities, so it can be built from a form, a JSON body or a CLI argument alike.
The buses in 9.2
A bus maps a command or query class to its handler. PrestaShop exposes two services with the same interface,
PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface and its single method handle($message).
| Service id | What it is |
|---|---|
|
A private service of class |
|
A public alias of the service above, for commands |
|
A public service defined with the command bus as its parent, so the same class, for queries |
The distinction between the command bus and the query bus is therefore conventional: in 9.2.0 both services wrap the same
Symfony Messenger bus, messenger.bus.default, configured in app/config/messenger.yml with a single middleware,
PrestaShopBundle\CommandBus\Middleware\CommandRegisterMiddleware, which records every executed command in an
ExecutedCommandRegistry (it feeds the CQRS panel of the Symfony debug toolbar and profiler). The Messenger defaults apply:
allow_no_handlers is false and allow_no_senders is true, there are no transports and no routing, so messages are
handled synchronously in the same process.
php bin/console debug:config framework messenger
php bin/console debug:messenger
debug:messenger is the most useful command for a module developer: it lists every message class the bus can dispatch
and the handler service that receives it, including the handlers of installed modules. On the checked installation it
listed 517 handler entries in total, among them the Symfony ones (RunProcessMessage, SendEmailMessage) next to the
core and module handlers.
MessengerCommandBus::handle() delegates to Messenger’s HandleTrait, which returns the handler’s result, and it unwraps
HandlerFailedException: when a handler throws, the caller receives the handler’s original exception (the domain
exception), not Messenger’s wrapper. The module example below relies on it, and the console command catches its own
RoastDateException type outside the bus. A message that no handler accepts raises Messenger’s own exception; in the
checked installation, handling a stdClass produced a NoHandlerForMessageException from the Symfony Messenger
component with the message No handler for message "stdClass"., and that exception is not wrapped. Symfony’s HandleTrait, which the
bus uses to return the handler’s result, expects exactly one handler and raises a LogicException when a message was
handled several times (Symfony behaviour that was not exercised here), so keep one handler per message.
Before 9.0 PrestaShop implemented the bus with the Tactician library and a league/tactician-bundle configuration.
Tactician was removed in 9.0 together with Guzzle and SwiftMailer, and the 9.2.0 composer.lock contains no Tactician
package. The developer documentation page on CQRS still says the bus is implemented with Tactician in its section on the
buses; that sentence describes 8.x and older, and the page’s code examples are otherwise current.
One call, end to end
The sequence below follows the Pocket Coffee example from a console command (a controller, a form data handler or an API operation looks the same from the bus onwards).
Inventory: prestashop:list:commands-and-queries
The console lists every command and query that has a registered handler, which turns the codebase into a catalogue of what the shop can do.
php bin/console prestashop:list:commands-and-queries
php bin/console prestashop:list:commands-and-queries --domain=Tax --domain=Currency --format=simple
php bin/console prestashop:list:commands-and-queries --hasApiEndpoint=1
The options are --domain (-d, repeatable, an exact namespace segment such as Currency), --format (-f, regular or
simple) and --hasApiEndpoint (1 to keep only the operations that the Admin API exposes, 0 for the others). The
regular format prints, for each entry, a counter, Class:, Type: (Command or Query), an API: line with the HTTP
operation when the Admin API exposes it, and the first line of the class docblock; the simple format prints the class
name followed by OK or NOT OK according to whether an API endpoint exists.
On the checked installation the core contributed 506 entries, 371 commands and 135 queries, spread over 58 domains (the
largest are Product with 76, Order with 26, Customer with 20 and Cart with 19), and 137 of them were exposed through the
Admin API. The listing treats an entry as a Command when its fully qualified class name contains \Command\ and as a Query
otherwise; the type is therefore a naming convention of the namespace, not a property of the class, and a command class
placed outside a Command namespace is reported as a Query.
A module’s classes appear as soon as its handlers are registered: after installing the example module below, the listing
included PocketCoffee\PcfbCqrs\Command\SetRoastDateCommand (Command) and PocketCoffee\PcfbCqrs\Query\GetRoastDate
(Query), and --domain=PocketCoffee listed exactly those two. Use the first docblock line of every command and query,
because it is what this command shows.
Build your own: the Pocket Coffee roast date
The example is a complete module, pcfbcqrs (technical name in lowercase letters and digits, as the module rules
require), with this layout. Every folder also contains the standard index.php redirect file, which is omitted from the
listings.
pcfbcqrs/
├── composer.json
├── pcfbcqrs.php
├── config/services.yml
└── src/
├── Command/SetRoastDateCommand.php
├── CommandHandler/SetRoastDateHandler.php
├── Console/SetRoastDateConsoleCommand.php
├── Exception/{RoastDateException,RoastDateConstraintException,RoastDateNotFoundException,CannotSaveRoastDateException}.php
├── Query/GetRoastDate.php
├── QueryHandler/GetRoastDateHandler.php
├── QueryResult/RoastDateInfo.php
├── Repository/RoastDateRepository.php
└── ValueObject/RoastDate.php
Composer and the module class
The module needs a Composer autoloader because its classes are namespaced; the shop image has no Composer, so the
autoloader is generated on the development machine and shipped in the module’s vendor/ folder.
{
"name": "pocketcoffee/pcfbcqrs",
"description": "Pocket Coffee roast date, CQRS example",
"type": "prestashop-module",
"license": "AFL-3.0",
"require": { "php": ">=8.1" },
"autoload": { "psr-4": { "PocketCoffee\\PcfbCqrs\\": "src/" } },
"config": { "prepend-autoloader": false }
}
The main class creates and drops the module’s own table. The repository uses the standard rules for SQL in modules: a
prefix from _DB_PREFIX_, integer casts, pSQL() for strings, a table of its own rather than a column added to a core
table, and IF NOT EXISTS and IF EXISTS so that install and uninstall can be repeated.
<?php
/**
* Pocket Coffee roast date (CQRS example).
*/
declare(strict_types=1);
if (!defined('_PS_VERSION_')) {
exit;
}
if (file_exists(__DIR__ . '/vendor/autoload.php')) {
require_once __DIR__ . '/vendor/autoload.php';
}
class Pcfbcqrs extends Module
{
public function __construct()
{
$this->name = 'pcfbcqrs';
$this->tab = 'administration';
$this->version = '1.0.0';
$this->author = 'Pocket Coffee';
$this->need_instance = 0;
$this->ps_versions_compliancy = ['min' => '9.0.0', 'max' => '9.99.99'];
$this->bootstrap = true;
parent::__construct();
$this->displayName = $this->trans('Pocket Coffee roast date', [], 'Modules.Pcfbcqrs.Admin');
$this->description = $this->trans('Stores the roast date of each product through CQRS commands.', [], 'Modules.Pcfbcqrs.Admin');
}
public function isUsingNewTranslationSystem(): bool
{
return true;
}
public function install(): bool
{
return parent::install() && $this->createTable();
}
public function uninstall(): bool
{
return $this->dropTable() && parent::uninstall();
}
private function createTable(): bool
{
return Db::getInstance()->execute(
'CREATE TABLE IF NOT EXISTS `' . _DB_PREFIX_ . 'pcfbcqrs_roast_date` ('
. '`id_product` INT UNSIGNED NOT NULL,'
. '`roast_date` DATE NOT NULL,'
. 'PRIMARY KEY (`id_product`)'
. ') ENGINE=' . _MYSQL_ENGINE_ . ' DEFAULT CHARSET=utf8mb4'
);
}
private function dropTable(): bool
{
return Db::getInstance()->execute('DROP TABLE IF EXISTS `' . _DB_PREFIX_ . 'pcfbcqrs_roast_date`');
}
}
Value object and exceptions
The value object validates a date string and refuses a future roast date; the exceptions carry a code so that a caller
can react to the kind of failure without parsing messages. All of them extend the core’s DomainException.
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\ValueObject;
use PocketCoffee\PcfbCqrs\Exception\RoastDateConstraintException;
/**
* Immutable, self-validating roast date. Equality is based on the value, not on identity.
*/
final class RoastDate
{
private readonly \DateTimeImmutable $date;
public function __construct(string $value)
{
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
if (false === $date || $date->format('Y-m-d') !== $value) {
throw new RoastDateConstraintException(sprintf('"%s" is not a valid Y-m-d date.', $value), RoastDateConstraintException::INVALID_FORMAT);
}
if ($date > new \DateTimeImmutable('today')) {
throw new RoastDateConstraintException(sprintf('Roast date %s is in the future.', $value), RoastDateConstraintException::IN_THE_FUTURE);
}
$this->date = $date;
}
public function format(): string
{
return $this->date->format('Y-m-d');
}
public function equals(self $other): bool
{
return $this->format() === $other->format();
}
}
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\Exception;
use PrestaShop\PrestaShop\Core\Domain\Exception\DomainException;
/**
* Base class of every exception thrown by the roast date domain.
*/
class RoastDateException extends DomainException
{
}
class RoastDateConstraintException extends RoastDateException
{
public const INVALID_FORMAT = 1;
public const IN_THE_FUTURE = 2;
public const INVALID_PRODUCT_ID = 3;
}
class RoastDateNotFoundException extends RoastDateException
{
}
class CannotSaveRoastDateException extends RoastDateException
{
}
The four exception classes are shown together to save space; PSR-4 requires one class per file, so in the module each is
its own file under src/Exception.
Command, handler and repository
The command validates its own input and converts the date string into the value object, so every instance in the system
is valid. It sits in a Command namespace so that the inventory classifies it correctly.
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\Command;
use PocketCoffee\PcfbCqrs\Exception\RoastDateConstraintException;
use PocketCoffee\PcfbCqrs\ValueObject\RoastDate;
/**
* Sets the roast date of a product. A command describes one action; it does not perform it.
*/
final class SetRoastDateCommand
{
private readonly RoastDate $roastDate;
/**
* @param int $productId
* @param string $roastDate date as Y-m-d (primitive input, converted to a value object here)
*/
public function __construct(private readonly int $productId, string $roastDate)
{
if ($productId <= 0) {
throw new RoastDateConstraintException(sprintf('Invalid product id %d.', $productId), RoastDateConstraintException::INVALID_PRODUCT_ID);
}
$this->roastDate = new RoastDate($roastDate);
}
public function getProductId(): int
{
return $this->productId;
}
public function getRoastDate(): RoastDate
{
return $this->roastDate;
}
}
The handler carries the #[AsCommandHandler] attribute, which is the whole registration: a compiler pass of the
core reads it, guesses the handled message from the type of the single parameter of the handle method and tags the
service for Messenger. It orchestrates the repository and returns nothing.
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\CommandHandler;
use PocketCoffee\PcfbCqrs\Command\SetRoastDateCommand;
use PocketCoffee\PcfbCqrs\Repository\RoastDateRepository;
use PrestaShop\PrestaShop\Core\CommandBus\Attributes\AsCommandHandler;
/**
* Handlers orchestrate services and return nothing on success.
*/
#[AsCommandHandler]
final class SetRoastDateHandler
{
public function __construct(private readonly RoastDateRepository $repository)
{
}
public function handle(SetRoastDateCommand $command): void
{
$this->repository->assertProductExists($command->getProductId());
$this->repository->save($command->getProductId(), $command->getRoastDate());
}
}
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\Repository;
use PocketCoffee\PcfbCqrs\Exception\CannotSaveRoastDateException;
use PocketCoffee\PcfbCqrs\Exception\RoastDateNotFoundException;
use PocketCoffee\PcfbCqrs\ValueObject\RoastDate;
/**
* Domain service: the only class that talks to the database for roast dates.
*/
class RoastDateRepository
{
private const TABLE = 'pcfbcqrs_roast_date';
public function assertProductExists(int $productId): void
{
if (!\Product::existsInDatabase($productId, 'product')) {
throw new RoastDateNotFoundException(sprintf('Product %d does not exist.', $productId));
}
}
public function save(int $productId, RoastDate $roastDate): void
{
$saved = \Db::getInstance()->execute(
'REPLACE INTO `' . _DB_PREFIX_ . self::TABLE . '` (`id_product`, `roast_date`) VALUES ('
. (int) $productId . ", '" . pSQL($roastDate->format()) . "')"
);
if (!$saved) {
throw new CannotSaveRoastDateException(sprintf('Could not save the roast date of product %d.', $productId));
}
}
public function get(int $productId): RoastDate
{
$value = \Db::getInstance()->getValue(
'SELECT `roast_date` FROM `' . _DB_PREFIX_ . self::TABLE . '` WHERE `id_product` = ' . (int) $productId
);
if (false === $value || null === $value) {
throw new RoastDateNotFoundException(sprintf('Product %d has no roast date.', $productId));
}
return new RoastDate((string) $value);
}
}
Query, result and query handler
The query is the mirror image. Its result is a DTO that carries the answer in full, including the derived number of days since the roast, so the badge needs no further computation.
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\Query;
final class GetRoastDate
{
public function __construct(private readonly int $productId)
{
}
public function getProductId(): int
{
return $this->productId;
}
}
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\QueryResult;
/**
* DTO returned by the query handler: typed, complete and free of ObjectModel internals.
*/
final class RoastDateInfo
{
public function __construct(
private readonly int $productId,
private readonly string $roastDate,
private readonly int $daysSinceRoast,
) {
}
public function getProductId(): int
{
return $this->productId;
}
public function getRoastDate(): string
{
return $this->roastDate;
}
public function getDaysSinceRoast(): int
{
return $this->daysSinceRoast;
}
}
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\QueryHandler;
use PocketCoffee\PcfbCqrs\Query\GetRoastDate;
use PocketCoffee\PcfbCqrs\QueryResult\RoastDateInfo;
use PocketCoffee\PcfbCqrs\Repository\RoastDateRepository;
use PrestaShop\PrestaShop\Core\CommandBus\Attributes\AsQueryHandler;
#[AsQueryHandler]
final class GetRoastDateHandler
{
public function __construct(private readonly RoastDateRepository $repository)
{
}
public function handle(GetRoastDate $query): RoastDateInfo
{
$roastDate = $this->repository->get($query->getProductId());
$days = (int) (new \DateTimeImmutable('today'))->diff(new \DateTimeImmutable($roastDate->format()))->days;
return new RoastDateInfo($query->getProductId(), $roastDate->format(), $days);
}
}
Service configuration and a console entry point
The service file registers the handlers, the repository and the console command by namespace, and excludes the plain
objects (commands, queries, results, value objects, exceptions) and every index.php, which would otherwise be loaded
as a service class and redirect the shop. The handlers need no explicit definition: autowire injects the repository
and autoconfigure turns the attribute into the Messenger tag. The console command receives the two buses by name.
services:
_defaults:
public: false
autowire: true
autoconfigure: true
# Domain services, handlers and the console command are registered by namespace.
# Commands, queries, results, value objects and exceptions are plain objects, not services.
PocketCoffee\PcfbCqrs\:
resource: '../src/*'
exclude:
- '../src/index.php'
- '../src/*/index.php'
- '../src/{Command,Query,QueryResult,ValueObject,Exception}'
# The two buses are the same MessengerCommandBus class registered under two ids.
PocketCoffee\PcfbCqrs\Console\SetRoastDateConsoleCommand:
arguments:
$commandBus: '@prestashop.core.command_bus'
$queryBus: '@prestashop.core.query_bus'
The console command is the smallest caller: it builds the command, sends it, builds the query, sends it and prints the DTO, and it catches only the domain’s own exception type.
<?php
declare(strict_types=1);
namespace PocketCoffee\PcfbCqrs\Console;
use PocketCoffee\PcfbCqrs\Command\SetRoastDateCommand;
use PocketCoffee\PcfbCqrs\Exception\RoastDateException;
use PocketCoffee\PcfbCqrs\Query\GetRoastDate;
use PocketCoffee\PcfbCqrs\QueryResult\RoastDateInfo;
use PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
#[AsCommand(name: 'pcfbcqrs:set-roast-date', description: 'Sets a product roast date through the CQRS buses and reads it back')]
final class SetRoastDateConsoleCommand extends Command
{
public function __construct(
private readonly CommandBusInterface $commandBus,
private readonly CommandBusInterface $queryBus,
) {
parent::__construct();
}
protected function configure(): void
{
$this
->addArgument('product-id', InputArgument::REQUIRED, 'Product id')
->addArgument('roast-date', InputArgument::REQUIRED, 'Roast date as Y-m-d');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
try {
$this->commandBus->handle(new SetRoastDateCommand((int) $input->getArgument('product-id'), (string) $input->getArgument('roast-date')));
/** @var RoastDateInfo $info */
$info = $this->queryBus->handle(new GetRoastDate((int) $input->getArgument('product-id')));
} catch (RoastDateException $e) {
$output->writeln(sprintf('<error>%s: %s (code %d)</error>', (new \ReflectionClass($e))->getShortName(), $e->getMessage(), $e->getCode()));
return Command::FAILURE;
}
$output->writeln(sprintf('Product %d roasted on %s (%d days ago)', $info->getProductId(), $info->getRoastDate(), $info->getDaysSinceRoast()));
return Command::SUCCESS;
}
}
Install and run
The module was copied into modules/pcfbcqrs, its autoloader was generated with composer dump-autoload -o in a
separate Composer container, and it was installed with the console. The runs below are the real outputs, with today’s date
2026-10-27 and product 1 of the demo data.
php bin/console prestashop:module install pcfbcqrs
# Install action on module pcfbcqrs succeeded.
php bin/console pcfbcqrs:set-roast-date 1 2026-10-20
# Product 1 roasted on 2026-10-20 (7 days ago)
php bin/console pcfbcqrs:set-roast-date 1 2027-01-01
# RoastDateConstraintException: Roast date 2027-01-01 is in the future. (code 2)
php bin/console pcfbcqrs:set-roast-date 1 20-10-2026
# RoastDateConstraintException: "20-10-2026" is not a valid Y-m-d date. (code 1)
php bin/console pcfbcqrs:set-roast-date 99999 2026-10-20
# RoastDateNotFoundException: Product 99999 does not exist. (code 0)
php bin/console pcfbcqrs:set-roast-date 0 2026-10-20
# RoastDateConstraintException: Invalid product id 0. (code 3)
The value-object failures (codes 1 and 2) and the id failure (code 3) are thrown while the command is being constructed,
before the bus is involved; the unknown product (code 0) is thrown inside the handler and reaches the console command
through the bus unwrapped, which is the behaviour of MessengerCommandBus described above. The handler was confirmed in the
container with debug:container for the handler class (tag messenger.message_handler, method handle, handled class
SetRoastDateCommand) and in debug:messenger, which listed SetRoastDateCommand handled by SetRoastDateHandler and
GetRoastDate handled by GetRoastDateHandler.
The sources were also checked with the tools of the module pages. php -l reported no syntax errors, lint:yaml accepted
config/services.yml, and the PrestaShop coding standard run through PHP CS Fixer 3.95.27 was applied to the sources (the
listings above are the formatted result). PHPStan 2.3.3 at level 6 found no problem in the use of Core and Symfony
types; its only findings were the legacy classes (Module, Db, Product, Context) and constants, which PrestaShop
generates at runtime and which a plain static analysis cannot discover without extra stubs, so a real module project
needs a stub or bootstrap configuration for them.
Handler registration, in detail
A new handler that never runs is almost always a registration mistake. The mechanism in 9.2 has four parts and each can be checked.
-
The class carries
#[AsCommandHandler]or#[AsQueryHandler]. Both attributes have one argument,method, defaulthandle; the attribute is for classes only. -
The handler method has exactly one parameter, and it has a class type. The compiler pass of the core takes that type as the handled message and raises an error at container compile time when the count of parameters is not one or the type is not a plain class name.
-
The class is a service with
autoconfigure: true. Autoconfiguration is what converts the attribute into themessenger.message_handlertag; a handler defined without it is silently never tagged. -
The container has been rebuilt:
php bin/console cache:clearafter adding a handler or editingservices.yml, and the module must be installed and enabled, because the container loads only the services of active modules.
debug:container <HandlerClass> shows the tag and the handled class, and debug:messenger shows the final routing. The
core’s own handlers are defined the same way (the tax creation handler is a service called
prestashop.adapter.tax.command_handler.add_tax_handler that carries the same attribute), and its handler interface in
Core\Domain is what a controller type-hints.
Two pitfalls are specific to modules. First, the services of a module are loaded from config/services.yml (or, from 9.2,
the first existing of services.php, services-9.2.yml, services-9.yml and services.yml), and the back-office
container is the one that holds the buses. The front-office container is a lighter one: on the checked installation,
asking the front container for prestashop.core.command_bus or prestashop.core.query_bus answered that the service does
not exist (as it did for router and translator, while doctrine.orm.entity_manager was available), and a module’s
handler services are not loaded into it. A front-office hook that needs data owned by a handler must therefore read it with a plain class that it instantiates itself (the repository, for
instance) or from a module front controller, not through the buses; the architecture page
explains the two containers. Second, the CLI note of the
developer documentation applies: legacy classes used inside a console command may need an initialized context. The
roast-date handler uses Product::existsInDatabase and Db successfully from the console, but a handler that depends on
the employee, the cart or the Smarty context needs the context helper described in the console documentation.
Values and DTOs in the core
The core’s own value objects show what to model. ExchangeRate refuses a rate of zero or less with a currency constraint
exception, so a service that receives an ExchangeRate needs no assertion of its own; TaxId wraps a positive integer;
ShippingMethod and OutOfRangeBehavior wrap the small set of allowed integers of a carrier (BY_WEIGHT 1, BY_PRICE 2;
USE_HIGHEST_RANGE 0, DISABLED 1) and throw when given anything else. On the output side, EditableTax carries the
tax id (as a value object), the localized names, the rate and the active flag, and EditableCustomer does the same for
a customer: a caller never has to ask whether a key exists. Both kinds are immutable, which makes them safe to cache and
to pass between layers.
The domain exception hierarchy follows the same pattern. Each domain has a base exception (CategoryException, TaxException)
that extends PrestaShop\PrestaShop\Core\Domain\Exception\DomainException, and specific subclasses such as
CannotAddCategoryException or CategoryNotFoundException that a handler throws. A handler that wraps a legacy
PrestaShopException translates it into a domain exception, so the upper layers never catch legacy types. This is also
what lets the Admin API map exceptions to HTTP statuses, as shown next.
The same classes behind the Admin API
An Admin API resource is a PHP class with the #[ApiResource] attribute whose operations name a command or a query. The
Tax resource of the API resources module (ps_apiresources, 0.9.0 in 9.2.0) shows the pattern, reduced here to the
creation operation.
#[ApiResource(
operations: [
new CQRSCreate(
uriTemplate: '/taxes',
validationContext: ['groups' => ['Default', 'Create']],
CQRSCommand: AddTaxCommand::class,
scopes: ['tax_write'],
CQRSCommandMapping: self::COMMAND_MAPPING,
),
new CQRSGet(
uriTemplate: '/taxes/{taxId}',
CQRSQuery: GetTaxForEditing::class,
scopes: ['tax_read'],
CQRSQueryMapping: self::QUERY_MAPPING,
),
],
exceptionToStatus: [TaxNotFoundException::class => Response::HTTP_NOT_FOUND],
)]
class Tax
{
public const COMMAND_MAPPING = [
'[enabled]' => '[active]',
'[names]' => '[localizedNames]',
];
}
The resource class is the API’s data shape (its public properties are the JSON fields, with Symfony validation
constraints), the mapping arrays rename API fields to the constructor arguments of the command and back to the fields of
the query result, scopes ties the operation to an OAuth scope, and exceptionToStatus turns a domain exception into an
HTTP status. The serializer builds the command from the request body, sends it to the command bus and, for a creation,
reads the resource back with the query. This is why the earlier POST /admin-api/taxes call, executed against the checked
installation, created a tax through AddTaxCommand and its handler (AddTaxHandler, which validates the Tax
ObjectModel and saves it) and answered {"taxId":3}, the id coming back as the handler’s value-object result, and why a
request without a name in the default language is refused before any handler runs. A resource for the roast date follows
the same shape with SetRoastDateCommand and GetRoastDate; the Admin API page shows how a module registers it.
In controllers and forms
Back-office controllers and form data handlers receive the buses by injection and never create an ObjectModel themselves.
Identifiable-object forms (the create and edit forms of the grid pages) pair a data provider that sends a query to fill
the form with a data handler that sends a command on submit, so a migrated page contains no persistence code. The same
holds for grid actions such as bulk delete: the controller sends one command per selected id, or a bulk command, and
handles the typed exceptions to show the right flash message. The developer documentation walks through the contact form
as the reference example, and the grid and forms page of this section shows how the pieces connect.
Pitfalls and version notes
-
Namespace decides the listing type. An entry is a Command only if its class name contains
\Command\. -
Handlers must be registered in the active module’s container. A disabled module’s handlers are not loaded; a handler of an installed but disabled module will not answer.
-
Return values. The core’s rule is that command handlers return nothing, except creations, which return an id value object (
AddTaxHandlerreturns aTaxId).HandleTraitreturns whatever the handler returns, so a caller can use the id, but a command that "returns data" is a smell: prefer a query. -
No handler or two handlers. Zero handlers raise
NoHandlerForMessageException, unwrapped. Keep one handler per message. -
The two buses are one bus. In 9.2.0, sending a command to the query bus works technically; the separation is a convention that the debug toolbar and the inventory do not enforce.
-
Contexts in the console. The split context services of 9.x (
ShopContext,LanguageContext,CurrencyContext,EmployeeContext) are built from the HTTP request; in a console command, requestingShopContextfailed in the checked installation with a "shop constraint has not been defined" error, so a console caller sets the context it needs (or uses the legacyContext) before dispatching. -
Removed and renamed. Tactician is gone since 9.0;
ean13becamegtinin CQRS return types in 9.0. -
Environment note.
lint:containerfailed in the checked development environment with an unrelated error on the log handler configuration, so usedebug:containeranddebug:messengerto verify service wiring there.
Legacy guidance from older books
PrestaShop Recipes (2017) and the other 1.x books call ObjectModel classes directly from module code and from
legacy controllers: new Product($id), setting properties and calling save(). That still works in 9.2 (the example’s
repository uses Product::existsInDatabase and Db), but the books predate commands, queries and the Admin API. For new
module code that changes shop data owned by the core, send the core’s own commands (EditTaxCommand, SetCarrierRangesCommand
and the rest of the inventory) instead of editing core tables or objects yourself, because a command also runs the
validation and the hooks that the domain defines.
Pocket Coffee thread
The roast-date domain built here stays small on purpose: one command, one query, one value object and a table of its own.
The next steps of the thread use it: the module pages add a hook that renders the badge from RoastDateInfo, a grid
column and an edit field in the product page, an Admin API resource that exposes SetRoastDateCommand and
GetRoastDate, and a front controller that shows the badge to customers without touching the back-office container.
References
-
PrestaShop devdocs: Changes in 9.0 (Messenger replaces Tactician)
-
Arnaldo Pérez Castaño, PrestaShop Recipes, Apress, 2017 (PrestaShop 1.6; read as legacy guidance)