Data Validation Proposal
Status: ✅ Implemented
This proposal has been implemented and integrated into the main documentation.
See the following documentation pages:
- Data Validation Guide - Complete guide
- Context & Variables - Context validation tables
- Events Overview - Event data validation
- Error Handling - Validation error events
- Keywords Reference - Events - Validation syntax
- PHP Binding - Custom validation rules
This proposal is kept as an archive for reference.
Problem Statement
EventFlow currently lacks a comprehensive data validation system. This creates several issues:
- No event data validation - Events entering the system are not validated before processing
- No context constraints - Context variables can enter invalid states
- Silent failures - Corrupt data causes runtime errors that are hard to trace
- 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.
| Feature | Location | Purpose |
|---|---|---|
Extended with: table | Event emission | Define data schema AND validation in one place |
| Post-action validation table | After context actions | Define constraints for context variables |
Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Table columns | field, value, validation | Type is a validation rule, not separate column |
| Rule separator | Comma | Clear separation between multiple rules |
| Unknown rules | Search bindings | Custom rules resolved via PHP bindings |
with placement | Same line as emit | Cleaner, more compact syntax |
| Context validation | Post-action table | Validation defined after action(s), optional |
| Default behavior | Check all rules | Collect all errors by default |
| Bail behavior | Field-level opt-out | Use bail rule to stop at first error |
| On event failure | Emit error event | :validation_failed event is emitted |
| On context failure | Emit + 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)
emit :checkout to @order with:
| field | value |
| order_id | $order_id |
| email | $email |
| total | $total |New Syntax (With Validation)
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.
| 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 rulesValidation is Optional
Validation rules can be added iteratively. Start without validation, add rules as needed:
// 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
$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:
$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:
// No validation - works fine
$retry_count increases by 1
$total becomes $subtotal + $taxAdd validation later when constraints become important:
// With validation - added when needed
$retry_count increases by 1
| $retry_count | required, integer, between 0 and 5 |Complete Example
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 5Constraint Violation (Detailed)
When a context constraint is violated, EventFlow performs a rollback and emits an event.
What Happens
- Context variable has current value (e.g.,
$retry_count= 5) - Action attempts to change it (e.g.,
$retry_count increases by 1) - New value would be 6
- Validation check:
between 0 and 5fails - Rollback:
$retry_countstays at 5 - Event:
:constraint_violatedis emitted
Constraint Violated Event Payload
{
"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
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_valueFlow 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)
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→ passvalid email→ failunique in "users"→ still checked, may fail
All errors collected and returned.
With Bail (Stop Early)
emit :register to @user with:
| field | value | validation |
| email | $email | bail, required, string, valid email, unique in "users" |If $email is empty string:
required→ passstring→ passvalid email→ failunique 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
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:
| Key | Meaning |
|---|---|
email.required | Message when email fails required rule |
email.valid email | Message when email fails valid email rule |
password.min 8 chars | Message when password fails min 8 chars rule |
Placeholders in Messages
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)
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_paymentAfter (With Validation)
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, $valueNested Data Validation
Object Fields (Dot Notation)
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)
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
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
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
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
| Rule | Description |
|---|---|
required | Must be present and not null |
optional | May be absent |
nullable | Can be null |
Type Rules
| Rule | Description |
|---|---|
string | Must be a string |
number | Must be numeric |
integer | Must be whole number |
boolean | Must be true/false |
array | Must be an array |
object | Must be an object |
datetime | Must be a datetime |
Numeric Rules
| Rule | Description |
|---|---|
greater than N | Value > N |
less than N | Value < N |
at least N | Value >= N |
at most N | Value <= N |
between N and M | N <= Value <= M |
positive | Value > 0 |
negative | Value < 0 |
String Rules
| Rule | Description |
|---|---|
min N characters | Minimum length |
max N characters | Maximum length |
exactly N characters | Exact length |
matching pattern "regex" | Regex match |
starting with "..." | Prefix check |
ending with "..." | Suffix check |
not empty | Non-empty string |
Format Rules
| Rule | Description |
|---|---|
valid email | Email format |
valid url | URL format |
valid phone | Phone format |
valid uuid | UUID format |
valid date | Date format |
valid datetime | DateTime format |
valid credit_card | Credit card number |
valid iban | IBAN format |
valid json | Valid JSON string |
valid ip | IP address |
Array Rules
| Rule | Description |
|---|---|
min N items | Minimum item count |
max N items | Maximum item count |
exactly N items | Exact item count |
of strings | All items are strings |
of numbers | All items are numbers |
of objects | All items are objects |
unique | No duplicates |
not empty | Has items |
Enum Rules
| Rule | Description |
|---|---|
one of "a", "b", "c" | Allowed values |
not one of "x", "y" | Disallowed values |
Comparison Rules
| Rule | Description |
|---|---|
equal to $other | Same as another field |
different from $other | Different from another |
before $date | Date is earlier |
after $date | Date is later |
Flow Control Rules
| Rule | Description |
|---|---|
bail | Stop 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
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
emit :register to @user with:
| field | value | validation |
| tc_kimlik | $tc_kimlik | required, string, valid turkish_id |EventFlow resolution:
required→ Built-in ✓string→ Built-in ✓valid turkish_id→ Not built-in → Search bindings → FoundValidTurkishId✓
Parameterized Rules
#[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:
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:
on :validation_failed
emit :checkout_error to @customer with:
| field | value | validation |
| errors | $errors | required |Error Structure
{
"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
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 emittedTesting Validation Failures
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 emittedTesting Context Constraints
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 5Workflow Integration
Data validation fits naturally into Event Flowing Sessions. Validation can be added iteratively as the team's understanding grows.
When to Discuss Validation
| Session | Participants | Event Validation | Context Validation |
|---|---|---|---|
| Session 2: Happy Path | Full Team | with: table with basic fields | Context used, no validation |
| Session 3: Edge Cases | QA + Dev | Detailed validation rules added | Constraints discussed |
| Session 4: Implementation | Dev Solo | Custom rules, messages | Dev adds validation tables |
Session 2: Data Contract Discovery
The team defines event data contracts. Validation rules can be minimal or absent:
// 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 $emailSession 3: Edge Case Testing
QA and Dev discuss edge cases. Validation rules emerge from questions:
// 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 | // NEWSession 4: Custom Validators
Developer implements custom rules and adds detailed validation:
// 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;
}
}// 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
# eventflow.config.yaml
validation:
mode: strict # fail on any error (default)
# mode: warn # log errors but continue
# mode: off # disable validationDefault Messages
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:
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:
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:
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:
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:
$retry_count increases by 1
| $retry_count | required, integer, between 0 and 5 |Summary
| Feature | Syntax |
|---|---|
| Event data + validation | emit ... with: table with 3 columns |
| Context validation | Table after action(s) |
| Multiple rules | Comma-separated: required, string, valid email |
| Type as rule | string, number, integer, etc. in validation |
| Unknown rules | Auto-search PHP bindings |
| Required field | required |
| Optional field | optional |
| Numeric constraint | greater than, between, at least |
| String constraint | min N characters, not empty |
| Format validation | valid email, valid uuid, valid date |
| Array constraint | min N items, not empty |
| Enum | one of "a", "b", "c" |
| Nested field | address.city in field column |
| Array items | items.* in field column |
| Conditional | when field is "value" suffix |
| Custom message | messages: block with field.rule keys |
| Default behavior | Check all rules, collect all errors |
| Stop early | bail rule for field-level opt-out |
| Event failure | :validation_failed |
| Context failure | :constraint_violated + rollback |
| Custom PHP rule | #[ValidationRule('...')] |
Key Benefits
- Single Source of Truth - Data schema and validation in one place
- 3-Column Simplicity - Type is just another validation rule
- Iterative Addition - Start without validation, add rules progressively
- Optional by Design - Validation tables are never required
- Comma-Separated Rules - Clear, readable multiple rules
- Auto-Binding Resolution - Unknown rules found in PHP bindings
- Consistent Syntax - Same table format for events and context
- Visible Contract - Everyone sees the full specification
- Default: Collect All - All errors returned by default
- Bail for Performance - Skip expensive validations on early failure