Creating Custom Packages

Custom packages are the ultimate way to extend MilliRules. They let you bundle conditions, actions, context data, and placeholder resolvers into reusable, self-contained modules. This guide shows you how to create packages that integrate seamlessly with MilliRules.

Why Create Custom Packages?#Copied!

Custom packages enable you to:

  • Bundle related functionality - Group conditions and actions by domain
  • Provide context data - Make data available to all rules
  • Add placeholder resolvers - Enable dynamic values in rules
  • Integrate third-party services - Connect external APIs and systems
  • Create reusable libraries - Share packages across projects
  • Maintain clean separation - Keep code organized by concern

Package Structure#Copied!

A complete custom package includes:

MyCustomPackage/
├── MyCustomPackage.php      # Package class (implements PackageInterface)
├── Conditions/              # Condition classes
│   ├── CustomCondition1.php
│   └── CustomCondition2.php
├── Actions/                 # Action classes
│   ├── CustomAction1.php
│   └── CustomAction2.php
└── PlaceholderResolver.php  # Optional placeholder resolver

Implementing PackageInterface#Copied!

All packages must implement PackageInterface:

 1namespace MilliRulesPackages;
 2
 3use MilliRulesContext;
 4
 5interface PackageInterface {
 6    public function get_name(): string;
 7    public function get_namespaces(): array;
 8    public function is_available(): bool;
 9    public function get_required_packages(): array;
10    public function register_providers(Context $context): void;
11    public function get_placeholder_resolver(Context $context);
12    public function register_rule(array $rule, array $metadata);
13    public function execute_rules(array $rules, Context $context): array;
14}

Basic Custom Package#Copied!

Minimal Package Implementation#Copied!

 1namespace MyPluginPackages;
 2
 3use MilliRulesPackagesBasePackage;
 4
 5class MyCustomPackage extends BasePackage {
 6    /**
 7     * Package name (must be unique)
 8     */
 9    public function get_name(): string {
10        return 'MyCustom';
11    }
12
13    /**
14     * Namespaces for conditions and actions
15     */
16    public function get_namespaces(): array {
17        return [
18            'MyPluginPackagesMyCustomConditions',
19            'MyPluginPackagesMyCustomActions',
20        ];
21    }
22
23    /**
24     * Check if package can be used in current environment
25     */
26    public function is_available(): bool {
27        // Example: check if required functions exist
28        return function_exists('my_required_function');
29    }
30}

Registering the Package#Copied!

 1use MilliRulesMilliRules;
 2use MyPluginPackagesMyCustomPackage;
 3
 4// Register custom package
 5$custom_package = new MyCustomPackage();
 6MilliRules::init(null, [$custom_package]);
 7
 8// Or let MilliRules auto-discover (if registered globally)
 9MilliRules::init();

Registering Context Providers#Copied!

Packages register context providers that load data lazily when needed. This improves performance by only loading data that rules actually use.

Simple Context Provider#Copied!

 1use MilliRulesContext;
 2
 3public function register_providers(Context $context): void {
 4    // Register a simple provider that loads on-demand
 5    $context->register_provider('my_custom', function() {
 6        return [
 7            'my_custom' => [
 8                'value1' => get_option('my_option_1'),
 9                'value2' => get_option('my_option_2'),
10                'timestamp' => time(),
11            ],
12        ];
13    });
14}

Benefit: Data is only retrieved when $context->get('my_custom.value1') is called.

Dynamic Context Provider#Copied!

 1use MilliRulesContext;
 2
 3public function register_providers(Context $context): void {
 4    // Register provider that loads complex data on-demand
 5    $context->register_provider('my_custom', function() {
 6        $user_data = [];
 7        if (is_user_logged_in()) {
 8            $user_id = get_current_user_id();
 9            $user_data = [
10                'id' => $user_id,
11                'meta' => get_user_meta($user_id),
12                'purchases' => $this->get_user_purchases($user_id),
13            ];
14        }
15
16        return [
17            'my_custom' => [
18                'user' => $user_data,
19                'site' => [
20                    'name' => get_bloginfo('name'),
21                    'url' => home_url(),
22                ],
23                'stats' => [
24                    'total_posts' => wp_count_posts()->publish,
25                    'total_users' => count_users()['total_users'],
26                ],
27            ],
28        ];
29    });
30}
31
32private function get_user_purchases($user_id) {
33    global $wpdb;
34    return $wpdb->get_results($wpdb->prepare(
35        "SELECT * FROM {$wpdb->prefix}purchases WHERE user_id = %d",
36        $user_id
37    ));
38}

