Add DTO (Data Transfer Object) support to Queue plugin - #192
Conversation
Allows job payloads to be dispatched and received as typed DTO objects instead of plain arrays, while maintaining full backward compatibility with existing array-based jobs. - `QueueManager::push()` now accepts a DTO object directly, or a plain array paired with an explicit `dtoClass` option - New `Message::getDto()` / `getDtoClass()` to hydrate the payload back into the DTO on the receiving side — `getArgument()` still returns the raw array unchanged - Two hydration styles, matching CakePHP 5.4's own DTO conventions (`#[RequestToDto]`, `SelectQuery::projectAs()`): constructor reflection (with nested DTOs and `#[CollectionOf]`), and a static `createFromArray()` factory - `shouldBeUnique` dedupe hashing now factors in `dtoClass`, so two different DTO types with coincidentally identical data are never treated as duplicates of each other - Fully backward compatible — legacy array-only pushes produce byte-identical message bodies; `getDto()` gracefully returns `null` (never throws) when no DTO was dispatched or the recorded `dtoClass` can no longer be autoloaded
| * string 'default' if empty. | ||
| */ | ||
| public static function push(string|array $className, array $data = [], array $options = []): void | ||
| public static function push(string|array $className, array|object $data = [], array $options = []): void |
There was a problem hiding this comment.
BC break here.
That would require a new major version if there is no other way of doing what you want to do with the existing type definitions.
| * coincidental structural match between unrelated DTOs does not collapse into one dedupe entry. | ||
| */ | ||
| public static function getUniqueId(string $class, string $method, array $data): string | ||
| public static function getUniqueId(string $class, string $method, array $data, ?string $dtoClass = null): string |
There was a problem hiding this comment.
Optional parameters with default values don't break compatibility though. Or are you thinking about the extension case?
| "enqueue/simple-client": "^0.10", | ||
| "psr/log": "^3.0" | ||
| "psr/log": "^3.0", | ||
| "ramsey/uuid": "^4.7.0" |
There was a problem hiding this comment.
it was bump fix for lowest in github build
There was a problem hiding this comment.
I don't understand, this dependency didn't exist earlier.
There was a problem hiding this comment.
ramsey/uuid is not used by the Queue plugin itself. It comes in transitively through enqueue/simple-client to enqueue/enqueue, which still allows ^3.5|^4. On --prefer-lowest that resolves to an old Ramsey that triggers PHP deprecations, and PHPUnit fails the run (especially subprocess worker tests).
After raising the plugin floor to PHP 8.2 / CakePHP 5.4, prefer-lowest runs on 8.2 and those vendor deprecations become hard CI failures. Bumping enqueue does not help today, because current enqueue 0.10.x still allows the old Ramsey range. Constraining ramsey/uuid to ^4.7 in this package is the practical way to keep prefer-lowest green until enqueue tightens its own requirement.
| QueueManager::push(ProcessOrderJob::class, $order); | ||
| ``` | ||
|
|
||
| The DTO is serialized into the same JSON-safe array that a plain array payload would produce (via `jsonSerialize()` when the DTO implements `JsonSerializable`, otherwise its public properties), and the DTO's class name travels alongside it so the job can hydrate it back. If you only have an array at the dispatch site but still want the job to receive a typed object, pass the target class via the `dtoClass` option instead: |
There was a problem hiding this comment.
How confident are folks around this not having any unserialize/remote code execution holes?
There was a problem hiding this comment.
Now we expect type from job code. body dtoClass remains metadata only; getDto() throws on failure.
| ```php | ||
| public function execute(Message $message): ?string | ||
| { | ||
| $order = $message->getDto(); // OrderDto, or null if no DTO was dispatched |
There was a problem hiding this comment.
Should this throw instead of return null? If the job is expecting a DTO, and one isn't there isn't that an error? If folks need backwards compatibility as they adopt DTOs, couldn't they use try/catch?
There was a problem hiding this comment.
Now getDto() throws on failure.
|
|
||
| ### Supported DTO classes | ||
|
|
||
| Hydration mirrors the DTO conventions used elsewhere in CakePHP (`#[RequestToDto]` for controllers, `SelectQuery::projectAs()` for the ORM), so the same DTO class can be reused across all three: |
There was a problem hiding this comment.
This is a great design choice. 👏
|
|
||
| $dtoClass = $this->getDtoClass(); | ||
| if ($dtoClass === null) { | ||
| return null; |
There was a problem hiding this comment.
This feels like an error condition to me.
| * | ||
| * Returns `null` when the message was not dispatched with a DTO. | ||
| */ | ||
| public function getDto(): ?object |
There was a problem hiding this comment.
How do I read the message body as a specific class? Supporting arbitrary classes is going to be dangerous. We need a way to let the user define what shape of data they're expecting without it coming from the message body.
If arbitrary classes can be deserialized, then we're weak to bad actors inserting 'poison jobs'. We've recently had security issues opened for the core redis cache being weak to a similar scenario, and I'd like to avoid that possibility here as well.
I think having a formal expected type, allows the typehints and usability of the method to be better as well.
$user = $this->getDto(UserCreateDto::class);Is very obvious, and it lets developers handle backwards compatibility and task parameter changes entirely in userland.
There was a problem hiding this comment.
Done in the way you proposed.
| * coincidental structural match between unrelated DTOs does not collapse into one dedupe entry. | ||
| */ | ||
| public static function getUniqueId(string $class, string $method, array $data): string | ||
| public static function getUniqueId(string $class, string $method, array $data, ?string $dtoClass = null): string |
There was a problem hiding this comment.
Optional parameters with default values don't break compatibility though. Or are you thinking about the extension case?
Hydrate only the type the job asks for so a tampered queue body cannot choose which class is instantiated. Throw on failure instead of returning null.
Allows job payloads to be dispatched and received as typed DTO objects instead of plain arrays, while maintaining full backward compatibility with existing array-based jobs.
Key Features:
QueueManager::push()now accepts a DTO object directly, or a plain array paired with an explicitdtoClassoption (metadata for uniqueness hashing / debugging)Message::getDto(ExpectedDto::class)hydrates the payload into the class the job asks for — the expected type comes from application code, not from the message body, so a tampered queue message cannot choose which class is instantiated.getArgument()still returns the raw array unchanged.getDtoClass()exposes anydtoClassrecorded at dispatch time as metadata only#[RequestToDto],SelectQuery::projectAs()): constructor reflection (with nested DTOs and#[CollectionOf]), and a staticcreateFromArray()factoryshouldBeUniquededupe hashing now factors indtoClass, so two different DTO types with coincidentally identical data are never treated as duplicates of each othergetDto()throws when the expected class is missing or the payload cannot be hydrated; jobs that still accept legacy arrays can catch that or keep usinggetArgument()Usage:
Note: requires bumping
cakephp/cakephpfrom^5.1.0to^5.4(needed forResultSetFactory::hydrateDto()/DtoMapper), plusphpfrom>=8.1to>=8.2to match. Since this raises the floor for every existing installation — not just DTO users — this should ship as3.0.0off a new3.xbranch rather than a2.xminor/patch release, with the version bump called out explicitly in the changelog/release notes.