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.
A real example, in plain English
Six things that make this an engine, not a switch
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.
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 02testRun() 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 03Variable 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.
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).
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).
Trigger → Conditions → Actions → Log
AutomationEngine::fire('payment_failed', $payment)Automation::forTrigger('payment_failed')->where('is_template', false)->with('rules')->get()evaluateCondition($key, $config) → strategy matchexecuteAction($key, $config) → strategy matchlogExecution(automation, status, actions, error, timing)resolveSampleModel(triggerKey, facilityId) — pulls a real row matching the trigger typeThree 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.
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.
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;
}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.
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}";
}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.
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.