Support for PHP 8.1 was dropped, so you have to upgrade to at least PHP 8.2.
Using the RouteBuilder
is now mandatory. The validation has been moved to addCommandRoute
and addQueryRoute
. The RouteParameters
have been removed in favor of parameters directly for the functions.
When not using it yet, you have to replace your usages of RoutePayload::generate
(which has been removed) with the RouteBuilder
functions.
Before:
$routes->add(
'api_news_create_news_article_command',
'/api/news/create-news-article-command',
)
->controller([CommandController::class, 'handle'])
->methods([Request::METHOD_POST])
->defaults([
'routePayload' => RoutePayload::generate(
dtoClass: CreateProductNewsArticleCommand::class,
handlerClass: CreateProductNewsArticleCommandHandler::class,
),
]);
After:
RouteBuilder::addCommandRoute(
$routes,
path: '/api/news/create-news-article-command',
dtoClass: CreateProductNewsArticleCommand::class,
handlerClass: CreateProductNewsArticleCommandHandler::class,
);
The route name generation changed. When the name must be something specific (because it's used as a reference), it must be set as a parameter for addCommandRoute
and addQueryRoute
. The name generation might change in future versions. If the name isn't used anywhere, you nether need to nor should set it.
Only if a name of a route is used as a reference, add it as a parameter to addCommandRoute
and addQueryRoute
.
Before:
RouteBuilder::addCommandRoute(
$routes,
path: '/api/news/create-news-article-command',
dtoClass: CreateProductNewsArticleCommand::class,
handlerClass: CreateProductNewsArticleCommandHandler::class,
);
After:
RouteBuilder::addCommandRoute(
$routes,
path: '/api/news/create-news-article-command',
dtoClass: CreateProductNewsArticleCommand::class,
handlerClass: CreateProductNewsArticleCommandHandler::class,
name: 'api_news_create_news_article_command',
);
The class RoutePayload
and the exceptions have been moved to DigitalCraftsman\CQRS\Routing
. Adapt your imports accordingly. You might replace the usages of RoutePayload
entirely through using the new RouteBuilder
(See routing).
The DTO Configuration
was renamed to RoutePayload
and moved from DigitalCraftsman\CQRS\DTO
to DigitalCraftsman\CQRS\ValueObject
. The named constructor was also renamed from routePayload
to generate
.
The method generate
now validates the input (through the constructor) and doesn't just rely on Psalm for the validation. The validation is done on warmup of the cache for all routes and for the specific route when triggered. The bundle configuration is validated now as well.
Before:
use DigitalCraftsman\CQRS\DTO\Configuration;
'routePayload' => Configuration::routePayload(
...
),
After:
use DigitalCraftsman\CQRS\Routing\RoutePayload;
'routePayload' => RoutePayload::generatePayload(
...
),
New method for RequestValidatorInterface
, RequestDataTransformerInterface
, DTOValidatorInterface
and HandlerWrapperInterface
The interfaces have been extended with areParametersValid(mixed $parameters): bool
which validates the parameters of the configuration on cache warmup. All request validators, request data transformers, DTO validators and handler wrappers therefore need to implement this new method.
For example the SilentExceptionWrapper
validates whether the parameters are an array of exceptions.
/** @param array<array-key, class-string<\Throwable>> $parameters */
public static function areParametersValid(mixed $parameters): bool
{
if (!is_array($parameters)) {
return false;
}
foreach ($parameters as $exceptionClass) {
if (!class_exists($exceptionClass)) {
return false;
}
$reflectionClass = new \ReflectionClass($exceptionClass);
if (!$reflectionClass->implementsInterface(\Throwable::class)) {
return false;
}
}
return true;
}
When there are no parameters needed, the validation can look as simple as this:
/** @param null $parameters */
public static function areParametersValid(mixed $parameters): bool
{
return $parameters === null;
}
The HandlerWrapperConfiguration
object was dropped in favor of using the class name as key and supplying the parameters directly as value.
Before:
'routePayload' => Configuration::routePayload(
handlerWrapperConfigurations: [
new HandlerWrapperConfiguration(ConnectionTransactionWrapper::class),
new HandlerWrapperConfiguration(
handlerWrapperClass: SilentExceptionWrapper::class,
parameters: [
EmailAddressDidNotChange::class,
],
),
],
),
After:
'routePayload' => Configuration::routePayload(
handlerWrapperClasses: [
ConnectionTransactionWrapper::class => null,
SilentExceptionWrapper::class => [
EmailAddressDidNotChange::class,
],
],
),
The bundle configuration also needs to be updated to set the classes as key in the configuration of the default handler wrappers and use parameters as value. Use null
when no parameter is needed. This change enabled the default handler wrappers to use parameters.
Before:
return static function (CqrsConfig $cqrsConfig) {
$cqrsConfig->queryController()
->defaultHandlerWrapperClasses([
ConnectionTransactionWrapper::class,
]);
$cqrsConfig->commandController()
->defaultHandlerWrapperClasses([
ConnectionTransactionWrapper::class,
]);
After:
return static function (CqrsConfig $cqrsConfig) {
$cqrsConfig->queryController()
->defaultHandlerWrapperClasses([
ConnectionTransactionWrapper::class => null,
]);
$cqrsConfig->commandController()
->defaultHandlerWrapperClasses([
ConnectionTransactionWrapper::class => null,
]);
It was identical with the one that can be defined in the Symfony framework configuration.
Remove it from the CQRS configuration and move your context into the framework.yaml
.
framework:
serializer:
default_context:
# Your context, for example:
skip_null_values: true
preserve_empty_objects: true
Support for PHP 8.0 was dropped, so you have to upgrade to at least PHP 8.1.
The method was renamed from transformDTOData
to transformRequestData
and the parameter $dtoData
was renamed to $requestData
.
Before:
final class YourCustomDTODataTransformer implements DTODataTransformerInterface
{
/** @param class-string $dtoClass */
public function transformDTOData(string $dtoClass, array $dtoData): object
{
...
}
}
After:
final class YourCustomRequestDataTransformer implements RequestDataTransformerInterface
{
/** @param class-string $dtoClass */
public function transformRequestData(string $dtoClass, array $requestData): object
{
...
}
}
The parameter $dtoData
was renamed to $requestData
.
Before:
final class YourCustomDTOConstructor implements DTOConstructorInterface
{
/**
* @return Command|Query
*
* @psalm-template T of Command|Query
* @psalm-param class-string<T> $dtoClass
* @psalm-return T
*/
public function constructDTO(array $dtoData, string $dtoClass): object
{
...
}
}
After:
final class YourCustomDTOConstructor implements DTOConstructorInterface
{
/**
* @psalm-template T of Command|Query
* @psalm-param class-string<T> $dtoClass
* @psalm-return T
*/
public function constructDTO(array $requestData, string $dtoClass): Command|Query
{
...
}
}
The DTOConstructorInterface
now returns Command|Query
instead of object
. You need to adapt the return types in your implementations.
Before:
final class YourCustomDTOConstructor implements DTOConstructorInterface
{
/**
* @return Command|Query
*
* @psalm-template T of Command|Query
* @psalm-param class-string<T> $dtoClass
* @psalm-return T
*/
public function constructDTO(array $dtoData, string $dtoClass): object
{
...
}
}
After:
final class YourCustomDTOConstructor implements DTOConstructorInterface
{
/**
* @psalm-template T of Command|Query
* @psalm-param class-string<T> $dtoClass
* @psalm-return T
*/
public function constructDTO(array $dtoData, string $dtoClass): Command|Query
{
...
}
}
The HandlerWrapperInterface
lost "finally logic". It turns out that there are no cases that can't be handled with just then
and catch
and on the other hand, there might be issues when multiple handler wrappers are used and can't be matched with the priority, because finally
was always triggered last. The methods finally
and finallyPriority
have been removed from the interface. The logic of implementations must be adapted in a way that the logic is moved from finally
into then
and catch
.
Before:
final class YourCustomHandlerWrapper implements HandlerWrapperInterface
{
private ?LockInterface $lock = null;
public function __construct(
private LockService $lockService,
) {
}
/**
* Only one request per user is handled at once.
*
* @param YourActionCommand $dto
*/
public function prepare(
Command|Query $dto,
Request $request,
mixed $parameters,
): void {
$lockPath = sprintf(
'your-action-%s',
(string) $dto->userId,
);
$this->lock = $this->lockService->createLock($lockPath);
$this->lock->acquire(true);
}
/** @param null $parameters */
public function catch(
Command|Query $dto,
Request $request,
mixed $parameters,
\Exception $exception,
): ?\Exception {
// Nothing to do
return $exception;
}
/** @param null $parameters */
public function then(
Command|Query $dto,
Request $request,
mixed $parameters,
): void {
// Nothing to do
}
/** @param null $parameters */
public function finally(
Command|Query $dto,
Request $request,
mixed $parameters,
): void {
if ($this->lock !== null) {
$this->lock->release();
}
}
// Priorities
public static function preparePriority(): int
{
return 200;
}
public static function catchPriority(): int
{
return 0;
}
public static function thenPriority(): int
{
return 0;
}
public static function finallyPriority(): int
{
return 0;
}
}
After:
final class YourCustomHandlerWrapper implements HandlerWrapperInterface
{
private ?LockInterface $lock = null;
public function __construct(
private LockService $lockService,
) {
}
/**
* Only one request per user is handled at once.
*
* @param YourActionCommand $dto
*/
public function prepare(
Command|Query $dto,
Request $request,
mixed $parameters,
): void {
$lockPath = sprintf(
'your-action-%s',
(string) $dto->userId,
);
$this->lock = $this->lockService->createLock($lockPath);
$this->lock->acquire(true);
}
/** @param null $parameters */
public function catch(
Command|Query $dto,
Request $request,
mixed $parameters,
\Exception $exception,
): ?\Exception {
if ($this->lock !== null) {
$this->lock->release();
}
return $exception;
}
/** @param null $parameters */
public function then(
Command|Query $dto,
Request $request,
mixed $parameters,
): void {
if ($this->lock !== null) {
$this->lock->release();
}
}
// Priorities
public static function preparePriority(): int
{
return 200;
}
public static function catchPriority(): int
{
return 0;
}
public static function thenPriority(): int
{
return 0;
}
}
It turned out that the NullableAsOptionalPropertiesDTODataTransformer
isn't of any use, as the Symfony serializer in the supported range, already set's nullable properties to null
when they aren't supplied as data. Therefore, you just have to remove it from all routes you added it to.
Before:
$routes->add(
'api_your_domain_your_command',
'/api/your-domain/your-command',
)
->controller([CommandController::class, 'handle'])
->methods([Request::METHOD_POST])
->defaults([
'routePayload' => Configuration::routePayload(
dtoClass: YourCommand::class,
handlerClass: YourCommandHandler::class,
dtoDataTransformerClasses: [
NullableAsOptionalPropertiesDTODataTransformer::class,
],
),
]);
After:
$routes->add(
'api_your_domain_your_command',
'/api/your-domain/your-command',
)
->controller([CommandController::class, 'handle'])
->methods([Request::METHOD_POST])
->defaults([
'routePayload' => Configuration::routePayload(
dtoClass: YourCommand::class,
handlerClass: YourCommandHandler::class,
),
]);
The transformDTOData
method in the DTODataTransformerInterface
was extended with a new parameter string $dtoClass
. Add this new parameter in your implementations of the interface.
Before:
final class YourCustomDTODataTransformer implements DTODataTransformerInterface
{
public function transformDTOData(array $dtoData): array
{
...
}
}
After:
final class YourCustomDTODataTransformer implements DTODataTransformerInterface
{
public function transformDTOData(string $dtoClass, array $dtoData): array
{
...
}
}