Creating Custom Conditions

Custom conditions define the "when" logic that determines if a rule should execute. This guide covers registering and using custom conditions.

Quick Start#Copied!

 1use MilliRulesRules;
 2use MilliRulesContext;
 3
 4// 1. Register reusable condition
 5Rules::register_condition('is_weekend', function ($args, Context $context) {
 6    // Reusable logic receiving args + context
 7    $day = date('N'); // 1 (Monday) to 7 (Sunday)
 8    return $day >= 6; // Saturday or Sunday
 9});
10
11// 2. Create Rule
12Rules::create('weekend_special')
13    ->when()
14        // Option A: Inline custom condition without registration
15        ->custom('is_business_hours', function (Context $context) {
16            $hour = (int) date('H');
17            return $hour >= 9 && $hour <= 17;
18        })
19
20        // Option B: Call registered condition via magic method
21        ->is_weekend()
22
23        // Option C: Call registered condition via custom()
24        ->custom('is_weekend')
25    ->then()
26        ->custom('apply_discount')
27    ->register();

Registering Conditions#Copied!

Choosing the Right Registration Method#Copied!

MilliRules offers four ways to define custom conditions. Choose based on your needs:

Method Best For Reusable? Operator Support?
Inline with ->custom() One-off checks ❌ No ❌ No
Callback Registration Simple boolean checks ✅ Yes ❌ No
Namespace Registration Complex conditions with operators ✅ Yes ✅ Yes (via BaseCondition)
Manual Wrapper Advanced use cases ✅ Yes ✅ Yes (if using BaseCondition)

Recommendation: Start with inline ->custom() for one-off checks. Use callback registration for reusable simple checks. Use namespace registration for complex conditions with operator support.


Method 1: Inline with ->custom() (Simplest - One-Off Checks)#Copied!

Best for: Quick one-off conditions that are only used in a single rule.

Define the condition directly in the rule using a callback:

 1use MilliRulesRules;
 2use MilliRulesContext;
 3
 4Rules::create('business_hours_only')
 5    ->when()
 6        ->custom('is_business_hours', function(Context $context) {
 7            // One-off condition logic right here
 8            $hour = (int) date('H');
 9            return $hour >= 9 && $hour <= 17;
10        })
11    ->then()
12        ->custom('process_request')
13    ->register();

Note: Inline callbacks receive only the Context parameter (not $args), since arguments are redundant for inline-defined conditions. To access context data, use $context->get('key').

Example with context access:

 1Rules::create('premium_user_check')
 2    ->when()
 3        ->custom('is_premium', function(Context $context) {
 4            $user = $context->get('user.id');
 5            $status = get_user_meta($user, 'account_status', true);
 6
 7            return $status === 'premium';
 8        })
 9    ->then()
10        ->custom('enable_features')
11    ->register();

Pros:

  • ✅ Very simple - no separate registration step
  • ✅ Perfect for one-off checks
  • ✅ Quick to write and test
  • ✅ Clean signature - only receives Context

Cons:

  • ❌ Not reusable across multiple rules
  • ❌ No operator support
  • ❌ Harder to test in isolation

Method 2: Callback Registration (Reusable Simple Checks)#Copied!

Best for: Reusable boolean checks across multiple rules, simple logic without operators.

Register once, use everywhere:

 1use MilliRulesRules;
 2use MilliRulesContext;
 3
 4// Register once at plugin initialization
 5Rules::register_condition('is_weekend', function($args, Context $context) {
 6    $day = date('N'); // 1 (Monday) to 7 (Sunday)
 7    return $day >= 6; // Saturday or Sunday
 8});
 9
10// With configuration
11Rules::register_condition('time_in_range', function($args, Context $context) {
12    $current_hour = (int) date('H');
13    $start = $args[0] ?? 0;
14    $end = $args[1] ?? 23;
15
16    return $current_hour >= $start && $current_hour <= $end;
17});
18
19// Access context data
20Rules::register_condition('user_has_role', function($args, Context $context) {
21    $context->load('user');
22    $required_role = $args['value'] ?? $args[0] ?? '';
23    $user_roles = $context->get('user.roles', []);
24
25    return in_array($required_role, $user_roles, true);
26});

Then use in any rule:

 1Rules::create('weekend_special')
 2    ->when()
 3        ->is_weekend()
 4        ->time_in_range(9, 17)
 5        ->user_has_role('customer')
 6    ->then()
 7        ->custom('apply_discount')
 8    ->register();

Pros:

  • ✅ Reusable across all rules
  • ✅ Simple to register and use
  • ✅ Good for boolean checks

Cons:

  • ❌ No operator support (must implement manually)
  • ❌ Harder to organize many conditions

Method 3: Namespace Registration (Best for Classes)#Copied!

