AW
Case study 03 · MONISCOPE

Automation engine — WHEN / IF / THEN

A small business-rules engine purpose-built for self-storage operators. Triggers, conditions, and actions are first-class rows in automation_rules — sortable, configurable, dry-runnable. Adding a new condition or action is one private method plus one entry in a dispatcher.

Automations are built and tested in dev; no production tenant has subscribed yet. The engine is wired through the same event bus that the rest of the application uses.
What an admin builds in the UI

A real example, in plain English

WHEN payment_failed IF amount > $100 AND customer_type = commercial THEN create_task("Call commercial tenant") AND send_sms("…")
The engineering "why"

Six things that make this an engine, not a switch

1

Triggers / conditions / actions are data, not code

Rows in automation_rules with type, key, JSON config, sort_order. A new condition is one private method + one match arm — no schema migration.

snippet 01
2

Same dispatcher pattern for actions

10 action keys, one match. Each returns a status string ("sent", "skipped:no_email", "applied:25.00") so the AutomationLog row reads like an audit trail.

snippet 02
3

testRun() is a real dry-run

Pulls a real sample model from the DB matching the trigger type, evaluates each condition INDEPENDENTLY (no short-circuit), returns per-condition pass/fail. The admin can debug a rule without waiting for a real event.

snippet 03
4

Variable interpolation with safe fallbacks

{{customer.name}}, {{rental.monthly_rate}}, {{payment.days_overdue}}. Built from resolved User + Rental + Payment with formatting; unrecognized vars stay as literals so typos surface in preview, not as empty strings.

in full case study
5

update_field is fenced behind a whitelist

The action looks safe but is dangerous — admins could otherwise overwrite arbitrary columns through the rule engine. UPDATABLE_FIELDS caps what it can touch (notes, tags, two columns per model).

in full case study
6

One broken automation doesn't stop the queue

Each automation runs in its own try/catch. Each execution writes an AutomationLog row regardless of outcome. send_webhook is bounded by Http::timeout(10).

in full case study
Architecture

Trigger → Conditions → Actions → Log

Event listener / scheduled command / model observer
AutomationEngine::fire('payment_failed', $payment)
Automation::forTrigger('payment_failed')->where('is_template', false)->with('rules')->get()
for each automation
evaluateConditions() · AND across all rules
evaluateCondition($key, $config) → strategy match
unit_sizeunit_typeamount_thresholdcustomer_typedays_sincehas_autopayrental_durationlien_stage
if all pass
executeActions() · sorted by sort_order
executeAction($key, $config) → strategy match
send_emailsend_smscreate_taskapply_feechange_rental_statuschange_unit_statussend_webhookadd_notesend_in_appupdate_field (whitelisted)
logExecution(automation, status, actions, error, timing)
Dry run · admin clicks “Test” in builder UI
AutomationEngine::testRun($automation)
resolveSampleModel(triggerKey, facilityId) — pulls a real row matching the trigger type
evaluates each condition individually (not short-circuit)
lists every action that WOULD run
returns structured preview to UI — no DB writes, no emails, no webhooks fired
The Code · how it flows

Three snippets, in execution order

Conditions evaluate first → actions execute on pass → testRun mirrors the same code path without side effects. Real excerpts from app/Services/AutomationEngine.php.

Step 1 · Decide whether to fire

Condition dispatcher (AND across all conditions, short-circuiting)

Three representative evaluators below. Each reads its own config shape — no shared schema, no over-abstraction. Adding evalUnitFloor is one method + one match arm.

AutomationEngine.php · evaluateCondition + 3 evaluatorsphp
private function evaluateConditions(Automation $automation, Model $model, array $context): bool
{
    // AND across all conditions. Short-circuits on first false.
    $conditions = $automation->rules->where('type', 'condition');
    foreach ($conditions as $condition) {
        if (!$this->evaluateCondition($condition->key, $condition->config, $model, $context)) {
            return false;
        }
    }
    return true;
}

private function evaluateCondition(string $key, array $config, Model $model, array $context): bool
{
    return match ($key) {
        'unit_size'        => $this->evalUnitSize($config, $model, $context),
        'unit_type'        => $this->evalUnitType($config, $model, $context),
        'amount_threshold' => $this->evalAmountThreshold($config, $model, $context),
        'customer_type'    => $this->evalCustomerType($config, $model, $context),
        'days_since'       => $this->evalDaysSince($config, $model, $context),
        'has_autopay'      => $this->evalHasAutopay($config, $model, $context),
        'rental_duration'  => $this->evalRentalDuration($config, $model, $context),
        'lien_stage'       => $this->evalLienStage($config, $model, $context),
        default            => true,  // unknown condition = pass (don't break old rules)
    };
}

// ── Three representative evaluators ──────────────────────────────
private function evalAmountThreshold(array $config, Model $model, array $context): bool
{
    // Pulls the "amount" off whatever subject the trigger fired on.
    $amount = match (true) {
        $model instanceof Payment => (float) $model->amount,
        $model instanceof Rental  => (float) $model->monthly_rate,
        default                   => null,
    };
    if ($amount === null) return true;  // no amount → pass

    $op        = $config['operator'] ?? 'gt';
    $threshold = (float) ($config['value'] ?? 0);

    return match ($op) {
        'gt'    => $amount > $threshold,
        'lt'    => $amount < $threshold,
        'gte'   => $amount >= $threshold,
        'lte'   => $amount <= $threshold,
        'eq'    => abs($amount - $threshold) < 0.01,
        default => true,
    };
}

private function evalCustomerType(array $config, Model $model, array $context): bool
{
    $user = $this->resolveUser($model, $context);
    if (!$user) return true;

    $target = $config['value'] ?? null;

    // "commercial" is a flag, not a role — handle separately.
    if ($target === 'commercial') {
        return $user->is_commercial ?? false;
    }

    // Otherwise compare against User::role.
    return $user->role === $target;
}
Step 2 · If conditions pass