Benefit: Expensive database queries and WordPress functions only execute when needed.

Context Provider with External API#Copied!

 1use MilliRulesContext;
 2
 3public function register_providers(Context $context): void {
 4    // Register provider that loads API data on-demand
 5    $context->register_provider('my_custom', function() {
 6        // Cache expensive API calls
 7        $api_data = get_transient('my_custom_api_data');
 8
 9        if ($api_data === false) {
10            $response = wp_remote_get('https://api.example.com/data', [
11                'timeout' => 10,
12                'headers' => ['Authorization' => 'Bearer ' . $this->get_api_key()],
13            ]);
14
15            if (!is_wp_error($response)) {
16                $api_data = json_decode(wp_remote_retrieve_body($response), true);
17                set_transient('my_custom_api_data', $api_data, 300); // Cache 5 minutes
18            } else {
19                $api_data = [];
20            }
21        }
22
23        return [
24            'my_custom' => [
25                'api' => $api_data,
26                'cached_at' => get_transient('my_custom_api_data_time') ?: time(),
27            ],
28        ];
29    });
30}

Benefit: API calls only execute if a rule actually needs the API data.


Creating Package Conditions#Copied!

Package conditions extend the available condition types.

Simple Package Condition#Copied!

 1namespace MyPluginPackagesMyCustomConditions;
 2
 3use MilliRulesConditionsBaseCondition;
 4use MilliRulesContext;
 5
 6class UserLevelCondition extends BaseCondition {
 7    protected function get_actual_value(Context $context) {
 8        $context->load('user');
 9        $user_id = $context->get('user.id', 0);
10
11        if (!$user_id) {
12            return 0;
13        }
14
15        // Get custom user level
16        return (int) get_user_meta($user_id, 'user_level', true);
17    }
18
19    public function get_type(): string {
20        return 'user_level';
21    }
22}

Usage:

 1Rules::create('premium_users')
 2    ->when()
 3        ->custom('user_level', ['value' => 5, 'operator' => '>='])
 4    ->then()
 5        ->custom('show_premium_content')
 6    ->register();

Condition Using Package Context#Copied!

 1namespace MyPluginPackagesMyCustomConditions;
 2
 3use MilliRulesConditionsBaseCondition;
 4use MilliRulesContext;
 5
 6class PurchaseCountCondition extends BaseCondition {
 7    protected function get_actual_value(Context $context) {
 8        // Load package context data
 9        $context->load('my_custom');
10
11        // Use data from package context
12        $purchases = $context->get('my_custom.user.purchases', []);
13        return count($purchases);
14    }
15
16    public function get_type(): string {
17        return 'purchase_count';
18    }
19}

Creating Package Actions#Copied!

Package actions provide functionality specific to your package's domain.

Simple Package Action#Copied!

 1namespace MyPluginPackagesMyCustomActions;
 2
 3use MilliRulesActionsBaseAction;
 4use MilliRulesContext;
 5
 6class UpdateUserLevelAction extends BaseAction {
 7    public function execute(Context $context): void {
 8        $context->load('user');
 9        $user_id = $context->get('user.id', 0);
10        $level = $this->config['level'] ?? 1;
11
12        if (!$user_id) {
13            error_log('UpdateUserLevelAction: No user logged in');
14            return;
15        }
16
17        update_user_meta($user_id, 'user_level', $level);
18        error_log("Updated user {$user_id} to level {$level}");
19    }
20
21    public function get_type(): string {
22        return 'update_user_level';
23    }
24}

Action with Placeholder Support#Copied!

 1namespace MyPluginPackagesMyCustomActions;
 2
 3use MilliRulesActionsBaseAction;
 4use MilliRulesContext;
 5
 6class SendNotificationAction extends BaseAction {
 7    public function execute(Context $context): void {
 8        // Resolve placeholders
 9        $message = $this->resolve_value($this->config['message'] ?? '');
10        $recipient = $this->resolve_value($this->config['to'] ?? '');
11
12        // Send notification
13        $this->send_notification($recipient, $message);
14    }
15
16    private function send_notification($to, $message) {
17        // Implementation...
18        wp_mail($to, 'Notification', $message);
19    }
20
21    public function get_type(): string {
22        return 'send_notification';
23    }
24}

