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.