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
php
··· 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:
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
··· 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
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
··· 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
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:
- 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).
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
| 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
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);
});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');
}