Skip to content

Add DTO (Data Transfer Object) support to Queue plugin - #192

Open
skie wants to merge 7 commits into
cakephp:3.xfrom
skie:feature/dto-support
Open

Add DTO (Data Transfer Object) support to Queue plugin#192
skie wants to merge 7 commits into
cakephp:3.xfrom
skie:feature/dto-support

Conversation

@skie

@skie skie commented Aug 8, 2026

Copy link
Copy Markdown
Member

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 explicit dtoClass option (metadata for uniqueness hashing / debugging)
  • New 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 any dtoClass recorded at dispatch time as metadata only
  • 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 for dispatch — legacy array-only pushes produce byte-identical message bodies. On receive, getDto() throws when the expected class is missing or the payload cannot be hydrated; jobs that still accept legacy arrays can catch that or keep using getArgument()

Usage:

$order = new OrderDto(id: 7, customer: 'Acme Corp', items: [
    new OrderItemDto(sku: 'SKU-1', quantity: 2),
]);

QueueManager::push(ProcessOrderJob::class, $order);

// Or, array payload + optional dtoClass metadata:
QueueManager::push(ProcessOrderJob::class, $data, [
    'dtoClass' => OrderDto::class,
]);
class ProcessOrderJob implements JobInterface
{
    public function execute(Message $message): ?string
    {
        $order = $message->getDto(OrderDto::class);
        return Processor::ACK;
    }
}

Note: requires bumping cakephp/cakephp from ^5.1.0 to ^5.4 (needed for ResultSetFactory::hydrateDto() / DtoMapper), plus php from >=8.1 to >=8.2 to match. Since this raises the floor for every existing installation — not just DTO users — this should ship as 3.0.0 off a new 3.x branch rather than a 2.x minor/patch release, with the version bump called out explicitly in the changelog/release notes.

skie added 4 commits August 8, 2026 19:07
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
Comment thread src/QueueManager.php
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR updated to 3.x

Comment thread src/QueueManager.php
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR updated to 3.x

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional parameters with default values don't break compatibility though. Or are you thinking about the extension case?

Comment thread composer.json
Comment thread src/Dto/DtoManager.php Outdated
@LordSimal
LordSimal requested a review from dereuromark August 8, 2026 16:38
@LordSimal
LordSimal changed the base branch from 2.x to 3.x August 8, 2026 16:40
@LordSimal
LordSimal requested review from ADmad and markstory August 8, 2026 16:44
@skie
skie requested a review from LordSimal August 8, 2026 19:23
Comment thread composer.json
"enqueue/simple-client": "^0.10",
"psr/log": "^3.0"
"psr/log": "^3.0",
"ramsey/uuid": "^4.7.0"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this package required?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it was bump fix for lowest in github build

@ADmad ADmad Aug 9, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand, this dependency didn't exist earlier.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/en/jobs.md Outdated
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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How confident are folks around this not having any unserialize/remote code execution holes?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now we expect type from job code. body dtoClass remains metadata only; getDto() throws on failure.

Comment thread docs/en/jobs.md Outdated
```php
public function execute(Message $message): ?string
{
$order = $message->getDto(); // OrderDto, or null if no DTO was dispatched

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now getDto() throws on failure.

Comment thread docs/en/jobs.md

### 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:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a great design choice. 👏

Comment thread src/Job/Message.php Outdated

$dtoClass = $this->getDtoClass();
if ($dtoClass === null) {
return null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels like an error condition to me.

Comment thread src/Job/Message.php Outdated
*
* Returns `null` when the message was not dispatched with a DTO.
*/
public function getDto(): ?object

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the way you proposed.

Comment thread src/QueueManager.php
* 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@skie
skie requested review from ADmad and markstory August 10, 2026 11:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants