Skip to content

Data Validation ​

EventFlow provides built-in validation for event data and context variables. Validation rules are defined inline, making data contracts visible and executable.

Core Philosophy ​

Validation is documentation. Documentation is validation.

Define once, validate everywhere.

Event Data Validation ​

Extend the with: table with a validation column to define rules for event data.

Basic Syntax ​

eventflow
emit :checkout to @order with:
  | field    | value     | validation                       |
  | order_id | $order_id | required, string, valid uuid     |
  | email    | $email    | required, string, valid email    |
  | total    | $total    | required, number, greater than 0 |
  | coupon   | $coupon   | optional, string                 |

Without Validation (Legacy) ​

The validation column is optional. You can start without validation and add rules iteratively:

eventflow
// No validation - works fine
emit :checkout to @order with:
  | field    | value     |
  | order_id | $order_id |
  | email    | $email    |

How It Works ​

emit :checkout to @order with:
  | field | value  | validation                    |
  | email | $email | required, string, valid email |
      │
      ▼
┌─────────────────────────────────────────────┐
│         @order receives :checkout           │
├─────────────────────────────────────────────┤
│                                             │
│   For each rule (comma-separated):          │
│                                             │
│   "required" → Built-in? ✓ → Check          │
│   "string" → Built-in? ✓ → Check            │
│   "valid email" → Built-in? ✓ → Check       │
│                                             │
│   All rules pass?                           │
│       │                                     │
│   ┌───┴───┐                                 │
│   ↓       ↓                                 │
│  Yes      No                                │
│   │       │                                 │
│   ▼       ▼                                 │
│ Handler  :validation_failed                 │
│ runs     emitted (all errors collected)     │
│                                             │
└─────────────────────────────────────────────┘

Context Validation ​

Context variables can have constraints defined via post-action tables.

Single Variable ​

eventflow
$retry_count increases by 1
  | $retry_count | required, integer, between 0 and 5 |

Multiple Variables ​

When multiple context variables are modified together, a single table follows all actions:

eventflow
$total increases by $item.price
$item_count increases by 1
$last_updated becomes now
  | $total        | required, number, greater than 0 |
  | $item_count   | required, integer                |
  | $last_updated | required, datetime               |

Constraint Violation ​

When a context constraint is violated, EventFlow performs a rollback and emits an event:

  1. Context variable has current value (e.g., $retry_count = 5)
  2. Action attempts to change it (e.g., $retry_count increases by 1)
  3. New value would be 6
  4. Validation check: between 0 and 5 fails
  5. Rollback: $retry_count stays at 5
  6. Event: :constraint_violated is emitted
eventflow
on :constraint_violated
  ? $field is "$retry_count"
    emit :max_retries_exceeded to @customer
    order moves to #failed
  ?
    log constraint violation with $field, $rule, $old_value, $new_value

Validation Rules ​

Rules are comma-separated. Type rules (string, number) are validation rules like any other.

Presence Rules ​

RuleDescription
requiredMust be present and not null
optionalMay be absent
nullableCan be null

Type Rules ​

RuleDescription
stringMust be a string
numberMust be numeric
integerMust be whole number
booleanMust be true/false
arrayMust be an array
objectMust be an object
datetimeMust be a datetime

Numeric Rules ​

RuleDescription
greater than NValue > N
less than NValue < N
at least NValue >= N
at most NValue <= N
between N and MN <= Value <= M
positiveValue > 0
negativeValue < 0

String Rules ​

RuleDescription
min N charactersMinimum length
max N charactersMaximum length
exactly N charactersExact length
matching pattern "regex"Regex match
starting with "..."Prefix check
ending with "..."Suffix check
not emptyNon-empty string

Format Rules ​

RuleDescription
valid emailEmail format
valid urlURL format
valid phonePhone format
valid uuidUUID format
valid dateDate format
valid datetimeDateTime format
valid credit_cardCredit card number
valid ibanIBAN format
valid jsonValid JSON string
valid ipIP address

Array Rules ​

RuleDescription
min N itemsMinimum item count
max N itemsMaximum item count
exactly N itemsExact item count
of stringsAll items are strings
of numbersAll items are numbers
of objectsAll items are objects
uniqueNo duplicates
not emptyHas items

Enum Rules ​

RuleDescription
one of "a", "b", "c"Allowed values
not one of "x", "y"Disallowed values

Comparison Rules ​

RuleDescription
equal to $otherSame as another field
different from $otherDifferent from another
before $dateDate is earlier
after $dateDate is later

Flow Control Rules ​

RuleDescription
bailStop validation on first error for this field

Bail Rule ​

By default, all validation rules are checked and all errors are collected. Use bail to stop at the first error for a field.

Default Behavior (Check All) ​

eventflow
emit :register to @user with:
  | field | value  | validation                                     |
  | email | $email | required, string, valid email, unique in "users" |

If $email is empty string:

  • required → pass (value exists)
  • string → pass
  • valid email → fail
  • unique in "users" → still checked, may fail

All errors collected and returned.

