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
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:
// 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
$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:
$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:
- 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
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_valueValidation Rules
Rules are comma-separated. Type rules (string, number) are validation rules like any other.
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 |
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:
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
Nested 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 |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 |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"
}Custom Validation Rules (PHP Bindings)
When a rule is not built-in, EventFlow searches for a matching PHP binding.
Creating a Custom Rule
<?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
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.
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 5Adoption Path
EventFlow validation is designed for iterative adoption. 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 |Complete Example
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, $valueSummary
| 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('...')] |
Related
- Context & Variables - Context operations
- Events Overview - Event handling
- Error Handling - Error events
- PHP Binding - Custom rules