Best for: Complex conditions with operator support, reusable logic, testable code.

Register an entire namespace once and all condition classes are auto-discovered:

 1use MilliRulesRules;
 2
 3// One-time registration at plugin initialization
 4Rules::register_namespace('Conditions', 'MyPluginConditions');

Create your condition class:

 1namespace MyPluginConditions;
 2
 3use MilliRulesConditionsBaseCondition;
 4use MilliRulesContext;
 5
 6class UserPurchaseCount extends BaseCondition
 7{
 8    protected function get_actual_value(Context $context): int
 9    {
10        $context->load('user');
11        $user_id = $context->get('user.id', 0);
12
13        if (!$user_id) {
14            return 0;
15        }
16
17        // Get purchase count from database
18        return (int) get_user_meta($user_id, 'purchase_count', true);
19    }
20
21    protected function get_expected_value(): int
22    {
23        return (int) ($this->config['value'] ?? 0);
24    }
25
26    public function get_type(): string
27    {
28        return 'user_purchase_count';  // Used for auto-discovery
29    }
30}

How it works:

  • The class name UserPurchaseCount is converted to user_purchase_count
  • MilliRules finds the class automatically via get_type()
  • No need to manually register each condition
  • Supports all operators (=, !=, >, >=, <, <=, LIKE, IN, REGEXP, EXISTS, IS)
  • Access configuration via $this->config

Usage:

 1Rules::create('vip_customers')
 2    ->when()
 3        // Both calling styles work identically
 4        ->user_purchase_count(10, '>=')  // Auto-discovered, operator supported
 5        // OR
 6        ->custom('user_purchase_count', ['value' => 10, 'operator' => '>='])
 7    ->then()
 8        ->custom('apply_vip_discount')
 9    ->register();

Method 4: Manual Wrapper (Advanced - Rarely Needed)#Copied!

Only use when: Namespace registration isn't suitable (dynamic class names, runtime conditions, etc.)

 1use MilliRulesRules;
 2
 3// ⚠️ Avoid this if possible - creates type duplication
 4Rules::register_condition('user_purchase_count', function($args, Context $context) {
 5    $condition = new MyPluginConditionsUserPurchaseCount($args, $context);
 6    return $condition->matches($context);
 7});

Why to avoid:

  • Type specified twice (in registration AND in get_type())
  • More verbose than namespace registration
  • Harder to maintain

When it's necessary:

  • You can't register the entire namespace
  • You need to pass custom dependencies to the constructor
  • You need conditional registration logic

Using Registered Conditions#Copied!

Once registered, conditions can be used via dynamic method calls:

 1Rules::create('special_offer')
 2    ->when()
 3        ->is_weekend()                      // Dynamic method
 4        ->time_in_range(9, 17)              // With parameters
 5        ->user_has_role('customer')         // Single value
 6    ->then()
 7        ->custom('send_offer')
 8    ->register();

Argument Patterns#Copied!

Both ->condition_name() and ->custom() accept identical argument formats:

No parameters (boolean check):

 1->is_weekend()
 2->custom('is_weekend')
 3// Both result in: ['type' => 'is_weekend']

Single value (equality check):

 1->user_role('administrator')
 2->custom('user_role', 'administrator')
 3// Both result in: ['type' => 'user_role', 'value' => 'administrator', 'operator' => '=']

Value with operator:

 1->user_age(18, '>=')
 2->custom('user_age', ['value' => 18, 'operator' => '>='])
 3// Both result in: ['type' => 'user_age', 'value' => 18, 'operator' => '>=']

Multiple positional arguments:

 1->time_in_range(9, 17)
 2// Result in: ['type' => 'time_in_range', 0 => 9, 1 => 17]
 3// Access via: $args[0], $args[1]

When to use which:

  • Dynamic methods: Shorter syntax when method name is the condition type
  • ->custom(): When condition type needs to be dynamic or passed as variable

Operator Support#Copied!

Custom conditions inheriting from BaseCondition automatically support all standard operators:

  • =, == - Equality
  • !=, <> - Not equal
  • >, >=, <, <= - Comparison
  • LIKE, NOT LIKE - Pattern matching
  • IN, NOT IN - Array membership
  • REGEXP - Regular expression
  • EXISTS, NOT EXISTS - Value existence
  • IS - Boolean strict comparison

See Operators Reference for complete details.

Auto-Detection#Copied!

When using dynamic methods, operators are auto-detected from value types:

 1->user_age(18)                           // Auto: '=' for scalar
 2->user_age(18, '>=')                     // Explicit: '>='
 3->user_email('')              // Auto: 'LIKE' for wildcard
 4->user_role(['admin', 'editor'])         // Auto: 'IN' for array
 5->is_logged_in(true)                     // Auto: 'IS' for boolean

Configuration Reference#Copied!

Standard Config Keys#Copied!

 1[
 2    'type' => 'condition_type',  // Required: condition identifier
 3    'value' => 'expected_value', // Common: value to compare against
 4    'operator' => '=',           // Common: comparison operator (default: '=')
 5    'name' => 'field_name',      // For name-based conditions (header, param, etc.)
 6    // ... custom keys as needed
 7]

Common Patterns#Copied!

Boolean check:

 1->is_weekend()
 2// Becomes: ['type' => 'is_weekend', 'operator' => 'IS']

Single value (equality):

 1->user_role('administrator')
 2// Becomes: ['type' => 'user_role', 'value' => 'administrator', 'operator' => '=']

Value with operator:

 1->user_age(18, '>=')
 2// Becomes: ['type' => 'user_age', 'value' => 18, 'operator' => '>=']

Name-based condition:

 1->request_header('User-Agent', '*Chrome*')
 2// Becomes: ['type' => 'request_header', 'name' => 'User-Agent', 'value' => '*Chrome*', 'operator' => 'LIKE']

Complex configuration:

 1->custom('advanced_check', [
 2    'value' => 'expected',
 3    'operator' => 'LIKE',
 4    'case_sensitive' => false,
 5    'cache' => true
 6])

Best Practices#Copied!

1. Always Return Boolean#Copied!

 1// ✅ Good - explicit boolean return
 2Rules::register_condition('is_valid', function($args, Context $context) {
 3    $value = $context->get('custom.value');
 4    return (bool) $value;  // Explicit cast
 5});
 6
 7// ❌ Bad - may return non-boolean
 8Rules::register_condition('is_valid', function($args, Context $context) {
 9    return $context->get('custom.value');  // Could be string, int, null...
10});

2. Avoid Side Effects#Copied!

 1// ✅ Good - pure check
 2Rules::register_condition('has_permission', function($args, Context $context) {
 3    return current_user_can($args['value'] ?? 'read');
 4});
 5
 6// ❌ Bad - modifies state
 7Rules::register_condition('has_permission', function($args, Context $context) {
 8    update_option('last_check', time());  // Don't do this!
 9    return current_user_can($args['value'] ?? 'read');
10});

3. Handle Missing Data Gracefully#Copied!

 1Rules::register_condition('user_has_meta', function($args, Context $context) {
 2    $context->load('user');
 3    $user_id = $context->get('user.id', 0);
 4
 5    // Handle case where user is not logged in
 6    if (!$user_id) {
 7        return false;
 8    }
 9
10    $meta_key = $args['value'] ?? $args[0] ?? '';
11    return !empty(get_user_meta($user_id, $meta_key, true));
12});

4. Use Type Hints#Copied!

 1use MilliRulesContext;
 2
 3Rules::register_condition('my_check', function(array $args, Context $context): bool {
 4    // Full IDE autocomplete and type safety
 5    $value = $context->get('custom.key');
 6    return $value === ($args['value'] ?? null);
 7});

Common Pitfalls#Copied!

Must Return Boolean#Copied!

 1// ❌ Wrong - returns string
 2Rules::register_condition('check_status', function($args, Context $context) {
 3    return get_option('site_status');  // Returns 'active', 'inactive', etc.
 4});
 5
 6// ✅ Correct - returns boolean
 7Rules::register_condition('is_active', function($args, Context $context) {
 8    return get_option('site_status') === 'active';
 9});

Don't Cache Incorrectly#Copied!

 1// ❌ Bad - static cache persists across requests
 2static $cache = null;
 3Rules::register_condition('expensive_check', function($args, Context $context) use (&$cache) {
 4    if ($cache === null) {
 5        $cache = expensive_calculation();
 6    }
 7    return $cache > 10;  // Stale data on subsequent requests!
 8});
 9
10// ✅ Good - use transients or request-scoped caching
11Rules::register_condition('expensive_check', function($args, Context $context) {
12    $result = get_transient('expensive_check_result');
13    if (false === $result) {
14        $result = expensive_calculation();
15        set_transient('expensive_check_result', $result, 60);
16    }
17    return $result > 10;
18});

Don't Perform Actions in Conditions#Copied!

 1// ❌ Wrong - sends email every time condition is checked
 2Rules::register_condition('notify_admin', function($args, Context $context) {
 3    wp_mail('', 'Check ran', 'Condition checked');
 4    return true;
 5});
 6
 7// ✅ Correct - conditions check, actions do things
 8Rules::register_condition('should_notify', function($args, Context $context) {
 9    return $context->get('user.login') === 'special_user';
10});
11
12// Then use an action to send email when condition matches

Next Steps#Copied!