Skip to content

Calculators ​

Calculators compute derived values before guards and actions run. They're useful for preparing data that guards need to evaluate or that actions need to use.

Execution Order ​

Calculators run first, so guards and actions can use the calculated values.

Basic Usage ​

Inline Calculator ​

php
MachineDefinition::define(
    config: [
        'states' => [
            'cart' => [
                'on' => [
                    'CHECKOUT' => [
                        'target' => 'checkout',
                        'calculators' => 'calculateTotalCalculator',
                        'guards' => 'hasMinimumTotalGuard',
                    ],
                ],
            ],
        ],
    ],
    behavior: [
        'calculators' => [
            'calculateTotalCalculator' => function (ContextManager $context) {
                $context->total = collect($context->items)->sum('price');
            },
        ],
        'guards' => [
            'hasMinimumTotalGuard' => fn($ctx) => $ctx->total >= 10,
        ],
    ],
);

Class-Based Calculator ​

Both inline keys and FQCN references work interchangeably — see Behavior Resolution for details.

php

use Tarfinlabs\EventMachine\Behavior\CalculatorBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class CalculateTotalCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        $subtotal = collect($context->items)->sum('price');
        $tax = $subtotal * 0.1;
        $shipping = $this->calculateShipping($context);

        $context->subtotal = $subtotal;
        $context->tax = $tax;
        $context->shipping = $shipping;
        $context->total = $subtotal + $tax + $shipping;
    }

    private function calculateShipping(ContextManager $context): float
    {
        $itemCount = count($context->items);
        return $itemCount > 5 ? 0 : 5.99;
    }
}

Multiple Calculators ​

Chain calculators for complex computations:

php
'on' => [
    'CHECKOUT' => [
        'target' => 'processing',
        'calculators' => [
            'calculateSubtotalCalculator',
            'applyDiscountsCalculator',
            'calculateTaxCalculator',
            'calculateShippingCalculator',
            'calculateFinalTotalCalculator',
        ],
        'guards' => 'hasValidTotalGuard',
    ],
],

Calculator Parameters ​

php
class DiscountCalculator extends CalculatorBehavior
{
    public function __invoke(
        ContextManager $context,
        EventBehavior $event,
    ): void {
        $discountCode = $event->payload['discountCode'] ?? null;

        if ($discountCode) {
            $discount = $this->lookupDiscount($discountCode);
            $context->discount = $discount;
            $context->total -= $discount;
        }
    }
}

Dependency Injection ​

php
class CalculateTaxCalculator extends CalculatorBehavior
{
    public function __construct(
        private readonly TaxService $taxService,
    ) {}

    public function __invoke(ContextManager $context): void
    {
        $taxRate = $this->taxService->getRateForAddress(
            $context->shippingAddress
        );

        $context->taxRate = $taxRate;
        $context->tax = $context->subtotal * $taxRate;
    }
}

Practical Examples ​

Order Total Calculation ​

php

use Tarfinlabs\EventMachine\Behavior\CalculatorBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class CalculateOrderTotalCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        $items = collect($context->items);

        // Calculate subtotal
        $subtotal = $items->sum(fn($item) =>
            $item['price'] * $item['quantity']
        );

        // Apply discount
        $discount = $this->calculateDiscount($context, $subtotal);

        // Calculate tax (after discount)
        $taxable = $subtotal - $discount;
        $tax = $taxable * 0.1;

        // Calculate shipping
        $shipping = $this->calculateShipping($items);

        // Set all values
        $context->subtotal = $subtotal;
        $context->discount = $discount;
        $context->tax = $tax;
        $context->shipping = $shipping;
        $context->total = $taxable + $tax + $shipping;
    }

    private function calculateDiscount($context, $subtotal): float
    {
        if ($context->discountCode === 'SAVE20') {
            return $subtotal * 0.2;
        }
        return 0;
    }

    private function calculateShipping($items): float
    {
        $weight = $items->sum('weight');
        return $weight > 10 ? 15.99 : 5.99;
    }
}

User Eligibility Calculation ​

php
class CalculateEligibilityCalculator extends CalculatorBehavior
{
    public function __construct(
        private readonly UserService $users,
        private readonly CreditService $credit,
    ) {}

    public function __invoke(ContextManager $context): void
    {
        $user = $this->users->find($context->userId);

        // Calculate credit score
        $creditScore = $this->credit->getScore($user);

        // Calculate debt ratio
        $debtRatio = $user->total_debt / $user->annual_income;

        // Calculate eligibility
        $context->creditScore = $creditScore;
        $context->debtRatio = $debtRatio;
        $context->isEligible = $creditScore >= 650 && $debtRatio < 0.4;
        $context->maxLoanAmount = $this->calculateMaxLoan($creditScore, $user);
    }
}

Pricing Calculation ​

php

use Tarfinlabs\EventMachine\Behavior\CalculatorBehavior; use Tarfinlabs\EventMachine\Behavior\EventBehavior; use Tarfinlabs\EventMachine\ContextManager; 
class CalculatePricingCalculator extends CalculatorBehavior
{
    public function __invoke(
        ContextManager $context,
        EventBehavior $event,
    ): void {
        $plan = $event->payload['plan'];
        $period = $event->payload['period'] ?? 'monthly';

        $pricing = $this->getPricing($plan, $period);

        $context->plan = $plan;
        $context->period = $period;
        $context->basePrice = $pricing['base'];
        $context->discount = $pricing['discount'];
        $context->finalPrice = $pricing['final'];
    }

