Skip to content

Data Validation Proposal ​

Status: ✅ Implemented


This proposal has been implemented and integrated into the main documentation.

See the following documentation pages:

This proposal is kept as an archive for reference.


Problem Statement ​

EventFlow currently lacks a comprehensive data validation system. This creates several issues:

  1. No event data validation - Events entering the system are not validated before processing
  2. No context constraints - Context variables can enter invalid states
  3. Silent failures - Corrupt data causes runtime errors that are hard to trace
  4. Defensive coding burden - Every handler must implement its own validation logic

Without validation, developers must either:

  • Trust all incoming data blindly (risky)
  • Write validation code in every handler (repetitive)
  • Handle invalid states throughout the codebase (complex)

Proposed Solution ​

Extend the existing with: table syntax to include validation rules, and add optional validation tables after context actions.

FeatureLocationPurpose
Extended with: tableEvent emissionDefine data schema AND validation in one place
Post-action validation tableAfter context actionsDefine constraints for context variables

Design Decisions ​

DecisionChoiceRationale
Table columnsfield, value, validationType is a validation rule, not separate column
Rule separatorCommaClear separation between multiple rules
Unknown rulesSearch bindingsCustom rules resolved via PHP bindings
with placementSame line as emitCleaner, more compact syntax
Context validationPost-action tableValidation defined after action(s), optional
Default behaviorCheck all rulesCollect all errors by default
Bail behaviorField-level opt-outUse bail rule to stop at first error
On event failureEmit error event:validation_failed event is emitted
On context failureEmit + rollback:constraint_violated event, value unchanged

Extended with: Table Syntax ​

The existing with: table is simplified to 3 columns: field, value, and validation. Type is now part of validation rules.

Current Syntax (No Validation) ​

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

New Syntax (With Validation) ​

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                 |
  | items    | $items    | required, array, min 1 item      |

Validation Rules ​

Rules are separated by commas. Type rules (string, number, array, etc.) are validation rules like any other. If a rule is not built-in, EventFlow searches for a matching PHP binding.

eventflow
| field     | value      | validation                               |
| email     | $email     | required, string, valid email            |  // Built-in rules
| tc_kimlik | $tc_kimlik | required, string, valid turkish_id       |  // Custom binding
| amount    | $amount    | required, number, greater than 0, less than 10000 |  // Multiple rules

Validation is Optional ​

Validation rules can be added iteratively. Start without validation, add rules as needed:

eventflow
// Phase 1: No validation (quick prototype)
emit :checkout to @order with:
  | field    | value     |
  | order_id | $order_id |
  | email    | $email    |

// Phase 2: Add basic validation
emit :checkout to @order with:
  | field    | value     | validation        |
  | order_id | $order_id | required          |
  | email    | $email    | required          |

// Phase 3: Add type validation
emit :checkout to @order with:
  | field    | value     | validation               |
  | order_id | $order_id | required, string         |
  | email    | $email    | required, string         |

// Phase 4: Add format validation
emit :checkout to @order with:
  | field    | value     | validation                    |
  | order_id | $order_id | required, string, valid uuid  |
  | email    | $email    | required, string, valid email |

How It Works ​

┌─────────────────────────────────────────────────────────────────────┐
│                         with: TABLE                                 │
├─────────────────────────────────────────────────────────────────────┤
│  field     │  value      │  validation                             │
├────────────┼─────────────┼─────────────────────────────────────────┤
│  order_id  │  $order_id  │  required, string, valid uuid           │
│  email     │  $email     │  required, string, valid email          │
│  total     │  $total     │  required, number, greater than 0       │
└─────────────────────────────────────────────────────────────────────┘
                              │
                              │ Each rule checked:
                              │   1. Is it built-in? → Use it
                              │   2. Not built-in? → Search bindings
                              ▼
              ┌───────────────────────────────┐
              │  • What data is sent          │
              │  • What validation rules      │
              │    must pass                  │
              └───────────────────────────────┘

Context Validation (Post-Action Table) ​

Context variables are validated via an optional table after the action(s) that use them. This keeps context usage natural while allowing constraints to be defined.

Single Context Variable ​

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

Multiple Context 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               |

Validation is Optional ​

Context validation tables are entirely optional. You can use context variables without any validation:

eventflow
// No validation - works fine
$retry_count increases by 1
$total becomes $subtotal + $tax

Add validation later when constraints become important:

eventflow
// With validation - added when needed
$retry_count increases by 1
  | $retry_count | required, integer, between 0 and 5 |

Complete Example ​

eventflow
machine: @order

on :checkout from @customer
  $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 |

  order moves to #awaiting_payment

on :retry_payment from @customer
  $retry_count increases by 1
    | $retry_count | required, integer, between 0 and 5 |

  // If $retry_count becomes 6, :constraint_violated is emitted
  // and $retry_count remains 5

Constraint Violation (Detailed) ​

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

What Happens ​

  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

Constraint Violated Event Payload ​

json
{
  "field": "$retry_count",
  "old_value": 5,
  "new_value": 6,
  "rule": "between 0 and 5",
  "message": "The retry_count must be between 0 and 5"
}

