Skip to content

Custom Context Classes

Custom context classes provide type safety, validation, and computed methods for your machine's data. They extend ContextManager and use Spatie Laravel Data.

Basic Custom Context

php
use Tarfinlabs\EventMachine\ContextManager;

class OrderContext extends ContextManager
{
    public function __construct(
        public string $orderId = '',
        public array $items = [],
        public float $total = 0.0,
        public ?string $customerId = null,
    ) {
        parent::__construct();
    }
}

Using Custom Context

php

use Tarfinlabs\EventMachine\Definition\MachineDefinition; MachineDefinition::define(
    config: [
        'initial' => 'pending',
        'context' => OrderContext::class,
        'states' => [...],
    ],
);

Type-Safe Access

php

use Tarfinlabs\EventMachine\Behavior\ActionBehavior; // In behaviors, you get typed context
class ProcessAction extends ActionBehavior
{
    public function __invoke(OrderContext $context): void
    {
        // Full IDE autocompletion
        $orderId = $context->orderId;
        $items = $context->items;

        // Type-safe assignment
        $context->total = 99.99;
    }
}

Validation Attributes

Use Spatie Laravel Data validation:

php
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Max;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\ArrayType;
use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Optional;

class OrderContext extends ContextManager
{
    public function __construct(
        #[Required]
        public string $orderId,

        #[Email]
        public string $customerEmail,

        #[ArrayType]
        public array $items = [],

        #[IntegerType]
        #[Min(0)]
        public int|Optional $quantity = 0,

        #[Min(0)]
        #[Max(1000000)]
        public float|Optional $total = 0.0,
    ) {
        parent::__construct();
    }
}

Optional Properties

Use Optional for fields that may not be set:

php
use Spatie\LaravelData\Optional;

class UserContext extends ContextManager
{
    public function __construct(
        public string $userId,
        public string|Optional $email = '',
        public ?string $name = null,
        public int|Optional $age = 0,
    ) {
        parent::__construct();

        // Initialize Optional values
        if ($this->email instanceof Optional) {
            $this->email = '';
        }
    }
}

Computed Methods

Add methods for complex calculations:

php

use Tarfinlabs\EventMachine\ContextManager; class CartContext extends ContextManager
{
    public function __construct(
        public array $items = [],
        public float $discountPercent = 0,
        public string $shippingMethod = 'standard',
    ) {
        parent::__construct();
    }

    public function subtotal(): float
    {
        return collect($this->items)->sum(
            fn($item) => $item['price'] * $item['quantity']
        );
    }

    public function discount(): float
    {
        return $this->subtotal() * ($this->discountPercent / 100);
    }

    public function shipping(): float
    {
        return match ($this->shippingMethod) {
            'express' => 15.99,
            'overnight' => 29.99,
            default => 5.99,
        };
    }

    public function tax(): float
    {
        return ($this->subtotal() - $this->discount()) * 0.1;
    }

    public function total(): float
    {
        return $this->subtotal() - $this->discount() + $this->tax() + $this->shipping();
    }

    public function itemCount(): int
    {
        return collect($this->items)->sum('quantity');
    }

    public function isEmpty(): bool
    {
        return empty($this->items);
    }
}

Using Computed Methods

php
// In guards
'guards' => [
    'hasMinimumTotalGuard' => fn(CartContext $ctx) => $ctx->total() >= 10,
    'isNotEmptyGuard' => fn(CartContext $ctx) => !$ctx->isEmpty(),
],

// In actions
'actions' => [
    'applyDiscountAction' => function (CartContext $ctx, EventBehavior $event) {
        $ctx->discountPercent = $event->payload['percent'];
        // `total()` will automatically reflect the discount
    },
],

Exposing Computed Values in API Responses

By default, computed methods are only available in PHP — in guards, actions, calculators, and OutputBehavior. They do not appear in endpoint JSON responses, because toArray() only serializes properties.

Override computedContext() to declare which computed values should be included in API responses:

php
class CartContext extends ContextManager
{
    public function __construct(
        public array $items = [],
        public float $discountPercent = 0,
        public string $shippingMethod = 'standard',
    ) {
        parent::__construct();
    }

    public function subtotal(): float { /* ... */ }
    public function total(): float { /* ... */ }
    public function isEmpty(): bool { return empty($this->items); }