Adding Placeholder Resolvers#Copied!

Placeholder resolvers enable dynamic values in rules.

Basic Placeholder Resolver#Copied!

 1use MilliRulesContext;
 2
 3public function get_placeholder_resolver(Context $context) {
 4    return function($placeholder_parts) use ($context) {
 5        // $placeholder_parts = ['my_custom', 'category', 'key']
 6        // From placeholder: {my_custom:category:key}
 7
 8        if ($placeholder_parts[0] !== 'my_custom') {
 9            return null; // Not for this package
10        }
11
12        // Load context data if not already loaded
13        $context->load('my_custom');
14
15        // Convert parts to dot notation path
16        $path = implode('.', $placeholder_parts);
17
18        // Get value from context
19        $value = $context->get($path, '');
20
21        return is_scalar($value) ? (string) $value : '';
22    };
23}

Usage:

 1Rules::create('use_custom_placeholder')
 2    ->when()->request_url('/api/*')
 3    ->then()
 4        ->custom('log', [
 5            'message' => 'Site: {my_custom:site:name}, Users: {my_custom:stats:total_users}'
 6        ])
 7    ->register();

Advanced Placeholder Resolver#Copied!

 1use MilliRulesContext;
 2
 3public function get_placeholder_resolver(Context $context) {
 4    return function($placeholder_parts) use ($context) {
 5        if ($placeholder_parts[0] !== 'my_custom') {
 6            return null;
 7        }
 8
 9        $category = $placeholder_parts[1] ?? '';
10        $key = $placeholder_parts[2] ?? '';
11
12        // Load context data once
13        $context->load('my_custom');
14
15        switch ($category) {
16            case 'user':
17                return $this->resolve_user_placeholder($context, $key);
18
19            case 'product':
20                return $this->resolve_product_placeholder($context, $key);
21
22            case 'setting':
23                return $this->resolve_setting_placeholder($context, $key);
24
25            default:
26                return '';
27        }
28    };
29}
30
31private function resolve_user_placeholder(Context $context, $key) {
32    switch ($key) {
33        case 'level':
34            return $context->get('my_custom.user.level', '0');
35        case 'points':
36            return $context->get('my_custom.user.points', '0');
37        default:
38            return $context->get("my_custom.user.{$key}", '');
39    }
40}

Declaring Package Dependencies#Copied!

Packages can require other packages.

Simple Dependency#Copied!

 1public function get_required_packages(): array {
 2    return ['PHP']; // Requires PHP package
 3}

Multiple Dependencies#Copied!

 1public function get_required_packages(): array {
 2    return ['PHP', 'WP']; // Requires both PHP and WordPress packages
 3}
Warning: Avoid circular dependencies. Package A should not require Package B if Package B requires Package A. MilliRules will detect this and throw an error.

Complete Custom Package Example#Copied!

Here's a complete example of a custom package for a membership system:

 1namespace MyPluginPackages;
 2
 3use MilliRulesPackagesBasePackage;
 4
 5class MembershipPackage extends BasePackage {
 6    public function get_name(): string {
 7        return 'Membership';
 8    }
 9
10    public function get_namespaces(): array {
11        return [
12            'MyPluginPackagesMembershipConditions',
13            'MyPluginPackagesMembershipActions',
14        ];
15    }
16
17    public function is_available(): bool {
18        // Check if membership system is active
19        return class_exists('My_Membership_System');
20    }
21
22    public function get_required_packages(): array {
23        return ['PHP', 'WP']; // Requires both PHP and WordPress
24    }
25
26    public function register_providers(Context $context): void {
27        // Register membership provider (loads on-demand)
28        $context->register_provider('membership', function() {
29            $user_id = get_current_user_id();
30
31            $membership_data = [];
32            if ($user_id) {
33                $membership_data = [
34                    'level' => get_user_meta($user_id, 'membership_level', true) ?: 'free',
35                    'status' => get_user_meta($user_id, 'membership_status', true) ?: 'inactive',
36                    'expiry' => get_user_meta($user_id, 'membership_expiry', true) ?: 0,
37                    'features' => $this->get_user_features($user_id),
38                ];
39            }
40
41            return [
42                'membership' => [
43                    'user' => $membership_data,
44                    'levels' => $this->get_available_levels(),
45                    'features' => $this->get_all_features(),
46                ],
47            ];
48        });
49    }
50
51    public function get_placeholder_resolver(Context $context) {
52        return function($parts) use ($context) {
53            if ($parts[0] !== 'membership') {
54                return null;
55            }
56
57            // Load membership context if not already loaded
58            $context->load('membership');
59
60            $category = $parts[1] ?? '';
61            $key = $parts[2] ?? '';
62
63            if ($category === 'user') {
64                return $context->get("membership.user.{$key}", '');
65            }
66
67            return '';
68        };
69    }
70
71    private function get_user_features($user_id) {
72        // Get features available to user
73        return ['feature1', 'feature2'];
74    }
75
76    private function get_available_levels() {
77        return ['free', 'basic', 'premium', 'enterprise'];
78    }
79
80    private function get_all_features() {
81        return ['feature1', 'feature2', 'feature3'];
82    }
83}