Handling Constraint Violations ​

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

Flow Diagram ​

$retry_count = 5
      │
      ▼
$retry_count increases by 1
      │
      ▼
┌─────────────────────────┐
│ New value would be: 6   │
│                         │
│ Check: between 0 and 5  │
│        6 > 5 → FAIL     │
└─────────────────────────┘
      │
      ▼
┌─────────────────────────┐
│ ROLLBACK                │
│ $retry_count stays 5    │
└─────────────────────────┘
      │
      ▼
┌─────────────────────────┐
│ EMIT :constraint_violated│
│ with:                   │
│   $field = "$retry_count"│
│   $old_value = 5        │
│   $new_value = 6        │
│   $rule = "between 0..."│
└─────────────────────────┘

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 per field and rule.

Syntax ​

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

Validation Flow ​

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)     │
│                                             │
└─────────────────────────────────────────────┘

Complete Example ​

Before (No Validation) ​

eventflow
machine: @order

on :checkout from @customer
  $order_id becomes uuid()
  $customer_email becomes $email
  emit :payment_request to @payment with:
    | field    | value     |
    | order_id | $order_id |
    | amount   | $total    |
  order moves to #awaiting_payment

After (With Validation) ​

eventflow
machine: @order

on :checkout from @customer
  $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

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 |

Deeply Nested ​

eventflow
emit :complex_order to @fulfillment with:
  | field                        | value | validation                 |
  | order.customer.address.city  | ...   | required, string, not empty|
  | order.items.*.variants.*.sku | ...   | required, string, not empty|

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 |

Built-in Validation Rules ​

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

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

declare(strict_types=1);

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.


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"
}

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

Workflow Integration ​

Data validation fits naturally into Event Flowing Sessions. Validation can be added iteratively as the team's understanding grows.

When to Discuss Validation ​

SessionParticipantsEvent ValidationContext Validation
Session 2: Happy PathFull Teamwith: table with basic fieldsContext used, no validation
Session 3: Edge CasesQA + DevDetailed validation rules addedConstraints discussed
Session 4: ImplementationDev SoloCustom rules, messagesDev adds validation tables

Session 2: Data Contract Discovery ​

The team defines event data contracts. Validation rules can be minimal or absent:

eventflow
// Session 2: Full team discussion
// Focus on WHAT data is sent, not validation details
emit :checkout to @order with:
  | field    | value     |
  | order_id | $order_id |  // Sarah: "Unique identifier"
  | email    | $email    |  // Maya: "For confirmation email"
  | total    | $total    |  // Jordan: "Must be positive"
  | items    | $items    |  // Jordan: "The cart items"

// Context usage - no validation yet
$order_id becomes uuid()
$customer_email becomes $email

Session 3: Edge Case Testing ​

QA and Dev discuss edge cases. Validation rules emerge from questions:

eventflow
// Session 3: Edge cases discovered

// Jordan: "What if email is malformed?"
// → Add validation rule

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

// Maya: "What if retry count exceeds 5?"
// → Add context constraint

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

Session 4: Custom Validators ​

Developer implements custom rules and adds detailed validation:

php
// Custom rule binding
#[ValidationRule('valid us_zipcode')]
class ValidUSZipcode implements Rule
{
    public function validate(mixed $value, Context $ctx): bool
    {
        return preg_match('/^[0-9]{5}(-[0-9]{4})?$/', $value) === 1;
    }
}
eventflow
// Dev adds comprehensive validation
emit :checkout to @order with:
  | field    | value     | validation                            |
  | order_id | $order_id | required, string, valid uuid          |
  | email    | $email    | bail, required, string, valid email, unique in "orders" |
  | total    | $total    | required, number, greater than 0      |
  | items    | $items    | required, array, min 1 item           |
  | zip      | $zip      | required, string, valid us_zipcode    |

  messages:
    email.valid email: "Please provide a valid email"
    email.unique in "orders": "This email already has an active order"

Configuration ​

Validation Mode ​

yaml
# eventflow.config.yaml
validation:
  mode: strict      # fail on any error (default)
  # mode: warn      # log errors but continue
  # mode: off       # disable validation

Default Messages ​

yaml
validation:
  messages:
    required: "The {field} field is required"
    email: "Please enter a valid email address"
    min_characters: "{field} must be at least {min} characters"

Adoption Path ​

EventFlow validation is designed for greenfield projects. 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 |

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('...')]

Key Benefits ​

  1. Single Source of Truth - Data schema and validation in one place
  2. 3-Column Simplicity - Type is just another validation rule
  3. Iterative Addition - Start without validation, add rules progressively
  4. Optional by Design - Validation tables are never required
  5. Comma-Separated Rules - Clear, readable multiple rules
  6. Auto-Binding Resolution - Unknown rules found in PHP bindings
  7. Consistent Syntax - Same table format for events and context
  8. Visible Contract - Everyone sees the full specification
  9. Default: Collect All - All errors returned by default
  10. Bail for Performance - Skip expensive validations on early failure

Released under the MIT License.