    protected function computedContext(): array
    {
        return [
            'subtotal'  => $this->subtotal(),
            'total'     => $this->total(),
            'isEmpty'   => $this->isEmpty(),
            'itemCount' => count($this->items),
        ];
    }
}

Now the endpoint response includes computed values alongside regular properties:

json
{
  "output": {
    "items": [...],
    "discountPercent": 10,
    "shippingMethod": "express",
    "subtotal": 99.99,
    "total": 104.98,
    "isEmpty": false,
    "itemCount": 3
  }
}

Not Persisted

Computed values are not stored in the database — they are recomputed fresh on every API response. This keeps the machine_events table clean and avoids stale derived data.

Output Filtering

Computed keys respect output array filtering on endpoints. If an endpoint specifies output: ['total', 'itemCount'], only those keys appear — both regular and computed.

Context Interfaces for Shared Behaviors

When a behavior is reused across multiple machines with different context classes, avoid coupling the behavior to specific context types with union types. Instead, define a PHP interface:

php
interface HasFarmer
{
    public function farmer(): Farmer;
}

interface HasTckn
{
    public function tckn(): string;
}

Implement the interface in each context class:

php
class CarSalesContext extends ContextManager implements HasFarmer, HasTckn
{
    // ...
    public function farmer(): Farmer { return $this->farmer; }
    public function tckn(): string { return $this->tckn; }
}

class FindeksContext extends ContextManager implements HasFarmer, HasTckn
{
    // ...
    public function farmer(): Farmer { return $this->farmer; }
    public function tckn(): string { return $this->tckn; }
}

Now the shared behavior type-hints the interface — no coupling to specific machines:

php
class VerifyIdentityAction extends ActionBehavior
{
    public function __invoke(HasTckn $context): void
    {
        $tckn = $context->tckn(); // IDE autocompletion, static analysis ✓
    }
}

This scales cleanly: adding a new machine that uses VerifyIdentityAction only requires the new context to implement HasTckn. The behavior class never changes.

When to use interfaces vs union types

Interfaces — when the behavior only needs a few shared properties and is reused across 2+ machines. Scales indefinitely.

Union types (CarSalesContext|FindeksContext) — when the behavior needs access to machine-specific properties that differ between contexts. Acceptable for 2-3 types.

Model Transformers

Handle Eloquent models in context:

php
use Spatie\LaravelData\Attributes\WithTransformer;

class OrderContext extends ContextManager
{
    public function __construct(
        #[WithTransformer(ModelTransformer::class)]
        public User|int|Optional $user,

        #[WithTransformer(ModelTransformer::class)]
        public Order|int|Optional $order,

        #[WithTransformer(ModelTransformer::class)]
        public ?Product $product = null,
    ) {
        parent::__construct();
    }
}

The transformer handles:

  • Storing model IDs for serialization
  • Loading models when accessed

Initialization Logic

Add initialization in the constructor:

php

use Tarfinlabs\EventMachine\ContextManager; class GameContext extends ContextManager
{
    public function __construct(
        public int|Optional $score = 0,
        public int|Optional $lives = 3,
        public array $inventory = [],
        public ?string $currentLevel = null,
    ) {
        parent::__construct();

        // Initialize Optional values
        if ($this->score instanceof Optional) {
            $this->score = 0;
        }
        if ($this->lives instanceof Optional) {
            $this->lives = 3;
        }

        // Set default level
        if ($this->currentLevel === null) {
            $this->currentLevel = 'level_1';
        }
    }
}

Self-Validation

php
$context = new OrderContext(
    orderId: 'order-123',
    customerEmail: 'invalid-email',  // Invalid
);

$context->selfValidate(); // Throws MachineContextValidationException

Validation and Creation

php
// Create with validation
$context = OrderContext::validateAndCreate([
    'orderId' => 'order-123',
    'customerEmail' => 'customer@example.com',
    'items' => [],
]);

// This will throw if validation fails
$context = OrderContext::validateAndCreate([
    'customerEmail' => 'invalid',
]);

Practical Examples

E-commerce Order Context

php