Membership Condition Example:

 1namespace MyPluginPackagesMembershipConditions;
 2
 3use MilliRulesConditionsBaseCondition;
 4use MilliRulesContext;
 5
 6class MembershipLevelCondition extends BaseCondition {
 7    protected function get_actual_value(Context $context) {
 8        $context->load('membership');
 9        return $context->get('membership.user.level', 'free');
10    }
11
12    public function get_type(): string {
13        return 'membership_level';
14    }
15}

Membership Action Example:

 1namespace MyPluginPackagesMembershipActions;
 2
 3use MilliRulesActionsBaseAction;
 4use MilliRulesContext;
 5
 6class UpgradeMembershipAction extends BaseAction {
 7    public function execute(Context $context): void {
 8        $context->load('user');
 9        $user_id = $context->get('user.id', 0);
10        $new_level = $this->config['level'] ?? 'basic';
11
12        if (!$user_id) {
13            return;
14        }
15
16        update_user_meta($user_id, 'membership_level', $new_level);
17        update_user_meta($user_id, 'membership_status', 'active');
18
19        // Resolve message with placeholders
20        $message = $this->resolve_value(
21            $this->config['message'] ?? 'Upgraded to {membership:user:level}'
22        );
23
24        error_log($message);
25    }
26
27    public function get_type(): string {
28        return 'upgrade_membership';
29    }
30}

Using the Custom Package:

 1use MilliRulesMilliRules;
 2use MyPluginPackagesMembershipPackage;
 3
 4// Initialize with custom package
 5$membership_package = new MembershipPackage();
 6MilliRules::init(null, [$membership_package]);
 7
 8// Create rule using package conditions and actions
 9Rules::create('auto_upgrade_frequent_buyers')
10    ->when()
11        ->is_user_logged_in()                                    // WP condition
12        ->custom('membership_level', ['value' => 'free'])        // Membership condition
13        ->custom('purchase_count', ['value' => 10, 'operator' => '>='])
14    ->then()
15        ->custom('upgrade_membership', [
16            'level' => 'premium',
17            'message' => 'Congratulations! Upgraded to premium membership.'
18        ])
19    ->register();

Real-World Example: Acorn MilliRules#Copied!

The Acorn MilliRules package is a real-world custom package that extends MilliRules for the Roots Acorn framework. It's a good reference for how to structure a production package.

Package Class#Copied!

The Acorn package registers route-aware conditions, HTTP response actions, and a route context provider:

 1namespace MilliRulesAcornPackagesAcorn;
 2
 3use MilliRulesAcornPackagesAcornContextsRoute;
 4use MilliRulesPackagesBasePackage;
 5
 6class Package extends BasePackage
 7{
 8    public function get_name(): string
 9    {
10        return 'Acorn';
11    }
12
13    public function get_namespaces(): array
14    {
15        return [
16            'MilliRules\Acorn\Packages\Acorn\Actions',
17            'MilliRules\Acorn\Packages\Acorn\Conditions',
18            'MilliRules\Acorn\Packages\Acorn\Contexts',
19        ];
20    }
21
22    public function is_available(): bool
23    {
24        return function_exists('app');
25    }
26
27    public function get_required_packages(): array
28    {
29        return ['PHP'];
30    }
31}

What It Provides#Copied!

Component Description
Conditions RouteName, RouteParameter, RouteController
Actions Redirect, SetHeader
Context Route metadata (name, parameters, controller, URI, middleware)
Auto-discovery Rule classes in app/Rules/ are registered automatically
Artisan commands 8 CLI commands to list, inspect, and scaffold rules

