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.

PrestaShop\PrestaShop\Core\Domain\<Domain>\Command in the core; your own namespace in a module, with \Command\ in the path (see the listing note below)

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.

PrestaShop\PrestaShop\Adapter\<Domain>\CommandHandler while it depends on legacy code; its interface lives in Core\Domain\<Domain>\CommandHandler

Query

Describes one read ("get the roast date of product 1"). Performs nothing, changes nothing.

Core\Domain\<Domain>\Query

Query handler

Executes one query and returns a typed result object, never an array and never an ObjectModel.

Adapter\<Domain>\QueryHandler

Query result (DTO)

A data transfer object with getters: the complete answer of one query (EditableTax, EditableCustomer). A caller can rely on every getter being filled, unlike an array that may or may not contain a key.

Core\Domain\<Domain>\QueryResult

Value object

A small immutable object defined by its value, not an identity, that validates itself on construction (TaxId, ExchangeRate, ShippingMethod). Two instances with the same value are equal.

Core\Domain\<Domain>\ValueObject

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.

Adapter\<Domain> (for example Repository, Validate, Update)

Domain exception

A typed exception specific to a domain (TaxNotFoundException, CannotAddCarrierException), all extending Core\Domain\Exception\DomainException. The upper layer decides how to show it.

Core\Domain\<Domain>\Exception

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

PrestaShop\PrestaShop\Core\CommandBus\CommandBusInterface

A private service of class PrestaShopBundle\CommandBus\MessengerCommandBus; autowiring a CommandBusInterface argument gives this one

prestashop.core.command_bus

A public alias of the service above, for commands

prestashop.core.query_bus

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

sequenceDiagram autonumber participant Caller as Console command or controller participant Bus as prestashop.core.command_bus participant Mid as CommandRegisterMiddleware participant H as SetRoastDateHandler participant Q as GetRoastDateHandler participant R as RoastDateRepository participant DB as Database Caller->>Caller: new SetRoastDateCommand(1, "2026-10-20") Note over Caller: the constructor validates the input and builds the RoastDate value object Caller->>Bus: handle(command) Bus->>Mid: Messenger middleware stack Mid->>Mid: register the executed command Mid->>H: handle(command) H->>R: assertProductExists(1) R->>DB: Product::existsInDatabase H->>R: save(1, roastDate) R->>DB: REPLACE INTO pcfbcqrs_roast_date H-->>Bus: void Bus-->>Caller: void Caller->>Bus: handle(new GetRoastDate(1)) on the query bus Bus->>Q: handle(query) Q-->>Caller: RoastDateInfo DTO Note over Caller,Q: a failure throws a typed domain exception, which the bus rethrows unwrapped

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.

  1. The class carries #[AsCommandHandler] or #[AsQueryHandler]. Both attributes have one argument, method, default handle; the attribute is for classes only.

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

  3. The class is a service with autoconfigure: true. Autoconfiguration is what converts the attribute into the messenger.message_handler tag; a handler defined without it is silently never tagged.

  4. The container has been rebuilt: php bin/console cache:clear after adding a handler or editing services.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 (AddTaxHandler returns a TaxId). HandleTrait returns 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, requesting ShopContext failed 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 legacy Context) before dispatching.

  • Removed and renamed. Tactician is gone since 9.0; ean13 became gtin in CQRS return types in 9.0.

  • Environment note. lint:container failed in the checked development environment with an unrelated error on the log handler configuration, so use debug:container and debug:messenger to 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.