Creating Custom Actions
Custom actions define the "then" and implement the actual business logic when rules match. This guide covers registering and using custom actions.
Quick Start#Copied!
1use MilliRulesRules;
2use MilliRulesContext;
3
4// 1. Register reusable action
5Rules::register_action('alert_slack', function ($config, Context $context) {
6 // Reusable logic receiving config + context
7 error_log("Slack Alert [{$config['channel']}]: " . ($config['message'] ?? ''));
8});
9
10// 2. Create Rule
11Rules::create('monitor_admin')
12 ->when()
13 ->request_url('/wp-admin/*')
14 ->user_role('editor')
15 ->then()
16 // Option A: Inline custom action without registration
17 ->custom('log_ip', function (Context $context) {
18 error_log("Access from IP: " . $context->get('request.ip'));
19 })
20
21 // Option B: Call registered action via magic method
22 ->alert_slack(['channel' => '#security', 'message' => 'Editor in admin area'])
23
24 // Option C: Call registered action via custom()
25 ->custom('alert_slack', ['channel' => '#security', 'message' => '{user.login} accessed admin area'])
26 ->register();
Registering Actions#Copied!
Choosing the Right Registration Method#Copied!
MilliRules offers four ways to define custom actions. Choose based on your needs:
| Method | Best For | Reusable? |
|---|---|---|
Inline with ->custom() |
One-off actions | ❌ No |
| Callback Registration | Reusable simple actions | ✅ Yes |
| Namespace Registration | Multiple action classes | ✅ Yes |
| Manual Wrapper | Advanced use cases | ✅ Yes |
Recommendation: Start with inline ->custom() for one-off actions. Use callback registration for reusable simple actions. Use namespace registration for complex actions with placeholders.
Method 1: Inline with ->custom() (Simplest - One-Off Actions)#Copied!
Best for: Quick one-off actions that are only used in a single rule.
Define the action directly in the rule using a callback:
1use MilliRulesRules;
2use MilliRulesContext;
3
4Rules::create('log_important_access')
5 ->when()->request_url('/important/*')
6 ->then()
7 ->custom('log_access', function(Context $context) {
8 // One-off action logic right here
9 error_log('Important page accessed: ' . $context->get('request.url'));
10 })
11 ->register();
Note: Inline callbacks receive only the Context parameter (not $args), since arguments are redundant for inline-defined actions. To access context data, use $context->get('key').
Example with context access:
1Rules::create('send_notification')
2 ->when()->user_role('administrator')
3 ->then()
4 ->custom('notify', function(Context $context) {
5 $user = $context->get('user.login');
6 $url = $context->get('request.url');
7
8 wp_mail(
9 '',
10 'Admin Login',
11 "User {$user} accessed {$url}"
12 );
13 })
14 ->register();
Pros:
- ✅ Very simple - no separate registration step
- ✅ Perfect for one-off actions
- ✅ Quick to write and test
- ✅ Clean signature - only receives Context
Cons:
- ❌ Not reusable across multiple rules
- ❌ No placeholder support (e.g.
{user.login}) - ❌ Harder to test in isolation
Method 2: Callback Registration (Reusable Simple Actions)#Copied!
Best for: Reusable actions across multiple rules, simple logic without placeholders.
Register once, use everywhere:
1use MilliRulesRules;
2use MilliRulesContext;
3
4// Register once at plugin initialization
5Rules::register_action('send_email', function($args, Context $context) {
6 $to = $args['to'] ?? '';
7 $subject = $args['subject'] ?? 'Notification';
8 $message = $args['message'] ?? '';
9
10 if (!$to) {
11 error_log('send_email: missing recipient');
12 return;
13 }
14
15 wp_mail($to, $subject, $message);
16});
17
18// Simple logging action
19Rules::register_action('log_message', function($args, Context $context) {
20 $message = $args['message'] ?? $args[0] ?? '';
21 error_log($message);
22});
Then use in any rule:
1Rules::create('log_important_requests')
2 ->when()->request_url('/important/*')
3 ->then()
4 ->log_message(['message' => 'Important page accessed'])
5 ->send_email(['to' => '', 'subject' => 'Important Access'])
6 ->register();
Pros:
- ✅ Reusable across all rules
- ✅ Simple to register and use
- ✅ Good for basic actions
Cons:
- ❌ No placeholder support (must implement manually)
- ❌ Harder to organize many actions
Method 3: Namespace Registration (Best for Classes)#Copied!
Register an entire namespace once and all action classes are auto-discovered:
1use MilliRulesRules;
2
3// One-time registration at plugin initialization
4Rules::register_namespace('Actions', 'MyPluginActions');
Create your action class:
1namespace MyPluginActions;
2
3use MilliRulesActionsBaseAction;
4use MilliRulesContext;
5
6class NotifyMail extends BaseAction
7{
8 public function execute($config, Context $context): void
9 {
10 // Access arguments via $this->args
11 $to = $this->args['to'] ?? '';
12 $message = $this->args['message'] ?? '';
13
14 // Resolve placeholders like {user.login}
15 $to = $this->resolve_value($to);
16 $message = $this->resolve_value($message);
17
18 wp_mail($to, 'Notification', $message);
19 }
20
21 public function get_type(): string
22 {
23 return 'notify_mail'; // Used for auto-discovery
24 }
25}
How it works:
- The class name
NotifyMailis converted tonotify_mail - MilliRules finds the class automatically via
get_type() - No need to manually register each action
- Supports placeholder resolution via
resolve_value() - Access arguments via
$this->args(numeric and named keys) - Access action type via
$this->type
Usage:
1Rules::create('notify_on_login')
2 ->when()->is_user_logged_in()
3 ->then()
4 // Both calling styles work identically
5 ->notify_mail(['to' => '', 'message' => 'User {user.login} logged in'])
6 // OR
7 ->custom('notify_mail', ['to' => '', 'message' => 'User {user.login} logged in'])
8 ->register();
Accessing Action Arguments#Copied!
When creating custom action classes (using namespace or manual registration), you need to access the arguments passed to the action. MilliRules provides a fluent get_arg() API for type-safe argument access with automatic placeholder resolution.
The get_arg() Method#Copied!
Access action arguments using the get_arg() method in your action classes:
1namespace MyPluginActions;
2
3use MilliRulesActionsBaseAction;
4use MilliRulesContext;
5
6class SendEmail extends BaseAction
7{
8 public function execute(Context $context): void
9 {
10 // Clean type-safe access with automatic placeholder resolution
11 $to = $this->get_arg('to', '')->string();
12 $subject = $this->get_arg('subject', 'Notification')->string();
13 $html = $this->get_arg('html', false)->bool();
14 $priority = $this->get_arg('priority', 10)->int();
15
16 wp_mail($to, $subject, 'Message content');
17 }
18
19 public function get_type(): string
20 {
21 return 'send_email';
22 }
23}
Type Conversion Methods#Copied!
The get_arg() method returns an ArgumentValue object that provides fluent type conversion:
| Method | Returns | Default for null |
|---|---|---|
->string() |
string |
'' (empty string) |
->bool() |
bool |
false |
->int() |
int |
0 |
->float() |
float |
0.0 |
->array() |
array |
[] (empty array) |
->raw() |
mixed |
null |
Automatic Placeholder Resolution#Copied!
Placeholders like {user.email} are automatically resolved when you call any type method:
1// Rule definition
2Rules::create('welcome_email')
3 ->when()->is_user_logged_in()
4 ->then()
5 ->send_email([
6 'to' => '{user.email}',
7 'subject' => 'Welcome {user.login}!'
8 ])
9 ->register();
10
11// In your SendEmail action class
12$to = $this->get_arg('to')->string();
13// Result: '' (placeholder automatically resolved)
14
15$subject = $this->get_arg('subject')->string();
16// Result: 'Welcome john!' (placeholder automatically resolved)
Positional Arguments#Copied!
Works with both named and positional arguments:
1class LogMessage extends BaseAction
2{
3 public function execute(Context $context): void
4 {
5 // Called via: ->logMessage('ERROR', 'Something broke', 3)
6 $level = $this->get_arg(0, 'info')->string();
7 $message = $this->get_arg(1, 'No message')->string();
8 $priority = $this->get_arg(2, 1)->int();
9
10 error_log("[{$level}] {$message} (priority: {$priority})");
11 }
12
13 public function get_type(): string
14 {
15 return 'log_message';
16 }
17}
Method 4: Manual Wrapper (Advanced - Rarely Needed)#Copied!
Only use when: Namespace registration isn't suitable (dynamic class names, runtime actions, etc.)
1use MilliRulesRules;
2
3// ⚠️ Avoid this if possible - creates type duplication
4Rules::register_action('notify', function($args, Context $context) {
5 $action = new MyPluginActionsNotifyMail($args, $context);
6 $action->execute($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 Actions#Copied!
Once registered, actions can be used via dynamic method calls:
1Rules::create('notify_admin')
2 ->when()->request_url('/important/*')
3 ->then()
4 ->send_email(['to' => '', 'subject' => 'Alert']) // Dynamic method
5 ->log_message(['message' => 'Important page accessed']) // Dynamic method
6 ->register();
Argument Patterns#Copied!
Both ->action_name() and ->custom() accept identical argument formats:
Named parameters (recommended):
1->send_email(['to' => '', 'subject' => 'Alert'])
2->custom('send_email', ['to' => '', 'subject' => 'Alert'])
3// Both result in: $this->args['to'], $this->args['subject']
Positional array:
1->send_email(['', 'Alert', 'Body'])
2->custom('send_email', ['', 'Alert', 'Body'])
3// Both result in: $this->args[0], $this->args[1], $this->args[2]
Single value:
1->log_message('Important event occurred')
2->custom('log_message', 'Important event occurred')
3// Both result in: $this->args[0]
When to use which:
- Dynamic methods: Shorter syntax when method name is the action type
->custom(): When action type needs to be dynamic or passed as variable
Best Practices#Copied!
1. Keep Actions Focused#Copied!
1// ✅ Good - single responsibility
2Rules::register_action('log_event', function($args, Context $context) {
3 error_log($args[0] ?? '');
4});
5
6// ❌ Bad - too many responsibilities
7Rules::register_action('do_everything', function($args, Context $context) {
8 // Logs, sends email, updates database, clears cache...
9});
2. Validate Configuration#Copied!
1Rules::register_action('send_email', function($args, Context $context) {
2 if (!isset($args['to']) || !is_email($args['to'])) {
3 error_log('send_email: invalid recipient');
4 return;
5 }
6
7 wp_mail($args['to'], $args['subject'] ?? '', $args['message'] ?? '');
8});
3. Handle Errors Gracefully#Copied!
1Rules::register_action('api_call', function($args, Context $context) {
2 try {
3 $response = wp_remote_post($args['url'] ?? '', [
4 'body' => $args['data'] ?? []
5 ]);
6
7 if (is_wp_error($response)) {
8 error_log('API call failed: ' . $response->get_error_message());
9 return;
10 }
11 } catch (Exception $e) {
12 error_log('API call exception: ' . $e->getMessage());
13 }
14});
4. Use Type Hints#Copied!
1use MilliRulesContext;
2
3Rules::register_action('my_action', function(array $args, Context $context): void {
4 // Full IDE autocomplete and type safety
5 $user = $context->get('user.login');
6});
Declaring Action Metadata#Copied!
Rules::register_action() returns an ActionMeta instance that lets you declare metadata about the action type. The metadata is used for both engine behavior (e.g., scoped locking) and consumer introspection (e.g., UI builders reading labels and descriptions).
Callback-Based Metadata#Copied!
Chain metadata methods directly after registration. The returned ActionMeta is the same instance stored in the registry, so all chained calls persist:
1Rules::register_action('add_flag', $addCallback)
2 ->scope('flag')
3 ->label('Add Flag')
4 ->description('Tag the response with a flag for bulk invalidation.')
5 ->categories('flags')
6 ->args()
7 ->string(0)->label('Flag')->required();
Class-Based Metadata via set_meta()#Copied!
For class-based actions extending BaseAction, override two static methods:
get_scope()— returns the lock scope. Called by the engine during rule execution, which may happen during early bootstrap. Must return a plain string — no framework-specific function calls.set_meta()— configures consumer-facing metadata. Called only when consumers request full metadata, after the framework has initialized.
1use MilliRulesActionsActionMeta;
2use MilliRulesActionsBaseAction;
3use MilliRulesContext;
4
5class AddFlag extends BaseAction
6{
7 // Engine-relevant. Called during early bootstrap — plain strings only.
8 public static function get_scope(): string
9 {
10 return 'flag';
11 }
12
13 // Consumer-relevant. Called after framework initialization.
14 public static function set_meta(ActionMeta $meta): void
15 {
16 $meta
17 ->label('Add Flag')
18 ->description('Tag the response with a flag.')
19 ->categories('flags');
20 }
21
22 public function execute(Context $context): void
23 {
24 $flag = $this->get_arg(0, '')->string();
25 // ... add the flag ...
26 }
27
28 public function get_type(): string
29 {
30 return 'add_flag';
31 }
32}
Why set_meta() takes an ActionMeta parameter instead of returning one: the engine owns the action type string (it knows what to look up), so it constructs the ActionMeta with the correct type and passes it in. Subclasses can't forget to call a parent method — there's no parent call to make — and they can't set the wrong type.
Why scope lives in get_scope() instead of set_meta(): the engine reads scope during rule execution (to build lock keys), which may happen during early bootstrap before the application framework has fully initialized. If scope were set inside set_meta() alongside framework-dependent calls, the engine couldn't read it safely. Splitting scope into a separate, string-only static method keeps the engine hot path runtime-safe.
Available Metadata Fields#Copied!
| Method / Override | Purpose | Read by | Where to declare |
|---|---|---|---|
get_scope() |
Lock grouping | RuleEngine (hot path) | Static method on class |
label() |
Human-readable name | Consumers (UIs) | Inside set_meta() |
description() |
Help text | Consumers (UIs) | Inside set_meta() |
categories() |
UI grouping (one or more) | Consumers (UIs) | Inside set_meta() |
args() |
Enter arguments context | Consumers (UIs) | Inside set_meta() |
extend() |
Plugin-specific bag | Consumers | Inside set_meta() |
get_scope()is engine-relevant: theRuleEnginecalls it directly (not viaset_meta()) to build value-level lock keys for paired actions (e.g.,add_flag/remove_flag). It must be runtime-safe — no framework-specific function calls — because rules may execute during early bootstrap.label,description,categories,argsare stored but never interpreted by MilliRules itself. They live insideset_meta(), which is called only when consumers (UI builders, CLIs, docs generators) introspect viaRules::get_action_meta($type).extendis the catch-all for anything plugin-specific that doesn't belong in MilliRules core.
Note:
set_meta()is called after the application framework has fully initialized. If your framework provides translation functions (e.g.,__()in WordPress), you can safely use them for labels, descriptions, and argument labels insideset_meta(). In contrast,get_scope()runs during early bootstrap and must return plain strings only.
Declaring Arguments#Copied!
Enter the arguments declaration context with $meta->args(). Inside, use type factories (->integer($key), ->string($key), etc.) to declare each argument, then chain config setters (->label(), ->default(), etc.) directly on it. To declare another argument, just call another type factory — it "walks" back to the builder and starts a new one.
This mirrors the ->when()/->then() context pattern from rule building.
MilliRules ships a small set of engine-level types (string, integer, number, boolean, choice, choices) plus an open format field for consumer-defined UI hints like 'url', 'seconds', or 'regex'.
1use MilliRulesActionsActionMeta;
2use MilliRulesActionsBaseAction;
3
4class SetTtl extends BaseAction
5{
6 public static function describe(ActionMeta $meta): void
7 {
8 $meta
9 ->label('Set TTL')
10 ->description('Set cache time-to-live.')
11 ->categories('caching')
12 ->args()
13 ->integer('ttl')
14 ->format('seconds') // UI hint: render as duration picker
15 ->label('TTL')
16 ->description('Duration in seconds')
17 ->default(3600)
18 ->min(0)
19 ->max(86400)
20 ->string('reason')
21 ->label('Reason')
22 ->default('');
23 }
24
25 public function execute(Context $context): void
26 {
27 $ttl = $this->get_arg('ttl', 3600)->int();
28 $reason = $this->get_arg('reason', '')->string();
29 // ...
30 }
31
32 public function get_type(): string { return 'set_ttl'; }
33}
Key points:
- No class name imports for arguments. You never write
ArgumentSchemaorArgumentsBuilderin your own code — they're internal. You only chain methods starting from$meta->args(). - Type is chosen by the factory method name (
->integer($key),->string($key), etc.). It's fixed once the argument is created. - Runtime guards catch misuse immediately. Calling
->min()on a string schema is fine (it's a length bound), but calling->min()on a boolean throwsInvalidArgumentExceptionat class-load time. default()rejects closures because schemas must be JSON-serializable. Pass scalars or arrays.- Declaration order is preserved — the order you declare arguments is the order consumers receive them.
options()for choice/choices — use->options([...])to declare the allowed values for->choice($key)or->choices($key)arguments. Accepts either simple form['a', 'b']or structured[['value' => 'a', 'label' => 'A']].- Consumer utilities are available:
$schema->validate($value)returns a plain English error or null;$schema->sanitize($value)coerces raw input to the declared type. MilliRules'RuleEnginedoes not call these — they're opt-in for consumers (validators, UIs, CLIs) that want to share coercion logic. - Meta methods called after
->args()are auto-forwarded — you can continue chaining->extend(),->categories(), or any otherActionMetamethod after declaring arguments. The chain routes through the argument schema's__call()back to the parent meta. No->end()or "put args() last" ceremony needed.
Choice and choices example
1$meta
2 ->label('Cache Mode')
3 ->args()
4 ->choice('strategy')
5 ->options(['eager', 'lazy', 'off'])
6 ->default('lazy')
7 ->label('Caching Strategy')
8 ->choices('vary_by')
9 ->options(['user', 'locale', 'device'])
10 ->default(['user'])
11 ->label('Vary By');
See the ArgumentSchema API reference for the full API.
Plugin-Specific Metadata via extend()#Copied!
Anything that doesn't belong in MilliRules core — icons, conditional visibility rules, documentation URLs, plugin-defined widgets — can be attached to ActionMeta via the extension bag:
1public static function describe(ActionMeta $meta): void
2{
3 $meta
4 ->label('Set TTL')
5 ->categories('caching')
6 // Plugin-specific metadata: MilliRules stores these but never reads them.
7 ->extend('my-plugin:icon', 'clock')
8 ->extend('my-plugin:docs_url', 'https://example.com/actions/set-ttl')
9 ->extend('my-plugin:requires_addon', 'pro');
10}
Namespacing convention: prefix your keys with your plugin slug and a colon (my-plugin:field-name) to avoid collisions with other consumers. MilliRules does not enforce this — the convention is the contract between consumers.
Consumers read extensions via:
1$meta = Rules::get_action_meta('set_ttl');
2$icon = $meta?->get_extension('my-plugin:icon'); // 'clock'
3$has = $meta?->has_extension('my-plugin:icon'); // true
4$all = $meta?->get_extensions(); // full keyed bag
Use arguments() for structured data that every consumer needs (argument metadata), and extend() for data that only specific consumers care about.
Introspecting Action Metadata#Copied!
Consumers can query metadata for any registered action type:
1$meta = Rules::get_action_meta('add_flag');
2if ($meta) {
3 echo $meta->get_label(); // 'Add Flag'
4 $cats = $meta->get_categories(); // ['flags']
5 $args = $meta->get_arguments(); // array<ArgumentSchema>
6 $icon = $meta->get_extension('my-plugin:icon');
7 $data = $meta->to_array(); // Serializable array for REST
8}
This works for both callback-based and class-based actions uniformly.
Common Pitfalls#Copied!
Don't Modify Context Expecting Persistence#Copied!
1// ❌ Wrong - context changes don't persist between rules
2Rules::register_action('bad_action', function($args, Context $context) {
3 $context->set('custom_value', 'modified');
4 // This change is lost after the action completes!
5});
6
7// ✅ Correct - use external state
8Rules::register_action('good_action', function($args, Context $context) {
9 update_option('custom_value', 'modified');
10 // Or use globals, database, cache, etc.
11});
Don't Perform Heavy Operations Without Caching#Copied!
1// ❌ Bad - runs on every execution
2Rules::register_action('slow_action', function($args, Context $context) {
3 $data = expensive_api_call();
4 process_data($data);
5});
6
7// ✅ Good - cache expensive operations
8Rules::register_action('cached_action', function($args, Context $context) {
9 $data = get_transient('cached_data');
10 if (false === $data) {
11 $data = expensive_api_call();
12 set_transient('cached_data', $data, HOUR_IN_SECONDS);
13 }
14 process_data($data);
15});
Next Steps#Copied!
- Custom Conditions - Create conditional logic
- Built-in Actions Reference - See available actions
- Placeholder System - Dynamic value resolution
- API Reference - Complete API documentation