Typed Contracts
Typed contracts bring type safety to machine delegation boundaries. Instead of passing untyped arrays between parent and child machines, you define typed DTOs that the framework validates and injects automatically.
Three Contract Types
| Contract | Direction | Base Class |
|---|---|---|
| MachineInput | Parent → Child | MachineInput |
| MachineOutput | Child → Parent (success) | MachineOutput |
| MachineFailure | Child → Parent (error) | MachineFailure |
MachineInput
Defines what data a child requires from its parent. Consumed at the delegation boundary — validates, merges into context, then is gone.
class PaymentInput extends MachineInput
{
public function __construct(
public readonly string $orderId,
public readonly int $amount,
public readonly string $currency = 'TRY',
) {}
}Parent-side usage
'delegating' => [
'machine' => PaymentMachine::class,
// Auto-resolve: constructor param names match parent context keys
'input' => PaymentInput::class,
// OR closure adapter for name mismatches
'input' => function (ContextManager $ctx): PaymentInput {
return new PaymentInput(
orderId: $ctx->get('currentOrderId'),
amount: $ctx->get('totalAmount'),
);
},
// OR untyped array (renamed from 'with')
'input' => ['orderId', 'amount'],
],Child-side declaration
MachineDefinition::define(config: [
'input' => PaymentInput::class,
'initial' => 'processing',
// ...
]);MachineOutput
Defines what data a machine produces. Works on any state (not just final). Plugs into v9's output type dispatch as a new case alongside OutputBehavior.
class PaymentOutput extends MachineOutput
{
public function __construct(
public readonly string $paymentId,
public readonly string $status,
public readonly ?string $transactionRef = null,
) {}
}Usage on states
'completed' => [
'type' => 'final',
'output' => PaymentOutput::class, // Auto-resolved from context
],Type dispatch order
When output is a string:
- Behavior registry key → resolve registered behavior
MachineOutputsubclass →fromContext()→ typed DTOOutputBehaviorsubclass → container resolve →__invoke()- Neither →
InvalidOutputDefinitionException
Parent receives typed output
'@done' => [
'target' => 'shipped',
'actions' => function (ContextManager $ctx, PaymentOutput $output): void {
$ctx->set('paymentId', $output->paymentId); // IDE autocomplete
},
],Composition with OutputBehavior
When output needs computation, OutputBehavior can return a MachineOutput:
class ComputedOutput extends OutputBehavior
{
public function __invoke(ContextManager $ctx): PaymentOutput
{
return new PaymentOutput(
paymentId: $ctx->get('paymentId'),
status: $this->computeStatus($ctx),
);
}
}MachineFailure
Maps exceptions to structured error data. Used for both machine and job delegation.
class PaymentFailure extends MachineFailure
{
public function __construct(
public readonly string $errorCode,
public readonly string $message,
public readonly ?string $gatewayResponse = null,
) {}
}Sensible default
fromException() auto-maps $message → getMessage(), $code → getCode(). Override for domain-specific mapping:
public static function fromException(Throwable $e): static
{
return new static(
errorCode: $e instanceof GatewayException ? $e->gatewayCode : 'UNKNOWN',
message: $e->getMessage(),
);
}Declaration
MachineDefinition::define(config: [
'failure' => PaymentFailure::class,
// ...
]);The failure key is optional. Without it, @fail actions receive raw exception data.
Discriminated Outputs
Different final states can produce different typed outputs:
'approved' => ['type' => 'final', 'output' => ApprovalOutput::class],
'rejected' => ['type' => 'final', 'output' => RejectionOutput::class],Parent routes per-state:
'@done.approved' => [
'actions' => function (ContextManager $ctx, ApprovalOutput $output): void { ... },
],
'@done.rejected' => [
'actions' => function (ContextManager $ctx, RejectionOutput $output): void { ... },
],Job Delegation
Jobs use the same contracts via interfaces:
class ProcessPaymentJob implements ReturnsOutput, ProvidesFailure
{
public function output(): PaymentOutput { ... }
public static function failure(Throwable $e): PaymentFailure { ... }
}Parent DX is identical for machine and job delegation.
Serialization
Typed contracts travel through queues via ChildMachineCompletionJob. The framework serializes via toArray() + stores the class FQCN, then reconstructs on the parent side for typed injection.
Testing
// Machine::fake with typed output
PaymentMachine::fake(output: new PaymentOutput(paymentId: 'pay_1', status: 'ok'));
// simulateChildDone with typed output
$tm->simulateChildDone(
childClass: PaymentMachine::class,
output: new PaymentOutput(paymentId: 'pay_1', status: 'ok'),
);