Usage Example#Copied!

 1// app/Rules/RedirectLegacyDocs.php
 2namespace AppRules;
 3
 4use MilliRulesRules;
 5
 6class RedirectLegacyDocs
 7{
 8    public function register(): void
 9    {
10        Rules::create('redirect_legacy_docs', 'Acorn')
11            ->when()
12                ->routeName('docs.*')
13                ->routeParameter('product', ['value' => 'old-product'])
14            ->then()
15                ->redirect('/docs/new-product/', ['status' => 301])
16            ->register();
17    }
18}

For full documentation, see the Acorn MilliRules docs.


Best Practices#Copied!

1. Use Descriptive Package Names#Copied!

 1// ✅ Good - clear and specific
 2public function get_name(): string {
 3    return 'WooCommerce';
 4}
 5
 6// ❌ Bad - vague or generic
 7public function get_name(): string {
 8    return 'Custom';
 9}

2. Validate Environment in is_available()#Copied!

 1// ✅ Good - comprehensive checks
 2public function is_available(): bool {
 3    return class_exists('WooCommerce')
 4        && function_exists('wc_get_product')
 5        && defined('WC_VERSION');
 6}
 7
 8// ❌ Bad - minimal checking
 9public function is_available(): bool {
10    return true;
11}

3. Cache Expensive Context Data#Copied!

 1use MilliRulesContext;
 2
 3// ✅ Good - caches API calls in lazy provider
 4public function register_providers(Context $context): void {
 5    $context->register_provider('my_package', function() {
 6        $data = get_transient('my_package_context');
 7
 8        if ($data === false) {
 9            $data = $this->fetch_expensive_data();
10            set_transient('my_package_context', $data, 300);
11        }
12
13        return ['my_package' => $data];
14    });
15}

Note: With lazy loading, this expensive data is only fetched when a rule actually needs it!

4. Document Your Package#Copied!

 1/**
 2 * Membership Package
 3 *
 4 * Provides membership-related conditions and actions.
 5 *
 6 * Conditions:
 7 * - membership_level: Check user's membership level
 8 * - membership_status: Check membership status
 9 * - has_feature: Check if user has access to feature
10 *
11 * Actions:
12 * - upgrade_membership: Upgrade user to new level
13 * - grant_feature: Grant feature access
14 * - send_membership_email: Send membership-related email
15 *
16 * Context:
17 * - membership.user.level: User's membership level
18 * - membership.user.status: Membership status
19 * - membership.user.features: Available features
20 *
21 * Placeholders:
22 * - {membership:user:level}: User's membership level
23 * - {membership:user:status}: Membership status
24 */
25class MembershipPackage extends BasePackage {
26    // ...
27}

Common Pitfalls#Copied!

1. Circular Dependencies#Copied!

 1// ❌ Wrong - circular dependency
 2class PackageA extends BasePackage {
 3    public function get_required_packages(): array {
 4        return ['PackageB']; // A requires B
 5    }
 6}
 7
 8class PackageB extends BasePackage {
 9    public function get_required_packages(): array {
10        return ['PackageA']; // B requires A - CIRCULAR!
11    }
12}

2. Accessing Unavailable Context#Copied!

 1use MilliRulesContext;
 2
 3// ❌ Wrong - doesn't check availability
 4protected function get_actual_value(Context $context) {
 5    $context->load('user');
 6    return $context->get('user.id'); // May return null if not available!
 7}
 8
 9// ✅ Correct - provides default value
10protected function get_actual_value(Context $context) {
11    $context->load('user');
12    return $context->get('user.id', 0);
13}

3. Expensive Provider Registration#Copied!

 1use MilliRulesContext;
 2
 3// ❌ Wrong - executes expensive operation during registration
 4public function register_providers(Context $context): void {
 5    $data = expensive_api_call(); // Runs on every request!
 6    $context->register_provider('my_package', function() use ($data) {
 7        return ['my_package' => $data];
 8    });
 9}
10
11// ✅ Correct - expensive operation runs only when provider loads
12public function register_providers(Context $context): void {
13    $context->register_provider('my_package', function() {
14        $data = wp_cache_get('my_package_data', 'my_group');
15
16        if ($data === false) {
17            $data = expensive_api_call(); // Only runs when needed!
18            wp_cache_set('my_package_data', $data, 'my_group', 300);
19        }
20
21        return ['my_package' => $data];
22    });
23}

Next Steps#Copied!


Ready for advanced techniques? Continue to Advanced Patterns to learn optimization strategies and advanced rule patterns.