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
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.
··· 2 hidden lines
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:
'on' => [
'CHECKOUT' => [
'target' => 'processing',
'calculators' => [
'calculateSubtotalCalculator',
'applyDiscountsCalculator',
'calculateTaxCalculator',
'calculateShippingCalculator',
'calculateFinalTotalCalculator',
],
'guards' => 'hasValidTotalGuard',
],
],Calculator Parameters
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
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
··· 2 hidden lines
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
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
··· 3 hidden lines
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
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:
- The exception is caught internally
- A
machine.calculator.{name}.failinternal event is recorded - The entire transition is aborted - the machine stays in its current state
- No guards or actions execute
- The
send()method returns the current state unchanged
To prevent silent failures, wrap risky operations in try/catch (see example below).
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:
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:
// Event type: {machine}.calculator.{calculatorName}.fail
// Payload: ['error' => 'Calculator failed: {exception message}']Calculator vs Action
| Aspect | Calculator | Action |
|---|---|---|
| Runs | Before guards | After guards pass |
| Purpose | Prepare data | Execute side effects |
| Typical use | Compute values | Call services, send emails |
| Can block transition | Yes (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)
$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
$state = State::forTesting(['value' => 10]);
MultiplyCalculator::runWithState($state, configParams: ['factor' => 3]);
expect($state->context->get('value'))->toBe(30);Faked (Machine-Level)
CalculateTotalCalculator::shouldRun()
->andReturnUsing(fn($ctx) => $ctx->set('total', 999));
OrderMachine::test()
->send('CALCULATE')
->assertContext('total', 999);Definition-Level Testing
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:
// Good - only calculates
$context->total = collect($context->items)->sum('price');
// Avoid - side effect
$this->analytics->track('total_calculated', $context->total);2. Order Calculators Logically
'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
// Good
'calculators' => [
'calculateOrderSubtotalCalculator',
'calculateShippingCostCalculator',
'applyMembershipDiscountCalculator',
],
// Avoid
'calculators' => ['calc1Calculator', 'calc2Calculator', 'doStuffCalculator'],4. Handle Edge Cases
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.