With Bail (Stop Early) ​

eventflow
emit :register to @user with:
  | field | value  | validation                                          |
  | email | $email | bail, required, string, valid email, unique in "users" |

If $email is empty string:

  • required → pass
  • string → pass
  • valid email → fail
  • unique in "users" → NOT checked (bailed)

Only first error returned. Useful when later validations are expensive (database queries).

Custom Error Messages ​

Add a messages: block after the with: table to customize error messages:

eventflow
emit :register to @user with:
  | field    | value  | validation                    |
  | email    | $email | required, string, valid email |
  | password | $pass  | required, string, min 8 chars |

  messages:
    email.required: "Email adresi zorunludur"
    email.valid email: "Geçerli bir email adresi girin"
    password.required: "Şifre zorunludur"
    password.min 8 chars: "Şifre en az 8 karakter olmalıdır"

Message Format ​

Messages use field.rule format:

KeyMeaning
email.requiredMessage when email fails required rule
email.valid emailMessage when email fails valid email rule
password.min 8 charsMessage when password fails min 8 chars rule

Placeholders in Messages ​

eventflow
messages:
  amount.greater than 0: "{field} must be positive, got {value}"
  items.min 1 item: "Cart must have at least {min} item"

Available placeholders:

  • {field} - Field name
  • {value} - Current value
  • {min}, {max} - Numeric constraints
  • {rule} - Full rule text

Nested Data Validation ​

Object Fields (Dot Notation) ​

eventflow
emit :register to @user with:
  | field           | value            | validation                              |
  | address         | $address         | required, object                        |
  | address.street  | $address.street  | required, string, not empty             |
  | address.city    | $address.city    | required, string, not empty             |
  | address.zip     | $address.zip     | required, string, matching pattern "^[0-9]{5}$" |
  | address.country | $address.country | required, string, exactly 2 characters  |

Array Items (Wildcard) ​

eventflow
emit :checkout to @order with:
  | field              | value               | validation                       |
  | items              | $items              | required, array, min 1 item      |
  | items.*.product_id | $items.*.product_id | required, string, valid uuid     |
  | items.*.quantity   | $items.*.quantity   | required, integer, at least 1    |
  | items.*.price      | $items.*.price      | required, number, greater than 0 |

Conditional Validation ​

When Clause ​

eventflow
emit :pay to @payment with:
  | field          | value        | validation                                                |
  | payment_method | $method      | required, string, one of "card", "bank", "crypto"         |
  | card_number    | $card_number | required, string, valid credit_card when payment_method is "card" |
  | card_cvv       | $card_cvv    | required, string, exactly 3 characters when payment_method is "card" |
  | iban           | $iban        | required, string, valid iban when payment_method is "bank"|
  | wallet_address | $wallet      | required, string when payment_method is "crypto"          |

Unless Clause ​

eventflow
emit :update_profile to @user with:
  | field            | value     | validation                                            |
  | keep_password    | $keep     | optional, boolean                                     |
  | new_password     | $new_pass | required, string, min 8 characters unless keep_password |
  | confirm_password | $confirm  | required, string, equal to $new_password unless keep_password |

Error Handling ​

Validation Failed Event ​

When event validation fails, :validation_failed is automatically emitted:

eventflow
on :validation_failed
  emit :checkout_error to @customer with:
    | field  | value   | validation |
    | errors | $errors | required   |

Error Structure ​

json
{
  "event": ":checkout",
  "source": "@customer",
  "timestamp": "2024-12-08T10:30:00Z",
  "failed_rules": [
    {
      "field": "email",
      "rule": "valid email",
      "value": "invalid-email",
      "message": "The email must be a valid email address"
    },
    {
      "field": "items",
      "rule": "min 1 item",
      "value": [],
      "message": "The items must have at least 1 item"
    }
  ],
  "summary": "2 validation errors occurred"
}

Custom Validation Rules (PHP Bindings) ​

When a rule is not built-in, EventFlow searches for a matching PHP binding.

Creating a Custom Rule ​

php
<?php

namespace App\Validation\Rules;

use EventFlow\Attributes\ValidationRule;
use EventFlow\Validation\Rule;
use EventFlow\Validation\Context;

#[ValidationRule('valid turkish_id')]
class ValidTurkishId implements Rule
{
    public function validate(mixed $value, Context $ctx): bool
    {
        if (!is_string($value) || strlen($value) !== 11) {
            return false;
        }

        return $this->validateChecksum($value);
    }

    public function message(): string
    {
        return 'The :field must be a valid Turkish ID number';
    }

    private function validateChecksum(string $id): bool
    {
        // Implementation...
        return true;
    }
}

Usage ​

eventflow
emit :register to @user with:
  | field     | value      | validation                          |
  | tc_kimlik | $tc_kimlik | required, string, valid turkish_id  |

EventFlow resolution:

  1. required → Built-in ✓
  2. string → Built-in ✓
  3. valid turkish_id → Not built-in → Search bindings → Found ValidTurkishId ✓

Parameterized Rules ​

