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.