    private function getPricing(string $plan, string $period): array
    {
        $prices = [
            'basic' => ['monthly' => 9.99, 'yearly' => 99.99],
            'pro' => ['monthly' => 19.99, 'yearly' => 199.99],
            'enterprise' => ['monthly' => 49.99, 'yearly' => 499.99],
        ];

        $base = $prices[$plan][$period] ?? 0;
        $discount = $period === 'yearly' ? $base * 0.17 : 0;

        return [
            'base' => $base,
            'discount' => $discount,
            'final' => $base - $discount,
        ];
    }
}

Date/Time Calculations ​

php
class CalculateDeliveryDateCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        $shippingMethod = $context->shippingMethod ?? 'standard';

        $deliveryDays = match ($shippingMethod) {
            'express' => 2,
            'priority' => 5,
            'standard' => 7,
            default => 10,
        };

        $orderDate = now();
        $deliveryDate = $orderDate->addBusinessDays($deliveryDays);

        $context->orderDate = $orderDate->toDateString();
        $context->estimatedDelivery = $deliveryDate->toDateString();
        $context->deliveryDays = $deliveryDays;
    }
}

Calculator Failure Behavior ​

Calculator Failures Abort Transitions

If a calculator throws an exception:

  1. The exception is caught internally
  2. A machine.calculator.{name}.fail internal event is recorded
  3. The entire transition is aborted - the machine stays in its current state
  4. No guards or actions execute
  5. The send() method returns the current state unchanged

To prevent silent failures, wrap risky operations in try/catch (see example below).

php
class RiskyCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        // If this throws, the transition is aborted
        $result = $this->externalService->calculate($context->data);
        $context->calculatedValue = $result;
    }
}

Handle potential failures gracefully:

php
class SafeCalculator extends CalculatorBehavior
{
    public function __invoke(ContextManager $context): void
    {
        try {
            $result = $this->externalService->calculate($context->data);
            $context->calculatedValue = $result;
        } catch (ExternalServiceException $e) {
            // Set a default or flag for guards to check
            $context->calculatedValue = null;
            $context->calculationFailed = true;
        }
    }
}

The internal event recorded on failure:

php
// Event type: {machine}.calculator.{calculatorName}.fail
// Payload: ['error' => 'Calculator failed: {exception message}']

Calculator vs Action ​

AspectCalculatorAction
RunsBefore guardsAfter guards pass
PurposePrepare dataExecute side effects
Typical useCompute valuesCall services, send emails
Can block transitionYes (if throws exception)No

When to Use Calculators ​

  • Computing totals, averages, or aggregates
  • Looking up data needed by guards
  • Preparing context for guards/actions
  • Formatting or transforming data

When to Use Actions ​

  • Saving to database
  • Sending notifications
  • Calling external APIs
  • Any side effects

Testing Calculators ​

Isolated (Unit) ​

php
$state = State::forTesting([
    'items' => [
        ['price' => 10, 'quantity' => 2],
        ['price' => 25, 'quantity' => 1],
    ],
]);

CalculateTotalCalculator::runWithState($state);
expect($state->context->get('total'))->toBe(45);

With Named Parameters ​

php
$state = State::forTesting(['value' => 10]);
MultiplyCalculator::runWithState($state, configParams: ['factor' => 3]);
expect($state->context->get('value'))->toBe(30);

Faked (Machine-Level) ​

php
CalculateTotalCalculator::shouldRun()
    ->andReturnUsing(fn($ctx) => $ctx->set('total', 999));

OrderMachine::test()
    ->send('CALCULATE')
    ->assertContext('total', 999);

Definition-Level Testing ​

php
it('calculates order total correctly', function () {
    $machine = MachineDefinition::define(
        config: [
            'initial' => 'cart',
            'context' => [
                'items' => [
                    ['price' => 10, 'quantity' => 2],
                    ['price' => 25, 'quantity' => 1],
                ],
            ],
            'states' => [
                'cart' => [
                    'on' => [
                        'CALCULATE' => [
                            'calculators' => 'calculateTotalCalculator',
                        ],
                    ],
                ],
            ],
        ],
        behavior: [
            'calculators' => [
                'calculateTotalCalculator' => function ($ctx) {
                    $ctx->total = collect($ctx->items)
                        ->sum(fn($i) => $i['price'] * $i['quantity']);
                },
            ],
        ],
    );

    $state = $machine->transition(['type' => 'CALCULATE']);

    expect($state->context->total)->toBe(45);
});

Full Testing Guide

See Isolated Testing and Testing Recipes for calculator ordering patterns.

Best Practices ​

1. Keep Calculators Pure ​

Calculate values, don't trigger side effects:

php
// Good - only calculates
$context->total = collect($context->items)->sum('price');

// Avoid - side effect
$this->analytics->track('total_calculated', $context->total);

2. Order Calculators Logically ​

php
'calculators' => [
    'calculateSubtotalCalculator',    // First: base calculation
    'applyDiscountsCalculator',       // Second: depends on subtotal
    'calculateTaxCalculator',         // Third: depends on discounted amount
    'calculateTotalCalculator',       // Last: combines all values
],

3. Use Descriptive Names ​

php
// Good
'calculators' => [
    'calculateOrderSubtotalCalculator',
    'calculateShippingCostCalculator',
    'applyMembershipDiscountCalculator',
],

// Avoid
'calculators' => ['calc1Calculator', 'calc2Calculator', 'doStuffCalculator'],

4. Handle Edge Cases ​

php
public function __invoke(ContextManager $context): void
{
    $items = $context->items ?? [];

    if (empty($items)) {
        $context->total = 0;
        return;
    }

    $context->total = collect($items)->sum('price');
}

Detailed Guide

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

Released under the MIT License.