use Tarfinlabs\EventMachine\ContextManager; class EcommerceContext extends ContextManager
{
    public function __construct(
        #[Required]
        public string $sessionId,

        public array $cartItems = [],

        #[Email]
        public string|Optional $customerEmail = '',

        public ?array $shippingAddress = null,

        public ?array $billingAddress = null,

        public ?string $paymentMethod = null,

        public ?string $couponCode = null,

        #[Min(0)]
        public float $discountAmount = 0,

        public ?string $orderId = null,

        public ?string $trackingNumber = null,
    ) {
        parent::__construct();
    }

    public function subtotal(): float
    {
        return collect($this->cartItems)->sum(
            fn($item) => $item['price'] * $item['quantity']
        );
    }

    public function hasItems(): bool
    {
        return !empty($this->cartItems);
    }

    public function hasShippingAddress(): bool
    {
        return $this->shippingAddress !== null;
    }

    public function hasPaymentMethod(): bool
    {
        return $this->paymentMethod !== null;
    }

    public function isReadyForCheckout(): bool
    {
        return $this->hasItems()
            && $this->hasShippingAddress()
            && $this->hasPaymentMethod();
    }
}

Loan Application Context

php

use Tarfinlabs\EventMachine\ContextManager; class LoanApplicationContext extends ContextManager
{
    public function __construct(
        #[Required]
        public string $applicationId,

        #[Required]
        #[Min(1000)]
        #[Max(1000000)]
        public float $requestedAmount,

        #[Required]
        #[Min(12)]
        #[Max(360)]
        public int $termMonths,

        public ?float $approvedAmount = null,

        public ?float $interestRate = null,

        public ?int $creditScore = null,

        public float $debtToIncomeRatio = 0,

        public array $documents = [],

        public array $approvals = [],

        public ?string $rejectionReason = null,
    ) {
        parent::__construct();
    }

    public function monthlyPayment(): ?float
    {
        if (!$this->approvedAmount || !$this->interestRate) {
            return null;
        }

        $principal = $this->approvedAmount;
        $rate = $this->interestRate / 12 / 100;
        $months = $this->termMonths;

        return $principal * ($rate * pow(1 + $rate, $months))
            / (pow(1 + $rate, $months) - 1);
    }

    public function hasAllDocuments(): bool
    {
        $required = ['id', 'income_proof', 'bank_statements'];
        return empty(array_diff($required, array_keys($this->documents)));
    }

    public function approvalCount(): int
    {
        return count($this->approvals);
    }

    public function needsMoreApprovals(int $required): bool
    {
        return $this->approvalCount() < $required;
    }
}

Workflow Context

php

use Tarfinlabs\EventMachine\ContextManager; class WorkflowContext extends ContextManager
{
    public function __construct(
        #[Required]
        public string $workflowId,

        #[Required]
        public string $requesterId,

        public string $currentStep = 'start',

        public array $completedSteps = [],

        public array $formData = [],

        public array $approvers = [],

        public array $comments = [],

        public ?string $finalDecision = null,

        public ?\DateTimeImmutable $submittedAt = null,

        public ?\DateTimeImmutable $completedAt = null,
    ) {
        parent::__construct();
    }

    public function hasCompletedStep(string $step): bool
    {
        return in_array($step, $this->completedSteps);
    }

    public function addComment(string $author, string $text): void
    {
        $this->comments[] = [
            'author' => $author,
            'text' => $text,
            'timestamp' => now()->toIso8601String(),
        ];
    }

    public function duration(): ?string
    {
        if (!$this->submittedAt || !$this->completedAt) {
            return null;
        }

        return $this->submittedAt->diff($this->completedAt)->format('%d days, %h hours');
    }
}

Best Practices

1. Use Appropriate Types

php
// Good - specific types
public string $orderId;
public float $total;
public array $items;

// Avoid - mixed types
public mixed $data;

2. Initialize Optional Values

php
public function __construct(
    public int|Optional $count = 0,
) {
    parent::__construct();

    if ($this->count instanceof Optional) {
        $this->count = 0;
    }
}

3. Add Computed Methods

php
// Good - encapsulated logic
public function isEligible(): bool
{
    return $this->creditScore >= 650 && $this->debtRatio < 0.4;
}

// Use in guards
'guards' => [
    'checkEligibilityGuard' => fn($ctx) => $ctx->isEligible(),
],

4. Validate at Boundaries

php
// Validate on creation
$context = OrderContext::validateAndCreate($input);

// Or self-validate
$context->selfValidate();

Detailed Guide

For comprehensive design guidelines with Do/Don't examples, see Context Design.

Testing

For testing custom context classes, see Testing Overview.

Released under the MIT License.