Skip to content

Command Bus — Dispatch, Scheduling & Job Model

Reference for middag-io/middag-php-framework's command bus: dispatch API, handler resolution, declarative scheduling, the (core-owned) job aggregate, and serialization rules. Backs FW-008.

Dispatching a command

A command is a plain, final readonly class implementing CommandInterface; a handler is resolved for it and invoked through Bus/MessageBus:

php
final readonly class CreateItem implements CommandInterface
{
    public function __construct(
        public string $type,
        public array $metadata,
        public int $userId,
    ) {}
}

final class CreateItemHandler
{
    public function __construct(
        private ItemRepositoryInterface $repository,
    ) {}

    public function __invoke(CreateItem $command): Item { /* ... */ }
}

$bus->dispatch(new CreateItem(...));

dispatch() is the only call — there is no dispatch_async() and no handle(). Whether the command runs inline or gets routed to a transport (drained later by Bus/Command/CommandWorker) is decided by middleware/routing configuration, not by which method the caller invokes.

Dispatch API — design history

Three sources describe this API across the project's history; only the current code is normative:

SourceSync callAsync call
ADR-705 (legacy, normative)$bus->dispatch($command)$bus->dispatch_async($command)
ref-705 (legacy, contradicted the ADR)command_bus::handle($command) (static facade)command_bus::dispatch_async($command)
Bus/MessageBus.php (current)dispatch($command) — one surface for bothsame call; transport routing decides

MessageBus extends Symfony Messenger's own MessageBus and adds no framework-specific handle() or dispatch_async() method.

Handler resolution

LocatorResolves viaUse when
ConventionHandlersLocatorNaming convention: {Command}{Command}HandlerThe handler class name is directly derivable from the command class name.
AttributeHandlersLocator#[AsCommandHandler(command: SomeCommand::class)] on the handler classThe host's native naming doesn't fit the convention (e.g. a Moodle-style snake_case *_command_handler class), or the handler's __invoke() has no typed parameter to infer the message type from.

Both locators are @api-relevant extension points — a project picks whichever fits its naming, they are not alternatives to choose between once and discard.

Declarative scheduling

A schedule is a periodic trigger for a command, declared with an attribute:

php
#[Schedule(minute: '0', hour: '4')]
final readonly class CleanLogsCommand extends Command { /* ... */ }
FieldNotes
minute, hour, day, month, weekdayStandard cron notation.
'R' (any field)Accepted for hosts implementing random scheduling (the Moodle db/tasks.php pattern).
$exclusiveHost-neutral "do not run concurrently" hint; the adapter maps it onto its own locking primitive.

A host adapter converts the attribute into its own native scheduling primitive (Moodle's task-definition array, WordPress's wp_schedule_event payload). The reading/matching/running side of the DSL is implemented by Bus/Schedule/CronFieldMatcher.php, Bus/Schedule/ScheduleReader.php, and Bus/Schedule/ScheduleRunner.php.

Job aggregate (core-owned, not shipped in this framework)

This framework's OSS surface is transport-agnostic: Bus/MessageBus, Bus/Transport/InMemoryTransport (the OSS default transport), and Bus/Command/CommandWorker. Governed job observability — retry, correlation, dedup, full lifecycle persistence — is middag-io/core territory (see architecture.md, Pillar 3). The table below documents that core-owned aggregate for whoever implements or maintains that layer.

Lifecycle: pendingstartedcompleted, or failed (recorded as a job_attempt) → retried → back to pending with attempts incremented → failed permanently at max attempts.

FieldPurpose
uuidJob identity.
extension, jobtypeWhat kind of work this is and who owns it.
statusCurrent lifecycle state.
priorityScheduling weight among pending jobs.
payloadThe command's serialized to_payload() output.
attempts, maxattemptsRetry counter and ceiling.
correlationidTies related jobs together across a business transaction.
dedupkeyPrevents duplicate enqueue of equivalent work.
groupkeyGroups jobs that must serialize relative to each other.
transportWhich transport the job was routed through.

job_attempt is an immutable satellite record written once per attempt.

Serialization

Commands serialize via a framework-defined CommandInterface::to_payload() / from_payload() pair — never a generic serialize()/json_encode(). This keeps a queued payload's shape independent of PHP's own serialization format. Incompatible schema changes to an already-queued command still require explicit rollout coordination; no automatic payload-versioning mechanism exists.

Anti-patterns

Anti-patternWhy it's wrong
Logic in the handler's constructor instead of __invoke()Constructor runs at resolution time, not dispatch time — breaks the handler's single point of execution.
A mutable command classCommands should be final readonly; mutability invites state to change between dispatch and handling.
Serializing objects directly inside to_payload() instead of primitive/array dataDefeats the point of a dedicated payload contract — reintroduces PHP-serialization coupling through the back door.
A handler reaching for a host's raw data-access global instead of an injected repositoryViolates the persistence boundary — see FW-013.
Calling dispatch() on an async-routed message without testing the from_payload(to_payload()) round tripThe only way to catch a payload that doesn't survive serialization before it reaches production.
A job with no maxattempts setAn infinite retry loop waiting to happen.

MIDDAG © 2015-2026