php
#[ValidationRule('unique in {table}')]
class UniqueInDatabase implements Rule
{
    public function validate(mixed $value, Context $ctx, string $table): bool
    {
        return !$this->db->table($table)->where('value', $value)->exists();
    }
}

Usage:

eventflow
emit :register to @user with:
  | field | value  | validation                                |
  | email | $email | bail, required, string, unique in "users" |

Note: Using bail before expensive rules like unique in "users" prevents unnecessary database queries when earlier validations fail.

Testing Integration ​

Testing Valid Data ​

eventflow
scenario: successful checkout
  given:
    @customer is logged in
    cart has items

  when:
    @customer sends :checkout to @order with:
      | field    | value                                  | validation                       |
      | order_id | "550e8400-e29b-41d4-a716-446655440000" | required, string, valid uuid     |
      | email    | "customer@example.com"                 | required, string, valid email    |
      | total    | 59.98                                  | required, number, greater than 0 |

  expect:
    = order is in #processing
    = :validation_failed was not emitted

Testing Validation Failures ​

eventflow
test: @order
  for scenario: checkout

  invalid email rejected:
    when:
      @customer sends :checkout to @order with:
        | field | value          | validation                    |
        | email | "not-an-email" | required, string, valid email |
    expect:
      = :validation_failed was emitted
      = order is not in #processing

  empty cart rejected:
    when:
      @customer sends :checkout to @order with:
        | field | value | validation                  |
        | items | []    | required, array, min 1 item |
    expect:
      = :validation_failed was emitted

Testing Context Constraints ​

eventflow
test: @order
  for scenario: retry payment

  retry limit enforced:
    given:
      $retry_count is 5
    when:
      @customer sends :retry_payment
    expect:
      = :constraint_violated was emitted
      = $retry_count is still 5

Adoption Path ​

EventFlow validation is designed for iterative adoption. Start simple, add complexity as needed.

Step 1: Start Simple ​

Begin with just required:

eventflow
emit :checkout to @order with:
  | field    | value     | validation |
  | order_id | $order_id | required   |
  | email    | $email    | required   |

Step 2: Add Types ​

Add type validation when type errors appear:

eventflow
emit :checkout to @order with:
  | field    | value     | validation       |
  | order_id | $order_id | required, string |
  | email    | $email    | required, string |
  | total    | $total    | required, number |

Step 3: Add Formats ​

Add format validation for specific requirements:

eventflow
emit :checkout to @order with:
  | field    | value     | validation                       |
  | order_id | $order_id | required, string, valid uuid     |
  | email    | $email    | required, string, valid email    |
  | total    | $total    | required, number, greater than 0 |

Step 4: Add Custom Rules ​

Implement domain-specific rules as PHP bindings:

eventflow
emit :checkout to @order with:
  | field     | value      | validation                         |
  | tc_kimlik | $tc_kimlik | required, string, valid turkish_id |

Step 5: Add Context Constraints ​

Add context validation when state constraints become important:

eventflow
$retry_count increases by 1
  | $retry_count | required, integer, between 0 and 5 |

Complete Example ​

eventflow
machine: @order

on :checkout from @customer (api)
  $order_id becomes uuid()
  $customer_email becomes $email
  $retry_count becomes 0
    | $order_id       | required, string, valid uuid  |
    | $customer_email | required, string, valid email |
    | $retry_count    | required, integer, at least 0 |

  emit :payment_request to @payment with:
    | field    | value     | validation                       |
    | order_id | $order_id | required, string, valid uuid     |
    | amount   | $total    | required, number, greater than 0 |
    | email    | $email    | required, string, valid email    |
    | items    | $items    | required, array, min 1 item      |

    messages:
      email.valid email: "Please provide a valid email address"
      amount.greater than 0: "Order total must be positive"
      items.min 1 item: "Cannot checkout with empty cart"

  order moves to #awaiting_payment

on :validation_failed
  emit :checkout_error to @customer with:
    | field  | value   | validation |
    | errors | $errors | required   |

on :constraint_violated
  ? $field is "$retry_count"
    emit :max_retries_exceeded to @customer
  ?
    log constraint violation with $field, $rule, $value

Summary ​

FeatureSyntax
Event data + validationemit ... with: table with 3 columns
Context validationTable after action(s)
Multiple rulesComma-separated: required, string, valid email
Type as rulestring, number, integer, etc. in validation
Unknown rulesAuto-search PHP bindings
Required fieldrequired
Optional fieldoptional
Numeric constraintgreater than, between, at least
String constraintmin N characters, not empty
Format validationvalid email, valid uuid, valid date
Array constraintmin N items, not empty
Enumone of "a", "b", "c"
Nested fieldaddress.city in field column
Array itemsitems.* in field column
Conditionalwhen field is "value" suffix
Custom messagemessages: block with field.rule keys
Default behaviorCheck all rules, collect all errors
Stop earlybail rule for field-level opt-out
Event failure:validation_failed
Context failure:constraint_violated + rollback
Custom PHP rule#[ValidationRule('...')]

Released under the MIT License.