Action dispatcher (executes sorted, returns audit-friendly status strings)

Same pattern as conditions. Each action returns a short status ("created", "applied:25.00", "skipped:no_rental") that lands in the AutomationLog row.

AutomationEngine.php · executeAction + 2 executorsphp
private function executeAction(string $key, array $config, Model $model, array $context): string
{
    return match ($key) {
        'send_email'           => $this->executeSendEmail($config, $model, $context),
        'send_sms'             => $this->executeSendSms($config, $model, $context),
        'create_task'          => $this->executeCreateTask($config, $model, $context),
        'apply_fee'            => $this->executeApplyFee($config, $model, $context),
        'change_rental_status' => $this->executeChangeRentalStatus($config, $model, $context),
        'change_unit_status'   => $this->executeChangeUnitStatus($config, $model, $context),
        'send_webhook'         => $this->executeSendWebhook($config, $model, $context),
        'add_note'             => $this->executeAddNote($config, $model, $context),
        'send_in_app'          => $this->executeSendInApp($config, $model, $context),
        'update_field'         => $this->executeUpdateField($config, $model, $context),
        'wait'                 => 'skipped:wait_not_supported_in_sync',
        default                => 'skipped:unknown_action',
    };
}

// ── Two representative executors ─────────────────────────────────
private function executeApplyFee(array $config, Model $model, array $context): string
{
    $rental = $this->resolveRental($model, $context);
    if (!$rental) return 'skipped:no_rental';

    $amount = (float) ($config['amount'] ?? 0);

    // FeeItem rows are pre-defined facility fees — admin picks from a dropdown,
    // amount looked up at execution time. Avoids hardcoding amounts in rules.
    if (!empty($config['fee_item_id'])) {
        $feeItem = FeeItem::find($config['fee_item_id']);
        if ($feeItem) {
            $amount = (float) $feeItem->amount;
        }
    }

    if ($amount <= 0) return 'skipped:zero_amount';

    Payment::create([
        'facility_id'  => FacilityContext::id(),
        'rental_id'    => $rental->id,
        'user_id'      => $rental->user_id,
        'amount'       => $amount,
        'status'       => 'pending',
        'method'       => 'manual',
        'due_date'     => now(),
        'description'  => $this->interpolate($config['description'] ?? 'Automated fee', $model, $context),
    ]);

    return 'applied:' . number_format($amount, 2);
}

private function executeChangeRentalStatus(array $config, Model $model, array $context): string
{
    $rental = $this->resolveRental($model, $context);
    if (!$rental) return 'skipped:no_rental';

    $newStatus = $config['status'] ?? null;
    if (!$newStatus) return 'skipped:no_status';

    $oldStatus = $rental->status;
    $rental->update(['status' => $newStatus]);

    AuditLog::log(
        'automation_rental_status_change',
        "Automation changed rental #{$rental->id} status from {$oldStatus} to {$newStatus}",
        null,
        $rental,
    );

    return "changed:{$oldStatus}->{$newStatus}";
}
Step 3 · Without firing for real

testRun() — real data, every condition evaluated, no side effects

Two important details: it pulls a real sample model matching the trigger type (so "is the customer commercial?" returns a real answer), and it does NOT short-circuit on first failed condition — every condition is evaluated independently so the admin sees which condition is blocking the rule.

AutomationEngine.php · testRun()php
public function testRun(Automation $automation): array
{
    $automation->loadMissing('rules');

    $triggerRule = $automation->rules->where('type', 'trigger')->first();
    $triggerKey  = $triggerRule?->key ?? 'unknown';

    // Pick a REAL sample subject from the DB — matching the trigger type.
    $sampleModel = $this->resolveSampleModel($triggerKey, $automation->facility_id);

    $context = ['trigger_key' => $triggerKey];
    $user    = $this->resolveUser($sampleModel, $context);
    $rental  = $this->resolveRental($sampleModel, $context);
    if ($user)   $context['user']   = $user;
    if ($rental) $context['rental'] = $rental;

    // Evaluate every condition INDEPENDENTLY — no short-circuit — so the
    // UI can show the admin which condition blocked the rule.
    $conditionResults = [];
    $allPass          = true;
    foreach ($automation->rules->where('type', 'condition') as $cond) {
        $passed = $this->evaluateCondition($cond->key, $cond->config, $sampleModel, $context);
        $conditionResults[] = [
            'key'    => $cond->key,
            'config' => $cond->config,
            'passed' => $passed,
        ];
        if (!$passed) {
            $allPass = false;
        }
    }

    // List actions that WOULD run, but don't run them.
    $actionsWouldRun = [];
    foreach ($automation->rules->where('type', 'action')->sortBy('sort_order') as $action) {
        $actionsWouldRun[] = [
            'key'           => $action->key,
            'config'        => $action->config,
            'would_execute' => $allPass,
        ];
    }

    return [
        'would_trigger'     => $allPass,
        'trigger_key'       => $triggerKey,
        'conditions_met'    => $conditionResults,
        'actions_would_run' => $actionsWouldRun,
        'sample_subject'    => [
            'type'  => class_basename($sampleModel),
            'id'    => $sampleModel->getKey(),
            'label' => $sampleModel->name ?? $sampleModel->unit_number ?? ('ID ' . $sampleModel->getKey()),
        ],
    ];
}

Source

Excerpts from app/Services/AutomationEngine.php in MONISCOPE (pre-launch). The full build also includes the {{var}} interpolation helper, the UPDATABLE_FIELDS whitelist for update_field, per-automation try/catch isolation, and the bounded webhook timeout — happy to walk through any of them.