# MilliPress — Complete Documentation

> Every documentation page for all MilliPress products in a single file. Index version: [llms.txt](https://www.millipress.com/llms.txt).

# MilliCache

---

Canonical: https://www.millipress.com/docs/millicache/01-getting-started/10-introduction

---
title: 'What Is MilliCache?'
description: 'MilliCache is a Redis-powered full-page cache for WordPress that serves pages from memory in under 10ms, with cache flags and rules for precise control.'
menu_order: 10
---

# Introduction

MilliCache is the most flexible Full Page Cache for scaling WordPress sites. 
It provides enterprise-grade in-memory caching using Redis, ValKey, Dragonfly, KeyDB, or any compatible Redis alternative.

## What is Full Page Caching?

Full page caching stores the complete HTML output of your WordPress pages in memory. 
When a visitor requests a cached page, MilliCache serves it directly from Redis without loading WordPress at all. This results in:

- **Response times under 10ms** instead of 200-2000ms
- **Dramatically reduced server load** — Database and WordPress are bypassed entirely
- **Better scalability** — Handle thousands of concurrent visitors
- **Improved SEO** — Search engines favor fast-loading sites

## Scale Your WordPress Infrastructure

One of MilliCache's most powerful capabilities is enabling **horizontal scaling**. Multiple web servers can share a single Redis instance, making it ideal for high-availability setups:

```mermaid
graph TB
    subgraph "Internet"
        LB[Load Balancer]
    end
    subgraph "Web Servers"
        WS1[Web Server 1]
        WS2[Web Server 2]
        WS3[Web Server N...]
    end
    subgraph "Shared Cache Layer"
        Redis[(Redis / ValKey)]
    end
    subgraph "Database"
        DB[(MySQL)]
    end

    LB --> WS1 & WS2 & WS3
    WS1 & WS2 & WS3 --> Redis
    WS1 & WS2 & WS3 -.-> DB
```

**Benefits of shared caching:**
- Any server can serve any cached page
- Cache is populated once, shared across all servers
- Cache invalidation propagates instantly to all servers
- No sticky sessions required for cached content
- Perfect for auto-scaling cloud environments

## Why Redis-Based Caching?

MilliCache uses Redis (or compatible alternatives) instead of files, database, or Memcache. Here's why:

| Storage      | Speed    | Scalability   | Shared Access  | Persistence  | Best For                   |
|--------------|----------|---------------|----------------|--------------|----------------------------|
| **Files**    | Slow     | Poor          | No             | Yes          | Single server, small sites |
| **Database** | Slow     | Limited       | Yes            | Yes          | Fallback only              |
| **Memcache** | Fast     | Good          | Yes            | No           | Simple key-value needs     |
| **Redis**    | **Fast** | **Excellent** | **Yes**        | **Optional** | **Production WordPress**   |

**Redis advantages:**
- Sub-millisecond latency for cache operations
- Rich data structures (flags stored efficiently)
- Atomic operations for cache invalidation
- Optional persistence for cache survival across restarts
- Cluster support for massive scale
- Active ecosystem with ValKey, KeyDB, Dragonfly alternatives

## Two Core Features: Flags & Rules

MilliCache's flexibility comes from two powerful features working together:

### Cache Flags

Each cached page is tagged with **flags** like `post:123`, `archive:post`, or `home`. 
When content changes, only related pages are invalidated—not the entire cache.

```mermaid
graph LR
    U[Post Updated] --> F[Find related entries <br/>to this post]
    F --> C[Clear only<br/>those entries]
    C --> W[Rest of cache<br/>stays warm]
```

Learn more: [Cache Flags Documentation](/docs/millicache/03-cache-flags/01-introduction)

### Caching Rules

Every caching decision is a **rule** you can customize. Think of it like smart home automation:

| Smart Home | MilliCache |
|------------|------------|
| "Turn off heating when I leave" | "Bypass cache when user is logged in" |
| "Dim lights after 10pm" | "Short TTL for breaking news posts" |

**You can create rules like:**
- Cache all pages **except** cart and checkout
- Use shorter TTL **for** archives, longer **for** single posts
- Bypass cache **when** a specific cookie exists
- Never cache **when** URL contains `?preview=true`

Learn more: [Rules Documentation](/docs/millicache/04-rules/01-introduction)

## How It Works

```mermaid
flowchart TD
    A[Incoming Request] --> B[advanced-cache.php]
    B --> C[Bootstrap Rules<br/><i>Pre-WordPress</i>]
    C --> D{Should Cache?}
    D -->|No| E[Load WordPress<br/><i>Skip MilliCache</i>]
    D -->|Yes| F[Check Cache]
    F --> G{Cache Hit?}
    G -->|Yes| H[Serve Cached HTML<br/><i>~5-10ms</i>]
    G -->|No| I[Load WordPress]
    I --> J[WordPress Rules<br/><i>Full context</i>]
    J --> K{Cache Response?}
    K -->|Yes| L[Store with Flags]
    K -->|No| M[Don't store]
    E --> N[Response]
    H --> N
    L --> N
    M --> N
```

## Additional Features

### Multisite Ready

Full support for WordPress Multisite:
- Per-site cache isolation
- Multi-network support
- Network-wide cache management
- Site-specific or network-wide configuration

### Developer Friendly

- Extensive WP-CLI commands
- REST API for remote management
- PHP functions for custom integrations
- Comprehensive hooks and filters

### Flexible Storage

Works with any Redis-compatible server:
- **Redis** — The original, most popular
- **ValKey** — Open-source fork, BSD licensed
- **KeyDB** — Multithreaded, higher throughput
- **Dragonfly** — Modern, memory efficient

## Using Acorn / Roots?

If you're running MilliCache on a [Roots](https://roots.io/) stack with Acorn, check out the [Acorn MilliCache](https://www.millipress.com/docs/acorn-millicache/) companion package. It adds a Laravel middleware that automatically stores Acorn route responses in MilliCache's Redis cache — no extra configuration needed.

```bash
composer require millipress/acorn-millicache
```

## Going Pro

[MilliCache Pro](https://www.millipress.com/millicache-pro/) extends MilliCache with the tooling and infrastructure features production sites grow into, organized as [modules](https://www.millipress.com/docs/millicache-pro/02-modules/01-overview/) you enable individually:

- **[Cache Entries Browser](https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries/)**: browse, search, and purge every cached page from the settings screen
- **[Visual Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/)**: build caching rules in the UI, no PHP required
- **[Block Editor Intelligence](https://www.millipress.com/docs/millicache-pro/02-modules/04-block-editor/)**: synced patterns, Query Loops, and templates clear exactly the affected pages
- **[Cache Preloading](https://www.millipress.com/docs/millicache-pro/02-modules/05-cache-preloading/)**: keeps the cache warm after publishing and after full clears
- **[Detailed Metrics](https://www.millipress.com/docs/millicache-pro/02-modules/06-detailed-metrics/)**: requests, bandwidth, and response-time charts on the Status dashboard
- **[Edge Cache](https://www.millipress.com/docs/millicache-pro/02-modules/07-edge-cache/)**: serve pages from bunny.net or Cloudflare, purged in sync with the local cache
- **[Asset CDN](https://www.millipress.com/docs/millicache-pro/02-modules/08-cdn/)**: serve CSS, JavaScript, images, and fonts from a pull-zone CDN
- **[Object Cache](https://www.millipress.com/docs/millicache-pro/02-modules/09-object-cache/)**: persistent object cache drop-in on the same Redis connection
- **[Storage Connections](https://www.millipress.com/docs/millicache-pro/02-modules/10-storage-connections/)**: configure replication and Sentinel visually

## Requirements

| Component  | Requirement                                |
|------------|--------------------------------------------|
| PHP        | 7.4 or higher                              |
| WordPress  | 5.6 or higher                              |
| Storage    | Redis, ValKey or another compatible server |

> [!TIP]
> For best performance, run your Redis server on the same machine as WordPress or use a low-latency network connection.

## Next Steps

Ready to get started? Continue to:

- [Installation & Quick Start](/docs/millicache/01-getting-started/20-installation) — Install and configure MilliCache
- [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) — Understand targeted invalidation
- [Rules](/docs/millicache/04-rules/01-introduction) — Control caching behavior

---

Canonical: https://www.millipress.com/docs/millicache/01-getting-started/20-installation

---
title: 'Installation & Quick Start'
description: 'Install MilliCache, the Redis page cache for WordPress, in under 5 minutes: enable WP_CACHE, connect Redis, and verify cache hits via headers or WP-CLI.'
menu_order: 20
---

# Installation & Quick Start

Get MilliCache installed and caching your WordPress site in 5 minutes.

## Requirements

| Component   | Requirement                               |
|-------------|-------------------------------------------|
| PHP         | 7.4 or higher                             |
| WordPress   | 5.6 or higher                             |
| Storage     | Redis, ValKey, Dragonfly, or KeyDB server |

> [!TIP]
> The optional `ext-zlib` PHP extension enables gzip compression of cached content, reducing memory usage significantly.

## Installation Methods

### Method 1: GitHub Release (Recommended)

1. Download the latest release from [GitHub Releases](https://github.com/millipress/millicache/releases)
2. Upload the ZIP file via **Plugins → Add New → Upload Plugin**
3. Activate the plugin

The release package includes all dependencies pre-bundled. Internal dependencies are prefixed to avoid conflicts; the MilliRules rule engine keeps its own `MilliRules\` namespace, so custom rules use the same classes on every install type.

When activated, MilliCache automatically creates the `advanced-cache.php` drop-in file required for early request interception.

### Method 2: Composer

For developers managing WordPress with Composer:

```bash
composer require millipress/millicache
```

Dependencies (`predis/predis` and `millipress/millirules`) are installed as regular Composer packages, without prefixing.

## Setup (2 Steps)

### Step 1: Enable WP_CACHE

Add to your `wp-config.php` **before** the "That's all, stop editing!" line:

```php
define( 'WP_CACHE', true );
```

> [!IMPORTANT]
> The `WP_CACHE` constant must be set to `true` for MilliCache to intercept requests early in the WordPress bootstrap process. Without this, caching will not work.

### Step 2: Configure Redis Connection (If Needed)

By default, MilliCache connects to Redis at `127.0.0.1:6379`. If your Redis server uses different settings:

**Option A: Via Admin UI**

Go to **Settings → MilliCache → Settings Tab → Storage Server** and enter your Redis credentials.

**Option B: Via wp-config.php**

```php
define( 'MC_STORAGE_HOST', '127.0.0.1' );
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_USERNAME', 'your-redis-username' );
define( 'MC_STORAGE_PASSWORD', 'your-redis-password' );
define( 'MC_STORAGE_DB', 0 );
```

Settings defined as constants override those in the admin UI.

## Verify It's Working

### Via Admin UI

Navigate to **Settings → MilliCache → Stats Tab** to see:

- Cache entries count
- Total cache size
- Cache hit ratio
- Recent cache activity

You can also check the admin bar: the **Cache** menu should show cache statistics.

### Via Browser (Easiest)

1. **Enable debug headers** in `wp-config.php`:
   ```php
   define( 'MC_CACHE_DEBUG', true );
   ```

2. **Visit your homepage** (logged out)

3. **Open browser developer tools** (F12) → Network tab

4. **Refresh the page** and check the response headers:

   | Header                 | First Visit | Second Visit         |
   |------------------------|-------------|----------------------|
   | `X-MilliCache-Status`  | `miss`      | `hit`                |
   | `X-MilliCache-Flags`   | not set     | Shows assigned flags |
   | `X-MilliCache-Expires` | not set     | Seconds until expiry |

   **Success**: Second visit shows `X-MilliCache-Status: hit` ✓

> [!TIP]
> The [MilliCache Browser Extension](https://github.com/MilliPress/millicache-browser-ext/) adds a dedicated panel to your browser's developer tools for easier debugging. 
> Debug mode (`MC_CACHE_DEBUG`) must be enabled for the extension to display cache information.

### Via WP-CLI (Optional)

If you have WP-CLI installed, you can run comprehensive tests:

**Check status:**
```bash
wp millicache status
```

Expected output:
```
+-------------------+------------------+
| property          | status           |
+-------------------+------------------+
| plugin_version    | 1.0.0            |
| wp_cache          | enabled          |
| advanced_cache    | symlink          |
| storage_connected | yes              |
| storage_version   | 7.2.4            |
| cache_entries     | 1                |
| cache_size        | 15 KB            |
+-------------------+------------------+
```

**Test Redis connection:**
```bash
wp millicache test
```

This performs connection, ping, write, read, and delete tests. All should show `PASS`.

**View cache statistics:**
```bash
wp millicache stats
```

## That's It!

Your site is now caching. Here's what happens automatically by default:

- **Anonymous visitors** receive cached pages (fast!)
- **Logged-in users** bypass the cache (personalized content)
- **Content updates** automatically clear related cache entries
- **Expired cache** serves stale content while regenerating (grace period)

## Default Behavior

Out of the box, MilliCache:

| Setting         | Default  | Meaning                               |
|-----------------|----------|---------------------------------------|
| TTL             | 1 day    | Cache expires after 24 hours          |
| Grace           | 1 month  | Stale cache served while regenerating |
| Gzip            | Enabled  | Compressed storage saves memory       |
| Logged-in users | Bypassed | No caching for authenticated users    |
| POST requests   | Bypassed | Only GET/HEAD requests cached         |
| Admin/REST/AJAX | Bypassed | Backend requests never cached         |

## Common Tasks

### Clear All Cache

**Via Admin Bar:**
**Cache → Clear Website Cache**

**Via Admin UI:**
**Settings → MilliCache → Stats Tab → Clear Cache Button**

**Via WP-CLI** (optional):
```bash
wp millicache clear
```

### Clear Specific Post

**Via Admin Bar:**
When viewing a post, click **Cache → Clear Post Cache**

**Via WP-CLI** (optional):
```bash
wp millicache clear --id=123
```

### View Configuration

**Via Admin UI:**
**Settings → MilliCache → Settings Tab**

**Via WP-CLI** (optional):
```bash
wp millicache config get
```

## Troubleshooting

### advanced-cache.php Issues

The drop-in is created automatically on activation. If it needs repair (e.g., after deployment or manual deletion):

**Via WP-CLI:**
```bash
wp millicache drop --force
```

> [!WARNING]
> If another caching plugin installed its own `advanced-cache.php`, you must deactivate that plugin first.

### Permission Issues

The `wp-content/settings` directory must be writable by the web server user.

### Redis Connection Failed

1. Verify Redis is running: `redis-cli ping` (should return `PONG`)
2. Check connection settings in **Settings → MilliCache → Settings Tab → Storage Server**
3. Test connectivity via **Settings → MilliCache → Stats Tab** or `wp millicache test`

### WP_CACHE Not Enabled

Check **Settings → MilliCache** for warnings. Ensure `define( 'WP_CACHE', true );` is in `wp-config.php` **before** the "That's all" line.

### No Cache Headers Showing

Make sure you're:
- Logged out (logged-in users bypass cache)
- Using a GET request (not POST)
- Not on an admin/login/REST/AJAX page
- Have `MC_CACHE_DEBUG` set to `true`

## Multisite Installation

For WordPress Multisite:

1. Network-activate the plugin
2. The `WP_CACHE` constant applies to all sites
3. Each site's cache is automatically isolated
4. Network admins can clear cache for all sites via **Network Admin → MilliCache**

See [Multisite](/docs/millicache/05-usage/30-multisite) for detailed configuration.

## Next Steps

- [Configuration](/docs/millicache/02-configuration/01-overview) - Customize caching behavior
- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) - Learn invalidation strategies
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) - Master command-line tools (optional)

---

Canonical: https://www.millipress.com/docs/millicache/01-getting-started/30-changelog

---
title: 'MilliCache Changelog'
description: 'Release notes for every MilliCache version: new features, bug fixes, and improvements to the Redis full-page cache plugin for WordPress, with commit links.'
menu_order: 30
---

# Changelog

## [1.8.1](https://github.com/MilliPress/MilliCache/compare/v1.8.0...v1.8.1) (2026-08-29)

Query parameters listed in `MC_CACHE_IGNORE_REQUEST_KEYS`, such as `gclid` or `utm_*`, now stay part of the request until the page is rendered. Redirects issued by WordPress, WooCommerce, or multilingual plugins like Polylang keep them in the target URL, while cached pages still never contain them.

### Bug Fixes

* **engine:** keep ignored query keys in the request until rendering ([f6f7d33](https://github.com/MilliPress/MilliCache/commit/f6f7d3350f1f66216462292f9390a160ff42f3b8))

## [1.8.0](https://github.com/MilliPress/MilliCache/compare/v1.7.7...v1.8.0) (2026-08-22)

<!-- mc:auto sha=9ba0bffb378a -->
1.8.0 brings a redesigned cache management experience centered on a command palette, new abilities for AI assistants, and several correctness fixes to the caching engine and rule system.

The admin bar's one-click flush button is replaced by a command palette that lets you clear or expire specific targets — pages, post types, taxonomies, or the full cache — with descriptive labels. A snackbar confirms the action and reports how many entries were actually removed. Add-ons can register their own palette commands and control their position in the list. The palette is wider to accommodate longer target names and no longer intrudes on every admin search field.

**AI assistants can now check cache status and clear the cache.** Two new abilities — available over REST and from MCP clients — answer the questions site owners most often ask through an assistant: "why is my page not cached" and "clear the cache for this post or URL". The status ability returns a curated health summary (connectivity, entry count, and the checks that need attention) without leaking the full plugin and theme inventory that the settings UI needs. [MilliCache Pro](https://www.millipress.com/millicache-pro/) extends this to every aspect of the plugin: caching, cache entries, rules, preloading, the edge cache, and the object cache can all be managed through abilities as well.

Cache responses now include the remaining lifetime of a served page. The engine has been corrected to never store redirect responses and to capture output from the outermost buffer, which prevents partial content from being cached when other plugins open buffers early. URL hashes now preserve non-default ports, so sites on non-standard ports get correct per-URL cache isolation. The invalidation queue runs even when the drop-in fails to load, so scheduled purges are not silently lost.

On the rules side, `wp-cron.php` is now excluded by a dedicated locked rule that cannot be overridden by site rules. Previously it was only covered by the generic dot-file rule, which a higher-priority site rule could outrank, potentially causing a stale cron lock response to be replayed and stalling scheduled events. The REST API exclusion now also matches the `?rest_route=` query-string form used when pretty permalinks are off.

WP-CLI's clear flags are now scoped to the current site context on multisite. Redundant entries have been removed from the admin search results. Portuguese (Brazil), Italian, Spanish, and French translations are complete.
<!-- /mc:auto -->

### Features

* **abilities:** let assistants read cache status and clear the cache ([f80b057](https://github.com/MilliPress/MilliCache/commit/f80b057e2d88a2d6ab68a88d63495b2bad36a149))
* **abilities:** report network-wide problems in a site's cache status ([4e2b9f1](https://github.com/MilliPress/MilliCache/commit/4e2b9f1be5fc0e3a6841d7463a555fd57002b544))
* **abilities:** say whether the install is a multisite ([4573485](https://github.com/MilliPress/MilliCache/commit/457348597a48a8cd7e7feb2dcdf306d73d392078))
* **adminbar:** replace one-click flush with command palette integration ([382b10b](https://github.com/MilliPress/MilliCache/commit/382b10beec75a421834632919875090c65d21443))
* **adminbar:** snackbar clear feedback and a wider palette ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **cache:** state the lifetime a replayed page has left ([8f699fd](https://github.com/MilliPress/MilliCache/commit/8f699fdad02c3d797f7923cb26b09c99cd5596d2))
* **clear:** report removed-entry counts instead of processed inputs ([23ec58f](https://github.com/MilliPress/MilliCache/commit/23ec58f9b5b2d09a23fae07137aafb171c912837))
* **cli:** scope bare clear flags to the WP-CLI site context ([d30d31e](https://github.com/MilliPress/MilliCache/commit/d30d31e386408c3a072c513a29108eb7528b9a6e))
* **commands:** let add-ons ride the palette's promote/demote cycle ([1db3d3f](https://github.com/MilliPress/MilliCache/commit/1db3d3f1a7475c2fe5221e82ae9fed2c5645c609))
* **commands:** offer expire alongside clear with descriptive target labels ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** capture the page in the outermost output buffer ([6c4d393](https://github.com/MilliPress/MilliCache/commit/6c4d393ba419e569ab4b8e5aca0f989ecf95d240))
* **engine:** capture the page in the outermost output buffer ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))
* **engine:** capture the page in the outermost output buffer ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))
* **engine:** expose the request's effective TTL override ([31e2555](https://github.com/MilliPress/MilliCache/commit/31e2555437c91f94271313df3a5a6be37dd8984e))
* **rules:** build the rule registry when the drop-in has not ([b6ef71b](https://github.com/MilliPress/MilliCache/commit/b6ef71b032d5f37f748ddc6ae7a75d209ea2a341))


### Bug Fixes

* **adminbar:** keep the admin bar button size stable on page load ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **cache:** report targets that belong to another site ([6a7947a](https://github.com/MilliPress/MilliCache/commit/6a7947ae23b67a4a6636befed36827171851427e))
* **clear:** anchor path-only URL targets onto the home URL ([27bd7dc](https://github.com/MilliPress/MilliCache/commit/27bd7dc6c42f3d2210bd02ae148d31fb461a08e5))
* **clear:** skip non-viewable taxonomies in post-related flags ([40cd884](https://github.com/MilliPress/MilliCache/commit/40cd88414019eda097f910bce72fb6acd40a01e8))
* **commands:** drop the stray focus ring after palette clears ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **commands:** stop crowding every admin search, and drop the settings entry ([87b52d1](https://github.com/MilliPress/MilliCache/commit/87b52d1c19b0c499ea2200306a3f4a9631021bf2))
* **engine:** execute the invalidation queue when the drop-in never loads ([746bd0e](https://github.com/MilliPress/MilliCache/commit/746bd0eb9cfafe57df36c39a45ac8074d0708819))
* **engine:** keep non-default ports in URL-based cache hashes ([ed6f3d6](https://github.com/MilliPress/MilliCache/commit/ed6f3d670305dcf7750ed49bf8425e1149d80efa))
* **engine:** never store redirect responses ([6c4d393](https://github.com/MilliPress/MilliCache/commit/6c4d393ba419e569ab4b8e5aca0f989ecf95d240))
* **engine:** never store redirect responses ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))
* **engine:** never store redirect responses ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))
* **rules:** lock wp-cron.php out of the cache and cover the rest_route form ([908ce04](https://github.com/MilliPress/MilliCache/commit/908ce04ec9a3f0e4b64813253eebb69135b7c36c))
* **rules:** skip an action whose placeholder resolved to nothing ([a5e4894](https://github.com/MilliPress/MilliCache/commit/a5e4894ad0a10ede4b91cce7f67463cea2c9ebc9))
* **updates:** keep update checks off every admin page load ([1661d07](https://github.com/MilliPress/MilliCache/commit/1661d079641cccc28003d304659cbdbe769070df))

## [1.8.0-beta.2](https://github.com/MilliPress/MilliCache/compare/v1.8.0-beta.1...v1.8.0-beta.2) (2026-08-16)

<!-- mc:auto sha=5f67c5ceb2e0 -->
This release tightens how MilliCache interacts with CDNs and external caches, extends what AI assistants and WP-CLI can see, and closes a handful of gaps that caused silent failures.

**Replayed pages now tell downstream caches their true remaining lifetime.** Previously a cached response replayed its original `Cache-Control` headers unchanged, so a shared cache or CDN that ignored the `Age` header would treat a near-expired entry as freshly filled and hold it for the full `s-maxage`. Replayed responses now carry only the share of `s-maxage` that actually remains, so caches that ignore `Age` expire in step with the stored entry rather than well after it.

**AI assistants can now check cache status and clear the cache.** Two new abilities — available over REST and from MCP clients — answer the questions site owners most often ask through an assistant: "why is my page not cached" and "clear the cache for this post or URL". The status ability returns a curated health summary (connectivity, entry count, and the checks that need attention) without leaking the full plugin and theme inventory that the settings UI needs.

**WP-CLI now sees the full rule registry.** Because WP-CLI skips the drop-in, `wp millicache rules list` previously showed an empty engine — no built-in rules and nothing registered through `millicache()->rules()`. The manager now registers the rule set itself when it finds it missing, so the same question gets the same answer regardless of how it is asked.

**Placeholder-driven rule actions no longer silently write garbage.** When a bucket or flag was set from a placeholder that could not be filled — a missing cookie, for example — the literal placeholder text `{cookie.geo_country}` was used as the bucket name, pooling every visitor without that cookie together. Those actions now do nothing when the placeholder is empty or unresolved.
<!-- /mc:auto -->

### Features

* **abilities:** let assistants read cache status and clear the cache ([f80b057](https://github.com/MilliPress/MilliCache/commit/f80b057e2d88a2d6ab68a88d63495b2bad36a149))
* **abilities:** report network-wide problems in a site's cache status ([4e2b9f1](https://github.com/MilliPress/MilliCache/commit/4e2b9f1be5fc0e3a6841d7463a555fd57002b544))
* **abilities:** say whether the install is a multisite ([4573485](https://github.com/MilliPress/MilliCache/commit/457348597a48a8cd7e7feb2dcdf306d73d392078))
* **cache:** state the lifetime a replayed page has left ([8f699fd](https://github.com/MilliPress/MilliCache/commit/8f699fdad02c3d797f7923cb26b09c99cd5596d2))
* **commands:** let add-ons ride the palette's promote/demote cycle ([1db3d3f](https://github.com/MilliPress/MilliCache/commit/1db3d3f1a7475c2fe5221e82ae9fed2c5645c609))
* **engine:** expose the request's effective TTL override ([31e2555](https://github.com/MilliPress/MilliCache/commit/31e2555437c91f94271313df3a5a6be37dd8984e))
* **rules:** build the rule registry when the drop-in has not ([b6ef71b](https://github.com/MilliPress/MilliCache/commit/b6ef71b032d5f37f748ddc6ae7a75d209ea2a341))


### Bug Fixes

* **cache:** report targets that belong to another site ([6a7947a](https://github.com/MilliPress/MilliCache/commit/6a7947ae23b67a4a6636befed36827171851427e))
* **commands:** stop crowding every admin search, and drop the settings entry ([87b52d1](https://github.com/MilliPress/MilliCache/commit/87b52d1c19b0c499ea2200306a3f4a9631021bf2))
* **rules:** skip an action whose placeholder resolved to nothing ([a5e4894](https://github.com/MilliPress/MilliCache/commit/a5e4894ad0a10ede4b91cce7f67463cea2c9ebc9))

## [1.8.0-beta.1](https://github.com/MilliPress/MilliCache/compare/v1.8.0-beta...v1.8.0-beta.1) (2026-08-10)

<!-- mc:auto sha=98337a07d375 -->
Plugin and language-pack update checks previously ran on every admin page load, so a slow or unresponsive millipress.com could stall every admin request waiting for a timeout. Both checks now run only during WordPress's scheduled update refresh cycles, with a 3-second timeout and a 15-minute back-off after any failure. "Check again" still triggers an immediate fresh check.
<!-- /mc:auto -->

### Bug Fixes

* **updates:** keep update checks off every admin page load ([1661d07](https://github.com/MilliPress/MilliCache/commit/1661d079641cccc28003d304659cbdbe769070df))

## [1.8.0-beta](https://github.com/MilliPress/MilliCache/compare/v1.7.7...v1.8.0-beta) (2026-08-07)

<!-- mc:auto sha=df2f4a4a2b26 -->
1.8.0-beta is a significant release centered on two themes: a smarter cache-clearing experience and a more reliable caching engine.

**Command palette integration.** The admin bar Cache button now opens the WordPress command palette (wp-admin, WordPress 7.0+) instead of immediately flushing. From there you can clear or expire the website or network cache, jump to settings, or type a post ID, URL, or flag to target exactly what needs clearing. Expire is new — it marks entries stale for stale-while-revalidate regeneration rather than deleting them outright. Clear and expire results surface as snackbar toasts with the actual entry count removed, so you know whether anything was matched. On the front end and older WordPress the submenu behaves as before, with a two-step confirmation added for the network-wide clear.

**Accurate cache invalidation.** Several bugs that caused clears to silently match nothing are fixed: path-only URL targets (e.g. `/blog/`) are now anchored to the home URL before hashing, so they find what was actually stored. Sites running on non-standard ports no longer miss URL-based clears due to a port mismatch in the hash. WP-CLI flag clears on multisite now respect the `--url` site context instead of looking for a bare flag that was never stored. Internal taxonomies (Polylang language terms, nav menus) are excluded from post-related invalidation batches, which reduces unnecessary purge payloads. And queue execution is now guaranteed even in WP-CLI processes where the drop-in never loads, so publishes and imports triggered from the CLI actually clear the cache.

**Outermost output buffer capture.** The engine now opens its capture buffer before any plugin loads, meaning HTML post-processors (TranslatePress, HTML optimizers) run inside it. Their final output is what gets stored, eliminating a class of caching issues where transformed HTML was bypassed. Redirect responses (3xx) are never stored. The new `millicache()->response()->is_storable()` method is available for extensions.
<!-- /mc:auto -->

### Features

* **adminbar:** replace one-click flush with command palette integration ([382b10b](https://github.com/MilliPress/MilliCache/commit/382b10beec75a421834632919875090c65d21443))
* **adminbar:** snackbar clear feedback and a wider palette ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **clear:** report removed-entry counts instead of processed inputs ([23ec58f](https://github.com/MilliPress/MilliCache/commit/23ec58f9b5b2d09a23fae07137aafb171c912837))
* **cli:** scope bare clear flags to the WP-CLI site context ([d30d31e](https://github.com/MilliPress/MilliCache/commit/d30d31e386408c3a072c513a29108eb7528b9a6e))
* **commands:** offer expire alongside clear with descriptive target labels ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** capture the page in the outermost output buffer ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))


### Bug Fixes

* **adminbar:** keep the admin bar button size stable on page load ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **clear:** anchor path-only URL targets onto the home URL ([27bd7dc](https://github.com/MilliPress/MilliCache/commit/27bd7dc6c42f3d2210bd02ae148d31fb461a08e5))
* **clear:** skip non-viewable taxonomies in post-related flags ([40cd884](https://github.com/MilliPress/MilliCache/commit/40cd88414019eda097f910bce72fb6acd40a01e8))
* **commands:** drop the stray focus ring after palette clears ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** execute the invalidation queue when the drop-in never loads ([746bd0e](https://github.com/MilliPress/MilliCache/commit/746bd0eb9cfafe57df36c39a45ac8074d0708819))
* **engine:** keep non-default ports in URL-based cache hashes ([ed6f3d6](https://github.com/MilliPress/MilliCache/commit/ed6f3d670305dcf7750ed49bf8425e1149d80efa))
* **engine:** never store redirect responses ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))

## [1.7.7](https://github.com/MilliPress/MilliCache/compare/v1.7.6...v1.7.7) (2026-07-29)

<!-- mc:auto sha=36dc5e53f3fb -->
Multisite metrics are the headlining fix here: response times, bandwidth, and stale-serve counts were silently dropped on network installs, leaving Insights charts flat. That's resolved.

On the status side, a new dashboard check surfaces when the settings config file can't be written — meaning components that load before WordPress would otherwise run on stale settings without any warning. MilliCache heals the file automatically once the directory is writable again. Relatedly, constants now behave more predictably: defining one sets and locks the value, and removing it unlocks the setting while preserving whatever was last saved.
<!-- /mc:auto -->

### Features

* **dropins:** share install reporting and heal extension drop-ins ([800111c](https://github.com/MilliPress/MilliCache/commit/800111c9cf4b4666669ca075ac606b4deb2d24aa))
* **engine:** expose readiness for exception-free drop-in probes ([24b8d37](https://github.com/MilliPress/MilliCache/commit/24b8d37146f2dd8e2a0b83d5a0214c2527af9c64))
* **status:** report when the config file cannot be written ([54f2e82](https://github.com/MilliPress/MilliCache/commit/54f2e821843c0d165db4fc463b9346af2b88822e))


### Bug Fixes

* **deps:** require millipress/millibase ^2.8.0 ([dd906d9](https://github.com/MilliPress/MilliCache/commit/dd906d9f241b121b865a9f8ba7acd883e95e489f))
* **metrics:** record response times and honor retention on multisite ([087e764](https://github.com/MilliPress/MilliCache/commit/087e7645a2c168d2eca40001b81dc6b44986c09f))
* Reinstall an already-correct drop-in symlink when --force is passed ([3b2ed2f](https://github.com/MilliPress/MilliCache/commit/3b2ed2fc2540f0987e6623b8091401ccb50fa181))


### Miscellaneous

* pin the next release to 1.7.7 ([dd67e0c](https://github.com/MilliPress/MilliCache/commit/dd67e0c8fe214974e0c965d1e1503e0fe2277222))

## [1.7.6](https://github.com/MilliPress/MilliCache/compare/v1.7.5...v1.7.6) (2026-07-28)


### Features

* **engine:** announce every cache clear as one merged flag batch ([e2f1513](https://github.com/MilliPress/MilliCache/commit/e2f15137798c35e975f3c7d1a5da4bbca786da4a))
* **status:** warn before the storage server runs out of memory ([a4fc9e6](https://github.com/MilliPress/MilliCache/commit/a4fc9e69116e4419bc1fd4f8b9fe793e322f2552))


### Bug Fixes

* **cli:** give the interactive redis-cli session the real terminal ([14e2128](https://github.com/MilliPress/MilliCache/commit/14e21281b09e5a009b3617c56d7cbcfa5343737f))


### Miscellaneous

* **deps:** update dev dependencies ([2aa340a](https://github.com/MilliPress/MilliCache/commit/2aa340a8656abc5fb5ecac0ee5fd571d86424c22))

## [1.7.5](https://github.com/MilliPress/MilliCache/compare/v1.7.4...v1.7.5) (2026-07-24)


### Bug Fixes

* **cache:** clear a post's cache when it is unpublished ([43a1a88](https://github.com/MilliPress/MilliCache/commit/43a1a883c1e28eb633ac7b427e32bcb5842da656))
* **cache:** clear feed caches when a post is published or updated ([b04ede8](https://github.com/MilliPress/MilliCache/commit/b04ede8a015f8d2a34ded93236e76be825538c57))
* **cache:** fire millicache_cache_cleared_by_posts on automatic post invalidation ([6615c7d](https://github.com/MilliPress/MilliCache/commit/6615c7d44eae208ccc5730bd23b386c347930917))
* **engine:** accept Vary tokens covered by request keying or inert on GET ([53ad7b7](https://github.com/MilliPress/MilliCache/commit/53ad7b74b7f2d3cd9118a64bca28aaee3b994427)), closes [#172](https://github.com/MilliPress/MilliCache/issues/172)
* **engine:** resolve Authorization bucket from redirect and basic-auth channels ([7e7ed1b](https://github.com/MilliPress/MilliCache/commit/7e7ed1b78d17323ab77afe5fbc71b8c6ceb53591))
* **storage:** prevent a fatal error when toggling MilliCache alongside MilliCache Pro ([1ad4949](https://github.com/MilliPress/MilliCache/commit/1ad4949e9a2f667d5942bf0cb7f5eb3f8c513fc3))

## [1.7.4](https://github.com/MilliPress/MilliCache/compare/v1.7.3...v1.7.4) (2026-07-22)


### Features

* **i18n:** install language packs from the millipress.com languages API ([dd2d73d](https://github.com/MilliPress/MilliCache/commit/dd2d73dc3f8d7a1f1d4f5dd432bab3693e38e891))
* **release:** post a single Discord notification on stable release ([57b0def](https://github.com/MilliPress/MilliCache/commit/57b0defddc9ffac48c948eae8681179f5e268d84))


### Bug Fixes

* **i18n:** serve JS translations as handle-named full catalogs ([747594a](https://github.com/MilliPress/MilliCache/commit/747594a108fcf46408d53dd75f6a99b0dc1ca6cb))
* **updater:** Correct endpoint URL for plugin update information ([46611ad](https://github.com/MilliPress/MilliCache/commit/46611ad197ff3c59ee0205bb8526139578dbdfbb))


### Miscellaneous

* **release:** ship the language-pack injector as 1.7.4 ([5bd8ff6](https://github.com/MilliPress/MilliCache/commit/5bd8ff6f730e63d90c14683e106105ed1b4be14f))

## [1.7.3](https://github.com/MilliPress/MilliCache/compare/v1.7.2...v1.7.3) (2026-07-19)


### Features

* **cli:** let `wp millicache drop` target and reinstall any drop-in ([8937287](https://github.com/MilliPress/MilliCache/commit/8937287d2d7ff505f4d91a53eb76a1d26c1e925e))
* **i18n:** make translation-ready for language-pack delivery ([4757aa8](https://github.com/MilliPress/MilliCache/commit/4757aa81ab833b2927f6d0d453253128d23e3cd5))
* **logging:** adopt the shared MilliBase Logger ([7eccbaa](https://github.com/MilliPress/MilliCache/commit/7eccbaae4a7010719e4a9330fda3fca5d5b415c8))


### Bug Fixes

* **pcp:** satisfy Plugin Check across shipped sources ([41f918d](https://github.com/MilliPress/MilliCache/commit/41f918de3912bde84f05708fc3ebea51f73cc6bb))
* **ui:** share Status card styles between MilliCache and MilliCache Pro ([6aac122](https://github.com/MilliPress/MilliCache/commit/6aac12210d438dec29cadfd54285e69e7bb9dd28))

## [1.7.2](https://github.com/MilliPress/MilliCache/compare/v1.7.1...v1.7.2) (2026-07-16)


### Bug Fixes

* **dropin:** invalidate OPcache when installing or removing drop-ins ([841707d](https://github.com/MilliPress/MilliCache/commit/841707dde73f15ac743d72bacff603bd86bb62d7))
* **rules:** share the rules engine with other MilliRules integrations ([ba02b55](https://github.com/MilliPress/MilliCache/commit/ba02b55a922b82426e3406a9b37ebc7ff8be8b31))
* **settings:** recognize the cache buckets key so MC_CACHE_BUCKETS works ([3255794](https://github.com/MilliPress/MilliCache/commit/32557945d9c48aae5f25dc11dbe6e87b466e1116))
* **updater:** only self-update when this copy owns the plugin basename ([2f6d584](https://github.com/MilliPress/MilliCache/commit/2f6d5849e83d1027b026948ba40221a516326944))

## [1.7.1](https://github.com/MilliPress/MilliCache/compare/v1.7.0...v1.7.1) (2026-07-14)


### Bug Fixes

* **engine:** publish singleton before re-entrant bootstrap work ([cd6aa21](https://github.com/MilliPress/MilliCache/commit/cd6aa21c480de4cb4b2de5ff9b1bdad956bff783))
* **status:** stop KPI tiles stretching in Safari ([c8d2945](https://github.com/MilliPress/MilliCache/commit/c8d2945b00ca287e8583f43ac20e3738a7bc1f5c))

## [1.7.0](https://github.com/MilliPress/MilliCache/compare/v1.6.2...v1.7.0) (2026-07-12)

MilliCache 1.7.0 is a big step forward. The full feature list is long, so here are the highlights:

**Redis Replication & Sentinel**
MilliCache now supports high-availability setups. For most sites we still recommend simply running Redis or Valkey on the host server, but larger projects that need a more resilient topology are now covered.

**Status Graphs**
You can finally see how your cache is actually performing. A rebuilt Status dashboard charts your hit and miss ratio over time, so you can tell at a glance whether the cache is doing its job. On Multisite you also get network-wide stats.

**Cache Buckets**
Store different versions of the same request, for example based on a request header. This is really handy with plugins like roots/post-content-to-markdown, where you want to cache and serve different responses for the same URL: HTML for humans, Markdown for AI. In MilliCache that's a single rule.

**Deduplication**
Byte-identical responses are stored only once. MilliCache content-addresses each response body, so any requests that produce the exact same HTML (whether it's /?foo and /?foo=bar, or two entirely different URLs) share a single stored copy in Redis. Less memory, same speed.

**Cache Health**
Beyond the graphs, MilliCache now integrates with WordPress Site Health, so issues surface where you'd expect them: you won't miss it if, for example, your drop-in file is not in place.


### Features

* **admin:** add millicache_admin_notice action and HTML allowlist ([c693a0a](https://github.com/MilliPress/MilliCache/commit/c693a0ac19f88dc066e08a093d381fa210f2333d))
* **adminbar:** animate + 500ms delay before post-clear recount ([00c7dc1](https://github.com/MilliPress/MilliCache/commit/00c7dc1199e1768c21f6432be3c436f44190d3db))
* **adminbar:** live cache size + bounded current-view clear ([3877451](https://github.com/MilliPress/MilliCache/commit/38774516beb6e970597a37512d0c9d0b81fdd0fb))
* **admin:** centered loading indicator on the Status tab ([534d7b0](https://github.com/MilliPress/MilliCache/commit/534d7b0923dc9832732f6cb2db2f6e4bd1385d49))
* **admin:** count user-defined custom rules in the snapshot ([ab0d686](https://github.com/MilliPress/MilliCache/commit/ab0d686e954866dfb2fdf4016b79ce99d7cbfe0f))
* **admin:** docs links per check, "warnings" pill label, rules in snapshot ([38307a4](https://github.com/MilliPress/MilliCache/commit/38307a4e60a2fea6e389ac8db6a30244327210bf))
* **admin:** expose three extension filters on the status snapshot ([28c8cf1](https://github.com/MilliPress/MilliCache/commit/28c8cf1b59de5c0b90efea6bd902ff72bbb31cc8))
* **admin:** footer Status indicator with unified status payload ([dd72515](https://github.com/MilliPress/MilliCache/commit/dd72515259044f3654f75e91614697e05e96028a))
* **admin:** integrate with the WordPress Site Health screens ([2470fb8](https://github.com/MilliPress/MilliCache/commit/2470fb87100c16adabc171b163f158376294d361))
* **admin:** make the status extension filters scope-aware ([9498b55](https://github.com/MilliPress/MilliCache/commit/9498b55031c3f881c79fe2867f98d3ed35942089))
* **admin:** per-check breakdown in the footer Status modal ([52e58c9](https://github.com/MilliPress/MilliCache/commit/52e58c970d82df3e0de35323ff0b16dc99841b0b))
* **admin:** polish free Status chart to match Pro ([4841e79](https://github.com/MilliPress/MilliCache/commit/4841e794e5e0c78edbea182b31c933f5ce652ecb))
* **admin:** rebuild the Status tab — fixed panels, KPI/chart cards, lean Pro teaser ([1ef92ca](https://github.com/MilliPress/MilliCache/commit/1ef92ca5a3bbe78e0819a099d23a19520a6aad1d))
* **admin:** surface storage topology across CLI, status, and settings ([3f34067](https://github.com/MilliPress/MilliCache/commit/3f34067fc312b831ee26825f7727e53f3b9d56e2))
* **cache:** Add bucket framework with content-addressable body dedup ([#126](https://github.com/MilliPress/MilliCache/issues/126)) ([a279fd7](https://github.com/MilliPress/MilliCache/commit/a279fd704a05ce58165aed45038798f175f214cb))
* **cache:** cap entries at 5MB raw to protect Redis from oversized responses ([2e2511f](https://github.com/MilliPress/MilliCache/commit/2e2511fa8df6cf45a9d31379d82961a5afa9aad1))
* **engine:** expose install_mode() to report how MilliCache is loaded ([788e079](https://github.com/MilliPress/MilliCache/commit/788e0793fe65d35bd2d55131f50e7d485ab9ac31))
* hand the advanced-cache.php drop-in between co-resident MilliCache copies ([80d5ab9](https://github.com/MilliPress/MilliCache/commit/80d5ab9b56f496b1549afd6c621ed5c00ff42673))
* **hooks:** standardize cache-cleared action names ([1dedb37](https://github.com/MilliPress/MilliCache/commit/1dedb3796bb47aaa2605adb56242171f8d6e8827))
* **metrics:** make hit/miss retention windows configurable ([45257b5](https://github.com/MilliPress/MilliCache/commit/45257b5094ef9a187915bb2decbe718af6ef0f38))
* **metrics:** record hit/miss in the response path, excluding the preloader ([f42780f](https://github.com/MilliPress/MilliCache/commit/f42780ff1dbe442732f06f8e25bfd09ae591a656))
* **metrics:** time-bucketed per-blog hit/miss metrics engine ([e031f9c](https://github.com/MilliPress/MilliCache/commit/e031f9cde4164537eb5ca476aef2e0d4496a1f2e))
* **migrations:** add Core/Migrations with storage→network move ([63875c4](https://github.com/MilliPress/MilliCache/commit/63875c4d4ac75c199413deb195f631687c10223c))
* **plugin:** Add author information and plugin URI to advanced cache file ([ef97d53](https://github.com/MilliPress/MilliCache/commit/ef97d532432a62e883dffa575e467769db38801f))
* **response:** emit Age header on cache hits (RFC 9111) ([035bd24](https://github.com/MilliPress/MilliCache/commit/035bd24a13e7e86295b72c11e58ec97bc4445f2d))
* **rules:** skip caching search result pages by default ([4bf4be3](https://github.com/MilliPress/MilliCache/commit/4bf4be35f1f02ef6a1a1a13a255bbb0bc358f795))
* **settings:** Include metrics in network-scoped Settings instance for multisite ([59d2fa5](https://github.com/MilliPress/MilliCache/commit/59d2fa5ad339c9de43c9aef6699aa97e4cdf8b4b))
* **settings:** order the settings tabs by declared position ([d8f208a](https://github.com/MilliPress/MilliCache/commit/d8f208a97f3169a9f7df1a2b6c7f93d17f8000ec))
* **settings:** preserve storage connection settings across a full reset ([77772b1](https://github.com/MilliPress/MilliCache/commit/77772b1fd5c7c54acaeca85eebe04a8a1a374eda))
* **settings:** skeleton loading state for the Status dashboard ([d5b48a7](https://github.com/MilliPress/MilliCache/commit/d5b48a7a629e79770d5fe3d96325bcd14648bb4f))
* **site-health:** surface every status issue, not just the drop-in ([7a2d793](https://github.com/MilliPress/MilliCache/commit/7a2d7934d68019ed8d45329a3dab956cf07ef748))
* **status:** informational check tier, severity ordering, sticky modal tabs ([ec6a249](https://github.com/MilliPress/MilliCache/commit/ec6a2497d4c9b072441adf4b5582f25ff785d3a8))
* **status:** rework cache size metrics and Status tab UI ([88991c4](https://github.com/MilliPress/MilliCache/commit/88991c46555d890f6cc1e8bb10704cb1d5df1d8b))
* **status:** show each check as a subject and a verdict ([f863b98](https://github.com/MilliPress/MilliCache/commit/f863b98d0570c6d33dee25ddf7667fdaf55db787))
* **storage:** add generic key/value surface for reuse by drop-ins ([a1d195c](https://github.com/MilliPress/MilliCache/commit/a1d195c171a79f5f622dd50b562dbe6c8f00844e))
* **storage:** add ping() active reachability probe ([85068a2](https://github.com/MilliPress/MilliCache/commit/85068a2f099ee123e81de2d2e6ce23ab96a2fc82))
* **storage:** emit URL + canonical flags from entry deletion/expiry hooks ([d2445b1](https://github.com/MilliPress/MilliCache/commit/d2445b14a74d9eaf90cff3ee28dfd5cc010202be))
* **storage:** extract Connection class with shape-inferred topology ([afdfdce](https://github.com/MilliPress/MilliCache/commit/afdfdce1784b9505190e05b2d5ee2d150885e258))
* **ui:** Add footer with MilliCache version to Network and Site settings pages ([bfe5a1f](https://github.com/MilliPress/MilliCache/commit/bfe5a1f20db0a542c041f1e74a66bf3a17efbedf))
* **ui:** Add footer with MilliCache version to Network and Site settings pages ([130dcc7](https://github.com/MilliPress/MilliCache/commit/130dcc79f88e509535f98950ef33abe680bfdd0d))
* **updater:** honor millicache_updates at check time + add prerelease opt-in ([0153b35](https://github.com/MilliPress/MilliCache/commit/0153b357dd79796c7d5f50d8f3777f000498a311))


### Bug Fixes

* **admin:** color the Status modal check icons ([4ab80b3](https://github.com/MilliPress/MilliCache/commit/4ab80b3d487b6b8855bf30d02621cc1c1fdfa79a))
* **cache status:** Correct key prefix for site flags in status retrieval ([0f419a2](https://github.com/MilliPress/MilliCache/commit/0f419a2bf51f73a7f8a84c0b8ed3fb39945f223d))
* **cache:** stop SWR regeneration from storing serve-time headers ([2447389](https://github.com/MilliPress/MilliCache/commit/2447389c4184d79bb169cc8444a2334c6ac2cf75))
* **cron:** self-heal the nightly maintenance schedule on load ([32f8579](https://github.com/MilliPress/MilliCache/commit/32f857987539ded6daa798d15f6e9286fe0adc4c))
* **drop-in:** remove Plugin Name header so the drop-in is not listed as a plugin ([c532c15](https://github.com/MilliPress/MilliCache/commit/c532c15925856f0017206c1b10b64feaf84b1c3c))
* **network:** Update network settings URL for MilliCache management ([4861431](https://github.com/MilliPress/MilliCache/commit/486143110f438e4df99edb84d6a0bf6d72437227))
* **release:** isolate Strauss from setup-php's github-oauth token ([85b642e](https://github.com/MilliPress/MilliCache/commit/85b642eead3f8c4eec2e1eefbfef0ea3efb3a951))
* **settings:** register the metrics.active default so it survives resolution ([f079f91](https://github.com/MilliPress/MilliCache/commit/f079f9146e59598aaa6b99f890c9c17d11e72a6d))
* **status:** call the deduplication count unique responses, not pages ([52447f9](https://github.com/MilliPress/MilliCache/commit/52447f9dbdc27c0c60cc54e639c177c2a3d78485))
* **status:** show clean package versions in the debug info ([4deacaf](https://github.com/MilliPress/MilliCache/commit/4deacaf0de21a07fb3524a6b909fa18651f776e7))
* **storage:** preserve flag membership when expiring a cache entry ([7c83b33](https://github.com/MilliPress/MilliCache/commit/7c83b3388ae5efffa876487a4a674140e1046fda))
* **ui:** Correct month value from 'M' to 'mo' ([a0dbbf5](https://github.com/MilliPress/MilliCache/commit/a0dbbf5c3496fc117fe4521fc950935418590601))
* **workflow:** make polish-release-pr idempotent across reruns ([077c0c8](https://github.com/MilliPress/MilliCache/commit/077c0c8b4e3f9258522a3d950134db64be4a2fa3))


### Performance

* **storage:** batch flag-to-key resolution when clearing by sets ([1ee0b65](https://github.com/MilliPress/MilliCache/commit/1ee0b65e51e96db7efb41eb8a6db2825eee9f318))

## [1.7.0-beta.7](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.6...v1.7.0-beta.7) (2026-07-10)

<!-- mc:auto sha=19d754483250 -->
Multi-copy installations — where MilliCache and a bundling plugin such as MilliCache Pro are active side-by-side — now hand off `advanced-cache.php` cleanly instead of leaving it in a broken state. Deactivating one copy re-points the drop-in to whichever sibling is still active, activation installs the drop-in belonging to the copy actually being activated, and a new self-heal step corrects a stale drop-in automatically after any plugin activation, deactivation, or update.
<!-- /mc:auto -->

### Features

* hand the advanced-cache.php drop-in between co-resident MilliCache copies ([80d5ab9](https://github.com/MilliPress/MilliCache/commit/80d5ab9b56f496b1549afd6c621ed5c00ff42673))

## [1.7.0-beta.6](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.5...v1.7.0-beta.6) (2026-07-08)

<!-- mc:auto sha=e82d844d39a5 -->
Stale-while-revalidate regeneration was storing serve-time headers — including injected `Age` and `Cache-Control: no-cache` — causing regenerated entries to replay `no-cache` forever and remain edge-uncached. That is fixed: a new `millicache_entry_headers` filter runs at the single store chokepoint for both miss-capture and background regen, serve-time headers are scrubbed before storage, and regen now uses the original stored headers as its base rather than the frozen post-`fastcgi_finish_request()` header table.

Cache hits now emit an `Age` header per RFC 9111, so downstream CDN edges subtract elapsed time from the freshness window and expire their copy in sync with this entry rather than resetting to a full lifetime.

A new 5 MB entry size cap (`MAX_ENTRY_SIZE`) rejects oversized responses — such as PDF exports — before they reach Redis, preventing `maxmemory` exhaustion and legitimate-page eviction.

The Status panel gains an informational check tier (gray info icon, no health impact) for neutral facts and features that are off by choice, with checks now ordered by severity. The `millicache_updates` filter is evaluated at update-check time rather than constructor time, so filters registered in `functions.php` or mu-plugins are honored. Define `MC_UPDATE_PRERELEASE` to opt a site into prerelease builds.
<!-- /mc:auto -->

### Features

* **cache:** cap entries at 5MB raw to protect Redis from oversized responses ([2e2511f](https://github.com/MilliPress/MilliCache/commit/2e2511fa8df6cf45a9d31379d82961a5afa9aad1))
* **response:** emit Age header on cache hits (RFC 9111) ([035bd24](https://github.com/MilliPress/MilliCache/commit/035bd24a13e7e86295b72c11e58ec97bc4445f2d))
* **status:** informational check tier, severity ordering, sticky modal tabs ([ec6a249](https://github.com/MilliPress/MilliCache/commit/ec6a2497d4c9b072441adf4b5582f25ff785d3a8))
* **updater:** honor millicache_updates at check time + add prerelease opt-in ([0153b35](https://github.com/MilliPress/MilliCache/commit/0153b357dd79796c7d5f50d8f3777f000498a311))


### Bug Fixes

* **cache:** stop SWR regeneration from storing serve-time headers ([2447389](https://github.com/MilliPress/MilliCache/commit/2447389c4184d79bb169cc8444a2334c6ac2cf75))
* **drop-in:** remove Plugin Name header so the drop-in is not listed as a plugin ([c532c15](https://github.com/MilliPress/MilliCache/commit/c532c15925856f0017206c1b10b64feaf84b1c3c))
* **release:** isolate Strauss from setup-php's github-oauth token ([85b642e](https://github.com/MilliPress/MilliCache/commit/85b642eead3f8c4eec2e1eefbfef0ea3efb3a951))

## [1.7.0-beta.5](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.4...v1.7.0-beta.5) (2026-06-30)

<!-- mc:auto sha=a3b5e42afc99 -->
This release tightens the storage and hook layers ahead of the 1.7.0 stable cut.

The most important fix addresses a regression introduced in v1.6.2: expiring a cache entry by flag was silently stripping its flag memberships, leaving it orphaned and unreachable by subsequent flag-based clears until its TTL naturally elapsed. Flag membership is now preserved correctly on expiry.

On the hooks side, all cache-invalidation actions are now named consistently under the `millicache_cache_cleared_by_<target>` pattern — `millicache_cache_cleared_by_urls` is new, and `millicache_cleared_by_networks` has been renamed to `millicache_cache_cleared_by_networks`. **This is a breaking change for any code listening on the old name.** Entry deletion and expiry hooks (`millicache_entry_deleting`, `millicache_entry_deleted`, and the new `millicache_entry_expired`) now carry the entry URL and canonical flags (e.g. `2:post:123`) as additional arguments, giving edge/CDN integrations a complete signal directly from the storage layer. Flag-to-key resolution when clearing by sets is also now batched into a single pipeline, reducing Redis round-trips proportionally to flag fan-out.

Finally, `Storage` gains a generic key/value surface (`get`, `get_multiple`, `set`, `delete`, `delete_by_pattern`) so Pro drop-ins such as an object-cache driver can reuse MilliCache's existing Redis connection and fail-fast logic without opening a second one.
<!-- /mc:auto -->

### Features

* **hooks:** standardize cache-cleared action names ([1dedb37](https://github.com/MilliPress/MilliCache/commit/1dedb3796bb47aaa2605adb56242171f8d6e8827))
* **plugin:** Add author information and plugin URI to advanced cache file ([ef97d53](https://github.com/MilliPress/MilliCache/commit/ef97d532432a62e883dffa575e467769db38801f))
* **storage:** add generic key/value surface for reuse by drop-ins ([a1d195c](https://github.com/MilliPress/MilliCache/commit/a1d195c171a79f5f622dd50b562dbe6c8f00844e))
* **storage:** emit URL + canonical flags from entry deletion/expiry hooks ([d2445b1](https://github.com/MilliPress/MilliCache/commit/d2445b14a74d9eaf90cff3ee28dfd5cc010202be))


### Bug Fixes

* **storage:** preserve flag membership when expiring a cache entry ([7c83b33](https://github.com/MilliPress/MilliCache/commit/7c83b3388ae5efffa876487a4a674140e1046fda))


### Performance

* **storage:** batch flag-to-key resolution when clearing by sets ([1ee0b65](https://github.com/MilliPress/MilliCache/commit/1ee0b65e51e96db7efb41eb8a6db2825eee9f318))

## [1.7.0-beta.4](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.3...v1.7.0-beta.4) (2026-06-23)

A full settings reset now leaves your storage connection intact, so clearing your caching behavior no longer disconnects your cache server or forces you to re-enter the connection details.

### Features

* **settings:** Include metrics in network-scoped Settings instance for multisite ([59d2fa5](https://github.com/MilliPress/MilliCache/commit/59d2fa5ad339c9de43c9aef6699aa97e4cdf8b4b))
* **settings:** preserve storage connection settings across a full reset ([77772b1](https://github.com/MilliPress/MilliCache/commit/77772b1fd5c7c54acaeca85eebe04a8a1a374eda))
* **settings:** skeleton loading state for the Status dashboard ([d5b48a7](https://github.com/MilliPress/MilliCache/commit/d5b48a7a629e79770d5fe3d96325bcd14648bb4f))

## [1.7.0-beta.3](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.2...v1.7.0-beta.3) (2026-06-20)

This beta brings high-availability storage to MilliCache. Alongside single-server setups, it adds first-class support for Redis Replication and Sentinel, inferred automatically from the shape of `MC_STORAGE_HOST` with no separate mode flag to manage: a host string is single-node, a `master` map enables master/replica replication, and a `service` map enables Sentinel-managed failover. The connection layer is also more resilient: a misconfigured connection now disables the cache and serves the site uncached instead of silently falling back to localhost, and a connection failure fails fast so a brief storage outage can no longer slow down every request. Cache analytics gain configurable hit/miss retention windows, letting you decide how much history to keep.

### Features

* **admin:** centered loading indicator on the Status tab ([534d7b0](https://github.com/MilliPress/MilliCache/commit/534d7b0923dc9832732f6cb2db2f6e4bd1385d49))
* **admin:** polish free Status chart to match Pro ([4841e79](https://github.com/MilliPress/MilliCache/commit/4841e794e5e0c78edbea182b31c933f5ce652ecb))
* **admin:** surface storage topology across CLI, status, and settings ([3f34067](https://github.com/MilliPress/MilliCache/commit/3f34067fc312b831ee26825f7727e53f3b9d56e2))
* **metrics:** make hit/miss retention windows configurable ([45257b5](https://github.com/MilliPress/MilliCache/commit/45257b5094ef9a187915bb2decbe718af6ef0f38))
* **storage:** extract Connection class with shape-inferred topology ([afdfdce](https://github.com/MilliPress/MilliCache/commit/afdfdce1784b9505190e05b2d5ee2d150885e258))


### Bug Fixes

* **network:** Update network settings URL for MilliCache management ([4861431](https://github.com/MilliPress/MilliCache/commit/486143110f438e4df99edb84d6a0bf6d72437227))

## [1.7.0-beta.2](https://github.com/MilliPress/MilliCache/compare/v1.7.0-beta.1...v1.7.0-beta.2) (2026-06-09)

Here's a friendly intro for the 1.7.0-beta.2 release:

---

This beta brings the biggest admin overhaul in a while. The Status tab has been rebuilt from the ground up with KPI cards, a 7-day hit-ratio sparkline, and a full-width requests chart — and the same diagnostic story now flows into WordPress's native Site Health screens, so admins troubleshooting from Tools → Site Health see MilliCache's drop-in state, storage connectivity, and WP_CACHE status right alongside core checks.

The footer gets a new Status pill that summarizes overall health at a glance and opens a modal with a structured per-check breakdown (good/warning/critical, with docs links) alongside the existing debug snapshot. That snapshot is also the source of truth for a revamped `wp millicache status` CLI command and a prefilled GitHub issue template, making bug reports a one-click affair.

On the metrics side, hit and miss counts are now recorded in the response path (hourly buckets, rolled up nightly, scoped per blog) with preloader requests sensibly excluded. The admin bar gains live cache-size fetching on menu open and a fix for "Clear Current View" that was accidentally wiping the entire cache when a shared flag existed.

Rounding things out: the nightly maintenance schedule now self-heals on load so it can't silently go missing after an update, a `ping()` probe gives Site Health an accurate storage-reachability signal, and the `install_mode()` helper reports whether MilliCache is running standalone or Composer-loaded.

### Features

* **adminbar:** animate + 500ms delay before post-clear recount ([00c7dc1](https://github.com/MilliPress/MilliCache/commit/00c7dc1199e1768c21f6432be3c436f44190d3db))
* **adminbar:** live cache size + bounded current-view clear ([3877451](https://github.com/MilliPress/MilliCache/commit/38774516beb6e970597a37512d0c9d0b81fdd0fb))
* **admin:** count user-defined custom rules in the snapshot ([ab0d686](https://github.com/MilliPress/MilliCache/commit/ab0d686e954866dfb2fdf4016b79ce99d7cbfe0f))
* **admin:** docs links per check, "warnings" pill label, rules in snapshot ([38307a4](https://github.com/MilliPress/MilliCache/commit/38307a4e60a2fea6e389ac8db6a30244327210bf))
* **admin:** expose three extension filters on the status snapshot ([28c8cf1](https://github.com/MilliPress/MilliCache/commit/28c8cf1b59de5c0b90efea6bd902ff72bbb31cc8))
* **admin:** footer Status indicator with unified status payload ([dd72515](https://github.com/MilliPress/MilliCache/commit/dd72515259044f3654f75e91614697e05e96028a))
* **admin:** integrate with the WordPress Site Health screens ([2470fb8](https://github.com/MilliPress/MilliCache/commit/2470fb87100c16adabc171b163f158376294d361))
* **admin:** make the status extension filters scope-aware ([9498b55](https://github.com/MilliPress/MilliCache/commit/9498b55031c3f881c79fe2867f98d3ed35942089))
* **admin:** per-check breakdown in the footer Status modal ([52e58c9](https://github.com/MilliPress/MilliCache/commit/52e58c970d82df3e0de35323ff0b16dc99841b0b))
* **admin:** rebuild the Status tab — fixed panels, KPI/chart cards, lean Pro teaser ([1ef92ca](https://github.com/MilliPress/MilliCache/commit/1ef92ca5a3bbe78e0819a099d23a19520a6aad1d))
* **engine:** expose install_mode() to report how MilliCache is loaded ([788e079](https://github.com/MilliPress/MilliCache/commit/788e0793fe65d35bd2d55131f50e7d485ab9ac31))
* **metrics:** record hit/miss in the response path, excluding the preloader ([f42780f](https://github.com/MilliPress/MilliCache/commit/f42780ff1dbe442732f06f8e25bfd09ae591a656))
* **metrics:** time-bucketed per-blog hit/miss metrics engine ([e031f9c](https://github.com/MilliPress/MilliCache/commit/e031f9cde4164537eb5ca476aef2e0d4496a1f2e))
* **storage:** add ping() active reachability probe ([85068a2](https://github.com/MilliPress/MilliCache/commit/85068a2f099ee123e81de2d2e6ce23ab96a2fc82))
* **ui:** Add footer with MilliCache version to Network and Site settings pages ([bfe5a1f](https://github.com/MilliPress/MilliCache/commit/bfe5a1f20db0a542c041f1e74a66bf3a17efbedf))
* **ui:** Add footer with MilliCache version to Network and Site settings pages ([130dcc7](https://github.com/MilliPress/MilliCache/commit/130dcc79f88e509535f98950ef33abe680bfdd0d))


### Bug Fixes

* **admin:** color the Status modal check icons ([4ab80b3](https://github.com/MilliPress/MilliCache/commit/4ab80b3d487b6b8855bf30d02621cc1c1fdfa79a))
* **cron:** self-heal the nightly maintenance schedule on load ([32f8579](https://github.com/MilliPress/MilliCache/commit/32f857987539ded6daa798d15f6e9286fe0adc4c))
* **settings:** register the metrics.active default so it survives resolution ([f079f91](https://github.com/MilliPress/MilliCache/commit/f079f9146e59598aaa6b99f890c9c17d11e72a6d))
* **workflow:** make polish-release-pr idempotent across reruns ([077c0c8](https://github.com/MilliPress/MilliCache/commit/077c0c8b4e3f9258522a3d950134db64be4a2fa3))

## [1.7.0-beta.1](https://github.com/MilliPress/MilliCache/compare/v1.6.2...v1.7.0-beta.1) (2026-05-13)

This release introduces a new **bucket framework** for response variants. Before, the cache treated every request producing the same URL as one entry — which meant sites returning different content based on a cookie, header, or user state had to either disable caching for those pages or fight invalidation manually. Now any rule condition can split requests into separate buckets — cookie values (consent state, A/B test arm, currency), auth tokens, or the `Accept` header. That last one is especially useful for sites optimizing for AI agents: pair MilliCache with something like [`roots/post-content-to-markdown`](https://github.com/roots/post-content-to-markdown), and the Markdown variant your AI crawlers fetch and the HTML variant your browsers fetch live under the same page context — publish or update a post, and both invalidate together instead of drifting out of sync. Paired with content-addressable body deduplication, identical response bodies are stored once and shared across every bucket that produces them, so variants that happen to land on the same output don't pay for duplicate storage.

**Multisite networks** get a proper Network Admin UI for storage settings. Until now, sharing Redis connection details across subsites meant setting them via `define()` constants in `wp-config.php` — that route still works (and still takes precedence if you set both), but you can now configure storage from Network Admin like any other network-wide setting. Existing per-site UI configurations are migrated there automatically the first time you upgrade, so nothing breaks. The **Status tab** has also been reworked with breakdowns that tell you something useful at a glance: total bytes, deduplicated bytes, average per entry — so you can see whether the body dedup is paying off for your site.

The upgraded MilliBase foundation also brings the new **Abilities API**. This exposes your cache settings through standardized REST endpoints that AI assistants and automation tools speak natively — so a tool like Claude can read your current TTL or update your ignore list directly, and CI pipelines can configure MilliCache the same way they'd configure any other service.


### Features

* **cache:** Add bucket framework with content-addressable body dedup ([#126](https://github.com/MilliPress/MilliCache/issues/126)) ([a279fd7](https://github.com/MilliPress/MilliCache/commit/a279fd704a05ce58165aed45038798f175f214cb))
* **admin:** add millicache_admin_notice action and HTML allowlist ([c693a0a](https://github.com/MilliPress/MilliCache/commit/c693a0ac19f88dc066e08a093d381fa210f2333d))
* **migrations:** add Core/Migrations with storage→network move ([63875c4](https://github.com/MilliPress/MilliCache/commit/63875c4d4ac75c199413deb195f631687c10223c))
* **status:** rework cache size metrics and Status tab UI ([88991c4](https://github.com/MilliPress/MilliCache/commit/88991c46555d890f6cc1e8bb10704cb1d5df1d8b))


### Bug Fixes

* **cache status:** Correct key prefix for site flags in status retrieval ([0f419a2](https://github.com/MilliPress/MilliCache/commit/0f419a2bf51f73a7f8a84c0b8ed3fb39945f223d))
* **ui:** Correct month value from 'M' to 'mo' ([a0dbbf5](https://github.com/MilliPress/MilliCache/commit/a0dbbf5c3496fc117fe4521fc950935418590601))

## [1.7.0-beta](https://github.com/MilliPress/MilliCache/compare/v1.6.2...v1.7.0-beta) (2026-05-05)

MilliCache 1.7.0-beta brings a significant upgrade to how the cache handles response variants. The new bucket framework gives any part of your stack a clean way to tell the cache "these requests are different" — whether that's by auth token, content type, A/B test arm, or any other signal you can express as a rule condition. On top of that, identical response bodies are now stored only once and shared across variants automatically, so sites serving multiple representations of the same content use meaningfully less Redis memory without any extra configuration.



### Features

* **cache:** Add bucket framework with content-addressable body dedup ([#126](https://github.com/MilliPress/MilliCache/issues/126)) ([a279fd7](https://github.com/MilliPress/MilliCache/commit/a279fd704a05ce58165aed45038798f175f214cb))

## [1.6.2](https://github.com/MilliPress/MilliCache/compare/v1.6.1...v1.6.2) (2026-05-04)


### Features

* **flags:** Normalize flag identifiers (lowercase + trim) at boundaries ([2859cd2](https://github.com/MilliPress/MilliCache/commit/2859cd21b1ac0791ee0d0323fa27f46747360540))
* **rules:** Load user-defined rules from settings into the rule engine ([b3fc827](https://github.com/MilliPress/MilliCache/commit/b3fc82709356b7a1e8cabab071504f2f2c4b778c))


### Bug Fixes

* **deps:** Require millipress/millirules ^1.1.5 ([4edb44d](https://github.com/MilliPress/MilliCache/commit/4edb44d9cf6a4d956f91624f0f53824e9d151d07))
* **storage:** Drop stale flag fields and their set memberships on store ([50b8d54](https://github.com/MilliPress/MilliCache/commit/50b8d541c9f155db62417311f91926f4b4d8efd4))


### Miscellaneous

* release 1.6.2 ([509add9](https://github.com/MilliPress/MilliCache/commit/509add99f3025e7859833b08f6c58601603c5dc1))

## [1.6.1](https://github.com/MilliPress/MilliCache/compare/v1.6.0...v1.6.1) (2026-05-01)


### Bug Fixes

* **deps:** Require millipress/millibase ^2.4.0 ([3d1a0f9](https://github.com/MilliPress/MilliCache/commit/3d1a0f968bad8da0aee81c21aa4b1ec9180ec326))

## [1.6.0](https://github.com/MilliPress/MilliCache/compare/v1.5.2...v1.6.0) (2026-04-30)


### Features

* **rules:** Expose MilliRules registry/validation through Manager ([812cf36](https://github.com/MilliPress/MilliCache/commit/812cf36980071bbd18884e6112fdfd22ba2d5e53))


### Bug Fixes

* **rules:** Restrict default REST rule to GET/HEAD to avoid lock warning ([3144a48](https://github.com/MilliPress/MilliCache/commit/3144a48541edc17296a8e5902b1c35bf466daff7))

## [1.5.2](https://github.com/MilliPress/MilliCache/compare/v1.5.1...v1.5.2) (2026-04-26)


### Features

* **plugin:** Implement singleton pattern for MilliCache instance management ([8d92bcb](https://github.com/MilliPress/MilliCache/commit/8d92bcb3b93fac1c2f11d0c611bf60a926fc6f47))


### Miscellaneous

* release 1.5.2 ([b2d9de5](https://github.com/MilliPress/MilliCache/commit/b2d9de569b5ec523b3ef845b7beeee873960abad))

## [1.5.1](https://github.com/MilliPress/MilliCache/compare/v1.5.0...v1.5.1) (2026-04-24)


### Bug Fixes

* **engine:** Simplify autoloader initialization for better compatibility ([fe45266](https://github.com/MilliPress/MilliCache/commit/fe45266fe14fef13df84ad4b2cb408a32f205ade))

## [1.5.0](https://github.com/MilliPress/MilliCache/compare/v1.4.2...v1.5.0) (2026-04-23)


### Features

* **rules:** Add action metadata, scoped locking, and order-aware execution ([7196cd3](https://github.com/MilliPress/MilliCache/commit/7196cd3132757d22c22e7e072900c371d015cfe1))
* **rules:** Lock critical built-in rules and use order 0/1 convention ([c011d4c](https://github.com/MilliPress/MilliCache/commit/c011d4cff4060885873d892f323b07e8d6709f3b))
* **storage:** support username for authentication ([#108](https://github.com/MilliPress/MilliCache/issues/108)) ([19d62eb](https://github.com/MilliPress/MilliCache/commit/19d62eb06295b9e704b9701ec4bb6c92155f574a))


### Bug Fixes

* **activator:** Handle both old and new variable name in drop-in regex ([cf80b88](https://github.com/MilliPress/MilliCache/commit/cf80b8800915250ad8c43e3076fe19421c7a67d3))
* **engine:** Use PHP_INT_MAX - 10 for template_redirect priority ([8b7118f](https://github.com/MilliPress/MilliCache/commit/8b7118f19161248a20c20c10874858ca1098c736))
* **settings:** Remove inline padding from status tab wrapper ([7d919f3](https://github.com/MilliPress/MilliCache/commit/7d919f3249ed52428c5be2fb9c415232c4d9954b))
* **tests:** Update do_cache arguments to include string type with default value ([5afed64](https://github.com/MilliPress/MilliCache/commit/5afed643a5c72377988b7e5df848f19ac66620a6))

## [1.4.2](https://github.com/MilliPress/MilliCache/compare/v1.4.1...v1.4.2) (2026-04-22)


### Bug Fixes

* **activator:** Ensure symlink creation only if function exists ([f935526](https://github.com/MilliPress/MilliCache/commit/f9355262ed1c560caf7cb9d1772ea00f94158573))

## [1.4.1](https://github.com/MilliPress/MilliCache/compare/v1.4.0...v1.4.1) (2026-04-22)


### Bug Fixes

* **advanced-cache.php:** Set correct path to plugin on copy file operation ([35179c2](https://github.com/MilliPress/MilliCache/commit/35179c2e44105ca2cbce553b6db00f284b42ac7c))

## [1.4.0](https://github.com/MilliPress/MilliCache/compare/v1.3.2...v1.4.0) (2026-04-01)

### Features

* **cache:** Store request URL and variant dimensions in cache entries ([d334421](https://github.com/MilliPress/MilliCache/commit/d334421d6dab06eaa759eb8189a5fd60e302eb7d))
* **settings:** Make schema defaults available to add-ons at plugin load ([c899d3b](https://github.com/MilliPress/MilliCache/commit/c899d3b5f4ad852465a038ea84dfdf7cb83b871b))


### Bug Fixes

* **e2e:** Checkout into lowercase directory for consistent plugin slug ([9a554a0](https://github.com/MilliPress/MilliCache/commit/9a554a029db904c47b8314656686c58e90221ea2))
* **release:** Reset manifest to last published version ([015ab7a](https://github.com/MilliPress/MilliCache/commit/015ab7a88006e4a9d4c5e05cc17cc007282e25e3))
* **storage:** Exclude expired keys from cache index count ([dcc68ef](https://github.com/MilliPress/MilliCache/commit/dcc68efa050b41374b9dbc8625b8001b729efad0))
* **storage:** Filter Redis hash fields to correctly identify flag fields ([ee18c24](https://github.com/MilliPress/MilliCache/commit/ee18c2471589032da8f6ce5d8980bbcf6b54d5cd))
* **storage:** Remove backward compat for pre-1.4.0 cache entries ([6ab8e0a](https://github.com/MilliPress/MilliCache/commit/6ab8e0a1588552119ddbe4c8c437f6fcfc2296b0))
* **storage:** Respect per-entry custom TTL/grace in Redis EXPIRE ([fd8ec58](https://github.com/MilliPress/MilliCache/commit/fd8ec58888242ce641cb0d7586ddbacdef0cbeb4))
* **tests:** Eliminate connection warnings from Storage scheme tests ([306f31d](https://github.com/MilliPress/MilliCache/commit/306f31d4a2eab4c2a111434a7ec4d3dd1f256a3b))


### Build

* **e2e:** Migrate to wp-env 11 with lifecycle scripts ([0220f95](https://github.com/MilliPress/MilliCache/commit/0220f954698a738f54d9a99534115816ea3e5264))

## [1.3.2](https://github.com/MilliPress/MilliCache/compare/v1.3.1...v1.3.2) (2026-03-23)


### Bug Fixes

* **ui:** Prevent asset enqueueing when admin bar is not showing ([b9be14a](https://github.com/MilliPress/MilliCache/commit/b9be14a4b244cae231afa812be2182a376c9b675))

## [1.3.1](https://github.com/MilliPress/MilliCache/compare/v1.3.0...v1.3.1) (2026-03-16)


### Bug Fixes

* **i18n:** Defer UI config to init hook to prevent early textdomain loading ([df51634](https://github.com/MilliPress/MilliCache/commit/df5163495f77256d0cf116b16a672452c6f49044))
* **ui:** Register hooks for UI initialization to ensure proper textdomain loading ([208621d](https://github.com/MilliPress/MilliCache/commit/208621ddec33dcabe1117f6bef25ef32613c635f))

## [1.3.0](https://github.com/MilliPress/MilliCache/compare/v1.2.0...v1.3.0) (2026-03-15)


### Features

* **admin:** Rebuild settings UI with MilliBase components ([574145e](https://github.com/MilliPress/MilliCache/commit/574145e38ebd9828a5a0b5ee5cde433bc528006d))
* **settings:** Integrate MilliBase as the settings framework ([83ba3fb](https://github.com/MilliPress/MilliCache/commit/83ba3fbd3eb51b0c6056b80cd90a5ca47254e793))
* **storage:** Add TLS support via scheme prefix in MC_STORAGE_HOST ([ac77698](https://github.com/MilliPress/MilliCache/commit/ac77698223331fc9383fa6ea0c7b5ddf4eb8691e))


### Bug Fixes

* **e2e:** Use dynamic slug in post deletion invalidation test ([1dcc057](https://github.com/MilliPress/MilliCache/commit/1dcc0573fc4c4090d45d7b7b3dc23638093a03b7))
* **manager:** Clearing by targets processes double prefixed flags in Multisite. ([17971f3](https://github.com/MilliPress/MilliCache/commit/17971f31b2e1c29dad7efdf12fbf92a610091e88))

## [1.2.0](https://github.com/MilliPress/MilliCache/compare/v1.1.0...v1.2.0) (2026-03-02)


### Features

* **storage:** Support Unix socket paths for Redis connections ([78254d7](https://github.com/MilliPress/MilliCache/commit/78254d77db584a6cacda52fadfd2e07e7552165f))


### Bug Fixes

* **storage:** Handle PredisException when retrieving Redis/Valkey config ([098b67c](https://github.com/MilliPress/MilliCache/commit/098b67ca6ae23901b28f8a594c8d2c3b4f7ebf2e))

## [1.1.0](https://github.com/MilliPress/MilliCache/compare/v1.0.2...v1.1.0) (2026-02-21)


### Features

* **deps:** Upgrade predis/predis from ^2.2 to ^3.0 ([b84a8bd](https://github.com/MilliPress/MilliCache/commit/b84a8bd5b3604308aea514c6559b88d1220a724d))


### Bug Fixes

* **release:** Remove draft config so Release Please creates git tags ([6d11112](https://github.com/MilliPress/MilliCache/commit/6d111121d23d4374d35f0facd08420dde3c22193))
* **ui:** Replace removed `warning` icon with `caution` ([3b8f686](https://github.com/MilliPress/MilliCache/commit/3b8f686e6540b2e688aaf9319e3a2b0b523d52cd))

## [1.0.2](https://github.com/MilliPress/MilliCache/compare/v1.0.1...v1.0.2) (2026-02-16)


### Bug Fixes

* Make check_cache_decision() public and remove Options::is_caching_allowed() ([b713aed](https://github.com/MilliPress/MilliCache/commit/b713aed77aa44708227596d7a66466d6057708bd))

## [1.0.1](https://github.com/MilliPress/MilliCache/compare/v1.0.0...v1.0.1) (2026-02-15)


### Bug Fixes

* **ci:** Use RELEASE_TOKEN for release-please to trigger PR workflows ([2f8322e](https://github.com/MilliPress/MilliCache/commit/2f8322efeb407fe34511ed6f5c28d1e279fa68a8))
* Register action namespaces in Engine constructor ([4a1ffb3](https://github.com/MilliPress/MilliCache/commit/4a1ffb3dd537536996acf3c137c5431f10ee348b))


### Refactoring

* **ci:** Move E2E from PR trigger to release workflow gate ([3c9de93](https://github.com/MilliPress/MilliCache/commit/3c9de93ecc5a7085bce86d906368ff44f92456a2))
* **ci:** Remove post-merge CI/E2E gates from release workflow ([810055a](https://github.com/MilliPress/MilliCache/commit/810055a38dfe84c43bc817472b426babebc50376))

## 1.0.0 (2026-02-13)

Initial stable release of MilliCache — a full-page cache plugin for WordPress powered by Redis compatible servers.

### Highlights

* **In-Memory Full-Page Cache** — Pages are served directly from memory before WordPress even initializes. No database queries.
* **Flag-Based Invalidation** — Tag cached pages with flags like `post:123` or `archive:category:5`, then clear related entries with a single command. Built-in flags are assigned automatically; custom flags give you full control.
* **Stale-While-Revalidate** — Serve expired content while fresh content regenerates in the background. Prevents cache stampedes on high-traffic pages.
* **Rules Engine** — Define caching behavior with a fluent, chainable PHP API. Set TTL, grace periods, and exclusions per condition — all version-controllable.
* **Multisite Native** — Per-site cache isolation with network-wide management. Clear one site, a subset, or an entire network.
* **Multiple Backends** — Redis, ValKey, KeyDB, or Dragonfly. Any Redis-compatible server works out of the box.
* **WP-CLI Integration** — Commands for cache testing, status checks, diagnostics, and bulk operations. AI-agent friendly.
* **Debug Headers & Browser Extension** — `X-MilliCache-*` headers show cache status, flags, and keys. The companion browser extension makes debugging effortless.
* **REST API** — `/millicache/v1/*` endpoints for cache control, status checks, and settings — ideal for CI/CD pipelines and monitoring.
* **Action & Filter Hooks** — Full customization of caching behavior, flag assignment, and invalidation events.
* **Smart Auto-Invalidation** — Cache clears automatically when posts, menus, widgets, or theme settings change.
* **Open Source** — GPL-2.0+ licensed. No vendor lock-in.

---

Canonical: https://www.millipress.com/docs/millicache/02-configuration/01-overview

---
title: 'Configuration Overview'
description: 'Configure MilliCache through wp-config.php constants, the WordPress admin UI, or WP-CLI. Covers priority order, setting categories, and common setups.'
menu_order: 10
---

# Configuration Overview

MilliCache can be configured through multiple sources. This guide covers the basics — see the [Reference](/docs/millicache/02-configuration/02-reference) for all available constants.

## Quick Start

Add this to your `wp-config.php` before `"That's all, stop editing!"`:

```php
// Required - enables WordPress caching
define( 'WP_CACHE', true );

// Optional - Connect to Redis (adjust for your setup)
define( 'MC_STORAGE_HOST', '127.0.0.1' );
define( 'MC_STORAGE_PORT', 6379 );
```

That's it! MilliCache will use the defaults for everything else.

## Configuration Sources

Settings are resolved in priority order:

| Priority        | Source                       | Best For                    |
|-----------------|------------------------------|-----------------------------|
| **1 (highest)** | Constants in `wp-config.php` | Production, version control |
| **2**           | Database (Admin UI, WP-CLI)  | Easy management             |
| **3**           | Defaults                     | Fallback values             |

Higher-priority sources always win. A constant overrides the database value.

### Using Constants

Define in `wp-config.php` with the format `MC_<MODULE>_<KEY>`:

```php
define( 'MC_CACHE_TTL', 86400 );       // 1 day
define( 'MC_CACHE_DEBUG', true );      // Debug headers
define( 'MC_STORAGE_HOST', 'redis' );  // Redis hostname
```

Defining a constant sets the value and locks it in the admin UI; removing the constant unlocks the setting but keeps its last value. Changing a constant behaves exactly like changing the setting in the admin UI: any side effects (such as rescheduling background jobs) are applied automatically on the next admin visit.

### Using Admin UI

Navigate to **Settings → MilliCache** to configure via the WordPress admin.

> [!NOTE]
> Settings defined via constants are shown but cannot be modified in the admin UI.

### Using WP-CLI

```bash
# View all settings
wp millicache config get

# Set a value
wp millicache config set cache.ttl 86400

# See where values come from
wp millicache config get --show-source
```

## Setting Categories

### Storage Settings

Connection to your Redis-compatible server:

| Constant              | Default     | Description                           |
|-----------------------|-------------|---------------------------------------|
| `MC_STORAGE_HOST`     | `127.0.0.1` | Hostname, IP, socket, or `tls://host` |
| `MC_STORAGE_PORT`     | `6379`      | TCP port                              |
| `MC_STORAGE_USERNAME` | `''`        | Redis ACL username                    |
| `MC_STORAGE_PASSWORD` | `''`        | Redis AUTH password                   |
| `MC_STORAGE_DB`       | `0`         | Database number (0-15)                |
| `MC_STORAGE_PREFIX`   | `mll`       | Key prefix                            |

### Cache Settings

Caching behavior:

| Constant         | Default   | Description            |
|------------------|-----------|------------------------|
| `MC_CACHE_TTL`   | `86400`   | Time-to-live (1 day)   |
| `MC_CACHE_GRACE` | `2592000` | Grace period (30 days) |
| `MC_CACHE_DEBUG` | `false`   | Debug headers          |
| `MC_CACHE_GZIP`  | `true`    | Compression            |

### Exclusion Settings

What to skip:

| Constant                       | Default           | Description               |
|--------------------------------|-------------------|---------------------------|
| `MC_CACHE_NOCACHE_PATHS`       | `[]`              | URL paths to exclude      |
| `MC_CACHE_NOCACHE_COOKIES`     | `[...]`           | Cookies that bypass cache |
| `MC_CACHE_IGNORE_COOKIES`      | `['_*']`          | Cookies stripped from key |
| `MC_CACHE_IGNORE_REQUEST_KEYS` | `['_*', 'utm_*']` | Query params to ignore    |

### Key Composition Settings

How cache entries differentiate from each other:

| Constant            | Default | Description                                                              |
|---------------------|---------|--------------------------------------------------------------------------|
| `MC_CACHE_UNIQUE`   | `[]`    | Static deployment-level keys folded into every request hash              |
| `MC_CACHE_BUCKETS`  | `[]`    | Per-request signal → token map (Accept negotiation, language, device, …) |

### Update Settings

Plugin update behavior:

| Constant               | Default | Description                        |
|------------------------|---------|------------------------------------|
| `MC_UPDATE_PRERELEASE` | `false` | Opt in to prerelease (beta) builds |

Update checks can be disabled with the `millicache_updates` filter. See the
[Constants Reference](/docs/millicache/02-configuration/02-reference#update-constants) and
[Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters#millicache_updates) for details.

## Common Configurations

### High-Traffic Site

```php
define( 'MC_CACHE_TTL', 604800 );     // 7 days
define( 'MC_CACHE_GRACE', 2592000 );  // 30 days grace
define( 'MC_CACHE_GZIP', true );
```

### Frequently Updated Content

```php
define( 'MC_CACHE_TTL', 3600 );       // 1 hour
define( 'MC_CACHE_GRACE', 86400 );    // 1 day grace
```

### Development Environment

```php
define( 'MC_CACHE_TTL', 60 );         // 1 minute
define( 'MC_CACHE_DEBUG', true );     // Show debug headers
```

### WooCommerce Site

WooCommerce already marks its dynamic pages (cart, checkout, my account) with the
`DONOTCACHEPAGE` constant, which MilliCache respects out of the box. What you should
configure is cookie handling: WooCommerce sets several cookies for ordinary browsing
visitors, and any cookie MilliCache does not ignore becomes part of the cache key.

```php
define( 'MC_CACHE_IGNORE_COOKIES', [
    '_*',                        // Keep the default (analytics cookies)
    'sbjs_*',                    // Order Attribution tracking (every visitor)
    'woocommerce_*',             // Recently viewed, cart hash, items in cart
    'wp_woocommerce_session_*',  // Customer session
    'store_notice*',             // Dismissed store notices
] );
```

Do **not** add `woocommerce_*` or `sbjs_*` to `MC_CACHE_NOCACHE_COOKIES`. Cookies like
`woocommerce_recently_viewed` are set the moment a visitor views a product, so a
bypass on them silently turns most browsing traffic into cache misses. See the
[FAQ](/docs/millicache/09-troubleshooting/02-faq#does-millicache-work-with-woocommerce) for the
full explanation.

## Viewing Current Configuration

```bash
# All settings with sources
wp millicache config get --show-source

# Test connection
wp millicache test

# Cache statistics
wp millicache stats
```

## Next Steps

- [Constants Reference](/docs/millicache/02-configuration/02-reference) — Complete list of all constants
- [Storage Backends](/docs/millicache/08-storage-backends/01-overview) — Redis/ValKey setup
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) — Command reference

---

Canonical: https://www.millipress.com/docs/millicache/02-configuration/02-reference

---
title: 'Configuration Constants Reference'
description: 'Complete reference of MilliCache wp-config.php constants: Redis connection, cache TTL and grace, exclusions, key composition, and update settings.'
menu_order: 20
---

# Constants Reference

This is a complete reference of all configuration constants available in MilliCache. Define these in `wp-config.php` before the line `"That's all, stop editing!"`.
Alternatively, use the admin UI (Settings -> MilliCache) or WP-CLI to manage settings stored in the database.

## Required Constants

### WP_CACHE

```php
define( 'WP_CACHE', true );
```

**Required.** Enables WordPress drop-in caching. Without this, MilliCache cannot intercept requests early.

## Storage Constants

Connection settings for your Redis-compatible server.

### MC_STORAGE_HOST

```php
define( 'MC_STORAGE_HOST', '127.0.0.1' );
```

| Property  | Value             |
|-----------|-------------------|
| Default   | `127.0.0.1`       |
| Type      | `string` or `array` |

Server hostname, IP address, or Unix socket path. Supports an optional `tls://` or `tcp://` scheme prefix for encrypted connections.

Set it to an **array** to enable replication (a `master` key) or Sentinel (a `service` key). See [High Availability](/docs/millicache/08-storage-backends/01-overview#high-availability-replication--sentinel) for the syntax. [MilliCache Pro](https://www.millipress.com/millicache-pro/) can configure all three topologies visually via its [Storage Connections module](https://www.millipress.com/docs/millicache-pro/02-modules/10-storage-connections/).

**Examples:**

```php
// IP address
define( 'MC_STORAGE_HOST', '10.0.0.5' );

// Hostname
define( 'MC_STORAGE_HOST', 'redis.example.com' );

// Docker container name
define( 'MC_STORAGE_HOST', 'redis' );

// Unix socket
define( 'MC_STORAGE_HOST', '/var/run/redis/redis.sock' );

// TLS connection (e.g. AWS ElastiCache with in-transit encryption)
define( 'MC_STORAGE_HOST', 'tls://master.example.cache.amazonaws.com' );
```

### MC_STORAGE_PORT

```php
define( 'MC_STORAGE_PORT', 6379 );
```

| Property   | Value     |
|------------|-----------|
| Default    | `6379`    |
| Type       | `integer` |

TCP port number. Ignored when using Unix sockets.

### MC_STORAGE_USERNAME

```php
define( 'MC_STORAGE_USERNAME', 'your-username' );
```

| Property  | Value        |
|-----------|--------------|
| Default   | `''` (empty) |
| Type      | `string`     |

Redis ACL username. Leave empty to use the default user. Required when your Redis server is configured with ACL users (Redis 6+).

> [!NOTE]
> When using the admin UI or WP-CLI, the setting key is `storage.username`.

### MC_STORAGE_PASSWORD

```php
define( 'MC_STORAGE_PASSWORD', 'your-password' );
```

| Property  | Value        |
|-----------|--------------|
| Default   | `''` (empty) |
| Type      | `string`     |

Redis AUTH password. Leave empty if no authentication required.

> [!NOTE]
> When using the admin UI or WP-CLI, the password is stored encrypted as `enc_password`.

### MC_STORAGE_DB

```php
define( 'MC_STORAGE_DB', 0 );
```

| Property   | Value     |
|------------|-----------|
| Default    | `0`       |
| Type       | `integer` |

Redis database number (0-15). Use different databases to isolate cache data.

### MC_STORAGE_PERSISTENT

```php
define( 'MC_STORAGE_PERSISTENT', true );
```

| Property  | Value     |
|-----------|-----------|
| Default   | `true`    |
| Type      | `boolean` |

Enable persistent connections. Reduces connection overhead but requires proper server configuration.

### MC_STORAGE_TIMEOUT

```php
define( 'MC_STORAGE_TIMEOUT', 1.0 );
```

| Property  | Value   |
|-----------|---------|
| Default   | `1.0`   |
| Type      | `float` (seconds) |

Connection timeout in seconds. The low default lets an unreachable backend fall back to uncached WordPress quickly. Raise it only if your server is healthy but distant.

### MC_STORAGE_READ_TIMEOUT

```php
define( 'MC_STORAGE_READ_TIMEOUT', 2.0 );
```

| Property  | Value   |
|-----------|---------|
| Default   | `2.0`   |
| Type      | `float` (seconds) |

Read/write timeout in seconds. Raise it only if you serve very large cached responses over a slow link.

### MC_STORAGE_PREFIX

```php
define( 'MC_STORAGE_PREFIX', 'mll' );
```

| Property   | Value    |
|------------|----------|
| Default    | `mll`    |
| Type       | `string` |

Prefix for all cache keys in Redis. Use different prefixes to share Redis between sites.

## Cache Constants

Settings that control caching behavior.

### MC_CACHE_TTL

```php
define( 'MC_CACHE_TTL', 86400 );
```

| Property   | Value           |
|------------|-----------------|
| Default    | `86400` (1 day) |
| Type       | `integer`       |

Time-to-live in seconds. How long cached content remains fresh.

| Value     | Duration  |
|-----------|-----------|
| `3600`    | 1 hour    |
| `86400`   | 1 day     |
| `604800`  | 1 week    |
| `2592000` | 30 days   |

### MC_CACHE_GRACE

```php
define( 'MC_CACHE_GRACE', 2592000 );
```

| Property   | Value               |
|------------|---------------------|
| Default    | `2592000` (30 days) |
| Type       | `integer`           |

Grace period in seconds. How long stale content can be served while regenerating.

### MC_CACHE_DEBUG

```php
define( 'MC_CACHE_DEBUG', false );
```

| Property  | Value     |
|-----------|-----------|
| Default   | `false`   |
| Type      | `boolean` |

Enable debug response headers. Useful for troubleshooting, disable in production.

### MC_CACHE_GZIP

```php
define( 'MC_CACHE_GZIP', true );
```

| Property  | Value     |
|-----------|-----------|
| Default   | `true`    |
| Type      | `boolean` |

Enable gzip compression of cached content. Requires `ext-zlib`.

### MC_CACHE_UNIQUE

```php
define( 'MC_CACHE_UNIQUE', [] );
```

| Property  | Value   |
|-----------|---------|
| Default   | `[]`    |
| Type      | `array` |

Static cache namespace: values folded into every request hash on this deployment. Use for deploy-time cache busting or multi-site isolation.

```php
define( 'MC_CACHE_UNIQUE', [ 'version' => '2.1', 'site_id' => 5 ] );
```

> [!TIP]
> For per-request differentiation, use `MC_CACHE_BUCKETS` instead.

### MC_CACHE_BUCKETS

```php
define( 'MC_CACHE_BUCKETS', [] );
```

| Property  | Value                                       |
|-----------|---------------------------------------------|
| Default   | `[]`                                        |
| Type      | `array<string, array<string, string>>`      |

Shared lookup tables for bucket resolvers. Each top-level key names a request *dimension*; the inner map translates raw request values into compact bucket tokens.

```php
define( 'MC_CACHE_BUCKETS', [
    'accept' => [ 'text/markdown' => 'md' ],
    'tenant' => [ 'acme' => 'acme', 'globex' => 'glx' ],
] );
```

Two built-in resolvers ship in MilliCache:

- **`auth`**: Authorization header. Always-on correctness primitive: each unique bearer token gets its own cache entry. No config needed.
- **`accept`**: Accept header content negotiation. Dormant unless `MC_CACHE_BUCKETS['accept']` is configured. Parses with q-values and looks up the top-preferred MIME type.

Other dimensions need a resolver implementation. See [Bucket Extension](/docs/millicache/07-developers/02-hooks-filters#bucket-extension): the rules engine's `set_bucket` action is the no-code way to add them.

> [!NOTE]
> Bucket tokens should be short: they're folded into the cache key. `md`, `de`, `mobile` are good; full MIME types or full UA strings are not.

### MC_CACHE_NOCACHE_PATHS

```php
define( 'MC_CACHE_NOCACHE_PATHS', [] );
```

| Property  | Value   |
|-----------|---------|
| Default   | `[]`    |
| Type      | `array` |

URL paths to exclude from caching. Supports wildcards.

```php
define( 'MC_CACHE_NOCACHE_PATHS', [
    '/my-account/*',
    '/checkout/*',
    '/cart/*',
] );
```

### MC_CACHE_NOCACHE_COOKIES

```php
define( 'MC_CACHE_NOCACHE_COOKIES', [ 'wp-*pass*', 'comment_author_*' ] );
```

| Property   | Value                               |
|------------|-------------------------------------|
| Default    | `['wp-*pass*', 'comment_author_*']` |
| Type       | `array`                             |

Cookies that cause cache bypass. Supports wildcards.

```php
define( 'MC_CACHE_NOCACHE_COOKIES', [
    'wp-*pass*',
    'comment_author_*',
    'memberpress_*',
] );
```

Bypass only on cookies that mark a genuinely personalized session (memberships,
logged-in states). For tracking or widget cookies that do not change the rendered
page, use `MC_CACHE_IGNORE_COOKIES` instead; a bypass on those disables caching
for every visitor carrying them. For WooCommerce specifically, see the
[FAQ](/docs/millicache/09-troubleshooting/02-faq#does-millicache-work-with-woocommerce).

### MC_CACHE_IGNORE_COOKIES

```php
define( 'MC_CACHE_IGNORE_COOKIES', [ '_*' ] );
```

| Property   | Value    |
|------------|----------|
| Default    | `['_*']` |
| Type       | `array`  |

Cookies stripped from cache key calculation. Supports wildcards. Ignored cookies
are also allowed in `Set-Cookie` response headers without preventing the page
from being cached.

```php
define( 'MC_CACHE_IGNORE_COOKIES', [
    '_*',       // Keep the default (analytics cookies)
    'sbjs_*',   // WooCommerce Order Attribution tracking
] );
```

Defining the constant replaces the default, so include `_*` in your list.

### MC_CACHE_IGNORE_REQUEST_KEYS

```php
define( 'MC_CACHE_IGNORE_REQUEST_KEYS', [ '_*', 'utm_*' ] );
```

| Property  | Value             |
|-----------|-------------------|
| Default   | `['_*', 'utm_*']` |
| Type      | `array`           |

Query parameters stripped from the cache key. Supports wildcards.

```php
define( 'MC_CACHE_IGNORE_REQUEST_KEYS', [
    '_*',
    'utm_*',
    'fbclid',
    'gclid',
] );
```

Requests that differ only in these parameters share one cache entry. The keys
are also removed from `$_SERVER['REQUEST_URI']`, `$_SERVER['QUERY_STRING']`,
`$_GET` and `$_REQUEST` at the end of `template_redirect` (priority
`PHP_INT_MAX - 10`), so redirects keep them while rendered HTML never contains
them. Code that reads them during rendering should use JavaScript instead.

## Update Constants

### MC_UPDATE_PRERELEASE

```php
define( 'MC_UPDATE_PRERELEASE', true );
```

| Property  | Value     |
|-----------|-----------|
| Default   | `false`   |
| Type      | `boolean` |

Opt in to prerelease builds. When defined truthy, the plugin requests
prerelease versions from the update endpoint, so beta and release-candidate
builds surface as available updates. Leave undefined to receive stable
releases only.

To turn update checks off entirely, use the [`millicache_updates`](/docs/millicache/07-developers/02-hooks-filters#millicache_updates)
filter.

## WordPress Cache Constants

Standard WordPress constants that affect MilliCache behavior.

### DONOTCACHEPAGE

```php
define( 'DONOTCACHEPAGE', true );
```

Set dynamically in themes/plugins to skip caching for the current request.
WooCommerce, for example, sets it on the cart, checkout, and my-account pages,
so those are excluded without any MilliCache configuration.

```php
// In your template
if ( some_condition() ) {
    define( 'DONOTCACHEPAGE', true );
}
```

### DOING_CRON

```php
define( 'DOING_CRON', true );
```

Automatically set by WordPress during cron execution. MilliCache skips caching.

### DOING_AJAX

```php
define( 'DOING_AJAX', true );
```

Automatically set by WordPress during AJAX requests. MilliCache skips caching.

### REST_REQUEST

```php
define( 'REST_REQUEST', true );
```

Automatically set by WordPress during REST API requests. MilliCache skips caching.

## Complete Configuration Example

```php
<?php
// wp-config.php

// Enable caching (REQUIRED)
define( 'WP_CACHE', true );

// Storage settings
define( 'MC_STORAGE_HOST', 'redis.example.com' );
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_USERNAME', 'your-username' );
define( 'MC_STORAGE_PASSWORD', 'secure-password' );
define( 'MC_STORAGE_DB', 0 );
define( 'MC_STORAGE_PERSISTENT', true );
define( 'MC_STORAGE_PREFIX', 'mll_prod' );

// Cache settings
define( 'MC_CACHE_TTL', 86400 );        // 1 day
define( 'MC_CACHE_GRACE', 2592000 );    // 30 days
define( 'MC_CACHE_DEBUG', false );
define( 'MC_CACHE_GZIP', true );

// Exclusions
define( 'MC_CACHE_NOCACHE_PATHS', [
    '/my-account/*',
    '/checkout/*',
    '/cart/*',
] );

define( 'MC_CACHE_NOCACHE_COOKIES', [
    'wp-*pass*',
    'comment_author_*',
] );

define( 'MC_CACHE_IGNORE_COOKIES', [
    '_*',
    '__utm*',
] );

define( 'MC_CACHE_IGNORE_REQUEST_KEYS', [
    '_*',
    'utm_*',
    'fbclid',
    'gclid',
] );

/* That's all, stop editing! */
```

## Next Steps

- [Configuration Overview](/docs/millicache/02-configuration/01-overview): Quick configuration guide
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands): Manage settings via CLI
- [Storage Backends](/docs/millicache/08-storage-backends/01-overview): Redis/ValKey setup

---

Canonical: https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction

---
title: 'Cache Flags: Targeted Invalidation'
description: 'Cache flags tag every MilliCache entry with labels like post:123, so WordPress content changes clear only affected pages instead of the whole cache.'
menu_order: 10
---

# Introduction to Cache Flags

Cache flags are one of MilliCache's most powerful features. 
They enable **targeted cache invalidation** — clearing only the cache entries that actually need updating, rather than wiping everything.

## The Challenge of Modern WordPress

Most caching plugins handle basic scenarios well — when a post is updated, they clear that post's cache and related archives. 
But WordPress is evolving rapidly with Gutenberg's modernization phases, creating new challenges.

### The Synced Pattern Problem

Consider **Synced Patterns** (reusable blocks) and **FSE Template Parts**. 
A single pattern might appear on dozens or hundreds of pages across your site:

- A promotional banner in your header template
- A newsletter signup block used across blog posts
- A pricing table embedded in multiple product pages

When you update that synced pattern, **which pages need their cache cleared?**

Traditional caching plugins have no way to know. 
Their only safe option is to **flush the entire site cache** — even if the pattern only appears on 5% of your pages.

### How Flags Solve This

MilliCache tags each cached page with **flags** — labels describing what content appears on that page. 
You can create flags for patterns, template parts, or any content relationship.

```mermaid
flowchart LR
    subgraph "Traditional: Synced Pattern Updated"
        A[Pattern Changed] --> B[Which pages use it?]
        B --> C[Unknown → Clear ALL]
    end
```

```mermaid
flowchart LR
    subgraph "MilliCache with Flags"
        D[Pattern Changed] --> E[Find entries with flag<br/><i>pattern:promo-banner</i>]
        E --> F[Clear only<br/>matching pages]
        F --> G[95% of cache<br/>stays warm]
    end
```

This becomes increasingly important as WordPress moves toward Full Site Editing, where template parts and patterns are shared across many pages.

## Real-World Analogy

Think of cache flags like **tags on photos** in your photo library:

- You tag photos with "vacation", "2024", "beach", "family"
- Later, you can find all "beach" photos instantly
- Deleting one tag doesn't affect others
- Photos can have multiple tags

Similarly, MilliCache pages can have multiple flags, and you can target specific flags for clearing.

## Why Single Pages Need Multiple Flags

A single URL can generate different cache entries based on:

- Query parameters (`?page=2`, `?sort=price`)
- Cookies (language preferences, currency)
- Other request variations

All these entries relate to the same content but have different cache keys. Flags group them logically:

| URL                        | Cache Key  | Flags                        |
|----------------------------|------------|------------------------------|
| `/product/shoe/`           | `abc123`   | `post:45`, `archive:product` |
| `/product/shoe/?color=red` | `def456`   | `post:45`, `archive:product` |
| `/product/shoe/reviews/`   | `ghi789`   | `post:45`, `archive:product` |

When the product is updated, clearing `post:45` removes all three entries — exactly what you need.

## Benefits of Flag-Based Invalidation

### Performance
- Cache stays warm for unaffected pages
- Less database load during updates
- Faster recovery after content changes

### Precision
- Update a post → only that post's pages cleared
- Update a category → only that category's archives cleared
- No collateral damage to unrelated content

### Flexibility
- Create custom flags for your specific needs
- Use wildcards for broad clearing (`post:*`)
- Integrate with your content workflow

## How Flags Flow Through MilliCache

```mermaid
sequenceDiagram
    participant V as Visitor
    participant M as MilliCache
    participant W as WordPress
    participant R as Redis/ValKey

    V->>M: Request /blog/my-post/
    M->>R: Check cache
    R-->>M: MISS
    M->>W: Load WordPress
    W-->>M: HTML + Flags [home, post:123]
    M-->>V: Response
    M->>R: Store with flags

    Note over V,R: Later: Post 123 is updated

    W->>M: Clear post:123
    M->>R: Find entries with flag
    R-->>M: 3 entries found
    M->>R: Delete entries
```

## Flags in MilliCache Pro

With [MilliCache Pro](https://www.millipress.com/millicache-pro/), flags become visible and reach further: the [Cache Entries Browser](https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries/) shows the flags of every cached page right in the settings screen, and the [Edge Cache module](https://www.millipress.com/docs/millicache-pro/02-modules/07-edge-cache/) mirrors flag-based invalidation onto your CDN, so edge purges are as surgical as local ones.

## Next Steps

- [Built-in Flags](/docs/millicache/03-cache-flags/02-built-in-flags) — Flags automatically assigned by MilliCache
- [Custom Flags](/docs/millicache/03-cache-flags/03-custom-flags) — Create your own flags for advanced control
- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) — Methods for clearing cache by flags

---

Canonical: https://www.millipress.com/docs/millicache/03-cache-flags/02-built-in-flags

---
title: 'Built-in Cache Flags'
description: 'Reference of cache flags MilliCache assigns automatically: home, post IDs, post type, taxonomy, author and date archives, feeds, and multisite prefixes.'
menu_order: 20
---

# Built-in Flags

MilliCache automatically assigns flags based on the type of page being cached. These built-in flags cover common WordPress content types.

## Homepage Flags

| Flag   | Applied When             |
|--------|--------------------------|
| `home` | Front page or blog index |

The `home` flag is added to:
- The site's front page (whether static or showing latest posts)
- The blog posts page (if using a static front page)

## Singular Content Flags

| Flag Format | Applied When         | Example    |
|-------------|----------------------|------------|
| `post:{id}` | Single post/page/CPT | `post:123` |

Every singular page (posts, pages, custom post types) receives a flag with its post-ID. 
This enables precise invalidation when that specific content is updated.

## Archive Flags

Archives receive flags based on their type:

### Post-Type Archives

| Flag Format           | Applied When             | Example           |
|-----------------------|--------------------------|-------------------|
| `archive:post`        | Blog/post archive        | `archive:post`    |
| `archive:{post_type}` | Custom post type archive | `archive:product` |

### Taxonomy Archives

| Flag Format                 | Applied When            | Example               |
|-----------------------------|-------------------------|-----------------------|
| `archive:category:{id}`     | Category archive        | `archive:category:5`  |
| `archive:post_tag:{id}`     | Tag archive             | `archive:post_tag:12` |
| `archive:{taxonomy}:{id}`   | Custom taxonomy archive | `archive:genre:8`     |

### Author Archives

| Flag Format            | Applied When   | Example            |
|------------------------|----------------|--------------------|
| `archive:author:{id}`  | Author archive | `archive:author:1` |

### Date Archives

| Flag Format                    | Applied When   | Example              |
|--------------------------------|----------------|----------------------|
| `archive:{year}`               | Year archive   | `archive:2026`       |
| `archive:{year}:{month}`       | Month archive  | `archive:2026:01`    |
| `archive:{year}:{month}:{day}` | Day archive    | `archive:2026:01:15` |

## Feed Flags

| Flag   | Applied When        |
|--------|---------------------|
| `feed` | RSS/Atom feed pages |

## Flag Prefixes in Multisite

In multisite installations, flags are automatically prefixed to ensure cache isolation between sites:

| Environment   | Format                          | Example        |
|---------------|---------------------------------|----------------|
| Single site   | `{flag}`                        | `post:123`     |
| Multisite     | `{site_id}:{flag}`              | `2:post:123`   |
| Multi-network | `{network_id}:{site_id}:{flag}` | `1:2:post:123` |

This ensures Site A's `post:123` doesn't conflict with Site B's `post:123`.

### Working with Prefixes

Use the helper function to handle prefixes correctly:

```php
// Prefix flags with the current site's prefix
$prefix = millicache_get_flag_prefix( ['home', 'post:123'] );
// Returns: ['home', 'post:123'] (single site), ['2:home', '2:post:123'] (multisite), or ['1:2:home', '1:2:post:123'] (multi-network)

// Prefix flags for a specific site
$flags = millicache_prefix_flags( ['home', 'post:123'], $site_id = 2 );
// Returns: ['2:home', '2:post:123'] in multisite
```

## Viewing Assigned Flags

### Debug Headers

Enable debug mode in the Settings UI or via constant to see flags in response headers:

```php
define( 'MC_CACHE_DEBUG', true );
```

Then check the `X-MilliCache-Flags` header:

```
X-MilliCache-Flags: home,post:1,post:2,post:3
```

>[!TIP]
> Use the MilliCache browser extension for easy flag inspection:
> [Get the MilliCache Browser Extension](https://github.com/MilliPress/millicache-browser-ext/releases/latest)

### WP-CLI

```bash
# View stats for entries with a specific flag
wp millicache stats --flag="post:*"

# View all entries with the home flag
wp millicache stats --flag="home"
```

## Automatic Cache Clearing

When content changes, MilliCache automatically identifies and clears related cache entries:

| Event                  | Flags Cleared                         |
|------------------------|---------------------------------------|
| Post published/updated | `post:{id}`, `home`, related archives |
| Post deleted           | `post:{id}`, `home`, related archives |
| Category updated       | `archive:category:{id}`               |
| Site option changed    | All site cache (configurable)         |

### Customizing Related Flags

Use the `millicache_flags_related_to_post` filter to customize which flags are cleared when a post changes:

```php
add_filter( 'millicache_flags_related_to_post', function( $flags, $post ) {
    // Also clear featured products when a product is updated
    if ( $post->post_type === 'product' && get_post_meta( $post->ID, 'featured', true ) ) {
        $flags[] = 'featured';
    }
    return $flags;
}, 10, 2 );
```

## Next Steps

- [Custom Flags](/docs/millicache/03-cache-flags/03-custom-flags) — Create your own flags
- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) — Methods for clearing by flags

---

Canonical: https://www.millipress.com/docs/millicache/03-cache-flags/03-custom-flags

---
title: 'Custom Cache Flags'
description: 'Create custom MilliCache cache flags via the flags filter, PHP helper functions, or rules, with wildcard patterns and tagging strategies for WordPress sites.'
menu_order: 30
---

# Custom Flags

While built-in flags cover common cases, custom flags let you target cache clearing based on your specific content structure and business logic.

## Adding Custom Flags

### Via Filter

The `millicache_flags_for_request` filter runs when a page is being cached:

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Add a flag based on the page template
    if ( is_page_template( 'templates/landing.php' ) ) {
        $flags[] = 'template:landing';
    }

    // Add a flag for pages with a specific Gutenberg block
    if ( is_singular() && has_block( 'my-plugin/hero-banner' ) ) {
        $flags[] = 'block:hero-banner';
    }

    return $flags;
} );
```

### Via PHP Function

Add flags dynamically during template rendering:

```php
// In your theme's template file
if ( is_product() ) {
    millicache_add_flag( 'woo:product' );
    millicache_add_flag( 'woo:product:' . get_the_ID() );
}

// Based on custom logic
if ( get_field( 'show_pricing_table' ) ) {
    millicache_add_flag( 'feature:pricing' );
}
```

### Via Rule Action

Use MilliRules for condition-based flag assignment:

```php
millicache()->rules()->create( 'mysite:seasonal-flag' )
    ->on( 'template_redirect', 25 )
    ->when()
        ->has_term( 'seasonal', 'product_cat' )
    ->then()
        ->add_flag( 'promo:seasonal' )
    ->register();
```

Learn more about the powerful [Rules System](/docs/millicache/04-rules/01-introduction).

## Removing Flags

Sometimes you need to remove a built-in flag:

```php
// Via PHP function
if ( is_front_page() && get_option( 'custom_homepage' ) ) {
    millicache_remove_flag( 'home' );
}
```

```php
// Via rule
millicache()->rules()->create( 'mysite:no-archive-flag' )
    ->on( 'template_redirect', 30 )
    ->when()
        ->is_post_type_archive( 'product' )
    ->then()
        ->remove_flag( 'archive:post' )
    ->register();
```

## Clearing Cache by Flags

### WP-CLI

```bash
# Clear by specific flag
wp millicache clear --flag="home"

# Clear multiple flags
wp millicache clear --flag="post:123,home,archive:post"

# Clear with wildcard
wp millicache clear --flag="post:*"
wp millicache clear --flag="archive:category:*"

# Multisite: Include site prefix
wp millicache clear --flag="2:post:*"
wp millicache clear --flag="*:home"
```

### PHP Functions

```php
// Clear by flags
millicache_clear_cache_by_flags( ['home', 'archive:post'] );

// Clear with wildcard
millicache_clear_cache_by_flags( 'product:*' );

// Expire instead of delete (serves stale while regenerating)
millicache_clear_cache_by_flags( 'home', true );

// Mixed targets (flags, post IDs, URLs)
millicache_clear_cache( [
    'home',                             // Flag
    'post:123',                         // Flag
    123,                                // Post ID
    'https://example.com/special-page/' // URL
] );
```

## Wildcard Patterns

MilliCache supports wildcards for flexible cache clearing:

### The `*` Wildcard

Matches any number of characters:

| Pattern     | Matches                              |
|-------------|--------------------------------------|
| `post:*`    | `post:1`, `post:123`, `post:999`     |
| `archive:*` | `archive:post`, `archive:category:5` |
| `*:home`    | `1:home`, `2:home` (multisite)       |
| `feature:*` | All feature flags                    |

### The `?` Wildcard

Matches exactly one character:

| Pattern  | Matches                        |
|----------|--------------------------------|
| `post:?` | `post:1` through `post:9` only |
| `?:home` | Sites with single-digit IDs    |

## Flag Design Patterns

### Hierarchical Flags

Use a consistent naming structure for granular control:

```php
// E-commerce example
$flags[] = 'product';                    // All products
$flags[] = 'product:category:5';         // Products in category 5
$flags[] = 'product:5:sku:ABC123';       // Specific product variant

// Clear all products
millicache_clear_cache_by_flags( 'product:*' );

// Clear category only
millicache_clear_cache_by_flags( 'product:category:5' );
```

### Feature Flags

Tag pages by feature for cross-cutting concerns:

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Tag pages showing dynamic pricing
    if ( has_dynamic_pricing() ) {
        $flags[] = 'feature:dynamic-pricing';
    }

    // Tag pages with real-time inventory
    if ( shows_inventory() ) {
        $flags[] = 'feature:inventory';
    }

    return $flags;
} );

// When pricing engine updates, clear all affected pages
millicache_clear_cache_by_flags( 'feature:dynamic-pricing' );
```

### WooCommerce Integration

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    if ( function_exists( 'is_product' ) && is_product() ) {
        $product = wc_get_product();

        // Tag by product type
        $flags[] = 'woo:' . $product->get_type();

        // Tag if on sale
        if ( $product->is_on_sale() ) {
            $flags[] = 'woo:sale';
        }

        // Tag by category
        foreach ( $product->get_category_ids() as $cat_id ) {
            $flags[] = 'woo:cat:' . $cat_id;
        }
    }
    return $flags;
} );

// Clear all sale items when sale ends
millicache_clear_cache_by_flags( 'woo:sale' );
```

### Time-Based Flags

For scheduled cache clearing:

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Add a date-based flag
    $flags[] = 'date:' . date( 'Y-m-d' );

    // Add a week flag for weekly content
    $flags[] = 'week:' . date( 'Y-W' );

    return $flags;
} );

// Clear yesterday's cached content via cron
$yesterday = date( 'Y-m-d', strtotime( '-1 day' ) );
millicache_clear_cache_by_flags( "date:{$yesterday}" );
```

## Best Practices

### Use Descriptive Names

```php
// Good
$flags[] = 'product:featured';
$flags[] = 'archive:sale';

// Avoid
$flags[] = 'x';
$flags[] = '123';
```

### Limit Flag Count

Each flag adds storage overhead. Be selective:

```php
// Good: Few targeted flags
$flags[] = 'post:' . $post->ID;
$flags[] = 'archive:' . $post->post_type;

// Avoid: Excessive flags
foreach ( get_all_meta( $post->ID ) as $key => $value ) {
    $flags[] = "meta:{$key}:{$value}";  // Could be hundreds!
}
```

### Use Wildcards for Clearing

Instead of tracking exact flags, use patterns:

```php
// Good: Use wildcard
millicache_clear_cache_by_flags( 'product:*' );

// Avoid: Listing every flag
millicache_clear_cache_by_flags( ['product:1', 'product:2', 'product:3', ...] );
```

### Document Your Flag Taxonomy

Plan and document your flag structure:

```
Your Site's Flag Structure:
├── home              - Homepage
├── post:{id}         - Individual posts
├── archive:
│   ├── post          - Post archive
│   ├── {type}        - CPT archives
│   └── {tax}:{id}    - Taxonomy archives
├── product:
│   ├── featured      - Featured products
│   └── sale          - On-sale products
├── block:
│   ├── hero          - Pages with hero block
│   └── testimonials  - Pages with testimonials
└── feature:
    ├── pricing       - Dynamic pricing pages
    └── inventory     - Real-time inventory
```

## Next Steps

- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) — All clearing methods
- [Rules Overview](/docs/millicache/04-rules/01-introduction) — Condition-based caching
- [Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters) — All flag-related hooks

---

Canonical: https://www.millipress.com/docs/millicache/04-rules/01-introduction

---
title: 'Rules Engine: Conditional Caching'
description: 'MilliCache rules decide when and how WordPress pages are cached: a fluent PHP API sets conditions, TTL, and bypasses across bootstrap and WordPress phases.'
menu_order: 10
---

# Introduction to Rules

Rules are MilliCache's second core feature. While [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) handle **what** to clear, rules control **when** to cache.

Together, flags and rules make MilliCache incredibly flexible:

- **Rules** decide: Should this request be cached? For how long?
- **Flags** decide: When content changes, which cache entries are affected?

## Why Rules?

Every caching decision in MilliCache is a **rule**. This means you can:

- Override any default behavior
- Add your own conditions
- Customize for your specific use case

Think of it like **smart home automation for your cache**:

| Smart Home                                                  | MilliCache                                                                   |
|-------------------------------------------------------------|------------------------------------------------------------------------------|
| "Turn off heating **when** I leave home"                    | "Bypass cache **when** user is logged in"                                    |
| "Turn on lights **when** it's dark **and** motion detected" | "Bypass cache **when** it's a POST request **and** URL contains `/checkout`" |
| "Set temperature to 18° **when** it's after 10pm"           | "Set TTL to 5 minutes **when** page is a product archive"                    |

## How Rules Work

Every rule has three parts:

```mermaid
flowchart LR
    C[Condition] --> |matches| A[Action]
    C --> |doesn't match| S[Skip rule]
    A --> R[Continue to next rule]
```

| Component     | Description                        | Example                                     |
|---------------|------------------------------------|---------------------------------------------|
| **Condition** | When should this rule apply?       | "If user is logged in"                      |
| **Action**    | What should happen?                | "Do not cache"                              |
| **Priority**  | When to evaluate (lower = earlier) | `0`/`1` (built-in), `10+` (custom)          |

## The Fluent API

Rules use a readable, chainable syntax powered by [MilliRules](https://www.millipress.com/docs/millirules/):

```php
millicache()->rules()->create( 'mysite:example-rule' )  // Create rule with an ID
    ->order( 10 )                                      // Set priority
    ->when()                                           // Start conditions
        ->request_url( '/news/*' )                     // Match URL pattern
    ->then()                                           // Start actions
        ->set_ttl( 1800 )                              // Set 30-minute TTL
    ->register();                                      // Register the rule
```

The phase is picked for you: MilliCache reads the conditions and actions you used,
and a rule that could only have run before WordPress is moved to the WordPress phase,
since that earlier phase is over by the time your code runs.

One thing is on you: **register after MilliCache has loaded.** In a plugin or a theme
that is already the case. Only a file that runs earlier, such as a must-use plugin,
needs the registration wrapped in `add_action( 'plugins_loaded', … )`; see
[where to put the code](/docs/millicache/04-rules/03-examples#where-to-put-the-code).

Prefer building rules without code? [MilliCache Pro](https://www.millipress.com/millicache-pro/) includes a [visual Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/): create, edit, and reorder caching rules directly in the settings screen, in addition to the PHP API.

## Two Execution Phases

MilliCache rules execute in two distinct phases:

```mermaid
flowchart TB
    R[Request] --> A[advanced-cache.php]
    A --> B[Bootstrap Rules<br/><i>PHP-Only Phase</i>]
    B --> |Bypass| WP1[WordPress loads]
    B --> |Continue| C{Cache Hit?}
    C --> |Yes| S[Serve cached HTML]
    C --> |No| WP2[WordPress loads]
    WP2 --> D[WordPress Rules<br/><i>Full Context Phase</i>]
    D --> |Bypass| E[Don't cache response]
    D --> |Continue| F[Cache response]
```

### Bootstrap Phase (`php`)

Runs **before WordPress loads**:
- Instant decisions with minimal overhead
- No database queries
- Can only check: URL, cookies, headers, constants

This phase runs inside `advanced-cache.php`, before any plugin or theme exists, so
**it cannot be reached from code**. Bootstrap rules come from the settings, which the
drop-in reads from the database. Writing them takes
[MilliCache Pro](https://www.millipress.com/millicache-pro/): either its
[Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/)
or `wp millicache rules import`.

### WordPress Phase (`wp`)

Runs **after WordPress loads**:
- Full WordPress context available
- Can check: user roles, post types, templates, etc.
- More powerful but slightly later in the request

## Built-in Rules

MilliCache includes sensible defaults that you can override:

- Never cache POST requests
- Never cache logged-in users
- Never cache admin/CLI/REST/AJAX requests
- Respect `DONOTCACHEPAGE` constant
- Honor excluded cookies and paths from settings

See [Built-in Rules](/docs/millicache/04-rules/02-built-in-rules) for the complete list.

## What You Can Do

With rules, for example, you can:

**Control caching decisions:**
```php
->then()->do_cache( false, 'Reason' )   // Bypass cache
->then()->do_cache( true )              // Force cache (override previous)
```

**Adjust cache timing:**
```php
->then()->set_ttl( 3600 )    // Cache for 1 hour
->then()->set_grace( 86400 ) // Allow stale for 1 day
```

**Manage flags:**
```php
->then()->add_flag( 'custom:flag' )
->then()->remove_flag( 'home' )
```

**Clear cache:**
```php
->then()->clear_cache( ['post:123', 'home'] )
->then()->clear_site_cache()
```

## Learn More

For deep documentation on the rules engine, conditions, actions, and patterns:

**[MilliRules Documentation](https://www.millipress.com/docs/millirules/)**

- [Core Concepts](https://www.millipress.com/docs/millirules/02-core-concepts/)
- [Conditions Reference](https://www.millipress.com/docs/millirules/05-reference/01-conditions/)
- [Actions Reference](https://www.millipress.com/docs/millirules/05-reference/02-actions/)

To manage rules visually instead, see the [Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/) in MilliCache Pro.

## Next Steps

- [Built-in Rules](/docs/millicache/04-rules/02-built-in-rules) — All default rules and when they run
- [Examples](/docs/millicache/04-rules/03-examples) — Practical MilliCache rule examples
- [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) — The partner feature to rules

---

Canonical: https://www.millipress.com/docs/millicache/04-rules/02-built-in-rules

---
title: 'Built-in Caching Rules'
description: 'Reference of MilliCache default rules: locked core bypasses, configured exclusions, logged-in and REST handling, plus the priority system for overrides.'
menu_order: 20
---

# Built-in Rules

MilliCache includes default rules that handle common caching scenarios. Built-in rules use priorities `0` and `1`, so your custom rules (`->order({10+})`) run after them and can override unlocked decisions.

## Bootstrap Phase Rules

Execute **before WordPress loads** via `advanced-cache.php`.

### Locked Core Rules (Order 0)

These bypass caching for fundamentally non-cacheable scenarios. Locked — cannot be overridden:

| Rule ID                           | Condition                    | Result |
|-----------------------------------|------------------------------|--------|
| `millicache:const:wp-cache`       | `WP_CACHE !== true`          | Bypass |
| `millicache:request:check-method` | Method not GET/HEAD          | Bypass |
| `millicache:request:cli`          | Running in WP-CLI            | Bypass |
| `millicache:request:xmlrpc`       | `XMLRPC_REQUEST` is true     | Bypass |
| `millicache:config:ttl-not-set`   | TTL is 0 or negative         | Bypass |

### Configuration-Based Rules (Order 0)

Apply exclusions from your configuration. Locked — user-configured exclusions are authoritative and cannot be overridden by custom rules:

| Rule ID                             | Condition                 | Result |
|-------------------------------------|---------------------------|--------|
| `millicache:config:nocache-cookies` | Excluded cookie present   | Bypass |
| `millicache:config:nocache-paths`   | URL matches excluded path | Bypass |

### Overridable Request Rules (Order 1)

Bypass caching for request types that most sites don't want cached — but sometimes do. Unlocked — can be overridden by custom rules at order `2+`:

| Rule ID                   | Condition                    | Result |
|---------------------------|------------------------------|--------|
| `millicache:request:file` | URL ends with file extension | Bypass |
| `millicache:request:rest` | URL contains `wp-json`       | Bypass |

**Default excluded cookies:**
- `wp-*pass*` — WordPress password-protected content
- `comment_author_*` — Comment author cookies

## WordPress Phase Rules

Execute **after WordPress loads** on the `template_redirect` hook.

### Locked WP Rules (Order 0, Hook Priority 20)

Cannot be overridden:

| Rule ID                          | Condition            | Result |
|----------------------------------|----------------------|--------|
| `millicache:wp:const:doing-cron` | `DOING_CRON` defined | Bypass |

### Overridable WP Rules (Order 1, Hook Priority 20)

Unlocked — can be overridden by custom rules at order `2+`:

| Rule ID                              | Condition                | Result |
|--------------------------------------|--------------------------|--------|
| `millicache:wp:search`               | Search results page      | Bypass |
| `millicache:wp:logged-in`            | User is logged in        | Bypass |
| `millicache:wp:response:code`        | HTTP status ≠ 200        | Bypass |
| `millicache:wp:const:donotcachepage` | `DONOTCACHEPAGE` defined | Bypass |
| `millicache:wp:const:doing-ajax`     | `DOING_AJAX` defined     | Bypass |

## Rule Priority System

Rules execute in priority order (lower numbers first):

| Priority  | Who Uses It            | Purpose                                             |
|-----------|------------------------|-----------------------------------------------------|
| 0         | Built-in locked rules  | Critical bypass + user-configured exclusions        |
| 1         | Built-in unlocked rules| Common-sense skips (file, REST) — override-friendly |
| 10+       | Your custom rules      | Override or extend defaults                         |

### All Rules Run

MilliRules evaluates **all rules** in order — there's no short-circuit behavior. Later rules can override earlier rules' decisions.

```php
// Built-in rule (order 0) sets do_cache(false) for POST requests — but
// millicache:request:check-method is locked, so it CANNOT be overridden.
// For an unlocked built-in (e.g. millicache:config:nocache-paths), your
// rule (order 10) runs AFTER and overrides it:
millicache()->rules()->create( 'mysite:cache-search-path' )
    ->on( 'template_redirect' )
    ->order( 10 )
    ->when()
        ->request_url( '/search/*' )
    ->then()
        ->do_cache( true )  // Overrides the earlier bypass
    ->register();
```

This means:
- All your rules **always run** after built-in rules
- Your rules can override built-in decisions (the last `do_cache()` wins)
- To completely replace a built-in rule, use the same rule ID (see below)

## Available Actions

### Bootstrap Phase Actions

Available in `php` rules (before WordPress):

| Action                          | Description                 | Example                                |
|---------------------------------|-----------------------------|----------------------------------------|
| `do_cache( $bool, $reason )`    | Enable/disable caching      | `->do_cache( false, 'Preview' )`       |
| `set_ttl( $seconds )`           | Override TTL                | `->set_ttl( 3600 )`                    |
| `set_grace( $seconds )`         | Override grace period       | `->set_grace( 86400 )`                 |
| `set_bucket( $name, $token )`   | Add a bucket to the hash    | `->set_bucket( 'device', 'mobile' )`   |

### WordPress Phase Actions

Available in `wp` rules (after WordPress loads):

| Action                       | Description            | Example                        |
|------------------------------|------------------------|--------------------------------|
| `do_cache( $bool, $reason )` | Enable/disable caching | `->do_cache( false, 'Admin' )` |
| `set_ttl( $seconds )`        | Override TTL           | `->set_ttl( 300 )`             |
| `set_grace( $seconds )`      | Override grace period  | `->set_grace( 3600 )`          |
| `add_flag( $flag )`          | Add cache flag         | `->add_flag( 'custom:flag' )`  |
| `remove_flag( $flag )`       | Remove cache flag      | `->remove_flag( 'home' )`      |
| `clear_cache( $targets )`    | Clear cache entries    | `->clear_cache( ['post:*'] )`  |
| `clear_site_cache()`         | Clear entire site      | `->clear_site_cache()`         |

## Available Conditions

### Core Conditions

Available in both `php` (bootstrap) and `wp` (WordPress) phases:

| Condition                    | Description          | Example                               |
|------------------------------|----------------------|---------------------------------------|
| `constant( $name, $value )`  | Check constant value | `->constant( 'WP_DEBUG', true )`      |
| `custom( $id, $callback )`   | Custom callback      | `->custom( 'my-check', fn() => ... )` |
| `request_method( $methods )` | HTTP method          | `->request_method( 'POST' )`          |
| `request_url( $pattern )`    | URL pattern match    | `->request_url( '/shop/*' )`          |
| `cookie( $name, $value )`    | Cookie check         | `->cookie( 'currency', 'EUR' )`       |

### WordPress `is_*` Conditions

All `is_*` methods are available in `wp` phase rules (require WordPress context):

| Condition                        | Description          |
|----------------------------------|----------------------|
| `is_singular( $post_types )`     | Single post/page/CPT |
| `is_front_page()`                | Front page           |
| `is_home()`                      | Blog homepage        |
| `is_post_type_archive( $types )` | Post type archive    |
| `is_category( $category )`       | Category archive     |
| `is_tag( $tag )`                 | Tag archive          |
| `is_tax( $taxonomy, $term )`     | Taxonomy archive     |
| `is_author( $author )`           | Author archive       |
| `is_date()`                      | Date archive         |
| `is_feed( $feeds )`              | Feed page            |
| `is_user_logged_in()`            | User logged in       |

### WordPress `has_*` Conditions

All `has_*` methods are available in `wp` phase rules (require WordPress context):

| Condition                      | Description             |
|--------------------------------|-------------------------|
| `has_term( $term, $taxonomy )` | Post has specific term  |
| `has_block( $block_name )`     | Post contains block     |
| `has_tag( $tag )`              | Post has tag            |
| `has_category( $category )`    | Post has category       |
| `has_post_thumbnail()`         | Post has featured image |
| `has_excerpt()`                | Post has excerpt        |
| `has_post_format( $format )`   | Post has format         |
| `has_nav_menu( $location )`    | Menu location has menu  |
| `has_custom_logo()`            | Site has custom logo    |

## Debugging Rules

### Enable Debug Mode

```php
define( 'MC_CACHE_DEBUG', true );
```

### Check Response Headers

The `X-MilliCache-Status` header shows the caching result:

| Value    | Meaning                          |
|----------|----------------------------------|
| `hit`    | Served from cache                |
| `miss`   | Not cached, will be stored       |
| `bypass` | A rule prevented caching         |
| `grace`  | Serving stale while regenerating |

### List Registered Rules

```php
// Log every registered rule, built-in and custom alike.
foreach ( millicache()->rules()->get_packages_rules() as $rule ) {
    $meta = $rule['_metadata'] ?? array();

    error_log( sprintf(
        'Rule: %s (order: %d, phase: %s)',
        $rule['id'],
        $meta['order'] ?? 0,
        $meta['type'] ?? ''
    ) );
}
```

## Override / Unregister Built-in Rules

To change the behavior of a built-in rule, you can unregister them or create your own with the **same ID**:

```php
add_action( 'template_redirect', function () {
    // Unregister a built-in rule to completely remove its behavior
    millicache()->rules()->unregister( 'millicache:wp:const:doing-ajax' );
} );

// Override the logged-in user bypass to allow caching for subscribers
millicache()->rules()->create( 'millicache:wp:logged-in' )  // Same ID as built-in
    ->on( 'template_redirect', 20 )         // Same hook & priority as built-in
    ->order( 10 )                           // Higher order to run after built-in
    ->when()
        ->is_user_logged_in()
        ->custom( 'is-editor-or-higher', function() {
            return current_user_can( 'edit_posts' );
        })
    ->then()
        ->do_cache( false, 'Editor role or above' )
    ->register();
```

Now subscribers can see cached pages, but editors and admins still bypass.

## Next Steps

- [Examples](/docs/millicache/04-rules/03-examples) — Practical rule examples
- [MilliRules Conditions](https://www.millipress.com/docs/millirules/05-reference/01-conditions/) — Full conditions reference
- [MilliRules Actions](https://www.millipress.com/docs/millirules/05-reference/02-actions/) — Full actions reference

---

Canonical: https://www.millipress.com/docs/millicache/04-rules/03-examples

---
title: 'Caching Rule Examples & Recipes'
description: 'Copy-ready MilliCache rule recipes: WooCommerce cart bypass, membership sites, per-content-type TTL, and more, built with the MilliRules fluent API.'
menu_order: 30
---

# Rule Examples

Practical MilliCache rule examples for common scenarios. All examples use the [MilliRules fluent API](https://www.millipress.com/docs/millirules/).

## Setup

Every example below starts from `$rules`, which you get from MilliCache:

```php
$rules = millicache()->rules();
```

### Where to put the code

Anywhere in a plugin or a theme. MilliCache is loaded by then, so the rule registers
right away and no wrapper is needed.

The exception is a file that runs *before* MilliCache, a must-use plugin being the
usual case. On a live request that is still fine, because the page cache loads at the
very top of WordPress. Under WP-CLI it is not: the CLI skips the page cache, so
`millicache()` does not exist yet and the file ends in a fatal error. Register on a
hook there, which also keeps the rules visible to anything reading them outside a
cached request: the abilities API, and `wp millicache rules list` in
[MilliCache Pro](https://www.millipress.com/millicache-pro/).

```php
add_action( 'plugins_loaded', function () {
    millicache()->rules()->create( 'mysite:example' )
        // …
        ->register();
} );
```

### Which phase a rule runs in

You do not have to say. MilliCache reads the conditions and actions you used and
picks the phase that can run them, and a rule that could only have run in the
bootstrap phase is moved to the WordPress phase, because by the time your code runs
that phase is already over. There it still sets the lifetime, adds flags, and decides
whether the generated page is stored.

Use `->on()` only to move a rule to a specific WordPress action.

> **The bootstrap phase cannot be reached from code.** It runs inside
> `advanced-cache.php`, before any plugin, theme or must-use plugin exists, and it
> runs once. Bootstrap rules have to live in the settings, which the drop-in reads
> from the database. Writing them takes
> [MilliCache Pro](https://www.millipress.com/millicache-pro/): either its
> [Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/)
> or `wp millicache rules import`.
>
> The difference is speed, not capability: a bootstrap rule can turn a request away
> before WordPress loads, while a WordPress-phase rule decides once the page has been
> built.

## Example 1: Different TTL by Content Type

Cache news for 15 minutes, documentation for 1 week:

```php
// Short TTL for news
$rules->create( 'mysite:news-ttl' )
    ->order( 10 )
    ->when()
        ->request_url( '/news/*' )
    ->then()
        ->set_ttl( 900 )  // 15 minutes
    ->register();

// Long TTL for documentation
$rules->create( 'mysite:docs-ttl' )
    ->order( 10 )
    ->when()
        ->request_url( '/docs/*' )
    ->then()
        ->set_ttl( 604800 )  // 1 week
    ->register();
```

## Example 2: WooCommerce Cart/Checkout Bypass

Never cache cart, checkout, or account pages:

```php
// Runs early in the WordPress phase, before the page is stored
$rules->create( 'mysite:woo-no-cache' )
    ->order( 1 )
    ->when_any()
        ->request_url( '*/cart/*' )
        ->request_url( '*/checkout/*' )
        ->request_url( '*/my-account/*' )
        ->cookie( 'woocommerce_*' )
    ->then()
        ->do_cache( false, 'WooCommerce dynamic page' )
    ->register();
```

## Example 3: Membership Site Caching

Cache for guests, bypass for active members:

```php
// WordPress phase - needs user context
$rules->create( 'mysite:members-no-cache' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_user_logged_in()
        ->custom( 'is-active-member', function() {
            // Check your membership plugin
            return function_exists( 'hasMembershipLevel' )
                && hasMembershipLevel();
        } )
    ->then()
        ->do_cache( false, 'Active member' )
    ->register();
```

## Example 4: A/B Testing Support

Different cache entries for A/B test variants:

```php
// Add a test variant as a flag
$rules->create( 'mysite:ab-test-flag' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->cookie( 'ab_variant' )
    ->then()
        ->add_flag( 'ab:' . ( $_COOKIE['ab_variant'] ?? 'control' ) )
    ->register();
```

## Example 5: Preview and Draft Bypass

Never cache previews or drafts:

```php
$rules->create( 'mysite:no-preview' )
    ->order( 1 )
    ->when_any()
        ->request_param( 'preview', 'true' )
        ->request_param( 'draft', '1' )
        ->request_param( 'p' )  // Post preview by ID
    ->then()
        ->do_cache( false, 'Preview/draft mode' )
    ->register();
```

## Example 6: Block-Based Rules

Use `has_block()` to target pages containing specific Gutenberg blocks:

```php
// Flag pages with a pricing table block
$rules->create( 'mysite:pricing-flag' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_singular()
        ->has_block( 'acme/pricing-table' )
    ->then()
        ->add_flag( 'block:pricing' )
    ->register();

// Short TTL for pages with live data blocks
$rules->create( 'mysite:live-data-ttl' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_singular()
        ->has_block( 'acme/live-stock-ticker' )
    ->then()
        ->set_ttl( 60 )  // 1 minute for live data
        ->add_flag( 'live-data' )
    ->register();
```

## Example 7: Term-Based Rules

Use `has_term()` for taxonomy-based caching decisions:

```php
// Flag seasonal products
$rules->create( 'mysite:seasonal-flag' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_singular( 'product' )
        ->has_term( 'seasonal', 'product_cat' )
    ->then()
        ->add_flag( 'promo:seasonal' )
    ->register();

// Short TTL for featured content
$rules->create( 'mysite:featured-ttl' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_singular( 'post' )
        ->has_term( 'featured', 'post_tag' )
    ->then()
        ->set_ttl( 1800 )  // 30 minutes
        ->add_flag( 'featured' )
    ->register();
```

## Example 8: Conditional TTL by Post Meta

Short TTL for "breaking news" posts:

```php
$rules->create( 'mysite:breaking-news-ttl' )
    ->on( 'template_redirect', 25 )
    ->order( 10 )
    ->when()
        ->is_singular( 'post' )
        ->custom( 'is-breaking', function() {
            return get_post_meta( get_the_ID(), 'breaking_news', true );
        } )
    ->then()
        ->set_ttl( 300 )  // 5 minutes for breaking news
        ->add_flag( 'breaking' )
    ->register();
```

## Example 9: Geolocation-Based Caching

Tag cache entries by country for geo-targeted content:

```php
$rules->create( 'mysite:geo-flag' )
    ->order( 10 )
    ->when()
        ->request_header( 'CF-IPCountry' )  // Cloudflare header
    ->then()
        ->custom( 'add-geo-flag', function() {
            $country = $_SERVER['HTTP_CF_IPCOUNTRY'] ?? 'XX';
            millicache_add_flag( 'geo:' . strtolower( $country ) );
        } )
    ->register();
```

## Example 10: API Rate Limiting Support

Short TTL for API responses:

```php
$rules->create( 'mysite:api-ttl' )
    ->order( 10 )
    ->when()
        ->request_url( '/api/*' )
        ->request_method( 'GET' )
    ->then()
        ->set_ttl( 60 )      // 1 minute
        ->set_grace( 300 )   // 5 minute grace
    ->register();
```

## Example 11: Mobile vs Desktop Caching

Separate cache entries for mobile and desktop:

```php
$rules->create( 'mysite:mobile-flag' )
    ->order( 10 )
    ->when()
        ->custom( 'is-mobile', function() {
            $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
            return preg_match( '/Mobile|Android|iPhone/i', $ua );
        } )
    ->then()
        ->custom( 'add-mobile-flag', function() {
            millicache_add_flag( 'device:mobile' );
        } )
    ->register();

$rules->create( 'mysite:desktop-flag' )
    ->order( 10 )
    ->when()
        ->custom( 'is-desktop', function() {
            $ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
            return ! preg_match( '/Mobile|Android|iPhone/i', $ua );
        } )
    ->then()
        ->custom( 'add-desktop-flag', function() {
            millicache_add_flag( 'device:desktop' );
        } )
    ->register();
```

## Example 12: Clear Cache on External Event

Clear product cache when inventory system updates:

```php
$rules->create( 'mysite:inventory-clear' )
    ->on( 'my_inventory_updated', 10 )  // Your custom hook
    ->when()
        ->custom( 'always', fn() => true )
    ->then()
        ->clear_cache( [ 'product:*', 'woo:sale' ] )
    ->register();
```

## Compound Conditions

Combine conditions with AND, OR, and NOT logic:

```php
// AND (default) - all conditions must match
$rules->create( 'mysite:all-match' )
    ->when()
        ->request_method( 'GET' )
        ->request_url( '/shop/*' )
        ->cookie( 'currency', 'EUR' )
    ->then()
        // Only runs if ALL conditions match
        ->add_flag( 'shop:eur' )
    ->register();

// OR - any condition can match
$rules->create( 'mysite:any-match' )
    ->when_any()
        ->request_url( '*/cart/*' )
        ->request_url( '*/checkout/*' )
        ->request_url( '*/account/*' )
    ->then()
        // Runs if ANY condition matches
        ->do_cache( false, 'Dynamic page' )
    ->register();

// NOT - none of the conditions should match
$rules->create( 'mysite:none-match' )
    ->when_none()
        ->request_method( 'GET' )
        ->request_method( 'HEAD' )
    ->then()
        // Runs if NEITHER GET nor HEAD
        ->do_cache( false, 'Non-cacheable method' )
    ->register();
```

## Tips

### Use Descriptive Rule IDs

```php
// Good - namespace:purpose
$rules->create( 'mysite:woo-cart-bypass' )

// Avoid - generic
$rules->create( 'rule1' )
```

### Choose the Right Phase

| Use Bootstrap (`php`) when... | Use WordPress (`wp`) when... |
|-------------------------------|------------------------------|
| Checking URL patterns | Checking user roles |
| Checking cookies/headers | Checking post meta |
| Setting TTL by path | Checking template |
| Early bypass decisions | Adding content-based flags |

### Keep Bootstrap Rules Simple

Bootstrap rules run before WordPress, so keep them fast:

```php
// Good - simple string match
->when()->request_url( '/api/*' )

// Avoid in bootstrap - complex logic
->when()->custom( 'complex', function() {
    // Loading files, database, etc. defeats the purpose
} )
```

## Learn More

- [MilliRules Documentation](https://www.millipress.com/docs/millirules/) — Complete rules engine reference
- [Conditions Reference](https://www.millipress.com/docs/millirules/05-reference/01-conditions/)
- [Actions Reference](https://www.millipress.com/docs/millirules/05-reference/02-actions/)
- [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) — Partner feature to rules
- [Visual Rules Builder](https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder/) — Build rules like these without code, in [MilliCache Pro](https://www.millipress.com/millicache-pro/)

---

Canonical: https://www.millipress.com/docs/millicache/05-usage/10-how-caching-works

---
title: 'How Full-Page Caching Works'
description: 'See how MilliCache serves WordPress pages from memory: request interception via advanced-cache.php, cache keys, hits and misses, and grace period serving.'
menu_order: 10
---

# How Caching Works

This guide explains the internals of MilliCache's caching mechanism, from request interception to cache serving.

## The Caching Lifecycle

### 1. Request Interception

When a request arrives, WordPress loads the `advanced-cache.php` drop-in before most of WordPress initializes. MilliCache's Engine starts here:

```
Request → wp-config.php → advanced-cache.php → MilliCache Engine
```

This early interception enables:
- Serving cached content without loading WordPress
- Evaluating rules before plugins load
- Minimal resource usage for cache hits

### 2. Bootstrap Rules Evaluation

Before WordPress loads, MilliCache evaluates **Bootstrap Rules** to determine if caching should proceed:

| Rule             | Condition                 | Result       |
|------------------|---------------------------|--------------|
| WP_CACHE check   | `WP_CACHE !== true`       | Skip caching |
| Request method   | Not GET or HEAD           | Skip caching |
| CLI check        | Running via WP-CLI        | Skip caching |
| REST API         | `REST_REQUEST === true`   | Skip caching |
| XMLRPC           | `XMLRPC_REQUEST === true` | Skip caching |
| File request     | URL matches file pattern  | Skip caching |
| No-cache cookies | Excluded cookie present   | Skip caching |
| No-cache paths   | URL matches excluded path | Skip caching |
| TTL check        | TTL ≤ 0                   | Skip caching |

If any rule triggers cache bypass, MilliCache lets WordPress handle the request normally.

> [!TIP]
> You can customize or add Bootstrap Rules via the [Rules](/docs/millicache/04-rules/01-introduction) system.

### 3. Cache Lookup

If caching proceeds, MilliCache generates a **cache key** from:

- Request URL (path and query string)
- Cookies (excluding ignored ones)
- Custom unique variables (`MC_CACHE_UNIQUE`)
- Resolved request **buckets**: Short tokens for per-request signals (Authorization is built-in; others can be added via rules)

The key is hashed and used to look up cached content in Redis:

```
Cache Key = hash( URL + filtered_cookies + unique_vars + buckets )
```

Buckets handle dimensions that vary *per request*; `unique_vars` provides static deployment-level isolation. See [`MC_CACHE_BUCKETS`](/docs/millicache/02-configuration/02-reference#mc_cache_buckets) and [Bucket Extension](/docs/millicache/07-developers/02-hooks-filters#bucket-extension).

### 4. Cache Hit

If cached content exists and is fresh:

1. Decompress content (if gzip enabled)
2. Send stored HTTP headers
3. Output HTML to browser
4. **Exit immediately** (WordPress never loads)

Response time: typically **5-15ms**.

### 5. Cache Miss

If no cache exists or content is expired:

1. Start output buffering immediately, before any plugin loads
2. Let WordPress load normally
3. Register WordPress Rules on `plugins_loaded`
4. Evaluate WordPress Rules on `template_redirect` to either cache or bypass
5. Capture the complete response
6. Store in Redis with flags (only when the rules allowed caching)
7. Send response to browser

Because the buffer opens before any plugin, it is always the *outermost* buffer: plugins that transform the page in their own output buffer (translation plugins, HTML optimizers) run inside it, and the cache stores their final HTML. The rules decide whether a response is *stored*, not whether it is captured — a bypassed response simply passes through unstored.

The first visitor to an uncached page pays this full render cost. [MilliCache Pro](https://www.millipress.com/millicache-pro/)'s [Cache Preloading](https://www.millipress.com/docs/millicache-pro/02-modules/05-cache-preloading/) avoids that by requesting pages in the background after publishing and after cache clears, so visitors always hit a warm cache.

### 6. Grace Period Serving

If cached content is expired but within the grace period:

1. Serve stale content immediately
2. Mark cache for regeneration
3. Next request triggers actual regeneration
4. Fresh content stored for future requests

This ensures visitors never wait for page generation.

## Cache Storage Structure

Each cache entry is split across two Redis keyspaces: the **request entry** holds per-request metadata; the **output entry** holds the response body and is content-addressable, so identical bodies across variants share storage.

| Keyspace | Key shape                       | Holds                                                                  |
|----------|---------------------------------|------------------------------------------------------------------------|
| Request  | `<prefix>:c:<request_hash>`     | Headers, status, flags, variant meta, `output_ref` (sha1 pointer)      |
| Output   | `<prefix>:o:<output_hash>`      | Response body bytes (compressed if gzip is enabled)                    |
| Refs     | `<prefix>:o:<output_hash>:refs` | Redis SET of request keys referencing this body                        |

The reference SET tracks who's pointing at each body so it can be garbage-collected when the last referrer is removed. Variants that genuinely differ get their own body; identical bodies across variants share one.

### Per-entry fields

| Component    | Description                                            |
|--------------|--------------------------------------------------------|
| `output_ref` | SHA-1 pointer to the body in the output keyspace       |
| `headers`    | HTTP response headers                                  |
| `status`     | HTTP status code                                       |
| `flags`      | Tags for invalidation                                  |
| `variant`    | Differentiating dimensions (cookies, buckets, method)  |
| `updated`    | Timestamp when cached                                  |
| `gzip`       | Whether the body bytes are compressed                  |

You can inspect all of this without touching `redis-cli`: the [Cache Entries Browser](https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries/) in MilliCache Pro lists every cached page with its flags, variant, size, status, and expiry.

### Flags (Tags)

Every cached page is tagged with flags for targeted invalidation:

```
Homepage:     [home, archive:post]
Single post:  [post:123]
Archives:     [archive:category:5, archive:post]
Author:       [archive:author:1]
```

When a post is updated, MilliCache clears all entries matching its flags.

## Cache Flow Diagram

```
┌──────────────────────────────────────────────────────────┐
│                           REQUEST                        │
└──────────────────────────────────────────────────────────┘
                               │
                               ▼
┌──────────────────────────────────────────────────────────┐
│                   advanced-cache.php                     │
│                   (MilliCache Engine)                    │
└──────────────────────────────────────────────────────────┘
                               │
                               ▼
┌──────────────────────────────────────────────────────────┐
│                    Bootstrap Rules                       │
│    [WP_CACHE] [Method] [Cookies] [Paths] [CLI] [REST]    │
└──────────────────────────────────────────────────────────┘
                               │
          ┌────────────────────┴────────────────┐
          │                                     │
      Skip Cache                             Continue
          │                                     │
          ▼                                     ▼
┌───────────────────┐           ┌─────────────────────────────┐
│   Load WordPress  │           │      Generate Cache Key     │
│    (no caching)   │           │      URL + Cookies + ...    │
└───────────────────┘           └─────────────────────────────┘
                                                │
                                                ▼
                                  ┌───────────────────────────┐
                                  │       Redis Lookup        │
                                  └───────────────────────────┘
                                                │
                    ┌───────────────────────────┼───────────────────────────┐
                    │                           │                           │
               Cache HIT                   Cache MISS                  Grace HIT
                    │                           │                           │
                    ▼                           ▼                           ▼
┌─────────────────────────┐    ┌─────────────────────────┐    ┌─────────────────────────┐
│   Decompress (if gzip)  │    │    Load WordPress       │    │   Serve Stale Content   │
│   Send Headers          │    │    Register WP Rules    │    │  Mark for Regeneration  │
│   Output HTML           │    │    Buffer Output        │    └─────────────────────────┘
│   EXIT (~5-15ms)        │    │    Store with Flags     │
└─────────────────────────┘    │    Send Response        │
                               └─────────────────────────┘
```

## Cache Status Values

| Status   | Meaning                                    |
|----------|--------------------------------------------|
| `hit`    | Fresh cached content served                |
| `miss`   | No cache exists, content generated         |
| `bypass` | Caching skipped (rule matched)             |
| `grace`  | Stale content served, regeneration pending |

View status via debug headers:

```
X-MilliCache-Status: hit
```

## What Gets Cached

**Cached:**
- GET and HEAD requests
- 200 OK responses
- Anonymous (logged-out) visitors
- Pages, posts, archives, taxonomies
- Custom post types and taxonomies
- Static front page
- RSS/Atom feeds (unless excluded)

**Not Cached:**
- POST, PUT, DELETE requests
- Logged-in users
- Non-200 responses (404, 500, etc.)
- AJAX requests (`DOING_AJAX`)
- Cron requests (`DOING_CRON`)
- REST API requests (`REST_REQUEST`)
- WP-CLI commands
- Pages with `DONOTCACHEPAGE` constant
- Requests matching excluded cookies/paths
- Responses larger than 5MB (raw size, before compression)

## Cache Key Components

The cache key ensures unique caching per variation:

| Component        | Example              | Effect                            |
|------------------|----------------------|-----------------------------------|
| URL path         | `/blog/hello-world/` | Each URL cached separately        |
| Query string     | `?page=2`            | Pagination cached separately      |
| Filtered cookies | `currency=USD`       | Custom variations (if configured) |
| Unique variables | `device=mobile`      | Custom variations (if configured) |

### Ignored Components

By default, these don't affect the cache key:

- Cookies starting with `_` (analytics)
- Query params starting with `_` or `utm_`
- Fragment identifiers (`#section`)

## Performance Characteristics

| Metric           | Cache Hit  | Cache Miss        |
|------------------|------------|-------------------|
| Response Time    | 5-15ms     | 200-2000ms        |
| PHP Execution    | Minimal    | Full WordPress    |
| Database Queries | 0          | Typically 50-300+ |
| Memory Usage     | Minimal    | Full application  |

## Next Steps

- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) - Understand invalidation
- [Rules](/docs/millicache/04-rules/01-introduction) - Condition-based caching control
- [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) - Targeted invalidation

---

Canonical: https://www.millipress.com/docs/millicache/05-usage/20-cache-clearing

---
title: 'Cache Clearing & Invalidation'
description: 'Clear or purge the WordPress page cache in MilliCache: automatic invalidation on content changes, plus targeted clearing by cache flags, URLs, or post IDs.'
menu_order: 20
---

# Cache Clearing

MilliCache provides multiple methods to clear cached content, from automatic invalidation to targeted manual clearing.

## Automatic Invalidation

MilliCache automatically clears cache when content changes:

### Post Updates

When a post is created, updated, unpublished (trashed, set to draft or private), or deleted:

- The post's URL is cleared
- Related archives are cleared (category, tag, author, date)
- The homepage is cleared
- RSS feeds are cleared

**Hooks triggering post-cache clearing:**
- `clean_post_cache`
- `before_delete_post`
- `transition_post_status`

### Site-Wide Events

These events clear the entire site cache:

- Theme switching (`switch_theme`)
- Menu updates (`wp_update_nav_menu`)
- Widget updates (`widget_update_callback`)
- Customizer saves (`customize_save_after`)
- Template part updates (`save_post_wp_template_part`)
- Permalink structure changes (`update_option_permalink_structure`)
- Active plugins changes (`update_option_active_plugins`)

[MilliCache Pro](https://www.millipress.com/millicache-pro/)'s [Block Editor module](https://www.millipress.com/docs/millicache-pro/02-modules/04-block-editor/) makes several of these full clears surgical: when a synced pattern, Query Loop source, or Site Editor template changes, exactly the pages using it are cleared instead of the whole site.

## Manual Clearing

### Admin Bar

For logged-in users with the `publish_pages` capability, hover the **Cache** button in the admin bar:

- **Clear Current View Cache** clears only the page you are viewing. On post edit screens this becomes **Clear {Post Type} Cache** for the edited post.
- **Clear Website Cache** clears the current site.
- **Clear Network Cache** (network admin only) clears every site and asks for a confirming second click.

Clicking the **Cache** button itself never clears anything: in wp-admin on WordPress 7.0+ it opens the command palette, elsewhere it toggles the submenu.

### Command Palette

In wp-admin on WordPress 7.0 or newer, click the **Cache** admin bar button (or press <kbd>Cmd</kbd>+<kbd>K</kbd> / <kbd>Ctrl</kbd>+<kbd>K</kbd> and search for "MilliCache"):

- **MilliCache: Clear website cache** (or **MilliCache: Clear network cache** in the network admin)
- **MilliCache: Clear cache for `<targets>`**: type flags, post IDs or URLs into the palette search, comma-separated, e.g. `post:123, https://example.com/pricing/`
- **MilliCache: Status & Settings**

In the network admin, typed targets are raw flag patterns cleared across all sites: `*:home` clears every site's home page, `5:*` everything on site 5.

### WP-CLI

```bash
# Clear all cache
wp millicache clear

# Clear specific posts
wp millicache clear --id=1,2,3

# Clear by URIs (paths or full URLs)
wp millicache clear --uri="/page-1/,/page-2/"

# Or with absolute URLs
wp millicache clear --uri="https://example.com/page-1/,https://example.com/page-2/"

# Clear by flags
wp millicache clear --flag="post:123,home,archive:*"

# Clear specific sites (multisite)
wp millicache clear --site=1,2,3

# Clear entire network (multisite)
wp millicache clear --network=1
```

### Expire vs Delete

By default, `wp millicache clear` deletes cache entries immediately. Use `--expire` to mark them as expired instead:

```bash
wp millicache clear --expire
```

**Expire behavior:**
- Entries remain in cache but marked expired
- Next request serves stale content (grace)
- Content regenerates in background
- Visitors never wait for regeneration

**Delete behavior:**
- Entries removed immediately
- Next request generates fresh content
- First visitor waits for generation

> [!TIP]
> Use `--expire` for non-critical updates to maintain performance. Use deletion for urgent content corrections.

## Flag-Based Clearing

MilliCache uses **flags** (tags) to enable targeted cache clearing. Each cached page is tagged with relevant flags.

### Built-in Flags

| Flag Format                    | Applied To        | Example              |
|--------------------------------|-------------------|----------------------|
| `home`                         | Homepage/blog     | `home`               |
| `post:{id}`                    | Single post/page  | `post:123`           |
| `archive:post`                 | Post archive      | `archive:post`       |
| `archive:{post_type}`          | Custom Post Type  | `archive:book`       |
| `archive:{taxonomy}:{term_id}` | Term archive      | `archive:category:5` |
| `archive:author:{id}`          | Author archive    | `archive:author:1`   |
| `archive:{year}`               | Year archive      | `archive:2026`       |
| `archive:{year}:{month}`       | Month archive     | `archive:2026:01`    |
| `feed`                         | RSS/Atom feeds    | `feed`               |

### Multisite Flag Prefixes

In multisite, flags are prefixed with site/network IDs:

| Environment   | Flag Format                       |
|---------------|-----------------------------------|
| Single site   | `post:123`                        |
| Multisite     | `{site_id}:post:123`              |
| Multi-network | `{network_id}:{site_id}:post:123` |

### Clearing by Flags

```bash
# Clear homepage
wp millicache clear --flag="home"

# Clear post and its archives
wp millicache clear --flag="post:123,archive:post,home"

# Clear all category archives
wp millicache clear --flag="archive:category:*"
```

## Programmatic Clearing

### PHP Functions

MilliCache provides helper functions in `functions.php`:

```php
// Clear all cache
millicache_reset_cache();
millicache_reset_cache( true ); // Expire instead of delete

// Clear by post-IDs
millicache_clear_cache_by_post_ids( [ 1, 2, 3 ] );
millicache_clear_cache_by_post_ids( [ 1, 2, 3 ], true ); // Expire

// Clear by URLs
millicache_clear_cache_by_urls( [
    'https://example.com/page-1/',
    'https://example.com/page-2/',
] );

// Clear by flags
millicache_clear_cache_by_flags( [ 'post:123', 'home' ] );
millicache_clear_cache_by_flags( [ 'post:123' ], true ); // Expire
millicache_clear_cache_by_flags( [ 'post:123' ], false, true ); // Add site prefix

// Clear by mixed targets (Post-IDs, Flags & URLs)
millicache_clear_cache( 'post:123' ); // Single flag
millicache_clear_cache( [ 1, 'home', 'https://example.com/page-1/' ] ); // Multiple flags

// Multisite: Clear by site IDs
millicache_clear_cache_by_site_ids( [ 1, 2 ] );
millicache_clear_cache_by_site_ids( [ 1, 2 ], 1 ); // Specific network

// Multisite: Clear by network
millicache_clear_cache_by_network_id( 1 );
```

### Hooks for Custom Clearing

Clear cache in response to custom events:

```php
// Clear cache when a custom option changes
add_action( 'update_option_my_custom_option', function() {
    millicache_reset_cache();
} );

// Clear specific posts when the ACF field updates
add_action( 'acf/save_post', function( $post_id ) {
    millicache_clear_cache_by_post_ids( [ $post_id ] );
} );
```

## Custom Flags

Add custom flags to enable targeted clearing:

```php
// Add a custom flag based on content
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Add a flag for WooCommerce shop
    if ( function_exists( 'is_shop' ) && is_shop() ) {
        $flags[] = 'woo:shop';
    }

    return $flags;
} );
```

Then clear by your custom flag:

```bash
wp millicache clear --flag="woo:shop"
```

## Clearing Actions (Hooks)

Hook into clearing events:

```php
// After clearing by post-IDs
add_action( 'millicache_cache_cleared_by_posts', function( $post_ids, $expire ) {
    error_log( 'Cleared cache for posts: ' . implode( ', ', $post_ids ) );
}, 10, 2 );

// After clearing by flags
add_action( 'millicache_cache_cleared_by_flags', function( $flags, $expire ) {
    // Notify external CDN
    notify_cdn_purge( $flags );
}, 10, 2 );
```

## Best Practices

### 1. Use Targeted Clearing

Clear only what's necessary:

```php
// Good: Clear specific content
millicache_clear_cache_by_post_ids( [ $post_id ] );

// Avoid: Clear everything
millicache_reset_cache();
```

### 2. Prefer Expire Over Delete

For non-critical updates, expire instead of delete:

```php
millicache_clear_cache_by_post_ids( [ $post_id ], true ); // expire = true
```

After a full clear, the first visitor to each page pays the render cost. With [Cache Preloading](https://www.millipress.com/docs/millicache-pro/02-modules/05-cache-preloading/) in [MilliCache Pro](https://www.millipress.com/millicache-pro/), the cache refills itself from your XML sitemap in the background instead.

### 3. Batch Related Clears

Clear related items together:

```php
// Clear post and its relationships
$flags = [
    "post:{$post_id}",
    'home',
    'archive:post',
];
millicache_clear_cache_by_flags( $flags );
```

### 4. Use Custom Flags for Cross-Cutting Concerns

Add flags for content that should clear together:

```php
// Tag all pages showing a promotion
add_filter( 'millicache_flags_for_request', function( $flags ) {
    if ( is_promotion_active() ) {
        $flags[] = 'promo:summer-sale';
    }
    return $flags;
} );

// Clear all promotion pages when the sale ends
millicache_clear_cache_by_flags( [ 'promo:summer-sale' ] );
```

## Next Steps

- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) - Complete CLI reference
- [Cache Flags](/docs/millicache/03-cache-flags/01-introduction) - Understanding flags
- [Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters) - All available hooks

---

Canonical: https://www.millipress.com/docs/millicache/05-usage/30-multisite

---
title: 'WordPress Multisite Caching'
description: 'Run MilliCache on WordPress multisite: network activation, per-site cache isolation via flag prefixes, network-wide settings, and site or network clearing.'
menu_order: 30
---

# Multisite

MilliCache fully supports WordPress Multisite with per-site cache isolation, network-wide management, and multi-network compatibility.

## Installation

### Network Activation

1. [Install MilliCache](/docs/millicache/01-getting-started/20-installation) as a regular plugin
2. Network-activate from **Network Admin → Plugins**
3. Add `WP_CACHE` to `wp-config.php`:

```php
define( 'WP_CACHE', true );
```

> [!IMPORTANT]
> MilliCache must be network-activated. Per-site activation is not supported in multisite.

## Cache Isolation

Each site's cache is automatically isolated using flag prefixes:

| Environment   | Flag Format                     | Example        |
|---------------|---------------------------------|----------------|
| Single site   | `{flag}`                        | `post:123`     |
| Multisite     | `{site_id}:{flag}`              | `2:post:123`   |
| Multi-network | `{network_id}:{site_id}:{flag}` | `1:2:post:123` |

This ensures:
- Site A's cache doesn't affect Site B
- Clearing Site A doesn't clear Site B
- Each site can have different content for the same path

## Network-Wide Settings

### Via Constants

Settings in `wp-config.php` apply to all sites:

```php
define( 'MC_CACHE_TTL', 86400 );
define( 'MC_STORAGE_HOST', 'redis.example.com' );
```

### Via Database

Network-level settings are stored in the main site's options. Per-site settings can be configured via the admin UI on each site.

The settings are synced to config files for each site:

```
/wp-content/settings/millicache/
├── example_com.php
├── site1_example_com.php
└── site2_example_com.php
```

Each file returns settings for that domain:

```php
<?php
// example_com.php
return [
    // ...
    'cache' => [
        // ...
        'ttl' => 3600,  // 1 hour for this site
    ],
];
```

## Cache Clearing

### Clear Single Site

From a specific site's context:

```bash
# Clear current site
wp millicache clear --url=site1.example.com

# Clear by site ID
wp millicache clear --site=2
```

Via admin bar on each site: **Cache → Clear Website Cache**

### Clear Multiple Sites

```bash
# Clear specific sites
wp millicache clear --site=1,2,3

# Clear sites with specific network
wp millicache clear --site=1,2,3 --network=1
```

### Clear Entire Network

```bash
# Clear all sites in network
wp millicache clear --network=1

# Clear all networks
wp millicache clear --network=1,2
```

Via Network Admin bar: **Cache → Clear Network Cache** (asks for a confirming second click)

Via command palette in the network admin: type a raw flag pattern such as `*:home` or `5:*` and run **MilliCache: Clear cache for** to clear it across all sites.

### Clear All Sites

```bash
wp millicache clear
```

Without arguments from the main site context, clears all cache.

## PHP Functions in Multisite

### Clear by Site IDs

```php
// Clear specific sites
millicache_clear_cache_by_site_ids( [ 1, 2, 3 ] );

// Clear sites in a specific network
millicache_clear_cache_by_site_ids( [ 1, 2 ], 1 );

// Expire instead of delete
millicache_clear_cache_by_site_ids( [ 1, 2 ], null, true );
```

### Clear by Network

```php
// Clear the entire network
millicache_clear_cache_by_network_id( 1 );

// Expire instead of delete
millicache_clear_cache_by_network_id( 1, true );
```

### Prefix Flags

```php
$flags = [ 'post:123', 'home' ];

// Prefix for current site
$prefixed = millicache_prefix_flags( $flags );
// Result: [ '2:post:123', '2:home' ]

// Prefix for specific site
$prefixed = millicache_prefix_flags( $flags, 3 );
// Result: [ '3:post:123', '3:home' ]
```

## Statistics

### Per-Site Stats

```bash
# Stats for specific site
wp millicache stats --url=site1.example.com

# Filter by site flag prefix
wp millicache stats --flag="2:*"
```

### Network Stats

From network admin context:

```bash
wp millicache stats
```

[MilliCache Pro](https://www.millipress.com/millicache-pro/) adds visual insight on top: [Detailed Metrics](https://www.millipress.com/docs/millicache-pro/02-modules/06-detailed-metrics/) charts requests, bandwidth, and response times per site, and the [Cache Entries Browser](https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries/) shows entries from all sites in the Network Admin.

## Subdirectory vs. Subdomain

MilliCache works with both multisite configurations:

### Subdomain Multisite

```
site1.example.com → Cache key includes full domain
site2.example.com → Separate cache namespace
```

### Subdirectory Multisite

```
example.com/site1/ → Cache key includes path
example.com/site2/ → Separate cache namespace
```

No special configuration required — MilliCache automatically handles both.

## Domain Mapping

If using domain mapping (third-party domains pointing to subsites):

1. Each mapped domain gets its own cache entries
2. Flags are still prefixed by site ID, not domain
3. Clearing by site ID clears all entries for that site

```php
// Clear site 3, regardless of which domains point to it
millicache_clear_cache_by_site_ids( [ 3 ] );
```

## Best Practices

### 1. Use Network-Wide Constants

Keep storage configuration consistent:

```php
// wp-config.php
define( 'MC_STORAGE_HOST', 'redis.internal' );
define( 'MC_STORAGE_PREFIX', 'mll_prod_' );
```

### 2. Monitor Per-Site Usage

Check which sites consume the most cache:

```bash
# Stats filtered by site prefix
for i in 1 2 3 4 5; do
  echo "Site $i:"
  wp millicache stats --flag="$i:*" --format=json
done
```

## Troubleshooting

### Cache Isn’t Isolated

If sites seem to share cache:

1. Verify network activation (not per-site)
2. Check flag prefixes: `wp millicache stats --flag="*" --format=json`
3. Verify `MC_STORAGE_PREFIX` is consistent across all sites

### Network Clear Not Working

1. Verify you're running from network admin context
2. Check user has `manage_network` capability
3. Use explicit network ID: `wp millicache clear --network=1`

### Inconsistent Settings

1. Check setting sources: `wp millicache config get --show-source`
2. Verify config file naming matches domain exactly
3. Constants override all other sources

## Next Steps

- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) - Command line management
- [Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters) - Multisite-specific hooks
- [Configuration](/docs/millicache/02-configuration/01-overview) - Settings overview

---

Canonical: https://www.millipress.com/docs/millicache/06-wp-cli/01-commands

---
title: 'WP-CLI Commands'
description: 'Complete WP-CLI cache command reference for MilliCache: clear, stats, status, test, drop, and config, with options for flags, URIs, and multisite targets.'
menu_order: 10
---

# WP-CLI Commands

Complete reference for MilliCache command-line interface.

## Quick Reference

| Command                       | Description                       |
|-------------------------------|-----------------------------------|
| `wp millicache clear`         | Clear cache entries               |
| `wp millicache stats`         | View cache statistics             |
| `wp millicache status`        | Show plugin and connection status |
| `wp millicache test`          | Test Redis connection             |
| `wp millicache drop`          | Install/repair advanced-cache.php |
| `wp millicache cli`           | Open interactive Redis CLI        |
| `wp millicache config get`    | View configuration                |
| `wp millicache config set`    | Set configuration value           |
| `wp millicache config reset`  | Reset to defaults                 |
| `wp millicache config backup` | Create settings backup            |
| `wp millicache config restore`| Restore settings from backup      |
| `wp millicache config export` | Export settings as JSON           |
| `wp millicache config import` | Import settings from JSON         |

[MilliCache Pro](https://www.millipress.com/millicache-pro/) adds further commands for its modules (`entries`, `rules`, `preload`, `metrics`, `edge`, `module`, `license`). See the [Pro WP-CLI reference](https://www.millipress.com/docs/millicache-pro/03-wp-cli/01-commands/).

---

## Cache Commands

### wp millicache clear

Clear cached entries with various targeting options.

```bash
wp millicache clear [--id=<id>] [--uri=<uri>] [--flag=<flag>]
                    [--site=<site>] [--network=<network>] [--expire]
```

**Options:**

| Option                | Description                                                |
|-----------------------|------------------------------------------------------------|
| `--id=<id>`           | Comma-separated post IDs                                   |
| `--uri=<uri>`         | Comma-separated paths or full URLs                         |
| `--flag=<flag>`       | Comma-separated cache flags (supports wildcards)           |
| `--site=<site>`       | Comma-separated site IDs (multisite)                       |
| `--network=<network>` | Comma-separated network IDs (multisite)                    |
| `--expire`            | Expire instead of delete (serve stale during regeneration) |

> On multisite, scope a command to a specific site with WP‑CLI's global
> `--url=<site-url>` flag — it switches the site context before the command
> runs. Use `--uri=` for the actual cache targets within that site.

**Examples:**

```bash
# Clear all cache
wp millicache clear

# Clear specific posts
wp millicache clear --id=1,2,3,42

# Clear by URIs (paths or full URLs)
wp millicache clear --uri="/,/about/"

# Clear by flags
wp millicache clear --flag="home,archive:post"

# Clear with wildcard
wp millicache clear --flag="post:*"

# Expire instead of delete
wp millicache clear --expire

# Multisite: clear specific sites
wp millicache clear --site=1,2,3

# Multisite: clear entire network
wp millicache clear --network=1
```

### wp millicache stats

Display cache statistics.

```bash
wp millicache stats [--flag=<flag>] [--format=<format>]
```

**Options:**

| Option              | Description                                        |
|---------------------|----------------------------------------------------|
| `--flag=<flag>`     | Filter by flag pattern (supports wildcards)        |
| `--format=<format>` | Output: `table`, `json`, `yaml` (default: `table`) |

**Examples:**

```bash
# Basic statistics
wp millicache stats

# Filter by flag pattern
wp millicache stats --flag="post:*"

# JSON output
wp millicache stats --format=json
```

**Output:**

```
+----------+---------+
| property | value   |
+----------+---------+
| entries  | 142     |
| size     | 2856432 |
| size_h   | 2.7 MB  |
| avg_size | 20.1 KB |
+----------+---------+
```

---

## Configuration Commands

Configuration commands are provided by the MilliBase framework and manage settings using a priority hierarchy: constants > file > database > defaults.

### wp millicache config get

Display current configuration values.

```bash
wp millicache config get [<key>] [--show-source] [--format=<format>]
```

**Options:**

| Option              | Description                                                    |
|---------------------|----------------------------------------------------------------|
| `<key>`             | Module or setting key (e.g., `cache` or `cache.ttl`)           |
| `--show-source`     | Show where each value comes from                               |
| `--format=<format>` | Output: `table`, `json`, `yaml`, `csv` (default: `table`)      |

**Examples:**

```bash
# View all settings
wp millicache config get

# View specific module
wp millicache config get cache

# View specific setting
wp millicache config get cache.ttl

# View setting as JSON
wp millicache config get cache.ttl --format=json

# Show setting sources
wp millicache config get --show-source
```

**Source values:**
- `constant` — Defined in wp-config.php
- `file` — Set in config file
- `database` — Saved via admin or CLI
- `default` — Built-in default value

### wp millicache config set

Set a configuration value.

```bash
wp millicache config set <key> <value>
```

Values are automatically coerced: `"true"`/`"false"` become booleans, `"null"` becomes null, and numeric strings become numbers.

**Examples:**

```bash
# Set TTL
wp millicache config set cache.ttl 3600

# Set boolean
wp millicache config set cache.debug true

# Set array (JSON format)
wp millicache config set cache.nocache_paths '["/cart/*", "/checkout/*"]'

# Set password (automatically encrypted, masked in output)
wp millicache config set storage.enc_password "secret"
```

> [!NOTE]
> Settings defined via constants cannot be overridden via CLI.

### wp millicache config reset

Reset settings to default values. A backup is automatically created before resetting.

```bash
wp millicache config reset [--module=<module>] [--yes]
```

**Options:**

| Option              | Description                                        |
|---------------------|----------------------------------------------------|
| `--module=<module>` | Reset specific module: `storage`, `cache`, `rules` |
| `--yes`             | Skip confirmation                                  |

**Examples:**

```bash
# Reset all settings
wp millicache config reset

# Reset specific module
wp millicache config reset --module=cache

# Skip confirmation
wp millicache config reset --yes
```

### wp millicache config backup

Create a manual backup of current settings.

```bash
wp millicache config backup
```

Backups expire after 12 hours and are also created automatically before reset and import operations.

### wp millicache config restore

Restore settings from the most recent backup.

```bash
wp millicache config restore
```

### wp millicache config export

Export settings as JSON to stdout or file.

```bash
wp millicache config export [--file=<path>] [--module=<module>] [--include-encrypted]
```

**Options:**

| Option                | Description                                    |
|-----------------------|------------------------------------------------|
| `--file=<path>`       | Write to file instead of stdout                |
| `--module=<module>`   | Export only a specific module                   |
| `--include-encrypted` | Include decrypted values of encrypted fields    |

**Examples:**

```bash
# Export to stdout
wp millicache config export

# Export to file
wp millicache config export --file=settings.json

# Export only cache module
wp millicache config export --module=cache

# Include encrypted fields
wp millicache config export --include-encrypted --file=full-backup.json
```

### wp millicache config import

Import settings from a JSON file. A backup is automatically created before importing.

```bash
wp millicache config import --file=<path> [--merge] [--no-merge] [--yes]
```

**Options:**

| Option        | Description                                              |
|---------------|----------------------------------------------------------|
| `--file=<path>` | Path to JSON file (required)                          |
| `--merge`     | Merge with existing settings (default)                   |
| `--no-merge`  | Replace all settings with imported values                |
| `--yes`       | Skip confirmation                                        |

**Examples:**

```bash
# Import from file (merges by default)
wp millicache config import --file=settings.json

# Replace all settings
wp millicache config import --file=settings.json --no-merge

# Skip confirmation
wp millicache config import --file=settings.json --yes
```


---

## Diagnostic Commands

### wp millicache status

Display comprehensive plugin and cache status.

```bash
wp millicache status [--format=<format>]
```

**Output:**

```
+-------------------+------------------+
| property          | status           |
+-------------------+------------------+
| plugin_version    | 1.0.0            |
| wp_cache          | enabled          |
| advanced_cache    | symlink          |
| storage_connected | yes              |
| storage_version   | 7.2.4            |
| storage_memory    | 12.5 MB / 256 MB |
| cache_entries     | 142              |
| cache_size        | 2.7 MB           |
+-------------------+------------------+
```

### wp millicache test

Run comprehensive Redis connection tests.

```bash
wp millicache test
```

**Output:**

```
+-------------+--------+------------------+
| test        | status | info             |
+-------------+--------+------------------+
| Connection  | PASS   | Connected        |
| Ping        | PASS   | 0.23ms           |
| Write       | PASS   | Stored test data |
| Read        | PASS   | Data verified    |
| Delete      | PASS   | Cleaned up       |
+-------------+--------+------------------+
```

### wp millicache drop

Install or repair the advanced-cache.php drop-in file.

```bash
wp millicache drop [--force]
```

**Options:**

| Option    | Description                     |
|-----------|---------------------------------|
| `--force` | Force reinstall even if current |

**Examples:**

```bash
# Standard install/fix
wp millicache drop

# Force reinstall
wp millicache drop --force
```

### wp millicache cli

Open an interactive Redis CLI session.

```bash
wp millicache cli
```

Requires `redis-cli` installed on the system. Launches with configured connection settings.

**Common Redis commands once connected:**

```redis
PING                    # Check connection
KEYS mll:*              # List MilliCache keys
DBSIZE                  # Get key count
INFO memory             # Check memory usage
QUIT                    # Exit CLI
```

---

## Multisite Usage

In multisite installations:

```bash
# Run on specific site
wp millicache clear --url=site1.example.com

# Clear specific sites
wp millicache clear --site=1,2,3

# Clear entire network
wp millicache clear --network=1

# Stats for specific site
wp millicache stats --flag="2:*"
```

---

## Output Formats

Most commands support multiple formats:

```bash
# Table (default)
wp millicache status

# JSON
wp millicache status --format=json

# YAML
wp millicache status --format=yaml
```

---

## Common Workflows

### After Deployment

```bash
wp millicache drop --force
wp millicache test
wp millicache clear
```

### Debugging

```bash
wp millicache status
wp millicache test
wp millicache config set cache.debug true
wp millicache stats
```

### Backup and Restore Settings

```bash
# Create a manual backup
wp millicache config backup

# Restore from backup
wp millicache config restore

# Export to file for external backup
wp millicache config export --file=backup.json

# Import from file
wp millicache config import --file=backup.json
```

### Health Check Script

```bash
#!/bin/bash
if wp millicache test; then
    echo "Redis connection OK"
else
    echo "Redis connection failed"
    exit 1
fi
```

---

## Exit Codes

| Code  | Meaning  |
|-------|----------|
| `0`   | Success  |
| `1`   | Error    |

---

## Help

```bash
# General help
wp help millicache

# Command-specific help
wp help millicache clear
wp help millicache config
```

## Next Steps

- [Configuration Reference](/docs/millicache/02-configuration/02-reference) — All constants
- [Cache Clearing](/docs/millicache/05-usage/20-cache-clearing) — Clearing strategies
- [Troubleshooting](/docs/millicache/09-troubleshooting/01-common-issues) — Common issues
- [Pro WP-CLI Commands](https://www.millipress.com/docs/millicache-pro/03-wp-cli/01-commands/) — Commands added by MilliCache Pro

---

Canonical: https://www.millipress.com/docs/millicache/07-developers/01-architecture

---
title: 'Plugin Architecture'
description: 'Inside the MilliCache architecture: the Engine singleton, Redis storage layer, cache manager, invalidation queue, and the advanced-cache.php request flow.'
menu_order: 10
---

# Architecture

This guide explains MilliCache's internal architecture for developers who want to extend or integrate with the plugin.

## High-Level Architecture

```
┌─────────────────────────────────────────────────────────────────────────┐
│                           WordPress Request                              │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                        advanced-cache.php                                │
│                     (Drop-in / Engine Entry)                            │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                              Engine                                      │
│                         (Singleton Core)                                │
│  ┌──────────────┐ ┌───────────────┐ ┌───────────────┐ ┌─────────────┐  │
│  │   Storage    │ │ FlagManager   │ │    Config     │ │   Options   │  │
│  │   (Redis)    │ │   (Flags)     │ │   (Settings)  │ │   (TTL)     │  │
│  └──────────────┘ └───────────────┘ └───────────────┘ └─────────────┘  │
│  ┌──────────────────────────────┐ ┌─────────────────────────────────┐  │
│  │     Cache Manager            │ │     Invalidation Manager        │  │
│  │  ┌────────┐ ┌────────┐      │ │  ┌────────┐ ┌────────────┐      │  │
│  │  │ Reader │ │ Writer │      │ │  │ Queue  │ │  Resolver  │      │  │
│  │  └────────┘ └────────┘      │ │  └────────┘ └────────────┘      │  │
│  └──────────────────────────────┘ └─────────────────────────────────┘  │
│  ┌─────────────────────────────────────────────────────────────────┐   │
│  │                   Request/Response Processing                    │   │
│  │  ┌───────────────────────┐    ┌──────────────────────────────┐  │   │
│  │  │  Request Processor    │    │    Response Processor        │  │   │
│  │  │  ┌────────┐ ┌───────┐ │    │    ┌────────┐ ┌────────┐    │  │   │
│  │  │  │ Parser │ │Hasher │ │    │    │ State  │ │Headers │    │  │   │
│  │  │  └────────┘ └───────┘ │    │    └────────┘ └────────┘    │  │   │
│  │  └───────────────────────┘    └──────────────────────────────┘  │   │
│  └─────────────────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────┐
│                           MilliRules Engine                              │
│  ┌───────────────────────┐        ┌──────────────────────────────────┐ │
│  │   Bootstrap Rules     │        │     WordPress Rules              │ │
│  │   (Pre-WordPress)     │        │     (Post-WordPress)             │ │
│  └───────────────────────┘        └──────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```

## Directory Structure

```
src/
├── MilliCache.php              # Main plugin class
├── Engine.php                  # Cache engine (singleton)
├── Core/
│   ├── Loader.php              # WordPress hook orchestrator
│   ├── Settings.php            # Configuration management
│   └── Storage.php             # Redis connection & operations
├── Admin/
│   ├── Admin.php               # Admin UI controller
│   ├── CLI.php                 # WP-CLI commands
│   ├── RestAPI.php             # REST endpoints
│   ├── Adminbar.php            # Admin bar integration
│   ├── Activator.php           # Plugin activation
│   └── Deactivator.php         # Plugin deactivation
├── Engine/
│   ├── Cache/
│   │   ├── Config.php          # Cache configuration
│   │   ├── Manager.php         # Cache operations orchestrator
│   │   ├── Reader.php          # Read from cache
│   │   ├── Writer.php          # Write to cache
│   │   ├── Entry.php           # Cache entry model
│   │   ├── Validator.php       # Cache validation
│   │   ├── Result.php          # Cache operation result
│   │   └── Invalidation/
│   │       ├── Manager.php     # Invalidation orchestrator
│   │       ├── Queue.php       # Invalidation queue
│   │       └── Resolver.php    # Target resolution
│   ├── Request/
│   │   ├── Processor.php       # Request handling
│   │   ├── Parser.php          # Parse request data
│   │   ├── Cleaner.php         # Clean/normalize request
│   │   └── Hasher.php          # Generate cache key
│   ├── Response/
│   │   ├── Processor.php       # Response handling
│   │   ├── State.php           # Response state machine
│   │   └── Headers.php         # HTTP header management
│   ├── Flags.php               # Flag manager
│   ├── Options.php             # Runtime option overrides
│   └── Utilities/
│       ├── Multisite.php       # Multisite helpers
│       ├── PatternMatcher.php  # Wildcard matching
│       └── ServerVars.php      # Server variable access
└── Rules/
    ├── Bootstrap.php           # Pre-WordPress rules
    ├── WordPress.php           # Post-WordPress rules
    ├── RequestFlags.php        # Flag assignment
    └── Actions/
        ├── PHP/                # Bootstrap phase actions
        │   ├── DoCache.php
        │   ├── SetTtl.php
        │   └── SetGrace.php
        └── WP/                 # WordPress phase actions
            ├── AddFlag.php
            ├── RemoveFlag.php
            ├── ClearCache.php
            └── ClearSiteCache.php
```

## Key Classes

### Engine (Singleton)

The central coordinator. Manages all subsystems and orchestrates the caching flow.

**File:** `src/Engine.php`

```php
// Recommended: Use the millicache() helper
$engine = millicache();

// Or access Engine directly
$engine = \MilliCache\Engine::instance();

// Access subsystems
$engine->storage();    // Redis connection
$engine->flags();      // Flag manager
$engine->config();     // Configuration
$engine->options();    // Runtime overrides
$engine->cache();      // Cache manager
$engine->clear();      // Invalidation manager
$engine->rules();      // Rules manager
```

### Storage

Handles all Redis operations.

**File:** `src/Core/Storage.php`

```php
$storage = $engine->storage();

// Low-level operations
$storage->set_cache( $key, $data, $flags );
$storage->get_cache( $key );
$storage->delete_cache( $key );
$storage->is_connected();
$storage->get_status();
```

#### Two-keyspace layout

The Storage layer splits each cache entry into two Redis hashes plus a reference set:

| Keyspace                  | Purpose                                                              |
|---------------------------|----------------------------------------------------------------------|
| `<prefix>:c:<hash>`       | Request entry — headers, status, flags, variant, `output_ref` (sha1) |
| `<prefix>:o:<hash>`       | Body bytes, content-addressable, deduplicated across variants        |
| `<prefix>:o:<hash>:refs`  | SET of request entries referencing this body                         |

`set_cache()` writes the body to `o:<hash>` (no-op if present), `SADD`s the request key into the refs SET, and stores the SHA-1 in the request entry's `output` field. `get_cache()` follows the pointer to the body. `delete_cache()` decrements the refs SET; the body is GC'd when SCARD hits zero.

The split is invisible at the cache-layer API: `Reader`, `Writer`, and `Manager` work with regular `Entry` value objects.

### Settings

Configuration management via MilliBase. Each scope owns its own
`\MilliBase\Settings` instance and the defaults that back it.

**Access:** `Site::settings()` and `Network::settings()` each return a
`\MilliBase\Settings` instance. On single-site, `Network::settings()`
delegates to `Site::settings()` so callers can read `storage` from either
without branching.

```php
use MilliCache\Base\Network;
use MilliCache\Base\Site;

$settings = Site::settings();

// Get settings
$ttl   = $settings->get( 'cache.ttl' );
$cache = $settings->get( 'cache' );

// Set settings
$settings->set( 'cache.ttl', 3600 );

// Import/Export
$settings->export( 'cache' );
$settings->import( $data );

// Network-scoped (multisite) — `storage` lives here on multisite
$storage = Network::settings()->get( 'storage' );
```

### Cache Manager

Coordinates cache read/write operations.

**File:** `src/Engine/Cache/Manager.php`

```php
$cache = $engine->cache();

// Check for cached content
$result = $cache->get( $request_hash );

// Store content
$cache->set( $request_hash, $content, $headers, $flags );
```

### Invalidation Manager

Handles cache clearing with queuing and resolution.

**File:** `src/Engine/Cache/Invalidation/Manager.php`

```php
$invalidation = $engine->clear();

// Clear by various targets
$invalidation->posts( [ 1, 2, 3 ] );
$invalidation->flags( [ 'home', 'archive:post' ] );
$invalidation->urls( [ 'https://example.com/' ] );
$invalidation->sites( [ 1, 2 ], $network_id );
$invalidation->networks( 1 );
$invalidation->all();
```

### Flag Manager

Manages cache flags for the current request.

**File:** `src/Engine/Flags.php`

```php
$flags = $engine->flags();

// Manage flags
$flags->add( 'custom:my-flag' );
$flags->remove( 'home' );
$flags->get_all();
$flags->has( 'post:123' );
```

### Request Processor

Parses and normalizes incoming requests.

**File:** `src/Engine/Request/Processor.php`

```php
$request = new \MilliCache\Engine\Request\Processor( $config );

// Get cache key
$hash = $request->get_hash();

// Check if cacheable
$cacheable = $request->is_cacheable();
```

## Request Flow

### Cache Hit Flow

```
1. advanced-cache.php
   └─► Engine::start()
       └─► Bootstrap rules evaluated
           └─► Request parsed and hashed
               └─► Cache lookup (Redis GET)
                   └─► HIT: Decompress + send headers + output + exit
```

### Cache Miss Flow

```
1. advanced-cache.php
   └─► Engine::start()
       └─► Bootstrap rules evaluated
           └─► Request parsed and hashed
               └─► Cache lookup (Redis GET)
                   └─► MISS: Output buffering starts (outermost buffer)
                       └─► Continue to WordPress

2. plugins_loaded hook
   └─► WordPress rules registered

3. template_redirect hook
   └─► WordPress rules evaluated
       └─► Storage sentinel arms (response may be stored)

4. shutdown hook
   └─► Output buffer captured
       └─► Rule decisions applied (bypass passes through unstored)
           └─► Response validated
               └─► Flags collected
                   └─► Cache stored (Redis SET)
                       └─► Output sent to browser
```

The buffer opens before any plugin or mu-plugin loads, so output-buffer
post-processors (translation plugins, HTML optimizers) always nest inside
it and flush first: the cache captures their transformed HTML. Requests
that never reach the `template_redirect` sentinel (admin screens, canonical
redirects, early exits) pass through the buffer unstored.

## Extension Points

### Hooks for Integration

```php
// Before cache storage
add_action( 'millicache_entry_storing', function( $hash, $key, $flags, $data ) {
    // Modify or log before storage
}, 10, 4 );

// After cache storage
add_action( 'millicache_entry_stored', function( $hash, $key, $flags, $data ) {
    // Notify external systems
}, 10, 4 );

// After cache clearing
add_action( 'millicache_cache_cleared', function( $expire ) {
    // Clear CDN, notify systems
} );

// Custom flags
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Add custom flags
    return $flags;
} );
```

### Custom Rule Actions

```php
$rules = millicache()->rules();

// Register the action, then use it by name like any built-in one.
$rules->register_action( 'my_custom_action', function ( $args, $context ) {
    // Custom logic.
} )->label( 'My Custom Action' );

$rules->create( 'mysite:custom' )
    ->order( 10 )
    ->when()
        ->request_url( '/special/*' )
    ->then()
        ->my_custom_action()
    ->register();
```

An inline closure works too, without registering the action first:

```php
$rules->create( 'mysite:custom' )
    ->when()
        ->request_url( '/special/*' )
    ->then()
        ->custom( 'my-custom-action', function ( $context ) {
            // Custom logic.
        } )
    ->register();
```

## Dependencies

### External

| Package                 | Purpose      |
|-------------------------|--------------|
| `predis/predis`         | Redis client |
| `millipress/millirules` | Rules engine |

### WordPress

| Component      | Usage                |
|----------------|----------------------|
| Drop-in API    | `advanced-cache.php` |
| Options API    | Settings storage     |
| Transients API | Backup storage       |
| REST API       | Remote management    |
| WP-CLI         | Command line         |
| Hooks API      | Integration points   |

## Performance Considerations

### Early Exit

Cache hits exit before WordPress loads:
- No database queries
- No plugin/theme code
- Minimal memory usage

### Lazy Loading

Subsystems initialize on first use:
```php
// Storage isn't connected until the first cache operation
$engine->storage()->get_cache( $key );
```

### Efficient Clearing

Invalidation uses Redis patterns:
```php
// Single Redis command clears matching keys
KEYS mll:flags:post:123:*
DEL [matching keys]
```

## Next Steps

- [Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters) - All available hooks
- [API Reference](/docs/millicache/07-developers/03-api-reference) - Function documentation
- [Rules Introduction](/docs/millicache/04-rules/01-introduction) - Extend the rules engine

---

Canonical: https://www.millipress.com/docs/millicache/07-developers/02-hooks-filters

---
title: 'Action Hooks & Filters'
description: 'Reference of every MilliCache WordPress hook and filter: cache storage and clearing events, flag assignment, settings, REST API, and capability checks.'
menu_order: 30
---

# Hooks & Filters

This is a complete reference of all hooks and filters available in MilliCache.

## Action Hooks

### Cache Storage Events

#### millicache_entry_storing

Fires before a cache entry is stored.

```php
add_action( 'millicache_entry_storing', function( $hash, $key, $flags, $data ) {
    // $hash  - Cache hash
    // $key   - Cache key
    // $flags - Array of flags
    // $data  - Cache data array

    error_log( "Storing cache: $key with " . count( $flags ) . " flags" );
}, 10, 4 );
```

#### millicache_entry_stored

Fires after a cache entry is stored.

```php
add_action( 'millicache_entry_stored', function( $hash, $key, $flags, $data ) {
    // Notify external CDN
    cdn_notify_cached( $key );
}, 10, 4 );
```

#### millicache_entry_deleting

Fires before a cache entry is deleted.

```php
add_action( 'millicache_entry_deleting', function( $hash, $key, $flags, $url ) {
    error_log( "Deleting cache: $url" );
}, 10, 4 );
```

#### millicache_entry_deleted

Fires after a cache entry is deleted. The `$url` argument lets a listener mirror
the eviction elsewhere (for example, purging a CDN/edge cache by URL) without
having to resolve the hash back to a request.

```php
add_action( 'millicache_entry_deleted', function( $hash, $key, $flags, $url ) {
    // $hash  - Cache hash
    // $key   - Cache key
    // $flags - Array of canonical flags (e.g. "2:post:123"), as emitted by
    //          millicache_cache_cleared_by_flags
    // $url   - The original request URL of the deleted entry

    cdn_purge_url( $url );
}, 10, 4 );
```

#### millicache_entry_expired

Fires after a cache entry is expired (aged out) by flag, e.g. via
`millicache()->clear()->flags( ..., $expire = true )`. Unlike deletion, the
entry and its flag membership are preserved; only its freshness is reset, so
the origin regenerates the response on the next request. Edge/CDN mirrors
should treat this as a purge signal too, since the cached body is now stale.

The payload matches `millicache_entry_deleted` exactly: `$hash`, `$key`,
canonical `$flags`, and `$url`.

```php
add_action( 'millicache_entry_expired', function( $hash, $key, $flags, $url ) {
    cdn_purge_url( $url );
}, 10, 4 );
```

---

### Cache Clearing Events

#### millicache_delete_flags / millicache_expire_flags

Fire when the clearing queue executes — once per request, with the complete,
deduplicated set of canonical flags collected from **every** clear, no matter
the trigger. The `millicache_cache_cleared_by_*` actions below are
trigger-scoped and carry the caller's vocabulary (post IDs, URLs, raw flags);
these two are the content-level counterpart: the final list as it goes to
storage — site-prefixed on multisite (`2:post:123`), raw patterns kept raw
(`*:test`, `5:*`). They fire immediately before the entries are removed;
per-entry outcomes are reported by `millicache_entry_deleted` /
`millicache_entry_expired`.

`millicache_delete_flags` carries the flags being deleted,
`millicache_expire_flags` the ones being expired (soft-cleared). Hook both
with the same callback when the distinction doesn't matter.

These are the hooks to use when you mirror MilliCache's invalidation
elsewhere (edge caches, external systems) and need the complete, canonical
picture from a single listener.

```php
$purge = function( $flags ) {
    // $flags - The complete batch of canonical flags being cleared.
    external_cache_purge_by_tags( $flags );
};

add_action( 'millicache_delete_flags', $purge );
add_action( 'millicache_expire_flags', $purge );
```

#### millicache_cache_cleared_by_urls

Fires after cache is cleared by URLs.

```php
add_action( 'millicache_cache_cleared_by_urls', function( $urls, $expire ) {
    // $urls   - Array of cleared URLs
    // $expire - Whether expire mode was used

    foreach ( $urls as $url ) {
        error_log( "Cleared cache for URL: $url" );
    }
}, 10, 2 );
```

#### millicache_cache_cleared_by_posts

Fires after cache is cleared by post IDs — both explicit clears (`clear()->posts()`,
WP-CLI `--id`) and the automatic invalidation that runs when a post is published,
updated, unpublished, or deleted. The related-content flags cleared alongside a
post (archives, author, taxonomies, dates) are reported separately by
`millicache_cache_cleared_by_flags`.

```php
add_action( 'millicache_cache_cleared_by_posts', function( $post_ids, $expire ) {
    // $post_ids - Array of post IDs
    // $expire   - Whether expire mode was used

    foreach ( $post_ids as $post_id ) {
        error_log( "Cleared cache for post: $post_id" );
    }
}, 10, 2 );
```

#### millicache_cache_cleared_by_flags

Fires after cache is cleared explicitly by flags (`clear()->flags()`, WP-CLI
`--flag`, the settings-page target clears). Carries the flags as the caller
passed them — normalized but un-prefixed. For the canonical, complete flag
batch of every clear, listen to `millicache_delete_flags` /
`millicache_expire_flags` instead.

```php
add_action( 'millicache_cache_cleared_by_flags', function( $flags, $expire ) {
    // $flags  - Array of flags as requested by the caller
    // $expire - Whether expire mode was used

    error_log( 'Flag clear requested: ' . implode( ', ', $flags ) );
}, 10, 2 );
```

#### millicache_cache_cleared_by_sites

Fires after cache is cleared by site IDs (multisite).

```php
add_action( 'millicache_cache_cleared_by_sites', function( $site_ids, $network_id, $expire ) {
    // $site_ids   - Array of site IDs
    // $network_id - Network ID (if specified)
    // $expire     - Whether expire mode was used
}, 10, 3 );
```

#### millicache_cache_cleared_by_networks

Fires after cache is cleared by network IDs.

```php
add_action( 'millicache_cache_cleared_by_networks', function( $network_ids, $expire ) {
    // $network_ids - Array of network IDs
    // $expire      - Whether expire mode was used
}, 10, 2 );
```

#### millicache_cache_cleared

Fires after any cache clearing operation.

```php
add_action( 'millicache_cache_cleared', function( $expire ) {
    // $expire - Whether expire mode was used

    // Log clearing event
    error_log( 'MilliCache cleared at ' . current_time( 'mysql' ) );

    // Clear external caches
    if ( function_exists( 'wp_cache_flush' ) ) {
        wp_cache_flush();
    }
} );
```

---

### REST API Events

#### millicache_rest_cache_action_performed

Fires after a REST API cache action is performed.

```php
add_action( 'millicache_rest_cache_action_performed', function( $action, $params, $request ) {
    // $action  - Action performed (clear, clear_current, clear_targets)
    // $params  - Action parameters
    // $request - WP_REST_Request object

    // Audit log
    audit_log( "Cache action: $action by user " . get_current_user_id() );
}, 10, 3 );
```

#### millicache_rest_settings_action_performed

Fires after a REST API settings action is performed.

```php
add_action( 'millicache_rest_settings_action_performed', function( $action, $params, $request ) {
    // $action - Action performed (reset, restore)

    audit_log( "Settings action: $action" );
}, 10, 3 );
```

---

### Admin Events

#### millicache_commands_enqueued

Fires after the command palette bundle has been enqueued (wp-admin on WordPress 7.0+, users with the clear-cache capability). Hook it to enqueue your own palette commands; palette availability, admin context and capability are already vetted.

```php
add_action( 'millicache_commands_enqueued', function() {
    wp_enqueue_script(
        'my-plugin-commands',
        plugins_url( 'build/commands.js', __FILE__ ),
        array( 'millicache-commands' ),
        '1.0.0',
        array( 'in_footer' => true )
    );
} );
```

On the JavaScript side, MilliCache registers its commands without a context so a regular <kbd>Cmd</kbd>+<kbd>K</kbd> open stays uncluttered, and promotes them to the pre-loaded `root` context while the palette is opened through the admin bar button. Extension commands can join that cycle by listening to the `millicache.adminbar.paletteOpen` JS hook:

```js
import { addAction } from '@wordpress/hooks';

// Fired just before the admin bar button opens the palette.
addAction( 'millicache.adminbar.paletteOpen', 'my-plugin/commands', () => {
    registerMyCommands( 'root' ); // re-register with context: 'root'
} );
```

---

## Filter Hooks

### Settings Filters

#### millicache_settings_defaults

Filter default settings.

```php
add_filter( 'millicache_settings_defaults', function( $defaults ) {
    // Modify defaults
    $defaults['cache']['ttl'] = 3600;  // 1 hour default

    return $defaults;
} );
```

---

### Cache Entry Filters

#### millicache_entry_headers

Filter the response headers stored with a cache entry. Runs on every store: a fresh cache miss and a stale-while-revalidate background regeneration alike. Because it runs on regeneration too, headers derived from the entry's flags (such as edge cache tags or `Cache-Control: s-maxage`) always match the flags being persisted and cannot drift.

```php
add_filter( 'millicache_entry_headers', function( $headers, $flags, $context ) {
    // Leave private (per-cookie/variant) entries off shared caches.
    if ( null !== $context['variant'] ) {
        return $headers;
    }

    // Replace it, don't append: strip the header we own before re-adding it.
    $headers = array_values( array_filter(
        $headers,
        fn( $h ) => stripos( $h, 'Cache-Tag:' ) !== 0
    ) );

    $headers[] = 'Cache-Tag: ' . implode( ',', $flags );

    return $headers;
}, 10, 3 );
```

**Parameters:**

- `$headers` (`string[]`) — Response headers as `"Key: Value"` strings, already scrubbed of MilliCache's own and per-hop headers.
- `$flags` (`string[]`) — Canonical flags persisted with the entry, in the same form Redis stores minus the `<storage_prefix>:f:` key prefix. On multisite these carry the site/network prefix (e.g. `2:post:9`); on single-site they are unprefixed (e.g. `post:9`).
- `$context` (`array`) — Store-time context: `url` (string), `variant` (array|null; non-null marks a private entry), `status` (int), `ttl` (int seconds), `grace` (int seconds).

> [!IMPORTANT]
> Listeners must be replace-not-append: strip any header they own before re-adding it, so the filter stays idempotent across the miss and regeneration passes.

---

### Cache Clearing Filters

#### millicache_flags_related_to_post

Filter flags that should be cleared when a post is updated.

```php
add_filter( 'millicache_flags_related_to_post', function( $flags, $post ) {
    // Add custom flags based on post content
    if ( has_block( 'myblock/featured', $post ) ) {
        $flags[] = 'featured-content';
    }

    // Add taxonomy-based flags
    $categories = get_the_category( $post->ID );
    foreach ( $categories as $cat ) {
        $flags[] = 'category:' . $cat->term_id;
    }

    return $flags;
}, 10, 2 );
```

#### millicache_settings_clear_site_hooks

Filter hooks that trigger full site cache clearing.

```php
add_filter( 'millicache_settings_clear_site_hooks', function( $hooks ) {
    // Add a custom hook that should clear the cache
    $hooks[] = 'my_custom_global_update';

    // Remove a default hook
    $key = array_search( 'switch_theme', $hooks );
    if ( $key !== false ) {
        unset( $hooks[ $key ] );
    }

    return $hooks;
} );
```

**Default hooks:**
- `save_post_wp_template_part`
- `customize_save_after`
- `wp_update_nav_menu`
- `switch_theme`
- `update_option_permalink_structure`
- `update_option_active_plugins`

#### millicache_settings_clear_site_options

Filter options that trigger full site cache clearing when updated.

```php
add_filter( 'millicache_settings_clear_site_options', function( $options ) {
    // Add a custom option
    $options[] = 'my_global_setting';

    return $options;
} );
```

---

### Bucket Extension

Buckets are short canonical tokens folded into the cache key to differentiate per-request signals. MilliCache does **not** expose a runtime hook filter for buckets — `advanced-cache.php` runs before any plugin or mu-plugin loads, so a `add_filter()` callback registered on `plugins_loaded` would never run during cache lookup. The lookup hash and the write hash would diverge, producing a permanent cache miss.

Buckets are extended through two paths whose timing matches MilliCache's boot order:

#### Static bucket configuration

Lookup tables for resolvers go in the [`MC_CACHE_BUCKETS`](/docs/millicache/02-configuration/02-reference#mc_cache_buckets) constant. Read during Config construction, available when the cache key is generated.

```php
define( 'MC_CACHE_BUCKETS', [
    'tenant' => [ 'acme' => 'acme', 'globex' => 'glx' ],
    'ab'     => [ 'control' => 'a', 'variant' => 'b' ],
] );
```

Defining a lookup table doesn't bucket anything by itself; a *resolver* still has to read the table and call `add_bucket()`.

#### Runtime bucket extension via the rules engine

For per-request bucket resolution that depends on conditions (URL match, header presence, cookie value, etc.), use the **rules engine**. Rules are evaluated during the early PHP phase, in time to influence the request hash.

The `set_bucket` PHP-phase action calls into:

```php
\MilliCache\Engine\Request\Bucket\Resolver::add_bucket( string $name, string $token ): void
```

`add_bucket()` is the programmatic extension point. Programmatic additions take precedence over built-in resolutions when names collide.

Example: bucket A/B test arms from a cookie:

```
Condition: cookie ab_arm matches /^[ab]$/
Action:    set_bucket name="ab" token="{cookie.ab_arm}"
```

The rule fires per request; the action calls `$resolver->add_bucket('ab', $cookie_value)`. Cache entries for arm A and arm B stay distinct, but if the rendered HTML happens to be byte-identical they automatically share storage via the content-addressable output keyspace.

> [!NOTE]
> Regular WordPress plugins cannot extend buckets at the cache-lookup phase because they load after `advanced-cache.php`. To influence cache differentiation, ship a settings update (extending `MC_CACHE_BUCKETS`) or register rules.

---

### Request Flags Filters

#### millicache_flags_for_request

Filter flags assigned to the current request.

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    // Add flags based on content
    if ( has_block( 'myblock/weather' ) ) {
        $flags[] = 'block:weather';
    }

    // Add flags for WooCommerce
    if ( function_exists( 'is_shop' ) && is_shop() ) {
        $flags[] = 'woo:shop';
    }

    // Add a template-based flag
    if ( is_page_template( 'templates/landing.php' ) ) {
        $flags[] = 'template:landing';
    }

    return $flags;
} );
```

---

### Capability Filters

#### millicache_clear_cache_capability

Filter the capability required to clear cache.

```php
add_filter( 'millicache_clear_cache_capability', function( $capability ) {
    // Require higher capability
    return 'manage_options';  // Only administrators

    // Or lower for specific sites
    // return 'edit_posts';  // Editors can clear
} );
```

**Default:** `publish_pages`

---

### REST API Filters

#### millicache_rest_cache_allowed_actions

Filter allowed cache actions via REST API.

```php
add_filter( 'millicache_rest_cache_allowed_actions', function( $actions ) {
    // Add custom action
    $actions[] = 'my_custom_action';

    // Remove an action
    $key = array_search( 'clear', $actions );
    if ( $key !== false ) {
        unset( $actions[ $key ] );
    }

    return $actions;
} );
```

**Default:** `['clear', 'clear_current', 'clear_targets']`

#### millicache_rest_settings_allowed_actions

Filter allowed settings actions via REST API.

```php
add_filter( 'millicache_rest_settings_allowed_actions', function( $actions ) {
    // Remove restore action
    $key = array_search( 'restore', $actions );
    if ( $key !== false ) {
        unset( $actions[ $key ] );
    }

    return $actions;
} );
```

**Default:** `['reset', 'restore']`

#### millicache_rest_status_response

Filter the REST API status response.

```php
add_filter( 'millicache_rest_status_response', function( $status ) {
    // Add custom data
    $status['custom_metric'] = get_custom_cache_metric();

    // Remove sensitive data
    unset( $status['storage']['password'] );

    return $status;
} );
```

---

### Update Filters

#### millicache_updates

Filter whether MilliCache checks MilliPress.com for plugin updates. Return
`false` to disable update checks entirely, which stops the remote request and
hides the update notice on the Plugins screen.

The filter is evaluated at update-check time (when WordPress refreshes the
`update_plugins` transient), so it can be added from a theme's `functions.php`
or another plugin and still be honored.

```php
add_filter( 'millicache_updates', '__return_false' );
```

**Default:** `true`

---

## Hook Usage Examples

### Notify CDN on Cache Clear

```php
add_action( 'millicache_cache_cleared_by_flags', function( $flags, $expire ) {
    // Convert MilliCache flags to CDN tags
    $cdn_tags = array_map( function( $flag ) {
        return 'millicache-' . sanitize_title( $flag );
    }, $flags );

    // Purge CDN
    cdn_purge_by_tags( $cdn_tags );
}, 10, 2 );
```

### Audit Logging

```php
add_action( 'millicache_cache_cleared', function( $expire ) {
    $user = wp_get_current_user();
    $method = $expire ? 'expired' : 'deleted';

    log_audit_event( 'cache_cleared', [
        'user_id'   => $user->ID,
        'user_name' => $user->user_login,
        'method'    => $method,
        'timestamp' => current_time( 'mysql' ),
    ] );
} );
```

### Custom Flags for ACF

```php
add_filter( 'millicache_flags_for_request', function( $flags ) {
    if ( ! function_exists( 'get_field' ) ) {
        return $flags;
    }

    // Add a flag for pages with a specific ACF field
    if ( is_singular() && get_field( 'enable_dynamic_content' ) ) {
        $flags[] = 'acf:dynamic';
    }

    return $flags;
} );

// Clear when the ACF field changes
add_action( 'acf/save_post', function( $post_id ) {
    if ( get_field( 'enable_dynamic_content', $post_id ) ) {
        millicache_clear_cache_by_flags( [ 'acf:dynamic' ] );
    }
} );
```

### Restrict Cache Clearing by Role

```php
add_filter( 'millicache_clear_cache_capability', function( $capability ) {
    // Only allow on staging/development
    if ( defined( 'WP_ENV' ) && WP_ENV === 'production' ) {
        return 'manage_options';  // Admins only in production
    }

    return 'edit_posts';  // Editors can clear on staging
} );
```

## Next Steps

- [API Reference](/docs/millicache/07-developers/03-api-reference) - Function documentation
- [Architecture](/docs/millicache/07-developers/01-architecture) - Internal structure
- [Rules Introduction](/docs/millicache/04-rules/01-introduction) - Extend the rules engine

---

Canonical: https://www.millipress.com/docs/millicache/07-developers/03-api-reference

---
title: 'PHP & REST API Reference'
description: 'MilliCache developer API reference: PHP functions for clearing cache and managing flags, plus REST endpoints under /millicache/v1/ for remote control.'
menu_order: 40
---

# API Reference

This reference documents all public PHP functions and REST API endpoints.

## PHP Functions

All functions are defined in `functions.php` and are available when the MilliCache Engine loads, even before WordPress.

### Cache Clearing Functions

#### millicache_reset_cache()

Clear all cache entries.

```php
millicache_reset_cache( bool $expire = false ): bool
```

**Parameters:**
- `$expire` - If true, expire entries (serve stale during regen) instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
// Delete all cache immediately
millicache_reset_cache();

// Expire all cache (serve stale while regenerating)
millicache_reset_cache( true );
```

---

#### millicache_clear_cache()

Clear cache by mixed targets.

```php
millicache_clear_cache( string|array $targets, bool $expire = false ): bool
```

**Parameters:**
- `$targets` - Flag(s), URL(s), or post ID(s) as string or array
- `$expire` - Expire instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
// Clear by single flag
millicache_clear_cache( 'home' );

// Clear by multiple targets
millicache_clear_cache( [ 'post:123', 'home', 'archive:post' ] );

// Clear by URL
millicache_clear_cache( 'https://example.com/about/' );
```

---

#### millicache_clear_cache_by_urls()

Clear cache by URLs.

```php
millicache_clear_cache_by_urls( array $urls, bool $expire = false ): bool
```

**Parameters:**
- `$urls` - Array of URLs to clear
- `$expire` - Expire instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
millicache_clear_cache_by_urls( [
    'https://example.com/',
    'https://example.com/about/',
    'https://example.com/contact/',
] );
```

---

#### millicache_clear_cache_by_post_ids()

Clear cache by post IDs.

```php
millicache_clear_cache_by_post_ids( array $post_ids, bool $expire = false ): bool
```

**Parameters:**
- `$post_ids` - Array of post IDs
- `$expire` - Expire instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
millicache_clear_cache_by_post_ids( [ 1, 2, 3 ] );

// With expiration
millicache_clear_cache_by_post_ids( [ 123 ], true );
```

---

#### millicache_clear_cache_by_flags()

Clear cache by flags.

```php
millicache_clear_cache_by_flags(
    array $flags,
    bool $expire = false,
    bool $add_prefix = false
): bool
```

**Parameters:**
- `$flags` - Array of cache flags
- `$expire` - Expire instead of delete
- `$add_prefix` - Add site/network prefix (multisite)

**Returns:** `bool` - Success status

**Example:**
```php
// Clear by flags
millicache_clear_cache_by_flags( [ 'home', 'archive:post' ] );

// With site prefix (multisite)
millicache_clear_cache_by_flags( [ 'home' ], false, true );
```

---

#### millicache_clear_cache_by_site_ids()

Clear cache by site IDs (multisite).

```php
millicache_clear_cache_by_site_ids(
    array $site_ids,
    int $network_id = null,
    bool $expire = false
): bool
```

**Parameters:**
- `$site_ids` - Array of site IDs
- `$network_id` - Specific network ID (optional)
- `$expire` - Expire instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
// Clear specific sites
millicache_clear_cache_by_site_ids( [ 1, 2, 3 ] );

// Clear sites in specific network
millicache_clear_cache_by_site_ids( [ 1, 2 ], 1 );
```

---

#### millicache_clear_cache_by_network_id()

Clear cache for entire network (multisite).

```php
millicache_clear_cache_by_network_id( int $network_id, bool $expire = false ): bool
```

**Parameters:**
- `$network_id` - Network ID to clear
- `$expire` - Expire instead of delete

**Returns:** `bool` - Success status

**Example:**
```php
millicache_clear_cache_by_network_id( 1 );
```

---

### Flag Management Functions

#### millicache_add_flag()

Add a flag to the current request.

```php
millicache_add_flag( string $flag ): void
```

**Parameters:**
- `$flag` - Flag to add

**Example:**
```php
// In theme or plugin
if ( is_product() ) {
    millicache_add_flag( 'woo:product' );
    millicache_add_flag( 'woo:product:' . get_the_ID() );
}
```

---

#### millicache_remove_flag()

Remove a flag from the current request.

```php
millicache_remove_flag( string $flag ): void
```

**Parameters:**
- `$flag` - Flag to remove

**Example:**
```php
// Remove home flag from the custom homepage
if ( is_front_page() && get_option( 'custom_homepage' ) ) {
    millicache_remove_flag( 'home' );
}
```

---

#### millicache_prefix_flags()

Add prefix to multiple flags.

```php
millicache_prefix_flags(
    array $flags,
    int $site_id = null,
    int $network_id = null
): array
```

**Parameters:**
- `$flags` - Array of flags to prefix
- `$site_id` - Site ID
- `$network_id` - Network ID

**Returns:** `array` - Prefixed flags

**Example:**
```php
$flags = [ 'post:123', 'home' ];
$prefixed = millicache_prefix_flags( $flags, 2 );
// Result: [ '2:post:123', '2:home' ]
```

---

### Cache Configuration Functions

#### millicache_set_ttl()

Override TTL for current request.

```php
millicache_set_ttl( int $ttl ): void
```

**Parameters:**
- `$ttl` - Time-to-live in seconds

**Example:**
```php
// Short TTL for dynamic page
if ( is_page( 'live-scores' ) ) {
    millicache_set_ttl( 60 );  // 1 minute
}
```

---

#### millicache_set_grace()

Override grace period for current request.

```php
millicache_set_grace( int $grace ): void
```

**Parameters:**
- `$grace` - Grace period in seconds

**Example:**
```php
// Long grace for important pages
if ( is_front_page() ) {
    millicache_set_grace( 86400 * 7 );  // 7 days
}
```

---

## REST API

All endpoints require authentication via nonce (`X-WP-Nonce` header).

### GET /wp-json/millicache/v1/status

Get plugin and cache status.

**Capability Required:** `manage_options`

**Parameters:**
- `network` (optional) - Set to "true" for network stats in multisite

**Response:**
```json
{
    "plugin_name": "millicache",
    "version": "1.0.0",
    "cache": {
        "entries": 142,
        "size": 2856432,
        "size_h": "2.7 MB"
    },
    "storage": {
        "connected": true,
        "version": "7.2.4",
        "memory": {
            "used": 12500000,
            "max": 268435456
        }
    },
    "dropin": {
        "status": "symlink",
        "outdated": false
    },
    "settings": {
        "has_defaults": false,
        "has_backup": true
    }
}
```

---

### POST /wp-json/millicache/v1/cache

Perform cache actions.

**Capability Required:** Filter `millicache_clear_cache_capability` (default: `publish_pages`)

**Actions:**

#### clear

Clear all cache.

```json
{
    "action": "clear",
    "is_network_admin": false
}
```

#### clear_current

Clear current page by flags.

```json
{
    "action": "clear_current",
    "request_flags": ["post:123", "home"]
}
```

#### clear_targets

Clear by mixed targets.

```json
{
    "action": "clear_targets",
    "targets": ["post:123", "https://example.com/page/"]
}
```

**Response:**
```json
{
    "success": true,
    "message": "The site cache has been cleared.",
    "action": "clear",
    "timestamp": 1699900000
}
```

---

### POST /wp-json/millicache/v1/settings

Perform settings actions.

**Capability Required:** `manage_options`

**Actions:**

#### reset

Reset settings to defaults.

```json
{
    "action": "reset"
}
```

#### restore

Restore from backup.

```json
{
    "action": "restore"
}
```

**Response:**
```json
{
    "success": true,
    "message": "Settings reset successfully.",
    "action": "reset",
    "timestamp": 1699900000
}
```

---

## Code Examples

### Complete Cache Integration

```php
<?php
/**
 * Custom cache integration for a membership site.
 */

// Add custom flags for membership content
add_filter( 'millicache_flags_for_request', function( $flags ) {
    if ( is_singular() && has_membership_content() ) {
        $flags[] = 'membership:content';
        $flags[] = 'membership:level:' . get_membership_level();
    }
    return $flags;
} );

// Clear membership content when the level changes
add_action( 'membership_level_changed', function( $user_id, $new_level ) {
    millicache_clear_cache_by_flags( [
        'membership:level:' . $new_level
    ] );
}, 10, 2 );

// Short TTL for dynamic membership pages. TTL, grace and cache-decision
// calls are honored at any point up to the final buffer flush — even from
// template code or shortcodes running after template_redirect.
add_action( 'template_redirect', function() {
    if ( is_page( 'member-dashboard' ) ) {
        millicache_set_ttl( 300 );  // 5 minutes
    }
} );

// Clear cache via REST API
add_action( 'rest_api_init', function() {
    register_rest_route( 'my-plugin/v1', '/clear-membership-cache', [
        'methods'  => 'POST',
        'callback' => function() {
            millicache_clear_cache_by_flags( [ 'membership:*' ] );
            return [ 'success' => true ];
        },
        'permission_callback' => function() {
            return current_user_can( 'manage_options' );
        },
    ] );
} );
```

### Bulk Operations

```php
<?php
/**
 * Bulk cache operations.
 */
function clear_category_cache( $category_id ) {
    $flags = [
        "archive:category:{$category_id}",
        'archive:post',
        'home',
    ];

    // Get posts in category
    $posts = get_posts( [
        'category'       => $category_id,
        'posts_per_page' => -1,
        'fields'         => 'ids',
    ] );

    foreach ( $posts as $post_id ) {
        $flags[] = "post:{$post_id}";
    }

    millicache_clear_cache_by_flags( array_unique( $flags ) );
}
```

## Next Steps

- [Hooks & Filters](/docs/millicache/07-developers/02-hooks-filters) - Extension points
- [Architecture](/docs/millicache/07-developers/01-architecture) - Internal structure
- [Rules Introduction](/docs/millicache/04-rules/01-introduction) - Rules engine

---

Canonical: https://www.millipress.com/docs/millicache/08-storage-backends/01-overview

---
title: 'Storage Backends: Redis, ValKey, KeyDB & Dragonfly'
description: 'Compare MilliCache storage backends: Redis, ValKey as a drop-in Redis replacement, KeyDB, and Dragonfly, with connection setup and managed hosting tips.'
menu_order: 10
---

# Storage Backends

MilliCache works with any Redis-compatible server. This guide covers supported backends, configuration, and recommendations.

## Supported Backends

| Backend | License | Key Feature | Best For |
|---------|---------|-------------|----------|
| **Redis** | RSALv2 | Most popular, proven | Most deployments |
| **ValKey** | BSD-3 | Open-source Redis fork | License-concerned users |
| **KeyDB** | BSD-3 | Multithreaded | High-throughput needs |
| **Dragonfly** | BSL | Memory efficient | Large cache requirements |

All backends use the same protocol and configuration. Your choice depends on licensing preferences, performance needs, and hosting availability.

## Quick Comparison

### Redis

The original in-memory data store. Most documentation, hosting support, and community resources.

- **Pros:** Battle-tested, extensive documentation, widest hosting support
- **Cons:** RSALv2 license may concern some users
- **Install:** [redis.io/docs/install](https://redis.io/docs/install/)

### ValKey

Linux Foundation fork of Redis, fully open-source under BSD-3 license.

- **Pros:** Fully open-source, active development, drop-in Redis replacement
- **Cons:** Newer project, less hosting support currently
- **Install:** [valkey.io/docs](https://valkey.io/docs/)

### KeyDB

Multithreaded Redis fork with improved performance on multi-core systems.

- **Pros:** Higher throughput, MVCC for better concurrency
- **Cons:** Less widespread than Redis
- **Install:** [docs.keydb.dev](https://docs.keydb.dev/)

### Dragonfly

Modern Redis alternative with significantly better memory efficiency.

- **Pros:** Uses less memory for same data, faster on large datasets
- **Cons:** Newer, Business Source License
- **Install:** [dragonflydb.io/docs](https://dragonflydb.io/docs/)

## MilliCache Configuration

Connection settings are the same for all backends.

### Basic Connection

```php
// wp-config.php
define( 'WP_CACHE', true );

define( 'MC_STORAGE_HOST', '127.0.0.1' );
define( 'MC_STORAGE_PORT', 6379 );
```

### With Authentication

```php
define( 'MC_STORAGE_HOST', 'redis.example.com' );
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_USERNAME', 'your-username' );
define( 'MC_STORAGE_PASSWORD', 'your-password' );
```

### Using Unix Socket

```php
define( 'MC_STORAGE_HOST', '/var/run/redis/redis.sock' );
define( 'MC_STORAGE_PORT', 0 );  // Ignored for sockets
```

### With TLS (e.g. AWS ElastiCache)

```php
define( 'MC_STORAGE_HOST', 'tls://master.example.cache.amazonaws.com' );
define( 'MC_STORAGE_PORT', 6379 );
```

### Multiple Sites on Same Server

Use different databases or prefixes:

```php
// Site A
define( 'MC_STORAGE_DB', 0 );
define( 'MC_STORAGE_PREFIX', 'sitea' );

// Site B
define( 'MC_STORAGE_DB', 1 );
define( 'MC_STORAGE_PREFIX', 'siteb' );
```

### All Connection Constants

| Constant                  | Default     | Description                                               |
|---------------------------|-------------|-----------------------------------------------------------|
| `MC_STORAGE_HOST`         | `127.0.0.1` | Hostname, IP, socket, `tls://host`, or a node array (see below) |
| `MC_STORAGE_PORT`         | `6379`      | TCP port (0 for sockets)                                  |
| `MC_STORAGE_USERNAME`     | `''`        | ACL username (default user if empty)                      |
| `MC_STORAGE_PASSWORD`     | `''`        | AUTH password                                             |
| `MC_STORAGE_DB`           | `0`         | Database number (0-15)                                    |
| `MC_STORAGE_PERSISTENT`   | `true`      | Use persistent connections                                |
| `MC_STORAGE_PREFIX`       | `mll`       | Key prefix                                                |
| `MC_STORAGE_TIMEOUT`      | `1.0`       | Connection timeout in seconds                             |
| `MC_STORAGE_READ_TIMEOUT` | `2.0`       | Read/write timeout in seconds                             |

### Timeouts

`MC_STORAGE_TIMEOUT` (default `1.0`) bounds connecting to the server, so an
unreachable backend falls back to uncached WordPress quickly instead of stalling
the request. `MC_STORAGE_READ_TIMEOUT` (default `2.0`) bounds waiting for a
command response; raise it only if you serve very large cached responses over a
slow link.

## High Availability: Replication & Sentinel

For read-scaling, a node-local replica, or automatic failover, set
`MC_STORAGE_HOST` to an array. A `master` key selects replication; a `service`
key selects Sentinel. These modes are configured in `wp-config.php` only; the
Settings screen shows a read-only notice when an array is active.

`MC_STORAGE_USERNAME`, `MC_STORAGE_PASSWORD`, and `MC_STORAGE_DB` apply to every
node. Each address is `host`, `host:port`, or `tls://host[:port]`.

### Replication (master + replicas)

```php
define( 'MC_STORAGE_HOST', array(
    'master'   => 'master.example.com',                     // writes
    'replicas' => array( '127.0.0.1', 'replica.tld:6380' ), // reads
) );

define( 'MC_STORAGE_PASSWORD', 'shared-secret' );  // applied to all nodes
```

`replicas` is optional and accepts a single address or a list. Replication is
asynchronous, so a just-written entry may briefly lag on a replica.

### Sentinel (managed failover)

```php
define( 'MC_STORAGE_HOST', array(
    'service'   => 'mymaster',
    'sentinels' => array( '10.0.0.1:26379', '10.0.0.2:26379' ),
) );
```

Sentinel discovers the master and re-resolves it on failover. Cluster mode
(sharding across multiple masters) is not supported; if your project requires
it, [reach out](https://www.millipress.com/contact/).

A misconfigured array (both `master` and `service`, Sentinel without
`sentinels`, or no recognized key) disables the cache and logs the reason rather
than connecting to the wrong server.

[MilliCache Pro](https://www.millipress.com/millicache-pro/) adds a
[visual connection editor](https://www.millipress.com/docs/millicache-pro/02-modules/10-storage-connections/)
for all three topologies, so replication and Sentinel can be set up from the
settings screen instead of a `MC_STORAGE_HOST` array in `wp-config.php`.

## Recommended Server Configuration

These settings are recommended for WordPress caching workloads:

```conf
# Memory allocation (adjust based on your needs)
maxmemory 256mb
maxmemory-policy allkeys-lru

# Persistence (optional - cache can be regenerated)
save ""
appendonly no

# Performance
tcp-keepalive 300
timeout 0

# Security (if exposed to network)
bind 127.0.0.1
requirepass your-strong-password
```

### Memory Sizing

| Site Size               | Recommended Memory  |
|-------------------------|---------------------|
| Small (< 100 pages)     | 64 MB               |
| Medium (100-1000 pages) | 128-256 MB          |
| Large (1000+ pages)     | 512 MB+             |

The `allkeys-lru` eviction policy automatically removes least-recently-used entries when memory is full.

### Applying Settings at Runtime

You don't have to edit the Redis configuration file to change these. The memory
limit and eviction policy can both be applied live with `CONFIG SET`, with no
restart:

```bash
redis-cli CONFIG SET maxmemory 512mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
```

To target the exact server MilliCache is configured to use (host, port,
database, and password) without looking those details up, open a session with
`wp millicache cli` and run the commands there:

```bash
wp millicache cli
127.0.0.1:6379> CONFIG SET maxmemory 512mb
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lru
127.0.0.1:6379> quit
```

`CONFIG SET` takes effect immediately but is lost on the next Redis restart. To
make the change permanent, either add the same directives to your Redis
configuration file, or run `CONFIG REWRITE` to write the running configuration
back to that file (this requires Redis to already be started from a config
file):

```bash
redis-cli CONFIG REWRITE
```

## Testing Your Connection

After configuration, verify the connection:

```bash
# Quick test
wp millicache test

# Check status
wp millicache status

# View stats
wp millicache stats
```

## Troubleshooting Connection Issues

### Connection Refused

```
Error: Connection refused
```

**Causes:**
- Server not running
- Wrong host/port
- Firewall blocking connection

**Solutions:**
```bash
# Check if Redis is running
redis-cli ping

# Check listening port
netstat -tlnp | grep 6379

# Test connection manually
redis-cli -h 127.0.0.1 -p 6379 ping
```

### Authentication Failed

```
Error: NOAUTH Authentication required
```

**Solution:** Set the authentication constants:
```php
define( 'MC_STORAGE_USERNAME', 'your-username' );     // omit if using default user
define( 'MC_STORAGE_PASSWORD', 'your-password' );
```

### Timeout

```
Error: Connection timed out
```

**Causes:**
- Server overloaded
- Network issues
- Firewall timeout

**Solutions:**
- Check server resources
- Verify network connectivity
- Check firewall rules
- If the backend is healthy but distant, raise `MC_STORAGE_TIMEOUT` (connection)
  or `MC_STORAGE_READ_TIMEOUT` (command response) above their `1.0`/`2.0` second defaults

## Cloud Provider Options

MilliCache works with managed Redis and Valkey services out of the box. Point
`MC_STORAGE_HOST` at the endpoint your provider gives you; most managed
services require TLS (use the `tls://` prefix) and password authentication.

| Provider | Service |
|----------|---------|
| AWS | ElastiCache (Redis OSS / Valkey) |
| Google Cloud | Memorystore |
| Azure | Azure Cache for Redis |
| DigitalOcean | Managed Caching (Valkey) |
| Upstash | Serverless Redis |

> [!NOTE]
> Managed services must run in **non-cluster mode**: a single primary endpoint,
> optionally with replicas. Cluster mode (sharding across multiple masters) is
> not supported. If your project requires cluster mode,
> [reach out](https://www.millipress.com/contact/) and tell us about your setup.

Because a managed endpoint is reached over the network rather than localhost,
also review the [timeout settings](#timeouts) if the service runs in a
different region than your web servers.

### AWS ElastiCache

ElastiCache offers both Redis OSS and Valkey engines; both work with
MilliCache. Create the cache with **cluster mode disabled** and connect to the
primary endpoint. With in-transit encryption enabled, prefix the host with
`tls://`:

```php
define( 'MC_STORAGE_HOST', 'tls://master.example.abc123.euc1.cache.amazonaws.com' );
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_PASSWORD', 'your-auth-token' );  // if AUTH is enabled
```

### Google Cloud Memorystore

Memorystore instances are reachable over private IP from within the same VPC,
so your WordPress servers must run in that network (Compute Engine, GKE, or
Cloud Run with a VPC connector):

```php
define( 'MC_STORAGE_HOST', '10.0.0.3' );  // instance IP
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_PASSWORD', 'your-auth-string' );  // if AUTH is enabled
```

### Azure Cache for Redis

Azure exposes caches at `*.redis.cache.windows.net` with TLS on port 6380. Use
an access key as the password:

```php
define( 'MC_STORAGE_HOST', 'tls://example.redis.cache.windows.net' );
define( 'MC_STORAGE_PORT', 6380 );
define( 'MC_STORAGE_PASSWORD', 'your-access-key' );
```

### DigitalOcean Managed Caching

DigitalOcean's managed caching service (formerly Managed Redis, now backed by
Valkey) requires TLS and provides host, port, username, and password in the
control panel's connection details:

```php
define( 'MC_STORAGE_HOST', 'tls://db-example-do-user-123456-0.db.ondigitalocean.com' );
define( 'MC_STORAGE_PORT', 25061 );
define( 'MC_STORAGE_USERNAME', 'default' );
define( 'MC_STORAGE_PASSWORD', 'your-password' );
```

### Upstash

Upstash offers serverless Redis with per-request pricing. Connections require
TLS:

```php
define( 'MC_STORAGE_HOST', 'tls://example-12345.upstash.io' );
define( 'MC_STORAGE_PORT', 6379 );
define( 'MC_STORAGE_PASSWORD', 'your-password' );
```

## Performance Tips

1. **Run locally when possible**: Same-machine Redis has lowest latency
2. **Use persistent connections**: Reduces connection overhead
3. **Enable compression**: `MC_CACHE_GZIP` reduces network transfer
4. **Size memory appropriately**: Avoid frequent evictions
5. **Monitor with `wp millicache stats`**: Track cache efficiency

Your storage server can do more than page caching: the [Object Cache module](https://www.millipress.com/docs/millicache-pro/02-modules/09-object-cache/) in [MilliCache Pro](https://www.millipress.com/millicache-pro/) ships a persistent object cache drop-in that reuses the same connection, speeding up wp-admin and uncached pages with zero extra setup.

## Next Steps

- [Configuration Reference](/docs/millicache/02-configuration/02-reference): All settings
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands): Command reference
- [Troubleshooting](/docs/millicache/09-troubleshooting/01-common-issues): Common issues

---

Canonical: https://www.millipress.com/docs/millicache/09-troubleshooting/01-common-issues

---
title: 'Troubleshooting'
description: 'Fix common MilliCache errors: Redis connection refused, pages not caching, drop-in conflicts, and stale content, using WP-CLI diagnostic commands.'
menu_order: 10
---

# Troubleshooting

This guide covers common issues and their solutions.

## Diagnostic Commands

Start troubleshooting with these commands:

```bash
# Check overall status
wp millicache status

# Test Redis connection
wp millicache test

# View cache statistics
wp millicache stats

# Check configuration sources
wp millicache config get --show-source
```

## Connection Issues

### Redis Connection Refused

**Symptoms:**
```
Error: Connection refused [tcp://127.0.0.1:6379]
```

**Solutions:**

1. **Check Redis is running:**
   ```bash
   redis-cli ping
   # Should return: PONG

   # Or check service status
   sudo systemctl status redis
   ```

2. **Verify Redis is listening:**
   ```bash
   netstat -tlnp | grep 6379
   # or
   ss -tlnp | grep 6379
   ```

3. **Check firewall rules:**
   ```bash
   sudo ufw status
   # Allow if needed
   sudo ufw allow 6379/tcp
   ```

4. **Verify configuration:**
   ```bash
   wp millicache config get storage
   ```

### Authentication Failed

**Symptoms:**
```
Error: NOAUTH Authentication required
```

**Solutions:**

1. **Verify credentials in MilliCache:**
   ```bash
   wp millicache config get storage.username
   wp millicache config get storage.enc_password
   ```

2. **Re-set credentials:**
   ```bash
   wp millicache config set storage.username "your-username"
   wp millicache config set storage.enc_password "your-password"
   ```

3. **Test Redis credentials directly:**
   ```bash
   # With a named user (Redis ACL)
   redis-cli -u "redis://your-username:your-password@127.0.0.1:6379" ping

   # With the default user (password only)
   redis-cli -a "your-password" ping
   ```

### Connection Timeout

**Symptoms:**
```
Error: Connection timed out
```

**Solutions:**

1. **Check network connectivity:**
   ```bash
   telnet redis-host 6379
   ```

2. **Verify DNS resolution:**
   ```bash
   host redis-host
   ```

3. **Check for network issues:**
   ```bash
   ping redis-host
   traceroute redis-host
   ```

4. **Increase PHP timeout (temporary):**
   ```php
   ini_set('default_socket_timeout', 10);
   ```

### Unix Socket Permission Denied

**Symptoms:**
```
Error: Permission denied [unix:///var/run/redis/redis.sock]
```

**Solutions:**

1. **Check socket exists:**
   ```bash
   ls -la /var/run/redis/redis.sock
   ```

2. **Add web server user to redis group:**
   ```bash
   sudo usermod -aG redis www-data
   sudo systemctl restart php-fpm
   ```

3. **Check Redis socket permissions:**
   ```conf
   # /etc/redis/redis.conf
   unixsocketperm 770
   ```

---

## Caching Issues

### Pages Not Being Cached

**Symptoms:**
- `X-MilliCache-Status: bypass`
- Cache entries count stays at 0
- Pages always show "miss"

**Diagnosis:**

1. **Enable debug mode:**
   ```php
   define( 'MC_CACHE_DEBUG', true );
   ```

2. **Check debug headers:**
   ```bash
   curl -I https://example.com/
   # Look for X-MilliCache-* headers
   ```

   > [!TIP]
   > The [MilliCache Browser Extension](https://github.com/MilliPress/millicache-browser-ext/) adds a dedicated panel to your browser's developer tools for easier debugging.

**Common causes:**

| Header Status  | Cause                 | Solution             |
|----------------|-----------------------|----------------------|
| No headers     | Drop-in not installed | `wp millicache drop` |
| `bypass`       | Rule triggered        | Check below          |
| `miss`         | First request         | Normal, will cache   |

3. **Check for bypass reasons:**

   - **Logged in?** Log out and test
   - **POST request?** Only GET/HEAD cached
   - **Excluded cookie?** Check `MC_CACHE_NOCACHE_COOKIES`
   - **Excluded path?** Check `MC_CACHE_NOCACHE_PATHS`
   - **TTL = 0?** Check `MC_CACHE_TTL`
   - **Oversized response?** Responses larger than 5MB (before compression) are never stored
   - **Redirect?** Responses with a 3xx status are never stored
   - **Plugin-level compression?** A `Content-Encoding` reason means another plugin compresses the page inside PHP (e.g. `ob_gzhandler`). Disable that plugin's compression — server-level compression (nginx/Apache) is unaffected and preferred

4. **Verify WP_CACHE:**
   ```bash
   wp config get WP_CACHE
   # Should be: true
   ```

### Output Buffering Conflicts

MilliCache captures pages in an output buffer that opens before any plugin
loads, so plugins that post-process HTML in their own buffer (translation
plugins, optimizers) are cached correctly. One situation needs attention:

- **Streaming endpoints** (Server-Sent Events, long-polling, large file
  downloads served on URLs without a file extension): these should never
  run through a page cache. Add them to `MC_CACHE_NOCACHE_PATHS` so the
  buffer is never opened for them.

> [!NOTE]
> With `display_errors` enabled, PHP notices printed during boot become part
> of the captured page. Keep `display_errors` off in production (WordPress'
> default) — notices belong in the error log.

### Cache Not Clearing

**Symptoms:**
- Old content still showing
- Updates not reflected

**Solutions:**

1. **Force clear all cache:**
   ```bash
   wp millicache clear
   ```

2. **Clear Redis directly:**
   ```bash
   redis-cli FLUSHDB
   ```

3. **Check for external cache (CDN, Varnish):**
   - Clear CDN cache separately
   - Check for upstream caching

4. **Verify clearing hooks working:**
   ```php
   add_action( 'millicache_cache_cleared', function() {
       error_log( 'Cache cleared successfully' );
   } );
   ```

### Stale Content After Updates

**Symptoms:**
- Updated content shows old version
- Takes time to refresh

**Diagnosis:**

1. **Check grace period behavior:**
   - Is content within grace period?
   - Grace serves stale during regen

2. **Verify post update triggers clearing:**
   ```php
   add_action( 'millicache_cache_cleared_by_posts', function( $ids ) {
       error_log( 'Cleared posts: ' . implode( ', ', $ids ) );
   } );
   ```

**Solutions:**

1. **Clear specific post:**
   ```bash
   wp millicache clear --id=123
   ```

2. **Delete instead of expire:**
   ```bash
   wp millicache clear --id=123
   # Without --expire flag = immediate delete
   ```

---

## Drop-in Issues

### advanced-cache.php Missing

**Symptoms:**
```
wp millicache status
# advanced_cache: missing
```

**Solution:**

```bash
wp millicache drop
```

### advanced-cache.php Outdated

**Symptoms:**
```
wp millicache status
# advanced_cache: outdated
```

**Solution:**

```bash
wp millicache drop --force
```

### Another Plugin's Drop-in

**Symptoms:**
```
Warning: Existing advanced-cache.php from another plugin detected.
```

**Solution:**

1. Deactivate other caching plugins
2. Delete old drop-in:
   ```bash
   rm wp-content/advanced-cache.php
   ```
3. Install MilliCache drop-in:
   ```bash
   wp millicache drop
   ```

### Symlink Not Working

**Symptoms:**
```
Notice: Symlinks not supported, using file copy.
```

**Cause:** File system or hosting doesn't support symlinks

**Impact:** None—file copy works identically

**If you want symlinks:**

1. Check file system supports symlinks
2. Verify permissions on `wp-content/`
3. Some hosts disable symlinks for security

---

## Performance Issues

### Slow Cache Hits

**Symptoms:**
- Cache hits taking >50ms
- Expected: 5-15ms

**Diagnosis:**

1. **Check network latency:**
   ```bash
   redis-cli --latency
   ```

2. **Check Redis performance:**
   ```bash
   redis-cli info stats | grep instantaneous_ops_per_sec
   ```

**Solutions:**

1. **Use Unix sockets:**
   ```php
   define( 'MC_STORAGE_HOST', '/var/run/redis/redis.sock' );
   ```

2. **Enable persistent connections:**
   ```php
   define( 'MC_STORAGE_PERSISTENT', true );
   ```

3. **Run Redis locally** instead of remote

### High Memory Usage

**Symptoms:**
- Redis using too much memory
- Keys being evicted

**Diagnosis:**

```bash
redis-cli info memory
wp millicache stats
```

**Solutions:**

1. **Increase Redis memory** (live, no restart):
   ```bash
   redis-cli CONFIG SET maxmemory 512mb
   ```
   Or run the same command against the configured server via `wp millicache cli`.
   `CONFIG SET` is lost on restart, so add `maxmemory 512mb` to your Redis config
   file (or run `redis-cli CONFIG REWRITE`) to keep it.

2. **Reduce TTL:**
   ```php
   define( 'MC_CACHE_TTL', 3600 );  // 1 hour
   ```

3. **Enable compression:**
   ```php
   define( 'MC_CACHE_GZIP', true );
   ```

4. **Reduce cache variations:**
   - Review `MC_CACHE_UNIQUE` settings
   - Add more items to ignore lists

### Cache Fragmentation

**Symptoms:**
- High used_memory_rss vs used_memory ratio
- Slow Redis operations

**Diagnosis:**

```bash
redis-cli info memory | grep fragmentation
```

**Solutions:**

1. **Restart Redis** (clears fragmentation)
2. **Use jemalloc** (better memory allocator)
3. **Schedule periodic restarts** (for high-write workloads)

---

## Multisite Issues

### Cache Not Isolated

**Symptoms:**
- Site A content appears on Site B
- Clearing Site A affects Site B

**Diagnosis:**

```bash
wp millicache stats --flag="*" --format=json
# Check flag prefixes
```

**Solutions:**

1. **Verify network activation:**
   - Plugin must be network-activated
   - Not per-site activated

2. **Check flag prefixes:**
   ```bash
   wp millicache stats --flag="1:*"  # Site 1
   wp millicache stats --flag="2:*"  # Site 2
   ```

### Network Clear Not Working

**Solutions:**

1. **Run from network admin context:**
   ```bash
   wp millicache clear --network=1
   ```

2. **Check capabilities:**
   - User needs `manage_network`

---

## Debug Techniques

### Enable All Logging

```php
// wp-config.php
define( 'WP_DEBUG', true );
define( 'WP_DEBUG_LOG', true );
define( 'MC_CACHE_DEBUG', true );
```

### Check PHP Error Log

```bash
tail -f /var/log/php/error.log
```

### Monitor Redis Commands

```bash
redis-cli monitor
```

### Test Request Flow

```bash
# With headers
curl -v https://example.com/ 2>&1 | grep -i millicache

# Multiple requests to test caching
for i in {1..3}; do
  curl -s -o /dev/null -w "Request $i: %{http_code} in %{time_total}s\n" https://example.com/
done
```

### Check Cache Entry

```bash
# Open Redis CLI
wp millicache cli

# Find cache keys
KEYS mll:*

# Get specific entry
GET mll:cache:abc123
```

## Getting Help

If issues persist:

1. **Gather diagnostics:**
   ```bash
   wp millicache status --format=json > status.json
   wp millicache config get --format=json > config.json
   wp millicache stats --format=json > stats.json
   ```

2. **Check PHP/WordPress versions**

3. **Report issues:** [GitHub Issues](https://github.com/millipress/millicache/issues)

## Next Steps

- [FAQ](/docs/millicache/09-troubleshooting/02-faq) - Common questions
- [Storage Backends](/docs/millicache/08-storage-backends/01-overview) - Configuration and optimization
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) - Command reference

---

Canonical: https://www.millipress.com/docs/millicache/09-troubleshooting/02-faq

---
title: 'Frequently Asked Questions'
description: 'Answers to frequent MilliCache questions on WordPress caching: requirements, verifying cache hits, WooCommerce, troubleshooting errors, and Pro features.'
menu_order: 20
---

# FAQ

Frequently asked questions about MilliCache.

## General Questions

### What is MilliCache?

MilliCache is a full-page caching plugin for WordPress that stores complete HTML pages in Redis (or compatible stores like ValKey, KeyDB, or Dragonfly). 
When a visitor requests a cached page, MilliCache serves it directly from memory without loading WordPress, resulting in sub-10ms response times.

### How is MilliCache different from other caching plugins?

| Feature                 | MilliCache            | Other Plugins    |
|-------------------------|-----------------------|------------------|
| Storage                 | Redis/ValKey/KeyDB    | Files/Database   |
| Speed                   | Sub-10ms              | 50-200ms         |
| Rules Engine            | MilliRules (powerful) | Basic conditions |
| Flag-based Invalidation | Yes                   | Limited          |
| Multisite               | Full support          | Varies           |
| License                 | GPL-2.0+              | Varies           |

### Does MilliCache require Redis?

Yes, MilliCache requires a Redis-compatible server. Supported options:
- Redis (original)
- ValKey (open-source fork)
- KeyDB (multithreaded)
- Dragonfly (high-performance)

### Is MilliCache free?

Yes, MilliCache is free and open-source under GPL-2.0+ license.

### Is there a Pro version?

Yes. [MilliCache Pro](https://www.millipress.com/millicache-pro/) adds premium [modules](https://www.millipress.com/docs/millicache-pro/02-modules/01-overview/) on top of the free plugin: a cache entries browser, a visual rules builder, block editor cache intelligence, preloading, detailed metrics, edge caching, an asset CDN, and a persistent object cache. It bundles MilliCache, so it replaces the free plugin rather than running alongside it.

---

## Installation & Setup

### What are the requirements?

- PHP 7.4 or higher
- WordPress 5.6 or higher
- Redis, ValKey, KeyDB, or Dragonfly server

### How do I know if caching is working?

1. Enable debug mode:
   ```php
   define( 'MC_CACHE_DEBUG', true );
   ```

2. Visit a page (logged out)

3. Check response headers:
   - First visit: `X-MilliCache-Status: miss`
   - Second visit: `X-MilliCache-Status: hit`

### Do I need to configure anything after installation?

Basic setup requires:

1. Add to `wp-config.php`:
   ```php
   define( 'WP_CACHE', true );
   ```

2. Install the drop-in:
   ```bash
   wp millicache drop
   ```

If Redis runs on default settings (localhost:6379), no additional configuration is needed.

---

## Caching Behavior

### What gets cached?

**Cached:**
- GET and HEAD requests
- 200 OK responses
- Anonymous visitors (logged-out)
- Pages, posts, archives, taxonomies
- RSS feeds (by default)

**Not Cached:**
- POST, PUT, DELETE requests
- Logged-in users
- Non-200 responses
- AJAX, REST API, XML-RPC, Cron
- WP-CLI commands

### Why aren't logged-in users cached?

Logged-in users see personalized content (admin bar, user-specific data). Caching would show incorrect content. This is a security and UX best practice.

### Can I cache logged-in users?

It's not recommended. If you need it:

1. Remove the logged-in rule via filter
2. Add user-specific flags
3. Ensure no sensitive data leaks

> [!WARNING]
> Caching logged-in users can expose private data. Only do this if you fully understand the implications.

### What is the grace period?

The grace period allows stale cache to be served while fresh content generates in the background. 
This prevents visitors from waiting for page generation after cache expires.

Example with TTL=1 day, Grace=30 days:
- Day 1: Fresh cache served
- Day 2: Cache expired, but grace serves stale while regenerating
- Day 31: Grace expired, visitor waits for fresh content

### How does cache invalidation work?

MilliCache uses **flags** (tags) for targeted invalidation:

1. Each page is tagged with flags (e.g., `post:123`, `home`, `archive:post`)
2. When content changes, related flags are identified
3. Only pages with matching flags are cleared

This means updating one post doesn't clear the entire cache—only affected pages.

---

## Compatibility

### Does MilliCache work with WooCommerce?

Yes, and the critical pages are safe out of the box: WooCommerce sets the
`DONOTCACHEPAGE` constant on the cart, checkout, and my-account pages, and a
built-in MilliCache rule respects it. You do not need path exclusions for a
standard setup.

What you should configure is cookie handling. WooCommerce sets several cookies
for ordinary browsing visitors, and any cookie MilliCache does not ignore becomes
part of the cache key, which fragments the cache into per-visitor variants:

- `sbjs_*`: Order Attribution tracking (Sourcebuster), set for every visitor
- `woocommerce_recently_viewed`: set as soon as a visitor views a product
- `woocommerce_cart_hash`, `woocommerce_items_in_cart`, `wp_woocommerce_session_*`: set once something is in the cart
- `store_notice*`: set when a visitor dismisses a store notice

Ignore all of them:

```php
define( 'MC_CACHE_IGNORE_COOKIES', [
    '_*',                        // Keep the default (analytics cookies)
    'sbjs_*',
    'woocommerce_*',
    'wp_woocommerce_session_*',
    'store_notice*',
] );
```

Do **not** put `woocommerce_*` or `sbjs_*` into `MC_CACHE_NOCACHE_COOKIES`.
`woocommerce_recently_viewed` matches that pattern and is set the moment anyone
views a product, so a blanket bypass silently turns most of your browsing traffic
into cache misses. Bypassing is also unnecessary: themes add products to the cart
via AJAX and refresh the mini cart client-side (cart fragments in classic themes,
the Store API in the Mini-Cart block), so shop and product pages stay correct even
for visitors with items in their cart.

Two exceptions:

- If your theme renders cart contents into the page server-side without any AJAX
  refresh (rare in modern themes), bypass for visitors with a cart instead of
  ignoring: add `woocommerce_items_in_cart` to `MC_CACHE_NOCACHE_COOKIES`.
- If you use custom dynamic pages WooCommerce does not know about (a custom order
  tracking page, for example), exclude them by path:

  ```php
  define( 'MC_CACHE_NOCACHE_PATHS', [ '/order-tracking/*' ] );
  ```

To verify the result, watch the hit ratio on the Status tab after deploying the
configuration. [MilliCache Pro](https://www.millipress.com/millicache-pro/) makes
this much easier to inspect: the
[Cache Entries Browser](https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries/)
lists every cached page with its variants, so you can see exactly what gets cached
and spot cookie-driven fragmentation, and
[Detailed Metrics](https://www.millipress.com/docs/millicache-pro/02-modules/06-detailed-metrics/)
charts the hit ratio over time.

### Does MilliCache work with membership plugins?

Yes. Common configurations:

```php
// MemberPress
define( 'MC_CACHE_NOCACHE_COOKIES', [
    'wp-*pass*',
    'memberpress_*',
    'mepr_*',
] );

// Restrict Content Pro
define( 'MC_CACHE_NOCACHE_COOKIES', [
    'wp-*pass*',
    'rcp_*',
] );
```

### Can I use MilliCache with a CDN?

Yes! MilliCache caches at the origin server. CDN caches on edge servers. They work together:

1. CDN checks its cache
2. CDN miss → Request reaches origin
3. MilliCache serves from Redis (fast!)
4. CDN caches the response

For cache clearing, you may need to:
- Clear MilliCache (origin)
- Clear CDN (edge)

[MilliCache Pro](https://www.millipress.com/millicache-pro/)'s [Edge Cache module](https://www.millipress.com/docs/millicache-pro/02-modules/07-edge-cache/) automates exactly this: pages are tagged at the CDN with the same flags they carry locally, and every local purge triggers the matching edge purge.

### Does MilliCache work with Cloudflare/other proxies?

Yes. MilliCache operates at the origin level, before any proxy/CDN.

### Does MilliCache conflict with other caching plugins?

Yes, you should only use one full-page caching plugin. Deactivate others before using MilliCache:

- WP Super Cache
- W3 Total Cache
- WP Fastest Cache
- LiteSpeed Cache
- etc.

Object caching plugins (Redis Object Cache) are fine — they cache different things.

---

## Multisite

### Does MilliCache support multisite?

Yes, fully. Features include:

- Per-site cache isolation
- Multi-network support
- Site and network-level clearing
- Per-site configuration options

### How do I clear cache for one site only?

```bash
# By site URL
wp millicache clear --url=site1.example.com

# By site ID
wp millicache clear --site=2
```

### How do I clear cache for all sites?

```bash
# All sites in network
wp millicache clear --network=1

# All cache (all networks)
wp millicache clear
```

---

## Performance

### How fast is MilliCache?

| Scenario               | Typical Response Time  |
|------------------------|------------------------|
| Cache hit              | 5-15ms                 |
| Cache miss (WordPress) | 200-2000ms             |

That's 10-200x faster than uncached WordPress.

### How much memory does MilliCache use?

Memory usage depends on:
- Number of cached pages
- Average page size
- Compression enabled

Typical usage with 1000 pages:
- Without compression: ~50-100MB
- With compression: ~15-30MB

### Should I enable compression?

Yes, for most sites:

```php
define( 'MC_CACHE_GZIP', true );
```

Benefits:
- 60-80% smaller cache entries
- Lower memory usage
- Faster cache retrieval

Requires `ext-zlib` PHP extension.

---

## WP-CLI

### What commands are available?

| Command                | Description     |
|------------------------|-----------------|
| `wp millicache clear`  | Clear cache     |
| `wp millicache stats`  | View statistics |
| `wp millicache status` | Check status    |
| `wp millicache test`   | Test connection |
| `wp millicache drop`   | Fix drop-in     |
| `wp millicache cli`    | Open Redis CLI  |
| `wp millicache config` | Manage settings |

### How do I clear cache via cron?

```bash
# Add to crontab
0 3 * * * cd /path/to/wordpress && wp millicache clear --expire
```

The `--expire` flag serves stale content while regenerating (gentler).

---

## Security

### Does MilliCache cache sensitive data?

MilliCache does not cache:
- Logged-in users
- POST requests
- Pages with excluded cookies

This prevents accidental caching of sensitive data.

### Is the Redis connection secure?

For security:
1. Use authentication: `MC_STORAGE_USERNAME` and `MC_STORAGE_PASSWORD`
2. Bind Redis to localhost if on same server
3. Use private networks for remote Redis
4. Consider Redis TLS for sensitive environments

### Does MilliCache modify WordPress core?

No. MilliCache uses WordPress's official drop-in caching API (`advanced-cache.php`). It doesn't modify core files.

---

## Troubleshooting

### Why isn't my page being cached?

Check debug headers (`MC_CACHE_DEBUG = true`):

| Status     | Meaning                   |
|------------|---------------------------|
| No headers | Drop-in not installed     |
| `bypass`   | Rule prevented caching    |
| `miss`     | First request, will cache |
| `hit`      | Cached and serving        |

Common bypass reasons:
- Logged in
- Excluded cookie
- Excluded path
- POST request
- Non-200 response
- Response larger than 5MB

### Why is cache not clearing?

1. Clear manually: `wp millicache clear`
2. Check Redis connection: `wp millicache test`
3. Verify clearing hooks: Check `millicache_cache_cleared` action
4. Clear external caches (CDN, proxy)

### Where can I get help?

- [Troubleshooting Guide](/docs/millicache/09-troubleshooting/01-common-issues)
- [GitHub Issues](https://github.com/millipress/millicache/issues)
- [Documentation](/docs/millicache/01-getting-started/10-introduction)

## Next Steps

- [Troubleshooting](/docs/millicache/09-troubleshooting/01-common-issues) - Detailed problem solving
- [Installation & Quick Start](/docs/millicache/01-getting-started/20-installation) - Get started fast
- [WP-CLI Commands](/docs/millicache/06-wp-cli/01-commands) - Command reference

# MilliCache Pro

---

Canonical: https://www.millipress.com/docs/millicache-pro/01-getting-started/10-introduction

---
title: 'What Is MilliCache Pro?'
description: 'MilliCache Pro extends the MilliCache full-page cache for WordPress with premium modules: edge caching, asset CDN, object cache, preloading, rules, and metrics.'
menu_order: 10
---

# Introduction

MilliCache Pro is the premium extension of [MilliCache](https://www.millipress.com/docs/millicache/), the most flexible full-page cache for scaling WordPress sites. It builds on everything MilliCache does (in-memory page caching, flag-based invalidation, the rules engine) and adds the tooling and infrastructure features that production sites grow into.

## What Pro Adds

Pro is organized into [modules](/docs/millicache-pro/02-modules/01-overview) you enable individually:

### See and Control Your Cache

- **[Cache Entries Browser](/docs/millicache-pro/02-modules/02-cache-entries)**: every cached page in a searchable table, with flags, variants, size, and expiry, and per-entry deletion.
- **[Visual Rules Builder](/docs/millicache-pro/02-modules/03-rules-builder)**: build caching rules in the UI instead of PHP. Match by URL, cookie, query, or WordPress conditionals; set TTLs; bypass; override built-in rules.
- **[Detailed Metrics](/docs/millicache-pro/02-modules/06-detailed-metrics)**: requests, bandwidth, response times, and visitor time saved, charted on the Status dashboard with hourly and daily history.

### Keep It Fast Automatically

- **[Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading)**: pages are rewarmed after publishing and refilled from your XML sitemap after a full clear, so visitors never wait for a page to be built.
- **[Block Editor Intelligence](/docs/millicache-pro/02-modules/04-block-editor)**: editing a synced pattern, a Query Loop source, or a Site Editor template clears exactly the affected pages instead of going stale or flushing everything.

### Scale Beyond One Server

- **[Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache)**: serve cached pages from bunny.net or Cloudflare edge locations, tagged and purged together with the local cache.
- **[Asset CDN](/docs/millicache-pro/02-modules/08-cdn)**: serve static files from a pull zone, with rewritten URLs baked into the page cache at zero runtime cost.
- **[Object Cache](/docs/millicache-pro/02-modules/09-object-cache)**: a persistent WordPress object cache drop-in on the same storage connection, speeding up wp-admin and uncached pages.
- **[Storage Connections](/docs/millicache-pro/02-modules/10-storage-connections)**: configure Redis replication and Sentinel from the UI.

Everything ships with matching [WP-CLI commands](/docs/millicache-pro/03-wp-cli/01-commands).

## How Pro Relates to MilliCache

MilliCache Pro **includes** the MilliCache engine: MilliCache Pro is the only plugin you install, giving you the full page cache plus the Pro modules. If the MilliCache plugin is active when you activate Pro, it is deactivated automatically and the bundled engine takes over with your settings and rules intact; see [upgrading from MilliCache](/docs/millicache-pro/01-getting-started/20-installation#already-running-millicache).

Without an active license, everything MilliCache itself provides keeps working; the Pro modules stay locked until you [activate a license](/docs/millicache-pro/01-getting-started/30-licensing).

## Requirements

| Component  | Requirement                                 |
|------------|---------------------------------------------|
| PHP        | 7.4 or higher                               |
| WordPress  | 6.6 or higher                               |
| Storage    | Redis, ValKey, or another compatible server (see [Storage Backends](https://www.millipress.com/docs/millicache/08-storage-backends/01-overview/)) |
| License    | A MilliCache Pro license key for the premium modules |

## Next Steps

- [Installation](/docs/millicache-pro/01-getting-started/20-installation): install and activate MilliCache Pro
- [Licensing](/docs/millicache-pro/01-getting-started/30-licensing): activate your license key
- [Modules Overview](/docs/millicache-pro/02-modules/01-overview): enable the features you need

---

Canonical: https://www.millipress.com/docs/millicache-pro/01-getting-started/20-installation

---
title: 'Installing MilliCache Pro'
description: 'Install MilliCache Pro on WordPress via ZIP upload or Composer, upgrade from MilliCache without migration, and manage updates, prereleases, and multisite.'
menu_order: 20
---

# Installation

MilliCache Pro installs like any WordPress plugin and brings everything it needs with it: the MilliCache engine, the settings framework, and the background task runner are all bundled in the release package.

## Requirements

| Component  | Requirement                                 |
|------------|---------------------------------------------|
| PHP        | 7.4 or higher                               |
| WordPress  | 6.6 or higher                               |
| Storage    | Redis, ValKey, Dragonfly, or KeyDB server   |

## Installation Methods

### Method 1: ZIP Upload

1. Download the latest `millicache-pro.zip` from your [customer dashboard](https://www.millipress.com/account).
2. Upload it via **Plugins → Add New → Upload Plugin**.
3. Activate the plugin. On multisite, **network-activate** it from Network Admin → Plugins.

### Method 2: Composer

For Composer-managed WordPress installs, MilliCache Pro is available from the MilliPress Composer repository. Your license key is the credential:

```bash
composer config repositories.millipress composer https://www.millipress.com/api/composer
composer config --global --auth http-basic.millipress.com token YOUR-LICENSE-KEY
composer require millipress/millicache-pro
```

The package installs as a `wordpress-plugin` (via [composer/installers](https://github.com/composer/installers)) into `wp-content/plugins/millicache-pro/` and is byte-identical to the release ZIP, with all dependencies prebundled. Your customer dashboard shows these commands prefilled with your key.

> [!TIP]
> Deploying via CI or a managed host (Forge, Ploi, GitHub Actions)? Drop `--global` and set the `COMPOSER_AUTH` environment variable on the build server instead, so the key never lands in the repository.

After installing with either method, activate the plugin (network-activate on multisite). On activation, MilliCache sets up its `advanced-cache.php` drop-in, exactly as it does on its own.

### Already Running MilliCache?

Nothing to migrate. When you activate MilliCache Pro, the standalone MilliCache plugin is deactivated automatically and the bundled engine takes over: your settings and rules carry over as they are, and only the cache itself starts cold. Afterwards you can delete the standalone MilliCache plugin.

## Set Up Caching

If this is a fresh install, complete MilliCache's two-step setup, as covered in the [MilliCache installation guide](https://www.millipress.com/docs/millicache/01-getting-started/20-installation/):

1. Add `define( 'WP_CACHE', true );` to `wp-config.php`.
2. Point MilliCache at your storage server (defaults to `127.0.0.1:6379`) via **Settings → MilliCache** or `wp-config.php` constants.

## Activate Your License

Go to **Settings → MilliCache → Settings → License**, paste the key from your purchase email, and click **Activate**. See [Licensing](/docs/millicache-pro/01-getting-started/30-licensing) for multisite setups, seat limits, and site migrations.

## Verify

- **Settings → MilliCache** now shows the **Modules**, **Entries**, and **Rules** tabs (these appear as soon as Pro is installed).
- The License section shows an **Active** badge.
- The **Status** tab reports "Your MilliCache Pro license is active. All premium features are available."

Then head to the [Modules tab](/docs/millicache-pro/02-modules/01-overview) and switch on what you need; all modules start out off (except the Block Editor section, which is ready as soon as you are licensed).

## Updates

Updates arrive through the normal WordPress update flow: the Plugins page shows "Update available" with version details, and updating works like any other plugin. Update checks are tied to your license, so keep it active to receive new releases.

On Composer-managed installs, update through Composer instead:

```bash
composer update millipress/millicache-pro
```

The repository always serves the latest release, authenticated with the same license key.

> [!NOTE]
> If updates stop appearing, check the License section first: a missing or inactive license is the usual cause, and that is where problems are surfaced.

### Prerelease (Beta) Updates

By default you only receive stable releases. Prereleases (betas and release candidates published ahead of the stable cut) are opt-in, and how you opt in depends on how the plugin is installed.

#### WordPress Updates

Add this to `wp-config.php`:

```php
define( 'MC_UPDATE_PRERELEASE', true );
```

The site then follows the prerelease channel: WordPress offers the newest release including prereleases, and downloads that build. Remove the constant (or set it to `false`) to return to stable, and the next update check switches the channel back. This is a per-site opt-in and does not affect other installs using the same license.

#### Composer

`MC_UPDATE_PRERELEASE` has no effect on Composer installs, because Composer never loads WordPress. Composer decides for itself using package stability, and by default it installs stable releases only. `composer require millipress/millicache-pro` and `composer update` will not pick up a beta.

To opt in, add the `@beta` stability flag to the constraint:

```bash
composer require "millipress/millicache-pro:^1.4@beta"
```

The flag applies to this package alone and leaves the rest of your project on stable. Return to stable by dropping the flag (`composer require "millipress/millicache-pro:^1.4"`).

> [!IMPORTANT]
> If your root `composer.json` lowers `minimum-stability` (many WordPress project templates set it to `dev`), also set `"prefer-stable": true`. Without it, Composer takes the newest version at any stability and can pull a beta into production without you asking, for this and every other dependency.

## Multisite

MilliCache Pro is network-activated and multisite-aware throughout:

- Site-scoped modules (Block Editor, Preloading) are configured on each site.
- Install-wide modules (Edge Cache, Asset CDN, Object Cache, Detailed Metrics) are configured in the Network Admin.
- Licensing supports one network-wide key or per-site keys; see [Licensing](/docs/millicache-pro/01-getting-started/30-licensing).

See the [MilliCache multisite guide](https://www.millipress.com/docs/millicache/05-usage/30-multisite/) for how the underlying cache isolates sites.

## Next Steps

- [Licensing](/docs/millicache-pro/01-getting-started/30-licensing): activate, migrate, and manage seats
- [Modules Overview](/docs/millicache-pro/02-modules/01-overview): what to enable first
- [WP-CLI Commands](/docs/millicache-pro/03-wp-cli/01-commands): manage everything from the terminal

---

Canonical: https://www.millipress.com/docs/millicache-pro/01-getting-started/30-licensing

---
title: 'Licensing'
description: 'Activate and manage your MilliCache Pro license: seats and grace states, wp-config.php constants, site migrations, and multisite network or per-site keys.'
menu_order: 30
---

# Licensing

A MilliCache Pro license unlocks the premium modules and plugin updates. Page caching itself never depends on the license: an expired or missing license leaves everything MilliCache itself provides fully intact.

## Activating a License

1. Go to **Settings → MilliCache → Settings → License**.
2. Paste the key from your purchase email into the **License key** field.
3. Click **Activate**.

The section badge switches to **Active** and every Pro module unlocks. Lost your key? It is always available in your [customer dashboard](https://www.millipress.com/account).

Once active, the key field is locked and displayed masked (first and last three characters only). The key is stored encrypted in your database and is never exposed in full through the settings screen or the REST API.

### Supplying the Key via wp-config.php

The key can be defined as a constant instead of saving it in the database:

```php
define( 'MC_LICENSE_KEY', '00000000-1111-2222-3333-444444444444' );
```

The constant overrides any saved key and locks the key field in the admin UI. No manual **Activate** click is needed: the next license check registers the installation automatically. On Multisite, a constant key acts as the network license for the whole install, so subsites ride it just like a key entered via **Network Activate**.

## Seats and License States

Your plan includes a number of **seats** (sites). Activations are checked against millipress.com, and the Status tab always tells you where you stand:

| State                   | Meaning                                                                                                              |
|-------------------------|----------------------------------------------------------------------------------------------------------------------|
| **Active**              | Everything is fine; all premium features are available.                                                              |
| **Grace**               | You are over your seat limit, but premium features keep working for a grace period. Review your plan before it ends. |
| **Over capacity**       | The grace period has ended; premium features are disabled until you free a seat or upgrade.                          |
| **Revoked / not found** | Contact support or check the key; page caching is unaffected.                                                        |

Going over the limit never breaks anything abruptly: you always pass through the grace state first.

> [!NOTE]
> License checks are cached and tolerate outages: if millipress.com is temporarily unreachable, your last-known-good license keeps premium features working for up to 72 hours while MilliCache retries in the background.

## Moving to a New Site or Domain

Before migrating, click **Deactivate** in the License section. This frees the activation slot upstream, so you can re-activate the same key on the new site or domain.

If you clone a site (staging copies, migrations that duplicate the database), the clone carries the original's install identity. Give the clone its own identity with:

```bash
wp millicache license install-id reset
```

## Multisite Networks

On multisite, licensing is flexible per site:

- **Network key (default):** activate one key in the **Network Admin** License section via **Network Activate**. Every active subsite uses it and counts toward your seat limit.
- **Own key:** a subsite can activate its own separate license key instead. Its License section is visible to super admins.
- **Opted out:** a subsite can be excluded from the network license entirely, running without the Pro features.

**Network Deactivate** frees the seats of every subsite using the network key and clears the saved key; subsites with their own license are unaffected.

## WP-CLI

| Command                                                   | Description                                                                            |
|-----------------------------------------------------------|----------------------------------------------------------------------------------------|
| `wp millicache license status`                            | Show state, seats, and the masked key                                                  |
| `wp millicache license activate --key=<key>`              | Save and activate a license key                                                        |
| `wp millicache license deactivate`                        | Deactivate and free this install's seats                                               |
| `wp millicache license subsite-state <blog_id> [<state>]` | Show or set a subsite's license mode: `network`, `own_key`, or `opted_out` (multisite) |
| `wp millicache license install-id [show\|reset]`          | Show or reset the install identity (reset after cloning)                               |

**Options:**

- `status` supports `--format=table|json|csv|yaml` and, on multisite, `--network` to list every subsite's license mode plus a network summary

**Examples:**

```bash
# Activate
wp millicache license activate --key=00000000-1111-2222-3333-444444444444

# Check the whole network at a glance
wp millicache license status --network

# Let one subsite use its own key
wp millicache license subsite-state 3 own_key
```

## Good to Know

- **Settings resets keep your license.** A full settings reset restores caching defaults but preserves the license key (along with storage and Edge Cache credentials), so you never have to dig out the key again.
- **Updates are license-gated.** Plugin updates arrive via the normal WordPress update flow as long as the license is active; see [Installation](/docs/millicache-pro/01-getting-started/20-installation#updates).
- **Try prereleases on staging.** Add `define( 'MC_UPDATE_PRERELEASE', true );` to `wp-config.php` to follow the prerelease channel, and the update flow offers the newest build including prereleases. Leave it out (the default) to track stable releases only. Best kept to a staging site.
- **Your key doubles as the Composer credential.** [Installing via Composer](/docs/millicache-pro/01-getting-started/20-installation#method-2-composer) authenticates against the MilliPress package repository with the license key as the HTTP Basic password; downloads require an active license.
- **Support snapshots are safe to share.** The Status tab's support snapshot includes the license state and seat usage but never the key itself.

## Next Steps

- [Modules Overview](/docs/millicache-pro/02-modules/01-overview): what your license unlocks
- [Installation](/docs/millicache-pro/01-getting-started/20-installation): if you have not installed yet

---

Canonical: https://www.millipress.com/docs/millicache-pro/01-getting-started/40-changelog

---
title: 'MilliCache Pro Changelog'
description: 'Version-by-version MilliCache Pro release notes: new features, bug fixes, and improvements, including changes in each bundled MilliCache engine update.'
menu_order: 40
---

# Changelog

## [1.4.1](https://github.com/MilliPress/MilliCache-Pro/compare/v1.4.0...v1.4.1) (2026-08-29)

<!-- mc:auto sha=ca34f663266a -->
On single-site installs, "Flush object cache" and "Purge edge cache" in the command palette now work correctly — they were posting to a multisite-only REST route and returning "No route was found".

The bundled MilliCache update (1.8.0 → 1.8.1) also fixes a subtle caching edge case: query parameters stripped for caching purposes (such as `gclid` or `utm_*` via `MC_CACHE_IGNORE_REQUEST_KEYS`) are now preserved in the live request until the page renders, so redirects from WordPress, WooCommerce, or multilingual plugins like Polylang correctly carry those parameters through to the target URL.
<!-- /mc:auto -->

### Features

* **deps:** update MilliCache to 1.8.1 ([#54](https://github.com/MilliPress/MilliCache-Pro/issues/54)) ([83bac72](https://github.com/MilliPress/MilliCache-Pro/commit/83bac72d776288b6b5ba9fedde9e8b6713a66ea5))


### Bug Fixes

* **commands:** cache actions from the command palette on single-site installs ([20ff644](https://github.com/MilliPress/MilliCache-Pro/commit/20ff6449e54bd0ff4b71b81117287d3a6d383f7e))


### CI

* **deps:** derive the bundle update commit type from the MilliCache bump ([5862843](https://github.com/MilliPress/MilliCache-Pro/commit/5862843aee1aec1d6a7f4e5eaf97d488294291b4))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.8.1

* **engine:** keep ignored query keys in the request until rendering ([f6f7d33](https://github.com/MilliPress/MilliCache/commit/f6f7d3350f1f66216462292f9390a160ff42f3b8))
<!-- /mc:auto-bundle -->

## [1.4.0](https://github.com/MilliPress/MilliCache-Pro/compare/v1.3.0...v1.4.0) (2026-08-22)

1.4.0 puts every part of MilliCache Pro at your assistant's fingertips and makes edge purging immediate.

**Everything MilliCache Pro does is now available as abilities.** Caching, cache entries, caching rules (including the ones defined in code), preloading, the edge cache, the object cache, and cache performance can all be inspected and managed through the WordPress Abilities API, over REST and from MCP clients. Together with the abilities that ship in MilliCache itself, an AI assistant can answer "why is my page not cached", clear exactly the right things, and check how well the cache is working.

**Edge purges are immediate.** Integrations can fire the `millicache_edge_purge` action with post IDs, flags, paths, or URLs, and the edge is purged before the call returns; media in a separate pull zone included. Queued purges and preloads start within seconds of a content change instead of waiting for the next cron run, and a crashed background worker can no longer hold up pending purges. bunny.net purges get the time they actually need and fail fast when the CDN is unreachable.

**Pro actions in the command palette.** Purge the edge cache, flush the object cache, and jump straight to the Entries and Rules tabs from the WordPress command palette. The edge purge names your provider, so you always know which CDN you are clearing.

**The Entries tab does more.** Reload or expire cached pages straight from the Entries browser, watch preload progress appear instantly, and get told when a preload queue is not being drained.

<!-- mc:auto sha=e9d4cec6bafa -->
1.4.0 brings the command palette from MilliCache 1.8.0 into Pro, extending it with edge cache and object cache actions alongside the standard clear and expire targets. Two bunny.net reliability fixes ship as well: URL purges no longer send the deprecated `async` parameter, and the request timeout has been raised to cover the ~7 seconds a real-world purge takes — previously, valid purges were cut off and reported as failures.
<!-- /mc:auto -->

### Features

* **abilities:** expose module operations via the WordPress Abilities API ([327fb6e](https://github.com/MilliPress/MilliCache-Pro/commit/327fb6e542894dfb03bf83532cdaa72e12a81ac9))
* **abilities:** expose the caching rules, the ones from code included ([5c1a43f](https://github.com/MilliPress/MilliCache-Pro/commit/5c1a43fd5a10c12a48a9b51f72bf43d600d18235))
* **abilities:** expose what is actually in the cache ([c790732](https://github.com/MilliPress/MilliCache-Pro/commit/c790732e39d9cb99e382c8a10588a1a847e02e4a))
* **abilities:** report how well the cache is working ([fcd6995](https://github.com/MilliPress/MilliCache-Pro/commit/fcd6995c82116e3f0a638793f5a37c2d0936c9cb))
* **abilities:** roll the Abilities API out across the modules ([e455ed3](https://github.com/MilliPress/MilliCache-Pro/commit/e455ed3a32207c3413a8f29661f921197e5129b9))
* **abilities:** say when cache performance is not being recorded ([58b304d](https://github.com/MilliPress/MilliCache-Pro/commit/58b304ddebdcef5b798234d67654a63d94b23141))
* **cli:** list the rules that come from code ([714a092](https://github.com/MilliPress/MilliCache-Pro/commit/714a0927562e5f8c6df3a69a87a8231ebcabd2f9))
* **commands:** jump straight to the Entries and Rules tabs ([f1e83a0](https://github.com/MilliPress/MilliCache-Pro/commit/f1e83a033af10eb33a945aaf6775d93a05142790))
* **commands:** name the provider in the edge purge, and document the palette ([6d38202](https://github.com/MilliPress/MilliCache-Pro/commit/6d38202e2898414e5fdec17b13d222352144559d))
* **commands:** offer Pro actions in the WordPress command palette ([2db1633](https://github.com/MilliPress/MilliCache-Pro/commit/2db16332fe493859d69b03a5abd67a14015a8a23))
* **commands:** purge the edge cache and flush the object cache ([824f355](https://github.com/MilliPress/MilliCache-Pro/commit/824f35586c30c212ab0407eb8908fbf223dd11a6))
* **deps:** update MilliCache to 1.8.0 ([8488bdf](https://github.com/MilliPress/MilliCache-Pro/commit/8488bdf9baa9663ab9ac833def07a364b724823a))
* **deps:** update MilliCache to 1.8.0-beta ([6fb40be](https://github.com/MilliPress/MilliCache-Pro/commit/6fb40be15d19462ce596660d760feba01f97da30))
* **deps:** update MilliCache to 1.8.0-beta.1 ([77e70a8](https://github.com/MilliPress/MilliCache-Pro/commit/77e70a8a17e66e097a5bf02a94752b87f02aa8c6))
* **deps:** update MilliCache to 1.8.0-beta.2 ([213be9d](https://github.com/MilliPress/MilliCache-Pro/commit/213be9dcb5962177fb31c2657180540d50e869a2))
* **deps:** update MilliPro to 1.5.4 ([6f886d5](https://github.com/MilliPress/MilliCache-Pro/commit/6f886d5dbbac0f21cb7b2e12c9205b7b87a59e0c))
* **edge-cache:** gate edge tagging on the engine's storage verdict ([865dc86](https://github.com/MilliPress/MilliCache-Pro/commit/865dc86c266c7a37bcad5a97ac31070d7c3a5758))
* **edge:** cap the lifetime the edge is told to keep a page ([8393de4](https://github.com/MilliPress/MilliCache-Pro/commit/8393de42fbb0acb931eef8547e3baf35c9f475ee))
* **edge:** purge the edge instantly, for integrations and everyone else ([1260443](https://github.com/MilliPress/MilliCache-Pro/commit/126044377c5dcd75a220952436533d1a8c88c51e))
* **entries:** reload or expire cached pages, and keep deletions deleted ([448dc99](https://github.com/MilliPress/MilliCache-Pro/commit/448dc99c491478f6063c7874b1be1185b6646b66))
* **preload:** report a queue that nothing is draining ([413ea3c](https://github.com/MilliPress/MilliCache-Pro/commit/413ea3cae45bddfea8af5f0a16bba963502891ba))
* **preload:** show preload progress instantly in the Entries tab ([8609d9d](https://github.com/MilliPress/MilliCache-Pro/commit/8609d9dc35be7c9864ed710bd3acb91132541897))
* roll the Abilities API out across the modules ([270f854](https://github.com/MilliPress/MilliCache-Pro/commit/270f8541d4852342ee8c233f91c25ecd57043cd7))


### Bug Fixes

* **abilities:** give action arguments their own names ([e353aa1](https://github.com/MilliPress/MilliCache-Pro/commit/e353aa1a75bb878b69be2685feeeb8f985660d34))
* **abilities:** steady the edge, object cache and preload answers ([6805763](https://github.com/MilliPress/MilliCache-Pro/commit/680576365ae80f6c8119ebd69cc54183fc974a35))
* **commands:** give the preload command the menu item's icon ([2b5f710](https://github.com/MilliPress/MilliCache-Pro/commit/2b5f710ac3bf1e8ed3b39c8d481b4fa1e8874177))
* **commands:** order the palette by intent, not by directory name ([6223ed9](https://github.com/MilliPress/MilliCache-Pro/commit/6223ed9dc826baf5c797e1a9f18ea2b8c9693212))
* **edge-cache:** enqueue the purge plan as a unique async action ([57a4598](https://github.com/MilliPress/MilliCache-Pro/commit/57a4598b2973cdcb4cf32a52207dcc1a36e2dd97))
* **edge:** drop the async parameter from bunny.net URL purges ([9f42bd3](https://github.com/MilliPress/MilliCache-Pro/commit/9f42bd333b4a48fb5bb97615df6765a764b458dc))
* **edge:** fail fast when the CDN is unreachable ([1fdee9f](https://github.com/MilliPress/MilliCache-Pro/commit/1fdee9f69da5b08b0a2eef13548fe5afe05e2d29))
* **edge:** give bunny.net purges the time they actually need ([3d11440](https://github.com/MilliPress/MilliCache-Pro/commit/3d1144035c805b705e92ac0f6ece7b07d6ad6e7b))
* **edge:** start background work through WP-Cron's own runner ([b068e47](https://github.com/MilliPress/MilliCache-Pro/commit/b068e47a4239232c378563d5578b6f4930014af8))
* **edge:** tell the edge the entry's effective lifetime ([c046f7c](https://github.com/MilliPress/MilliCache-Pro/commit/c046f7c8b4ff4769e393f2bcbf19a4f4301d6f63))
* **preload:** re-warm cache entries again when they are cleared individually ([36d639e](https://github.com/MilliPress/MilliCache-Pro/commit/36d639e4ff930843b418bcc4be0edd0ddef4dd35))
* **preload:** show each site's own preload progress on multisite ([b81a72a](https://github.com/MilliPress/MilliCache-Pro/commit/b81a72a3f13ca732f7c17565769032403a9ce2c5))


### Performance

* **preload:** start preloading right away instead of on the next cron run ([f411103](https://github.com/MilliPress/MilliCache-Pro/commit/f411103e8d70aee70604863d5933c43362236503))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.8.0

* **abilities:** let assistants read cache status and clear the cache ([f80b057](https://github.com/MilliPress/MilliCache/commit/f80b057e2d88a2d6ab68a88d63495b2bad36a149))
* **abilities:** report network-wide problems in a site's cache status ([4e2b9f1](https://github.com/MilliPress/MilliCache/commit/4e2b9f1be5fc0e3a6841d7463a555fd57002b544))
* **abilities:** say whether the install is a multisite ([4573485](https://github.com/MilliPress/MilliCache/commit/457348597a48a8cd7e7feb2dcdf306d73d392078))
* **adminbar:** replace one-click flush with command palette integration ([382b10b](https://github.com/MilliPress/MilliCache/commit/382b10beec75a421834632919875090c65d21443))
* **adminbar:** snackbar clear feedback and a wider palette ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **cache:** state the lifetime a replayed page has left ([8f699fd](https://github.com/MilliPress/MilliCache/commit/8f699fdad02c3d797f7923cb26b09c99cd5596d2))
* **clear:** report removed-entry counts instead of processed inputs ([23ec58f](https://github.com/MilliPress/MilliCache/commit/23ec58f9b5b2d09a23fae07137aafb171c912837))
* **cli:** scope bare clear flags to the WP-CLI site context ([d30d31e](https://github.com/MilliPress/MilliCache/commit/d30d31e386408c3a072c513a29108eb7528b9a6e))
* **commands:** let add-ons ride the palette's promote/demote cycle ([1db3d3f](https://github.com/MilliPress/MilliCache/commit/1db3d3f1a7475c2fe5221e82ae9fed2c5645c609))
* **commands:** offer expire alongside clear with descriptive target labels ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** capture the page in the outermost output buffer ([6c4d393](https://github.com/MilliPress/MilliCache/commit/6c4d393ba419e569ab4b8e5aca0f989ecf95d240))
* **engine:** capture the page in the outermost output buffer ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))
* **engine:** capture the page in the outermost output buffer ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))
* **engine:** expose the request's effective TTL override ([31e2555](https://github.com/MilliPress/MilliCache/commit/31e2555437c91f94271313df3a5a6be37dd8984e))
* **rules:** build the rule registry when the drop-in has not ([b6ef71b](https://github.com/MilliPress/MilliCache/commit/b6ef71b032d5f37f748ddc6ae7a75d209ea2a341))
* **adminbar:** keep the admin bar button size stable on page load ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **cache:** report targets that belong to another site ([6a7947a](https://github.com/MilliPress/MilliCache/commit/6a7947ae23b67a4a6636befed36827171851427e))
* **clear:** anchor path-only URL targets onto the home URL ([27bd7dc](https://github.com/MilliPress/MilliCache/commit/27bd7dc6c42f3d2210bd02ae148d31fb461a08e5))
* **clear:** skip non-viewable taxonomies in post-related flags ([40cd884](https://github.com/MilliPress/MilliCache/commit/40cd88414019eda097f910bce72fb6acd40a01e8))
* **commands:** drop the stray focus ring after palette clears ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **commands:** stop crowding every admin search, and drop the settings entry ([87b52d1](https://github.com/MilliPress/MilliCache/commit/87b52d1c19b0c499ea2200306a3f4a9631021bf2))
* **engine:** execute the invalidation queue when the drop-in never loads ([746bd0e](https://github.com/MilliPress/MilliCache/commit/746bd0eb9cfafe57df36c39a45ac8074d0708819))
* **engine:** keep non-default ports in URL-based cache hashes ([ed6f3d6](https://github.com/MilliPress/MilliCache/commit/ed6f3d670305dcf7750ed49bf8425e1149d80efa))
* **engine:** never store redirect responses ([6c4d393](https://github.com/MilliPress/MilliCache/commit/6c4d393ba419e569ab4b8e5aca0f989ecf95d240))
* **engine:** never store redirect responses ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))
* **engine:** never store redirect responses ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))
* **rules:** lock wp-cron.php out of the cache and cover the rest_route form ([908ce04](https://github.com/MilliPress/MilliCache/commit/908ce04ec9a3f0e4b64813253eebb69135b7c36c))
* **rules:** skip an action whose placeholder resolved to nothing ([a5e4894](https://github.com/MilliPress/MilliCache/commit/a5e4894ad0a10ede4b91cce7f67463cea2c9ebc9))
* **updates:** keep update checks off every admin page load ([1661d07](https://github.com/MilliPress/MilliCache/commit/1661d079641cccc28003d304659cbdbe769070df))
<!-- /mc:auto-bundle -->

## [1.4.0-beta.3](https://github.com/MilliPress/MilliCache-Pro/compare/v1.4.0-beta.2...v1.4.0-beta.3) (2026-08-21)

<!-- mc:auto sha=2a2d36df0db9 -->
The edge cache background worker now starts purge and preload jobs through WP-Cron's standard runner instead of calling Action Scheduler internals directly. A crashed worker can no longer stall pending purges until cron cleanup runs — queued jobs start within seconds, so the edge stops serving stale pages much sooner after a purge is triggered. Sites running WP-CLI on https-enforced installs will also see cron spawns correctly use the stored site scheme rather than the CLI context.
<!-- /mc:auto -->

### Bug Fixes

* **edge:** start background work through WP-Cron's own runner ([b068e47](https://github.com/MilliPress/MilliCache-Pro/commit/b068e47a4239232c378563d5578b6f4930014af8))

## [1.4.0-beta.2](https://github.com/MilliPress/MilliCache-Pro/compare/v1.4.0-beta.1...v1.4.0-beta.2) (2026-08-18)

<!-- mc:auto sha=15d1334c6d29 -->
Edge purges now fire instantly instead of waiting for the next cron run — fire `millicache_edge_purge` and the edge is clean before the action returns, with post IDs, flags, paths, and URLs all accepted in the same call. The queue runner also starts immediately after a content change, so editor saves and cache clears reach the CDN within seconds rather than up to a minute later. When a CDN is unreachable, provider calls time out after 5 seconds and remaining calls in the same request are skipped, capping the worst-case delay at 5 seconds instead of 15 per target. Two documentation updates round out the release: purge scope per CDN provider (bunny.net account-wide, Cloudflare zone-scoped) and when background purges and preloads actually begin.
<!-- /mc:auto -->

### Features

* **edge:** purge the edge instantly, for integrations and everyone else ([1260443](https://github.com/MilliPress/MilliCache-Pro/commit/126044377c5dcd75a220952436533d1a8c88c51e))


### Bug Fixes

* **edge:** fail fast when the CDN is unreachable ([1fdee9f](https://github.com/MilliPress/MilliCache-Pro/commit/1fdee9f69da5b08b0a2eef13548fe5afe05e2d29))


### Performance

* **preload:** start preloading right away instead of on the next cron run ([f411103](https://github.com/MilliPress/MilliCache-Pro/commit/f411103e8d70aee70604863d5933c43362236503))

## [1.4.0-beta.1](https://github.com/MilliPress/MilliCache-Pro/compare/v1.4.0-beta...v1.4.0-beta.1) (2026-08-16)

<!-- mc:auto sha=f7f8e69c65b5 -->
This beta completes the AI/assistant integration started in 1.4.0-beta and rounds out several areas that surfaced gaps during that work.

**Abilities API — the main theme of this release.** The WordPress Abilities API now reaches every Pro module. An AI assistant or other client can read which pages are cached and why a URL holds multiple copies, check cache performance figures (hit ratio, bandwidth, response times, and how they compare to the previous period), inspect caching rules including those registered from code, and see whether performance recording is even switched on — previously zeroes came back either way, with no way to tell the difference. Preload, Object Cache, and Rules join Edge Cache, each through the same pattern Edge Cache established. REST exposure follows the module's capability; MCP lists the readable abilities and the preload (recoverable work) and withholds the edge purge and object cache flush, whose costs fall outside the site.

**Command palette.** Pro's verbs were missing from the WordPress command palette, so the admin bar button showed only half the product. Modules now contribute palette entries the same way they contribute status checks, gated on the module being active and licensed. The full sitemap preload is the first entry. Edge purge and object cache flush follow, with the edge purge kept out of the promoted list since some providers rate-limit a full purge. Entries and Rules gain direct jump commands too. The palette now sorts by intent rather than directory name, and the preload entry carries the same icon as the menu item.

**Entries.** Row actions now act on the whole page rather than one variant. Reload expires the entry and hands the URL to the preloader so the stored copy keeps serving until the replacement arrives; it only appears when Cache Preloading is active. Expire marks a page stale for the next visitor to rebuild. Delete now really deletes — with preloading active, deletions were being re-queued immediately. "Delete all variants" was also removing only a single entry; that is fixed.

**Edge cache.** Two fixes to what the edge is told. The live response was reading the configured TTL while the stored copy used the entry's effective one, so a rule that shortens a page's lifetime had no effect on the generating response. Both now read the same source. Separately, the backstop TTL sent to the edge is now capped at one hour — with tag purges handling content changes, the TTL is only a fallback for a purge that never arrives, and there was no reason for it to reach a day. The `millicache_edge_ttl` filter can still raise the cap.

**Preload.** A new check detects a queue that nothing is draining — preloaded pages sitting unsent while every other signal reads fine. Once URLs have waited fifteen minutes with none fetched, the status says so and names the likely cause (background tasks not running). On multisite, preload progress was reporting the combined count for all sites; each site now owns its queue.

**WP-CLI.** `wp millicache rules list` now shows rules from code alongside stored rules, told apart by a source column. Asking for a code-registered rule by ID no longer says no such rule exists — it says where the rule lives instead.
<!-- /mc:auto -->

### Features

* **abilities:** expose module operations via the WordPress Abilities API ([327fb6e](https://github.com/MilliPress/MilliCache-Pro/commit/327fb6e542894dfb03bf83532cdaa72e12a81ac9))
* **abilities:** expose the caching rules, the ones from code included ([5c1a43f](https://github.com/MilliPress/MilliCache-Pro/commit/5c1a43fd5a10c12a48a9b51f72bf43d600d18235))
* **abilities:** expose what is actually in the cache ([c790732](https://github.com/MilliPress/MilliCache-Pro/commit/c790732e39d9cb99e382c8a10588a1a847e02e4a))
* **abilities:** report how well the cache is working ([fcd6995](https://github.com/MilliPress/MilliCache-Pro/commit/fcd6995c82116e3f0a638793f5a37c2d0936c9cb))
* **abilities:** roll the Abilities API out across the modules ([e455ed3](https://github.com/MilliPress/MilliCache-Pro/commit/e455ed3a32207c3413a8f29661f921197e5129b9))
* **abilities:** say when cache performance is not being recorded ([58b304d](https://github.com/MilliPress/MilliCache-Pro/commit/58b304ddebdcef5b798234d67654a63d94b23141))
* **cli:** list the rules that come from code ([714a092](https://github.com/MilliPress/MilliCache-Pro/commit/714a0927562e5f8c6df3a69a87a8231ebcabd2f9))
* **commands:** jump straight to the Entries and Rules tabs ([f1e83a0](https://github.com/MilliPress/MilliCache-Pro/commit/f1e83a033af10eb33a945aaf6775d93a05142790))
* **commands:** name the provider in the edge purge, and document the palette ([6d38202](https://github.com/MilliPress/MilliCache-Pro/commit/6d38202e2898414e5fdec17b13d222352144559d))
* **commands:** offer Pro actions in the WordPress command palette ([2db1633](https://github.com/MilliPress/MilliCache-Pro/commit/2db16332fe493859d69b03a5abd67a14015a8a23))
* **commands:** purge the edge cache and flush the object cache ([824f355](https://github.com/MilliPress/MilliCache-Pro/commit/824f35586c30c212ab0407eb8908fbf223dd11a6))
* **deps:** update MilliCache to 1.8.0-beta.1 ([77e70a8](https://github.com/MilliPress/MilliCache-Pro/commit/77e70a8a17e66e097a5bf02a94752b87f02aa8c6))
* **deps:** update MilliCache to 1.8.0-beta.2 ([213be9d](https://github.com/MilliPress/MilliCache-Pro/commit/213be9dcb5962177fb31c2657180540d50e869a2))
* **deps:** update MilliPro to 1.5.4 ([6f886d5](https://github.com/MilliPress/MilliCache-Pro/commit/6f886d5dbbac0f21cb7b2e12c9205b7b87a59e0c))
* **edge:** cap the lifetime the edge is told to keep a page ([8393de4](https://github.com/MilliPress/MilliCache-Pro/commit/8393de42fbb0acb931eef8547e3baf35c9f475ee))
* **entries:** reload or expire cached pages, and keep deletions deleted ([448dc99](https://github.com/MilliPress/MilliCache-Pro/commit/448dc99c491478f6063c7874b1be1185b6646b66))
* **preload:** report a queue that nothing is draining ([413ea3c](https://github.com/MilliPress/MilliCache-Pro/commit/413ea3cae45bddfea8af5f0a16bba963502891ba))
* roll the Abilities API out across the modules ([270f854](https://github.com/MilliPress/MilliCache-Pro/commit/270f8541d4852342ee8c233f91c25ecd57043cd7))


### Bug Fixes

* **abilities:** give action arguments their own names ([e353aa1](https://github.com/MilliPress/MilliCache-Pro/commit/e353aa1a75bb878b69be2685feeeb8f985660d34))
* **abilities:** steady the edge, object cache and preload answers ([6805763](https://github.com/MilliPress/MilliCache-Pro/commit/680576365ae80f6c8119ebd69cc54183fc974a35))
* **commands:** give the preload command the menu item's icon ([2b5f710](https://github.com/MilliPress/MilliCache-Pro/commit/2b5f710ac3bf1e8ed3b39c8d481b4fa1e8874177))
* **commands:** order the palette by intent, not by directory name ([6223ed9](https://github.com/MilliPress/MilliCache-Pro/commit/6223ed9dc826baf5c797e1a9f18ea2b8c9693212))
* **edge:** tell the edge the entry's effective lifetime ([c046f7c](https://github.com/MilliPress/MilliCache-Pro/commit/c046f7c8b4ff4769e393f2bcbf19a4f4301d6f63))
* **preload:** show each site's own preload progress on multisite ([b81a72a](https://github.com/MilliPress/MilliCache-Pro/commit/b81a72a3f13ca732f7c17565769032403a9ce2c5))

## [1.4.0-beta](https://github.com/MilliPress/MilliCache-Pro/compare/v1.3.0...v1.4.0-beta) (2026-08-07)

1.4.0-beta ships the **MilliCache 1.8.0-beta** engine, which changes where the page cache captures your HTML. Preload and Edge Cache each pick up reliability fixes alongside it.

**The engine now captures the page in the outermost output buffer.** MilliCache opens its buffer in the drop-in phase, before WordPress loads any plugins, so it is the last component to see the response rather than the first. Plugins that rewrite the whole page in a buffer of their own now finish before MilliCache stores anything, which means the cached copy matches what a visitor would have received without the cache. Multilingual sites show this most clearly: with TranslatePress, MilliCache used to capture the page before translation ran, so every cache hit served untranslated HTML. Those sites cache correctly now. Redirects are no longer stored either, so a temporary redirect cannot get baked into the cache.

**Preload** now shows its spinner in the Entries tab the moment you clear entries or kick off a full warm, refreshing the remaining URL count every five seconds so you're never left guessing. Clearing a single entry also re-queues that exact URL for preloading, with one exception: trashed or deleted posts are skipped since their URLs would return a 404.

**Edge Cache** receives two targeted fixes. Saving a post through Gutenberg with legacy meta boxes (such as Polylang's language selector) previously fired duplicate purge plans; the purge action is now enqueued as a unique async task so the duplicate is dropped while the original runs, and retries still work correctly. Separately, the edge tagger now asks the engine directly whether a response is storable instead of inferring it from `headers_sent()`, which the outermost buffer makes unreliable. REST, AJAX, admin redirects, and other early-exit responses are correctly marked private instead of being handed to the edge.

Because the buffer change touches every request, this is a release worth testing before it reaches production. WordPress installs opt in with `define( 'MC_UPDATE_PRERELEASE', true );` in `wp-config.php`. Composer projects opt in per package with `composer require "millipress/millicache-pro:^1.4@beta"`, since Composer installs stable versions only by default.

### Features

* **deps:** update MilliCache to 1.8.0-beta ([6fb40be](https://github.com/MilliPress/MilliCache-Pro/commit/6fb40be15d19462ce596660d760feba01f97da30))
* **edge-cache:** gate edge tagging on the engine's storage verdict ([865dc86](https://github.com/MilliPress/MilliCache-Pro/commit/865dc86c266c7a37bcad5a97ac31070d7c3a5758))
* **preload:** show preload progress instantly in the Entries tab ([8609d9d](https://github.com/MilliPress/MilliCache-Pro/commit/8609d9dc35be7c9864ed710bd3acb91132541897))


### Bug Fixes

* **edge-cache:** enqueue the purge plan as a unique async action ([57a4598](https://github.com/MilliPress/MilliCache-Pro/commit/57a4598b2973cdcb4cf32a52207dcc1a36e2dd97))
* **preload:** re-warm cache entries again when they are cleared individually ([36d639e](https://github.com/MilliPress/MilliCache-Pro/commit/36d639e4ff930843b418bcc4be0edd0ddef4dd35))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.8.0-beta

* **adminbar:** replace one-click flush with command palette integration ([382b10b](https://github.com/MilliPress/MilliCache/commit/382b10beec75a421834632919875090c65d21443))
* **adminbar:** snackbar clear feedback and a wider palette ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **clear:** report removed-entry counts instead of processed inputs ([23ec58f](https://github.com/MilliPress/MilliCache/commit/23ec58f9b5b2d09a23fae07137aafb171c912837))
* **cli:** scope bare clear flags to the WP-CLI site context ([d30d31e](https://github.com/MilliPress/MilliCache/commit/d30d31e386408c3a072c513a29108eb7528b9a6e))
* **commands:** offer expire alongside clear with descriptive target labels ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** capture the page in the outermost output buffer ([7606f04](https://github.com/MilliPress/MilliCache/commit/7606f04b2ea9749f4ffd41f4a52e203bc477b2b6))
* **adminbar:** keep the admin bar button size stable on page load ([2c77206](https://github.com/MilliPress/MilliCache/commit/2c77206eb6b1635730c2047168b942b88bb03b03))
* **clear:** anchor path-only URL targets onto the home URL ([27bd7dc](https://github.com/MilliPress/MilliCache/commit/27bd7dc6c42f3d2210bd02ae148d31fb461a08e5))
* **clear:** skip non-viewable taxonomies in post-related flags ([40cd884](https://github.com/MilliPress/MilliCache/commit/40cd88414019eda097f910bce72fb6acd40a01e8))
* **commands:** drop the stray focus ring after palette clears ([880c9eb](https://github.com/MilliPress/MilliCache/commit/880c9ebc4bf0e57edff930cb4f31c55578c58d01))
* **engine:** execute the invalidation queue when the drop-in never loads ([746bd0e](https://github.com/MilliPress/MilliCache/commit/746bd0eb9cfafe57df36c39a45ac8074d0708819))
* **engine:** keep non-default ports in URL-based cache hashes ([ed6f3d6](https://github.com/MilliPress/MilliCache/commit/ed6f3d670305dcf7750ed49bf8425e1149d80efa))
* **engine:** never store redirect responses ([6cee142](https://github.com/MilliPress/MilliCache/commit/6cee14202bdaf15594dfd7b9ed659533ef025d4b))
<!-- /mc:auto-bundle -->

## [1.3.0](https://github.com/MilliPress/MilliCache-Pro/compare/v1.2.1...v1.3.0) (2026-07-29)

<!-- mc:auto sha=4f618cfcdcbd -->
Cache Preloading now discovers every sitemap declared in `robots.txt` on each run, so multilingual sites (e.g. The SEO Framework with Polylang) warm all languages automatically without touching settings. The Sitemap URL setting becomes **Sitemaps** and accepts multiple paths or full URLs when you need to override what's discovered. The Status tab and `wp millicache preload` report each sitemap's result individually.

Object cache setup is more resilient across the board. `MC_OBJECT_CACHE_ACTIVE` now works in both directions — set it to `true` to install the drop-in, `false` to remove it, and the toggle shows as locked in the admin UI. When `wp-content` isn't writable the toggle locks and explains why; REST and CLI activations revert automatically if the install fails; and the Status tab surfaces a missing drop-in rather than showing the module as simply off. The drop-in also re-points itself after atomic deploys and plugin reactivation. `MC_LICENSE_KEY` can now be supplied via `wp-config.php` alongside `MC_OBJECT_CACHE_ACTIVE` — docs cover both.

Bundled MilliCache moves from 1.7.6 to 1.7.7. The headline fix there is multisite metrics: response times, bandwidth, and stale-serve counts were silently dropped on network installs, leaving Insights charts flat.
<!-- /mc:auto -->

### Features

* **deps:** update MilliCache to 1.7.7 ([#41](https://github.com/MilliPress/MilliCache-Pro/issues/41)) ([0d92545](https://github.com/MilliPress/MilliCache-Pro/commit/0d92545d67ac95f0b5f06ae8218304236f1728e3))
* **object-cache:** follow the MC_OBJECT_CACHE_ACTIVE constant in both directions ([bc05a5a](https://github.com/MilliPress/MilliCache-Pro/commit/bc05a5a4f7f77c17236b8c46e60d84d0aa54071f))
* **object-cache:** prevent broken activations and heal the drop-in on deploys ([c0e18d2](https://github.com/MilliPress/MilliCache-Pro/commit/c0e18d202cb1946d72019fe6b6ab6a8bd856b45c))
* **preload:** preload every sitemap listed in robots.txt ([bc52355](https://github.com/MilliPress/MilliCache-Pro/commit/bc52355970d04524b068f44c2c238a5035fd5b23))


### Bug Fixes

* **deps:** require MilliPro 1.5.3 ([141d970](https://github.com/MilliPress/MilliCache-Pro/commit/141d9704885d5107d48db25f86f9bf1ab303c171))
* **object-cache:** attach silently while the engine is still booting ([40bab37](https://github.com/MilliPress/MilliCache-Pro/commit/40bab37f0c5bcbc01f7d3ec13c7dc2dddd27ddb5))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.7.7

* **dropins:** share install reporting and heal extension drop-ins ([800111c](https://github.com/MilliPress/MilliCache/commit/800111c9cf4b4666669ca075ac606b4deb2d24aa))
* **engine:** expose readiness for exception-free drop-in probes ([24b8d37](https://github.com/MilliPress/MilliCache/commit/24b8d37146f2dd8e2a0b83d5a0214c2527af9c64))
* **status:** report when the config file cannot be written ([54f2e82](https://github.com/MilliPress/MilliCache/commit/54f2e821843c0d165db4fc463b9346af2b88822e))
* **deps:** require millipress/millibase ^2.8.0 ([dd906d9](https://github.com/MilliPress/MilliCache/commit/dd906d9f241b121b865a9f8ba7acd883e95e489f))
* **metrics:** record response times and honor retention on multisite ([087e764](https://github.com/MilliPress/MilliCache/commit/087e7645a2c168d2eca40001b81dc6b44986c09f))
* Reinstall an already-correct drop-in symlink when --force is passed ([3b2ed2f](https://github.com/MilliPress/MilliCache/commit/3b2ed2fc2540f0987e6623b8091401ccb50fa181))
<!-- /mc:auto-bundle -->

## [1.2.1](https://github.com/MilliPress/MilliCache-Pro/compare/v1.2.0...v1.2.1) (2026-07-28)

<!-- mc:auto sha=3413ea1cf978 -->
Edge cache purges now mirror MilliCache's own flag batches exactly, so bunny.net and Cloudflare always purge the same set of entries that MilliCache cleared, including wildcard flag patterns. This requires the bundled MilliCache 1.7.6, which also gains a low-memory warning on the Status screen.

Module toggle state for Preload and Block Editor is migrated from `.enabled` to `.active` to match all other modules; existing settings carry over automatically.
<!-- /mc:auto -->

### Features

* **deps:** update MilliCache to 1.7.6 ([#38](https://github.com/MilliPress/MilliCache-Pro/issues/38)) ([4b6ecd5](https://github.com/MilliPress/MilliCache-Pro/commit/4b6ecd538c2de7f543010cd5164748bc5068c394))
* **edge-cache:** mirror every cache clear through MilliCache's flag batch ([4518614](https://github.com/MilliPress/MilliCache-Pro/commit/4518614801e88a8b6ece2e30811d23276bf92fd4))


### Bug Fixes

* **entries:** show titles and post type labels with their special characters ([875368e](https://github.com/MilliPress/MilliCache-Pro/commit/875368eff78dd68b023859de828c0d82e9c66fb0))
* **object-cache:** persist WP-CLI writes when the engine boots late ([4565e6d](https://github.com/MilliPress/MilliCache-Pro/commit/4565e6d71b6757d2c9579a96f6a37a326bbcf483))


### Refactoring

* **modules:** unify module on/off toggles on the .active settings key ([8a748e1](https://github.com/MilliPress/MilliCache-Pro/commit/8a748e17032895838f82124c97592620f5e72a64))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.7.6

* **engine:** announce every cache clear as one merged flag batch ([e2f1513](https://github.com/MilliPress/MilliCache/commit/e2f15137798c35e975f3c7d1a5da4bbca786da4a))
* **status:** warn before the storage server runs out of memory ([a4fc9e6](https://github.com/MilliPress/MilliCache/commit/a4fc9e69116e4419bc1fd4f8b9fe793e322f2552))
* **cli:** give the interactive redis-cli session the real terminal ([14e2128](https://github.com/MilliPress/MilliCache/commit/14e21281b09e5a009b3617c56d7cbcfa5343737f))
<!-- /mc:auto-bundle -->

## [1.2.0](https://github.com/MilliPress/MilliCache-Pro/compare/v1.1.0...v1.2.0) (2026-07-24)

<!-- mc:auto sha=4bd813cd86ab -->
Sites running behind a host-managed CDN — Kinsta, WP Engine, and similar Cloudflare-Enterprise platforms — have had a silent problem: the host strips cache tags in transit, so edge purges matched nothing and stale content kept serving. This release fixes that end-to-end. A new Host CDN compatibility setting emits tags under a neutral header that a one-time Cloudflare Cache Response Rule converts back into cache tags on your zone, restoring purging. The Status tab now detects the condition too: instead of reporting Edge Cache as Active while purges silently fail, it explains what is happening and points to the setting and the matching zone rule.
<!-- /mc:auto -->

### Features

* **deps:** update MilliCache to 1.7.5 ([#36](https://github.com/MilliPress/MilliCache-Pro/issues/36)) ([1ecef1c](https://github.com/MilliPress/MilliCache-Pro/commit/1ecef1ce78903fbc337e3142ffd04a89d447a464))
* **edge:** keep purging working behind host CDNs like Kinsta and WP Engine ([56f0404](https://github.com/MilliPress/MilliCache-Pro/commit/56f0404807292a4114a7241e7b7ee2c20fc15712))
* **edge:** warn on the Status tab when a host CDN blocks purging ([f975dd6](https://github.com/MilliPress/MilliCache-Pro/commit/f975dd671f224f6263bda2c6c13e12a6ad098970))
* **release:** include bundled MilliCache changes in the changelog and release notes ([5031673](https://github.com/MilliPress/MilliCache-Pro/commit/5031673fba6c46ae2e4b65e0112206227efd9f63))


### Bug Fixes

* **metrics:** keep the dashboard date range from going stale ([81b76bf](https://github.com/MilliPress/MilliCache-Pro/commit/81b76bf491e62113bddb67c6f80060812d12d922))
* **metrics:** warn when the seed day count is clamped ([c86b169](https://github.com/MilliPress/MilliCache-Pro/commit/c86b16938998b5aadc3b61d9d561f1906aaf6d9d))
* **release:** unbreak the polish workflow file ([cea6758](https://github.com/MilliPress/MilliCache-Pro/commit/cea6758a60562f1b17ccdcdc1467d9542709e27c))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.7.5

* **cache:** clear a post's cache when it is unpublished ([43a1a88](https://github.com/MilliPress/MilliCache/commit/43a1a883c1e28eb633ac7b427e32bcb5842da656))
* **cache:** clear feed caches when a post is published or updated ([b04ede8](https://github.com/MilliPress/MilliCache/commit/b04ede8a015f8d2a34ded93236e76be825538c57))
* **cache:** fire millicache_cache_cleared_by_posts on automatic post invalidation ([6615c7d](https://github.com/MilliPress/MilliCache/commit/6615c7d44eae208ccc5730bd23b386c347930917))
* **engine:** accept Vary tokens covered by request keying or inert on GET ([53ad7b7](https://github.com/MilliPress/MilliCache/commit/53ad7b74b7f2d3cd9118a64bca28aaee3b994427)), closes [#172](https://github.com/MilliPress/MilliCache/issues/172)
* **engine:** resolve Authorization bucket from redirect and basic-auth channels ([7e7ed1b](https://github.com/MilliPress/MilliCache/commit/7e7ed1b78d17323ab77afe5fbc71b8c6ceb53591))
* **storage:** prevent a fatal error when toggling MilliCache alongside MilliCache Pro ([1ad4949](https://github.com/MilliPress/MilliCache/commit/1ad4949e9a2f667d5942bf0cb7f5eb3f8c513fc3))
<!-- /mc:auto-bundle -->

## [1.1.0](https://github.com/MilliPress/MilliCache-Pro/compare/v1.0.0...v1.1.0) (2026-07-22)

MilliCache Pro now speaks your language. This release wires the plugin into the same language-pack delivery that ships translations for MilliCache, so a site running in German, Portuguese, or any other translated locale sees Pro's settings, modules, and command output in that language, updated automatically as new translations land. Along the way, composed interface strings were made properly translatable and JS translations are now served as full catalogs, so switching languages covers every corner of the settings screen.

The bundled MilliCache moves to 1.7.4, which brings the language-pack installation this release builds on and corrects the endpoint the updater checks for new versions.

### Features

* **deps:** update MilliCache to 1.7.4 ([#35](https://github.com/MilliPress/MilliCache-Pro/issues/35)) ([8608732](https://github.com/MilliPress/MilliCache-Pro/commit/86087326f9fcb7e204dbf03fad56c2e61922f158))
* **i18n:** register millicache-pro with the core language-pack injector ([69ff2ea](https://github.com/MilliPress/MilliCache-Pro/commit/69ff2ea65c0d862dff95fb3eb0111ec55a306928))
* **i18n:** register the millipro text domain for language-pack delivery ([352ec2e](https://github.com/MilliPress/MilliCache-Pro/commit/352ec2ee9cc04510392a567b66c7dbbc5063a958))


### Bug Fixes

* **i18n:** make composed UI strings translatable ([cd65e3a](https://github.com/MilliPress/MilliCache-Pro/commit/cd65e3acde1da70a84f1411dad99de9a897e7441))
* **i18n:** serve JS translations as handle-named full catalogs ([05a163c](https://github.com/MilliPress/MilliCache-Pro/commit/05a163c8443f56dd2ddd82546eff6313c11c5232))


### Refactoring

* **i18n:** follow the millicache_translation_domains filter rename ([928e0a7](https://github.com/MilliPress/MilliCache-Pro/commit/928e0a752464133f62cc8864926ea6e5ab5f50ae))

<!-- mc:auto-bundle -->
### Updated to MilliCache 1.7.4

* **i18n:** install language packs from the millipress.com languages API ([dd2d73d](https://github.com/MilliPress/MilliCache/commit/dd2d73dc3f8d7a1f1d4f5dd432bab3693e38e891))
* **release:** post a single Discord notification on stable release ([57b0def](https://github.com/MilliPress/MilliCache/commit/57b0defddc9ffac48c948eae8681179f5e268d84))
* **i18n:** serve JS translations as handle-named full catalogs ([747594a](https://github.com/MilliPress/MilliCache/commit/747594a108fcf46408d53dd75f6a99b0dc1ca6cb))
* **updater:** Correct endpoint URL for plugin update information ([46611ad](https://github.com/MilliPress/MilliCache/commit/46611ad197ff3c59ee0205bb8526139578dbdfbb))
<!-- /mc:auto-bundle -->

## 1.0.0 (2026-07-19)

Welcome to **MilliCache Pro**, the premium extension that turns MilliCache's fast, reliable full-page cache into a complete performance toolkit for WordPress. Pro is built as a set of modules you switch on individually, so your site runs exactly the features you need and nothing more. Here is everything the first release brings.

### Caching

- **Visual Rules Builder**: decide what gets cached, and for how long, from a point-and-click editor. Target pages by URL, template, post type, preview state, and time-based conditions, all without touching code.
- **Block Editor intelligence**: precise cache invalidation that understands the modern editor, clearing exactly the right pages when you update synced patterns, Query Loops, or Site Editor templates.
- **Cache Preloading**: keep the cache warm automatically. Pro rebuilds pages from your sitemap after you publish and after a full clear, so visitors almost always land on a cached page.
- **Cache Entries Browser**: see inside the cache. Browse, search, and delete every cached page and its variants from a dedicated tab.

### Content Delivery

- **Edge Cache**: serve whole cached pages from bunny.net or Cloudflare's global edge, purged in lockstep with your local cache so readers never see stale content.
- **Asset CDN**: offload static files (CSS, JS, images, fonts) to a pull zone and rewrite their URLs for you, with flexible include and exclude matching.

### Insights

- **Detailed Metrics**: a Status-dashboard view of requests, hit rate, bandwidth, and response times, with the estimated time your cache has saved.

### Storage & Reliability

- **Persistent Object Cache**: a drop-in object cache backed by the same storage connection as your page cache, self-healing when the drop-in goes missing.
- **High-Availability Storage Connections**: a visual editor for Redis replica and Sentinel topologies, plus per-site key prefixes.

### Platform

- **Full multisite awareness**: modules configure themselves where their feature lives, whether per-site, network-wide, or both. Entries and Rules understand network-level data.
- **License management and automatic updates**: activate your license in the settings screen and receive signed updates directly in WordPress.
- **WP-CLI throughout**: list and toggle modules, manage rules, trigger preloads, and reinstall drop-ins from the command line.

Thank you for choosing MilliCache Pro. Every module here exists to make your site faster with less effort, so enable the ones you need and get back to building.

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/01-overview

---
title: 'Modules Overview'
description: 'MilliCache Pro splits its features into modules you enable individually: entries browser, rules builder, preloading, metrics, edge cache, CDN, and object cache.'
menu_order: 10
---

# Modules

MilliCache Pro is organized into **modules**. Each one solves a specific problem, and each one is enabled individually, so your site runs exactly the features you use and nothing else.

## The Modules

| Module                                             | Group             | What it does                                                                          |
|----------------------------------------------------|-------------------|---------------------------------------------------------------------------------------|
| [Cache Entries](/docs/millicache-pro/02-modules/02-cache-entries)             | (Entries tab)     | Browse, search, and delete every cached page                                          |
| [Rules Builder](/docs/millicache-pro/02-modules/03-rules-builder)             | (Rules tab)       | Create custom caching rules visually, without code                                    |
| [Block Editor](/docs/millicache-pro/02-modules/04-block-editor)               | Caching           | Precise invalidation for synced patterns, Query Loops, and Site Editor templates      |
| [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading)       | Caching           | Keep the cache warm after publishing and after full clears                            |
| [Object Cache](/docs/millicache-pro/02-modules/09-object-cache)               | Caching           | Persistent WordPress object cache on the same storage connection                      |
| [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache)                   | Content Delivery  | Serve cached pages from bunny.net or Cloudflare, purged together with the local cache |
| [Asset CDN](/docs/millicache-pro/02-modules/08-cdn)                           | Content Delivery  | Serve static files (CSS, JS, images, fonts) from a pull zone                          |
| [Detailed Metrics](/docs/millicache-pro/02-modules/06-detailed-metrics)       | Insights          | Requests, bandwidth, and response-time analytics on the Status dashboard              |
| [Storage Connections](/docs/millicache-pro/02-modules/10-storage-connections) | (Storage section) | Visual editor for replication and Sentinel storage topologies                         |

## Where to Find Them

Pro adds two things to the MilliCache settings screen (**Settings → MilliCache**):

- A **Modules** tab listing every module with its own settings section, grouped into *Caching*, *Content Delivery*, and *Insights*. The toggle in each section header switches the module on or off; changes take effect on the next request.
- Dedicated **Entries** and **Rules** tabs, plus the upgraded Status dashboard and Storage section.

## Module Scopes on Multisite

Modules configure themselves where their feature actually lives:

| Scope    | Modules                                               | Where configured                                                                                 |
|----------|-------------------------------------------------------|--------------------------------------------------------------------------------------------------|
| Per site | Block Editor, Cache Preloading                        | Each site's settings screen                                                                      |
| Network  | Edge Cache, Asset CDN, Object Cache, Detailed Metrics | Network Admin only (one pull zone, one drop-in, one metrics policy for the whole install)        |
| Both     | Entries, Rules                                        | Site screens show the site's data; Network Admin shows network-wide data and network-level rules |

On a single site, all modules simply appear in the one settings screen.

## Licensing

Modules are premium features: without an [active license](/docs/millicache-pro/01-getting-started/30-licensing), module toggles are locked and their runtime behavior stays off, while MilliCache's page caching continues to work normally. The settings sections stay visible so you can see what a license unlocks.

## WP-CLI

Modules can be listed and toggled from the command line:

```bash
wp millicache module list
wp millicache module activate <module>
wp millicache module deactivate <module>
```

Module keys match the settings sections: for example `preload`, `editor`, `metrics`, `edge`, `cdn`, `object-cache`. Run `wp millicache module list` to see the exact keys on your install; called without a module key, `activate` and `deactivate` prompt interactively. On multisite, add `--network` to manage network-scoped modules.

See the [WP-CLI reference](/docs/millicache-pro/03-wp-cli/01-commands) for each module's own commands.

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/02-cache-entries

---
title: 'Cache Entries Browser'
description: 'Browse every cached page from the MilliCache Pro Entries tab: search URLs and flags, inspect variants, size, and expiry, and reload, expire, or delete entries.'
menu_order: 20
---

# Cache Entries Browser

The Entries module adds an **Entries** tab to your MilliCache settings screen. It turns the cache from a black box into a browsable table: every cached page is listed with its flags, size, HTTP status, and expiry, and you can search, filter, and rebuild or remove entries page by page.

Open it at **Settings → MilliCache → Entries**. On multisite, the Network Admin settings screen shows entries from all sites.

## What You See

Each row represents one cached page. Pages cached in multiple variants (for example, different [buckets](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/) or gzip states sharing the same flags) are grouped into a single row with a variant count.

| Column        | Meaning                                                                                                                                  |
|---------------|------------------------------------------------------------------------------------------------------------------------------------------|
| **Title**     | Page title with a link to the URL and a freshness indicator                                                                              |
| **Site**      | Origin site (network view on multisite only, click to filter)                                                                            |
| **URL**       | The cached request URL                                                                                                                   |
| **Type**      | Singular, Archive, Home, Feed, or Other                                                                                                  |
| **Status**    | HTTP status code of the cached response                                                                                                  |
| **Post Type** | Post type behind singular entries                                                                                                        |
| **Flags**     | All [cache flags](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/) on the entry, click a flag to filter by it |
| **Cached**    | When the entry was stored (relative time)                                                                                                |
| **Expires**   | Time until expiry, or the remaining grace period for stale entries                                                                       |
| **Gzip**      | Whether the body is stored compressed                                                                                                    |
| **Size**      | Stored size of the entry                                                                                                                 |
| **Variants**  | Number of variants sharing this flag set (opens the variant modal)                                                                       |

A summary bar above the table shows the total number of cached pages, the total cache size, the number of stale entries, and a distribution bar by post type. While a [preload](/docs/millicache-pro/02-modules/05-cache-preloading) is running, the summary also shows live progress. The table refreshes automatically every 15 seconds while the tab is open.

## Searching and Filtering

The search field matches URLs and flags (with wildcard support, so you can search the same way you clear) and finds pages by title or content through WordPress search:

- `/pricing` finds every entry whose URL contains `/pricing`
- `post:*` finds all singular post entries
- `archive:*` finds all archive entries
- `Hello World` finds the cached pages of matching posts

Type, post type, gzip state, and (on multisite) site are available as column filters. Clicking any flag or the post-type distribution bar applies the matching filter directly.

## Inspecting Variants

When a page exists in more than one variant, the **Variants** count opens a modal listing each stored variant. The modal only shows the dimensions that actually differ between variants: cookies, unique request variables, buckets, request method, protocol, HTTP status, differing response headers, size, and gzip state. Individual variants can be expired or deleted here — the row actions in the table always cover the whole page.

This is the fastest way to answer "why is this page cached twice?": the differing column tells you which request dimension split the cache.

## Reloading, Expiring, and Deleting

Row actions apply to the whole page, variants included. Select several rows to run any of them as a bulk action.

| Action        | What happens                                                                          |
|---------------|---------------------------------------------------------------------------------------|
| **Copy URL**  | Copies the cached request URL to your clipboard                                       |
| **Reload**    | Rebuilds the page now. The stored copy keeps serving while the replacement is fetched |
| **Expire**    | Marks the page stale. It keeps serving until the next visitor triggers a rebuild      |
| **Delete**    | Removes the page from the cache. The next visitor waits for it to be built again      |

**Reload** only appears while [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading) is active, since it hands the page to the preloader. It never leaves a gap: the old copy stays available the whole time, so nobody hits an empty cache while the new one is being fetched.

**Expire** is the patient version of the same thing. Nothing is fetched, so it costs no traffic — useful when you want a large selection rebuilt gradually as visitors arrive rather than all at once.

**Delete** really deletes. With Cache Preloading active, clearing an entry normally re-queues its URL straight away, which is what keeps the cache complete after an edit. Deleting from this tab deliberately skips that, so the page stays out of the cache until someone visits it. Use Reload when you want it back immediately.

For flag-based or site-wide clearing, use the regular [cache clearing](https://www.millipress.com/docs/millicache/05-usage/20-cache-clearing/) tools.

## WP-CLI

The module adds `wp millicache entries`, a read-only listing command with the same filters as the UI:

```bash
wp millicache entries [--search=<term>] [--flag=<pattern>] [--type=<type>]
                      [--post-type=<type>] [--status=<status>] [--gzip=<gzip>]
                      [--orderby=<field>] [--order=<order>]
                      [--fields=<fields>] [--format=<format>]
```

**Options:**

| Option                | Description                                                          |
|-----------------------|----------------------------------------------------------------------|
| `--search=<term>`     | Substring match on flags and URLs                                    |
| `--flag=<pattern>`    | Wildcard flag pattern (e.g. `post:*`, `*:post:*`)                    |
| `--type=<type>`       | `singular`, `home`, `archive`, `feed`, or `other`                    |
| `--post-type=<type>`  | Post type slug                                                       |
| `--status=<status>`   | HTTP status code                                                     |
| `--gzip=<gzip>`       | `yes` or `no`                                                        |
| `--orderby=<field>`   | `url`, `type`, `status`, `size`, `stored`, `ttl` (default: `stored`) |
| `--order=<order>`     | `asc` or `desc` (default: `desc`)                                    |
| `--fields=<fields>`   | Columns to show (default: `url,flags,stored,ttl,size`)               |
| `--format=<format>`   | `table`, `json`, `csv`, `yaml`, `count` (default: `table`)           |

Available fields: `hash`, `url`, `type`, `post_type`, `status`, `size`, `stored`, `ttl`, `grace`, `gzip`, `flags`.

**Examples:**

```bash
# List all cached entries
wp millicache entries

# All singular pages
wp millicache entries --type=singular --post-type=page

# Everything tagged with a post flag
wp millicache entries --flag="post:*"

# Include grace period in the output
wp millicache entries --fields=url,size,ttl,grace

# Count entries
wp millicache entries --format=count
```

To delete entries from the command line, use the standard [`wp millicache clear`](https://www.millipress.com/docs/millicache/06-wp-cli/01-commands/) command.

## Notes

> [!NOTE]
> Entries whose grace period has fully elapsed are hidden from the browser. They are unreachable ghosts awaiting eviction by the storage server and no longer count as cached pages.

> [!NOTE]
> The entry index is cached for 30 seconds to keep the tab fast on large caches. Deletions refresh it immediately; a brand-new cache entry can take a few seconds to appear.

## Next Steps

- [Cache Flags](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/): understand the flags shown on each entry
- [Cache Clearing](https://www.millipress.com/docs/millicache/05-usage/20-cache-clearing/): flag-based and site-wide invalidation
- [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading): refill the cache after clearing

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/03-rules-builder

---
title: 'Visual Rules Builder'
description: 'Build WordPress caching rules visually with MilliCache Pro: match by URL, cookie, or conditionals, set TTLs, bypass the cache, and override built-in rules.'
menu_order: 30
---

# Visual Rules Builder

Every caching decision in MilliCache is a [rule](https://www.millipress.com/docs/millicache/04-rules/01-introduction/). MilliCache ships a full rules engine, but writing rules requires PHP. The Rules module adds a **Rules** tab to your settings screen where you build, edit, and reorder rules visually. No code, no deployment.

Open it at **Settings → MilliCache → Rules**.

## What a Rule Looks Like

A rule combines three things:

1. **Conditions**: when does this rule apply? Match by URL, cookie, query parameter, request method, user role, or any of the WordPress conditionals such as `is_singular` or `is_search`.
2. **Match type**: whether **all**, **any**, or **none** of the conditions must match.
3. **Actions**: what happens when it matches. All caching actions from the engine are available, such as setting a custom TTL, bypassing the cache, or adding flags.

Rules run in order (1 to 999, default 50), so a later rule can refine or override an earlier one. Drag rules in the list to reorder them; only the moved rule's order number changes.

**Example rules you can build in a minute** (see [Examples](#examples) below):

- Do not cache any URL under `/members/`
- Use a 5-minute TTL for your `/news/` section
- Bypass the cache when a `currency` cookie is present
- Split the cache into separate variants by request header, such as Markdown and HTML versions of the same page

## Built-in Rules and Overrides

The list also shows the rules MilliCache and its extensions register in code, read-only and visually separated (toggle their visibility in the table's appearance settings). You can override any unlocked built-in rule: the builder creates an editable copy under the same rule ID, and your version shadows the original. An "Overrides" badge marks such rules so it stays obvious which behavior is yours.

A few critical built-in rules are locked and cannot be overridden; they protect things that would break the site if cached (see [Built-in Rules](https://www.millipress.com/docs/millicache/04-rules/02-built-in-rules/)).

## Available Conditions

The condition picker is a searchable, grouped list built from the [MilliRules](https://www.millipress.com/docs/millirules/) engine:

- **Request conditions**: URL, query parameters, cookies, headers, request method, and more (see the [conditions reference](https://www.millipress.com/docs/millirules/05-reference/01-conditions/))
- **WordPress conditionals**: `is_singular`, `is_single`, `is_page`, `is_archive`, `is_home`, `is_front_page`, `is_search`, `is_404`, `is_feed`, `is_category`, `is_tag`, `is_tax`, `is_author`, `is_date`, `is_post_type_archive`, `is_paged`, `is_attachment`, `is_user_logged_in`, `is_preview`, `is_customize_preview`, `is_page_template`, `is_embed`, `is_robots`
- **Content conditionals**: `has_block`, `has_blocks`, `has_post_thumbnail`, `has_excerpt`, `has_tag`, `has_term`, `has_category`, `has_nav_menu`, `has_post_format`
- **Choice-based conditions** with prefilled options: post type, post status, user role, request method, and WordPress environment type

Developers can add further `is_*`/`has_*` conditionals to the picker via the `millicache_rule_builder_conditionals` filter.

## Examples

Each example lists what you pick in the rule editor. Text values support wildcards: `*` matches anything, `?` matches one character, and `/regex/` switches to a regular expression.

### Never Cache a Members Area

| | |
|---|---|
| **Condition** | Request URL is `*/members/*` |
| **Action** | Set Cache Decision, with Should Cache off and a reason like `Members area` |

The reason shows up in the debug headers, so you can always see why a page was not cached.

### Short TTL for a News Section

| | |
|---|---|
| **Condition** | Request URL is `*/news/*` |
| **Action** | Set TTL to `300` seconds |

Fresh content expires after five minutes while the rest of the site keeps the default TTL. When several rules set a TTL, the rule with the higher order wins.

### Bypass While a Cookie Is Present

| | |
|---|---|
| **Condition** | Cookie `currency` exists |
| **Action** | Set Cache Decision, with Should Cache off |

Visitors who picked a currency see live pages; everyone else keeps getting cached ones. If the personalized output is itself cacheable per currency, a bucket (next example) is the better tool: it keeps caching on and gives each currency its own entry.

### Separate Buckets for Markdown and HTML (Accept Header)

Sites that serve AI agents a Markdown representation of their pages (for example with a content-negotiation plugin that responds to `Accept: text/markdown`) need the cache to keep both representations of the same URL apart. Buckets do exactly that:

| | |
|---|---|
| **Condition** | Request Header, with Header Name `Accept` and value `*text/markdown*` |
| **Action** | Set Bucket, with Bucket Name `format` and Token `md` |

Requests preferring Markdown are cached in their own `format=md` bucket, while browsers keep hitting the regular HTML entry. Both entries live under the same page context and carry the same flags, so publishing an update invalidates the HTML and Markdown copies together instead of letting them drift apart.

The same pattern covers any header- or cookie-based variant: A/B test arms, currencies, consent states. One `Set Bucket` action per variant dimension, and the [Entries browser](/docs/millicache-pro/02-modules/02-cache-entries) shows the resulting variants per page.

> [!NOTE]
> Rules built purely from request conditions (URL, header, cookie, query, method) run in MilliCache's early phase, before WordPress even loads, exactly like the built-in bypass rules. Rules that use WordPress conditionals naturally run once WordPress is available, so they cannot influence decisions made earlier. Locked built-ins, such as the REST API bypass, cannot be overridden either way.

## Multisite: Network and Site Rules

On multisite the Rules tab exists in both places:

- **Network Admin** rules apply to every site in the network. Network administrators can mark a rule (or a single action inside it) as **locked**, which prevents sites from overriding it.
- **Site** rules apply to one site. A site rule that reuses the ID of an unlocked network rule overrides it for that site, marked with the same "Overrides" badge.

## Validation

The builder validates before saving, inline in the editor:

- A rule needs an ID (letters, numbers, hyphens, underscores, dots, colons) and at least one action
- Order must be an integer between 1 and 999
- Conditions and actions are checked by the rules engine itself, so a rule that saves is a rule that runs

## WP-CLI

Manage rules from the command line with `wp millicache rules`:

| Command                                  | Description                                    |
|------------------------------------------|------------------------------------------------|
| `wp millicache rules list`               | List all custom rules                          |
| `wp millicache rules get <id>`           | Show one rule as JSON or YAML                  |
| `wp millicache rules enable <id>`        | Enable a rule                                  |
| `wp millicache rules disable <id>`       | Disable a rule                                 |
| `wp millicache rules delete <id>`        | Delete a rule                                  |
| `wp millicache rules export [--file=<file>]` | Export all custom rules as JSON            |
| `wp millicache rules import <file> [--replace]` | Import rules from a JSON file           |

**Options:**

- `list` supports `--format=table|json|csv|yaml` (default: `table`)
- `get` supports `--format=json|yaml` (default: `json`)
- `import` appends and skips duplicate IDs by default; `--replace` replaces all existing custom rules

**Examples:**

```bash
# See what is active
wp millicache rules list

# Move rules between environments
wp millicache rules export --file=rules.json
wp millicache rules import rules.json --replace
```

Export and import make rules portable: build them on staging, review the JSON in version control, and import them in production.

## Rules in Code Still Work

The builder complements the PHP API, it does not replace it. Rules registered in code via the [MilliRules fluent API](https://www.millipress.com/docs/millicache/04-rules/03-examples/) keep working and appear read-only in the list. Use code for rules that belong in version control with your theme or plugin, and the builder for everything you want to change without a deployment.

## Next Steps

- [Rules Introduction](https://www.millipress.com/docs/millicache/04-rules/01-introduction/): how MilliCache rules work
- [Built-in Rules](https://www.millipress.com/docs/millicache/04-rules/02-built-in-rules/): what ships by default
- [MilliRules Conditions Reference](https://www.millipress.com/docs/millirules/05-reference/01-conditions/): every available condition

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/04-block-editor

---
title: 'Block Editor Cache Intelligence'
description: 'MilliCache Pro tracks block relationships in WordPress: edits to synced patterns, Query Loops, and Site Editor templates clear exactly the affected pages.'
menu_order: 40
---

# Block Editor

The Block Editor module understands how blocks connect your pages: when a shared block, pattern, or template changes, exactly the affected pages are cleared. Without it, edits to shared content either leave stale pages in the cache or force a full flush.

Enable it under **Settings → MilliCache → Modules → Block Editor**. The module is site-scoped, so on multisite each site configures it individually.

## What It Does

The module tracks three kinds of block relationships, each with its own toggle:

### Dynamic Blocks (default: on)

Adds cache flags for blocks like **Latest Posts** and **Query Loop**, so pages embedding them are cleared when the underlying archive changes.

A page containing a Latest Posts block is tagged with the `archive:post` flag. A Query Loop block tags the page with `archive:{post_type}` for each post type it queries. When you publish a new post, MilliCache's normal archive invalidation clears these pages along with the real archives, so embedded post lists never go stale.

### Synced Blocks (default: on)

Tracks which pages use synced blocks (patterns). When a synced block is edited, all pages containing it are automatically cleared.

Every page embedding a synced pattern is tagged with a `block:{id}` flag. Saving the pattern clears exactly those pages, whether it is used on two pages or two thousand.

### Site Editor Templates (default: off)

Clears only the pages affected when you edit a template or template part in the Site Editor: editing a template clears the pages it renders, while editing a shared part (like a global header) still clears every page that uses it.

Pages are tagged with the template (`tpl:{slug}`) and every template part (`pt:{slug}`) that rendered them, including parts nested inside other parts. While this option is active, it also replaces MilliCache's default behavior of flushing the whole site when a template part is saved: the purge becomes precise instead of site-wide.

> [!NOTE]
> The Site Editor Templates option only appears on block themes. Turn it on if you actively edit templates and parts in the Site Editor; otherwise leave it off to skip the extra cache tracking.

## How the Flags Fit In

All three features build on MilliCache's [flag system](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/). The module adds flags while pages are cached and clears by flag when the source content changes:

| You edit                     | Cleared pages                                    |
|------------------------------|--------------------------------------------------|
| A synced pattern             | Every page embedding it (`block:{id}`)           |
| A post type with Query Loops | Pages embedding a Query Loop for that post type (`archive:{type}`, via normal archive invalidation) |
| A Site Editor template       | Pages rendered by that template (`tpl:{slug}`)   |
| A template part              | Pages using that part, even nested (`pt:{slug}`) |

You can see these flags on any cached page in the [Entries browser](/docs/millicache-pro/02-modules/02-cache-entries) or in the `X-MilliCache-Flags` debug header.

## Notes

- Dynamic and synced block tracking works on classic and block themes alike; only the Site Editor Templates feature requires a block theme.
- Flags are added when a page is cached. After enabling the module, pages tagged with the new flags accumulate as the cache refills; clear the cache once to start fresh.

## Next Steps

- [Cache Flags](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/): how flag-based invalidation works
- [Cache Entries Browser](/docs/millicache-pro/02-modules/02-cache-entries): inspect the flags on any cached page
- [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading): rewarm pages after they are cleared

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/05-cache-preloading

---
title: 'Cache Preloading'
description: 'Preload the WordPress cache automatically with MilliCache Pro: pages rewarm after publishing and the full cache refills from your XML sitemaps after a clear.'
menu_order: 50
---

# Cache Preloading

Cache Preloading keeps your cache warm, so visitors never wait for a page to be built. Instead of the first visitor after a cache clear paying the full WordPress render time, MilliCache Pro requests the pages itself in the background.

Enable it under **Settings → MilliCache → Modules → Cache Preloading**.

## What Triggers a Preload

Preloading is event-driven. There is no cron schedule to configure; the module reacts to the moments where the cache actually loses entries:

| Event                              | What gets preloaded                                          |
|------------------------------------|--------------------------------------------------------------|
| **You publish or update a post**   | The post itself plus its related pages (the post-type archive, author, taxonomy, and date pages, derived from the post's cache flags) |
| **A cache entry is invalidated**   | The removed entry's URL, reconstructed from its flags, is re-queued |
| **A full cache clear**             | Every URL from your XML sitemaps                             |
| **You enable the module**          | Every URL from your XML sitemaps                             |
| **Preload Full Cache** (manual)    | Every URL from your XML sitemaps                             |

The manual trigger lives in the settings header menu (**Preload Full Cache**) and on the empty state of the [Entries browser](/docs/millicache-pro/02-modules/02-cache-entries).

> [!NOTE]
> Expiring the cache (the soft variant of clearing, where stale pages keep being served while they regenerate) intentionally does not trigger a sitemap preload. Stale-while-revalidate already keeps visitors fast in that case.

## How It Works

```mermaid
flowchart LR
    A[Cache cleared /<br/>post published] --> B[URLs queued]
    B --> C[Action Scheduler<br/>background batches]
    C --> D[HTTP request<br/>per URL]
    D --> E[Page rendered once,<br/>stored in cache]
```

1. URLs are collected in a queue (a set, so the same URL is never queued twice).
2. Action Scheduler processes the queue in background batches of 50 URLs. Processing starts within a couple of seconds: MilliCache Pro starts the background worker right away instead of waiting for the next WP-Cron run. On servers that cannot request themselves over HTTP (blocked loopback requests), batches start on the next cron run instead, typically within a minute.
3. Each URL is requested over HTTP with the user agent `MilliCache/1.0`, exactly like a visitor would. The response lands in the cache; already-cached URLs are simply a cache hit and cost nothing to re-request.

While the [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache) module has purges pending, preload batches wait and retry 30 seconds later, so pages are never warmed through a stale CDN edge.

You can watch progress in the summary bar of the Entries tab ("Fetching sitemap…", "Preloading: N URLs remaining") and in the Status tab.

## Which Sitemaps Are Preloaded

By default, you configure nothing: on every full preload, MilliCache Pro reads your site's `robots.txt` and preloads **every sitemap listed on a `Sitemap:` line**. That is the sitemap protocol's official discovery mechanism, so whatever publishes your sitemaps (WordPress core, The SEO Framework, Yoast, Rank Math) is picked up automatically, including setups with more than one sitemap. If `robots.txt` lists no sitemaps, `/sitemap.xml` is used as the fallback.

Discovery is live: the list is read fresh on each run and never stored, so a sitemap that appears later (a new language, a new SEO plugin) is picked up without touching any settings. The Status tab shows which sitemaps the last run actually used.

Sitemap indexes are followed recursively, so a sitemap that links to per-post-type sitemaps is fully expanded. Entries pointing at a different host are ignored, since preloading only warms this site's cache.

URLs are warmed exactly as the sitemap lists them. A sitemap that still advertises `http://` addresses after a move to HTTPS therefore warms nothing: each request is answered with a redirect, and redirects are never cached. If a preload run completes but the cache stays empty, a non-canonical sitemap is the first thing to check. Fixing it at the source also spares real visitors the extra hop.

### Multilingual Sites (Polylang, WPML)

Multilingual sites often publish **one sitemap per language** with no index linking them. The SEO Framework with Polylang, for example, serves `/sitemap.xml` for the default language and `/en/sitemap.xml` for English, and only `robots.txt` mentions both. Because discovery starts from `robots.txt`, all language sitemaps are found and preloaded automatically; there is nothing to configure.

SEO plugins that publish a single sitemap index covering all languages (Yoast and Rank Math in most configurations) work just as automatically, since indexes are expanded recursively.

## Settings

| Setting       | Default | Description                                                                                                                                                                 |
|---------------|---------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Sitemaps**  | empty   | XML sitemaps to preload from, as paths relative to the site root (`/sitemap.xml`) or full URLs. Leave empty to discover them automatically from `robots.txt` (recommended). |

Adding entries **replaces** the discovered list entirely; discovery is off as soon as the list has one entry. Use this to:

- **Exclude a sitemap**: enter only the ones you want, e.g. just `/sitemap.xml` to skip a language sitemap listed in `robots.txt`.
- **Preload an unlisted sitemap**: add its path or URL alongside the others.
- **Work around a static `robots.txt`**: if your `robots.txt` is a static file without `Sitemap:` lines, list your sitemaps here.

> [!IMPORTANT]
> WordPress serves no core sitemap (and no `Sitemap:` line in `robots.txt`) while **Settings → Reading → "Discourage search engines from indexing this site"** is enabled. In that case there is nothing to preload from: either make the site public or add a Sitemaps entry pointing at one generated by an SEO plugin. The Status tab warns about this state.

## Monitoring

The **Status** tab shows a Cache Preloading check with the outcome of the last sitemap run: how many URLs were queued, from which sitemaps, and how long ago. If a sitemap could not be fetched, returned an error status, or contained no URLs, the check tells you exactly that instead of failing silently — including when only one of several sitemaps failed.

## WP-CLI

```bash
wp millicache preload [--uri=<uri>] [--dry-run] [--async]
```

By default, the command fetches your sitemaps (configured or discovered from `robots.txt`) and preloads every URL synchronously with a progress bar.

**Options:**

| Option          | Description                                                                 |
|-----------------|-----------------------------------------------------------------------------|
| `--uri=<uri>`   | Preload a single URI (path or full URL) instead of the full sitemap. Use the global `--url=<site-url>` to scope to a specific site on multisite. |
| `--dry-run`     | List URLs that would be preloaded without actually requesting them          |
| `--async`       | Enqueue URLs for background processing via Action Scheduler instead of preloading synchronously |

**Examples:**

```bash
# Preload all URLs from all sitemaps
wp millicache preload

# Preload a single URI
wp millicache preload --uri=https://example.com/about/

# Preview which URLs would be preloaded
wp millicache preload --dry-run

# Enqueue for background processing
wp millicache preload --async
```

If the module is inactive, the command offers to activate it before running.

## Notes

- Background preloading runs through Action Scheduler (bundled with MilliCache Pro), which relies on site traffic or WP-Cron to process its queue. On very quiet sites, consider [running Action Scheduler via a real cron job](https://actionscheduler.org/wp-cli/) so preloads finish promptly.
- Preload requests are fire-and-forget with a short timeout. The page keeps rendering server-side after the request returns, so a large site finishes warming shortly after the queue empties.
- Disabling the module drains the queue and unschedules all pending preload work.

## Next Steps

- [Cache Clearing](https://www.millipress.com/docs/millicache/05-usage/20-cache-clearing/): what clears the cache in the first place
- [Cache Entries Browser](/docs/millicache-pro/02-modules/02-cache-entries): watch the cache refill
- [Detailed Metrics](/docs/millicache-pro/02-modules/06-detailed-metrics): measure your hit ratio over time

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/06-detailed-metrics

---
title: 'Detailed Metrics & Insights'
description: 'MilliCache Pro charts requests, bandwidth, hit ratio, and response times on the Status dashboard, with hourly and daily history and visitor time saved.'
menu_order: 60
---

# Detailed Metrics

MilliCache always tracks its cache hit ratio. The Detailed Metrics module goes further: it charts requests, **bandwidth**, and **response times** on the Status dashboard, so you can see exactly what the cache saves you and how it trends over time.

Enable it under **Settings → MilliCache → Modules → Detailed Metrics** (in the Insights group).

## The Insights Dashboard

With the module active, the Status tab gains a full analytics view:

- **Right now**: live KPI cards, including storage memory usage
- **KPI cards**: Requests, Hit ratio, and Visitor time saved for the selected period, each with a sparkline and a comparison against the previous period of equal length
- **Breakdown charts**: Requests, Bandwidth, and Hit ratio over time, plus a Response time chart comparing cached against uncached delivery (with a "~N× faster" summary)
- **Date range picker**: switch between hourly and daily granularity, pick a custom range, or use presets (Today, Last 14 days, Last 30 days, and the last 12 calendar months)

The view refreshes automatically every minute. Your granularity, date range, and site selection are remembered per browser.

On **multisite**, the Network Admin dashboard adds a site picker: view the whole network aggregated, a single site, or any subset of sites. Each site's own dashboard shows its own numbers.

## What Is Recorded

| Metric              | Recorded          | Notes                                        |
|---------------------|-------------------|----------------------------------------------|
| Hits and misses     | Always            | Powers the hit ratio, even without this module |
| Bytes served (hit/miss) | Module active | Powers the bandwidth breakdown               |
| Serve and generation time | Module active | Powers the response-time comparison        |
| Stale serves        | Module active     | Pages served from grace while regenerating   |

Data is stored in your cache storage server in hourly buckets; a nightly job rolls completed days up into daily buckets and prunes anything past retention. Recording is best-effort by design: it never adds a failure mode to serving pages. Internal requests (such as [preloading](/docs/millicache-pro/02-modules/05-cache-preloading)) are excluded, so the numbers reflect real visitors.

> [!NOTE]
> The detailed fields (bandwidth, timings, stale serves) only exist from the moment you enable the module. Time ranges before that show hits and misses only.

## Settings

| Setting               | Default  | Description                                                     |
|-----------------------|----------|------------------------------------------------------------------|
| **Keep hourly data**  | 60 days  | How far back you can zoom into per-hour detail on the dashboard. Hourly counters are the bulk of what metrics keep in storage. |
| **Keep daily data**   | 730 days | How long the per-day totals behind long-term trends are kept. Daily counters are tiny; a year costs roughly 2,500 small fields per site. |

Both accept days, weeks, months, or years. The date picker automatically limits itself to the retained window.

On multisite, Detailed Metrics is configured in the **Network Admin**: one toggle and one retention policy for the whole network. Subsites see their dashboards but do not configure recording individually.

## WP-CLI

| Command                        | Description                                            |
|--------------------------------|--------------------------------------------------------|
| `wp millicache metrics seed`   | Fill the metrics history with plausible sample data    |
| `wp millicache metrics clear`  | Delete all recorded metrics for the current site       |

**`seed` options:**

| Option            | Description                                              |
|-------------------|----------------------------------------------------------|
| `--days=<days>`   | How many days of daily history to generate (max 365, default 7) |
| `--clear`         | Wipe existing metrics before seeding                     |

**Examples:**

```bash
# Seed a week of sample metrics
wp millicache metrics seed

# Reseed a year from scratch
wp millicache metrics seed --days=365 --clear

# Start over with real data only
wp millicache metrics clear
```

Seeding is handy on staging or fresh installs when you want to see the dashboard populated before real traffic arrives. On multisite, seed individual sites with the global `--url=<site-url>` flag to populate the network dashboard.

## Notes

- Metrics buckets are stored in UTC; the dashboard renders them in your site's timezone and date format.
- Dashboard responses are cached for one minute, so freshly recorded traffic can lag up to a minute behind.
- A valid license is required: the dashboard's detail endpoint and the extended recording are Pro features. Basic hit/miss tracking works without a license.

## Next Steps

- [Cache Entries Browser](/docs/millicache-pro/02-modules/02-cache-entries): inspect what is in the cache right now
- [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading): improve the hit ratio the dashboard shows you

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/07-edge-cache

---
title: 'Edge Cache (CDN Page Caching)'
description: 'Serve WordPress pages from bunny.net or Cloudflare edge locations. MilliCache Pro tags every page at the CDN and purges it in sync with the local cache.'
menu_order: 70
---

# Edge Cache

The Edge Cache module serves cached pages from a CDN location close to your visitors. When content changes, the CDN is cleared together with the local cache, so it never shows outdated pages.

This is the piece that usually makes CDN page caching impractical for WordPress: a CDN happily caches your HTML, but has no idea when a post update makes it stale. MilliCache Pro solves this by mirroring its [flag-based invalidation](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/) onto the CDN. Every page is tagged at the edge with the same flags it carries locally, and every local purge triggers the matching edge purge.

Static files ride along: because the pull zone fronts your whole site, CSS, JavaScript, images, and fonts are delivered and cached from the edge too; both providers cache common static file types out of the box. You only need the [Asset CDN](/docs/millicache-pro/02-modules/08-cdn) module when files should live in a pull zone of their own.

Enable it under **Settings → MilliCache → Modules → Edge Cache** (Content Delivery group).

> [!NOTE]
> Edge Cache is currently in **Beta**.

## Supported Providers

| Provider       | Tagging header | Purge granularity                    |
|----------------|----------------|--------------------------------------|
| **bunny.net**  | `CDN-Tag`      | By tag (wildcard), by URL, full zone |
| **Cloudflare** | `Cache-Tag`    | By tag (exact), by URL/prefix, full zone |

## How It Works

```mermaid
flowchart LR
    subgraph "Page served"
        A[Cacheable response] --> B[Tag headers emitted<br/>with MilliCache flags]
        B --> C[CDN stores page<br/>with tags + TTL]
    end
    subgraph "Content changes"
        D[Local cache cleared<br/>by flag] --> E[Matching edge purge<br/>queued at shutdown]
        E --> F[Background purge<br/>via Action Scheduler]
    end
```

**Tagging:** when MilliCache serves a cacheable page, the module emits the page's cache flags as CDN tags (for example `post:123`, `home`, `archive:post`), plus a `Cache-Control: s-maxage` header matching MilliCache's own TTL, so edge copies expire together with the local cache.

**Purging:** the module listens to every MilliCache invalidation event, including entry expiry, post updates, attachment deletions, and full clears. Purges collected during a request are coalesced into a single background job (a full-zone purge makes narrower ones redundant) and dispatched through Action Scheduler with automatic retries.

**Personalized responses stay personal:** only the anonymous, cacheable response is edge-cached. Responses that vary per visitor (logged-in users, cookie-based variants, cache buckets) are marked `Cache-Control: private`, so the CDN never stores them. The other half of the protection lives in your zone configuration: CDNs ignore cookies when serving an already-stored page, so the provider setups below include a cookie bypass that keeps the edge from serving the anonymous copy to a logged-in visitor. Do not skip that step.

## Setup: bunny.net

1. Create a **pull zone** for your site in the bunny.net dashboard.
2. In the pull zone's caching settings, set **Cache Expiration Time** to *"Respect Origin Cache-Control"*, so pages expire together with the local cache.
3. Keep **"Strip Response Cookies" off**. It is on by default, and with it on, logins break because the zone swallows WordPress's login cookies.
4. Do not enable **"Vary Cache by Request Headers"**: bunny.net disables tag and URL purging when it is on, which would leave only full-zone purges.
5. Add an **Edge Rule** that bypasses the cache for logged-in visitors, so they always reach your server instead of a stored anonymous page: set the action to *"Override Cache Time"* with **0** seconds, and add a *Request Header* condition on the header `Cookie` with the value `*wordpress_logged_in_*`. Add `*wp-postpass_*` and `*comment_author_*` as further values of the same condition (match *any*).
6. In MilliCache, select **bunny.net** as the provider, then enter:
   - **API key**: your bunny.net account API key (Account Settings → API). Stored encrypted; used only to purge your pull zone.
   - **Pull zone ID**: the numeric ID of the pull zone that fronts this site.
7. Click **Test connection**. A **Connected** badge confirms the zone is reachable.
8. Point your site's DNS at the pull zone and enable the module.

## Setup: Cloudflare

1. In the Cloudflare dashboard, create an **API token** with the *Zone Read* and *Cache Purge* permissions for your zone (My Profile → API Tokens).
2. Copy the **Zone ID** from the zone's Overview page (under API).
3. Cloudflare only caches pages when a **Cache Rule** makes HTML eligible. Add one for this site with Edge TTL set to *"Use cache-control header if present, bypass cache if not"*: only pages MilliCache marks cacheable are stored, and they expire together with the local cache.
4. In the same Cache Rule, exclude requests that carry WordPress auth cookies. Cloudflare ignores cookies when serving a stored page, so without this exclusion a logged-in visitor gets the anonymous copy (no admin bar, no personalization). Use a custom filter expression like:

   ```
   (http.host eq "example.com"
   and not http.cookie contains "wordpress_logged_in_"
   and not http.cookie contains "wordpress_sec_"
   and not http.cookie contains "wp-postpass_"
   and not http.cookie contains "comment_author_")
   ```

   Running a shop? Exclude its session cookies the same way (for WooCommerce: `woocommerce_items_in_cart` and `woocommerce_cart_hash`).
5. In MilliCache, select **Cloudflare** as the provider, enter the API token and Zone ID, and click **Test connection**.
6. Enable the module.

> [!NOTE]
> Tag-based purging ("purge by Cache-Tag") is available on all Cloudflare plans. The Free plan rate-limits purge calls; MilliCache retries rate-limited purges automatically with increasing delays.

## Behind a Host CDN (Kinsta and Similar)

Some hosts route every site through their own Cloudflare layer: Kinsta does for all sites, WP Engine on its Advanced Network and Global Edge Security offerings, and hosts built on Cloudflare Enterprise (Rocket.net, Cloudways with the Cloudflare add-on) likewise. When your own Cloudflare zone sits in front of such a host, traffic passes through two Cloudflare zones in a row; Cloudflare calls this topology [Orange-to-Orange (O2O)](https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/saas-customers/how-it-works/). In it, tag purging silently breaks: `Cache-Tag` is a Cloudflare-internal header, so the host's Cloudflare consumes and strips it from the response before it reaches your zone, the layer actually caching your pages. Pages are then stored untagged, and tag purges succeed at the API but match nothing, leaving stale pages at the edge until the TTL expires.

To check whether you are affected, compare a tag purge with a URL purge on a cached page: `wp millicache edge purge --flag=post:123` has no effect, while `wp millicache edge purge --uri=https://example.com/sample-post/` clears it instantly.

The fix takes two steps:

1. In MilliCache, enable **Host CDN compatibility** in the Edge Cache settings (shown when Cloudflare is the provider). MilliCache then emits its tags under the neutral `MilliCache-Edge-Flags` header, which passes through the host's CDN untouched.
2. In your Cloudflare zone, convert that header back into cache tags: go to **Caching → Cache Rules**, open the **Cache Response Rules** tab, and create a rule matching all incoming requests with the action **Modify cache tags**. As the source, pick **Parse from response header** with header name `millicache-edge-flags` (entered in lowercase), separator `,` (comma), and limit `128`; as the action below, pick **Override existing tags**. Cache Response Rules are available on every Cloudflare plan.

Clear the cache once after enabling the toggle, so existing entries are re-stored with the new header. If you prefer visitors not to see the tag list, add a second action to the same rule that removes the `MilliCache-Edge-Flags` response header after parsing.

## Settings

| Setting                       | Provider   | Description                                                                                                                                                                   |
|-------------------------------|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Provider**                  | both       | The CDN whose edge MilliCache tags and purges                                                                                                                                 |
| **API key**                   | bunny.net  | Account API key, stored encrypted                                                                                                                                             |
| **Pull zone ID**              | bunny.net  | Numeric ID of the pull zone fronting this site                                                                                                                                |
| **API token**                 | Cloudflare | Token with Zone Read and Cache Purge permissions, stored encrypted                                                                                                            |
| **Zone ID**                   | Cloudflare | The zone's ID from its Overview page                                                                                                                                          |
| **Host CDN compatibility**    | Cloudflare | Emits tags under a header that survives a host's own CDN layer; see [Behind a Host CDN](#behind-a-host-cdn-kinsta-and-similar)                                                |
| **Shared zone**            | both       | Keeps this installation's clears from reaching other WordPress installations that share the zone; see [Several installations in one zone](#several-installations-in-one-zone) |
| **Test connection**           | both       | Saves and verifies the credentials against the provider API                                                                                                                   |

On **multisite**, Edge Cache is configured in the Network Admin: one shared pull zone fronts the whole network. Tags are automatically site-prefixed (`2:post:123`), so purges stay isolated per site even though every site's pages share one zone.

### Several installations in one zone

Separate WordPress installations, single sites or whole multisites, can share one zone too, for example several sites under one Cloudflare domain. Out of the box their tags collide: every installation tags its pages `post:123` or `2:post:123`, so clearing a post on one also evicts the matching pages of the others, and a full clear on any of them empties the zone for all.

Turn on **Shared zone** on each one to give it its own namespace. The installation's [Key Prefix](/docs/millicache-pro/02-modules/10-storage-connections#shared-connection-settings) from the Storage settings becomes its tag namespace (`{prefix}:post:123`, or `{prefix}:2:post:123` on a Multisite), and a site or network clear purges only that namespace. Two things follow:

- Each installation needs a distinct Key Prefix. The auto-generated default (`mc_ab12`) already is one, but a short readable prefix such as `shop` or `blog` makes the tags easier to recognise in your CDN dashboard: `shop:post:123`. Set it before turning isolation on, because changing it later moves the local cache to a new namespace.
- A clear no longer purges the whole zone, so files without tags (CSS, JavaScript and images served straight from your server) stay at the edge until they expire. Use *Purge edge cache* from the command palette or `wp millicache edge purge --all` when you need the whole zone gone.

Switching isolation on or off, or changing the Key Prefix, purges the whole zone once, because tag can no longer reach pages tagged under the old namespace.

## WP-CLI

| Command                     | Description                                       |
|-----------------------------|---------------------------------------------------|
| `wp millicache edge status` | Show provider, configuration, and enabled state   |
| `wp millicache edge test`   | Test the configured credentials against the zone  |
| `wp millicache edge purge`  | Purge edge objects synchronously                  |

**`purge` options:**

| Option           | Description                                                             |
|------------------|--------------------------------------------------------------------------|
| `--all`          | Purge the entire pull zone                                              |
| `--flag=<flag>`  | Purge every edge object tagged with this MilliCache flag (e.g. `post:123`, `home`); accepts a comma-separated list |
| `--uri=<uri>`    | Purge a specific media URL; a trailing `*` requests a prefix purge; accepts a comma-separated list |

**Examples:**

```bash
wp millicache edge purge --all
wp millicache edge purge --flag=post:123,home
wp millicache edge purge --uri=https://example.com/wp-content/uploads/a.jpg
```

Unlike runtime invalidation (which is queued in the background), CLI purges run synchronously.

## Developer Filters

| Filter                          | Purpose                                                              |
|---------------------------------|----------------------------------------------------------------------|
| `millicache_edge_ttl`           | Override the edge TTL in seconds; return `0` to emit no TTL header and let the zone configuration govern expiry |
| `millicache_edge_tag_flags`     | Prune or adjust the flags emitted as edge tags                       |
| `millicache_edge_variant_vary`  | Opt a response variant into edge caching by returning a `Vary` header name (only effective on providers that honor response Vary; the current providers do not, so variants stay private) |

## Immediate Purges for Integrations

Runtime invalidation is coalesced and queued in the background, which is right for bulk clears. But an integration that purges one or two targets on a user's click (replacing an image, for example) wants the edge clean before the user reloads. Fire this action to purge immediately, in the same request:

```php
do_action( 'millicache_edge_purge', array( 123, 'home', '/pricing/', 'https://example.com/wp-content/uploads/a.jpg' ) );
```

It takes a single target or a list, mixed freely, with the same semantics as MilliCache's clear targets: a post ID purges that post's pages, a flag (`home`, `archive`) purges everything tagged with it, and a path or absolute URL purges by URL. URL targets may live on another hostname than your site; purging media from a separate media pull zone is the intended use. How far that reaches depends on the provider: bunny.net resolves the URL to any pull zone in the same account, while Cloudflare only purges hostnames of the configured zone (its domain and subdomains; a different domain is a different zone and stays untouched). A media CDN at a different provider than the page cache cannot be purged. `*` purges the configured pull zone, and since a separate media zone keeps its objects, URL targets still run alongside it.

The action is safe to fire unconditionally: while the Edge Cache module is off or unconfigured (or MilliCache is not installed at all), it simply does nothing. Small payloads purge before the action returns; payloads above ten targets and rate-limited retries run through the background queue.

## Notes

- Queued purges start within a couple of seconds of a content change: MilliCache Pro starts the background worker right away instead of waiting for the next WP-Cron run. On servers that cannot request themselves over HTTP (blocked loopback requests), purges run on the next cron run instead, typically within a minute.
- Rate-limited purges are retried automatically (three attempts in total, with increasing delays). If a purge is still rate-limited after the final attempt, the edge copy is left to expire by its TTL.
- While edge purges are pending, [cache preloading](/docs/millicache-pro/02-modules/05-cache-preloading) batches wait, so pages are never rewarmed through a stale edge.

## Next Steps

- [Asset CDN](/docs/millicache-pro/02-modules/08-cdn): offload CSS, JS, images, and fonts
- [Cache Flags](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction/): the tags mirrored to the edge
- [Cache Clearing](https://www.millipress.com/docs/millicache/05-usage/20-cache-clearing/): every local clear becomes an edge purge

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/08-cdn

---
title: 'Asset CDN'
description: 'Offload CSS, JavaScript, images, and fonts to a CDN pull zone. MilliCache Pro rewrites asset URLs and bakes them into the WordPress page cache at zero cost.'
menu_order: 80
---

# Asset CDN

The Asset CDN module serves static files (CSS, JavaScript, images, fonts) from a CDN instead of your server. Pages load faster and your server handles fewer requests.

It works with any pull-zone CDN (bunny.net, KeyCDN, CloudFront, and similar): the CDN fetches each file from your server the first time it is requested, then serves it from the edge. MilliCache Pro's job is rewriting the asset URLs in your HTML to point at the CDN hostname.

Already running [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache)? Its pull zone fronts your whole site, so your static files are cached at the edge as well. Use the Asset CDN when files should live in a pull zone of their own, or when the site itself is not routed through a CDN.

You find it under **Settings → MilliCache → Modules → Asset CDN** (Content Delivery group).

> [!NOTE]
> The Asset CDN is currently in **Beta**.

## Setup

1. Create a **pull zone** at your CDN provider that uses this site as its origin.
2. Enable **CORS** on the zone so fonts load correctly.
3. In MilliCache, enter the zone hostname (or a custom domain pointing at it) as the **CDN hostname**.
4. Enable the module. Your pages now link static files from the CDN hostname.

That is the whole setup: no files are uploaded or synchronized, the pull zone fetches them on demand.

## How It Works

The module rewrites URLs in your HTML output: any URL on your own host (or one of the additional hostnames) that points into an included directory and ends in an included extension is switched to the CDN hostname. That covers absolute URLs, protocol-relative URLs, root-relative paths, and even JSON-escaped URLs inside inline scripts. Query strings like `?ver=6.5` pass through untouched, so cache busting keeps working.

The rewrite is designed to cost nothing on cached traffic: it runs just inside MilliCache's page buffer, so on cacheable requests the **rewritten HTML is what gets stored**. Cache hits are served with CDN URLs already baked in, before WordPress even loads. Uncached front-end requests (including logged-in users) are rewritten on the fly, so every visitor sees consistent URLs.

The admin, feeds, and the Customizer preview are never rewritten.

## Settings

| Setting                  | Default                        | Description                                       |
|--------------------------|--------------------------------|---------------------------------------------------|
| **CDN hostname**         | empty                          | Your pull zone hostname, or a custom domain pointing at it. A bare hostname is normalized to `https://`. |
| **Additional hostnames** | empty                          | Files on your site's own hostname are always served from the CDN. Add other domains your files are linked under, e.g. `www.example.com` when the site runs on `example.com`. |
| **Included directories** | `wp-content`, `wp-includes`    | Only files in these folders are served from the CDN. `*` is a wildcard, and regular expressions go between `#` characters. |
| **Included extensions**  | `css, js, png, jpg, jpeg, gif, webp, avif, svg, ico, woff, woff2, ttf, otf, eot, mp4, webm, pdf` | Only these file types are served from the CDN. `*` is a wildcard: `jp*g` matches jpg and jpeg. |
| **Exclusions**           | empty                          | Files matching any of these stay on your server. Plain text matches anywhere in the URL, `*` is a wildcard (e.g. `/uploads/*.svg`), and regular expressions go between `#` characters. |

Because CDN URLs are baked into cached pages, changing any of these settings (or toggling the module) clears the page cache so no page keeps serving outdated URLs.

On **multisite**, the CDN is configured in the Network Admin: one CDN hostname fronts the whole install.

## WP-CLI

```bash
wp millicache cdn status
```

Prints the configured hostname, enabled state, and the current additional-hostname, directory, extension, and exclusion lists.

## Notes

- The CDN hostname must differ from your site's hostname; if they match, the module does nothing.
- Only HTML responses are rewritten. JSON and other response types pass through untouched.
- Fonts require CORS headers from the CDN; if icon fonts or web fonts fail to load after enabling, check the zone's CORS setting first.
- The module rewrites asset URLs only. To serve whole **pages** from the CDN edge, use the [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache) module.

## Next Steps

- [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache): serve cached pages from the CDN edge, too
- [How Caching Works](https://www.millipress.com/docs/millicache/05-usage/10-how-caching-works/): where the rewrite sits in the request flow

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/09-object-cache

---
title: 'Persistent Object Cache'
description: 'MilliCache Pro ships a persistent Redis object cache drop-in for WordPress, reusing the page cache connection to speed up wp-admin and uncached pages.'
menu_order: 90
---

# Object Cache

The Object Cache module remembers the results of repeated database queries, so WordPress does not run them again on every request. This speeds up the admin and all uncached pages.

It ships MilliCache's own `object-cache.php` drop-in, backed by the same storage connection as the full-page cache. No second Redis configuration, no extra plugin: enable the toggle and the drop-in is installed.

Enable it under **Settings → MilliCache → Modules → Object Cache** (Caching group).

## Why Pair It With the Page Cache

The page cache makes cached pages fast; the object cache makes everything else faster:

- **wp-admin**, which the page cache never touches
- **Cache misses**, where WordPress has to render the page
- **Logged-in users** and other requests your rules bypass

Because it reuses the page cache's connection and storage server, it needs no setup of its own and is monitored together with the rest of MilliCache in the Status tab.

## How It Works

The drop-in is a two-tier cache:

- **In-memory tier**: values already read during the current request are served from PHP memory, exactly like WordPress core's built-in cache.
- **Persistent tier**: values survive across requests in your storage server, namespaced under their own key prefix so they never collide with page cache entries. Flushing the object cache never touches cached pages, and clearing the page cache never touches object cache data.

If the storage server is briefly unreachable, the drop-in degrades transparently to the in-memory tier instead of taking the site down. If the [igbinary](https://www.php.net/manual/en/book.igbinary.php) PHP extension is available, it is used automatically for faster, smaller serialization.

On **multisite**, keys are isolated per site, global groups are shared network-wide, and the module is managed from the Network Admin (the drop-in is one file for the whole install).

## Playing Nice With Other Object Caches

MilliCache never overwrites or removes a drop-in it does not own:

- If another `object-cache.php` is already installed, MilliCache works fine alongside it and the module stays off. You do not need to change anything.
- To switch to the built-in object cache instead, remove the existing drop-in first; the toggle unlocks automatically.
- Disabling the module removes the drop-in again (only if it is MilliCache's own).

## Requirements

| Requirement            | Why                                                                    |
|------------------------|-------------------------------------------------------------------------|
| `WP_CACHE` set to `true` | The page cache boots the storage connection the drop-in reuses. With `WP_CACHE` off, the object cache runs in-memory only and the Status tab tells you so. |
| Writable `wp-content`  | The drop-in file is installed there                                     |
| No foreign drop-in     | MilliCache refuses to replace another plugin's object cache             |

## WP-CLI

| Command                                 | Description                                    |
|-----------------------------------------|------------------------------------------------|
| `wp millicache object-cache status`     | Show drop-in state and module setting          |
| `wp millicache object-cache enable`     | Install the drop-in and switch the module on   |
| `wp millicache object-cache disable`    | Switch the module off and remove the drop-in   |
| `wp millicache object-cache flush`      | Flush the object cache                         |

`enable` refuses to run while a foreign `object-cache.php` is installed, and `disable` leaves a foreign drop-in in place, matching the UI behavior.

## Enabling via wp-config.php

For automated deployments, the toggle can be pinned as a constant:

```php
define( 'MC_OBJECT_CACHE_ACTIVE', true );
```

The constant overrides the saved setting and locks the toggle in the admin UI, following MilliCache's usual [configuration priority](https://www.millipress.com/docs/millicache/02-configuration/01-overview/). The constant behaves exactly like the toggle: setting it to `true` installs the drop-in on the next admin request, setting it to `false` removes it again. The foreign drop-in guard applies as always.

## Notes

- The object cache implements the full modern WordPress cache API, including multi-key operations (`wp_cache_get_multiple` and friends), `wp_cache_flush_runtime`, and `wp_cache_flush_group`.
- `wp cache flush` (and `wp millicache object-cache flush`) clears only object cache data, never cached pages.
- A storage outage is logged to the PHP error log and handled gracefully; the site keeps running on the in-memory tier.

## Next Steps

- [Storage Backends](https://www.millipress.com/docs/millicache/08-storage-backends/01-overview/): the server both caches share
- [High-Availability Connections](/docs/millicache-pro/02-modules/10-storage-connections): replication and Sentinel for the shared connection

---

Canonical: https://www.millipress.com/docs/millicache-pro/02-modules/10-storage-connections

---
title: 'High-Availability Storage Connections'
description: 'Configure Redis replication and Sentinel from the MilliCache Pro settings UI: a visual connection editor covers single-server, replica, and Sentinel setups.'
menu_order: 100
---

# Storage Connections

MilliCache supports high-availability storage out of the box: a single server, a master/replica replication set, or a [Sentinel](https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/)-managed cluster (see [Storage Backends](https://www.millipress.com/docs/millicache/08-storage-backends/01-overview/) in the MilliCache docs). Without Pro, replication and Sentinel are configured through the `MC_STORAGE_HOST` constant in `wp-config.php`.

MilliCache Pro upgrades the **Storage Server** section of the settings screen with a visual connection editor, so all three topologies can be configured from the UI.

## Connection Modes

The **Connection** field offers three modes:

### Single Server

One host and port (or a Unix socket path, or a `tls://` host for encrypted connections). This is MilliCache's default configuration.

### Replication

A **master** node plus any number of **replicas**. MilliCache writes to the master and can distribute reads across replicas. Add replica rows as needed.

### Sentinel

A **service name** (the Sentinel master group name, e.g. `mymaster`) plus the list of **Sentinel** nodes (default port 26379). Sentinel discovers the current master automatically, so failovers are handled without touching your WordPress configuration.

Switching between modes keeps the first host you entered, so trying a topology is non-destructive.

## Shared Connection Settings

The username, password, and database below the connection field apply to **every node** of the configured topology:

| Setting                            | Default        | Description                                                                                                                                                                                                                                                                                                            |
|------------------------------------|----------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Username**                       | empty          | ACL username, if your server uses named users                                                                                                                                                                                                                                                                          |
| **Password**                       | empty          | Stored encrypted                                                                                                                                                                                                                                                                                                       |
| **Database ID**                    | `0`            | Database number within the server (typically 0-15)                                                                                                                                                                                                                                                                     |
| **Key Prefix**                     | auto-generated | Namespaces every cache key this site writes; only change it to pin a specific namespace when several installations share one server. Letters, digits, hyphens and underscores, up to 8 characters. The [Edge Cache](/docs/millicache-pro/02-modules/07-edge-cache#several-installations-in-one-zone) module reuses it as the site's tag namespace |
| **Persistent Storage Connection**  | on             | Reuse the connection between requests instead of reconnecting each time                                                                                                                                                                                                                                                |

## Constants Still Win

If `MC_STORAGE_HOST` is defined in `wp-config.php`, the connection editor shows the pinned value read-only ("Defined by the MC_STORAGE_HOST constant in wp-config.php."). Constants take precedence over UI settings, exactly as in MilliCache itself; the [constants reference](https://www.millipress.com/docs/millicache/02-configuration/02-reference/) documents the array shapes for replication and Sentinel.

## Notes

- A full settings reset preserves the storage connection, so resetting your caching behavior never disconnects your cache server.
- The [Object Cache](/docs/millicache-pro/02-modules/09-object-cache) drop-in rides the same connection automatically, including replication and Sentinel setups.

## Next Steps

- [Storage Backends](https://www.millipress.com/docs/millicache/08-storage-backends/01-overview/): supported servers and HA concepts
- [Configuration Reference](https://www.millipress.com/docs/millicache/02-configuration/02-reference/): the equivalent `wp-config.php` constants

---

Canonical: https://www.millipress.com/docs/millicache-pro/03-wp-cli/01-commands

---
title: 'WP-CLI Commands'
description: 'Complete WP-CLI reference for MilliCache Pro: license, module, entries, rules, preload, metrics, edge, cdn, and object-cache commands with usage examples.'
menu_order: 10
---

# WP-CLI Commands

MilliCache Pro extends the `wp millicache` command with everything the Pro modules can do. [MilliCache's own commands](https://www.millipress.com/docs/millicache/06-wp-cli/01-commands/) (`clear`, `stats`, `status`, `test`, `config`, and friends) all keep working.

## Quick Reference

| Command                              | Description                                     |
|--------------------------------------|-------------------------------------------------|
| `wp millicache license status`       | Show license state, seats, masked key           |
| `wp millicache license activate`     | Activate a license key                          |
| `wp millicache license deactivate`   | Deactivate and free this install's seats        |
| `wp millicache license subsite-state`| Show/set a subsite's license mode (multisite)   |
| `wp millicache license install-id`   | Show or reset the install identity              |
| `wp millicache module list`          | List modules and their status                   |
| `wp millicache module activate`      | Activate a module                               |
| `wp millicache module deactivate`    | Deactivate a module                             |
| `wp millicache entries`              | List cached entries with filters                |
| `wp millicache rules <sub>`          | Manage custom caching rules                     |
| `wp millicache preload`              | Preload the cache from the sitemap              |
| `wp millicache metrics seed`         | Seed sample metrics data                        |
| `wp millicache metrics clear`        | Delete recorded metrics                         |
| `wp millicache edge <sub>`           | Test, inspect, and purge the edge cache         |
| `wp millicache cdn status`           | Show the Asset CDN configuration                      |
| `wp millicache object-cache <sub>`   | Manage the object cache drop-in                 |

> On multisite, commands that touch settings accept `--network` to address the
> network scope. Commands for network-owned modules (Edge Cache, Asset CDN, Object
> Cache) read the owning scope automatically. Use WP-CLI's global
> `--url=<site-url>` to run in a specific site's context.

---

## License

### wp millicache license status

```bash
wp millicache license status [--network] [--format=<format>]
```

| Option              | Description                                                        |
|---------------------|--------------------------------------------------------------------|
| `--network`         | Multisite: list every subsite's license mode plus a network summary |
| `--format=<format>` | `table`, `json`, `csv`, `yaml` (default: `table`)                  |

### wp millicache license activate

```bash
wp millicache license activate [--key=<key>]
```

Saves the key and activates it; without `--key`, an already-saved key is activated. On multisite this activates the network license covering all active subsites.

### wp millicache license deactivate

```bash
wp millicache license deactivate
```

Frees this install's activation seats, for example before migrating to a new domain.

### wp millicache license subsite-state

```bash
wp millicache license subsite-state <blog_id> [<state>]
```

Show or set a subsite's license mode on multisite: `network`, `own_key`, or `opted_out`.

### wp millicache license install-id

```bash
wp millicache license install-id [show|reset]
```

Shows or resets the install's identity. Reset it on a cloned site so the clone activates as its own install.

---

## Modules

### wp millicache module

```bash
wp millicache module list [--format=<format>]
wp millicache module activate [<module>]
wp millicache module deactivate [<module>]
```

`list` shows every toggleable module with its status. `activate` and `deactivate` take the module key (for example `preload`); without one, they prompt interactively.

```bash
wp millicache module list
wp millicache module activate preload
wp millicache module deactivate cdn --network
```

---

## Cache Entries

### wp millicache entries

```bash
wp millicache entries [--search=<term>] [--flag=<pattern>] [--type=<type>]
                      [--post-type=<type>] [--status=<status>] [--gzip=<gzip>]
                      [--orderby=<field>] [--order=<order>]
                      [--fields=<fields>] [--format=<format>]
```

Lists cached entries with the same filters as the [Entries browser](/docs/millicache-pro/02-modules/02-cache-entries), where the options are documented in full.

```bash
wp millicache entries --flag="post:*"
wp millicache entries --type=singular --post-type=page --format=json
```

---

## Rules

### wp millicache rules

```bash
wp millicache rules list [--format=<format>]
wp millicache rules get <id> [--format=<format>]
wp millicache rules enable <id>
wp millicache rules disable <id>
wp millicache rules delete <id>
wp millicache rules export [--file=<file>]
wp millicache rules import <file> [--replace]
```

Manages the custom rules from the [Rules builder](/docs/millicache-pro/02-modules/03-rules-builder). `import` appends and skips duplicate IDs by default; `--replace` swaps out all existing custom rules.

`list` shows both kinds, marked in the `source` column: `settings` for the rules from the Rules Builder, `code` for those a plugin, a theme or MilliCache itself registers. Only the stored ones can be changed here; a code rule has to be changed where it is defined.

```bash
wp millicache rules export --file=rules.json
wp millicache rules import rules.json --replace
```

---

## Preloading

### wp millicache preload

```bash
wp millicache preload [--uri=<uri>] [--dry-run] [--async]
```

| Option        | Description                                                         |
|---------------|----------------------------------------------------------------------|
| `--uri=<uri>` | Preload a single URI (path or full URL) instead of the full sitemap |
| `--dry-run`   | List URLs that would be preloaded without requesting them           |
| `--async`     | Enqueue for background processing instead of preloading synchronously |

```bash
wp millicache preload
wp millicache preload --uri=https://example.com/about/
wp millicache preload --dry-run
wp millicache preload --async
```

---

## Metrics

### wp millicache metrics seed

```bash
wp millicache metrics seed [--days=<days>] [--clear]
```

Fills the metrics history with plausible sample data so the dashboard has something to chart on a fresh install.

| Option          | Description                                                     |
|-----------------|------------------------------------------------------------------|
| `--days=<days>` | How many days of daily history to generate (max 365, default 7) |
| `--clear`       | Wipe existing metrics before seeding                            |

### wp millicache metrics clear

```bash
wp millicache metrics clear
```

Deletes all recorded metrics for the current site.

---

## Edge Cache

### wp millicache edge

```bash
wp millicache edge status
wp millicache edge test
wp millicache edge purge [--all] [--flag=<flag>] [--uri=<uri>]
```

`status` shows the provider and configuration state, `test` verifies the credentials against the zone, and `purge` purges synchronously:

| Option          | Description                                                              |
|-----------------|---------------------------------------------------------------------------|
| `--all`         | Purge the entire pull zone                                               |
| `--flag=<flag>` | Purge every edge object tagged with this flag; comma-separated list allowed |
| `--uri=<uri>`   | Purge a specific media URL; trailing `*` requests a prefix purge         |

```bash
wp millicache edge purge --all
wp millicache edge purge --flag=post:123,home
```

---

## Asset CDN

### wp millicache cdn status

```bash
wp millicache cdn status
```

Prints the configured hostname, enabled state, and the directory, extension, and exclusion lists.

---

## Object Cache

### wp millicache object-cache

```bash
wp millicache object-cache status
wp millicache object-cache enable
wp millicache object-cache disable
wp millicache object-cache flush
```

`enable` installs the drop-in and switches the module on (refusing while a foreign `object-cache.php` is installed); `disable` switches it off and removes MilliCache's own drop-in; `flush` clears the object cache without touching cached pages.

### wp millicache drop

```bash
wp millicache drop                        # reinstall all drop-ins
wp millicache drop object-cache --force   # force-reinstall just the object cache
wp millicache drop --force                # force-reinstall everything
```

Reinstalls MilliCache's drop-in files in `wp-content` — ideal for CD pipelines where symlinks break between deploys. With no name it covers both `advanced-cache` and (with the object cache switched on) `object-cache`; pass a name to target one. `--force` rewrites the drop-in even when it looks current and cold-starts the object cache so no stale entries survive. A third-party `object-cache.php` is always left in place.

---

## Next Steps

- [MilliCache WP-CLI Commands](https://www.millipress.com/docs/millicache/06-wp-cli/01-commands/): the base commands: clear, stats, status, test, config
- [Modules Overview](/docs/millicache-pro/02-modules/01-overview): what each module does
- [AI Access](/docs/millicache-pro/05-abilities/01-abilities): the same operations for a REST client or an assistant

---

Canonical: https://www.millipress.com/docs/millicache-pro/04-command-palette/01-commands

---
title: 'Command Palette'
description: 'Run MilliCache Pro from the WordPress command palette: preload the cache, purge the edge cache, flush the object cache, clear by URL or flag, and jump to the Entries and Rules tabs.'
menu_order: 10
---

# Command Palette

WordPress 7.0 ships a command palette, and MilliCache puts its verbs in it. Press <kbd>⌘K</kbd> (<kbd>Ctrl</kbd>+<kbd>K</kbd> on Windows) anywhere in the admin, or click **Cache** in the admin bar.

The two entry points differ:

- **The admin bar button** opens the palette with the MilliCache commands already listed, so you can pick one without typing.
- **⌘K** opens the palette empty, the way WordPress intends. MilliCache's commands are there, you just search for them.

## What you can run

| Command | What it does |
|---------|--------------|
| **Clear website cache** | Empties the whole page cache for this site |
| **Expire website cache** | Marks every page stale instead of deleting it, so visitors keep getting an instant response while pages regenerate |
| **Preload full cache** | Queues every URL from your sitemaps for background preloading |
| **Purge … edge cache** | Empties the whole zone at your CDN. The label names your provider, e.g. *Purge Cloudflare edge cache* |
| **Flush object cache** | Empties the object cache |
| **Entries** | Opens the Entry Browser |
| **Rules** | Opens the Rules Builder |

### When to purge the edge cache by hand

You usually don't have to. Clearing or expiring anything already purges its copy at the CDN: MilliCache tags every edge response, and a site clear purges that site's tags along with the local cache.

Reach for **Purge … edge cache** when tagging can't do the job:

- bunny.net's zone-level Vary Cache disables tag and URL purges, so emptying the whole zone is the only option
- you changed the edge configuration and want a clean slate
- something is still stale at the CDN after a normal clear

It empties everything the zone holds, so the next visit to every page goes to your server again.

Commands only appear when their module is switched on and your license is active. On a multisite, **Purge edge cache** and **Flush object cache** live in the Network Admin, alongside the network-wide clear and expire.

## Clearing a single page

Type what you want to clear instead of picking a command, and MilliCache offers to clear or expire exactly that:

| You type | What gets cleared |
|----------|-------------------|
| `/blog/` | That page |
| `https://example.com/blog/` | The same, written out |
| `123` | The post with that ID |
| `post:123` | The same, as a cache flag |
| `flag:home` | The `home` flag |
| `home*` | Every flag starting with `home` |

Separate several targets with commas: `/blog/, /shop/, post:12`.

Bare words are treated as a search, not as a cache flag — otherwise every search anywhere in the admin would offer to clear it. To clear a flag whose name is a plain word, either write `flag:` in front of it or open the palette from the admin bar button, where a bare flag is unambiguous.

In the Network Admin every target is a flag pattern instead, so `5:*` clears site 5 and `*:posts` clears the `posts` flag on every site.

## Related

- [WP-CLI Commands](/docs/millicache-pro/03-wp-cli/01-commands) — the same operations for scripts and deploys
- [Cache Entries](/docs/millicache-pro/02-modules/02-cache-entries) — the browser behind the **Entries** command
- [Cache Preloading](/docs/millicache-pro/02-modules/05-cache-preloading) — what **Preload full cache** queues
- [AI Access](/docs/millicache-pro/05-abilities/01-abilities): the same operations for a REST client or an assistant

---

Canonical: https://www.millipress.com/docs/millicache-pro/05-abilities/01-abilities

---
title: 'AI Access'
description: 'Every MilliCache Pro operation as a WordPress ability: what an assistant can read, what it can change, what it can never do, and how rule writing is kept safe.'
menu_order: 10
---

# AI Access

WordPress 7.0 ships the Abilities API, a register of the things a site can do, described well enough that software can pick the right one. MilliCache Pro registers its operations there, so the cache can be inspected and steered from a REST client, a script, or an AI assistant.

Everything on this page is also available from the [WP-CLI commands](/docs/millicache-pro/03-wp-cli/01-commands) and the [command palette](/docs/millicache-pro/04-command-palette/01-commands). Abilities are a third door to the same rooms, not a separate set of powers.

## Two doors, and they are not the same

Every ability is reachable over REST at `/wp-json/wp-abilities/v1/`, guarded by the same capability as the settings screen. Nothing is exposed to a visitor.

A **subset** is additionally offered to AI assistants over MCP, which needs the [MCP Adapter](https://github.com/WordPress/mcp-adapter) plugin. That subset is deliberately smaller: REST exposure means *you* can call it, MCP means *a model may decide to call it on your behalf*. Those are different questions, so they have different answers.

## What you can do

| Ability | What it does | Assistant |
|---|---|---|
| `cache-status` | Cache state, entry count, size | ✅ |
| `cache-clear` | Clear the whole cache, or by URL or flag | ✅ |
| `cache-entries-list` | List cached pages as metadata | ✅ |
| `cache-entry-get` | One page in detail, including why it has several copies | ✅ |
| `rules-list` | Every caching rule in full | ✅ |
| `rules-schema` | The building blocks a rule may use | ✅ |
| `rules-create` | Add a rule, always switched off | ✅ |
| `rules-update` | Change part of a rule | ✅ |
| `rules-toggle` | Switch a rule on or off | ✅ |
| `rules-delete` | Remove a rule that is switched off | ✅ |
| `preload-status` | What the preload queue is doing | ✅ |
| `preload` | Queue the sitemap URLs for preloading | ✅ |
| `edge-status` | Provider, zone, credentials, active state | ✅ |
| `edge-test` | Verify the stored credentials against the provider | ✅ |
| `edge-purge` | Empty the whole zone at your CDN | ❌ |
| `object-cache-status` | Drop-in state and connection | ✅ |
| `object-cache-flush` | Empty the object cache | ❌ |
| `settings-export` | Settings without any secrets | ✅ |
| `settings-backup` | Snapshot the current settings | ❌ |
| `settings-reset` | Back to defaults | ❌ |
| `settings-restore` | Return to the snapshot | ❌ |

`cache-status`, `cache-clear` and the settings abilities come from MilliCache and are there without a license. The rest arrive with their module, so an ability exists only while its module is switched on and your license is active.

### Why some are closed to assistants

The pattern is not "read is safe, write is dangerous". `cache-clear` is a write and is open, because a cleared cache costs a little server time and rebuilds itself. What stays closed is what a person should be choosing:

- **`edge-purge` and `object-cache-flush`** empty everything at once, on every site of a network, and the cost lands at your CDN or your storage server rather than here.
- **`settings-reset` and `settings-restore`** replace your configuration. `settings-backup` is closed too, because there is no import ability to pair it with: on its own it would guard against a risk that does not exist over this route.

`settings-export` never returns your storage password or API keys, whichever route it is called from. Anything an ability returns can end up in a conversation with a third party, so secrets are decided per field, not per caller.

## Writing rules safely

Caching rules are the one place where an assistant could quietly break a site. A rule that switches caching off matches silently: no error appears, the pages simply stop being cached. Three guarantees hold that in check.

**A new rule always arrives switched off.** There is no way to ask for anything else, so nothing an assistant adds takes effect until you turn it on. Read what it proposes, then decide.

**Only a switched-off rule can be removed.** That closes the loop: a rule just added can always be taken back, while one you deliberately turned on cannot vanish without a separate, visible step.

**A rule cannot be renamed or switched on through an update.** Changing what a rule does and changing whether it runs stay separate acts.

Two more things get refused before anything is stored:

- A **condition type nothing answers to**. Both `is_frontpage` (a near miss for `is_front_page`) and an invented name are rejected, rather than becoming a rule that quietly never matches.
- A **placeholder that will never resolve**, such as `{request.param.utm_source}` where `{param.utm_source}` was meant. An unresolved placeholder stays in the value as literal text, which would put every visitor into the same bucket. See below.

When a change leaves a rule with no conditions at all, the answer says so in a `warning`, because such a rule applies to every request on the site. That is occasionally what you want, which is why it is a warning and not an error.

### Placeholders

A rule value can carry a placeholder, written `{category.key}`, which is filled in per request:

| Category | Example |
|---|---|
| `param` | `{param.utm_source}` |
| `cookie` | `{cookie.geo_country}` |
| `header` | `{header.accept}` |
| `request` | `{request.host}`, `{request.headers.accept}` |
| `post` | `{post.id}`, `{post.type}` |
| `term`, `user`, `query` | `{term.slug}`, `{user.roles}`, `{query.paged}` |

`rules-schema` lists them with the keys each one accepts, and a plugin that registers its own category appears there too. Categories such as `param` and `cookie` take any key, since you choose the name. Others accept a fixed set, and a key outside it is refused: `{post.idd}` does not get stored.

## Reading why a page has several copies

`cache-entry-get` answers the question the counters cannot. When one URL holds four entries, the cache split it along one of five dimensions, and `split_by` names the one that actually differs: `url` (a query string), `cookies`, `unique`, `buckets`, `method` or `https`. Each copy then carries a `variant` object with all its values.

The cached HTML itself is never returned. An entry for a signed-in visitor holds personalised markup, which has no business in a chat transcript.

Note that `cache-entries-list` counts URLs while `cache-status` counts stored entries, so the two disagree whenever any page has more than one copy. That is normal.

## Multisite

Abilities for network-wide settings carry a `network-` prefix, for example `millicache/network-rules-list`, and sit alongside their per-site counterparts. Modules owned by the network (Edge Cache, Object Cache) appear only in the network form.

One difference is worth knowing: `cache-clear` is open to an assistant for a single site but `network-cache-clear` is not, because clearing every site of a network at once is a decision that should be yours.

## Related

- [WP-CLI Commands](/docs/millicache-pro/03-wp-cli/01-commands): the same operations for scripts and deploys
- [Command Palette](/docs/millicache-pro/04-command-palette/01-commands): the same operations by keyboard
- [Rules Builder](/docs/millicache-pro/02-modules/03-rules-builder): writing the same rules by hand
- [Cache Entries](/docs/millicache-pro/02-modules/02-cache-entries): the browser behind the entry abilities

# Acorn MilliCache

---

Canonical: https://www.millipress.com/docs/acorn-millicache/01-getting-started/01-introduction

---
title: 'Introduction'
description: 'Acorn MilliCache stores Acorn and Laravel route responses in MilliCache''s Redis full-page cache, bringing WordPress-grade caching to Roots stack routes.'
menu_order: 10
---

# Introduction

Acorn MilliCache bridges [Acorn](https://roots.io/acorn/) custom routes with [MilliCache](https://www.millipress.com/docs/millicache/)'s Redis full-page caching. It provides a single Laravel middleware (`StoreResponse`) that captures Acorn route responses and stores them in the exact format MilliCache's `advanced-cache.php` drop-in expects.

## Why Acorn MilliCache?

Acorn MilliCache adds a `StoreResponse` middleware to your Acorn router. On a cache MISS, the middleware:

1. Lets the controller (and any inner middleware like [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/)) handle the request
2. Checks MilliCache's cache decision — respecting all rules that ran during the request
3. Captures the finished response (content, headers, status code)
4. Tags the entry with a `route:{name}` cache flag for targeted invalidation
5. Stores it in Redis/ValKey via MilliCache's `ResponseProcessor`

On the next request, `advanced-cache.php` serves the cached version directly — no WordPress, no Acorn/Laravel, no controller. Just Redis and PHP.

> [!NOTE]
> This package only handles cache **storage**. Cache **serving** is handled by MilliCache's `advanced-cache.php` drop-in. Cache **rules and conditions** are managed by [MilliRules](https://www.millipress.com/docs/millirules/) and [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/).

## Prerequisites

| Requirement       | Version                   |
|-------------------|---------------------------|
| PHP               | >= 8.1                    |
| Roots Acorn       | ^4.0, ^5.0 or ^6.0        |
| MilliCache        | ^1.2.2                    |
| Acorn MilliRules  | optional                  |

MilliCache is declared as a Composer dependency and will be installed automatically. However, it is a WordPress plugin that must be **activated and configured** separately. See the [MilliCache installation guide](https://www.millipress.com/docs/millicache/01-getting-started/20-installation) for details.

If you use [MilliCache Pro](https://www.millipress.com/millicache-pro/), activate only the Pro plugin: it bundles MilliCache, so the Composer-installed MilliCache plugin stays deactivated.

> [!TIP]
> **Pair with [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/)** for the full picture. While Acorn MilliCache handles cache *storage*, Acorn MilliRules lets you define route-aware conditions, HTTP response actions, redirects, header manipulation, and custom cache flags — all from auto-discovered rule classes. Caching is just the beginning.
>
> ```bash
> composer require millipress/acorn-millirules
> ```

## Next Steps

- **[Installation](/docs/acorn-millicache/01-getting-started/02-installation)** — install the package, publish the config, and verify caching works
- **[Configuration](/docs/acorn-millicache/02-configuration/01-configuration)** — customize middleware groups or disable auto-registration
- **[How It Works](/docs/acorn-millicache/03-how-it-works/01-how-it-works)** — understand the middleware pipeline and cache lifecycle

---

**Ready to get started?** Continue to the [Installation guide](/docs/acorn-millicache/01-getting-started/02-installation).

---

Canonical: https://www.millipress.com/docs/acorn-millicache/01-getting-started/02-installation

---
title: 'Installation'
description: 'Install Acorn MilliCache with Composer in your Bedrock or Sage project, publish the config, and verify Redis caching of Acorn routes with a HIT header.'
menu_order: 20
---

# Installation

## Requirements

Before installing, ensure you have:

- **Roots Acorn ^4.0, ^5.0 or ^6.0** set up in your [Bedrock](https://roots.io/bedrock/), [Sage](https://roots.io/sage/) or [Radicle](https://roots.io/radicle/) project.

## Install the Package

```bash
composer require millipress/acorn-millicache
```

This also installs [MilliCache](https://www.millipress.com/docs/millicache/) as a Composer dependency.

> [!IMPORTANT]
> MilliCache is a regular WordPress plugin. After Composer installs it, you still need to **activate** it in WordPress and configure it (Redis/ValKey connection, `advanced-cache.php` drop-in, etc.). See the [MilliCache installation guide](https://www.millipress.com/docs/millicache/01-getting-started/20-installation).

> [!NOTE]
> Using [MilliCache Pro](https://www.millipress.com/millicache-pro/)? Pro bundles MilliCache, so activate **only** the Pro plugin and leave the Composer-installed MilliCache plugin deactivated. It stays in place as the package's Composer dependency.

> [!TIP]
> The service provider is registered automatically via Acorn's package discovery (`extra.acorn.providers` in `composer.json`). No manual provider registration is needed.

## Publish the Config

```bash
wp acorn vendor:publish --tag=millicache
```

This copies the config file to `config/millicache.php` in your Acorn application. The config controls whether the middleware is active and which middleware groups it attaches to.

> [!NOTE]
> Publishing the config is optional. The package works with sensible defaults out of the box: middleware enabled, attached to the `web` group.

## Verify

1. Make sure you are **logged out** (the middleware respects MilliCache's caching rules, which skip logged-in users by default)
2. Visit an Acorn route in your browser
3. Reload the page
4. Check the response headers — you should see:

```
X-MilliCache-Status: HIT
```

If you see `MISS` on every request, check that:

- MilliCache is active and its `advanced-cache.php` drop-in is in place
- The route is not excluded by a MilliCache rule or condition
- You are not logged in or sending cookies that bypass caching

## Add Acorn MilliRules (Optional)

Want route-aware conditions, redirects, header manipulation, and custom cache flags for your Acorn routes? Add [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/):

```bash
composer require millipress/acorn-millirules
```

Then scaffold your first rule:

```bash
wp acorn rules:make:rule RedirectLegacyPages
```

Rule classes are auto-discovered — no registration needed. See the [Acorn MilliRules documentation](https://www.millipress.com/docs/acorn-millirules/) for the full list of conditions and actions.

## Next Steps

- **[Configuration](/docs/acorn-millicache/02-configuration/01-configuration)** — add middleware groups, disable auto-registration, or register manually
- **[How It Works](/docs/acorn-millicache/03-how-it-works/01-how-it-works)** — understand the full request lifecycle

---

Canonical: https://www.millipress.com/docs/acorn-millicache/02-configuration/01-configuration

---
title: 'Configuration'
description: 'Configuration reference for Acorn MilliCache: middleware groups, manual StoreResponse registration, and automatic cache clearing on Artisan commands.'
menu_order: 10
---

# Configuration

The published config file lives at `config/millicache.php` in your Acorn application. It controls the `StoreResponse` middleware and automatic cache clearing for Artisan commands — all other caching settings (TTL, grace period, exclusions, compression, etc.) are managed by [MilliCache itself](https://www.millipress.com/docs/millicache/02-configuration/01-overview).

## Config Reference

```php
return [

    'middleware' => [
        'enabled' => true,
        'groups' => ['web'],
    ],

    'clear' => [
        'optimize:clear' => 'route*',
        'route:clear'    => 'route*',
        'route:cache'    => 'route*',
    ],

];
```

| Key                    | Type                       | Default     | Description                                             |
|------------------------|----------------------------|-------------|---------------------------------------------------------|
| `middleware.enabled`   | `bool`                     | `true`      | Whether to auto-register the `StoreResponse` middleware |
| `middleware.groups`    | `list<string>`             | `['web']`   | Router middleware groups the middleware is appended to   |
| `clear`                | `array<string, string>`    | *(see above)* | Maps Artisan commands to flag patterns for automatic cache clearing |

## Adding Middleware Groups

By default, the middleware is only added to the `web` group. If you have Acorn routes in other middleware groups that should be cached, add them to the `groups` array:

```php
'middleware' => [
    'enabled' => true,
    'groups' => ['web', 'api'],
],
```

The middleware is appended to each group via `pushMiddlewareToGroup()`, so it runs *after* all other middleware in the group — exactly when the response is ready to be captured.

## Disabling Automatic Registration

If you need full control over where the middleware runs, disable auto-registration and register it manually:

```php
// config/millicache.php
'middleware' => [
    'enabled' => false,
    'groups' => ['web'],
],
```

Then register the middleware yourself in a service provider or route file:

```php
use MilliCache\Acorn\Http\Middleware\StoreResponse;

// In a route group
Route::middleware([StoreResponse::class])->group(function () {
    Route::get('/cached-route', [MyController::class, 'index']);
});

// Or append to a group manually
$router->pushMiddlewareToGroup('web', StoreResponse::class);
```

> [!TIP]
> Manual registration is useful when you want the middleware on specific routes rather than an entire group, or when you need to control its position in the middleware stack.

## Automatic Cache Clearing

The `clear` config maps Artisan commands to MilliCache flag patterns. When a listed command runs, all cache entries matching its flag pattern are automatically cleared.

```php
'clear' => [
    'optimize:clear' => 'route*',
    'route:clear'    => 'route*',
    'route:cache'    => 'route*',
],
```

The key is the Artisan command name, the value is the flag pattern to clear:

| Pattern | Clears |
|---------|--------|
| `route*` | All Acorn route caches (named and unnamed) |
| `route:products:index` | Only the `products.index` route cache |
| `route:api*` | All API route caches |
| `*` | All MilliCache entries (including WordPress page caches) |

You can add your own commands to the list:

```php
'clear' => [
    'optimize:clear' => 'route*',
    'route:clear'    => 'route*',
    'route:cache'    => 'route*',
    'deploy:finish'  => 'route*',     // custom deployment command
],
```

To disable automatic clearing entirely, set `clear` to an empty array:

```php
'clear' => [],
```

## Related Configuration

All other caching behavior is configured through MilliCache and MilliRules:

- **TTL, grace period, compression** — [MilliCache Configuration](https://www.millipress.com/docs/millicache/02-configuration/01-overview)
- **Cache exclusions, conditions, rules** — [MilliRules Documentation](https://www.millipress.com/docs/millirules/)
- **Route-aware conditions** — [Acorn MilliRules Documentation](https://www.millipress.com/docs/acorn-millirules/)

---

Canonical: https://www.millipress.com/docs/acorn-millicache/03-how-it-works/01-how-it-works

---
title: 'How It Works'
description: 'How Acorn MilliCache caches Laravel route responses in WordPress: the StoreResponse middleware pipeline, cache flags, clearing, and the Redis HIT path.'
menu_order: 10
---

# How It Works

This page explains the caching gap that Acorn MilliCache fills, how the `StoreResponse` middleware pipeline works, and how cached responses are served.

## The Caching Gap

MilliCache's standard caching flow works like this:

1. A request arrives → `advanced-cache.php` checks Redis for a cached response
2. On **HIT** → the cached page is served immediately (WordPress never loads)
3. On **MISS** → WordPress loads, MilliCache hooks `template_redirect` to start output buffering, and the finished response is stored in Redis

**The problem:** Acorn custom routes are resolved during `parse_request` and send their response directly — *before* WordPress reaches `template_redirect`. MilliCache's output-buffering hook never fires, so Acorn route responses are never cached.

```mermaid
flowchart TD
    A[Request arrives] --> B{advanced-cache.php<br/>Redis lookup}
    B -->|HIT| C["Serve cached page<br/>~5-15 ms"]
    B -->|MISS| D[WordPress loads]
    D --> E[parse_request]
    E -->|Acorn route| F[Laravel router handles request]
    E -->|WordPress route| G[template_redirect]
    G --> H[MilliCache output buffering]
    H --> I["Store in Redis ✓"]
    F --> J[Response sent]
    J --> K[template_redirect never fires]
    K --> L["Not cached ✗"]

    style C fill:#d4edda
    style I fill:#d4edda
    style L fill:#f8d7da
```

## How StoreResponse Fills the Gap

The `StoreResponse` middleware runs inside Acorn's Laravel router — exactly where WordPress hooks cannot reach. It captures the finished response and stores it in Redis using MilliCache's own API.

### Middleware Pipeline

```mermaid
flowchart TD
    A[Request enters middleware] --> B{millicache function<br/>exists?}
    B -->|No| C[Run controller only]
    C --> D[Return response]
    B -->|Yes| N{check_cache_decision?}
    N -->|Yes| O[Remove ignored query keys]
    N -->|No| E[Run inner pipeline]
    O --> E
    E --> F[Response ready]
    F --> G{check_cache_decision?}
    G -->|No| H[Return response]
    G -->|Yes| I{Content available?}
    I -->|No| H
    I -->|Yes| J[Add cache flags]
    J --> K[Store in Redis]
    K --> H

    style K fill:#d4edda
```

The middleware follows this sequence:

1. **Check MilliCache is active** — `function_exists('millicache')`. If MilliCache isn't loaded (e.g. deactivated), the middleware becomes a no-op.
2. **Remove ignored query keys** — if the request may be cached, `millicache()->request()->normalize()` strips the keys listed in `MC_CACHE_IGNORE_REQUEST_KEYS` (e.g. `gclid`, `utm_*`) from the superglobals and the Laravel `Request` is refreshed from them. MilliCache does the same for WordPress templates at the end of `template_redirect`, which Acorn routes never reach; without this step a visitor's tracking parameters could end up in the stored response.
3. **Run the inner pipeline** — `$next($request)` passes the request through any inner middleware (including [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/)' `ExecuteRules` middleware, if installed) and into your controller.
4. **Check the cache decision** — `millicache()->check_cache_decision()`. By this point, both MilliCache's PHP bootstrap rules *and* any WordPress-aware rules from Acorn MilliRules have executed. If any rule called `do_cache(false)` (e.g. for logged-in users), the check returns `false` and the response is returned without storing. MilliCache handles bypass and reason headers internally.
5. **Capture the response** — the middleware reads the response content, headers, and status code.
6. **Add cache flags** — adds a `route:{name}` flag for named routes, or a bare `route` flag for unnamed routes (see [Cache Flags](#cache-flags) below).
7. **Store in Redis** — delegates to `millicache()->response()->store()`, which handles hash generation, flag collection, compression, and writing the cache entry.

> [!IMPORTANT]
> The cache decision is checked **after** the inner pipeline runs. This ensures that rules requiring WordPress context (e.g. `is_user_logged_in()`) have already executed. [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/) can disable caching based on logged-in users, specific routes, or any custom condition.

## What Gets Stored

The middleware passes these values to MilliCache's `ResponseProcessor`:

| Value       | Source                         | Description                          |
|-------------|--------------------------------|--------------------------------------|
| Content     | `$response->getContent()`      | The full response body               |
| Headers     | `$response->headers->all()`    | All response headers in `Key: Value` format |
| Status code | `$response->getStatusCode()`   | HTTP status code (e.g. `200`)        |
| TTL         | `millicache()->options()->get_ttl()`   | Cache lifetime from MilliCache config |
| Grace       | `millicache()->options()->get_grace()` | Stale-while-revalidate grace period  |

> [!NOTE]
> MilliCache's `Writer::validate_headers()` automatically filters out `Set-Cookie` and `X-MilliCache-*` headers before writing. The middleware does not need to handle this.

## Cache Flags

MilliCache uses [cache flags](https://www.millipress.com/docs/millicache/03-cache-flags/01-introduction) for targeted invalidation — e.g. purging all entries tagged with a specific flag. For WordPress pages, MilliCache automatically adds flags like `post:123` or `archive:category:5` via its `RequestFlags` rules. Since Acorn routes bypass that hook, this package adds its own flags before storing.

### Automatic Flags

The middleware adds a cache flag based on the route name:

| Route | Flag | Example |
|-------|------|---------|
| Named | `route:{name}` | `route:products:index` |
| Unnamed | `route` | `route` |

The Laravel route name is converted from dots to colons to match MilliCache's flag convention (`products.index` → `route:products:index`). Unnamed routes receive a bare `route` fallback flag.

In addition, MilliCache automatically adds a `url:{hash}` flag for every cache entry (both WordPress and Acorn).

Use the `route*` wildcard to target all Acorn route caches at once, or a specific flag like `route:products:index` for targeted invalidation.

> [!TIP]
> Naming your routes gives you granular cache invalidation for free. Unnamed routes can only be cleared in bulk via `route*` or individually via their `url:{hash}`.

### Custom Flags

You can add custom flags to Acorn route cache entries using [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/). Define a rule with an `add_flag` action that targets your route by name, controller, or any other condition.

## Cache Clearing

### WP-CLI

MilliCache provides WP-CLI commands for cache management. These work for all cached entries, including Acorn routes:

```bash
# Clear all cached pages
wp millicache clear

# Clear all Acorn route caches
wp millicache clear --flag=route*

# Clear a specific route's cache
wp millicache clear --flag=route:products:index
```

See the [MilliCache WP-CLI documentation](https://www.millipress.com/docs/millicache/06-wp-cli/01-commands) for the full command reference.

### Automatic Clearing

Acorn MilliCache automatically clears cache entries when certain Artisan commands run. The mapping between commands and flag patterns is [configurable](/docs/acorn-millicache/02-configuration/01-configuration#automatic-cache-clearing):

```php
// config/millicache.php
'clear' => [
    'optimize:clear' => 'route*',
    'route:clear'    => 'route*',
    'route:cache'    => 'route*',
],
```

By default, `optimize:clear`, `route:clear`, and `route:cache` all clear Acorn route caches (`route*`). WordPress page caches are not affected.

> [!NOTE]
> For targeted clearing of a specific route's cache, use `wp millicache clear --flag=route:products:index` via WP-CLI.

## Cache Serving (HIT Path)

Once a response is stored, subsequent requests are served by MilliCache's `advanced-cache.php` drop-in. This runs *before* WordPress loads:

1. `advanced-cache.php` calculates the request hash
2. Looks up the hash in Redis
3. On **HIT** → sends the cached headers, status code, and body directly
4. WordPress, Acorn, and Laravel are never loaded (~5–15 ms response time)

This package has no role in the HIT path. It only handles MISS storage.

## Error Handling

Cache storage is wrapped in a `try/catch` block. If Redis is unavailable or any storage step fails:

- The original response is returned to the visitor **unchanged**
- The error is logged via `error_log()` with an `[acorn-millicache]` prefix
- No exception propagates to the user

> [!TIP]
> Cache failures are silent by design. A broken cache connection should degrade to uncached responses, never to error pages.

## Full Request Lifecycle

```mermaid
sequenceDiagram
    participant Browser
    participant AdvancedCache as advanced-cache.php
    participant WordPress
    participant Acorn as Acorn Router
    participant Middleware as StoreResponse
    participant Controller
    participant Redis

    Browser->>AdvancedCache: GET /acorn-route
    AdvancedCache->>Redis: Lookup hash
    Redis-->>AdvancedCache: MISS

    AdvancedCache->>WordPress: Continue loading
    WordPress->>Acorn: parse_request (route matched)
    Acorn->>Middleware: Enter middleware stack
    Middleware->>Middleware: Check millicache() exists ✓
    Middleware->>Controller: $next($request) (runs ExecuteRules + controller)
    Controller-->>Middleware: Response (200, HTML, headers)
    Middleware->>Middleware: check_cache_decision() ✓
    Middleware->>Middleware: Add flag (route:{name})
    Middleware->>Redis: Store via millicache()->response()->store()
    Middleware-->>Browser: Return response

    Note over Browser,Redis: Next request — HIT path

    Browser->>AdvancedCache: GET /acorn-route
    AdvancedCache->>Redis: Lookup hash
    Redis-->>AdvancedCache: HIT
    AdvancedCache-->>Browser: Cached response (~5-15 ms)
```

## Further Reading

- [MilliCache — How Caching Works](https://www.millipress.com/docs/millicache/05-usage/10-how-caching-works) — the full cache lifecycle including output buffering, compression, and stale-while-revalidate
- [MilliCache — Configuration](https://www.millipress.com/docs/millicache/02-configuration/01-overview) — TTL, grace period, and other cache settings
- [Acorn MilliRules](https://www.millipress.com/docs/acorn-millirules/) — route-aware cache rules and conditions

---

Canonical: https://www.millipress.com/docs/acorn-millicache/04-reference/04-changelog

---
title: 'Changelog'
description: 'Acorn MilliCache release history: new features, fixes, and Acorn compatibility updates for the Laravel route caching bridge to MilliCache and Redis.'
menu_order: 40
---

# Changelog

## [1.3.1](https://github.com/MilliPress/Acorn-MilliCache/compare/v1.3.0...v1.3.1) (2026-08-29)


### Bug Fixes

* **middleware:** remove ignored query keys before the controller runs ([596fbfb](https://github.com/MilliPress/Acorn-MilliCache/commit/596fbfb584b5d5ddce7b471cb7d8239bfe7547ba))

## [1.3.0](https://github.com/MilliPress/Acorn-MilliCache/compare/v1.2.0...v1.3.0) (2026-07-16)


### Features

* **store:** respect Cache-Control no-store on responses ([e0128fe](https://github.com/MilliPress/Acorn-MilliCache/commit/e0128fe0ee85397bae0024a6e55e29bc8fb8b3f4))


### Bug Fixes

* **deps:** require MilliCache 1.7.2 for the shared rules engine ([7c6c462](https://github.com/MilliPress/Acorn-MilliCache/commit/7c6c462fb9c4b9a647daa9bbeb80f250e7158ce8))

## [1.2.0](https://github.com/MilliPress/Acorn-MilliCache/compare/v1.1.0...v1.2.0) (2026-03-31)


### Features

* **compat:** Add Acorn 6 support ([9edc6d9](https://github.com/MilliPress/Acorn-MilliCache/commit/9edc6d9dabf6ad67012387a9429b8adc5f501a55))

## [1.1.0](https://github.com/MilliPress/Acorn-MilliCache/compare/v1.0.0...v1.1.0) (2026-02-16)


### Features

* Use check_cache_decision() for post-next cache gating ([a737954](https://github.com/MilliPress/Acorn-MilliCache/commit/a737954b4e4bba2ccb32a15089cb345a48100c79))

## 1.0.0 (2026-02-13)


### Features

* Add automatic cache clearing on Artisan commands ([0a33aee](https://github.com/MilliPress/Acorn-MilliCache/commit/0a33aeef97c76aa1edde81a2dd5b7ebe3132d5fc))
* Add cache flags to StoreResponse middleware ([07e67e6](https://github.com/MilliPress/Acorn-MilliCache/commit/07e67e66482552b326e9b56a6786a0490df7f128))


### Bug Fixes

* Reset release-please manifest to 0.0.0 for initial release ([d02ff59](https://github.com/MilliPress/Acorn-MilliCache/commit/d02ff59d54fbfa29120797e8fed33414dcbbf751))

# MilliRules

---

Canonical: https://www.millipress.com/docs/millirules/01-getting-started/01-introduction

---
title: 'Introduction to MilliRules'
description: 'MilliRules is a declarative PHP rules engine for WordPress and any PHP app. Define conditions and actions with a fluent when/then API instead of if-else chains.'
menu_order: 10
---

# Introduction to MilliRules

MilliRules is a powerful, flexible rule engine for PHP and WordPress that lets you create conditional logic using an elegant fluent API. Whether you're building a WordPress plugin or a framework-agnostic PHP application, MilliRules makes it easy to implement complex business rules without tangling your code with if-else statements.

## What is MilliRules?

MilliRules allows you to define rules that automatically execute actions when specific conditions are met. Think of it as a sophisticated "if-then" system that:

- **Separates logic from code** - Define rules independently of your application logic
- **Works everywhere** - Use in WordPress, Laravel, Symfony, or any PHP 7.4+ project
- **Provides a fluent API** - Write readable, chainable code that's easy to understand
- **Extends easily** - Add custom conditions, actions, and packages for your needs

## Why Use MilliRules?

### Clean, Declarative Code

Instead of scattering conditional logic throughout your codebase:

```php
// Traditional approach - logic mixed with implementation
if (is_admin() && is_user_logged_in() && $_SERVER['REQUEST_URI'] === '/wp-admin/settings.php') {
    if (current_user_can('manage_options')) {
        do_action('my_admin_action');
        update_option('last_settings_access', time());
        error_log('Admin accessed settings');
    }
}
```

With MilliRules, you define rules declaratively:

```php
// MilliRules approach - clean and declarative
Rules::create('log_settings_access', 'wp')
    ->title('Log Settings Page Access')
    ->when()
        ->request_url('/wp-admin/settings.php')
        ->is_user_logged_in()
        ->user_can('manage_options')
    ->then()
        ->custom('trigger_admin_action')
        ->custom('update_last_access')
        ->custom('log_access')
    ->register();
```

### Key Benefits

1. **Maintainability** - Rules are self-contained and easy to understand, update, or remove
2. **Reusability** - Define conditions and actions once, use them across multiple rules
3. **Testability** - Test rules in isolation without complex setup
4. **Flexibility** - Dynamically register or unregister rules based on runtime conditions
5. **Organization** - Group related rules together, control execution order
6. **Extensibility** - Create custom conditions, actions, and packages tailored to your needs

## When to Use MilliRules

MilliRules is ideal for:

### WordPress Development
- **Content Filtering** - Modify content based on user roles, post types, or custom conditions
- **Access Control** - Restrict or grant access to pages, features, or content
- **Caching Logic** - Apply cache headers based on request patterns
- **Feature Flags** - Enable/disable features based on environment or user attributes
- **Admin Customization** - Modify admin behavior based on user roles or contexts

### PHP Applications
- **API Rate Limiting** - Apply rate limits based on user tiers or endpoints
- **Request Routing** - Route requests based on complex conditions
- **Data Validation** - Apply validation rules based on context
- **Business Logic** - Implement business rules that change frequently
- **Event Handling** - Trigger actions based on application events

## Core Components

MilliRules is built around four main concepts:

```mermaid
flowchart LR
    subgraph Rules["Rules"]
        R["Combines conditions + actions<br/>with metadata"]
    end

    subgraph Conditions["Conditions"]
        C["Define WHEN<br/>to execute"]
    end

    subgraph Actions["Actions"]
        A["Define WHAT<br/>happens"]
    end

    subgraph Packages["Packages"]
        P["Provide conditions,<br/>actions & context"]
    end

    Packages --> Conditions
    Packages --> Actions
    Conditions --> Rules
    Actions --> Rules
```

### 1. Rules
The foundation of MilliRules - a rule combines conditions and actions with metadata like title, order, and enabled status.

### 2. Conditions
Define when a rule should execute. MilliRules provides built-in conditions for URLs, HTTP methods, cookies, constants, and WordPress-specific checks.

### 3. Actions
Define what happens when conditions are met. Actions can be simple callbacks, class methods, or complex custom implementations.

### 4. Packages
Modular functionality bundles that provide conditions, actions, context providers, and placeholder resolvers. MilliRules comes with PHP and WordPress packages out of the box.

## How It Works

The MilliRules execution flow:

```mermaid
flowchart LR
    Init["1. Initialize"] --> Register["2. Register Rules"]
    Register --> Execute["3. Execute"]
    Execute --> Evaluate["4. Evaluate Conditions"]
    Evaluate -->|"match"| Actions["5. Run Actions"]
    Evaluate -->|"no match"| Skip["Skip Rule"]
```

1. **Initialization** - `MilliRules::init()` registers and loads packages
2. **Rule Registration** - Rules are created and registered using the fluent API
3. **Execution** - Rules execute automatically (via WordPress hooks) or manually
4. **Condition Evaluation** - Each rule's conditions are evaluated against the current context
5. **Action Execution** - When conditions match, the rule's actions execute in sequence

## When NOT to Use MilliRules

MilliRules might be overkill for:

- **Simple one-time checks** - A basic `if` statement is often sufficient
- **Performance-critical hot paths** - The rule engine adds minimal but measurable overhead
- **Extremely simple applications** - If you only need 1-2 conditional checks, MilliRules might be unnecessary

## Using Acorn / Roots?

If you're building on the [Roots](https://roots.io/) stack with Acorn, check out the [Acorn MilliRules](https://millipress.com/docs/acorn-millirules/) companion package. It adds route-aware conditions, HTTP response actions, Artisan commands, and automatic rule discovery — everything you need to define and manage rules that react to Laravel routes.

```bash
composer require millipress/acorn-millirules
```

## Getting Started

Ready to start using MilliRules? Follow these steps:

1. **[Quick Start](/docs/millirules/01-getting-started/02-quick-start)** - Install and initialize MilliRules in minutes
2. **[Your First Rule](/docs/millirules/01-getting-started/03-first-rule)** - Create your first rule with a hands-on tutorial
3. **[Core Concepts](/docs/millirules/02-core-concepts/01-concepts)** - Deep dive into architecture and concepts

## Learn More

### Core Documentation
- **[Core Concepts](/docs/millirules/02-core-concepts/01-concepts)** - Understand rules, conditions, actions, and packages
- **[Packages System](/docs/millirules/02-core-concepts/02-packages)** - Learn about the package architecture
- **[Building Rules](/docs/millirules/02-core-concepts/03-building-rules)** - Master the fluent API

### Customization
- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Create your own condition types
- **[Custom Actions](/docs/millirules/03-customization/02-custom-actions)** - Build custom action handlers
- **[Custom Packages](/docs/millirules/03-customization/03-custom-packages)** - Extend MilliRules with custom packages

### Reference
- **[Conditions Reference](/docs/millirules/05-reference/01-conditions)** - All available built-in conditions
- **[Actions Reference](/docs/millirules/05-reference/02-actions)** - Action patterns and examples
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete API documentation

---

**Ready to get started?** Continue to [Quick Start Guide](/docs/millirules/01-getting-started/02-quick-start) to install and initialize MilliRules.

---

Canonical: https://www.millipress.com/docs/millirules/01-getting-started/02-quick-start

---
title: 'Quick Start Guide'
description: 'Install MilliRules via Composer and initialize the PHP rules engine in minutes, with package auto-detection, verification steps, and common pitfalls to avoid.'
menu_order: 20
---

# Quick Start Guide

This guide will help you install and initialize MilliRules in just a few minutes. Whether you're using WordPress or a standalone PHP application, you'll be ready to create your first rule quickly.

## Prerequisites

Before installing MilliRules, ensure you have:

- **PHP 7.4 or higher**
- **Composer** - For dependency management
- **(Optional) WordPress 5.0+** - If using WordPress-specific features

## Installation via Composer

MilliRules is installed via Composer. Run this command in your project directory:

```bash
composer require MilliPress/MilliRules
```

This will download MilliRules and all its dependencies into your `vendor/` directory.

## Initializing MilliRules

Before creating rules, you need to initialize MilliRules. This registers the available packages (PHP and WordPress) and prepares the rule engine.

### Basic Initialization

For most installations, whether you are using WordPress or a standalone PHP application, use the simple initialization:

```php
use MilliRules\MilliRules;

// Initialize with auto-detected packages
MilliRules::init();
```

This automatically detects your environment:
- In **WordPress**, it registers and loads both the PHP and WordPress packages.
- In **Framework-agnostic** environments, it automatically loads only the PHP package.

### Custom Package Selection

You can explicitly specify which packages to load:

```php
use MilliRules\MilliRules;

// Load only the PHP package (useful for early execution)
MilliRules::init(['PHP']);

// Or load specific packages with custom instances
$custom_package = new MyCustomPackage();
MilliRules::init(null, [$custom_package]);
```

> [!NOTE]
> The first parameter accepts package names as strings to load specific packages. The second parameter accepts PackageInterface instances to register. Using `null` for both parameters tells MilliRules to register and auto-load default packages.

## Verifying Installation

To verify that MilliRules is properly installed and initialized, you can check the loaded packages:

```php
use MilliRules\MilliRules;

// Initialize MilliRules
MilliRules::init();

// Check loaded packages
$packages = MilliRules::get_loaded_packages();
error_log('Loaded packages: ' . print_r($packages, true));
```

If everything is working correctly, you should see the PHP package (and WordPress package if in WordPress environment) in your error log.

## Common Pitfalls

### 1. Forgetting to Initialize

```php
// ❌ Wrong - rules created before initialization
Rules::create('my_rule')->when()->request_url('/test')->then()->register();
MilliRules::init();

// ✅ Correct - initialize first
MilliRules::init();
Rules::create('my_rule')->when()->request_url('/test')->then()->register();
```

### 2. Incorrect WordPress Hook Priority

```php
// ❌ Wrong - initializing too late
add_action('init', function() {
    MilliRules::init();
}, 999); // Rules may miss early hooks

// ✅ Correct - initialize early or at top level
MilliRules::init();
```

### 3. Missing Autoloader

```php
// ❌ Wrong - missing autoloader
use MilliRules\MilliRules;
MilliRules::init(); // Fatal error: Class not found

// ✅ Correct - include autoloader first
require_once __DIR__ . '/vendor/autoload.php';
use MilliRules\MilliRules;
MilliRules::init();
```

## Troubleshooting

### Rules Not Executing

1. **Check if MilliRules is initialized**: Make sure `MilliRules::init()` is called before creating rules
2. **Verify rule is registered**: Add `error_log()` calls to confirm your rule registration code runs
3. **Check WordPress hook timing**: Ensure your rules are created before the hooks they target fire
4. **Enable debug logging**: Check your error log for MilliRules-related messages

### Package Not Available Error

If you see "Package not available" errors:

1. **WordPress package**: Ensure WordPress is fully loaded before initializing MilliRules
2. **Custom packages**: Verify your custom package's `is_available()` method returns `true`
3. **Check dependencies**: Ensure required packages are loaded first

### Getting Help

- Review the [API Reference](/docs/millirules/05-reference/03-api) for detailed method documentation
- Check [Real-World Examples](/docs/millirules/04-advanced/01-examples) for complete working code
- Examine your error logs for detailed error messages

## Best Practices

1. **Initialize early** - Call `MilliRules::init()` as early as possible in your application
2. **Check your environment** - Verify PHP version and Composer are properly configured
3. **Enable error logging** - Turn on error logging during development to catch issues quickly
4. **Test in isolation** - Create a simple test rule to verify MilliRules is working before building complex logic

## Next Steps

Now that MilliRules is installed and initialized, you're ready to create your first rule!

Continue to [Creating Your First Rule](/docs/millirules/01-getting-started/03-first-rule) to start building with MilliRules.

---

Canonical: https://www.millipress.com/docs/millirules/01-getting-started/03-first-rule

---
title: 'Creating Your First Rule'
description: 'Build your first MilliRules rule step by step: a WordPress example with URL conditions, a custom logging action, and fixes for common beginner mistakes.'
menu_order: 30
---

# Creating Your First Rule

Now that you have MilliRules installed and initialized, let's create your first rule. This hands-on tutorial will walk you through building a simple but functional rule that logs admin dashboard access in WordPress.

## Anatomy of a Rule

Every MilliRules rule consists of three main parts:

1. **Rule Creation** - Define the rule with an ID and type
2. **Conditions** (when) - Specify when the rule should execute
3. **Actions** (then) - Define what happens when conditions are met

Here's the basic structure:

```php
use MilliRules\Rules;

Rules::create('rule_id')
    ->when()
        // Add conditions here
    ->then()
        // Add actions here
    ->register();
```

## Your First Rule: Log Admin Access

Let's create a rule that logs a message whenever someone accesses the WordPress admin dashboard.

### Step 1: Initialize MilliRules

Add this to your plugin's main file or `functions.php`:

```php
use MilliRules\MilliRules;
use MilliRules\Rules;

// Initialize the rule engine
MilliRules::init();
```

### Step 2: Create Your First Rule

```php
use MilliRules\Rules;

// Create a rule that runs on WordPress 'init' hook
Rules::create('log_admin_access', 'wp')
    ->title('Log Admin Dashboard Access') // Optional
    ->order(10) // Optional
    ->when()
        ->request_url('/wp-admin/*')  // Matches any admin URL
        ->is_user_logged_in()         // User must be logged in
    ->then()
        ->custom('log_message', ['value' => 'Admin dashboard accessed'])
    ->register();
```

### Step 3: Register the Custom Action

Since `log_message` is a custom action, let's register it:

```php
use MilliRules\Rules;
use MilliRules\Context;

Rules::register_action('log_message', function($args, Context $context) {
    $message = $args['message'] ?? $args[0] ?? 'No message';
    error_log('MilliRules: ' . $message);
});
```

### Complete Example

Here's everything together in a WordPress plugin context:

```php
/**
 * Plugin Name: My First MilliRules Plugin
 * Description: Logs admin dashboard access using MilliRules
 * Version: 1.0.0
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;
use MilliRules\Context;

// Initialize MilliRules
MilliRules::init();

// Register custom log action
Rules::register_action('log_message', function($args, Context $context) {
    $message = $args['message'] ?? $args[0] ?? 'No message';
    error_log('MilliRules: ' . $message);
});

// Create the rule
Rules::create('log_admin_access', 'wp')
    ->title('Log Admin Dashboard Access')
    ->order(10)
    ->when()
        ->request_url('/wp-admin/*')
        ->is_user_logged_in()
    ->then()
        ->custom('log_message', ['value' => 'Admin dashboard accessed'])
    ->register();
```

> [!TIP]
> Check your error log (usually in `wp-content/debug.log` if `WP_DEBUG_LOG` is enabled) to see the logged messages when you access the WordPress admin dashboard.

## Understanding What Just Happened

Let's break down what this rule does:

1. **Rule Creation**: `Rules::create('log_admin_access', 'wp')` creates a new rule with ID `log_admin_access` and type `wp` (WordPress)

2. **Metadata**: `->title()` and `->order()` add descriptive information and control execution sequence

3. **Conditions**: The `->when()` builder defines conditions that must be met:
   - Request URL matches `/wp-admin/*` (wildcard pattern)
   - User is logged in

4. **Actions**: The `->then()` builder defines what happens when conditions match:
   - Log a message via the custom `log_message` action

5. **Registration**: `->register()` registers the rule with MilliRules

## Simple Condition Examples

Here are some common conditions you can use:

### URL Matching

```php
// Exact match
->when()->request_url('/contact')

// Wildcard pattern
->when()->request_url('/blog/*')

// Multiple URLs (OR logic)
->when_any()
    ->request_url('/about')
    ->request_url('/contact')
```

### HTTP Method Checking

```php
// Check for POST requests
->when()->request_method('POST')

// Check for GET or HEAD (using array)
->when()->request_method(['GET', 'HEAD'])
```

### User Status (WordPress)

```php
// User must be logged in
->when()->is_user_logged_in()

// User must NOT be logged in
->when()->is_user_logged_in(false)
```

### Cookie Checking

```php
// Check if cookie exists
->when()->cookie('session_id')

// Check cookie value
->when()->cookie('user_preference', 'dark_mode')
```

## Simple Action Examples

### Logging

```php
// Register a logging action
Rules::register_action('log', function($args, Context $context) {
    error_log('MilliRules: ' . ($args['value'] ?? ''));
});

// Use it in a rule
->then()->custom('log', ['value' => 'Something happened'])
```

### Redirects

```php
// Register a redirect action
Rules::register_action('redirect', function($args, Context $context) {
    $url = $args['url'] ?? '/';
    wp_redirect($url);
    exit;
});

// Use it in a rule
->then()->custom('redirect', ['url' => '/login'])
```

### Setting Headers

```php
// Register a cache header action
Rules::register_action('set_cache', function($args, Context $context) {
    $duration = $args['duration'] ?? 3600;
    header('Cache-Control: max-age=' . $duration);
});

// Use it in a rule
->then()->custom('set_cache', ['duration' => 7200])
```

## Running Rules

By default, WordPress rules execute automatically on their specified hook. You don't need to manually trigger execution.

However, you can also execute rules manually:

```php
use MilliRules\MilliRules;

// Execute all registered rules
$result = MilliRules::execute_rules();

// Check execution statistics
echo 'Rules processed: ' . $result['rules_processed'] . "\n";
echo 'Rules matched: ' . $result['rules_matched'] . "\n";
echo 'Actions executed: ' . $result['actions_executed'] . "\n";
```

> [!IMPORTANT]
> WordPress rules registered with `->on('hook_name')` execute automatically when that hook fires. You only need manual execution for PHP-only rules or when testing.

## Common Beginner Mistakes

### 1. Forgetting to Call `register()`

```php
// ❌ Wrong - rule never registered
Rules::create('my_rule')
    ->when()->request_url('/test')
    ->then()->custom('action');
// Missing ->register()

// ✅ Correct
Rules::create('my_rule')
    ->when()->request_url('/test')
    ->then()->custom('action')
    ->register();
```

### 2. Using Undefined Custom Actions

```php
// ❌ Wrong - 'send_email' not registered
Rules::create('notify')
    ->when()->request_url('/contact')
    ->then()->custom('send_email')  // Not defined!
    ->register();

// ✅ Correct - register action first
Rules::register_action('send_email', function($args, Context $context) {
    // Email sending logic here
});

Rules::create('notify')
    ->when()->request_url('/contact')
    ->then()->custom('send_email')
    ->register();
```

### 3. Incorrect Condition Logic

```php
// ❌ Wrong - using when() with single condition that should be OR
Rules::create('public_access')
    ->when()  // This uses AND logic by default
        ->request_url('/public')
        ->request_url('/open')  // Can't match both!
    ->then()->custom('grant_access')
    ->register();

// ✅ Correct - use when_any() for OR logic
Rules::create('public_access')
    ->when_any()  // Use OR logic
        ->request_url('/public')
        ->request_url('/open')
    ->then()->custom('grant_access')
    ->register();
```

## Next Steps

Congratulations! You've created your first MilliRules rule. Now you're ready to explore more advanced features:

### Learn Core Concepts
- **[Core Concepts](/docs/millirules/02-core-concepts/01-concepts)** - Understand the architecture and how rules work internally
- **[Packages System](/docs/millirules/02-core-concepts/02-packages)** - Learn about the package system and how to use it
- **[Building Rules](/docs/millirules/02-core-concepts/03-building-rules)** - Master the fluent API and advanced rule patterns

### Explore Available Features
- **[Operators](/docs/millirules/02-core-concepts/04-operators)** - Learn about pattern matching and comparison operators
- **[Placeholders](/docs/millirules/02-core-concepts/05-placeholders)** - Use dynamic values in your actions
- **[Built-in Conditions](/docs/millirules/05-reference/01-conditions)** - Discover all available conditions

### Build Custom Components
- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Create your own condition types
- **[Custom Actions](/docs/millirules/03-customization/02-custom-actions)** - Build custom action handlers
- **[Custom Packages](/docs/millirules/03-customization/03-custom-packages)** - Extend MilliRules with custom packages

---

**Ready to dive deeper?** Continue to [Core Concepts](/docs/millirules/02-core-concepts/01-concepts) to understand the fundamental architecture of MilliRules.

---

Canonical: https://www.millipress.com/docs/millirules/02-core-concepts/01-concepts

---
title: 'Rules, Conditions, and Actions'
description: 'How MilliRules works under the hood: rules combine conditions and actions, execute in order against a shared context, and use locking to prevent overrides.'
menu_order: 10
---

# Core Concepts - Rules, Conditions, and Actions

Understanding MilliRules' core concepts is essential for building powerful, maintainable rules. This guide explains the fundamental architecture and how all the pieces work together.

## The Rule Engine Architecture

MilliRules follows a simple but powerful pattern: **When conditions are met, then actions execute**.

```mermaid
flowchart TB
    subgraph Rule["Rule Definition"]
        direction LR
        Conditions["Conditions<br/>(When)"] -->|"match"| Actions["Actions<br/>(Then)"]
    end

    subgraph Foundation["Context & Packages"]
        Context["Context Data"]
        Packages["Package Providers"]
    end

    Conditions --> Context
    Actions --> Context
    Context <--> Packages
```

## What is a Rule?

A **rule** is a self-contained unit of logic that:
- Has a unique identifier
- Contains one or more conditions
- Contains one or more actions
- Executes when its conditions are satisfied
- Operates on a shared context

### Rule Structure

Every rule consists of:

```php
use MilliRules\Rules;

Rules::create('rule_id')           // Unique identifier
    ->title('Rule Title')           // Human-readable name (optional)
    ->order(10)                     // Execution sequence (optional)
    ->enabled(true)                 // Enable/disable flag (optional)
    ->when()                        // Condition builder
        ->condition1()
        ->condition2()
    ->then()                        // Action builder
        ->action1()
        ->action2()
    ->register();                   // Register with engine
```

### Rule Properties

| Property     | Type   | Description                            | Default       |
|--------------|--------|----------------------------------------|---------------|
| `id`         | string | Unique identifier (required)           | -             |
| `title`      | string | Human-readable name                    | Empty         |
| `order`      | int    | Execution sequence (lower = first)     | 10            |
| `enabled`    | bool   | Whether rule should execute            | true          |
| `type`       | string | Rule type (`php` or `wp`)              | Auto-detected |
| `match_type` | string | Condition logic (`all`, `any`, `none`) | `all`         |
| `conditions` | array  | Condition configurations               | []            |
| `actions`    | array  | Action configurations                  | []            |

> [!IMPORTANT]
> Rule IDs must be unique within your application. Using duplicate IDs may cause unexpected behavior. Consider prefixing IDs with your plugin or project name.

## Rule Execution Order

Rules execute in sequence based on their `order` value:

```php
Rules::create('second_rule')->order(10)->when()->request_url('/test')->then()->register();
Rules::create('first_rule')->order(5)->when()->request_url('/test')->then()->register();
Rules::create('third_rule')->order(20)->when()->request_url('/test')->then()->register();
```

**Execution order**: first_rule → second_rule → third_rule

> [!TIP]
> Use order ranges to organize rules by purpose:
> - **0-9**: Core system rules
> - **10-19**: Default application rules
> - **20-49**: Feature-specific rules
> - **50-99**: Override rules
> - **100+**: Emergency override rules

### Why Order Matters

When multiple rules modify the same value or state:

```php
// Rule 1 (order: 10) sets cache to 3600 seconds
Rules::create('cache_short')->order(10)
    ->when()->request_url('/api/*')
    ->then()->custom('set_cache', ['value' => '3600'])
    ->register();

// Rule 2 (order: 20) overrides cache to 7200 seconds
Rules::create('cache_long')->order(20)
    ->when()->request_url('/api/stable/*')
    ->then()->custom('set_cache', ['value' => '7200'])
    ->register();
```

For URL `/api/stable/users`:
1. Both rules match
2. Rule 1 executes first (order: 10) → cache set to 3600
3. Rule 2 executes second (order: 20) → cache overridden to 7200
4. **Final value**: 7200 seconds

### Preventing Overwrites with Action Locking

Sometimes you want to **prevent** later rules from overriding values. Use `->lock()` to lock an action, preventing subsequent actions from changing the same setting:

```php
// Rule 1 (order: 10) sets cache and LOCKS it
Rules::create('cache_short')->order(10)
    ->when()->request_url('/api/*')
    ->then()->custom('set_cache', ['value' => '3600'])->lock()  // Lock this action
    ->register();

// Rule 2 (order: 20) tries to override but is BLOCKED
Rules::create('cache_long')->order(20)
    ->when()->request_url('/api/stable/*')
    ->then()->custom('set_cache', ['value' => '7200'])  // IGNORED - cache is locked
    ->register();
```

For URL `/api/stable/users`:
1. Both rules match
2. Rule 1 executes first (order: 10) → cache set to 3600 and locked
3. Rule 2 matches, but its `set_cache` action is **blocked** (cache already locked)
4. **Final value**: 3600 seconds (protected from override)

#### Scoped Locking for Paired Actions

For actions that work in pairs (like `add_flag`/`remove_flag`), consumer plugins can register them with a shared **scope**. This changes locking from type-level to **value-level** — locking a specific flag value instead of blocking all flag operations.

**Callback-based registration** — chain `->scope()` after `register_action()`:

```php
Rules::register_action('add_flag', $addCallback)->scope('flag');
Rules::register_action('remove_flag', $removeCallback)->scope('flag');
```

**Class-based registration** — override the static `get_scope()` method on your `BaseAction` subclass. Scope lives in `get_scope()` (not in `set_meta()`) so the engine can read it during rule execution without triggering `set_meta()`. This is critical for plugins that run rules during `advanced-cache.php` boot, before WordPress is fully loaded.

```php
use MilliRules\Actions\BaseAction;

class AddFlag extends BaseAction
{
    // Engine-relevant. Runtime-safe — no WordPress functions allowed.
    public static function get_scope(): string
    {
        return 'flag';
    }

    public function execute(Context $context): void { /* ... */ }
    public function get_type(): string { return 'add_flag'; }
}
```

Once registered, scoped locking behaves like this:

```php
// Rule 1: Add and lock a system flag
Rules::create('core-author-flag')->order(0)
    ->when()->is_author()
    ->then()->add_flag('archive:author:1')->lock()  // Locks 'flag:archive:author:1'
    ->register();

// Rule 2: This works — different flag value, different lock key
Rules::create('user-custom-flag')->order(10)
    ->when()->request_url('/special/*')
    ->then()->add_flag('my-custom-flag')  // Allowed
    ->register();

// Rule 3: This is BLOCKED — same scope + value, even though it's remove_flag
Rules::create('user-remove-flag')->order(10)
    ->when()->request_url('/no-author/*')
    ->then()->remove_flag('archive:author:1')  // Blocked
    ->register();
```

**Key Points**:
- **Unscoped actions** (default): locks are per action type — `set_ttl(300)->lock()` blocks all `set_ttl` calls
- **Scoped actions**: locks are per value — `add_flag('x')->lock()` only blocks operations on flag `'x'`
- Scoped locking works across action types sharing the same scope (e.g., `add_flag` and `remove_flag`)
- Different action types can still execute regardless of locks
- Lock only applies if the rule's conditions match
- Non-scalar values (arrays/objects) cannot be locked at the scope level; the action will execute but `->lock()` is silently skipped with a warning

### Preventing Rule Replacement with Rule Locking

Action locking prevents later rules from *executing* the same action, but it doesn't prevent someone from *replacing* the rule itself by re-registering the same rule ID with different conditions or actions. For safety-critical rules, use `->lock()` on the rule builder to make the entire rule immutable:

```php
// This rule cannot be overwritten or unregistered
Rules::create('no-cache-post')->lock()->order(0)
    ->when_all()->request_method('POST')
    ->then()->set_cache(false)->lock()  // Also lock the action
    ->register();

// This will be silently rejected — the original rule stays intact
Rules::create('no-cache-post')  // Same ID
    ->when_all()  // Permissive conditions
    ->then()->set_cache(true)  // Flipped action
    ->register();
```

**Two levels of locking work together**:
- **Rule-level `lock()`** — prevents the rule *definition* from being replaced or removed
- **Action-level `lock()`** — prevents the same action from executing in later rules

Use both for maximum protection on core safety rules.

## Conditions: The "When" Logic

**Conditions** determine whether a rule should execute. They evaluate the current context and return true or false.

### Condition Types

MilliRules provides two categories of conditions:

#### 1. PHP Package Conditions (Framework-Agnostic)

Available in any PHP environment:

```php
->when()
    ->request_url('/api/*')           // URL pattern matching
    ->request_method('POST')          // HTTP method
    ->request_header('Content-Type', 'application/json')  // Headers
    ->cookie('session_id')            // Cookie existence/value
    ->request_param('action', 'save') // Query/form parameters
    ->constant('WP_DEBUG', true)      // PHP/WordPress constants
```

#### 2. WordPress Package Conditions

Available only in WordPress environments. MilliRules supports **all** WordPress `is_*` conditional tags (like `is_single()`, `is_tax()`, `is_404()`, etc.):

```php
->when()
    ->is_user_logged_in()             // User authentication
    ->is_singular('post')             // Singular post/page
    ->is_archive()                    // Archive pages
    ->is_tax('channel', 'mtv', '!=')  // Taxonomy term with optional operator
    ->post_type('product')            // Post type
    ->is_home()                       // Home page
    ->is_sticky()                     // Supports ANY is_* function!
```

> [!NOTE]
> WordPress conditions require the WordPress package to be loaded. MilliRules automatically detects WordPress and loads the appropriate package.

### Condition Evaluation Logic

MilliRules supports three evaluation strategies:

#### Match All (AND Logic)

**All conditions must be true** for the rule to execute. This is the default behavior.

```php
Rules::create('secure_api_access')
    ->when()  // Implicitly uses match_all()
        ->request_url('/api/*')
        ->request_method('POST')
        ->request_header('Authorization', 'Bearer *', 'LIKE')
    ->then()
        ->custom('process_request')
    ->register();
```

**Evaluates to**: `condition1 AND condition2 AND condition3`

#### Match Any (OR Logic)

**At least one condition must be true** for the rule to execute.

```php
Rules::create('development_environments')
    ->when()
        ->match_any()  // Explicit OR logic
        ->constant('WP_DEBUG', true)
        ->constant('WP_ENVIRONMENT_TYPE', 'local')
        ->constant('WP_ENVIRONMENT_TYPE', 'development')
    ->then()
        ->custom('enable_debug_bar')
    ->register();
```

**Evaluates to**: `condition1 OR condition2 OR condition3`

#### Match None (NOT Logic)

**All conditions must be false** for the rule to execute.

```php
Rules::create('production_only')
    ->when()
        ->match_none()  // Explicit NOT logic
        ->constant('WP_DEBUG', true)
        ->constant('WP_ENVIRONMENT_TYPE', 'local')
    ->then()
        ->custom('enable_caching')
    ->register();
```

**Evaluates to**: `NOT condition1 AND NOT condition2`

> [!WARNING]
> You cannot mix match types within a single `->when()` block. If you need complex logic like `(A AND B) OR (C AND D)`, create separate rules or use custom condition callbacks.

## Actions: The "Then" Behavior

**Actions** are what happens when conditions are satisfied. They can modify data, trigger side effects, log information, or perform any operation.

### Action Execution

Actions execute **immediately and sequentially** when their rule's conditions match:

```php
Rules::create('api_request_handler')
    ->when()
        ->request_url('/api/process')
    ->then()
        ->custom('log_request')      // Executes first
        ->custom('validate_data')    // Executes second
        ->custom('process_request')  // Executes third
        ->custom('send_response')    // Executes fourth
    ->register();
```

### Action Types

MilliRules supports various action types:

#### 1. Custom Callback Actions

Define actions inline using callbacks:

```php
Rules::register_action('send_email', function($args, Context $context) {
    $to = $args['to'] ?? '';
    $subject = $args['subject'] ?? 'Notification';
    $message = $args['message'] ?? 'Your message';
    wp_mail($to, $subject, $message);
});

// Use in rules:
->then()
    ->send_email(['to' => 'admin@example.com', 'subject' => 'New User Registration'])
    // OR
    ->custom('send_email', ['to' => 'admin@example.com', 'subject' => 'New User Registration'])
```

#### 2. Class-Based Actions

Create reusable action classes:

```php
use MilliRules\Actions\ActionInterface;
use MilliRules\Context;

class SendEmailAction implements ActionInterface {
    private $config;
    private $context;

    public function __construct(array $config, Context $context) {
        $this->config = $config;
        $this->context = $context;
    }

    public function execute(Context $context): void {
        $to = $this->config['value'] ?? '';
        wp_mail($to, 'Subject', 'Message');
    }

    public function get_type(): string {
        return 'send_email';
    }
}
```

#### 3. WordPress Hook Actions

Trigger WordPress actions or filters with inlined callback:

```php
->on('wp_mail', 10) // Registers with WordPress hook
->then()
    ->log_sent_mail(function($args, Context $context) {
        $hook_name = $context->get('hook.name'); // Will be 'wp_mail'
        $hook_args = $context->get('hook.args'); // Will be the array of arguments

        if ($hook_name === 'wp_mail') {
            // Log email sent to $hook_args[0] with subject $hook_args[1] and message $hook_args[2]
            error_log("Sent email to {$hook_args[0]} with subject \"{$hook_args[1]}\"");
        }
    })
```

> [!TIP]
> Use class-based actions for complex logic that requires state management or extensive configuration. Use callback actions for simple, one-off operations.

## Context: Shared Data Pool

The **context** is an object that provides lazy-loaded access to all the data available to conditions and actions. Context sections are loaded on-demand, meaning only the data you actually need is retrieved.

### Context Structure

Context provides a flat, organized structure:

```php
use MilliRules\Context;

// Context sections (loaded on-demand):
[
    'request' => [
        'method' => 'GET',
        'uri' => '/wp-admin/edit.php',
        'scheme' => 'https',
        'host' => 'example.com',
        'path' => '/wp-admin/edit.php',
        'query' => 'post_type=page',
        'referer' => 'https://example.com',
        'user_agent' => 'Mozilla/5.0...',
        'headers' => [...],
        'ip' => '192.168.1.1',
    ],
    'cookie' => [...],       // Cookies (separate from request)
    'param' => [...],        // Request parameters (GET/POST)
    'post' => [...],         // WordPress post data
    'user' => [...],         // WordPress user data
    'query' => [...],        // WordPress query variables (post_type, paged, s, etc.)
    'term' => [...],         // WordPress taxonomy terms
    'rule' => [              // Current rule metadata (set by engine)
        'id' => 'my-rule',
        'order' => 10,
    ],
    'hook' => [              // WordPress hook info (WP package only)
        'name' => 'template_redirect',
        'args' => [...],
    ],
    // Custom package data...
]
```

### Lazy Loading

Context data is loaded **only when needed**. This provides significant performance benefits by avoiding unnecessary data retrieval:

```php
use MilliRules\MilliRules;
use MilliRules\Context;

// Initialize MilliRules
MilliRules::init();

// Context is created but data isn't loaded yet
$context = new Context();

// Data loads automatically when accessed
$uri = $context->get('request.uri');  // Triggers 'request' provider loading

// Access nested values using dot notation
$userId = $context->get('user.id', 0);  // Triggers 'user' provider loading
```

**Key features:**
- **On-demand loading**: Context sections load only when accessed
- **Memoization**: Each section loads at most once per request
- **Granular providers**: Separate providers for request, cookie, and param data
- **Automatic dependencies**: Dependencies load automatically when needed

### Accessing Context in Custom Code

Callback actions and conditions receive the Context object, which provides methods to access data:

```php
use MilliRules\Context;

Rules::register_action('log_context', function($args, Context $context) {
    // get() automatically loads data (recommended)
    $method = $context->get('request.method', 'UNKNOWN');
    $user_id = $context->get('user.id', 0);

    error_log("Request: $method, User: $user_id");
});
```

You can also explicitly load context sections for clarity:

```php
use MilliRules\Context;

Rules::register_action('log_context', function($args, Context $context) {
    // Explicit load() for clarity (optional)
    $context->load('request');
    $context->load('user');

    $method = $context->get('request.method', 'UNKNOWN');
    $user_id = $context->get('user.id', 0);

    error_log("Request: $method, User: $user_id");
});
```

### Context Methods

The Context class provides several useful methods:

```php
// Get a value using dot notation (automatically loads the section if needed)
$value = $context->get('post.type', 'post');
// ↑ Internally calls $context->load('post') if not already loaded

// Set a value using dot notation
$context->set('custom.data', 'value');

// Check if a path exists
if ($context->has('user.id')) {
    // User data is loaded
}

// Explicitly load a context section (optional - get() does this automatically)
$context->load('request');

// Export context as array (for debugging)
$array = $context->to_array();
```

> [!IMPORTANT]
> Context sections are loaded lazily. The `get()` method **automatically loads** the top-level section if it hasn't been loaded yet. You rarely need to call `load()` manually - it's mainly useful for pre-loading multiple sections or for code clarity.

## Packages: Modular Functionality

**Packages** are self-contained modules that provide:
- Conditions
- Actions
- Context data
- Placeholder resolvers

### Built-in Packages

#### PHP Package

**Always available** in any PHP environment:
- Framework-agnostic HTTP conditions
- Request/response handling
- Cookie and header management

```php
// PHP package is always loaded
MilliRules::init();
```

#### WordPress Package

**Available only in WordPress**:
- WordPress-specific conditions
- Hook-based execution
- WordPress data in context

```php
// Automatically loads WordPress package if WordPress is detected
MilliRules::init();
```

### Package Dependencies

Packages can depend on other packages:

```php
// WordPress package requires PHP package
class WordPressPackage extends BasePackage {
    public function get_required_packages(): array {
        return ['PHP'];  // PHP must be loaded first
    }
}
```

MilliRules automatically resolves dependencies:
1. Detects required packages
2. Loads dependencies first
3. Prevents circular dependencies

> [!WARNING]
> Circular dependencies (Package A requires Package B, Package B requires Package A) will cause an error. Design your packages carefully to avoid this.

## Rule Types: PHP vs. WordPress

MilliRules supports two rule types that determine execution strategy:

### PHP Rules (`type: 'php'`)

- Execute immediately when `execute_rules()` is called
- No WordPress hook integration
- Suitable for early execution (caching, redirects)
- Framework-agnostic

```php
Rules::create('cache_check', 'php')
    ->when()->request_url('/api/*')
    ->then()->custom('check_cache')
    ->register();

// Manual execution required
MilliRules::execute_rules(['PHP']);
```

### WordPress Rules (`type: 'wp'`)

- Execute automatically on WordPress hooks
- Integrated with WordPress lifecycle
- Access to WordPress data and functions
- Default type when WordPress is detected

```php
Rules::create('admin_notice', 'wp')
    ->on('admin_notices', 10)  // Registers with WordPress hook
    ->when()->is_user_logged_in()
    ->then()->custom('show_notice')
    ->register();

// Executes automatically when 'admin_notices' hook fires
```

### Auto-Detection

MilliRules auto-detects rule type based on:
1. Explicit `type` parameter
2. Used conditions (WordPress conditions → `wp` type)
3. Hook registration (`.on()` → `wp` type)
4. Default to `php` if ambiguous

```php
// Auto-detected as 'wp' due to WordPress condition
Rules::create('wp_rule_auto')
    ->when()->is_user_logged_in()
    ->then()->custom('action')
    ->register();
```

> [!TIP]
> Always explicitly specify the rule type when creating rules to avoid ambiguity: `Rules::create('rule_id', 'wp')` or `Rules::create('rule_id', 'php')`.

## Execution Flow

Understanding the execution flow helps debug issues and optimize performance:

```
1. Initialize MilliRules
   ↓
2. Register packages
   ↓
3. Load available packages
   ↓
4. Build context from packages
   ↓
5. Register rules
   ↓
6. Trigger execution (manual or hook-based)
   ↓
7. For each rule:
   a. Check if enabled
   b. Validate package availability
   c. Evaluate conditions
   d. If conditions match → execute actions
   ↓
8. Return execution statistics
```

### Execution Statistics

Every execution returns detailed statistics:

```php
$result = MilliRules::execute_rules();

/*
[
    'rules_processed' => 10,   // Total rules evaluated
    'rules_skipped' => 2,      // Rules skipped (disabled/missing packages)
    'rules_matched' => 5,      // Rules where conditions matched
    'actions_executed' => 12,  // Total actions executed
    'context' => [...],        // Execution context
]
*/
```

> [!TIP]
> Use execution statistics for debugging and performance monitoring. Log them in development to understand rule behavior.

## Best Practices

### 1. Keep Rules Focused

```php
// ✅ Good - focused, single purpose
Rules::create('cache_api_responses')
    ->when()->request_url('/api/*')
    ->then()->custom('set_cache_headers')
    ->register();

// ❌ Bad - too many responsibilities
Rules::create('do_everything')
    ->when()->request_url('*')
    ->then()
        ->custom('check_cache')
        ->custom('validate_user')
        ->custom('process_request')
        ->custom('send_email')
        ->custom('update_database')
    ->register();
```

### 2. Use Descriptive Names

```php
// ✅ Good - clear purpose
Rules::create('block_non_authenticated_api_access')
    ->title('Block API Access for Non-Authenticated Users')

// ❌ Bad - unclear purpose
Rules::create('rule1')
    ->title('Check stuff')
```

### 3. Order Rules Logically

```php
// ✅ Good - logical ordering
Rules::create('set_default_cache')->order(10)  // Set defaults first
Rules::create('override_api_cache')->order(20) // Override for specific cases
Rules::create('disable_cache_dev')->order(30)  // Development override last
```

### 4. Leverage Context

```php
use MilliRules\Context;

// ✅ Good - uses context effectively
Rules::register_action('log_user_action', function($args, Context $context) {
    $action = $args['action'] ?? 'accessed';
    $user = $context->get('user.login', 'guest');
    $url = $context->get('request.uri', 'unknown');
    error_log("User $user $action $url");
});
```

## Common Patterns

### 1. Progressive Enhancement Pattern

Layer features based on availability:

```php
// Base rule for all environments
Rules::create('base_security')->order(10)
    ->when()->request_url('*')
    ->then()->custom('basic_security_headers')
    ->register();

// Enhanced rule for WordPress
Rules::create('wp_security')->order(20)
    ->when()->is_user_logged_in()
    ->then()->custom('additional_security_headers')
    ->register();
```

### 3. Override Pattern

Allow specific rules to override general rules:

```php
// General rule
Rules::create('default_cache')->order(10)
    ->when()->request_url('*')
    ->then()->custom('set_cache', ['value' => '3600'])
    ->register();

// Specific override
Rules::create('api_no_cache')->order(20)
    ->when()->request_url('/api/dynamic/*')
    ->then()->custom('set_cache', ['value' => '0'])
    ->register();
```

## Next Steps

Now that you understand core concepts, explore these topics:

- **[Building Rules](/docs/millirules/02-core-concepts/03-building-rules)** - Master the fluent API in depth
- **[Built-in Conditions Reference](/docs/millirules/05-reference/01-conditions)** - Complete condition reference
- **[Operators](/docs/millirules/02-core-concepts/04-operators)** - Pattern matching and comparisons
- **[Packages](/docs/millirules/02-core-concepts/02-packages)** - Deep dive into the package system

---

**Questions about core concepts?** Check the [Complete API Reference](/docs/millirules/05-reference/03-api) or explore [Real-World Examples](/docs/millirules/04-advanced/01-examples) to see these concepts in action.

---

Canonical: https://www.millipress.com/docs/millirules/02-core-concepts/02-packages

---
title: 'Understanding the Package System'
description: 'How MilliRules packages bundle conditions, actions, and context providers: the PHP and WordPress packages, dependency resolution, and lazy context loading.'
menu_order: 20
---

# Understanding the Package System

The package system is the architectural foundation of MilliRules, providing modularity, extensibility, and environment-specific functionality. This guide explains how packages work, their lifecycle, and how to leverage them effectively.

## What is a Package?

A **package** is a self-contained module that provides:

- **Conditions** - Specific to the package's domain
- **Actions** - Operations relevant to the package
- **Context** - Data available during rule execution
- **Placeholder Resolvers** - Custom placeholder categories
- **Dependencies** - Other packages required for operation

Packages enable MilliRules to work across different environments (vanilla PHP, WordPress, custom frameworks) while maintaining a consistent API.

## Package Architecture

```mermaid
flowchart TB
    PM["PackageManager<br/>(Central Coordination)"]

    PM --> PHP["PHP Package"]
    PM --> WP["WP Package"]

    WP -->|"requires"| PHP
```

### PackageManager

The `PackageManager` is a static class that coordinates all packages:

- Registers packages
- Resolves dependencies
- Loads packages in correct order
- Aggregates context from all loaded packages
- Routes rules to appropriate packages

### Package Interface

All packages implement `PackageInterface`:

```php
namespace MilliRules\Packages;

use MilliRules\Context;

interface PackageInterface {
    public function get_name(): string;
    public function get_namespaces(): array;
    public function is_available(): bool;
    public function get_required_packages(): array;
    public function register_providers(Context $context): void;
    public function get_placeholder_resolver(Context $context);
    public function register_rule(array $rule, array $metadata);
    public function execute_rules(array $rules, Context $context): array;
}
```

---

## Built-in Packages

### PHP Package

The **PHP Package** provides framework-agnostic HTTP and request handling.

**Characteristics**:
- Always available in any PHP 7.4+ environment
- No dependencies
- Provides HTTP request conditions
- Builds request-based context

**Namespace**: `MilliRules\Packages\PHP`

**Conditions Provided**:
- `request_url` - URL/URI matching
- `request_method` - HTTP method checking
- `request_header` - Request header validation
- `request_param` - Query/form parameter checking
- `cookie` - Cookie existence/value checking
- `constant` - PHP constant checking

**Context Providers Registered**:
```php
// PHP package registers these context providers (loaded on-demand):

'request' => [
    'method' => 'GET',
    'uri' => '/path',
    'scheme' => 'https',
    'host' => 'example.com',
    'path' => '/path',
    'query' => 'key=value',
    'referer' => 'https://example.com/ref',
    'user_agent' => 'Mozilla/5.0...',
    'headers' => [...],
    'ip' => '192.168.1.1',
],

'cookie' => [
    // $_COOKIE data (loaded separately from request)
],

'param' => [
    // array_merge($_GET, $_POST) (loaded separately from request)
],
```

**Availability Check**:
```php
public function is_available(): bool {
    return true; // Always available
}
```

---

### WordPress Package

The **WordPress Package** provides WordPress-specific functionality.

**Characteristics**:
- Available only in WordPress environments
- Depends on PHP package
- Provides WordPress conditions
- Integrates with WordPress hooks
- Builds WordPress-specific context

**Namespace**: `MilliRules\Packages\WP`

**Conditions Provided**:
- `is_user_logged_in` - User authentication status
- `is_singular` - Singular post/page check
- `is_home` - Home page check
- `is_archive` - Archive page check
- `post_type` - Post type validation

**Context Providers Registered**:
```php
// WordPress package registers these context providers (loaded on-demand):
// Note: Uses flat structure, no 'wp' namespace

'post' => [
    'id' => 123,
    'title' => 'My Post',
    'type' => 'post',
    'status' => 'publish',
    'author' => 1,
    'parent' => 0,
    'name' => 'my-post',
    // ... normalized post fields
],

'user' => [
    'id' => 1,
    'login' => 'admin',
    'email' => 'admin@example.com',
    'roles' => ['administrator'],
    'logged_in' => true,
],

'query' => [
    'post_type' => 'post',
    'paged' => 1,
    's' => '',
    'm' => '',
    // ... WordPress query variables from $wp_query->query_vars
],

'term' => [
    // Taxonomy term data (when applicable)
],
```

**Availability Check**:
```php
public function is_available(): bool {
    return function_exists('add_action'); // Detects WordPress
}
```

**Dependencies**:
```php
public function get_required_packages(): array {
    return ['PHP']; // Requires PHP package
}
```

---

## Package Lifecycle

### 1. Registration

Packages are registered with `PackageManager`:

```php
use MilliRules\PackageManager;
use MilliRules\Packages\PHP\PHPPackage;
use MilliRules\Packages\WP\WordPressPackage;

// Manual registration
$php_package = new PHPPackage();
$wp_package = new WordPressPackage();

PackageManager::register_package($php_package);
PackageManager::register_package($wp_package);
```

### 2. Loading

Packages are loaded during initialization:

```php
use MilliRules\MilliRules;

// Auto-loads available packages
MilliRules::init();

// Or specify packages explicitly
MilliRules::init(['PHP', 'WP']);
```

**Loading Process**:
1. Check if package is available (`is_available()`)
2. Resolve dependencies
3. Load required packages first
4. Load the requested package
5. Detect circular dependencies

### 3. Context Provider Registration

Packages register context providers that load data on-demand:

```php
use MilliRules\Context;

// During initialization, packages register their providers
class PHPPackage extends BasePackage {
    public function register_providers(Context $context): void {
        // Register request provider (loads only when needed)
        $context->register_provider('request', function() {
            return [/* request data */];
        });

        // Register cookie provider (loads only when needed)
        $context->register_provider('cookie', function() {
            return $_COOKIE;
        });

        // Register param provider (loads only when needed)
        $context->register_provider('param', function() {
            return array_merge($_GET, $_POST);
        });
    }
}

// Context sections load lazily when accessed:
// - 'request' loads when $context->get('request.uri') is called
// - 'cookie' loads when $context->get('cookie.session_id') is called
// - 'param' loads when $context->get('param.action') is called
```

### 4. Rule Execution

Rules execute using context from loaded packages:

```php
$result = MilliRules::execute_rules();

/*
[
    'rules_processed' => 10,
    'rules_skipped' => 2,     // Skipped if package unavailable
    'rules_matched' => 5,
    'actions_executed' => 12,
    'context' => [...],
]
*/
```

---

## Package Dependencies

Packages can depend on other packages using `get_required_packages()`.

### Declaring Dependencies

```php
namespace MyPlugin\Packages;

use MilliRules\Packages\BasePackage;

class CustomPackage extends BasePackage {
    public function get_name(): string {
        return 'Custom';
    }

    public function get_required_packages(): array {
        return ['PHP', 'WP']; // Requires both PHP and WordPress
    }

    // ... other methods
}
```

### Dependency Resolution

PackageManager automatically resolves dependencies:

```php
// Request to load Custom package
MilliRules::init(['Custom']);

// Automatic resolution:
// 1. Custom requires ['PHP', 'WP']
// 2. WP requires ['PHP']
// 3. Load order: PHP → WP → Custom
```

### Circular Dependency Detection

```php
// Package A requires Package B
// Package B requires Package A
// ↓
// Error: Circular dependency detected

try {
    MilliRules::init(['A']);
} catch (Exception $e) {
    error_log('Circular dependency: ' . $e->getMessage());
}
```

> [!WARNING]
> Design your package dependencies carefully to avoid circular dependencies. Each package should have a clear, unidirectional dependency relationship.

---

## Package Namespaces

Packages provide namespaces for conditions and actions.

### Namespace Registration

```php
public function get_namespaces(): array {
    return [
        'MilliRules\Packages\PHP\Conditions',
        'MilliRules\Packages\PHP\Actions',
    ];
}
```

### Namespace Resolution

When a condition is used, MilliRules finds the appropriate class:

```php
// User writes:
->request_url('/api/*')

// MilliRules resolves:
// 1. Converts 'request_url' to 'RequestUrl'
// 2. Searches registered namespaces
// 3. Finds: MilliRules\Packages\PHP\Conditions\RequestUrlCondition
// 4. Instantiates class with config and context
```

**Longest Match Algorithm**:

Multiple packages can provide overlapping namespaces. MilliRules uses the longest matching namespace:

```php
// Registered namespaces:
// - 'MyPlugin\Conditions'
// - 'MyPlugin\Conditions\Advanced'

// Looking for: MyPlugin\Conditions\Advanced\CustomCondition
// ↓
// Uses: 'MyPlugin\Conditions\Advanced' (longest match)
```

---

## Package Filtering

You can filter which packages are used during execution.

### Filter by Package Name

```php
// Execute only with PHP package (skip WordPress)
$result = MilliRules::execute_rules(['PHP']);

// Execute only with WordPress package
$result = MilliRules::execute_rules(['WP']);

// Execute with specific packages
$result = MilliRules::execute_rules(['PHP', 'Custom']);
```

### Use Cases

**Early execution** (before WordPress loads):

```php
// In mu-plugins or early hook
MilliRules::init(['PHP']);

// Execute only PHP rules
$result = MilliRules::execute_rules(['PHP']);
```

**Testing specific packages**:

```php
// Test only PHP-related rules
$php_result = MilliRules::execute_rules(['PHP']);

// Test only WordPress-related rules
$wp_result = MilliRules::execute_rules(['WP']);
```

---

## Package Context

### Accessing Package Context

```php
use MilliRules\Context;

Rules::register_action('context_aware', function($args, Context $context) {
    // Check which providers are available
    // Note: Providers are loaded on-demand, not preloaded

    // Access request data (triggers lazy loading if not already loaded)
    if ($context->has('request.uri')) {
        $url = $context->get('request.uri', '');
        error_log("Request URL: {$url}");
    }

    // Access WordPress user data (triggers lazy loading if not already loaded)
    $context->load('user');
    if ($context->has('user.id')) {
        $user_id = $context->get('user.id', 0);
        error_log("WordPress user: {$user_id}");
    }
});
```

### Conditional Package Features

```php
Rules::create('flexible_rule')
    ->when_any()  // Use OR logic
        // PHP condition (always works)
        ->request_url('/api/*')

        // WordPress condition (works if WP package loaded)
        ->is_user_logged_in()
    ->then()
        ->custom('context_aware')  // Action adapts to available packages
    ->register();
```

---

## Best Practices

### 1. Check Package Availability

```php
use MilliRules\Context;

// ✅ Good - check before using package-specific features
Rules::register_action('safe_wp_action', function($args, Context $context) {
    $context->load('user');

    if (!$context->has('user.id')) {
        error_log('WordPress user context not available');
        return;
    }

    $user_id = $context->get('user.id', 0);
    // Use WordPress features
});

// ❌ Bad - assumes WordPress context is always available
Rules::register_action('unsafe_action', function($args, Context $context) {
    $context->load('user');
    $user_id = $context->get('user.id'); // May return null if not available!
});
```

### 2. Declare Dependencies Explicitly

```php
// ✅ Good - explicit dependencies
class MyPackage extends BasePackage {
    public function get_required_packages(): array {
        return ['PHP', 'WP'];
    }
}

// ❌ Bad - undeclared dependencies
class MyPackage extends BasePackage {
    public function register_providers(Context $context): void {
        // Uses WordPress functions without declaring dependency!
        $context->register_provider('data', function() {
            return ['option' => get_option('my_option')];
        });
    }
}
```

### 3. Use Appropriate Package for Rules

```php
// ✅ Good - PHP rule for PHP conditions
Rules::create('cache_check', 'php')
    ->when()->request_url('/api/*')
    ->then()->custom('check_cache')
    ->register();

// ✅ Good - WordPress rule for WordPress conditions
Rules::create('admin_notice', 'wp')
    ->when()->is_user_logged_in()
    ->then()->custom('show_notice')
    ->register();

// ❌ Unclear - mixed without explicit type
Rules::create('mixed_rule')  // Type will be auto-detected
    ->when()
        ->request_url('/api/*')
        ->is_user_logged_in()
    ->then()->custom('action')
    ->register();
```

### 4. Handle Package Unavailability Gracefully

```php
use MilliRules\PackageManager;

// Check if package is loaded
if (PackageManager::is_package_loaded('WP')) {
    // Create WordPress-specific rules
    Rules::create('wp_rule')
        ->when()->is_user_logged_in()
        ->then()->custom('wp_action')
        ->register();
}
```

---

## Package Information

### Get Loaded Packages

```php
use MilliRules\MilliRules;

// Get package names
$package_names = MilliRules::get_loaded_packages();
// ['PHP', 'WP']

error_log('Loaded packages: ' . implode(', ', $package_names));
```

### Check Specific Package

```php
use MilliRules\PackageManager;

if (PackageManager::is_package_loaded('WP')) {
    error_log('WordPress package is loaded');
}

if (PackageManager::has_packages()) {
    error_log('At least one package is loaded');
}
```

### Get Package Instance

```php
use MilliRules\PackageManager;

$php_package = PackageManager::get_package('PHP');

if ($php_package) {
    $namespaces = $php_package->get_namespaces();
    error_log('PHP package namespaces: ' . print_r($namespaces, true));
}
```

---

## Common Patterns

### 1. Environment-Specific Loading

```php
// Load different packages based on environment
if (defined('WP_CLI') && WP_CLI) {
    // CLI environment - PHP only
    MilliRules::init(['PHP']);
} elseif (defined('DOING_CRON') && DOING_CRON) {
    // Cron environment - PHP + WordPress
    MilliRules::init(['PHP', 'WP']);
} else {
    // Normal request - all packages
    MilliRules::init();
}
```

### 2. Progressive Enhancement

```php
// Base rules with PHP package
MilliRules::init(['PHP']);

Rules::create('base_security')
    ->when()->request_url('*')
    ->then()->custom('basic_security')
    ->register();

// Enhance with WordPress if available
if (PackageManager::is_package_loaded('WP')) {
    Rules::create('wp_security')
        ->when()->is_user_logged_in()
        ->then()->custom('enhanced_security')
        ->register();
}
```

### 3. Package-Specific Rules

```php
// Group rules by package
$php_rules = [
    'api_cache', 'request_logging', 'header_security'
];

$wp_rules = [
    'admin_notices', 'user_redirects', 'content_filtering'
];

// Register PHP rules
foreach ($php_rules as $rule_id) {
    Rules::create($rule_id, 'php')
        ->when()->request_url('*')
        ->then()->custom($rule_id . '_action')
        ->register();
}

// Register WordPress rules (if available)
if (PackageManager::is_package_loaded('WP')) {
    foreach ($wp_rules as $rule_id) {
        Rules::create($rule_id, 'wp')
            ->when()->is_user_logged_in()
            ->then()->custom($rule_id . '_action')
            ->register();
    }
}
```

---

## Troubleshooting

### Package Not Loading

**Check availability**:
```php
$package = PackageManager::get_package('WP');
if (!$package) {
    error_log('Package not registered');
} elseif (!$package->is_available()) {
    error_log('Package not available in this environment');
}
```

### Dependency Issues

**Check dependencies**:
```php
$package = PackageManager::get_package('Custom');
$required = $package->get_required_packages();

foreach ($required as $dep) {
    if (!PackageManager::is_package_loaded($dep)) {
        error_log("Missing dependency: {$dep}");
    }
}
```

### Context Missing Data

**Verify providers are registered**:
```php
use MilliRules\Context;

$context = new Context();
MilliRules::init(); // Registers all providers

// Check if a specific provider is available
$context->load('user');
if (!$context->has('user.id')) {
    error_log('WordPress user context not available');
}

// Export context to see all loaded sections
$array = $context->to_array();
error_log('Available context keys: ' . implode(', ', array_keys($array)));
```

---

## Next Steps

- **[Creating Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Extend package conditions
- **[Creating Custom Packages](/docs/millirules/03-customization/03-custom-packages)** - Build your own packages
- **[Advanced Patterns](/docs/millirules/04-advanced/02-advanced-patterns)** - Advanced package techniques
- **[WordPress Integration](/docs/millirules/04-advanced/03-wordpress-integration)** - WordPress package details

---

**Ready to create your own package?** Continue to [Creating Custom Packages](/docs/millirules/03-customization/03-custom-packages) to learn how to extend MilliRules with your own functionality.

---

Canonical: https://www.millipress.com/docs/millirules/02-core-concepts/03-building-rules

---
title: 'Building Rules with the Fluent API'
description: 'Compose custom rules with the MilliRules fluent PHP API: chain when/then methods, combine match types in condition groups, and hook into WordPress.'
menu_order: 30
---

# Building Rules with the Fluent API

MilliRules provides an elegant, fluent API that makes building rules intuitive and readable. This guide covers everything from basic rule creation to advanced pattern matching and complex condition logic.

## The Fluent Interface

The fluent interface allows you to chain methods together to build rules in a natural, readable way:

```mermaid
flowchart LR
    Create["Rules::create()"] --> Meta["Metadata<br/><i> ->title(), ->order(), ->enabled() </i>"]
    Meta --> When["when()<br/><i>+ conditions</i>"]
    When --> And["and()->when()<br/><i>+ more conditions</i>"]
    And --> And
    When --> Then["then()<br/><i>+ actions</i>"]
    And --> Then
    Then --> Register["register()"]
```

```php
use MilliRules\Rules;

Rules::create('my_rule')
    ->title('My Rule Title')
    ->order(10)
    ->enabled(true)
    ->when()
        ->condition1()
        ->condition2()
    ->then()
        ->action1()
        ->action2()
    ->register();
```

Each method returns the builder object, allowing you to continue chaining.

## Creating Rules

### Basic Rule Creation

The `create()` method is your starting point:

```php
use MilliRules\Rules;

// Minimal rule with auto-detected type
$rule = Rules::create('rule_id');

// With explicit type
$rule = Rules::create('rule_id', 'wp');  // WordPress rule
$rule = Rules::create('rule_id', 'php'); // PHP rule
```

> [!IMPORTANT]
> Rule IDs must be unique across your entire application. Consider using a prefix to avoid conflicts: `'my_plugin_rule_id'` or `'company_feature_rule'`.

### Replacing and Removing Rules

#### Rule Replacement

Registering a rule with an existing ID **replaces** the previous rule:

```php
// Original rule
Rules::create('api_cache')
    ->when()->request_url('/api/*')
    ->then()->custom('cache_response', ['ttl' => 3600])
    ->register();

// Later: Replace with updated version (same ID)
Rules::create('api_cache')
    ->when()->request_url('/api/*')
    ->then()->custom('cache_response', ['ttl' => 7200])  // Different TTL
    ->register();

// Only the second rule exists - the first was replaced
```

This is useful for:
- Child themes overriding parent theme rules
- Plugins modifying default rules
- Environment-specific rule customization

#### Which rule wins

Both rules above use the default order of `10`, so the second replaces the first. When the orders differ, **the higher order wins** — regardless of which registered first:

```php
Rules::create('api_cache')->order(20)->then()->custom('a')->register();
Rules::create('api_cache')->order(10)->then()->custom('b')->register();

// The order 20 rule stays. The second registration is discarded
// with a warning; its order is available via
// PackageManager::discarded_orders('api_cache').
```

Deciding by order rather than by registration sequence keeps the outcome stable no matter which plugin or theme file loads first. A tie goes to the incoming rule, so a stored rule can still take over a built-in registered with the same number.

If your override is being ignored, give it a **higher** order than the rule you are replacing. [Locked rules](/docs/millirules/02-core-concepts/01-concepts#preventing-rule-replacement-with-rule-locking) are never replaced, at any order.

#### Removing Rules

To completely remove a rule, use `Rules::unregister()`:

```php
// Remove a rule by ID
Rules::unregister('unwanted_rule');

// Example: Child theme disables parent's rule
Rules::unregister('parent_theme_sidebar_rule');

// Example: Conditionally disable rules
if (wp_get_environment_type() === 'production') {
    Rules::unregister('debug_logging_rule');
}
```

> [!TIP]
> `Rules::unregister()` returns `true` if the rule was found and removed, `false` otherwise.

### Rule Metadata

Add descriptive information to your rules:

```php
Rules::create('api_cache_control')
    ->title('Control API Response Caching')   // Human-readable title
    ->order(15)                               // Execution sequence
    ->enabled(true)                           // Enable/disable
    ->register();
```

#### Setting Execution Order

The `order()` method controls when rules execute (lower numbers execute first):

```php
// These execute in sequence: security → cache → logging
Rules::create('security_check')->order(5)->when()->then()->register();
Rules::create('cache_control')->order(10)->when()->then()->register();
Rules::create('request_logging')->order(15)->when()->then()->register();
```

> [!TIP]
> Use order increments of 5 or 10 to leave room for inserting rules between existing ones later.

#### Enabling/Disabling Rules

```php
// Enable rule
Rules::create('my_rule')->enabled(true)->when()->then()->register();

// Disable rule (useful for testing or feature flags)
Rules::create('my_rule')->enabled(false)->when()->then()->register();

// Use constant for dynamic control
Rules::create('debug_rule')
    ->enabled(defined('WP_DEBUG') && WP_DEBUG)
    ->when()->then()->register();
```

## Building Conditions

Conditions determine when a rule should execute. The `when()` method starts the condition builder.

### Basic Conditions

```php
Rules::create('check_request')
    ->when()
        ->request_url('/api/users')       // Check URL
        ->request_method('POST')          // Check HTTP method
    ->then()
        ->custom('process_users')
    ->register();
```

### Condition Chaining

Chain multiple conditions together:

```php
Rules::create('secure_api')
    ->when()
        ->request_url('/api/*')                               // URL pattern
        ->request_method('POST')                              // HTTP method
        ->request_header('Content-Type', 'application/json')  // Header
        ->cookie('session_id')                                // Cookie exists
    ->then()
        ->custom('process_request')
    ->register();
```

By default, **all conditions must be true** (AND logic).

### Match Types: Controlling Condition Logic

MilliRules supports three match types that control how conditions are evaluated:

#### Match All (AND Logic) - Default

All conditions must be true:

```php
Rules::create('strict_validation')
    ->when()  // Implicitly uses match_all()
        ->request_url('/api/secure')
        ->request_method('POST')
        ->cookie('auth_token')
    ->then()
        ->custom('process_secure_request')
    ->register();
```

**Evaluates as**: `condition1 AND condition2 AND condition3`

#### Match Any (OR Logic)

At least one condition must be true:

```php
Rules::create('flexible_access')
    ->when()
        ->match_any()  // Use OR logic
        ->request_url('/public/*')
        ->cookie('visitor_token')
        ->is_user_logged_in()
    ->then()
        ->custom('grant_access')
    ->register();
```

**Evaluates as**: `condition1 OR condition2 OR condition3`

#### Match None (NOT Logic)

All conditions must be false:

```php
Rules::create('production_only')
    ->when()
        ->match_none()  // Use NOT logic
        ->constant('WP_DEBUG', true)
        ->constant('WP_LOCAL_DEV', true)
    ->then()
        ->custom('enable_production_features')
    ->register();
```

**Evaluates as**: `NOT condition1 AND NOT condition2`

### Alternative Match Type Methods

You can also use dedicated methods to start condition building with a specific match type:

```php
// These are equivalent:
->when()->match_all()
->when_all()

->when()->match_any()
->when_any()

->when()->match_none()
->when_none()
```

**Example with alternative syntax**:

```php
Rules::create('development_environments')
    ->when_any()  // Start with OR logic
        ->constant('WP_DEBUG', true)
        ->constant('WP_ENVIRONMENT_TYPE', 'local')
        ->constant('WP_ENVIRONMENT_TYPE', 'development')
    ->then()
        ->custom('enable_debug_tools')
    ->register();
```

> [!WARNING]
> You cannot mix match types within a single `when()` block. Choose one match type per condition group. To combine different match types, use `->and()` to create multiple groups (see [Condition Groups](#condition-groups) below).

## Condition Groups

When a single match type isn't enough, use `->and()` to chain multiple condition groups with different logic. Each group has its own match type, and **all groups must pass** for the rule to match.

### Basic Condition Groups

```php
use MilliRules\Rules;

// "any of these post types" AND "none of these user roles"
Rules::create('members_only_content')
    ->when_any()
        ->post_type('premium_post')
        ->post_type('members_page')
    ->and()->when_none()
        ->user_role('subscriber')
        ->user_role('pending')
    ->then()
        ->custom('grant_premium_access')
    ->register();
```

**Evaluates as**: `(post_type=premium_post OR post_type=members_page) AND NOT (user_role=subscriber) AND NOT (user_role=pending)`

### Multiple Groups

Chain as many groups as needed:

```php
Rules::create('complex_cache_rule')
    ->when_any()                          // Group 1: URL matching (OR)
        ->request_url('/api/*')
        ->request_url('/feed/*')
    ->and()->when_all()                   // Group 2: Method + auth (AND)
        ->request_method('GET')
        ->cookie('session_id')
    ->and()->when_none()                  // Group 3: Exclusions (NOT)
        ->constant('WP_DEBUG', true)
        ->request_param('nocache')
    ->then()
        ->custom('enable_caching')
    ->register();
```

**Evaluates as**: `(URL matches any) AND (method=GET AND cookie exists) AND NOT (debug OR nocache param)`

### How It Works

Each `->and()` call finalizes the current group and prepares for the next one:

1. `->when_any()` starts the first group with OR logic
2. `->and()` wraps the collected conditions into a group
3. `->when_none()` starts a new group with NOT logic
4. `->then()` finalizes the last group and transitions to actions

Groups are always combined with AND logic — every group must pass for the rule to match.

### Data Structure

Under the hood, condition groups are stored as entries in the `conditions` array. A group entry has `match_type` + `conditions` keys, while a regular condition has a `type` key:

```php
[
    'match_type' => 'all',
    'conditions' => [
        [
            'match_type' => 'any',       // ← This is a group
            'conditions' => [
                ['type' => 'post_type', 'value' => 'premium_post'],
                ['type' => 'post_type', 'value' => 'members_page'],
            ],
        ],
        [
            'match_type' => 'none',      // ← This is a group
            'conditions' => [
                ['type' => 'user_role', 'value' => 'subscriber'],
            ],
        ],
    ],
]
```

> [!TIP]
> For OR logic between condition sets (e.g., "(A AND B) OR (C AND D)"), use separate rules with the same actions. Each rule represents one branch of the OR.

## Seamless Builder Transitions

The fluent API allows seamless transitions between builders:

```php
Rules::create('wordpress_admin_check')
    ->when()
        ->request_url('/wp-admin/*')
        ->is_user_logged_in()  // WordPress condition
    // Automatically transitions from ConditionBuilder to Rules
    ->then()
        ->custom('log_admin_access')
    ->register();
```

This works because:
1. The `->when()` method returns a `ConditionBuilder`
2. WordPress conditions (like `is_user_logged_in()`) are not defined on `ConditionBuilder`
3. The builder's `__call()` magic method detects this
4. It adds the condition and returns the parent `Rules` object
5. You can seamlessly call `->then()` to build actions

### Manual Builder Management

For more control, you can manually manage builder instances:

```php
$rule = Rules::create('complex_rule');

$conditions = $rule->when();
$conditions->request_url('/api/*');
$conditions->request_method('POST');

$actions = $rule->then();
$actions->custom('validate_data');
$actions->custom('process_request');

$rule->register();
```

## Building Actions

Actions execute when conditions are satisfied. The `then()` method starts the action builder.

### Basic Actions

```php
Rules::create('process_request')
    ->when()
        ->request_url('/api/process')
    ->then()
        ->custom('log_request')
        ->custom('validate_data')
        ->custom('process_data')
        ->custom('send_response')
    ->register();
```

Actions execute **sequentially** in the order they're defined.

### Action Configuration

Pass configuration to actions using arrays:

```php
Rules::create('send_notification')
    ->when()
        ->request_url('/api/notify')
    ->then()
        ->custom('send_email', [
            'value' => 'admin@example.com',
            'subject' => 'New Notification',
            'message' => 'You have a new notification'
        ])
        ->custom('log_notification', [
            'value' => 'Email sent to admin'
        ])
    ->register();
```

### Inline Actions with Closures

For simple operations, define actions inline:

```php
use MilliRules\Context;

Rules::register_action('inline_log', function($args, Context $context) {
    $message = $args['value'] ?? 'No message';
    error_log('MilliRules: ' . $message);
});

Rules::create('use_inline_action')
    ->when()->request_url('/test')
    ->then()->custom('inline_log', ['value' => 'Test URL accessed'])
    ->register();
```

## Using Operators

Operators control how condition values are compared. While MilliRules auto-detects operators, you can specify them explicitly.

### Explicit Operator Specification

```php
Rules::create('operator_examples')
    ->when()
        // Equality
        ->request_method('GET', '=')       // Exact match (default)
        ->request_method('POST', '!=')     // Not equal

        // Numeric comparison
        ->request_param('age', '18', '>')  // Greater than
        ->request_param('age', '65', '<')  // Less than

        // Pattern matching
        ->request_url('/admin/*', 'LIKE')  // Wildcard pattern
        ->request_url('/^\\/api\\/v[0-9]+\\//i', 'REGEXP')  // Regex

        // Array membership
        ->request_method(['GET', 'HEAD'], 'IN')  // In array
        ->request_method(['POST', 'PUT'], 'NOT IN')  // Not in array

        // Existence checking
        ->cookie('session_id', null, 'EXISTS')      // Cookie exists
        ->cookie('temp_token', null, 'NOT EXISTS')  // Cookie doesn't exist

        // Boolean comparison
        ->constant('WP_DEBUG', true, 'IS')      // Is true
        ->constant('WP_DEBUG', false, 'IS NOT') // Is not true
    ->then()
        ->custom('action')
    ->register();
```

### Auto-Detected Operators

MilliRules automatically infers operators from values:

```php
Rules::create('auto_operators')
    ->when()
        // String → '=' operator
        ->request_method('GET')

        // Array → 'IN' operator
        ->request_method(['GET', 'HEAD'])

        // Boolean → 'IS' operator
        ->constant('WP_DEBUG', true)

        // Null → 'EXISTS' operator
        ->cookie('session_id')

        // String with wildcards → 'LIKE' operator
        ->request_url('/admin/*')

        // String starting with '/' → 'REGEXP' operator
        ->request_url('/^\\/api\\//i')
    ->then()
        ->custom('action')
    ->register();
```

> [!TIP]
> Let MilliRules auto-detect operators for cleaner code. Only specify operators explicitly when you need precise control or when auto-detection doesn't match your intent.

For complete operator documentation, see [Operators and Pattern Matching](/docs/millirules/02-core-concepts/04-operators).

## Custom Conditions

When built-in conditions aren't enough, use custom conditions.

### Inline Custom Conditions

```php
use MilliRules\Context;

Rules::register_condition('is_weekend', function(Context $context) {
    $day = date('N'); // 1 (Monday) to 7 (Sunday)
    return $day >= 6; // Saturday or Sunday
});

Rules::create('weekend_special')
    ->when()
        ->custom('is_weekend')
        ->request_url('/special-offer')
    ->then()
        ->custom('show_weekend_discount')
    ->register();
```

### Parameterized Custom Conditions

```php
use MilliRules\Context;

Rules::register_condition('time_range', function($args, Context $context) {
    $current_hour = (int) date('H');
    $start = $args['start'] ?? 0;
    $end = $args['end'] ?? 23;

    return $current_hour >= $start && $current_hour <= $end;
});

Rules::create('business_hours')
    ->when()
        ->custom('time_range', ['start' => 9, 'end' => 17])
    ->then()
        ->custom('show_business_hours_message')
    ->register();
```

See [Creating Custom Conditions](/docs/millirules/03-customization/01-custom-conditions) for advanced techniques.

## WordPress Hook Integration

WordPress rules can execute on specific hooks:

```php
Rules::create('admin_notice', 'wp')
    ->on('admin_notices', 10)  // Hook name and priority
    ->when()
        ->is_user_logged_in()
        ->constant('WP_DEBUG', true)
    ->then()
        ->custom('show_debug_notice')
    ->register();
```

### Common WordPress Hooks

```php
// Initialization
->on('init', 10)
->on('plugins_loaded', 10)

// Frontend
->on('wp', 10)
->on('template_redirect', 10)
->on('wp_enqueue_scripts', 10)

// Admin
->on('admin_init', 10)
->on('admin_menu', 10)
->on('admin_notices', 10)

// Content
->on('the_content', 10)
->on('the_title', 10)

// Saving
->on('save_post', 10)
->on('wp_insert_post', 10)
```

> [!NOTE]
> The `->on()` method automatically sets the rule type to `'wp'`. You don't need to specify the type explicitly when using hooks.

## Advanced Patterns

### 1. Conditional Rule Registration

Register rules only when needed:

```php
if (is_admin()) {
    Rules::create('admin_only_rule')
        ->when()->is_user_logged_in()
        ->then()->custom('admin_action')
        ->register();
}

if (defined('WP_CLI') && WP_CLI) {
    Rules::create('cli_only_rule')
        ->when()->custom('is_cli_context')
        ->then()->custom('cli_action')
        ->register();
}
```

### 2. Dynamic Rule Generation

Generate rules programmatically:

```php
$protected_urls = ['/admin', '/dashboard', '/settings'];

foreach ($protected_urls as $url) {
    Rules::create('protect_' . sanitize_title($url))
        ->when()
            ->request_url($url . '/*')
            ->is_user_logged_in(false)  // Not logged in
        ->then()
            ->custom('redirect_to_login', ['url' => $url])
        ->register();
}
```

### 3. Rule Groups with Shared Configuration

Create related rules with shared settings:

```php
$api_rules_config = [
    'order' => 10,
    'type' => 'php',
];

$api_endpoints = ['users', 'posts', 'comments'];

foreach ($api_endpoints as $endpoint) {
    Rules::create("api_{$endpoint}_cache")
        ->order($api_rules_config['order'])
        ->when()
            ->request_url("/api/{$endpoint}/*")
            ->request_method('GET')
        ->then()
            ->custom('set_cache_headers', ['duration' => 3600])
        ->register();
}
```

### 4. Condition Groups with `and()`

Combine multiple condition groups with different match types using `->and()`:

```php
// (any of these URLs) AND (none of these roles)
Rules::create('restricted_pages')
    ->when_any()
        ->request_url('/members/*')
        ->request_url('/premium/*')
    ->and()->when_none()
        ->user_role('subscriber')
        ->user_role('pending')
    ->then()
        ->custom('grant_access')
    ->register();
```

See [Condition Groups](#condition-groups) below for full documentation.

## Method Chaining Reference

### Rule Builder Methods

| Method        | Parameters                    | Returns            | Description                              |
|---------------|-------------------------------|--------------------|------------------------------------------|
| `create()`    | `string $id, ?string $type`   | `Rules`            | Create new rule                          |
| `title()`     | `string $title`               | `Rules`            | Set rule title                           |
| `order()`     | `int $order`                  | `Rules`            | Set execution order                      |
| `enabled()`   | `bool $enabled`               | `Rules`            | Enable/disable rule                      |
| `lock()`      | -                             | `Rules`            | Lock rule (prevent overwrite/unregister) |
| `when()`      | -                             | `ConditionBuilder` | Start condition builder                  |
| `when_all()`  | -                             | `ConditionBuilder` | Start with AND logic                     |
| `when_any()`  | -                             | `ConditionBuilder` | Start with OR logic                      |
| `when_none()` | -                             | `ConditionBuilder` | Start with NOT logic                     |
| `and()`       | -                             | `Rules`            | Finalize group, chain next `when_*()`    |
| `then()`      | `?array $actions`             | `ActionBuilder`    | Start action builder                     |
| `on()`        | `string $hook, int $priority` | `Rules`            | Set WordPress hook                       |
| `register()`  | -                             | `bool`             | Register rule (replaces if ID exists)    |
| `unregister()`| `string $rule_id`             | `bool`             | Remove rule by ID (static method)        |

### Condition Builder Methods

| Method            | Parameters                 | Returns            | Description               |
|-------------------|----------------------------|--------------------|---------------------------|
| `match_all()`     | -                          | `ConditionBuilder` | Use AND logic             |
| `match_any()`     | -                          | `ConditionBuilder` | Use OR logic              |
| `match_none()`    | -                          | `ConditionBuilder` | Use NOT logic             |
| `custom()`        | `string $type, mixed $arg` | `ConditionBuilder` | Add custom condition      |
| `add_namespace()` | `string $namespace`        | `ConditionBuilder` | Add condition namespace   |
| `{dynamic}()`     | `mixed ...$args`           | `mixed`            | Dynamic condition methods |

### Action Builder Methods

| Method            | Parameters                 | Returns         | Description            |
|-------------------|----------------------------|-----------------|------------------------|
| `custom()`        | `string $type, mixed $arg` | `ActionBuilder` | Add custom action      |
| `lock()`          | -                          | `ActionBuilder` | Lock last action type  |
| `add_namespace()` | `string $namespace`        | `ActionBuilder` | Add action namespace   |
| `{dynamic}()`     | `mixed ...$args`           | `mixed`         | Dynamic action methods |

## Best Practices

### 1. Use Descriptive Rule IDs

```php
// ✅ Good - clear and descriptive
Rules::create('block_non_authenticated_api_access')
Rules::create('cache_public_api_responses')
Rules::create('log_admin_user_actions')

// ❌ Bad - unclear and unmaintainable
Rules::create('rule1')
Rules::create('check')
Rules::create('x')
```

### 2. Add Titles to All Rules

```php
// ✅ Good - includes helpful title
Rules::create('api_authentication')
    ->title('Enforce API Authentication')
    ->when()->request_url('/api/*')
    ->then()->custom('check_auth')
    ->register();

// ❌ Bad - no title makes debugging harder
Rules::create('api_authentication')
    ->when()->request_url('/api/*')
    ->then()->custom('check_auth')
    ->register();
```

### 3. Keep Condition Groups Logical

```php
// ✅ Good - logical grouping
Rules::create('secure_api_access')
    ->when()
        ->request_url('/api/secure/*')    // Context: API endpoint
        ->request_method('POST')          // Context: HTTP method
        ->cookie('auth_token')            // Context: Authentication
    ->then()->custom('process_secure_request')
    ->register();

// ❌ Bad - unrelated conditions
Rules::create('random_checks')
    ->when()
        ->request_url('/api/*')
        ->is_home()                       // Unrelated to API
        ->constant('WP_DEBUG', true)      // Unrelated to request
    ->then()->custom('do_something')
    ->register();
```

### 4. Use Comments for Complex Logic

```php
Rules::create('complex_caching_logic')
    ->order(15)
    ->when()
        // Check if this is a cacheable request
        ->request_method(['GET', 'HEAD'], 'IN')

        // Ensure we're not in admin or login areas
        ->request_url('/wp-admin/*', 'NOT LIKE')
        ->request_url('/wp-login.php', '!=')

        // Verify user preferences allow caching
        ->cookie('disable_cache', null, 'NOT EXISTS')
    ->then()
        ->custom('apply_caching_headers')
    ->register();
```

### 5. Test Rules Incrementally

```php
// Start simple
Rules::create('test_rule')
    ->when()->request_url('/test')
    ->then()->custom('log', ['value' => 'Test URL hit'])
    ->register();

// Add complexity gradually
Rules::create('test_rule')
    ->when()
        ->request_url('/test')
        ->request_method('POST')  // Add second condition
    ->then()
        ->custom('log', ['value' => 'Test POST hit'])
    ->register();
```

## Common Pitfalls

### 1. Forgetting to Register

```php
// ❌ Wrong - rule never registered
Rules::create('my_rule')
    ->when()->request_url('/test')
    ->then()->custom('action');
// Missing ->register()

// ✅ Correct
Rules::create('my_rule')
    ->when()->request_url('/test')
    ->then()->custom('action')
    ->register();  // Always register!
```

### 2. Mixing Match Types

```php
// ❌ Wrong - cannot switch match types mid-chain
Rules::create('mixed_logic')
    ->when()
        ->match_all()
        ->condition1()
        ->match_any()  // Cannot switch!
        ->condition2()

// ✅ Correct - use one match type per group
Rules::create('consistent_logic')
    ->when_any()
        ->condition1()
        ->condition2()

// ✅ Also correct - use and() for different match types
Rules::create('grouped_logic')
    ->when_any()
        ->condition1()
        ->condition2()
    ->and()->when_none()
        ->condition3()
```

### 3. Incorrect Hook Timing

```php
// ❌ Wrong - registering rules too late
add_action('wp_footer', function() {
    MilliRules::init();
    Rules::create('my_rule')->on('init')->when()->then()->register();
    // 'init' hook already fired!
});

// ✅ Correct - register early
add_action('plugins_loaded', function() {
    MilliRules::init();
    Rules::create('my_rule')->on('template_redirect')->when()->then()->register();
}, 1); // Early priority
```

## Troubleshooting

### Rules Not Executing

**Check initialization**:
```php
// Verify MilliRules is initialized
$packages = MilliRules::get_loaded_packages();
error_log('Loaded packages: ' . print_r($packages, true));
```

**Verify rule registration**:
```php
Rules::create('debug_rule')
    ->title('Debug Rule')
    ->when()->request_url('*')
    ->then()->custom('log', ['value' => 'Rule executed'])
    ->register();

error_log('Rule registered');
```

**Check execution statistics**:
```php
$result = MilliRules::execute_rules();
error_log('Execution stats: ' . print_r($result, true));
```

### Conditions Not Matching

Add debugging to your conditions:

```php
use MilliRules\Context;

Rules::register_condition('debug_condition', function(Context $context) {
    $array = $context->to_array();
    error_log('Context: ' . print_r($array, true));
    return true;
});

Rules::create('debug_rule')
    ->when()
        ->custom('debug_condition')
        ->your_actual_condition()
    ->then()->custom('action')
    ->register();
```

## Next Steps

- **[Built-in Conditions Reference](/docs/millirules/05-reference/01-conditions)** - Explore all available conditions
- **[Operators and Pattern Matching](/docs/millirules/02-core-concepts/04-operators)** - Master comparison operators
- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Create your own conditions
- **[WordPress Integration](/docs/millirules/04-advanced/03-wordpress-integration)** - WordPress-specific features

---

**Need more examples?** Check out [Real-World Examples](/docs/millirules/04-advanced/01-examples) for complete, working code samples.

---

Canonical: https://www.millipress.com/docs/millirules/02-core-concepts/04-operators

---
title: 'Operators and Pattern Matching'
description: 'Guide to all 13 MilliRules matching operators: equality, comparison, LIKE wildcards, REGEXP, and fixes when the value does not match the pattern you expect.'
menu_order: 40
---

# Operators and Pattern Matching

Operators are the backbone of condition evaluation in MilliRules. They determine how actual values are compared against expected values. This comprehensive guide covers all 13 operators with examples and best practices.

## Operator Overview

MilliRules supports 13 operators organized into five categories:

| Category       | Operators                    | Description                   |
|----------------|------------------------------|-------------------------------|
| **Equality**   | `=`, `!=`                    | Exact matching and inequality |
| **Comparison** | `>`, `>=`, `<`, `<=`         | Numeric comparisons           |
| **Pattern**    | `LIKE`, `NOT LIKE`, `REGEXP` | Wildcard and regex matching   |
| **Membership** | `IN`, `NOT IN`               | Array membership testing      |
| **Existence**  | `EXISTS`, `NOT EXISTS`       | Value existence checking      |
| **Boolean**    | `IS`, `IS NOT`               | Boolean value comparison      |

> [!NOTE]
> All operators are **case-insensitive**. `'LIKE'`, `'like'`, and `'Like'` are treated identically. MilliRules normalizes operators to uppercase internally.

## Auto-Detection

MilliRules intelligently detects the appropriate operator based on the value type:

```mermaid
flowchart TD
    Value["Value Type"] --> String{"String?"}
    Value --> Array{"Array?"}
    Value --> Bool{"Boolean?"}
    Value --> Null{"Null?"}

    String -->|"has * or ?"| LIKE["LIKE"]
    String -->|"starts with /"| REGEXP["REGEXP"]
    String -->|"plain"| EQUALS["="]

    Array --> IN["IN"]
    Bool --> IS["IS"]
    Null --> EXISTS["EXISTS"]
```

```php
// String value → '=' operator
->request_method('GET')

// Array value → 'IN' operator
->request_method(['GET', 'HEAD'])

// Boolean value → 'IS' operator
->constant('WP_DEBUG', true)

// Null value → 'EXISTS' operator
->cookie('session_id')

// String with wildcards (* or ?) → 'LIKE' operator
->request_url('/admin/*')

// String starting with '/' → 'REGEXP' operator
->request_url('/^\\/api\\//i')
```

> [!TIP]
> Auto-detection makes your code cleaner and more readable. Only specify operators explicitly when you need precise control.

### Auto-Inference at Runtime

Auto-detection applies not only in the builder API but also at **runtime** for `=` and `!=` operators. When the engine evaluates a condition with `=` or `!=`, it inspects the expected value and automatically upgrades the operator:

- Value contains `*` or `?` → treated as `LIKE` / `NOT LIKE`
- Value matches `/regex/` → treated as `REGEXP` / `NOT REGEXP`
- Plain string → exact match

This means rules stored with `operator: '='` and `value: 'session_*'` will correctly perform wildcard matching — no need to explicitly store `LIKE`.

**Escaping literal wildcards**: Use `\*` and `\?` when you need to match the actual `*` or `?` characters:

```php
// Matches the literal string "price_5*2"
->condition('price_5\*2')  // \* = literal asterisk, stays as '='
```

---

## Equality Operators

### = (Equals)

**Exact match comparison**. This is the default operator.

#### Syntax
```php
->condition($value)           // Auto-detected
->condition($value, '=')      // Explicit
```

#### Examples

**String comparison**:
```php
Rules::create('exact_match')
    ->when()
        ->request_method('POST')              // Exact match
        ->request_url('/api/users')           // Exact URL
        ->cookie('theme', 'dark')             // Exact value
    ->then()->custom('action')
    ->register();
```

**Numeric comparison**:
```php
Rules::create('exact_number')
    ->when()
        ->request_param('page', '1', '=')     // Page equals 1
        ->constant('PHP_VERSION', '8.0', '=') // Version equals 8.0
    ->then()->custom('action')
    ->register();
```

**Boolean comparison** (prefer `IS` operator):
```php
// Works but not recommended
->constant('WP_DEBUG', true, '=')

// Better - use IS operator
->constant('WP_DEBUG', true, 'IS')
```

> [!IMPORTANT]
> String comparisons are **case-sensitive**: `'POST'` does not equal `'post'`. Use exact casing or normalize values before comparison.

> [!NOTE]
> When the value contains wildcards (`*`, `?`) or is a regex pattern (`/pattern/`), the `=` operator automatically upgrades to `LIKE` or `REGEXP` at runtime. See [Auto-Inference at Runtime](#auto-inference-at-runtime). To match a literal `*` or `?`, escape it with a backslash: `\*`, `\?`.

---

### != (Not Equals)

**Inequality comparison**. Matches when values are not equal.

#### Syntax
```php
->condition($value, '!=')
```

#### Examples

**Exclude values**:
```php
Rules::create('not_post_method')
    ->when()
        ->request_method('POST', '!=')        // Not POST
        ->request_url('/wp-login.php', '!=')  // Not login page
    ->then()->custom('action')
    ->register();
```

**Exclude status**:
```php
Rules::create('not_debug_mode')
    ->when()
        ->constant('WP_DEBUG', true, '!=')    // Debug not enabled
    ->then()->custom('production_action')
    ->register();
```

> [!NOTE]
> Like `=`, the `!=` operator auto-upgrades to `NOT LIKE` for wildcards and `NOT REGEXP` for regex patterns. See [Auto-Inference at Runtime](#auto-inference-at-runtime).

---

## Comparison Operators

Comparison operators perform **numeric comparisons**. Non-numeric strings are cast to numbers (often 0).

### > (Greater Than)

Matches when actual value is greater than expected value.

#### Syntax
```php
->condition($value, '>')
```

#### Examples

```php
Rules::create('pagination')
    ->when()
        ->request_param('page', '1', '>')     // Page > 1
        ->request_param('limit', '10', '>')   // Limit > 10
    ->then()->custom('paginated_action')
    ->register();
```

---

### >= (Greater Than or Equal)

Matches when actual value is greater than or equal to expected value.

#### Syntax
```php
->condition($value, '>=')
```

#### Examples

```php
Rules::create('php_version_check')
    ->when()
        ->constant('PHP_VERSION', '7.4', '>=')  // PHP 7.4+
    ->then()->custom('use_modern_features')
    ->register();
```

---

### < (Less Than)

Matches when actual value is less than expected value.

#### Syntax
```php
->condition($value, '<')
```

#### Examples

```php
Rules::create('early_pagination')
    ->when()
        ->request_param('page', '5', '<')     // First 4 pages
    ->then()->custom('show_getting_started')
    ->register();
```

---

### <= (Less Than or Equal)

Matches when actual value is less than or equal to expected value.

#### Syntax
```php
->condition($value, '<=')
```

#### Examples

```php
Rules::create('legacy_php')
    ->when()
        ->constant('PHP_VERSION', '7.3', '<=')  // PHP 7.3 or older
    ->then()->custom('use_legacy_code')
    ->register();
```

> [!WARNING]
> Comparison operators cast values to numbers. String `'abc'` becomes `0` which may cause unexpected results. Ensure you're comparing numeric values.

---

## Pattern Matching Operators

Pattern matching operators allow flexible string matching using wildcards or regular expressions.

### LIKE (Wildcard Pattern)

**SQL-style wildcard matching** using `*` (matches any characters) and `?` (matches single character).

#### Syntax
```php
->condition($pattern, 'LIKE')   // Explicit
->condition($pattern)            // Auto-detected if pattern contains * or ?
```

#### Wildcards

| Wildcard | Description | Example | Matches | Doesn't Match |
|----------|-------------|---------|---------|---------------|
| `*` | Any characters (0 or more) | `/admin/*` | `/admin/`, `/admin/posts`, `/admin/a/b/c` | `/administrator/` |
| `?` | Single character | `/page-?` | `/page-1`, `/page-a` | `/page-10`, `/page-` |

#### Examples

**Prefix matching**:
```php
Rules::create('admin_urls')
    ->when()
        ->request_url('/wp-admin/*')  // Matches /wp-admin/anything
    ->then()->custom('admin_action')
    ->register();
```

**Suffix matching**:
```php
Rules::create('api_endpoints')
    ->when()
        ->request_url('*/api')        // Matches anything/api
    ->then()->custom('api_action')
    ->register();
```

**Contains matching**:
```php
Rules::create('search_pages')
    ->when()
        ->request_url('*search*')     // Contains "search" anywhere
    ->then()->custom('search_action')
    ->register();
```

**Single character wildcard**:
```php
Rules::create('version_urls')
    ->when()
        ->request_url('/v?/*')        // Matches /v1/, /v2/, /va/, etc.
    ->then()->custom('version_action')
    ->register();
```

**Header patterns**:
```php
Rules::create('bearer_tokens')
    ->when()
        ->request_header('Authorization', 'Bearer *', 'LIKE')
    ->then()->custom('validate_token')
    ->register();
```

**Complex patterns**:
```php
Rules::create('complex_pattern')
    ->when()
        ->request_url('/api/v?/users/*')  // /api/v1/users/123, /api/v2/users/abc
    ->then()->custom('api_action')
    ->register();
```

> [!TIP]
> LIKE patterns are case-sensitive. Use REGEXP with the `i` flag for case-insensitive matching.

---

### NOT LIKE (Inverse Wildcard)

**Inverse of LIKE**. Matches when pattern does NOT match.

#### Syntax
```php
->condition($pattern, 'NOT LIKE')
```

#### Examples

**Exclude admin areas**:
```php
Rules::create('non_admin')
    ->when()
        ->request_url('/wp-admin/*', 'NOT LIKE')
        ->request_url('/wp-login.php', '!=')
    ->then()->custom('public_action')
    ->register();
```

**Exclude API endpoints**:
```php
Rules::create('non_api')
    ->when()
        ->request_url('/api/*', 'NOT LIKE')
    ->then()->custom('web_action')
    ->register();
```

---

### REGEXP (Regular Expression)

**Full regular expression matching** using PHP's `preg_match()`.

#### Syntax
```php
->condition($regex_pattern, 'REGEXP')  // Explicit
->condition('/pattern/')                // Auto-detected (starts with /)
```

#### Pattern Format

Regex patterns must be valid PHP regex with delimiters:

```php
'/pattern/'           // Basic pattern
'/pattern/i'          // Case-insensitive
'/pattern/u'          // UTF-8
'/^\\/api\\//'        // Must escape forward slashes
```

> [!IMPORTANT]
> Always use delimiters (`/pattern/`) and escape forward slashes in the pattern itself (`\\/`).

#### Examples

**API version matching**:
```php
Rules::create('api_versions')
    ->when()
        // Matches /api/v1/, /api/v2/, /api/v123/
        ->request_url('/^\\/api\\/v[0-9]+\\//i', 'REGEXP')
    ->then()->custom('api_action')
    ->register();
```

**Email validation**:
```php
Rules::create('email_param')
    ->when()
        ->request_param('email', '/^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$/i', 'REGEXP')
    ->then()->custom('process_email')
    ->register();
```

**Complex URL patterns**:
```php
Rules::create('product_urls')
    ->when()
        // Matches /product/abc-123, /product/xyz-456
        ->request_url('/^\\/product\\/[a-z]+-[0-9]+$/i', 'REGEXP')
    ->then()->custom('show_product')
    ->register();
```

**Date patterns**:
```php
Rules::create('date_urls')
    ->when()
        // Matches /2024/01/15, /2023/12/31
        ->request_url('/^\\/[0-9]{4}\\/[0-9]{2}\\/[0-9]{2}$/', 'REGEXP')
    ->then()->custom('date_archive')
    ->register();
```

**Case-insensitive matching**:
```php
Rules::create('case_insensitive')
    ->when()
        ->request_url('/\\/admin/i', 'REGEXP')  // Matches /admin, /ADMIN, /Admin
    ->then()->custom('action')
    ->register();
```

#### Common Regex Patterns

| Pattern | Regex | Example |
|---------|-------|---------|
| Numeric ID | `/^\\/post\\/[0-9]+$/` | `/post/123` |
| Alphanumeric slug | `/^\\/page\\/[a-z0-9-]+$/i` | `/page/my-slug` |
| UUID | `/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i` | `550e8400-e29b-41d4-a716-446655440000` |
| API versioning | `/^\\/api\\/v[0-9]+\\//` | `/api/v2/` |
| Date (YYYY-MM-DD) | `/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/` | `2024-01-15` |
| Email | `/^[a-z0-9._%+-]+@[a-z0-9.-]+\\.[a-z]{2,}$/i` | `user@example.com` |

> [!WARNING]
> Complex regex can impact performance. Use LIKE patterns when possible for better performance.

---

## Membership Operators

Membership operators check if a value exists in an array of possibilities.

### IN (In Array)

Matches when actual value is **in** the array of expected values.

#### Syntax
```php
->condition([$val1, $val2, ...], 'IN')  // Explicit
->condition([$val1, $val2, ...])         // Auto-detected
```

#### Examples

**Multiple HTTP methods**:
```php
Rules::create('safe_methods')
    ->when()
        ->request_method(['GET', 'HEAD', 'OPTIONS'], 'IN')
    ->then()->custom('cacheable_action')
    ->register();

// Auto-detected IN operator
Rules::create('safe_methods_auto')
    ->when()
        ->request_method(['GET', 'HEAD', 'OPTIONS'])
    ->then()->custom('cacheable_action')
    ->register();
```

**Multiple URLs**:
```php
Rules::create('protected_pages')
    ->when()
        ->request_url([
            '/dashboard',
            '/profile',
            '/settings'
        ], 'IN')
    ->then()->custom('require_auth')
    ->register();
```

**Environment types**:
```php
Rules::create('non_production')
    ->when()
        ->constant('WP_ENVIRONMENT_TYPE', ['local', 'development', 'staging'], 'IN')
    ->then()->custom('enable_debug')
    ->register();
```

**Post types**:
```php
Rules::create('content_types')
    ->when()
        ->post_type(['post', 'page', 'article'], 'IN')
    ->then()->custom('show_reading_time')
    ->register();
```

---

### NOT IN (Not In Array)

Matches when actual value is **not in** the array of expected values.

#### Syntax
```php
->condition([$val1, $val2, ...], 'NOT IN')
```

#### Examples

**Exclude methods**:
```php
Rules::create('non_modifying')
    ->when()
        ->request_method(['POST', 'PUT', 'DELETE', 'PATCH'], 'NOT IN')
    ->then()->custom('read_only_action')
    ->register();
```

**Exclude URLs**:
```php
Rules::create('non_admin_urls')
    ->when()
        ->request_url([
            '/wp-admin/*',
            '/wp-login.php'
        ], 'NOT IN')
    ->then()->custom('public_action')
    ->register();
```

---

## Existence Operators

Existence operators check whether a value exists, regardless of its actual value.

### EXISTS

Matches when a value **exists and is not empty**.

#### Syntax
```php
->condition(null, 'EXISTS')   // Explicit
->condition()                 // Auto-detected (no value parameter)
```

#### Empty Values

These values are considered "non-existent":
- `null`
- `''` (empty string)
- `[]` (empty array)
- `'0'` is **NOT** empty (it exists)

#### Examples

**Cookie existence**:
```php
Rules::create('has_session')
    ->when()
        ->cookie('session_id', null, 'EXISTS')  // Cookie exists
    ->then()->custom('load_session')
    ->register();

// Shorthand
Rules::create('has_session_short')
    ->when()
        ->cookie('session_id')  // EXISTS auto-detected
    ->then()->custom('load_session')
    ->register();
```

**Parameter existence**:
```php
Rules::create('has_action_param')
    ->when()
        ->request_param('action')  // Parameter exists with any value
    ->then()->custom('route_action')
    ->register();
```

**Header existence**:
```php
Rules::create('has_auth_header')
    ->when()
        ->request_header('Authorization')  // Header exists
    ->then()->custom('validate_auth')
    ->register();
```

---

### NOT EXISTS

Matches when a value **does not exist or is empty**.

#### Syntax
```php
->condition(null, 'NOT EXISTS')
```

#### Examples

**New visitor detection**:
```php
Rules::create('first_visit')
    ->when()
        ->cookie('visited_before', null, 'NOT EXISTS')
    ->then()->custom('show_welcome')
    ->register();
```

**Missing parameters**:
```php
Rules::create('no_page_param')
    ->when()
        ->request_param('page', null, 'NOT EXISTS')
    ->then()->custom('show_first_page')
    ->register();
```

**Opt-out detection**:
```php
Rules::create('analytics_allowed')
    ->when()
        ->cookie('analytics_opt_out', null, 'NOT EXISTS')
    ->then()->custom('track_analytics')
    ->register();
```

---

## Boolean Operators

Boolean operators provide strict boolean value comparison.

### IS (Is True/False)

Matches when value **strictly equals** true or false.

#### Syntax
```php
->condition(true, 'IS')    // Explicit
->condition(true)           // Auto-detected
```

#### Examples

**Debug mode**:
```php
Rules::create('debug_enabled')
    ->when()
        ->constant('WP_DEBUG', true, 'IS')
    ->then()->custom('show_debug_bar')
    ->register();

// Auto-detected
Rules::create('debug_enabled_auto')
    ->when()
        ->constant('WP_DEBUG', true)  // IS auto-detected
    ->then()->custom('show_debug_bar')
    ->register();
```

**Feature flags**:
```php
Rules::create('feature_enabled')
    ->when()
        ->constant('FEATURE_ENABLED', true)
    ->then()->custom('use_new_feature')
    ->register();
```

**Boolean states**:
```php
Rules::create('user_logged_in')
    ->when()
        ->is_user_logged_in()  // Returns boolean, uses IS
    ->then()->custom('show_dashboard')
    ->register();
```

---

### IS NOT (Is Not True/False)

Matches when value **does not strictly equal** true or false.

#### Syntax
```php
->condition(true, 'IS NOT')
->condition(false, 'IS NOT')
```

#### Examples

**Debug disabled**:
```php
Rules::create('production_mode')
    ->when()
        ->constant('WP_DEBUG', true, 'IS NOT')  // Not true (false or undefined)
    ->then()->custom('production_features')
    ->register();
```

**Feature disabled**:
```php
Rules::create('legacy_mode')
    ->when()
        ->constant('NEW_FEATURE', true, 'IS NOT')
    ->then()->custom('use_legacy_code')
    ->register();
```

> [!NOTE]
> `IS` and `IS NOT` perform **strict boolean comparison**. Use `=` and `!=` for truthy/falsy comparisons.

---

## Operator Precedence

When using multiple conditions with different operators, all conditions are evaluated independently. There's no operator precedence since conditions are combined using match types (all/any/none).

```php
Rules::create('multiple_operators')
    ->when()  // match_all by default
        ->request_url('/api/*', 'LIKE')           // Pattern
        ->request_method(['GET', 'HEAD'], 'IN')   // Membership
        ->cookie('session_id', null, 'EXISTS')    // Existence
    ->then()->custom('action')
    ->register();

// Evaluates as: (URL LIKE /api/*) AND (method IN [GET, HEAD]) AND (cookie EXISTS)
```

---

## Operator Auto-Detection Rules

MilliRules uses these rules for operator auto-detection:

| Value Type | Pattern | Auto-Detected Operator | Example |
|------------|---------|----------------------|---------|
| Array | Any array | `IN` | `['GET', 'POST']` → `IN` |
| Boolean | `true` or `false` | `IS` | `true` → `IS` |
| Null | `null` | `EXISTS` | `null` → `EXISTS` |
| String with wildcard | Contains `*` or `?` | `LIKE` | `'/admin/*'` → `LIKE` |
| String (regex) | Starts with `/` | `REGEXP` | `'/^abc/'` → `REGEXP` |
| Other | Any other value | `=` | `'POST'` → `=` |

```php
// Auto-detection examples
->request_method('GET')                    // = (string, no wildcard)
->request_method(['GET', 'HEAD'])          // IN (array)
->constant('WP_DEBUG', true)               // IS (boolean)
->cookie('session_id')                     // EXISTS (no value parameter)
->request_url('/admin/*')                  // LIKE (has wildcard)
->request_url('/^\\/api\\//i')             // REGEXP (starts with /)
```

---

## Best Practices

### 1. Let Auto-Detection Work

```php
// ✅ Good - clean and readable
->request_url('/api/*')
->request_method(['GET', 'HEAD'])
->constant('WP_DEBUG', true)

// ❌ Unnecessary - auto-detection works fine
->request_url('/api/*', 'LIKE')
->request_method(['GET', 'HEAD'], 'IN')
->constant('WP_DEBUG', true, 'IS')
```

### 2. Use LIKE for Simple Patterns

```php
// ✅ Good - simple and fast
->request_url('/api/*')
->request_url('/product-*')

// ❌ Overkill - regex is slower
->request_url('/^\\/api\\//i', 'REGEXP')
->request_url('/^\\/product-/i', 'REGEXP')
```

### 3. Validate Regex Patterns

```php
// ✅ Good - valid regex with delimiters
->request_url('/^\\/api\\/v[0-9]+\\//i', 'REGEXP')

// ❌ Wrong - missing delimiters
->request_url('^/api/v[0-9]+/', 'REGEXP')

// ❌ Wrong - forward slashes not escaped
->request_url('/^/api/v[0-9]+/', 'REGEXP')
```

### 4. Use Appropriate Operators

```php
// ✅ Good - right operator for the job
->request_method(['GET', 'HEAD'], 'IN')      // Multiple values
->request_url('/admin/*', 'LIKE')            // Pattern match
->constant('WP_DEBUG', true, 'IS')           // Boolean
->cookie('session_id', null, 'EXISTS')       // Existence

// ❌ Wrong - inefficient or incorrect
->request_method('GET', '=')                 // Use IN for multiple
->request_method('POST', '=')
->request_url('/admin/edit.php', 'LIKE')     // Use = for exact match
```

### 5. Consider Performance

**Operator performance** (fastest to slowest):
1. `=`, `!=` (equality)
2. `IS`, `IS NOT` (boolean)
3. `EXISTS`, `NOT EXISTS` (existence)
4. `>`, `>=`, `<`, `<=` (numeric)
5. `IN`, `NOT IN` (membership)
6. `LIKE`, `NOT LIKE` (wildcard)
7. `REGEXP` (regex - slowest)

```php
// ✅ Good - fast checks first
->when()
    ->request_method('POST')              // Fast equality
    ->request_url('/api/users')           // Fast equality
    ->request_header('Authorization')     // Fast existence
    ->custom('complex_validation')        // Slow custom check last

// ❌ Bad - slow check first
->when()
    ->request_url('/complex-.*-pattern/i', 'REGEXP')  // Slow regex first!
    ->request_method('POST')
```

---

## Common Pitfalls

### 1. Case Sensitivity

```php
// ❌ Wrong - case mismatch
->request_method('post')  // Won't match 'POST'

// ✅ Correct - exact case
->request_method('POST')

// ✅ Alternative - case-insensitive regex
->request_method('/^post$/i', 'REGEXP')
```

### 2. Wildcard Escaping

```php
// ❌ Wrong - literal asterisk not treated as wildcard
->request_url('\\*')

// ✅ Correct - asterisk is wildcard
->request_url('*')

// ✅ If you need literal asterisk, use regex
->request_url('/\\*/','REGEXP')
```

### 3. Regex Delimiters

```php
// ❌ Wrong - no delimiters
->request_url('^/api/', 'REGEXP')

// ✅ Correct - with delimiters
->request_url('/^\\/api\\//i', 'REGEXP')
```

### 4. Empty Arrays

```php
// ❌ Wrong - empty array always fails
->request_method([], 'IN')

// ✅ Correct - non-empty array
->request_method(['GET', 'POST'], 'IN')
```

---

## Next Steps

- **[Dynamic Placeholders](/docs/millirules/02-core-concepts/05-placeholders)** - Use dynamic values in conditions
- **[Built-in Conditions](/docs/millirules/05-reference/01-conditions)** - See operators in action
- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Implement custom operators
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - Complete working examples

---

**Ready for advanced features?** Continue to [Dynamic Placeholders](/docs/millirules/02-core-concepts/05-placeholders) to learn about using dynamic values in your rules.

---

Canonical: https://www.millipress.com/docs/millirules/02-core-concepts/05-placeholders

---
title: 'Dynamic Placeholders'
description: 'Inject runtime values into MilliRules actions with placeholders like {request.uri} and {user.login}, plus custom resolvers for your own PHP context data.'
menu_order: 50
---

# Dynamic Placeholders

Placeholders allow you to inject dynamic runtime values into your rules. Instead of hardcoding values, you can reference contextual data using a simple dot-notation syntax that gets resolved during rule execution.

## What Are Placeholders?

Placeholders are special tokens enclosed in curly braces that get replaced with actual values from the execution context:

```php
// Static value
'value' => 'Fixed string'

// Dynamic placeholder
'value' => '{request.uri}'         // Current URL
'value' => '{request.method}'      // HTTP method
'value' => '{user.login}'          // Current user's login
'value' => '{cookie.session_id}'   // Session cookie value
```

## Placeholder Syntax

The placeholder syntax uses dot-separated parts to navigate the context hierarchy:

```
{category.subcategory.key}
```

- `category` - Top-level context category (request, user, post, cookie, etc.)
- `subcategory` - Nested category (optional, can have multiple levels)
- `key` - Specific value to retrieve

### Examples

```php
'{request.uri}'              // $context['request']['uri']
'{request.method}'           // $context['request']['method']
'{request.headers.host}'     // $context['request']['headers']['host']
'{user.id}'                  // $context['user']['id']
'{post.title}'               // $context['post']['title']
'{cookie.session_id}'        // $context['cookie']['session_id']
```

## Built-in Placeholder Categories

### Request Placeholders

Access HTTP request data from the PHP package context.

#### Available Request Placeholders

| Placeholder            | Description       | Example Value                  |
|------------------------|-------------------|--------------------------------|
| `{request.method}`     | HTTP method       | `GET`, `POST`                  |
| `{request.uri}`        | Full request URI  | `/wp-admin/edit.php`           |
| `{request.scheme}`     | URL scheme        | `https`                        |
| `{request.host}`       | Host name         | `example.com`                  |
| `{request.path}`       | URL path          | `/wp-admin/edit.php`           |
| `{request.query}`      | Query string      | `post_type=page`               |
| `{request.referer}`    | HTTP referer      | `https://example.com/previous` |
| `{request.user_agent}` | User agent string | `Mozilla/5.0...`               |
| `{request.ip}`         | Client IP address | `192.168.1.1`                  |

#### Request Headers

```php
'{request.headers.content-type}'    // Content-Type header
'{request.headers.authorization}'   // Authorization header
'{request.headers.accept}'          // Accept header
'{request.headers.user-agent}'      // User-Agent header
```

> [!NOTE]
> Header names in placeholders are case-insensitive: `{request.headers.Content-Type}` and `{request.headers.content-type}` are equivalent.

#### Examples

```php
Rules::register_action('log_request', function($args, Context $context) {
    $message = $args['value'] ?? '';
    error_log($message);
});

Rules::create('log_requests')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('log_request', [
            'value' => 'API request: {request.method} {request.uri} from {request.ip}'
        ])
    ->register();

// Logs: "API request: GET /api/users from 192.168.1.1"
```

---

### Cookie Placeholders

Access cookie values.

```php
'{cookie.session_id}'        // $_COOKIE['session_id']
'{cookie.user_preference}'   // $_COOKIE['user_preference']
'{cookie.theme}'             // $_COOKIE['theme']
```

#### Examples

```php
Rules::register_action('personalize', function($args, Context $context) {
    $theme = $args['theme'] ?? 'default';
    apply_theme($theme);
});

Rules::create('apply_user_theme')
    ->when()->cookie('theme')
    ->then()
        ->custom('personalize', [
            'theme' => '{cookie.theme}'  // Uses cookie value
        ])
    ->register();
```

---

### Parameter Placeholders

Access query and form parameters.

```php
'{param.action}'         // $_GET['action'] or $_POST['action']
'{param.id}'            // $_GET['id'] or $_POST['id']
'{param.page}'          // $_GET['page'] or $_POST['page']
```

#### Examples

```php
Rules::register_action('process_action', function($args, Context $context) {
    $action = $args['action'] ?? '';
    $id = $args['id'] ?? 0;
    error_log("Processing action: {$action} for ID: {$id}");
});

Rules::create('process_request')
    ->when()
        ->request_param('action')
        ->request_param('id')
    ->then()
        ->custom('process_action', [
            'action' => '{param.action}',
            'id' => '{param.id}'
        ])
    ->register();
```

---

### Header Placeholders

Access request headers. Header names are matched case-insensitively.

```php
'{header.accept}'          // Accept header
'{header.user-agent}'      // User-Agent header
'{header.x-forwarded-for}' // X-Forwarded-For header
```

---

### WordPress Placeholders

Access WordPress-specific data (available only when WordPress package is loaded).

#### User Placeholders

| Placeholder         | Description        | Example Value     |
|---------------------|--------------------|-------------------|
| `{user.id}`         | User ID            | `123`             |
| `{user.login}`      | User login name    | `john_doe`        |
| `{user.email}`      | User email         | `john@example.com`|
| `{user.roles}`      | User roles (array) | `administrator`   |
| `{user.logged_in}`  | Whether logged in  | `1`, `` (empty)   |

#### Post Placeholders

| Placeholder     | Description  | Example Value      |
|-----------------|--------------|--------------------|
| `{post.id}`     | Post ID      | `456`              |
| `{post.title}`  | Post title   | `My Blog Post`     |
| `{post.type}`   | Post type    | `post`, `page`     |
| `{post.status}` | Post status  | `publish`, `draft` |
| `{post.author}` | Author ID    | `123`              |
| `{post.parent}` | Parent ID    | `0`, `12`          |
| `{post.name}`   | Post slug    | `my-blog-post`     |

#### Term Placeholders

| Placeholder       | Description   | Example Value |
|-------------------|---------------|---------------|
| `{term.id}`       | Term ID       | `7`           |
| `{term.slug}`     | Term slug     | `news`        |
| `{term.name}`     | Term name     | `News`        |
| `{term.taxonomy}` | Taxonomy name | `category`    |

#### Query Variable Placeholders

Access WordPress query variables from `$wp_query->query_vars`:

| Placeholder         | Description           | Example Value      |
|---------------------|-----------------------|--------------------|
| `{query.post_type}` | Current post type     | `'post'`, `'page'` |
| `{query.paged}`     | Current page number   | `1`, `2`, `3`      |
| `{query.s}`         | Search query          | `'search term'`    |
| `{query.m}`         | Month/year archive    | `'202312'`         |
| `{query.cat}`       | Category ID           | `'5'`              |
| `{query.tag}`       | Tag slug              | `'news'`           |
| `{query.author}`    | Author ID or name     | `'1'`              |

#### Examples

```php
use MilliRules\Context;

Rules::register_action('log_user_action', function($args, Context $context) {
    error_log($args['message'] ?? '');
});

Rules::create('log_search')
    ->when()
        ->request_url('/search')
        ->is_search()
    ->then()
        ->custom('log_user_action', [
            'message' => 'User {user.login} searched for "{query.s}" on {request.uri}'
        ])
    ->register();

// Logs: "User john_doe searched for "wordpress plugins" on /somewhere"
```

---

## Using Placeholders in Actions

Placeholders are primarily used in action configurations to inject dynamic values.

### Basic Usage

```php
Rules::register_action('send_notification', function($args, Context $context) {
    $to = $args['to'] ?? '';
    $subject = $args['subject'] ?? '';
    $message = $args['message'] ?? '';

    // Placeholders already resolved by BaseAction
    wp_mail($to, $subject, $message);
});

Rules::create('notify_on_login')
    ->when()
        ->is_user_logged_in()
        ->request_url('/wp-admin/*')
    ->then()
        ->custom('send_notification', [
            'to' => 'admin@example.com',
            'subject' => 'User Login Alert',
            'message' => 'User {user.login} logged in from {request.ip}'
        ])
    ->register();
```

### Multiple Placeholders

```php
Rules::register_action('log_detailed', function($args, Context $context) {
    error_log($args['message'] ?? '');
});

Rules::create('detailed_logging')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('log_detailed', [
            'message' => '{request.method} request to {request.uri} from {request.ip} '
                       . 'at {request.timestamp} by user {user.login}'
        ])
    ->register();
```

### Nested Placeholders

```php
Rules::register_action('set_header', function($args, Context $context) {
    $name = $args['name'] ?? '';
    $value = $args['value'] ?? '';

    if (!headers_sent()) {
        header("{$name}: {$value}");
    }
});

Rules::create('custom_header')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('set_header', [
            'name' => 'X-Request-ID',
            'value' => '{request.headers.x-request-id}'  // Forward header value
        ])
    ->register();
```

---

## Implementing Placeholder Resolution

### In BaseAction Subclasses

Actions extending `BaseAction` automatically get placeholder resolution:

```php
namespace MyPlugin\Actions;

use MilliRules\Actions\BaseAction;

class CustomNotificationAction extends BaseAction {
    public function execute(array $context): void {
        // Resolve placeholders in config values
        $message = $this->resolve_value($this->config['message'] ?? '');
        $recipient = $this->resolve_value($this->config['to'] ?? '');

        // Use resolved values
        wp_mail($recipient, 'Notification', $message);
    }

    public function get_type(): string {
        return 'custom_notification';
    }
}
```

### In Callback Actions

Callback actions need to manually resolve placeholders:

```php
use MilliRules\PlaceholderResolver;

Rules::register_action('manual_resolution', function($args, Context $context) {
    $resolver = new PlaceholderResolver($context);

    // Resolve individual value
    $message = $resolver->resolve($args['message'] ?? '');

    // Use resolved value
    error_log($message);
});

Rules::create('use_manual_resolution')
    ->when()->request_url('/test')
    ->then()
        ->custom('manual_resolution', [
            'message' => 'Testing from {request.ip}'
        ])
    ->register();
```

---

## Creating Custom Placeholder Resolvers

Register custom placeholder categories for your own data sources.

### Registering Custom Resolvers

```php
use MilliRules\Rules;

// Register custom placeholder category
Rules::register_placeholder('custom', function($context, $parts) {
    // $parts[0] is the first key after 'custom:'
    // $parts[1] is the second key, etc.

    switch ($parts[0] ?? '') {
        case 'site_name':
            return get_bloginfo('name');

        case 'site_url':
            return home_url();

        case 'current_time':
            return date('Y-m-d H:i:s');

        case 'option':
            return get_option($parts[1] ?? '');

        default:
            return '';
    }
});
```

### Using Custom Placeholders

```php
Rules::register_action('log_custom', function($args, Context $context) {
    error_log($args['message'] ?? '');
});

Rules::create('use_custom_placeholders')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('log_custom', [
            'message' => 'Request to {custom.site_name} at {custom.current_time}'
        ])
    ->register();

// Access WordPress options
Rules::create('use_option_placeholder')
    ->when()->request_url('/test')
    ->then()
        ->custom('log_custom', [
            'message' => 'Site tagline: {custom.option.blogdescription}'
        ])
    ->register();
```

### Complex Custom Resolvers

```php
Rules::register_placeholder('env', function($context, $parts) {
    $key = $parts[0] ?? '';

    // Environment variables
    if ($key === 'var') {
        return getenv($parts[1] ?? '');
    }

    // Server information
    if ($key === 'server') {
        return $_SERVER[strtoupper($parts[1] ?? '')] ?? '';
    }

    // Custom environment data
    $env_data = [
        'name' => WP_ENVIRONMENT_TYPE ?? 'production',
        'debug' => WP_DEBUG ?? false,
        'version' => get_bloginfo('version'),
    ];

    return $env_data[$key] ?? '';
});

// Usage:
// {env.name}           → 'production'
// {env.debug}          → true/false
// {env.var.API_KEY}    → getenv('API_KEY')
// {env.server.http_host} → $_SERVER['HTTP_HOST']
```

---

## Advanced Placeholder Patterns

### Conditional Placeholders

Use placeholders with fallback values:

```php
Rules::register_action('log_with_fallback', function($args, Context $context) {
    $resolver = new PlaceholderResolver($context);

    // Resolve with fallback
    $user = $resolver->resolve($args['user'] ?? '') ?: 'guest';
    $message = "User: {$user}";

    error_log($message);
});

Rules::create('log_with_defaults')
    ->when()->request_url('*')
    ->then()
        ->custom('log_with_fallback', [
            'user' => '{user.login}'  // Falls back to 'guest' if empty
        ])
    ->register();
```

### Placeholder Transformation

Transform placeholder values:

```php
Rules::register_action('transform_placeholder', function($args, Context $context) {
    $resolver = new PlaceholderResolver($context);
    $value = $resolver->resolve($args['value'] ?? '');

    // Transform resolved value
    $transformed = strtoupper($value);
    $transformed = sanitize_text_field($transformed);

    error_log($transformed);
});
```

### Array Placeholders

Access array values:

```php
// Access first role
'{user.roles.0}'        // First role

// Access header values
'{request.headers.accept}' // Accept header
```

### Object Property Access

Access public properties and magic properties on objects using dot notation:

```php
// Access public object properties
'{hook.args.0.ID}'           // WP_Post object's ID property
'{hook.args.0.post_title}'   // WP_Post object's post_title property
'{hook.args.0.post_author}'  // WP_Post object's post_author property

// Access magic properties (via __get() method)
'{hook.args.2.permalink}'    // WP_Post object's permalink (magic property)

// Mixed array and object access
'{hook.args.2.ID}'          // Third argument (index 2) → object's ID property
```

#### WordPress Hook Examples

WordPress hooks often pass objects as arguments. You can now access their properties directly:

```php
use MilliRules\Context;

// Example: transition_post_status hook passes (new_status, old_status, $post)
Rules::register_action('clear_post_cache', function($args, Context $context) {
    $url = $args['url'] ?? '';
    // Clear cache for the URL
    wp_cache_delete($url);
});

Rules::create('clear_on_publish')
    ->when()
        ->hook_is('transition_post_status')
        ->hook_arg(0, '==', 'publish')  // New status is 'publish'
    ->then()
        ->custom('clear_post_cache', [
            'url' => '{hook.args.2.permalink}'  // Access WP_Post's permalink property
        ])
    ->register();
```

#### Nested Objects and Arrays

Combine array and object access for complex data structures:

```php
// WordPress comment object in an array
'{comments.0.comment_author}'       // First comment's author
'{comments.0.comment_content}'      // First comment's content

// API response with nested objects
'{api.response.data.items.0.id}'    // First item's ID from API response

// Custom data structures
'{data.user.profile.settings}'      // Access nested object properties
```

#### How It Works

When resolving placeholders, MilliRules automatically detects whether each segment is:
- **Array access**: Uses `isset()` and `$array[$key]`
- **Object property access**: Checks `property_exists()` for public properties, or `__get()` for magic properties

This allows seamless access to mixed array/object structures without special syntax.

---

## Placeholder Resolution Flow

Understanding how placeholders are resolved:

```
1. Action configuration contains placeholder: "{request.uri}"
   ↓
2. BaseAction::resolve_value() detects placeholder
   ↓
3. PlaceholderResolver splits by dots: ['request', 'uri']
   ↓
4. Looks up category 'request' in registered resolvers
   ↓
5. PHP package resolver handles 'request' category
   ↓
6. Returns $context['request']['uri']
   ↓
7. Placeholder replaced with actual value: "/api/users"
   ↓
8. Action executes with resolved value
```

---

## Best Practices

### 1. Use Descriptive Placeholder Names

```php
// ✅ Good - clear what data is being used
'message' => 'User {user.login} accessed {request.uri}'

// ❌ Bad - unclear placeholders
'message' => 'User {u} accessed {r}'
```

### 2. Provide Fallback Values

```php
Rules::register_action('safe_action', function($args, Context $context) {
    $resolver = new PlaceholderResolver($context);

    // Resolve with fallback
    $user = $resolver->resolve($args['user'] ?? '') ?: 'Unknown User';
    $ip = $resolver->resolve($args['ip'] ?? '') ?: '0.0.0.0';

    error_log("User: {$user}, IP: {$ip}");
});
```

### 3. Validate Resolved Values

```php
Rules::register_action('validated_action', function($args, Context $context) {
    $resolver = new PlaceholderResolver($context);
    $email = $resolver->resolve($args['email'] ?? '');

    // Validate resolved value
    if (!is_email($email)) {
        error_log('Invalid email from placeholder');
        return;
    }

    // Use validated value
    wp_mail($email, 'Subject', 'Message');
});
```

### 4. Document Custom Placeholders

```php
/**
 * Custom Placeholder: {payment.gateway}
 * Returns the active payment gateway name
 *
 * Custom Placeholder: {payment.status.order_id}
 * Returns the payment status for a given order ID
 *
 * Example: {payment.status.123} → 'completed'
 */
Rules::register_placeholder('payment', function($context, $parts) {
    // Implementation...
});
```

---

## Common Pitfalls

### 1. Missing Context Data

```php
// ❌ Wrong - WordPress placeholders in PHP-only context
Rules::create('php_rule', 'php')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('action', [
            'value' => '{user.login}'  // Empty! WordPress not available
        ])
    ->register();

// ✅ Correct - check context availability
Rules::register_action('safe_wp_action', function($args, Context $context) {
    if (!isset($context['wp'])) {
        error_log('WordPress context not available');
        return;
    }

    $resolver = new PlaceholderResolver($context);
    $user = $resolver->resolve('{user.login}');
    // ...
});
```

### 2. Incorrect Placeholder Syntax

```php
// ❌ Wrong - missing braces
'value' => 'request.uri'

// ❌ Wrong - incorrect separator (colons are not supported)
'value' => '{request:uri}'

// ✅ Correct - proper syntax (dot-notation)
'value' => '{request.uri}'
```

### 3. Case Sensitivity

```php
// Context keys are case-sensitive
// ✅ Correct
'{request.uri}'

// ❌ Wrong
'{Request:URI}'
'{REQUEST:URI}'
```

---

## Discovering Available Placeholders

The set of placeholders depends on which packages are loaded and what plugins have registered, so it is not a fixed list. Ask the engine:

```php
$placeholders = Rules::get_all_placeholder_metas();

// [
//   'request' => [
//       'label'       => 'Request',
//       'description' => 'The current HTTP request, for example {request.host} ...',
//       'keys'        => ['method', 'uri', ...],
//       'source'      => 'context',
//   ],
//   ...
// ]
```

An empty `keys` array means the key is chosen by you — a cookie name, a query parameter, a query var. A non-empty one is closed, so anything outside it will not resolve.

This matters because an unresolvable placeholder is **left in the value verbatim** (see [Missing Context Data](#1-missing-context-data) above) — a typo like `{reqest.host}` fails silently, turning a per-visitor value into a constant string. If you build rules from user input, validate against this catalog first.

See the [API reference](/docs/millirules/05-reference/03-api#get_all_placeholder_metas-array) for the full contract.

---

## Next Steps

- **[Understanding the Package System](/docs/millirules/02-core-concepts/02-packages)** - Learn about package architecture
- **[Creating Custom Actions](/docs/millirules/03-customization/02-custom-actions)** - Implement actions with placeholders
- **[Advanced Patterns](/docs/millirules/04-advanced/02-advanced-patterns)** - Advanced placeholder techniques
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - See placeholders in action

---

**Ready to extend MilliRules?** Continue to [Creating Custom Packages](/docs/millirules/03-customization/03-custom-packages) to learn how to add your own context data and placeholders.

---

Canonical: https://www.millipress.com/docs/millirules/03-customization/01-custom-conditions

---
title: 'Creating Custom Conditions'
description: 'Four ways to create custom conditions in MilliRules: inline callbacks, registered PHP closures, and BaseCondition classes with operator and metadata support.'
menu_order: 10
---

# 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

```php

use MilliRules\Rules;
use MilliRules\Context;

// 1. Register reusable condition
Rules::register_condition('is_weekend', function ($args, Context $context) {
    // Reusable logic receiving args + context
    $day = date('N'); // 1 (Monday) to 7 (Sunday)
    return $day >= 6; // Saturday or Sunday
});

// 2. Create Rule
Rules::create('weekend_special')
    ->when()
        // Option A: Inline custom condition without registration
        ->custom('is_business_hours', function (Context $context) {
            $hour = (int) date('H');
            return $hour >= 9 && $hour <= 17;
        })

        // Option B: Call registered condition via magic method
        ->is_weekend()

        // Option C: Call registered condition via custom()
        ->custom('is_weekend')
    ->then()
        ->custom('apply_discount')
    ->register();
```

## Registering Conditions

### Choosing the Right Registration Method

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)

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

Define the condition directly in the rule using a callback:

```php
use MilliRules\Rules;
use MilliRules\Context;

Rules::create('business_hours_only')
    ->when()
        ->custom('is_business_hours', function(Context $context) {
            // One-off condition logic right here
            $hour = (int) date('H');
            return $hour >= 9 && $hour <= 17;
        })
    ->then()
        ->custom('process_request')
    ->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:**

```php
Rules::create('premium_user_check')
    ->when()
        ->custom('is_premium', function(Context $context) {
            $user = $context->get('user.id');
            $status = get_user_meta($user, 'account_status', true);

            return $status === 'premium';
        })
    ->then()
        ->custom('enable_features')
    ->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)

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

Register once, use everywhere:

```php
use MilliRules\Rules;
use MilliRules\Context;

// Register once at plugin initialization
Rules::register_condition('is_weekend', function($args, Context $context) {
    $day = date('N'); // 1 (Monday) to 7 (Sunday)
    return $day >= 6; // Saturday or Sunday
});

// With configuration
Rules::register_condition('time_in_range', function($args, Context $context) {
    $current_hour = (int) date('H');
    $start = $args[0] ?? 0;
    $end = $args[1] ?? 23;

    return $current_hour >= $start && $current_hour <= $end;
});

// Access context data
Rules::register_condition('user_has_role', function($args, Context $context) {
    $context->load('user');
    $required_role = $args['value'] ?? $args[0] ?? '';
    $user_roles = $context->get('user.roles', []);

    return in_array($required_role, $user_roles, true);
});
```

**Then use in any rule:**

```php
Rules::create('weekend_special')
    ->when()
        ->is_weekend()
        ->time_in_range(9, 17)
        ->user_has_role('customer')
    ->then()
        ->custom('apply_discount')
    ->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)

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

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

```php
use MilliRules\Rules;

// One-time registration at plugin initialization
Rules::register_namespace('Conditions', 'MyPlugin\Conditions');
```

**Create your condition class:**

```php
namespace MyPlugin\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class UserPurchaseCount extends BaseCondition
{
    protected function get_actual_value(Context $context): int
    {
        $context->load('user');
        $user_id = $context->get('user.id', 0);

        if (!$user_id) {
            return 0;
        }

        // Get purchase count from database
        return (int) get_user_meta($user_id, 'purchase_count', true);
    }

    protected function get_expected_value(): int
    {
        return (int) ($this->config['value'] ?? 0);
    }

    public function get_type(): string
    {
        return 'user_purchase_count';  // Used for auto-discovery
    }
}
```

**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:**
```php
Rules::create('vip_customers')
    ->when()
        // Both calling styles work identically
        ->user_purchase_count(10, '>=')  // Auto-discovered, operator supported
        // OR
        ->custom('user_purchase_count', ['value' => 10, 'operator' => '>='])
    ->then()
        ->custom('apply_vip_discount')
    ->register();
```

---

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

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

```php
use MilliRules\Rules;

// ⚠️ Avoid this if possible - creates type duplication
Rules::register_condition('user_purchase_count', function($args, Context $context) {
    $condition = new \MyPlugin\Conditions\UserPurchaseCount($args, $context);
    return $condition->matches($context);
});
```

**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

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

```php
Rules::create('special_offer')
    ->when()
        ->is_weekend()                      // Dynamic method
        ->time_in_range(9, 17)              // With parameters
        ->user_has_role('customer')         // Single value
    ->then()
        ->custom('send_offer')
    ->register();
```

### Argument Patterns

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

**No parameters (boolean check):**
```php
->is_weekend()
->custom('is_weekend')
// Both result in: ['type' => 'is_weekend']
```

**Single value (equality check):**
```php
->user_role('administrator')
->custom('user_role', 'administrator')
// Both result in: ['type' => 'user_role', 'value' => 'administrator', 'operator' => '=']
```

**Value with operator:**
```php
->user_age(18, '>=')
->custom('user_age', ['value' => 18, 'operator' => '>='])
// Both result in: ['type' => 'user_age', 'value' => 18, 'operator' => '>=']
```

**Multiple positional arguments:**
```php
->time_in_range(9, 17)
// Result in: ['type' => 'time_in_range', 0 => 9, 1 => 17]
// 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

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](/docs/millirules/02-core-concepts/04-operators)** for complete details.

### Auto-Detection

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

```php
->user_age(18)                           // Auto: '=' for scalar
->user_age(18, '>=')                     // Explicit: '>='
->user_email('*@gmail.com')              // Auto: 'LIKE' for wildcard
->user_role(['admin', 'editor'])         // Auto: 'IN' for array
->is_logged_in(true)                     // Auto: 'IS' for boolean
```

## Configuration Reference

### Standard Config Keys

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

### Common Patterns

**Boolean check:**
```php
->is_weekend()
// Becomes: ['type' => 'is_weekend', 'operator' => 'IS']
```

**Single value (equality):**
```php
->user_role('administrator')
// Becomes: ['type' => 'user_role', 'value' => 'administrator', 'operator' => '=']
```

**Value with operator:**
```php
->user_age(18, '>=')
// Becomes: ['type' => 'user_age', 'value' => 18, 'operator' => '>=']
```

**Name-based condition:**
```php
->request_header('User-Agent', '*Chrome*')
// Becomes: ['type' => 'request_header', 'name' => 'User-Agent', 'value' => '*Chrome*', 'operator' => 'LIKE']
```

**Complex configuration:**
```php
->custom('advanced_check', [
    'value' => 'expected',
    'operator' => 'LIKE',
    'case_sensitive' => false,
    'cache' => true
])
```

## Best Practices

### 1. Always Return Boolean

```php
// ✅ Good - explicit boolean return
Rules::register_condition('is_valid', function($args, Context $context) {
    $value = $context->get('custom.value');
    return (bool) $value;  // Explicit cast
});

// ❌ Bad - may return non-boolean
Rules::register_condition('is_valid', function($args, Context $context) {
    return $context->get('custom.value');  // Could be string, int, null...
});
```

### 2. Avoid Side Effects

```php
// ✅ Good - pure check
Rules::register_condition('has_permission', function($args, Context $context) {
    return current_user_can($args['value'] ?? 'read');
});

// ❌ Bad - modifies state
Rules::register_condition('has_permission', function($args, Context $context) {
    update_option('last_check', time());  // Don't do this!
    return current_user_can($args['value'] ?? 'read');
});
```

### 3. Handle Missing Data Gracefully

```php
Rules::register_condition('user_has_meta', function($args, Context $context) {
    $context->load('user');
    $user_id = $context->get('user.id', 0);

    // Handle case where user is not logged in
    if (!$user_id) {
        return false;
    }

    $meta_key = $args['value'] ?? $args[0] ?? '';
    return !empty(get_user_meta($user_id, $meta_key, true));
});
```

### 4. Use Type Hints

```php
use MilliRules\Context;

Rules::register_condition('my_check', function(array $args, Context $context): bool {
    // Full IDE autocomplete and type safety
    $value = $context->get('custom.key');
    return $value === ($args['value'] ?? null);
});
```

## Common Pitfalls

### Must Return Boolean

```php
// ❌ Wrong - returns string
Rules::register_condition('check_status', function($args, Context $context) {
    return get_option('site_status');  // Returns 'active', 'inactive', etc.
});

// ✅ Correct - returns boolean
Rules::register_condition('is_active', function($args, Context $context) {
    return get_option('site_status') === 'active';
});
```

### Don't Cache Incorrectly

```php
// ❌ Bad - static cache persists across requests
static $cache = null;
Rules::register_condition('expensive_check', function($args, Context $context) use (&$cache) {
    if ($cache === null) {
        $cache = expensive_calculation();
    }
    return $cache > 10;  // Stale data on subsequent requests!
});

// ✅ Good - use transients or request-scoped caching
Rules::register_condition('expensive_check', function($args, Context $context) {
    $result = get_transient('expensive_check_result');
    if (false === $result) {
        $result = expensive_calculation();
        set_transient('expensive_check_result', $result, 60);
    }
    return $result > 10;
});
```

### Don't Perform Actions in Conditions

```php
// ❌ Wrong - sends email every time condition is checked
Rules::register_condition('notify_admin', function($args, Context $context) {
    wp_mail('admin@example.com', 'Check ran', 'Condition checked');
    return true;
});

// ✅ Correct - conditions check, actions do things
Rules::register_condition('should_notify', function($args, Context $context) {
    return $context->get('user.login') === 'special_user';
});

// Then use an action to send email when condition matches
```

## Declaring Condition Metadata

Conditions can declare consumer-facing metadata so UIs can render condition pickers, operator dropdowns, and input fields without hand-maintained schemas.

### Callback-Based Metadata

Chain metadata methods directly after registration:

```php
Rules::register_condition('is_weekend', function($args, Context $context) {
    return date('N') >= 6;
})
    ->label('Is Weekend')
    ->description('Matches on Saturdays and Sundays.')
    ->categories('date')
    ->operators('=', '!=');
```

### Class-Based Metadata via `set_meta()`

For class-based conditions extending `BaseCondition`, override the static `set_meta()` method:

```php
use MilliRules\Conditions\ConditionMeta;
use MilliRules\Conditions\BaseCondition;

class RequestUrl extends BaseCondition
{
    public static function set_meta(ConditionMeta $meta): void
    {
        $meta
            ->label('Request URL')
            ->description('Match the current request URL against a pattern.')
            ->categories('request')
            ->operators('=', '!=', 'LIKE', 'REGEXP', 'IN', 'NOT IN')
            ->args()
                ->string('value')->label('URL Pattern')->required();
    }

    // get_actual_value(), get_type(), etc.
}
```

The `argument_mapping` (from `BaseCondition::get_argument_mapping()`) is automatically included when the engine resolves class-based condition metadata.

> **Note**: `set_meta()` is called after the application framework has fully initialized. If your framework provides translation functions, you can safely use them for labels, descriptions, and argument labels inside `set_meta()`.

### Available Metadata Fields

| Method              | Purpose                    | Notes |
| ------------------- | -------------------------- | ----- |
| `label()`           | Human-readable name        | Shown in condition pickers |
| `description()`     | Help text                  | Tooltip or inline help |
| `categories()`      | UI grouping (one or more)  | Groups conditions in dropdowns |
| `operators()`       | Supported operators        | Populates operator dropdown |
| `argument_mapping()` | Arg-to-config key mapping | Auto-set for class-based conditions |
| `args()`            | Argument schemas           | Same walking-builder as actions |
| `extend()`          | Plugin-specific metadata   | Stored but not interpreted |

See the [ConditionMeta API reference](/docs/millirules/05-reference/03-api#conditionmeta--fluent-condition-metadata) for the full API.

## Next Steps

- **[Custom Actions](/docs/millirules/03-customization/02-custom-actions)** - Implement action logic
- **[Built-in Conditions Reference](/docs/millirules/05-reference/01-conditions)** - See available conditions
- **[Operators Reference](/docs/millirules/02-core-concepts/04-operators)** - Complete operator guide
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete API documentation

---

Canonical: https://www.millipress.com/docs/millirules/03-customization/02-custom-actions

---
title: 'Creating Custom Actions'
description: 'Create custom actions in MilliRules with PHP callbacks or BaseAction classes: typed argument access, placeholder resolution, and metadata for UI rule builders.'
menu_order: 20
---

# 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

```php

use MilliRules\Rules;
use MilliRules\Context;

// 1. Register reusable action
Rules::register_action('alert_slack', function ($config, Context $context) {
    // Reusable logic receiving config + context
    error_log("Slack Alert [{$config['channel']}]: " . ($config['message'] ?? ''));
});

// 2. Create Rule
Rules::create('monitor_admin')
    ->when()
        ->request_url('/wp-admin/*')
        ->user_role('editor')
    ->then()
        // Option A: Inline custom action without registration
        ->custom('log_ip', function (Context $context) {
            error_log("Access from IP: " . $context->get('request.ip'));
        })

        // Option B: Call registered action via magic method
        ->alert_slack(['channel' => '#security', 'message' => 'Editor in admin area'])
        
        // Option C: Call registered action via custom()
        ->custom('alert_slack', ['channel' => '#security', 'message' => '{user.login} accessed admin area'])
    ->register();
```

## Registering Actions

### Choosing the Right Registration Method

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)

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

Define the action directly in the rule using a callback:

```php
use MilliRules\Rules;
use MilliRules\Context;

Rules::create('log_important_access')
    ->when()->request_url('/important/*')
    ->then()
        ->custom('log_access', function(Context $context) {
            // One-off action logic right here
            error_log('Important page accessed: ' . $context->get('request.url'));
        })
    ->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:**

```php
Rules::create('send_notification')
    ->when()->user_role('administrator')
    ->then()
        ->custom('notify', function(Context $context) {
            $user = $context->get('user.login');
            $url = $context->get('request.url');

            wp_mail(
                'admin@example.com',
                'Admin Login',
                "User {$user} accessed {$url}"
            );
        })
    ->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)

**Best for:** Reusable actions across multiple rules, simple logic without placeholders.

Register once, use everywhere:

```php
use MilliRules\Rules;
use MilliRules\Context;

// Register once at plugin initialization
Rules::register_action('send_email', function($args, Context $context) {
    $to = $args['to'] ?? '';
    $subject = $args['subject'] ?? 'Notification';
    $message = $args['message'] ?? '';

    if (!$to) {
        error_log('send_email: missing recipient');
        return;
    }

    wp_mail($to, $subject, $message);
});

// Simple logging action
Rules::register_action('log_message', function($args, Context $context) {
    $message = $args['message'] ?? $args[0] ?? '';
    error_log($message);
});
```

**Then use in any rule:**

```php
Rules::create('log_important_requests')
    ->when()->request_url('/important/*')
    ->then()
        ->log_message(['message' => 'Important page accessed'])
        ->send_email(['to' => 'admin@example.com', 'subject' => 'Important Access'])
    ->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)

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

```php
use MilliRules\Rules;

// One-time registration at plugin initialization
Rules::register_namespace('Actions', 'MyPlugin\Actions');
```

**Create your action class:**

```php
namespace MyPlugin\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class NotifyMail extends BaseAction
{
    public function execute($config, Context $context): void
    {
        // Access arguments via $this->args
        $to = $this->args['to'] ?? '';
        $message = $this->args['message'] ?? '';

        // Resolve placeholders like {user.login}
        $to = $this->resolve_value($to);
        $message = $this->resolve_value($message);

        wp_mail($to, 'Notification', $message);
    }

    public function get_type(): string
    {
        return 'notify_mail';  // Used for auto-discovery
    }
}
```

**How it works:**
- The class name `NotifyMail` is converted to `notify_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:**
```php
Rules::create('notify_on_login')
    ->when()->is_user_logged_in()
    ->then()
        // Both calling styles work identically
        ->notify_mail(['to' => 'admin@example.com', 'message' => 'User {user.login} logged in'])
        // OR
        ->custom('notify_mail', ['to' => 'admin@example.com', 'message' => 'User {user.login} logged in'])
    ->register();
```

---

## Accessing Action Arguments

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

Access action arguments using the `get_arg()` method in your action classes:

```php
namespace MyPlugin\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class SendEmail extends BaseAction
{
    public function execute(Context $context): void
    {
        // Clean type-safe access with automatic placeholder resolution
        $to = $this->get_arg('to', 'admin@example.com')->string();
        $subject = $this->get_arg('subject', 'Notification')->string();
        $html = $this->get_arg('html', false)->bool();
        $priority = $this->get_arg('priority', 10)->int();

        wp_mail($to, $subject, 'Message content');
    }

    public function get_type(): string
    {
        return 'send_email';
    }
}
```

### Type Conversion Methods

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

Placeholders like `{user.email}` are automatically resolved when you call any type method:

```php
// Rule definition
Rules::create('welcome_email')
    ->when()->is_user_logged_in()
    ->then()
        ->send_email([
            'to' => '{user.email}',
            'subject' => 'Welcome {user.login}!'
        ])
    ->register();

// In your SendEmail action class
$to = $this->get_arg('to')->string();
// Result: 'john@example.com' (placeholder automatically resolved)

$subject = $this->get_arg('subject')->string();
// Result: 'Welcome john!' (placeholder automatically resolved)
```

### Positional Arguments

Works with both named and positional arguments:

```php
class LogMessage extends BaseAction
{
    public function execute(Context $context): void
    {
        // Called via: ->logMessage('ERROR', 'Something broke', 3)
        $level = $this->get_arg(0, 'info')->string();
        $message = $this->get_arg(1, 'No message')->string();
        $priority = $this->get_arg(2, 1)->int();

        error_log("[{$level}] {$message} (priority: {$priority})");
    }

    public function get_type(): string
    {
        return 'log_message';
    }
}
```

---

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

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

```php
use MilliRules\Rules;

// ⚠️ Avoid this if possible - creates type duplication
Rules::register_action('notify', function($args, Context $context) {
    $action = new \MyPlugin\Actions\NotifyMail($args, $context);
    $action->execute($context);
});
```

**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

Once registered, actions can be used via **dynamic method calls**:

```php
Rules::create('notify_admin')
    ->when()->request_url('/important/*')
    ->then()
        ->send_email(['to' => 'admin@example.com', 'subject' => 'Alert'])   // Dynamic method
        ->log_message(['message' => 'Important page accessed'])             // Dynamic method
    ->register();
```

### Argument Patterns

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

**Named parameters (recommended):**
```php
->send_email(['to' => 'admin@example.com', 'subject' => 'Alert'])
->custom('send_email', ['to' => 'admin@example.com', 'subject' => 'Alert'])
// Both result in: $this->args['to'], $this->args['subject']
```

**Positional array:**
```php
->send_email(['admin@example.com', 'Alert', 'Body'])
->custom('send_email', ['admin@example.com', 'Alert', 'Body'])
// Both result in: $this->args[0], $this->args[1], $this->args[2]
```

**Single value:**
```php
->log_message('Important event occurred')
->custom('log_message', 'Important event occurred')
// 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

### 1. Keep Actions Focused

```php
// ✅ Good - single responsibility
Rules::register_action('log_event', function($args, Context $context) {
    error_log($args[0] ?? '');
});

// ❌ Bad - too many responsibilities
Rules::register_action('do_everything', function($args, Context $context) {
    // Logs, sends email, updates database, clears cache...
});
```

### 2. Validate Configuration

```php
Rules::register_action('send_email', function($args, Context $context) {
    if (!isset($args['to']) || !is_email($args['to'])) {
        error_log('send_email: invalid recipient');
        return;
    }

    wp_mail($args['to'], $args['subject'] ?? '', $args['message'] ?? '');
});
```

### 3. Handle Errors Gracefully

```php
Rules::register_action('api_call', function($args, Context $context) {
    try {
        $response = wp_remote_post($args['url'] ?? '', [
            'body' => $args['data'] ?? []
        ]);

        if (is_wp_error($response)) {
            error_log('API call failed: ' . $response->get_error_message());
            return;
        }
    } catch (\Exception $e) {
        error_log('API call exception: ' . $e->getMessage());
    }
});
```

### 4. Use Type Hints

```php
use MilliRules\Context;

Rules::register_action('my_action', function(array $args, Context $context): void {
    // Full IDE autocomplete and type safety
    $user = $context->get('user.login');
});
```

## Declaring Action Metadata

`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

Chain metadata methods directly after registration. The returned `ActionMeta` is the same instance stored in the registry, so all chained calls persist:

```php
Rules::register_action('add_flag', $addCallback)
    ->scope('flag')
    ->label('Add Flag')
    ->description('Tag the response with a flag for bulk invalidation.')
    ->categories('flags')
    ->args()
        ->string(0)->label('Flag')->required();
```

### Class-Based Metadata via `set_meta()`

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.

```php
use MilliRules\Actions\ActionMeta;
use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class AddFlag extends BaseAction
{
    // Engine-relevant. Called during early bootstrap — plain strings only.
    public static function get_scope(): string
    {
        return 'flag';
    }

    // Consumer-relevant. Called after framework initialization.
    public static function set_meta(ActionMeta $meta): void
    {
        $meta
            ->label('Add Flag')
            ->description('Tag the response with a flag.')
            ->categories('flags');
    }

    public function execute(Context $context): void
    {
        $flag = $this->get_arg(0, '')->string();
        // ... add the flag ...
    }

    public function get_type(): string
    {
        return 'add_flag';
    }
}
```

**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

| 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: the `RuleEngine` calls it directly (not via `set_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`, `args`** are stored but never interpreted by MilliRules itself. They live inside `set_meta()`, which is called only when consumers (UI builders, CLIs, docs generators) introspect via `Rules::get_action_meta($type)`.
- **`extend`** is 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 inside `set_meta()`. In contrast, `get_scope()` runs during early bootstrap and must return plain strings only.

### Declaring Arguments

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'`.

```php
use MilliRules\Actions\ActionMeta;
use MilliRules\Actions\BaseAction;

class SetTtl extends BaseAction
{
    public static function describe(ActionMeta $meta): void
    {
        $meta
            ->label('Set TTL')
            ->description('Set cache time-to-live.')
            ->categories('caching')
            ->args()
                ->integer('ttl')
                    ->format('seconds')  // UI hint: render as duration picker
                    ->label('TTL')
                    ->description('Duration in seconds')
                    ->default(3600)
                    ->min(0)
                    ->max(86400)
                ->string('reason')
                    ->label('Reason')
                    ->default('');
    }

    public function execute(Context $context): void
    {
        $ttl    = $this->get_arg('ttl', 3600)->int();
        $reason = $this->get_arg('reason', '')->string();
        // ...
    }

    public function get_type(): string { return 'set_ttl'; }
}
```

Key points:

- **No class name imports for arguments**. You never write `ArgumentSchema` or `ArgumentsBuilder` in 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 throws `InvalidArgumentException` at 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' `RuleEngine` does 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 other `ActionMeta` method 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

```php
$meta
    ->label('Cache Mode')
    ->args()
        ->choice('strategy')
            ->options(['eager', 'lazy', 'off'])
            ->default('lazy')
            ->label('Caching Strategy')
        ->choices('vary_by')
            ->options(['user', 'locale', 'device'])
            ->default(['user'])
            ->label('Vary By');
```

See the [ArgumentSchema API reference](/docs/millirules/05-reference/03-api#argumentschema--argument-metadata) for the full API.

### Plugin-Specific Metadata via `extend()`

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:

```php
public static function describe(ActionMeta $meta): void
{
    $meta
        ->label('Set TTL')
        ->categories('caching')
        // Plugin-specific metadata: MilliRules stores these but never reads them.
        ->extend('my-plugin:icon', 'clock')
        ->extend('my-plugin:docs_url', 'https://example.com/actions/set-ttl')
        ->extend('my-plugin:requires_addon', 'pro');
}
```

**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:

```php
$meta = Rules::get_action_meta('set_ttl');
$icon = $meta?->get_extension('my-plugin:icon');         // 'clock'
$has  = $meta?->has_extension('my-plugin:icon');         // true
$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

Consumers can query metadata for any registered action type:

```php
$meta = Rules::get_action_meta('add_flag');
if ($meta) {
    echo $meta->get_label();              // 'Add Flag'
    $cats = $meta->get_categories();      // ['flags']
    $args = $meta->get_arguments();       // array<ArgumentSchema>
    $icon = $meta->get_extension('my-plugin:icon');
    $data = $meta->to_array();            // Serializable array for REST
}
```

This works for both callback-based and class-based actions uniformly.

## Common Pitfalls

### Don't Modify Context Expecting Persistence

```php
// ❌ Wrong - context changes don't persist between rules
Rules::register_action('bad_action', function($args, Context $context) {
    $context->set('custom_value', 'modified');
    // This change is lost after the action completes!
});

// ✅ Correct - use external state
Rules::register_action('good_action', function($args, Context $context) {
    update_option('custom_value', 'modified');
    // Or use globals, database, cache, etc.
});
```

### Don't Perform Heavy Operations Without Caching

```php
// ❌ Bad - runs on every execution
Rules::register_action('slow_action', function($args, Context $context) {
    $data = expensive_api_call();
    process_data($data);
});

// ✅ Good - cache expensive operations
Rules::register_action('cached_action', function($args, Context $context) {
    $data = get_transient('cached_data');
    if (false === $data) {
        $data = expensive_api_call();
        set_transient('cached_data', $data, HOUR_IN_SECONDS);
    }
    process_data($data);
});
```

## Next Steps

- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Create conditional logic
- **[Built-in Actions Reference](/docs/millirules/05-reference/02-actions)** - See available actions
- **[Placeholder System](/docs/millirules/02-core-concepts/05-placeholders)** - Dynamic value resolution
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete API documentation

---

Canonical: https://www.millipress.com/docs/millirules/03-customization/03-custom-packages

---
title: 'Creating Custom Packages'
description: 'Bundle custom conditions, actions, context providers, and placeholder resolvers into reusable MilliRules packages, with a complete PHP membership example.'
menu_order: 30
---

# 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?

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

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

All packages must implement `PackageInterface`:

```php
namespace MilliRules\Packages;

use MilliRules\Context;

interface PackageInterface {
    public function get_name(): string;
    public function get_namespaces(): array;
    public function is_available(): bool;
    public function get_required_packages(): array;
    public function register_providers(Context $context): void;
    public function get_placeholder_resolver(Context $context);
    public function register_rule(array $rule, array $metadata);
    public function execute_rules(array $rules, Context $context): array;
}
```

---

## Basic Custom Package

### Minimal Package Implementation

```php
namespace MyPlugin\Packages;

use MilliRules\Packages\BasePackage;

class MyCustomPackage extends BasePackage {
    /**
     * Package name (must be unique)
     */
    public function get_name(): string {
        return 'MyCustom';
    }

    /**
     * Namespaces for conditions and actions
     */
    public function get_namespaces(): array {
        return [
            'MyPlugin\Packages\MyCustom\Conditions',
            'MyPlugin\Packages\MyCustom\Actions',
        ];
    }

    /**
     * Check if package can be used in current environment
     */
    public function is_available(): bool {
        // Example: check if required functions exist
        return function_exists('my_required_function');
    }
}
```

### Registering the Package

```php
use MilliRules\MilliRules;
use MyPlugin\Packages\MyCustomPackage;

// Register custom package
$custom_package = new MyCustomPackage();
MilliRules::init(null, [$custom_package]);

// Or let MilliRules auto-discover (if registered globally)
MilliRules::init();
```

---

## Registering Context Providers

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

### Simple Context Provider

```php
use MilliRules\Context;

public function register_providers(Context $context): void {
    // Register a simple provider that loads on-demand
    $context->register_provider('my_custom', function() {
        return [
            'my_custom' => [
                'value1' => get_option('my_option_1'),
                'value2' => get_option('my_option_2'),
                'timestamp' => time(),
            ],
        ];
    });
}
```

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

### Class-Based Context Provider

A context placed in your package's `Contexts` namespace is discovered automatically, and — unlike a closure — it can describe itself to the placeholder catalog returned by `Rules::get_all_placeholder_metas()`.

```php
namespace MyPlugin\Contexts;

use MilliRules\Contexts\BaseContext;

class Tenant extends BaseContext {
    public function get_key(): string {
        return 'tenant';
    }

    // Shown in rule-builder UIs. Defaults to the key.
    public function get_label(): string {
        return 'Tenant';
    }

    // One sentence naming a concrete {category.key}. Read by a person picking
    // a placeholder and by AI clients choosing one. Defaults to ''.
    public function get_description(): string {
        return 'The current tenant, for example {tenant.plan}.';
    }

    // The keys {tenant.*} accepts. Return an empty array (the default) when
    // the key is chosen by the caller, as with a cookie name.
    public function get_keys(): array {
        return ['id', 'plan', 'seats'];
    }

    // Skipped everywhere if the environment can't answer it.
    public function is_available(): bool {
        return function_exists('my_plugin_current_tenant');
    }

    protected function build(): array {
        $tenant = my_plugin_current_tenant();

        return [
            'tenant' => [
                'id'    => $tenant->id,
                'plan'  => $tenant->plan,
                'seats' => $tenant->seats,
            ],
        ];
    }
}
```

**Declare `get_keys()` whenever the set is closed.** An unresolvable placeholder is left in the value verbatim rather than raising an error, so `{tenant.pln}` would silently become the literal string `{tenant.pln}` instead of the plan. Declaring the keys lets a rule builder reject the typo before the rule is stored.

Return keys as a **fixed list**, not one derived from `build()`, unless `build()` returns the same shape unconditionally — a catalog whose shape changed with the current request would be useless for validating a stored rule.

### Dynamic Context Provider

```php
use MilliRules\Context;

public function register_providers(Context $context): void {
    // Register provider that loads complex data on-demand
    $context->register_provider('my_custom', function() {
        $user_data = [];
        if (is_user_logged_in()) {
            $user_id = get_current_user_id();
            $user_data = [
                'id' => $user_id,
                'meta' => get_user_meta($user_id),
                'purchases' => $this->get_user_purchases($user_id),
            ];
        }

        return [
            'my_custom' => [
                'user' => $user_data,
                'site' => [
                    'name' => get_bloginfo('name'),
                    'url' => home_url(),
                ],
                'stats' => [
                    'total_posts' => wp_count_posts()->publish,
                    'total_users' => count_users()['total_users'],
                ],
            ],
        ];
    });
}

private function get_user_purchases($user_id) {
    global $wpdb;
    return $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}purchases WHERE user_id = %d",
        $user_id
    ));
}
```

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

### Context Provider with External API

```php
use MilliRules\Context;

public function register_providers(Context $context): void {
    // Register provider that loads API data on-demand
    $context->register_provider('my_custom', function() {
        // Cache expensive API calls
        $api_data = get_transient('my_custom_api_data');

        if ($api_data === false) {
            $response = wp_remote_get('https://api.example.com/data', [
                'timeout' => 10,
                'headers' => ['Authorization' => 'Bearer ' . $this->get_api_key()],
            ]);

            if (!is_wp_error($response)) {
                $api_data = json_decode(wp_remote_retrieve_body($response), true);
                set_transient('my_custom_api_data', $api_data, 300); // Cache 5 minutes
            } else {
                $api_data = [];
            }
        }

        return [
            'my_custom' => [
                'api' => $api_data,
                'cached_at' => get_transient('my_custom_api_data_time') ?: time(),
            ],
        ];
    });
}
```

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

---

## Creating Package Conditions

Package conditions extend the available condition types.

### Simple Package Condition

```php
namespace MyPlugin\Packages\MyCustom\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class UserLevelCondition extends BaseCondition {
    protected function get_actual_value(Context $context) {
        $context->load('user');
        $user_id = $context->get('user.id', 0);

        if (!$user_id) {
            return 0;
        }

        // Get custom user level
        return (int) get_user_meta($user_id, 'user_level', true);
    }

    public function get_type(): string {
        return 'user_level';
    }
}
```

**Usage**:
```php
Rules::create('premium_users')
    ->when()
        ->custom('user_level', ['value' => 5, 'operator' => '>='])
    ->then()
        ->custom('show_premium_content')
    ->register();
```

### Condition Using Package Context

```php
namespace MyPlugin\Packages\MyCustom\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class PurchaseCountCondition extends BaseCondition {
    protected function get_actual_value(Context $context) {
        // Load package context data
        $context->load('my_custom');

        // Use data from package context
        $purchases = $context->get('my_custom.user.purchases', []);
        return count($purchases);
    }

    public function get_type(): string {
        return 'purchase_count';
    }
}
```

---

## Creating Package Actions

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

### Simple Package Action

```php
namespace MyPlugin\Packages\MyCustom\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class UpdateUserLevelAction extends BaseAction {
    public function execute(Context $context): void {
        $context->load('user');
        $user_id = $context->get('user.id', 0);
        $level = $this->config['level'] ?? 1;

        if (!$user_id) {
            error_log('UpdateUserLevelAction: No user logged in');
            return;
        }

        update_user_meta($user_id, 'user_level', $level);
        error_log("Updated user {$user_id} to level {$level}");
    }

    public function get_type(): string {
        return 'update_user_level';
    }
}
```

### Action with Placeholder Support

```php
namespace MyPlugin\Packages\MyCustom\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class SendNotificationAction extends BaseAction {
    public function execute(Context $context): void {
        // Resolve placeholders
        $message = $this->resolve_value($this->config['message'] ?? '');
        $recipient = $this->resolve_value($this->config['to'] ?? '');

        // Send notification
        $this->send_notification($recipient, $message);
    }

    private function send_notification($to, $message) {
        // Implementation...
        wp_mail($to, 'Notification', $message);
    }

    public function get_type(): string {
        return 'send_notification';
    }
}
```

---

## Adding Placeholder Resolvers

Placeholder resolvers enable dynamic values in rules.

### Basic Placeholder Resolver

```php
use MilliRules\Context;

public function get_placeholder_resolver(Context $context) {
    return function($placeholder_parts) use ($context) {
        // $placeholder_parts = ['my_custom', 'category', 'key']
        // From placeholder: {my_custom.category.key}

        if ($placeholder_parts[0] !== 'my_custom') {
            return null; // Not for this package
        }

        // Load context data if not already loaded
        $context->load('my_custom');

        // Convert parts to dot notation path
        $path = implode('.', $placeholder_parts);

        // Get value from context
        $value = $context->get($path, '');

        return is_scalar($value) ? (string) $value : '';
    };
}
```

**Usage**:
```php
Rules::create('use_custom_placeholder')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('log', [
            'message' => 'Site: {my_custom.site.name}, Users: {my_custom.stats.total_users}'
        ])
    ->register();
```

### Advanced Placeholder Resolver

```php
use MilliRules\Context;

public function get_placeholder_resolver(Context $context) {
    return function($placeholder_parts) use ($context) {
        if ($placeholder_parts[0] !== 'my_custom') {
            return null;
        }

        $category = $placeholder_parts[1] ?? '';
        $key = $placeholder_parts[2] ?? '';

        // Load context data once
        $context->load('my_custom');

        switch ($category) {
            case 'user':
                return $this->resolve_user_placeholder($context, $key);

            case 'product':
                return $this->resolve_product_placeholder($context, $key);

            case 'setting':
                return $this->resolve_setting_placeholder($context, $key);

            default:
                return '';
        }
    };
}

private function resolve_user_placeholder(Context $context, $key) {
    switch ($key) {
        case 'level':
            return $context->get('my_custom.user.level', '0');
        case 'points':
            return $context->get('my_custom.user.points', '0');
        default:
            return $context->get("my_custom.user.{$key}", '');
    }
}
```

---

## Declaring Package Dependencies

Packages can require other packages.

### Simple Dependency

```php
public function get_required_packages(): array {
    return ['PHP']; // Requires PHP package
}
```

### Multiple Dependencies

```php
public function get_required_packages(): array {
    return ['PHP', 'WP']; // Requires both PHP and WordPress packages
}
```

> [!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

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

```php
namespace MyPlugin\Packages;

use MilliRules\Packages\BasePackage;

class MembershipPackage extends BasePackage {
    public function get_name(): string {
        return 'Membership';
    }

    public function get_namespaces(): array {
        return [
            'MyPlugin\Packages\Membership\Conditions',
            'MyPlugin\Packages\Membership\Actions',
        ];
    }

    public function is_available(): bool {
        // Check if membership system is active
        return class_exists('My_Membership_System');
    }

    public function get_required_packages(): array {
        return ['PHP', 'WP']; // Requires both PHP and WordPress
    }

    public function register_providers(Context $context): void {
        // Register membership provider (loads on-demand)
        $context->register_provider('membership', function() {
            $user_id = get_current_user_id();

            $membership_data = [];
            if ($user_id) {
                $membership_data = [
                    'level' => get_user_meta($user_id, 'membership_level', true) ?: 'free',
                    'status' => get_user_meta($user_id, 'membership_status', true) ?: 'inactive',
                    'expiry' => get_user_meta($user_id, 'membership_expiry', true) ?: 0,
                    'features' => $this->get_user_features($user_id),
                ];
            }

            return [
                'membership' => [
                    'user' => $membership_data,
                    'levels' => $this->get_available_levels(),
                    'features' => $this->get_all_features(),
                ],
            ];
        });
    }

    public function get_placeholder_resolver(Context $context) {
        return function($parts) use ($context) {
            if ($parts[0] !== 'membership') {
                return null;
            }

            // Load membership context if not already loaded
            $context->load('membership');

            $category = $parts[1] ?? '';
            $key = $parts[2] ?? '';

            if ($category === 'user') {
                return $context->get("membership.user.{$key}", '');
            }

            return '';
        };
    }

    private function get_user_features($user_id) {
        // Get features available to user
        return ['feature1', 'feature2'];
    }

    private function get_available_levels() {
        return ['free', 'basic', 'premium', 'enterprise'];
    }

    private function get_all_features() {
        return ['feature1', 'feature2', 'feature3'];
    }
}
```

**Membership Condition Example**:

```php
namespace MyPlugin\Packages\Membership\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class MembershipLevelCondition extends BaseCondition {
    protected function get_actual_value(Context $context) {
        $context->load('membership');
        return $context->get('membership.user.level', 'free');
    }

    public function get_type(): string {
        return 'membership_level';
    }
}
```

**Membership Action Example**:

```php
namespace MyPlugin\Packages\Membership\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class UpgradeMembershipAction extends BaseAction {
    public function execute(Context $context): void {
        $context->load('user');
        $user_id = $context->get('user.id', 0);
        $new_level = $this->config['level'] ?? 'basic';

        if (!$user_id) {
            return;
        }

        update_user_meta($user_id, 'membership_level', $new_level);
        update_user_meta($user_id, 'membership_status', 'active');

        // Resolve message with placeholders
        $message = $this->resolve_value(
            $this->config['message'] ?? 'Upgraded to {membership.user.level}'
        );

        error_log($message);
    }

    public function get_type(): string {
        return 'upgrade_membership';
    }
}
```

**Using the Custom Package**:

```php
use MilliRules\MilliRules;
use MyPlugin\Packages\MembershipPackage;

// Initialize with custom package
$membership_package = new MembershipPackage();
MilliRules::init(null, [$membership_package]);

// Create rule using package conditions and actions
Rules::create('auto_upgrade_frequent_buyers')
    ->when()
        ->is_user_logged_in()                                    // WP condition
        ->custom('membership_level', ['value' => 'free'])        // Membership condition
        ->custom('purchase_count', ['value' => 10, 'operator' => '>='])
    ->then()
        ->custom('upgrade_membership', [
            'level' => 'premium',
            'message' => 'Congratulations! Upgraded to premium membership.'
        ])
    ->register();
```

---

## Real-World Example: Acorn MilliRules

The [Acorn MilliRules](https://github.com/MilliPress/Acorn-MilliRules) package is a real-world custom package that extends MilliRules for the [Roots Acorn](https://roots.io/acorn/) framework. It's a good reference for how to structure a production package.

### Package Class

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

```php
namespace MilliRules\Acorn\Packages\Acorn;

use MilliRules\Acorn\Packages\Acorn\Contexts\Route;
use MilliRules\Packages\BasePackage;

class Package extends BasePackage
{
    public function get_name(): string
    {
        return 'Acorn';
    }

    public function get_namespaces(): array
    {
        return [
            'MilliRules\\Acorn\\Packages\\Acorn\\Actions',
            'MilliRules\\Acorn\\Packages\\Acorn\\Conditions',
            'MilliRules\\Acorn\\Packages\\Acorn\\Contexts',
        ];
    }

    public function is_available(): bool
    {
        return function_exists('app');
    }

    public function get_required_packages(): array
    {
        return ['PHP'];
    }
}
```

### What It Provides

| 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

```php
// app/Rules/RedirectLegacyDocs.php
namespace App\Rules;

use MilliRules\Rules;

class RedirectLegacyDocs
{
    public function register(): void
    {
        Rules::create('redirect_legacy_docs', 'Acorn')
            ->when()
                ->routeName('docs.*')
                ->routeParameter('product', ['value' => 'old-product'])
            ->then()
                ->redirect('/docs/new-product/', ['status' => 301])
            ->register();
    }
}
```

For full documentation, see the [Acorn MilliRules docs](https://millipress.com/docs/acorn-millirules/).

---

## Best Practices

### 1. Use Descriptive Package Names

```php
// ✅ Good - clear and specific
public function get_name(): string {
    return 'WooCommerce';
}

// ❌ Bad - vague or generic
public function get_name(): string {
    return 'Custom';
}
```

### 2. Validate Environment in is_available()

```php
// ✅ Good - comprehensive checks
public function is_available(): bool {
    return class_exists('WooCommerce')
        && function_exists('wc_get_product')
        && defined('WC_VERSION');
}

// ❌ Bad - minimal checking
public function is_available(): bool {
    return true;
}
```

### 3. Cache Expensive Context Data

```php
use MilliRules\Context;

// ✅ Good - caches API calls in lazy provider
public function register_providers(Context $context): void {
    $context->register_provider('my_package', function() {
        $data = get_transient('my_package_context');

        if ($data === false) {
            $data = $this->fetch_expensive_data();
            set_transient('my_package_context', $data, 300);
        }

        return ['my_package' => $data];
    });
}
```

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

### 4. Document Your Package

```php
/**
 * Membership Package
 *
 * Provides membership-related conditions and actions.
 *
 * Conditions:
 * - membership_level: Check user's membership level
 * - membership_status: Check membership status
 * - has_feature: Check if user has access to feature
 *
 * Actions:
 * - upgrade_membership: Upgrade user to new level
 * - grant_feature: Grant feature access
 * - send_membership_email: Send membership-related email
 *
 * Context:
 * - membership.user.level: User's membership level
 * - membership.user.status: Membership status
 * - membership.user.features: Available features
 *
 * Placeholders:
 * - {membership.user.level}: User's membership level
 * - {membership.user.status}: Membership status
 */
class MembershipPackage extends BasePackage {
    // ...
}
```

---

## Common Pitfalls

### 1. Circular Dependencies

```php
// ❌ Wrong - circular dependency
class PackageA extends BasePackage {
    public function get_required_packages(): array {
        return ['PackageB']; // A requires B
    }
}

class PackageB extends BasePackage {
    public function get_required_packages(): array {
        return ['PackageA']; // B requires A - CIRCULAR!
    }
}
```

### 2. Accessing Unavailable Context

```php
use MilliRules\Context;

// ❌ Wrong - doesn't check availability
protected function get_actual_value(Context $context) {
    $context->load('user');
    return $context->get('user.id'); // May return null if not available!
}

// ✅ Correct - provides default value
protected function get_actual_value(Context $context) {
    $context->load('user');
    return $context->get('user.id', 0);
}
```

### 3. Expensive Provider Registration

```php
use MilliRules\Context;

// ❌ Wrong - executes expensive operation during registration
public function register_providers(Context $context): void {
    $data = expensive_api_call(); // Runs on every request!
    $context->register_provider('my_package', function() use ($data) {
        return ['my_package' => $data];
    });
}

// ✅ Correct - expensive operation runs only when provider loads
public function register_providers(Context $context): void {
    $context->register_provider('my_package', function() {
        $data = wp_cache_get('my_package_data', 'my_group');

        if ($data === false) {
            $data = expensive_api_call(); // Only runs when needed!
            wp_cache_set('my_package_data', $data, 'my_group', 300);
        }

        return ['my_package' => $data];
    });
}
```

---

## Next Steps

- **[Advanced Patterns](/docs/millirules/04-advanced/02-advanced-patterns)** - Advanced package techniques
- **[WordPress Integration](/docs/millirules/04-advanced/03-wordpress-integration)** - WordPress-specific patterns
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete API documentation
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - See complete package implementations

---

**Ready for advanced techniques?** Continue to [Advanced Patterns](/docs/millirules/04-advanced/02-advanced-patterns) to learn optimization strategies and advanced rule patterns.

---

Canonical: https://www.millipress.com/docs/millirules/04-advanced/01-examples

---
title: 'Real-World Examples'
description: 'Complete MilliRules examples for WordPress: page caching, access control, API rate limiting, feature flags, WooCommerce rules, and a full membership system.'
menu_order: 10
---

# Real-World Examples

This guide provides complete, working examples of MilliRules implementations for common use cases. Each example includes full code with explanations.

## Table of Contents

- [Page Caching System](#page-caching-system)
- [Access Control and Redirects](#access-control-and-redirects)
- [Content Modification](#content-modification)
- [User Tracking and Analytics](#user-tracking-and-analytics)
- [API Rate Limiting](#api-rate-limiting)
- [Feature Flags](#feature-flags)
- [WooCommerce Integration](#woocommerce-integration)
- [Membership System](#membership-system)

---

## Page Caching System

A complete page caching implementation using early execution.

```php
/**
 * Plugin Name: MilliRules Page Cache
 * Description: Intelligent page caching with MilliRules
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;
use MilliRules\Context;

// Initialize early (in mu-plugins or early hook)
add_action('plugins_loaded', function() {
    MilliRules::init();

    // Register cache check action
    Rules::register_action('check_page_cache', function($args, Context $context) {
        $context->load('request');
        $uri = $context->get('request.uri', '') ?? '';
        $cache_key = 'page_cache_' . md5($uri);

        $cached = get_transient($cache_key);

        if ($cached !== false) {
            header('Content-Type: text/html; charset=UTF-8');
            header('X-Cache: HIT');
            header('X-Cache-Key: ' . $cache_key);
            echo $cached;
            exit;
        }
    });

    // Register cache save action
    Rules::register_action('save_page_cache', function($args, Context $context) {
        $uri = $context->get('request.uri', '') ?? '';
        $cache_key = 'page_cache_' . md5($uri);
        $duration = $args['duration'] ?? 3600;

        ob_start(function($buffer) use ($cache_key, $duration) {
            // Save to cache
            set_transient($cache_key, $buffer, $duration);

            // Add cache header
            header('X-Cache: MISS');
            header('X-Cache-Key: ' . $cache_key);

            return $buffer;
        });
    });

    // Rule 1: Check cache for cacheable requests
    Rules::create('check_cache', 'php')
        ->order(5)
        ->when()
            ->request_method(['GET', 'HEAD'], 'IN')
            ->request_url('/wp-admin/*', 'NOT LIKE')
            ->request_url('/wp-login.php', '!=')
            ->cookie('wordpress_logged_in_*', null, 'NOT EXISTS')
        ->then()
            ->custom('check_page_cache')
        ->register();

    // Rule 2: Save to cache after response
    Rules::create('save_cache', 'php')
        ->order(10)
        ->when()
            ->request_method(['GET', 'HEAD'], 'IN')
            ->request_url('/wp-admin/*', 'NOT LIKE')
            ->request_url('/wp-login.php', '!=')
        ->then()
            ->custom('save_page_cache', ['duration' => 3600])
        ->register();

    // Execute early rules
    MilliRules::execute_rules(['PHP']);
}, 1);

// Clear cache on post update
add_action('save_post', function($post_id) {
    // Clear all page cache
    global $wpdb;
    $wpdb->query("DELETE FROM {$wpdb->options} WHERE option_name LIKE '_transient_page_cache_%'");
});
```

---

## Access Control and Redirects

Protect pages and redirect unauthorized users.

```php
/**
 * Plugin Name: MilliRules Access Control
 * Description: Rule-based access control
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register custom conditions
    Rules::register_condition('user_has_role', function($args, Context $context) {
        $required_role = $args['role'] ?? '';
        $user_roles = $context['wp']['user']['roles'] ?? [];
        return in_array($required_role, $user_roles);
    });

    Rules::register_condition('user_can', function($args, Context $context) {
        $capability = $args['capability'] ?? '';
        $user_id = $context->get('user.id', 0) ?? 0;
        return $user_id && user_can($user_id, $capability);
    });

    // Register redirect action
    Rules::register_action('redirect_to', function($args, Context $context) {
        $url = $args['url'] ?? home_url();
        $status = $args['status'] ?? 302;
        $message = $args['message'] ?? '';

        if ($message) {
            set_transient('redirect_message_' . get_current_user_id(), $message, 30);
        }

        wp_redirect($url, $status);
        exit;
    });

    // Register message display action
    Rules::register_action('show_redirect_message', function($args, Context $context) {
        $user_id = get_current_user_id();
        $message = get_transient('redirect_message_' . $user_id);

        if ($message) {
            delete_transient('redirect_message_' . $user_id);
            add_action('admin_notices', function() use ($message) {
                echo '<div class="notice notice-warning"><p>' . esc_html($message) . '</p></div>';
            });
        }
    });

    // Rule 1: Protect admin area
    Rules::create('protect_admin', 'wp')
        ->on('admin_init', 5)
        ->when()
            ->is_user_logged_in()
            ->custom('user_can', ['capability' => 'edit_posts'])
            ->match_none()
        ->then()
            ->custom('redirect_to', [
                'url' => home_url(),
                'message' => 'You do not have permission to access the admin area.'
            ])
        ->register();

    // Rule 2: Protect specific pages
    Rules::create('protect_membership_pages', 'wp')
        ->on('template_redirect', 10)
        ->when()
            ->request_url('/members/*', 'LIKE')
            ->is_user_logged_in(false)
        ->then()
            ->custom('redirect_to', [
                'url' => wp_login_url($_SERVER['REQUEST_URI'] ?? ''),
                'message' => 'Please log in to access member content.',
                'status' => 302
            ])
        ->register();

    // Rule 3: Role-based page protection
    Rules::create('protect_premium_content', 'wp')
        ->on('template_redirect', 10)
        ->when()
            ->request_url('/premium/*', 'LIKE')
            ->is_user_logged_in()
            ->custom('user_has_role', ['role' => 'subscriber'])
            ->match_none()
        ->then()
            ->custom('redirect_to', [
                'url' => home_url('/upgrade'),
                'message' => 'Upgrade to premium to access this content.'
            ])
        ->register();

    // Rule 4: Display redirect messages
    Rules::create('display_messages', 'wp')
        ->on('admin_notices', 10)
        ->when()->is_user_logged_in()
        ->then()->custom('show_redirect_message')
        ->register();

}, 1);
```

---

## Content Modification

Dynamically modify WordPress content based on conditions.

```php
/**
 * Plugin Name: MilliRules Content Modifier
 * Description: Conditional content modification
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register content modification actions
    Rules::register_action('prepend_content', function($args, Context $context) {
        $text = $args['text'] ?? '';
        $priority = $args['priority'] ?? 10;

        add_filter('the_content', function($content) use ($text) {
            return $text . $content;
        }, $priority);
    });

    Rules::register_action('append_content', function($args, Context $context) {
        $text = $args['text'] ?? '';
        $priority = $args['priority'] ?? 10;

        add_filter('the_content', function($content) use ($text) {
            return $content . $text;
        }, $priority);
    });

    Rules::register_action('add_reading_time', function($args, Context $context) {
        add_filter('the_content', function($content) {
            $word_count = str_word_count(strip_tags($content));
            $reading_time = ceil($word_count / 200); // 200 words per minute

            $badge = '<div class="reading-time" style="background: #f0f0f0; padding: 10px; margin-bottom: 20px; border-radius: 5px;">';
            $badge .= '<strong>⏱ Reading time:</strong> ' . $reading_time . ' min';
            $badge .= '</div>';

            return $badge . $content;
        }, 10);
    });

    // Rule 1: Add disclaimer to product posts
    Rules::create('product_disclaimer', 'wp')
        ->on('the_content', 10)
        ->when()
            ->is_singular('post')
            ->post_type('product')
        ->then()
            ->custom('prepend_content', [
                'text' => '<div class="disclaimer" style="background: #fff3cd; padding: 15px; margin-bottom: 20px; border-left: 4px solid #ffc107;">' .
                         '<strong>⚠ Disclaimer:</strong> Product specifications and prices are subject to change without notice.' .
                         '</div>',
                'priority' => 10
            ])
        ->register();

    // Rule 2: Add reading time to blog posts
    Rules::create('add_blog_reading_time', 'wp')
        ->on('the_content', 10)
        ->when()
            ->is_singular('post')
            ->post_type('post')
        ->then()
            ->custom('add_reading_time')
        ->register();

    // Rule 3: Add CTA to pages for non-members
    Rules::create('membership_cta', 'wp')
        ->on('the_content', 10)
        ->when()
            ->is_singular('page')
            ->is_user_logged_in(false)
        ->then()
            ->custom('append_content', [
                'text' => '<div class="membership-cta" style="background: #007cba; color: white; padding: 30px; margin-top: 30px; text-align: center; border-radius: 5px;">' .
                         '<h3 style="color: white; margin-top: 0;">Enjoying this content?</h3>' .
                         '<p>Join our community to access exclusive content and features!</p>' .
                         '<a href="/register" style="background: white; color: #007cba; padding: 12px 30px; text-decoration: none; border-radius: 3px; display: inline-block; font-weight: bold;">Join Now</a>' .
                         '</div>',
                'priority' => 20
            ])
        ->register();

    // Rule 4: Add author bio to posts
    Rules::create('author_bio', 'wp')
        ->on('the_content', 10)
        ->when()
            ->is_singular('post')
            ->post_type('post')
        ->then()
            ->custom('append_content', [
                'text' => '<?php
                    $author_id = get_the_author_meta("ID");
                    $author_name = get_the_author();
                    $author_bio = get_the_author_meta("description");
                    $author_url = get_author_posts_url($author_id);

                    echo "<div class=\"author-bio\" style=\"background: #f9f9f9; padding: 20px; margin-top: 30px; border-radius: 5px;\">";
                    echo "<h4>About " . esc_html($author_name) . "</h4>";
                    echo "<p>" . esc_html($author_bio) . "</p>";
                    echo "<a href=\"" . esc_url($author_url) . "\">View all posts by " . esc_html($author_name) . "</a>";
                    echo "</div>";
                ?>',
                'priority' => 30
            ])
        ->register();

}, 1);
```

---

## User Tracking and Analytics

Track user behavior and log analytics events.

```php
/**
 * Plugin Name: MilliRules Analytics
 * Description: User tracking and analytics
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register tracking actions
    Rules::register_action('track_page_view', function($args, Context $context) {
        global $wpdb;

        $table = $wpdb->prefix . 'page_views';
        $user_id = $context->get('user.id', 0) ?? 0;
        $url = $context->get('request.uri', '') ?? '';
        $ip = $context['request']['ip'] ?? '';
        $user_agent = $context['request']['user_agent'] ?? '';

        $wpdb->insert($table, [
            'user_id' => $user_id,
            'url' => $url,
            'ip' => $ip,
            'user_agent' => $user_agent,
            'viewed_at' => current_time('mysql'),
        ]);
    });

    Rules::register_action('track_event', function($args, Context $context) {
        global $wpdb;

        $table = $wpdb->prefix . 'analytics_events';
        $event_type = $args['event_type'] ?? 'pageview';
        $event_data = $args['event_data'] ?? [];
        $user_id = $context->get('user.id', 0) ?? 0;

        $wpdb->insert($table, [
            'user_id' => $user_id,
            'event_type' => $event_type,
            'event_data' => json_encode($event_data),
            'created_at' => current_time('mysql'),
        ]);
    });

    Rules::register_action('update_user_activity', function($args, Context $context) {
        $user_id = $context->get('user.id', 0) ?? 0;

        if ($user_id) {
            update_user_meta($user_id, 'last_activity', time());
            update_user_meta($user_id, 'total_visits',
                (int) get_user_meta($user_id, 'total_visits', true) + 1
            );
        }
    });

    // Rule 1: Track all page views
    Rules::create('track_all_pages', 'wp')
        ->on('wp', 10)
        ->when()->request_url('*')
        ->then()->custom('track_page_view')
        ->register();

    // Rule 2: Track user activity
    Rules::create('track_user_activity', 'wp')
        ->on('wp', 10)
        ->when()->is_user_logged_in()
        ->then()->custom('update_user_activity')
        ->register();

    // Rule 3: Track important pages
    Rules::create('track_important_pages', 'wp')
        ->on('wp', 10)
        ->when()
            ->request_url(['/pricing', '/contact', '/checkout'], 'IN')
        ->then()
            ->custom('track_event', [
                'event_type' => 'important_page_view',
                'event_data' => [
                    'page' => '{request.uri}',
                    'referrer' => '{request.referer}'
                ]
            ])
        ->register();

    // Rule 4: Track downloads
    Rules::create('track_downloads', 'wp')
        ->on('wp', 10)
        ->when()
            ->request_url('/downloads/*', 'LIKE')
            ->request_param('file')
        ->then()
            ->custom('track_event', [
                'event_type' => 'file_download',
                'event_data' => [
                    'file' => '{param.file}',
                    'user_id' => '{user.id}'
                ]
            ])
        ->register();

}, 1);

// Create tables on plugin activation
register_activation_hook(__FILE__, function() {
    global $wpdb;

    $charset_collate = $wpdb->get_charset_collate();

    $sql1 = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}page_views (
        id bigint(20) NOT NULL AUTO_INCREMENT,
        user_id bigint(20) NOT NULL DEFAULT 0,
        url varchar(255) NOT NULL,
        ip varchar(45) NOT NULL,
        user_agent text,
        viewed_at datetime NOT NULL,
        PRIMARY KEY  (id),
        KEY user_id (user_id),
        KEY viewed_at (viewed_at)
    ) $charset_collate;";

    $sql2 = "CREATE TABLE IF NOT EXISTS {$wpdb->prefix}analytics_events (
        id bigint(20) NOT NULL AUTO_INCREMENT,
        user_id bigint(20) NOT NULL DEFAULT 0,
        event_type varchar(100) NOT NULL,
        event_data text,
        created_at datetime NOT NULL,
        PRIMARY KEY  (id),
        KEY user_id (user_id),
        KEY event_type (event_type),
        KEY created_at (created_at)
    ) $charset_collate;";

    require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
    dbDelta($sql1);
    dbDelta($sql2);
});
```

---

## API Rate Limiting

Implement rate limiting for API endpoints.

```php
/**
 * Plugin Name: MilliRules API Rate Limiter
 * Description: Rate limiting for API endpoints
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register rate limit condition
    Rules::register_condition('within_rate_limit', function($args, Context $context) {
        $ip = $context['request']['ip'] ?? '';
        $limit = $args['limit'] ?? 60; // Requests per minute
        $period = $args['period'] ?? 60; // Seconds

        $cache_key = 'rate_limit_' . md5($ip);
        $current = get_transient($cache_key) ?: 0;

        if ($current >= $limit) {
            return false; // Rate limit exceeded
        }

        // Increment counter
        set_transient($cache_key, $current + 1, $period);

        return true;
    });

    // Register rate limit response action
    Rules::register_action('send_rate_limit_response', function($args, Context $context) {
        $retry_after = $args['retry_after'] ?? 60;

        status_header(429);
        header('Content-Type: application/json');
        header('Retry-After: ' . $retry_after);

        echo json_encode([
            'error' => 'Rate limit exceeded',
            'message' => 'Too many requests. Please try again later.',
            'retry_after' => $retry_after
        ]);

        exit;
    });

    // Rule 1: Rate limit API endpoints
    Rules::create('api_rate_limit', 'php')
        ->order(5)
        ->when()
            ->request_url('/wp-json/*', 'LIKE')
            ->custom('within_rate_limit', ['limit' => 60, 'period' => 60])
            ->match_none() // If NOT within limit
        ->then()
            ->custom('send_rate_limit_response', ['retry_after' => 60])
        ->register();

    // Rule 2: Stricter limits for authentication endpoints
    Rules::create('auth_rate_limit', 'php')
        ->order(3)
        ->when()
            ->request_url('/wp-json/*/auth/*', 'LIKE')
            ->custom('within_rate_limit', ['limit' => 10, 'period' => 60])
            ->match_none()
        ->then()
            ->custom('send_rate_limit_response', ['retry_after' => 300])
        ->register();

    // Execute early
    MilliRules::execute_rules(['PHP']);

}, 1);
```

---

## Feature Flags

Implement dynamic feature flags.

```php
/**
 * Plugin Name: MilliRules Feature Flags
 * Description: Dynamic feature flag system
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register feature flag condition
    Rules::register_condition('feature_enabled', function($args, Context $context) {
        $feature = $args['feature'] ?? '';
        return get_option("feature_flag_{$feature}", false);
    });

    // Register feature actions
    Rules::register_action('enable_feature', function($args, Context $context) {
        $feature = $args['feature'] ?? '';

        if ($feature) {
            // Mark feature as enabled
            update_option("feature_enabled_{$feature}", true);

            // Load feature code
            $feature_file = plugin_dir_path(__FILE__) . "features/{$feature}.php";
            if (file_exists($feature_file)) {
                require_once $feature_file;
            }
        }
    });

    // Rule 1: Enable beta features for admins in dev
    Rules::create('enable_beta_for_admins', 'wp')
        ->when()
            ->is_user_logged_in()
            ->custom('user_has_role', ['role' => 'administrator'])
            ->constant('WP_ENVIRONMENT_TYPE', ['local', 'development'], 'IN')
        ->then()
            ->custom('enable_feature', ['feature' => 'beta_dashboard'])
            ->custom('enable_feature', ['feature' => 'advanced_editor'])
        ->register();

    // Rule 2: Enable features based on flags
    Rules::create('load_new_checkout', 'wp')
        ->when()->custom('feature_enabled', ['feature' => 'new_checkout'])
        ->then()->custom('enable_feature', ['feature' => 'new_checkout'])
        ->register();

    // Rule 3: Gradual rollout (10% of users)
    Rules::register_condition('in_rollout_group', function($args, Context $context) {
        $percentage = $args['percentage'] ?? 10;
        $user_id = $context->get('user.id', 0) ?? 0;

        // Consistent assignment based on user ID
        return ($user_id % 100) < $percentage;
    });

    Rules::create('gradual_rollout', 'wp')
        ->when()
            ->is_user_logged_in()
            ->custom('in_rollout_group', ['percentage' => 10])
        ->then()
            ->custom('enable_feature', ['feature' => 'experimental_ui'])
        ->register();

}, 1);

// Helper function to check if feature is enabled
function is_feature_enabled($feature) {
    return get_option("feature_enabled_{$feature}", false);
}
```

---

## WooCommerce Integration

Complete WooCommerce conditional logic example.

```php
/**
 * Plugin Name: MilliRules WooCommerce Integration
 * Description: Advanced WooCommerce rules
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    if (!class_exists('WooCommerce')) {
        return;
    }

    MilliRules::init();

    // Register WooCommerce conditions
    Rules::register_condition('cart_total', function($args, Context $context) {
        $minimum = $args['minimum'] ?? 0;
        $operator = $args['operator'] ?? '>=';
        $cart_total = WC()->cart->get_total('edit');

        return BaseCondition::compare_values($cart_total, $minimum, $operator);
    });

    Rules::register_condition('cart_item_count', function($args, Context $context) {
        $count = $args['count'] ?? 1;
        $operator = $args['operator'] ?? '>=';
        $cart_count = WC()->cart->get_cart_contents_count();

        return BaseCondition::compare_values($cart_count, $count, $operator);
    });

    Rules::register_condition('has_product_category_in_cart', function($args, Context $context) {
        $category_slug = $args['category'] ?? '';

        foreach (WC()->cart->get_cart() as $cart_item) {
            $product_id = $cart_item['product_id'];
            if (has_term($category_slug, 'product_cat', $product_id)) {
                return true;
            }
        }

        return false;
    });

    // Register WooCommerce actions
    Rules::register_action('apply_discount', function($args, Context $context) {
        $coupon = $args['coupon'] ?? '';

        if ($coupon && !WC()->cart->has_discount($coupon)) {
            WC()->cart->apply_coupon($coupon);
            wc_add_notice('Discount applied automatically!', 'success');
        }
    });

    Rules::register_action('add_cart_notice', function($args, Context $context) {
        $message = $args['message'] ?? '';
        $type = $args['type'] ?? 'notice';

        if ($message) {
            wc_add_notice($message, $type);
        }
    });

    // Rule 1: Free shipping for orders over $50
    Rules::create('free_shipping_notice', 'wp')
        ->on('woocommerce_before_cart', 10)
        ->when()
            ->custom('cart_total', ['minimum' => 50])
        ->then()
            ->custom('add_cart_notice', [
                'message' => '🎉 You qualify for free shipping!',
                'type' => 'success'
            ])
        ->register();

    // Rule 2: Auto-apply discount for bulk orders
    Rules::create('bulk_order_discount', 'wp')
        ->on('woocommerce_before_calculate_totals', 10)
        ->when()
            ->custom('cart_item_count', ['count' => 10, 'operator' => '>='])
        ->then()
            ->custom('apply_discount', ['coupon' => 'BULK10'])
        ->register();

    // Rule 3: Category-specific promotion
    Rules::create('electronics_promo', 'wp')
        ->on('woocommerce_before_cart', 10)
        ->when()
            ->custom('has_product_category_in_cart', ['category' => 'electronics'])
            ->custom('cart_total', ['minimum' => 100, 'operator' => '>='])
        ->then()
            ->custom('apply_discount', ['coupon' => 'ELECTRONICS15'])
            ->custom('add_cart_notice', [
                'message' => '15% discount applied to your electronics purchase!',
                'type' => 'success'
            ])
        ->register();

    // Rule 4: Minimum order notice
    Rules::create('minimum_order_notice', 'wp')
        ->on('woocommerce_before_cart', 10)
        ->when()
            ->custom('cart_total', ['minimum' => 25, 'operator' => '<'])
        ->then()
            ->custom('add_cart_notice', [
                'message' => 'Add $' . (25 - WC()->cart->get_total('edit')) . ' more to meet our minimum order amount.',
                'type' => 'notice'
            ])
        ->register();

}, 1);
```

---

## Membership System

Complete membership system with tiers and access control.

```php
/**
 * Plugin Name: MilliRules Membership System
 * Description: Complete membership tier system
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;

add_action('init', function() {
    MilliRules::init();

    // Register membership conditions
    Rules::register_condition('has_membership_level', function($args, Context $context) {
        $required_level = $args['level'] ?? 'free';
        $user_id = $context->get('user.id', 0) ?? 0;

        if (!$user_id) {
            return $required_level === 'free';
        }

        $user_level = get_user_meta($user_id, 'membership_level', true) ?: 'free';

        $levels = ['free' => 0, 'basic' => 1, 'premium' => 2, 'enterprise' => 3];

        return ($levels[$user_level] ?? 0) >= ($levels[$required_level] ?? 0);
    });

    Rules::register_condition('membership_expired', function($args, Context $context) {
        $user_id = $context->get('user.id', 0) ?? 0;

        if (!$user_id) {
            return false;
        }

        $expiry = get_user_meta($user_id, 'membership_expiry', true);

        if (!$expiry) {
            return false; // No expiry = lifetime
        }

        return time() > $expiry;
    });

    // Register membership actions
    Rules::register_action('restrict_content', function($args, Context $context) {
        $message = $args['message'] ?? 'This content requires a membership.';
        $cta_url = $args['cta_url'] ?? home_url('/membership');

        add_filter('the_content', function($content) use ($message, $cta_url) {
            $restricted = '<div class="membership-required" style="background: #f9f9f9; padding: 30px; text-align: center; border: 2px solid #ddd; border-radius: 5px;">';
            $restricted .= '<h3>🔒 Members Only Content</h3>';
            $restricted .= '<p>' . esc_html($message) . '</p>';
            $restricted .= '<a href="' . esc_url($cta_url) . '" class="button" style="background: #007cba; color: white; padding: 12px 30px; text-decoration: none; border-radius: 3px; display: inline-block;">Upgrade Membership</a>';
            $restricted .= '</div>';

            return $restricted;
        });
    });

    Rules::register_action('show_membership_badge', function($args, Context $context) {
        $user_id = $context->get('user.id', 0) ?? 0;
        $level = get_user_meta($user_id, 'membership_level', true) ?: 'free';

        $badges = [
            'free' => '⚪',
            'basic' => '🔵',
            'premium' => '⭐',
            'enterprise' => '👑'
        ];

        add_filter('the_author', function($author) use ($level, $badges) {
            return $author . ' ' . ($badges[$level] ?? '');
        });
    });

    // Rule 1: Restrict premium content
    Rules::create('restrict_premium_posts', 'wp')
        ->on('the_content', 10)
        ->when()
            ->post_type('post')
            ->custom('post_meta', ['key' => 'membership_required', 'value' => 'premium'])
            ->custom('has_membership_level', ['level' => 'premium'])
            ->match_none()
        ->then()
            ->custom('restrict_content', [
                'message' => 'This premium content is available to Premium and Enterprise members.',
                'cta_url' => home_url('/upgrade-to-premium')
            ])
        ->register();

    // Rule 2: Expired membership redirect
    Rules::create('expired_membership_redirect', 'wp')
        ->on('template_redirect', 5)
        ->when()
            ->is_user_logged_in()
            ->custom('membership_expired')
            ->request_url('/members/*', 'LIKE')
        ->then()
            ->custom('redirect_to', [
                'url' => home_url('/renew-membership'),
                'message' => 'Your membership has expired. Please renew to access member content.'
            ])
        ->register();

    // Rule 3: Show membership badge on comments
    Rules::create('show_member_badge', 'wp')
        ->on('comment_text', 10)
        ->when()->is_user_logged_in()
        ->then()->custom('show_membership_badge')
        ->register();

    // Rule 4: Member-only downloads
    Rules::create('protect_downloads', 'wp')
        ->on('template_redirect', 10)
        ->when()
            ->request_url('/downloads/*', 'LIKE')
            ->custom('has_membership_level', ['level' => 'basic'])
            ->match_none()
        ->then()
            ->custom('redirect_to', [
                'url' => home_url('/membership'),
                'message' => 'Membership required to access downloads.'
            ])
        ->register();

}, 1);
```

---

## Summary

These examples demonstrate:

✅ **Complete implementations** - Ready-to-use code
✅ **Real-world scenarios** - Common use cases
✅ **Best practices** - Proper error handling and validation
✅ **WordPress integration** - Hook usage and compatibility
✅ **Advanced patterns** - Complex condition logic and actions

## Next Steps

- **[Getting Started](/docs/millirules/01-getting-started/01-introduction)** - Begin your MilliRules journey
- **[Core Concepts](/docs/millirules/02-core-concepts/01-concepts)** - Understand the fundamentals
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete method documentation

---

**Have questions or suggestions?** Visit the [MilliRules GitHub repository](https://github.com/millipress/millirules) for support and contributions.

---

Canonical: https://www.millipress.com/docs/millirules/04-advanced/02-advanced-patterns

---
title: 'Advanced Patterns'
description: 'Advanced MilliRules techniques: early execution before WordPress loads, performance-ordered conditions, debugging with execution stats, and testing rules.'
menu_order: 20
---

# Advanced Patterns

This guide covers advanced techniques for optimizing performance, debugging issues, implementing complex rule patterns, and integrating MilliRules deeply into your applications.

## Early Execution

Early execution runs rules before WordPress fully loads, enabling caching systems, redirects, and performance optimizations.

### MU-Plugin Early Execution

```php
/**
 * Plugin Name: MilliRules Early Execution
 * Description: Runs MilliRules before WordPress loads
 */

require_once WPMU_PLUGIN_DIR . '/millirules-vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;
use MilliRules\Context;

// Initialize with PHP package only (WordPress not loaded yet)
MilliRules::init(['PHP']);

// Register early execution rules
Rules::create('early_cache_check', 'php')
    ->when()
        ->request_url('/api/*')
        ->request_method('GET')
    ->then()
        ->custom('check_cache')
        ->custom('early_exit_if_cached')
    ->register();

// Execute early rules
$result = MilliRules::execute_rules(['PHP']);
```

### Custom Cache Integration

```php
Rules::register_action('check_cache', function($args, Context $context) {
    $cache_key = 'page_' . md5($context->get('request.uri', '') ?? '');
    $cached = get_transient($cache_key);

    if ($cached !== false) {
        // Send cached response
        header('Content-Type: text/html; charset=UTF-8');
        header('X-Cache: HIT');
        echo $cached;
        exit;
    }
});

Rules::register_action('save_to_cache', function($args, Context $context) {
    $cache_key = 'page_' . md5($context->get('request.uri', '') ?? '');
    $duration = $args['duration'] ?? 3600;

    ob_start(function($buffer) use ($cache_key, $duration) {
        set_transient($cache_key, $buffer, $duration);
        return $buffer;
    });
});

Rules::create('api_caching')
    ->when()
        ->request_url('/api/*')
        ->request_method('GET')
    ->then()
        ->custom('check_cache') // Check first
        ->custom('save_to_cache', ['duration' => 3600]) // Save if not cached
    ->register();
```

---

## Performance Optimization

### Rule Ordering for Performance

```php
// Place most restrictive/fastest conditions first
Rules::create('optimized_rule')
    ->order(10)
    ->when()
        // Fast checks first
        ->request_method('POST')                    // Very fast
        ->request_url('/api/specific-endpoint')     // Fast
        ->cookie('session_id')                      // Fast

        // Slower checks last
        ->custom('expensive_validation')            // Slow
    ->then()
        ->custom('process_request')
    ->register();
```

### Lazy Loading

```php
// Load rules only when needed
add_action('init', function() {
    MilliRules::init();

    // Load admin rules only in admin
    if (is_admin()) {
        require_once __DIR__ . '/rules/admin-rules.php';
    }

    // Load frontend rules only on frontend
    if (!is_admin()) {
        require_once __DIR__ . '/rules/frontend-rules.php';
    }

    // Load API rules only for API requests
    if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/api/')) {
        require_once __DIR__ . '/rules/api-rules.php';
    }
}, 1);
```

---

## Debugging Strategies

### Debug Logging

```php
// Enable comprehensive debugging
define('MILLIRULES_DEBUG', true);

Rules::register_action('debug_log', function($args, Context $context) {
    if (!defined('MILLIRULES_DEBUG') || !MILLIRULES_DEBUG) {
        return;
    }

    $message = $args['message'] ?? '';
    $data = $args['data'] ?? [];

    error_log('=== MilliRules Debug ===');
    error_log('Message: ' . $message);
    error_log('Data: ' . print_r($data, true));
    error_log('======================');
});

// Add debug actions to rules
Rules::create('debuggable_rule')
    ->when()
        ->request_url('/api/*')
    ->then()
        ->custom('debug_log', [
            'message' => 'API request started',
            'data' => ['url' => '{request.uri}']
        ])
        ->custom('process_api')
        ->custom('debug_log', [
            'message' => 'API request completed'
        ])
    ->register();
```

### Execution Statistics

```php
// Track execution statistics
$result = MilliRules::execute_rules();

error_log('=== Execution Statistics ===');
error_log('Rules processed: ' . $result['rules_processed']);
error_log('Rules skipped: ' . $result['rules_skipped']);
error_log('Rules matched: ' . $result['rules_matched']);
error_log('Actions executed: ' . $result['actions_executed']);
error_log('===========================');

// Performance tracking
$start_time = microtime(true);
$start_memory = memory_get_usage();

$result = MilliRules::execute_rules();

$execution_time = microtime(true) - $start_time;
$memory_used = memory_get_usage() - $start_memory;

error_log("Execution time: {$execution_time}s");
error_log("Memory used: " . size_format($memory_used));
```

### Debug Conditions

```php
Rules::register_condition('debug_context', function($args, Context $context) {
    error_log('=== Context Debug ===');
    error_log('Full context: ' . print_r($context, true));
    error_log('===================');
    return true; // Always matches
});

Rules::create('debug_rule')
    ->when()
        ->custom('debug_context')
        ->your_actual_conditions()
    ->then()
        ->your_actions()
    ->register();
```

---

## Complex Rule Patterns

### Conditional Rule Groups

```php
// Environment-specific rules
$environment = wp_get_environment_type();

if ($environment === 'local') {
    // Local development rules
    Rules::create('local_debug')
        ->when()->constant('WP_DEBUG', true, '=')
        ->then()->custom('enable_verbose_logging')
        ->register();

    Rules::create('local_logging')
        ->when()->request_url('*')
        ->then()->custom('log_all_requests')
        ->register();

} elseif ($environment === 'staging') {
    // Staging environment rules
    Rules::create('staging_monitoring')
        ->when()->request_url('*')
        ->then()->custom('track_staging_metrics')
        ->register();

} elseif ($environment === 'production') {
    // Production rules
    Rules::create('prod_caching')
        ->when()->request_url('/api/*')
        ->then()->custom('enable_aggressive_cache')
        ->register();

    Rules::create('prod_security')
        ->when()->request_method('POST')
        ->then()->custom('enhanced_security_check')
        ->register();
}
```

### Dynamic Rule Generation

```php
// Generate rules from configuration
$protected_endpoints = [
    '/api/users' => ['GET', 'POST'],
    '/api/posts' => ['GET', 'POST', 'PUT', 'DELETE'],
    '/api/settings' => ['GET', 'PUT'],
];

foreach ($protected_endpoints as $endpoint => $methods) {
    $rule_id = 'protect_' . sanitize_title($endpoint);

    Rules::create($rule_id)
        ->when()
            ->request_url($endpoint)
            ->request_method($methods, 'IN')
            ->is_user_logged_in(false) // Not logged in
        ->then()
            ->custom('send_401_unauthorized')
        ->register();
}
```

---

## Package Filtering

Execute rules with specific packages only.

### Selective Package Execution

```php
// Execute only PHP rules (before WordPress loads)
$php_result = MilliRules::execute_rules(['PHP']);

// Execute only WordPress rules
$wp_result = MilliRules::execute_rules(['WP']);

// Execute with custom packages
$custom_result = MilliRules::execute_rules(['PHP', 'Custom']);
```

### Context-Aware Package Selection

```php
add_action('init', function() {
    MilliRules::init();

    // Determine which packages to use
    $packages = ['PHP'];

    if (function_exists('add_action')) {
        $packages[] = 'WP';
    }

    if (class_exists('WooCommerce')) {
        $packages[] = 'WooCommerce';
    }

    // Execute with selected packages
    $result = MilliRules::execute_rules($packages);
}, 5);
```

---

## Context Manipulation

### Extending Context

```php
// Add custom data to context before execution
add_filter('millirules_context', function(Context $context) {
    $context['custom'] = [
        'api_key' => get_option('my_api_key'),
        'feature_flags' => get_option('feature_flags', []),
        'site_config' => get_site_config(),
    ];

    return $context;
});
```

### Context Transformation

```php
// Transform context for specific rules
Rules::register_action('with_transformed_context', function($args, Context $context) {
    // Add computed values
    $context['computed'] = [
        'is_business_hours' => check_business_hours(),
        'user_tier' => calculate_user_tier($context),
        'request_complexity' => analyze_request($context),
    ];

    // Execute sub-action with transformed context
    $callback = $args['callback'] ?? null;
    if (is_callable($callback)) {
        $callback($context);
    }
});
```

---

## Error Handling

### Graceful Degradation

```php
Rules::register_action('safe_api_call', function($args, Context $context) {
    try {
        $response = wp_remote_post($args['url'], [
            'body' => json_encode($args['data']),
            'headers' => ['Content-Type' => 'application/json'],
            'timeout' => 10,
        ]);

        if (is_wp_error($response)) {
            throw new Exception($response->get_error_message());
        }

        $status = wp_remote_retrieve_response_code($response);
        if ($status >= 400) {
            throw new Exception("API returned status {$status}");
        }

        // Success
        return json_decode(wp_remote_retrieve_body($response), true);

    } catch (Exception $e) {
        error_log('API Error: ' . $e->getMessage());

        // Fallback behavior
        $fallback = $args['fallback'] ?? null;
        if (is_callable($fallback)) {
            return $fallback($context);
        }

        return null;
    }
});
```

### Error Notification

```php
Rules::register_action('notify_on_error', function($args, Context $context) {
    try {
        // Risky operation
        perform_critical_operation($config);

    } catch (Exception $e) {
        // Log error
        error_log('Critical error: ' . $e->getMessage());

        // Notify admin
        wp_mail(
            get_option('admin_email'),
            'MilliRules Critical Error',
            "Error: {$e->getMessage()}\n\nContext: " . print_r($context, true)
        );

        // Store error for admin dashboard
        update_option('millirules_last_error', [
            'message' => $e->getMessage(),
            'time' => time(),
            'context' => $context,
        ]);
    }
});
```

---

## Testing Strategies

### Unit Testing Rules

```php
class MilliRulesTest extends WP_UnitTestCase {
    public function setUp(): void {
        parent::setUp();
        MilliRules::init();
    }

    public function test_api_cache_rule() {
        // Register test action
        Rules::register_action('test_cache', function($args, Context $context) {
            update_option('test_cache_called', true);
        });

        // Create rule
        Rules::create('test_api_cache')
            ->when()
                ->request_url('/api/test')
                ->request_method('GET')
            ->then()
                ->custom('test_cache')
            ->register();

        // Simulate request
        $_SERVER['REQUEST_URI'] = '/api/test';
        $_SERVER['REQUEST_METHOD'] = 'GET';

        // Execute
        $result = MilliRules::execute_rules();

        // Assert
        $this->assertEquals(1, $result['rules_matched']);
        $this->assertTrue(get_option('test_cache_called'));
    }
}
```

### Integration Testing

```php
function test_complete_workflow() {
    // Setup
    MilliRules::init();

    $executed_actions = [];

    Rules::register_action('track_execution', function($args, Context $context) use (&$executed_actions) {
        $executed_actions[] = $args['step'];
    });

    // Create multi-step rule
    Rules::create('workflow_test')
        ->when()->request_url('/test-workflow')
        ->then()
            ->custom('track_execution', ['step' => 'validate'])
            ->custom('track_execution', ['step' => 'process'])
            ->custom('track_execution', ['step' => 'complete'])
        ->register();

    // Execute
    $_SERVER['REQUEST_URI'] = '/test-workflow';
    MilliRules::execute_rules();

    // Verify execution order
    assert($executed_actions === ['validate', 'process', 'complete']);

    echo "Workflow test passed!\n";
}
```

---

## Best Practices Summary

### 1. Performance

- Order conditions from fastest to slowest
- Cache expensive operations
- Use early execution for caching/redirects
- Load rules only when needed

### 2. Debugging

- Enable debug logging in development
- Track execution statistics
- Use debug conditions to inspect context
- Monitor memory and execution time

### 3. Maintainability

- Use descriptive rule IDs
- Group related rules by feature
- Document complex logic
- Use consistent naming conventions

### 4. Error Handling

- Always validate input
- Provide fallback behaviors
- Log errors appropriately
- Notify admins of critical issues

### 5. Testing

- Write unit tests for custom conditions/actions
- Test complete rule workflows
- Test with different package combinations
- Simulate various environments

---

## Next Steps

- **[WordPress Integration Guide](/docs/millirules/04-advanced/03-wordpress-integration)** - WordPress-specific patterns
- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete method documentation
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - Complete working examples

---

**Ready to explore WordPress integration?** Continue to [WordPress Integration Guide](/docs/millirules/04-advanced/03-wordpress-integration) for WordPress-specific techniques and patterns.

---

Canonical: https://www.millipress.com/docs/millirules/04-advanced/03-wordpress-integration

---
title: 'WordPress Integration Guide'
description: 'Integrate the MilliRules PHP rules engine with WordPress: hook-based execution, is_* conditional tags, hook arguments in context, and WooCommerce patterns.'
menu_order: 30
---

# WordPress Integration Guide

MilliRules integrates seamlessly with WordPress, providing powerful rule-based logic for plugins, themes, and WordPress applications. This guide covers WordPress-specific features, hooks, patterns, and best practices.

## WordPress Package Overview

The WordPress package extends MilliRules with WordPress-specific functionality:

- **WordPress Conditions** - Post types, user roles, query flags, etc.
- **Hook Integration** - Automatic WordPress hook registration
- **WordPress Context** - Post, user, and query data
- **Template Integration** - Content filtering and modification

##WordPress Initialization

### Basic Initialization

```php
/**
 * Plugin Name: My MilliRules Plugin
 * Description: Custom rules for WordPress
 */

require_once __DIR__ . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;
use MilliRules\Context;

add_action('init', function() {
    // Initialize MilliRules (auto-loads WordPress package)
    MilliRules::init();

    // Register your rules here
    register_custom_rules();
}, 1); // Priority 1 for early initialization
```

### Theme Integration

```php
// functions.php

require_once get_template_directory() . '/vendor/autoload.php';

use MilliRules\MilliRules;
use MilliRules\Rules;
use MilliRules\Context;

add_action('after_setup_theme', function() {
    MilliRules::init();

    // Theme-specific rules
    require_once get_template_directory() . '/rules/content-rules.php';
    require_once get_template_directory() . '/rules/layout-rules.php';
}, 1);
```

---

## WordPress Hooks

WordPress rules can execute on specific hooks.

### Common Hooks

#### Initialization Hooks

```php
// plugins_loaded - Very early, plugins just loaded
Rules::create('early_setup')
    ->on('plugins_loaded', 10)
    ->when()->constant('WP_DEBUG', true)
    ->then()->custom('enable_debug_features')
    ->register();

// init - Standard initialization
Rules::create('standard_setup')
    ->on('init', 10)
    ->when()->is_user_logged_in()
    ->then()->custom('setup_user_features')
    ->register();

// wp_loaded - WordPress fully loaded
Rules::create('late_setup')
    ->on('wp_loaded', 10)
    ->when()->constant('DOING_AJAX', true)
    ->then()->custom('setup_ajax_handlers')
    ->register();
```

#### Frontend Hooks

```php
// wp - Main query has been executed
Rules::create('after_query')
    ->on('wp', 10)
    ->when()->is_singular('post')
    ->then()->custom('track_post_view')
    ->register();

// template_redirect - Before template is loaded
Rules::create('before_template')
    ->on('template_redirect', 10)
    ->when()
        ->request_param('preview', 'true')
        ->is_user_logged_in(false)
    ->then()
        ->custom('redirect_to_login')
    ->register();

// wp_enqueue_scripts - Enqueue frontend assets
Rules::create('conditional_assets')
    ->on('wp_enqueue_scripts', 10)
    ->when()->is_singular(['post', 'page'])
    ->then()->custom('enqueue_reading_mode_scripts')
    ->register();
```

#### Admin Hooks

```php
// admin_init - Admin initialization
Rules::create('admin_setup')
    ->on('admin_init', 10)
    ->when()->is_user_logged_in()
    ->then()->custom('setup_admin_features')
    ->register();

// admin_menu - Add admin menu items
Rules::create('conditional_menu')
    ->on('admin_menu', 10)
    ->when()
        ->is_user_logged_in()
        ->custom('user_has_permission', ['permission' => 'manage_settings'])
    ->then()
        ->custom('add_settings_menu')
    ->register();

// admin_notices - Display admin notices
Rules::create('warning_notice')
    ->on('admin_notices', 10)
    ->when()
        ->constant('WP_DEBUG', true)
        ->constant('WP_ENVIRONMENT_TYPE', 'production')
    ->then()
        ->custom('show_debug_warning')
    ->register();
```

#### Content Hooks

```php
// the_content - Filter post content
Rules::create('add_content_disclaimer')
    ->on('the_content', 10)
    ->when()
        ->is_singular('post')
        ->post_type('product')
    ->then()
        ->custom('prepend_disclaimer')
    ->register();

// the_title - Filter post title
Rules::create('modify_title')
    ->on('the_title', 10)
    ->when()
        ->is_singular('post')
        ->custom('is_featured_post')
    ->then()
        ->custom('add_featured_badge_to_title')
    ->register();
```

#### Save Hooks

```php
// save_post - After post is saved
Rules::create('post_save_notification')
    ->on('save_post', 10)
    ->when()
        ->post_type('post')
        ->custom('post_status_changed_to_published')
    ->then()
        ->custom('send_publication_notification')
    ->register();

// wp_insert_post - When post is created/updated
Rules::create('track_post_creation')
    ->on('wp_insert_post', 10)
    ->when()->post_type(['post', 'page'])
    ->then()->custom('log_post_creation')
    ->register();
```

---

## Accessing Hook Arguments

Many WordPress hooks pass arguments to their callbacks. MilliRules automatically captures these arguments and makes them available in the execution context under `$context['wp']['hook']`.

### Hook Context Structure

When a WordPress hook fires with arguments, they're added to the context:

```php
$context = [
    'request' => [...],
    'wp' => [
        'post' => [...],
        'user' => [...],
        'query' => [...],            // Query variables (post_type, paged, s, etc.)
        'constants' => [...],
        'hook' => [
            'name' => 'save_post',      // The hook name
            'args' => [                  // Array of hook arguments
                0 => 123,                // First argument (post ID)
                1 => WP_Post{...},       // Second argument (post object)
                2 => true,               // Third argument (update flag)
            ],
        ],
    ],
];
```

> [!NOTE]
> The 'query' context contains WordPress query variables from `$wp_query->query_vars`.
> For query conditionals (is_singular, is_home, etc.), use the dedicated `is_*` condition
> methods instead of checking context values.

### Common Hook Signatures

Different WordPress hooks pass different arguments:

#### save_post Hook
```php
// WordPress signature: do_action('save_post', $post_id, $post, $update)
Rules::create('handle_post_save')
    ->on('save_post')
    ->when()->post_type('post')
    ->then()->custom('process_save', function(Context $context) {
        $post_id = $context['wp']['hook']['args'][0] ?? null;
        $post    = $context['wp']['hook']['args'][1] ?? null;
        $update  = $context['wp']['hook']['args'][2] ?? false;

        if ($update) {
            error_log("Updated post: {$post->post_title} (ID: {$post_id})");
        } else {
            error_log("Created new post: {$post->post_title} (ID: {$post_id})");
        }
    })
    ->register();
```

#### comment_post Hook
```php
// WordPress signature: do_action('comment_post', $comment_id, $approved)
Rules::create('new_comment_notification')
    ->on('comment_post')
    ->then()->custom('notify_admin', function(Context $context) {
        $comment_id = $context['wp']['hook']['args'][0] ?? null;
        $approved   = $context['wp']['hook']['args'][1] ?? 0;

        if ($approved === 1) {
            wp_mail(
                get_option('admin_email'),
                'New Comment Approved',
                "Comment ID: {$comment_id}"
            );
        }
    })
    ->register();
```

#### transition_post_status Hook
```php
// WordPress signature: do_action('transition_post_status', $new_status, $old_status, $post)
Rules::create('publish_notification')
    ->on('transition_post_status')
    ->when()->custom('status_changed_to_publish', function(Context $context) {
        $new_status = $context['wp']['hook']['args'][0] ?? '';
        $old_status = $context['wp']['hook']['args'][1] ?? '';

        return $new_status === 'publish' && $old_status !== 'publish';
    })
    ->then()->custom('send_notification', function(Context $context) {
        $post = $context['wp']['hook']['args'][2] ?? null;

        if ($post) {
            error_log("Post published: {$post->post_title}");
        }
    })
    ->register();
```

### Using Hook Arguments in Conditions or Actions

Create reusable conditions or actions that access hook arguments:

```php
// Register a condition that checks post ID range
Rules::register_condition('post_id_in_range', function($args, Context $context) {
    $post_id = $context['wp']['hook']['args'][0] ?? 0;
    $min = $args['min'] ?? 0;
    $max = $args['max'] ?? PHP_INT_MAX;

    return $post_id >= $min && $post_id <= $max;
});

// Use the condition
Rules::create('process_specific_posts')
    ->on('save_post')
    ->when()->custom('post_id_in_range', ['min' => 100, 'max' => 200])
    ->then()->custom('special_processing')
    ->register();
```

### Best Practices for Hook Arguments

#### 1. Always Provide Defaults

```php
// ✅ Good - provides defaults for missing arguments
$post_id = $context['wp']['hook']['args'][0] ?? null;
$post    = $context['wp']['hook']['args'][1] ?? null;

if (!$post_id || !$post) {
    return; // Safely handle missing data
}

// ❌ Bad - assumes arguments exist
$post_id = $context['wp']['hook']['args'][0];
$post    = $context['wp']['hook']['args'][1];
```

#### 2. Check Hook Name When Arguments Are Context-Specific

```php
Rules::register_condition('is_post_being_published', function($args, Context $context) {
    $hook_name = $context['wp']['hook']['name'] ?? '';

    // Different hooks have different argument structures
    if ($hook_name === 'transition_post_status') {
        $new_status = $context['wp']['hook']['args'][0] ?? '';
        return $new_status === 'publish';
    }

    if ($hook_name === 'save_post') {
        $post = $context['wp']['hook']['args'][1] ?? null;
        return $post && $post->post_status === 'publish';
    }

    return false;
});
```

### Hooks Without Arguments

Hooks that don't pass arguments (like `init`, `template_redirect`, `wp_loaded`) will not have a `hook` key in the context:

```php
Rules::create('init_hook')
    ->on('init')
    ->then()->custom('check_hook', function(Context $context) {
        if (isset($context['wp']['hook'])) {
            // This won't execute for 'init' hook
            error_log('Hook has arguments');
        } else {
            // This will execute
            error_log('Hook has no arguments');
        }
    })
    ->register();
```

---

## WordPress Conditions

The WordPress package provides conditions for WordPress-specific scenarios.

### User Conditions

```php
// Check if user is logged in
Rules::create('authenticated_only')
    ->when()->is_user_logged_in()
    ->then()->custom('show_dashboard')
    ->register();

// Check user roles (custom condition)
Rules::register_condition('user_has_role', function($args, Context $context) {
    $required_role = $args['role'] ?? '';
    $user_roles = $context['wp']['user']['roles'] ?? [];

    return in_array($required_role, $user_roles);
});

Rules::create('admin_only')
    ->when()
        ->is_user_logged_in()
        ->custom('user_has_role', ['role' => 'administrator'])
    ->then()
        ->custom('show_admin_tools')
    ->register();
```

### Query Conditions

```php
// Singular posts/pages
Rules::create('single_post_layout')
    ->when()->is_singular('post')
    ->then()->custom('apply_single_post_layout')
    ->register();

// Home page
Rules::create('homepage_features')
    ->when()->is_home()
    ->then()->custom('load_homepage_features')
    ->register();

// Archives
Rules::create('archive_sidebar')
    ->when()->is_archive()
    ->then()->custom('show_archive_sidebar')
    ->register();

// Multiple post types
Rules::create('content_enhancement')
    ->when()->is_singular(['post', 'page', 'article'], 'IN')
    ->then()->custom('enhance_content_display')
    ->register();
```

### Post Conditions

```php
// Check post type
Rules::create('product_features')
    ->when()->post_type('product')
    ->then()->custom('enable_product_features')
    ->register();

// Custom post status check
Rules::register_condition('post_status', function($args, Context $context) {
    $expected = $args['value'] ?? '';
    $actual = $context['wp']['post']['post_status'] ?? '';

    return $actual === $expected;
});

Rules::create('draft_warning')
    ->when()
        ->post_type('post')
        ->custom('post_status', ['value' => 'draft'])
    ->then()
        ->custom('show_draft_warning')
    ->register();
```

---

## WordPress Actions

Create WordPress-specific actions for common operations.

### Content Modification

```php
Rules::register_action('prepend_to_content', function($args, Context $context) {
    $text = $args['text'] ?? '';
    $priority = $args['priority'] ?? 10;

    add_filter('the_content', function($content) use ($text) {
        return $text . $content;
    }, $priority);
});

Rules::create('add_reading_time')
    ->when()->is_singular('post')
    ->then()
        ->custom('prepend_to_content', [
            'text' => '<div class="reading-time">5 min read</div>',
            'priority' => 10
        ])
    ->register();
```

### Navigation Menu Modification

```php
Rules::register_action('add_menu_item', function($args, Context $context) {
    $menu_slug = $args['menu_slug'] ?? '';
    $title = $args['title'] ?? '';
    $capability = $args['capability'] ?? 'read';
    $url = $args['url'] ?? '#';

    add_menu_page($title, $title, $capability, $menu_slug, function() use ($url) {
        wp_redirect($url);
        exit;
    });
});

Rules::create('add_tools_menu')
    ->on('admin_menu', 20)
    ->when()->is_user_logged_in()
    ->then()
        ->custom('add_menu_item', [
            'menu_slug' => 'custom-tools',
            'title' => 'Custom Tools',
            'capability' => 'manage_options',
            'url' => admin_url('admin.php?page=custom-tools')
        ])
    ->register();
```

### Widget Registration

```php
Rules::register_action('register_sidebar', function($args, Context $context) {
    $sidebar_config = wp_parse_args($config, [
        'name' => 'Custom Sidebar',
        'id' => 'custom-sidebar',
        'description' => 'A custom sidebar',
        'before_widget' => '<div class="widget">',
        'after_widget' => '</div>',
        'before_title' => '<h3>',
        'after_title' => '</h3>',
    ]);

    register_sidebar($sidebar_config);
});

Rules::create('conditional_sidebar')
    ->on('widgets_init', 10)
    ->when()->constant('ENABLE_CUSTOM_SIDEBAR', true)
    ->then()
        ->custom('register_sidebar', [
            'name' => 'Product Sidebar',
            'id' => 'product-sidebar'
        ])
    ->register();
```

### User Meta Updates

```php
Rules::register_action('update_user_meta', function($args, Context $context) {
    $user_id = $context->get('user.id', 0) ?? 0;
    $meta_key = $args['key'] ?? '';
    $meta_value = $args['value'] ?? '';

    if (!$user_id || !$meta_key) {
        return;
    }

    update_user_meta($user_id, $meta_key, $meta_value);
});

Rules::create('track_login_time')
    ->on('wp_login', 10)
    ->when()->is_user_logged_in()
    ->then()
        ->custom('update_user_meta', [
            'key' => 'last_login',
            'value' => time()
        ])
    ->register();
```

---

## WooCommerce Integration

### WooCommerce Conditions

```php
Rules::register_condition('cart_total', function($args, Context $context) {
    if (!function_exists('WC')) {
        return false;
    }

    $minimum = $args['minimum'] ?? 0;
    $cart_total = WC()->cart->get_total('');

    return $cart_total >= $minimum;
});

Rules::register_condition('has_product_in_cart', function($args, Context $context) {
    if (!function_exists('WC')) {
        return false;
    }

    $product_id = $args['product_id'] ?? 0;

    foreach (WC()->cart->get_cart() as $cart_item) {
        if ($cart_item['product_id'] == $product_id) {
            return true;
        }
    }

    return false;
});
```

### WooCommerce Actions

```php
Rules::register_action('apply_coupon', function($args, Context $context) {
    if (!function_exists('WC')) {
        return;
    }

    $coupon_code = $args['coupon'] ?? '';

    if ($coupon_code && !WC()->cart->has_discount($coupon_code)) {
        WC()->cart->apply_coupon($coupon_code);
    }
});

Rules::create('auto_apply_coupon')
    ->when()
        ->custom('cart_total', ['minimum' => 100])
        ->is_user_logged_in()
    ->then()
        ->custom('apply_coupon', ['coupon' => 'LOYALTYDISCOUNT'])
    ->register();
```

---

## Plugin Integration Patterns

### Feature Flags

```php
// Enable/disable features based on rules
Rules::register_action('enable_feature', function($args, Context $context) {
    $feature = $args['feature'] ?? '';

    if ($feature) {
        update_option("feature_enabled_{$feature}", true);
    }
});

Rules::create('enable_beta_features')
    ->when()
        ->is_user_logged_in()
        ->custom('user_has_role', ['role' => 'administrator'])
        ->constant('WP_ENVIRONMENT_TYPE', ['local', 'development'], 'IN')
    ->then()
        ->custom('enable_feature', ['feature' => 'beta_dashboard'])
        ->custom('enable_feature', ['feature' => 'advanced_editor'])
    ->register();
```

### Access Control

```php
Rules::register_action('restrict_access', function($args, Context $context) {
    $message = $args['message'] ?? 'Access denied';
    $redirect = $args['redirect'] ?? home_url();

    wp_die($message, 'Access Denied', [
        'link_url' => $redirect,
        'link_text' => 'Go back',
    ]);
});

Rules::create('protect_admin_pages')
    ->when()
        ->request_url('/wp-admin/options-*.php')
        ->is_user_logged_in()
        ->custom('user_has_role', ['role' => 'administrator'])
        ->match_none() // NOT administrator
    ->then()
        ->custom('restrict_access', [
            'message' => 'Only administrators can access this page',
            'redirect' => admin_url()
        ])
    ->register();
```

### Conditional Plugin Loading

```php
// Conditionally load plugin features
add_action('plugins_loaded', function() {
    MilliRules::init();

    Rules::register_action('load_plugin_module', function($args, Context $context) {
        $module = $args['module'] ?? '';
        $file = plugin_dir_path(__FILE__) . "modules/{$module}.php";

        if (file_exists($file)) {
            require_once $file;
        }
    });

    Rules::create('load_api_module')
        ->when()->request_url('/wp-json/myplugin/*')
        ->then()->custom('load_plugin_module', ['module' => 'api'])
        ->register();

    Rules::create('load_admin_module')
        ->when()->constant('WP_ADMIN', true)
        ->then()->custom('load_plugin_module', ['module' => 'admin'])
        ->register();
}, 5);
```

---

## Best Practices

### 1. Hook Timing

```php
// ✅ Good - initialize early
add_action('init', function() {
    MilliRules::init();
    register_rules();
}, 1); // Early priority

// ❌ Bad - too late, hooks may have fired
add_action('wp_footer', function() {
    MilliRules::init(); // Too late!
    register_rules();
});
```

### 2. WordPress Function Availability

```php
// ✅ Good - checks function availability
Rules::register_condition('wp_safe_condition', function($args, Context $context) {
    if (!function_exists('get_current_user_id')) {
        return false;
    }

    $user_id = get_current_user_id();
    return $user_id > 0;
});

// ❌ Bad - assumes WordPress is loaded
Rules::register_condition('unsafe_condition', function($args, Context $context) {
    $user_id = get_current_user_id(); // May not exist!
    return $user_id > 0;
});
```

### 3. Multisite Compatibility

```php
Rules::register_condition('is_main_site', function($args, Context $context) {
    if (!is_multisite()) {
        return true; // Not multisite, always main site
    }

    return is_main_site();
});

Rules::create('main_site_only_feature')
    ->when()->custom('is_main_site')
    ->then()->custom('enable_network_feature')
    ->register();
```

### 4. Translation Ready

```php
Rules::register_action('show_message', function($args, Context $context) {
    $message = $args['message'] ?? '';

    // Make translatable
    $translated = __($message, 'my-text-domain');

    echo '<div class="notice">' . esc_html($translated) . '</div>';
});
```

---

## Troubleshooting

### Rules Not Executing in WordPress

**Check initialization timing**:
```php
// Verify MilliRules is initialized
add_action('init', function() {
    if (!class_exists('MilliRules\MilliRules')) {
        error_log('MilliRules not loaded!');
        return;
    }

    MilliRules::init();
    error_log('MilliRules initialized');
}, 1);
```

**Verify package loading**:
```php
$packages = MilliRules::get_loaded_packages();
error_log('Loaded packages: ' . implode(', ', $packages));

if (!in_array('WP', $packages)) {
    error_log('WordPress package not loaded!');
}
```

### Hook Conflicts

```php
// Check if hook has fired
add_action('init', function() {
    error_log('Init hook fired');
    MilliRules::init();

    Rules::create('test_rule')
        ->on('template_redirect', 10)
        ->when()->request_url('*')
        ->then()->custom('log', ['value' => 'Template redirect fired'])
        ->register();
}, 1);

add_action('template_redirect', function() {
    error_log('template_redirect fired directly');
}, 1);
```

---

## Next Steps

- **[API Reference](/docs/millirules/05-reference/03-api)** - Complete method documentation
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - WordPress integration examples
- **[Advanced Patterns](/docs/millirules/04-advanced/02-advanced-patterns)** - Advanced WordPress patterns

---

**Ready for complete examples?** Continue to [Real-World Examples](/docs/millirules/04-advanced/01-examples) to see full WordPress implementations and use cases.

---

Canonical: https://www.millipress.com/docs/millirules/05-reference/01-conditions

---
title: 'Built-in Conditions Reference'
description: 'Reference for every built-in MilliRules condition: PHP request URL, method, header, cookie, and constant checks plus WordPress is_* and has_* conditionals.'
menu_order: 10
---

# Built-in Conditions Reference

MilliRules comes with a comprehensive set of built-in conditions for both framework-agnostic PHP applications and WordPress-specific scenarios. This reference guide documents every available condition with examples and usage patterns.

## Condition Packages

Conditions are organized into packages:

- **PHP Package** - Framework-agnostic HTTP and request conditions (always available)
- **WordPress Package** - WordPress-specific conditions (available only in WordPress)

## PHP Package Conditions

The PHP package provides framework-agnostic conditions that work in any PHP 7.4+ environment. These conditions handle HTTP requests, headers, cookies, and parameters.

### request_url

Check the request URL or URI path against a pattern or value.

**Namespace**: `MilliRules\Packages\PHP\Conditions\RequestUrl`

**Signature**:
```php
->request_url($value, $operator = '=')
```

**Parameters**:
- `$value` (string|array): URL pattern or array of patterns
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: All operators (=, !=, LIKE, REGEXP, IN, NOT IN, EXISTS, etc.)

**Context Data Used**: `$context->get('request.uri', '')`

#### Examples

**Exact match**:
```php
Rules::create('exact_url')
    ->when()
        ->request_url('/wp-admin/edit.php')  // Exact URL match
    ->then()->custom('action')
    ->register();
```

**Wildcard pattern matching**:
```php
Rules::create('admin_urls')
    ->when()
        ->request_url('/wp-admin/*', 'LIKE')  // Matches any admin URL
    ->then()->custom('action')
    ->register();

// Auto-detected LIKE operator (has wildcard)
Rules::create('api_urls')
    ->when()
        ->request_url('/api/*')  // LIKE operator auto-detected
    ->then()->custom('action')
    ->register();
```

**Multiple URL patterns**:
```php
Rules::create('protected_areas')
    ->when()
        ->request_url([
            '/wp-admin/*',
            '/wp-login.php',
            '/dashboard/*'
        ], 'IN')
    ->then()->custom('check_authentication')
    ->register();
```

**Regex matching**:
```php
Rules::create('api_versioned')
    ->when()
        // Matches /api/v1/, /api/v2/, etc.
        ->request_url('/^\\/api\\/v[0-9]+\\//i', 'REGEXP')
    ->then()->custom('route_api_request')
    ->register();
```

**Exclude patterns**:
```php
Rules::create('non_admin_urls')
    ->when()
        ->request_url('/wp-admin/*', 'NOT LIKE')  // Not admin URLs
    ->then()->custom('public_action')
    ->register();
```

> [!TIP]
> Use wildcards (`*` matches anything, `?` matches single character) for flexible pattern matching without the complexity of regex.

---

### request_method

Check the HTTP request method (GET, POST, PUT, DELETE, etc.).

**Namespace**: `MilliRules\Packages\PHP\Conditions\RequestMethod`

**Signature**:
```php
->request_method($value, $operator = '=')
```

**Parameters**:
- `$value` (string|array): HTTP method(s) to check
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: =, !=, IN, NOT IN, EXISTS

**Context Data Used**: `$context->get('request.method', '')`

#### Examples

**Single method**:
```php
Rules::create('post_requests')
    ->when()
        ->request_method('POST')  // Only POST requests
    ->then()->custom('process_form')
    ->register();
```

**Multiple methods** (OR logic):
```php
Rules::create('safe_methods')
    ->when()
        ->request_method(['GET', 'HEAD'], 'IN')  // GET or HEAD
    ->then()->custom('enable_caching')
    ->register();

// Auto-detected IN operator (array value)
Rules::create('safe_methods_auto')
    ->when()
        ->request_method(['GET', 'HEAD'])  // IN auto-detected
    ->then()->custom('enable_caching')
    ->register();
```

**Exclude methods**:
```php
Rules::create('non_post_requests')
    ->when()
        ->request_method('POST', '!=')
    ->then()->custom('action')
    ->register();

Rules::create('non_modifying_requests')
    ->when()
        ->request_method(['POST', 'PUT', 'DELETE', 'PATCH'], 'NOT IN')
    ->then()->custom('read_only_action')
    ->register();
```

> [!NOTE]
> HTTP methods are case-insensitive. Both `'POST'` and `'post'` work identically.

---

### request_header

Check request headers against expected values.

**Namespace**: `MilliRules\Packages\PHP\Conditions\RequestHeader`

**Signature**:
```php
->request_header($header_name, $value = null, $operator = '=')
```

**Parameters**:
- `$header_name` (string): Header name (case-insensitive)
- `$value` (mixed): Expected value (null to check existence)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: All operators

**Context Data Used**: `$context['request']['headers'][$header_name]`

#### Examples

**Check header existence**:
```php
Rules::create('has_auth_header')
    ->when()
        ->request_header('Authorization')  // Header exists
    ->then()->custom('process_authenticated')
    ->register();
```

**Check header value**:
```php
Rules::create('json_requests')
    ->when()
        ->request_header('Content-Type', 'application/json')
    ->then()->custom('parse_json')
    ->register();
```

**Pattern matching headers**:
```php
Rules::create('bearer_token')
    ->when()
        ->request_header('Authorization', 'Bearer *', 'LIKE')
    ->then()->custom('validate_token')
    ->register();
```

**Multiple accepted values**:
```php
Rules::create('json_or_xml')
    ->when()
        ->request_header('Content-Type', [
            'application/json',
            'application/xml',
            'text/xml'
        ], 'IN')
    ->then()->custom('parse_structured_data')
    ->register();
```

**Regex for complex matching**:
```php
Rules::create('api_key_format')
    ->when()
        ->request_header('X-API-Key', '/^[A-Za-z0-9]{32}$/', 'REGEXP')
    ->then()->custom('validate_api_key')
    ->register();
```

> [!IMPORTANT]
> Header names are case-insensitive in HTTP. `'Content-Type'`, `'content-type'`, and `'CONTENT-TYPE'` all reference the same header.

---

### request_param

Check URL query parameters or form POST data.

**Namespace**: `MilliRules\Packages\PHP\Conditions\RequestParam`

**Signature**:
```php
->request_param($param_name, $value = null, $operator = '=')
```

**Parameters**:
- `$param_name` (string): Parameter name
- `$value` (mixed): Expected value (null to check existence)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: All operators

**Context Data Used**: `$context['request']['params'][$param_name]` (merges $_GET and $_POST)

#### Examples

**Check parameter existence**:
```php
Rules::create('has_action_param')
    ->when()
        ->request_param('action')  // Parameter exists
    ->then()->custom('route_action')
    ->register();
```

**Check parameter value**:
```php
Rules::create('delete_action')
    ->when()
        ->request_param('action', 'delete')
    ->then()->custom('confirm_delete')
    ->register();
```

**Numeric comparison**:
```php
Rules::create('pagination')
    ->when()
        ->request_param('page', '1', '>')  // Page > 1
    ->then()->custom('show_pagination')
    ->register();
```

**Multiple accepted values**:
```php
Rules::create('list_actions')
    ->when()
        ->request_param('view', ['list', 'grid', 'table'], 'IN')
    ->then()->custom('render_list_view')
    ->register();
```

**Pattern matching**:
```php
Rules::create('search_query')
    ->when()
        ->request_param('s', '*product*', 'LIKE')  // Contains "product"
    ->then()->custom('enhance_product_search')
    ->register();
```

> [!NOTE]
> `request_param` checks both GET and POST parameters, with POST taking precedence if the same parameter exists in both.

---

### cookie

Check for cookie existence or value.

**Namespace**: `MilliRules\Packages\PHP\Conditions\Cookie`

**Signature**:
```php
->cookie($cookie_name, $value = null, $operator = '=')
```

**Parameters**:
- `$cookie_name` (string): Cookie name
- `$value` (mixed): Expected value (null to check existence)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: All operators

**Context Data Used**: `$context['request']['cookies'][$cookie_name]` (from $_COOKIE)

#### Examples

**Check cookie existence**:
```php
Rules::create('has_session')
    ->when()
        ->cookie('session_id')  // Cookie exists
    ->then()->custom('load_session')
    ->register();
```

**Check cookie value**:
```php
Rules::create('theme_preference')
    ->when()
        ->cookie('theme', 'dark')
    ->then()->custom('apply_dark_theme')
    ->register();
```

**Cookie doesn't exist**:
```php
Rules::create('first_time_visitor')
    ->when()
        ->cookie('visited_before', null, 'NOT EXISTS')
    ->then()->custom('show_welcome_message')
    ->register();
```

**Multiple cookie values**:
```php
Rules::create('preferred_languages')
    ->when()
        ->cookie('lang', ['en', 'en-US', 'en-GB'], 'IN')
    ->then()->custom('use_english')
    ->register();
```

**Pattern matching cookies**:
```php
Rules::create('tracking_cookies')
    ->when()
        ->cookie('_ga', 'GA*', 'LIKE')  // Google Analytics cookie
    ->then()->custom('record_analytics')
    ->register();
```

> [!WARNING]
> Cookies are set by the client and can be manipulated. Never trust cookie values for security-critical decisions without additional validation.

---

### constant

Check PHP or WordPress constants.

**Namespace**: `MilliRules\Packages\PHP\Conditions\Constant`

**Signature**:
```php
->constant($constant_name, $value = null, $operator = '=')
```

**Parameters**:
- `$constant_name` (string): Constant name
- `$value` (mixed): Expected value (null to check existence)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: All operators

**Context Data Used**: Uses `defined()` and `constant()` PHP functions

#### Examples

**Check constant existence**:
```php
Rules::create('has_debug_constant')
    ->when()
        ->constant('WP_DEBUG')  // Constant is defined
    ->then()->custom('enable_debug_mode')
    ->register();
```

**Check boolean constants**:
```php
Rules::create('debug_enabled')
    ->when()
        ->constant('WP_DEBUG', true)  // Debug is ON
    ->then()->custom('show_debug_info')
    ->register();

Rules::create('debug_disabled')
    ->when()
        ->constant('WP_DEBUG', false)  // Debug is OFF
    ->then()->custom('hide_debug_info')
    ->register();
```

**Check string constants**:
```php
Rules::create('local_environment')
    ->when()
        ->constant('WP_ENVIRONMENT_TYPE', 'local')
    ->then()->custom('enable_local_features')
    ->register();
```

**Multiple environment types**:
```php
Rules::create('non_production')
    ->when()
        ->constant('WP_ENVIRONMENT_TYPE', ['local', 'development'], 'IN')
    ->then()->custom('enable_dev_tools')
    ->register();
```

**Version checking**:
```php
Rules::create('php_version_check')
    ->when()
        ->constant('PHP_VERSION', '8.0', '>=')
    ->then()->custom('use_php8_features')
    ->register();
```

> [!TIP]
> Use constant conditions to create environment-specific rules that behave differently in development, staging, and production.

---

## WordPress Package Conditions

WordPress package conditions are available only when WordPress is detected. They provide access to WordPress-specific functionality and query information.

### Generic WordPress is_* Conditions

MilliRules supports any WordPress conditional tag function through the `IsConditional` class. Any function starting with `is_` (like `is_singular()`, `is_home()`, `is_archive()`, `is_category()`, etc.) can be used as a condition.

**How It Works**:
- The `IsConditional` class acts as a bridge between MilliRules and WordPress conditional tags
- Supports all WordPress conditional tags: [WordPress Conditional Tags](https://developer.wordpress.org/themes/basics/conditional-tags/)
- Arguments passed to the condition are forwarded to the WordPress function
- Operates in two modes: Boolean Mode (no arguments) or Function Call Mode (with arguments)

#### Basic Usage (Boolean Mode)

When used without arguments, `is_*` conditions behave as simple boolean checks:

```php
// Fluent builder
Rules::create('rule-1')
    ->when()->is_404()->then()->register();

// Array configuration
[
    'id'         => 'rule-1',
    'conditions' => [
        [ 'type' => 'is_404' ], // is_404() IS TRUE
    ],
    'actions'    => [],
];
```

In this mode:
- The underlying WordPress function is called with **no arguments**
- The boolean result is compared to the configured `value` (default: `true`) using the configured `operator` (default: `IS`)

Examples:
- `->is_404()` → `is_404() IS true`
- `->is_user_logged_in(false)` → `is_user_logged_in() IS false`

**Basic conditionals**:
```php
// Check if any archive page
Rules::create('archive_pages')
    ->when()
        ->is_archive()
    ->then()->custom('show_archive_sidebar')
    ->register();
```

#### Function Call Mode (With Arguments)

For conditionals that accept arguments, you can pass them directly to the builder. `IsConditional` will call the underlying `is_*` function with those arguments and compare the result to `true`.

In this mode:
- All non-boolean arguments are treated as **function arguments** for the underlying `is_*` function
- The condition always checks whether the function result is `true` (using `value = true` internally)

**With arguments**:
```php
// Single-argument conditional
->is_singular('page')           // is_singular('page') IS TRUE

// Multi-argument conditional
->is_tax('genre', 'sci-fi')     // is_tax('genre', 'sci-fi') IS TRUE
```

**Combining conditions**:
```php
// Check if user is logged in and viewing a product archive
Rules::create('archive_list_user_orders')
    ->when()
        ->is_user_logged_in()
        ->is_post_type_archive('product')
    ->then()->custom('show_orders')
    ->register();
```

#### Using Operators with WordPress Conditionals

You can optionally pass a comparison operator as the **last argument** when using function call mode. This operator controls how the boolean result of the `is_*` function is compared to `true`.

Supported operators:
- `=`
- `!=`
- `IS`
- `IS NOT`

```php
// Calls is_tax('genre', 'sci-fi') and compares result != TRUE
// Check for multiple taxonomy terms with IN operator
Rules::create('action_or_drama')
    ->when()
        ->is_tax('genre', 'sci-fi', '!=');
    ->then()->custom('show_newsletter_cta')
    ->register();

// Check for multiple taxonomy terms with IN operator
Rules::create('action_or_drama')
    ->when()
        ->is_tax('genre', ['action', 'drama'], 'IN')
    ->then()->custom('show_intense_content_warning')
    ->register();
```

#### Implementation Notes

- The builder records all raw method arguments in a generic `args` key in the condition config
- The WordPress `IsConditional` class interprets `args` to determine whether to operate in boolean mode or function-call mode
- Other packages can reuse the `args` convention in their own condition classes without any changes to core engine or base condition logic

---

### Generic WordPress has_* Conditions

MilliRules supports any WordPress conditional tag function starting with `has_` through the `HasConditional` class. Functions like `has_post_thumbnail()`, `has_block()`, `has_term()`, `has_excerpt()`, etc. can all be used as conditions.

**How It Works**:
- The `HasConditional` class acts as a bridge between MilliRules and WordPress `has_*` conditional functions
- Arguments passed to the condition are forwarded to the WordPress function
- Operates in two modes: Boolean Mode (no arguments) or Function Call Mode (with arguments)

#### Basic Usage (Boolean Mode)

When used without arguments, `has_*` conditions behave as simple boolean checks:

```php
// Fluent builder
Rules::create('rule-1')
    ->when()->has_post_thumbnail()->then()->register();

// Array configuration
[
    'id'         => 'rule-1',
    'conditions' => [
        [ 'type' => 'has_post_thumbnail' ], // has_post_thumbnail() IS TRUE
    ],
    'actions'    => [],
];
```

In this mode:
- The underlying WordPress function is called with **no arguments**
- The boolean result is compared to the configured `value` (default: `true`) using the configured `operator` (default: `IS`)

Examples:
- `->has_post_thumbnail()` → `has_post_thumbnail() IS true`
- `->has_post_thumbnail(false)` → `has_post_thumbnail() IS false`

**Basic conditionals**:
```php
// Check if the post has an excerpt
Rules::create('has_excerpt')
    ->when()
        ->has_excerpt()
    ->then()->custom('show_custom_excerpt')
    ->register();
```

#### Function Call Mode (With Arguments)

For conditionals that accept arguments, you can pass them directly to the builder. `HasConditional` will call the underlying `has_*` function with those arguments and compare the result to `true`.

In this mode:
- All non-boolean arguments are treated as **function arguments** for the underlying `has_*` function
- The condition always checks whether the function result is `true` (using `value = true` internally)

**With arguments**:
```php
// Single-argument conditional
->has_block('core/paragraph')       // has_block('core/paragraph') IS TRUE

// Multi-argument conditional
->has_term('news', 'category')      // has_term('news', 'category') IS TRUE
```

**Combining conditions**:
```php
// Check if a post has a featured image and a specific block
Rules::create('rich_content')
    ->when()
        ->has_post_thumbnail()
        ->has_block('core/gallery')
    ->then()->custom('show_rich_layout')
    ->register();
```

#### Using Operators with WordPress has_* Conditionals

You can optionally pass a comparison operator as the **last argument** when using function call mode. This operator controls how the boolean result of the `has_*` function is compared to `true`.

Supported operators:
- `=`
- `!=`
- `IS`
- `IS NOT`

```php
// Check that a post does NOT have a specific term
Rules::create('not_in_news')
    ->when()
        ->has_term('news', 'category', '!=')
    ->then()->custom('show_generic_sidebar')
    ->register();

// Check that a post does NOT have a featured image
Rules::create('no_thumbnail')
    ->when()
        ->has_post_thumbnail(false)
    ->then()->custom('show_placeholder_image')
    ->register();
```

#### Implementation Notes

- The builder records all raw method arguments in a generic `args` key in the condition config
- The WordPress `HasConditional` class interprets `args` to determine whether to operate in boolean mode or function-call mode
- Other packages can reuse the `args` convention in their own condition classes without any changes to core engine or base condition logic

---

### post_type

Check the current post type.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\PostType`

**Signature**:
```php
->post_type($post_types, $operator = '=')
```

**Parameters**:
- `$post_types` (string|array): Post type(s) to check
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: =, !=, IN, NOT IN, EXISTS

**Context Data Used**: `$context['wp']['post']['post_type']`

#### Examples

**Single post type**:
```php
Rules::create('product_pages')
    ->when()
        ->post_type('product')
    ->then()->custom('show_product_gallery')
    ->register();
```

**Multiple post types**:
```php
Rules::create('content_types')
    ->when()
        ->post_type(['post', 'page', 'article'], 'IN')
    ->then()->custom('enable_reading_time')
    ->register();
```

**Exclude post type**:
```php
Rules::create('non_page_content')
    ->when()
        ->post_type('page', '!=')
    ->then()->custom('show_author_bio')
    ->register();
```

---

### post_status

Check the current post status.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\PostStatus`

**Signature**:
```php
->post_status($status, $operator = '=')
```

**Parameters**:
- `$status` (string|array): Post status(es) to check (`publish`, `draft`, `pending`, `private`, `future`, `trash`, etc.)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: =, !=, IN, NOT IN

**How it resolves**: Reads `post_status` from the queried object or global `$post`.

#### Examples

**Single status**:
```php
Rules::create('published_only')
    ->when()
        ->post_status('publish')
    ->then()->custom('show_share_buttons')
    ->register();
```

**Multiple statuses**:
```php
Rules::create('visible_content')
    ->when()
        ->post_status(['publish', 'private'], 'IN')
    ->then()->custom('enable_comments')
    ->register();
```

**Exclude status**:
```php
Rules::create('not_draft')
    ->when()
        ->post_status('draft', '!=')
    ->then()->custom('index_content')
    ->register();
```

> [!NOTE]
> This condition reads the actual `post_status` property — there is no WordPress `is_post_status()` conditional tag, which is why this dedicated condition exists.

---

### post_parent

Check the parent post ID of the current post.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\PostParent`

**Signature**:
```php
->post_parent($parent_id, $operator = '=')
```

**Parameters**:
- `$parent_id` (int|array): Parent post ID(s) to check
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: =, !=, IN, NOT IN, >, <

**How it resolves**: Reads `post_parent` from the queried object or global `$post`. Returns `0` if no post is found.

#### Examples

**Exact parent**:
```php
Rules::create('child_of_about')
    ->when()
        ->post_parent(10)
    ->then()->custom('show_about_subnav')
    ->register();
```

**Has any parent** (hierarchical post):
```php
Rules::create('is_child_page')
    ->when()
        ->post_parent(0, '>')
    ->then()->custom('show_breadcrumbs')
    ->register();
```

**Is top-level page** (no parent):
```php
Rules::create('top_level_page')
    ->when()
        ->post_parent(0)
    ->then()->custom('show_child_pages_menu')
    ->register();
```

> [!TIP]
> Use `->post_parent(0, '>')` as an efficient way to check if a page is a child of any parent, regardless of which parent.

---

### user_role

Check the roles of the current logged-in user.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\UserRole`

**Signature**:
```php
->user_role($role, $operator = 'IN')
```

**Parameters**:
- `$role` (string|array): Role(s) to check (`administrator`, `editor`, `author`, `contributor`, `subscriber`, or custom roles)
- `$operator` (string): Comparison operator (default: `'IN'`)

**Supported Operators**: =, !=, IN, NOT IN

**How it resolves**: Loads user data from context and checks the `roles` array. Uses intersection logic — a user with multiple roles matches if *any* of their roles match any of the expected roles.

#### Examples

**Single role**:
```php
Rules::create('admin_features')
    ->when()
        ->user_role('administrator')
    ->then()->custom('show_admin_toolbar')
    ->register();
```

**Multiple roles**:
```php
Rules::create('editorial_features')
    ->when()
        ->user_role(['editor', 'administrator'], 'IN')
    ->then()->custom('show_editorial_tools')
    ->register();
```

**Exclude role**:
```php
Rules::create('non_subscribers')
    ->when()
        ->user_role('subscriber', '!=')
    ->then()->custom('show_premium_content')
    ->register();
```

> [!NOTE]
> WordPress users can have multiple roles. This condition uses array intersection, so `->user_role('editor')` will match a user who has both `editor` and `administrator` roles.

---

### wp_environment

Check the WordPress environment type.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\WpEnvironment`

**Signature**:
```php
->wp_environment($environment, $operator = '=')
```

**Parameters**:
- `$environment` (string|array): Environment type(s) to check (`production`, `staging`, `development`, `local`)
- `$operator` (string): Comparison operator (default: `'='`)

**Supported Operators**: =, !=, IN, NOT IN

**How it resolves**: Uses `wp_get_environment_type()` (WordPress 5.5+). Falls back to `'production'` if the function is unavailable.

#### Examples

**Production check**:
```php
Rules::create('production_only')
    ->when()
        ->wp_environment('production')
    ->then()->custom('enable_caching')
    ->register();
```

**Non-production environments**:
```php
Rules::create('dev_tools')
    ->when()
        ->wp_environment(['development', 'local'], 'IN')
    ->then()->custom('enable_debug_bar')
    ->register();
```

**Exclude production**:
```php
Rules::create('not_production')
    ->when()
        ->wp_environment('production', '!=')
    ->then()->custom('show_environment_banner')
    ->register();
```

> [!TIP]
> You can also use `->constant('WP_ENVIRONMENT_TYPE', 'local')` from the PHP package for the same effect. The `wp_environment` condition is a convenience wrapper that handles the function availability check.

---

### query_var

Check WordPress query variables.

**Namespace**: `MilliRules\Packages\WordPress\Conditions\QueryVar`

**Signature**:
```php
->query_var($name, $value = null, $operator = '=')
```

**Parameters**:
- `$name` (string): Query variable name (e.g., `paged`, `post_type`, `s`, `m`, `author`, etc.)
- `$value` (mixed): Expected value (null defaults to `EXISTS` operator)
- `$operator` (string): Comparison operator (default: `'='`, or `'EXISTS'` when no value)

**Supported Operators**: =, !=, IN, NOT IN, LIKE, EXISTS, NOT EXISTS

**How it resolves**: First checks the execution context, then falls back to `get_query_var()`. When no value is provided, automatically uses the `EXISTS` operator.

#### Examples

**Check existence**:
```php
Rules::create('is_search')
    ->when()
        ->query_var('s')  // Defaults to EXISTS
    ->then()->custom('enhance_search')
    ->register();
```

**Check value**:
```php
Rules::create('page_two')
    ->when()
        ->query_var('paged', 2)
    ->then()->custom('show_pagination_notice')
    ->register();
```

**Check post type query var**:
```php
Rules::create('product_archive_query')
    ->when()
        ->query_var('post_type', 'product')
    ->then()->custom('show_product_filters')
    ->register();
```

**Check non-existence**:
```php
Rules::create('no_search')
    ->when()
        ->query_var('s', null, 'NOT EXISTS')
    ->then()->custom('show_default_content')
    ->register();
```

> [!NOTE]
> `query_var` is a name-based condition — the first argument is always the query variable name, not a value. This makes it unique among WordPress conditions.

---

## Combining Conditions

### PHP Conditions Only

```php
Rules::create('api_authentication', 'php')
    ->when()
        ->request_url('/api/*')
        ->request_method('POST')
        ->request_header('Authorization', 'Bearer *', 'LIKE')
        ->cookie('session_id')
    ->then()->custom('process_api_request')
    ->register();
```

### WordPress Conditions Only

```php
Rules::create('admin_users_posts', 'wp')
    ->when()
        ->is_user_logged_in()
        ->is_singular('post')
        ->post_type('post')
    ->then()->custom('show_admin_tools')
    ->register();
```

### Mixed PHP and WordPress Conditions

```php
Rules::create('secure_admin_area', 'wp')
    ->when()
        ->request_url('/wp-admin/*')      // PHP condition
        ->is_user_logged_in()              // WordPress condition
        ->cookie('admin_preference')       // PHP condition
    ->then()->custom('customize_admin')
    ->register();
```

> [!TIP]
> When mixing PHP and WordPress conditions, ensure the WordPress package is available. Auto-detection will set the rule type to `'wp'` when WordPress conditions are used.

## Condition Evaluation Order

Conditions are evaluated in the order they're defined:

```php
Rules::create('sequential_checks')
    ->when()
        ->request_url('/api/*')        // Checked first
        ->request_method('POST')        // Checked second
        ->cookie('auth_token')          // Checked third
    ->then()->custom('action')
    ->register();
```

For performance, place the **most restrictive or fastest conditions first**:

```php
// ✅ Good - quick checks first
Rules::create('optimized')
    ->when()
        ->request_method('POST')        // Fast check
        ->request_url('/specific/path') // Fast check
        ->custom('complex_validation')  // Slow custom check last
    ->then()->custom('action')
    ->register();

// ❌ Bad - slow check first
Rules::create('unoptimized')
    ->when()
        ->custom('complex_validation')  // Slow check first!
        ->request_method('POST')
        ->request_url('/specific/path')
    ->then()->custom('action')
    ->register();
```

> [!IMPORTANT]
> With match_all() (default), if any condition fails, subsequent conditions are not evaluated. Place restrictive conditions first to short-circuit evaluation early.

## Custom Conditions

When built-in conditions aren't sufficient, create custom conditions:

```php
// Register custom condition
Rules::register_condition('is_weekend', function(Context $context) {
    return date('N') >= 6; // Saturday or Sunday
});

// Use in rule
Rules::create('weekend_special')
    ->when()
        ->custom('is_weekend')
        ->request_url('/shop/*')
    ->then()->custom('apply_weekend_discount')
    ->register();
```

See [Creating Custom Conditions](/docs/millirules/03-customization/01-custom-conditions) for detailed information.

## Best Practices

### 1. Use Specific Conditions

```php
// ✅ Good - specific conditions
->when()
    ->request_url('/api/users')
    ->request_method('GET')

// ❌ Bad - too broad
->when()
    ->request_url('*')
```

### 2. Leverage Auto-Detection

```php
// ✅ Good - let MilliRules detect operators
->request_url('/admin/*')           // LIKE auto-detected
->request_method(['GET', 'HEAD'])   // IN auto-detected
->constant('WP_DEBUG', true)        // IS auto-detected

// ❌ Unnecessary - explicit when auto-detected works
->request_url('/admin/*', 'LIKE')
->request_method(['GET', 'HEAD'], 'IN')
->constant('WP_DEBUG', true, 'IS')
```

### 3. Group Related Conditions

```php
// ✅ Good - logical grouping
Rules::create('api_security')
    ->when()
        // API context
        ->request_url('/api/*')
        ->request_method('POST')

        // Authentication
        ->cookie('session_id')
        ->request_header('Authorization')
    ->then()->custom('process_secure_api')
    ->register();
```

### 4. Use Comments for Complex Logic

```php
Rules::create('complex_caching')
    ->when()
        // Cacheable request types
        ->request_method(['GET', 'HEAD'], 'IN')

        // Not in admin or login areas
        ->request_url('/wp-admin/*', 'NOT LIKE')
        ->request_url('/wp-login.php', '!=')

        // User hasn't disabled caching
        ->cookie('disable_cache', null, 'NOT EXISTS')
    ->then()->custom('apply_cache')
    ->register();
```

## Next Steps

- **[Built-in Actions](/docs/millirules/05-reference/02-actions)** - Learn about available actions
- **[Operators](/docs/millirules/02-core-concepts/04-operators)** - Master comparison and pattern matching
- **[Custom Conditions](/docs/millirules/03-customization/01-custom-conditions)** - Create your own conditions
- **[Examples](/docs/millirules/04-advanced/01-examples)** - See conditions in real-world scenarios

---

**Need more details?** Check the [API Reference](/docs/millirules/05-reference/03-api) for complete method signatures and parameters.

---

Canonical: https://www.millipress.com/docs/millirules/05-reference/02-actions

---
title: 'Built-in Actions Reference'
description: 'How the MilliRules action system works: callback and class-based actions, execution order, context access, and reusable patterns for redirects and caching.'
menu_order: 20
---

# Built-in Actions Reference

Actions are the "then" part of your rules—they define what happens when conditions are met. Unlike conditions, MilliRules' action system is primarily designed around **custom actions** that you define for your specific needs.

## Understanding the Action System

MilliRules provides a flexible action framework that allows you to:

- **Register custom callback actions** for quick, inline functionality
- **Create reusable action classes** for complex operations
- **Execute actions sequentially** in the order they're defined
- **Access full context** within actions for data-driven decisions

## Action Execution Flow

When a rule's conditions match:

```
1. Rule conditions evaluate to true
   ↓
2. Rule engine triggers action execution
   ↓
3. For each action in the rule:
   a. Instantiate action with config and context
   b. Execute action's execute() method
   c. Continue to next action
   ↓
4. Return execution statistics
```

Actions execute **immediately and sequentially**. There's no action queue or deferred execution.

> [!IMPORTANT]
> Actions execute in the exact order they're defined in your rule. If one action fails, MilliRules logs the error and continues to the next action.

## Action Types

### 1. Custom Callback Actions

The simplest way to create actions is using callback functions.

#### Registering Callback Actions

```php
use MilliRules\Rules;
use MilliRules\Context;

// Simple action
Rules::register_action('log_message', function($args, Context $context) {
    $message = $args['message'] ?? $args[0] ?? 'No message';
    error_log('MilliRules: ' . $message);
});

// Action with context access
Rules::register_action('log_user_action', function($args, Context $context) {
    $action = $args['action'] ?? 'accessed';
    $user = $context->get('user.login', 'guest');
    $url = $context->get('request.uri', 'unknown');
    error_log("User {$user} {$action} {$url}");
});
```

#### Using Callback Actions in Rules

```php
Rules::create('log_admin_access')
    ->when()
        ->request_url('/wp-admin/*')
        ->is_user_logged_in()
    ->then()
        ->custom('log_message', ['value' => 'Admin area accessed'])
        ->custom('log_user_action')
    ->register();
```

**Callback Parameters**:
- `$context` (array): Full execution context with request, WP data, etc.
- `$config` (array): Action configuration passed from the rule

> [!TIP]
> Use callback actions for simple operations that don't require state management or extensive configuration.

---

### 2. Class-Based Actions

For complex operations, create action classes implementing `ActionInterface`.

#### Action Interface

```php
namespace MilliRules\Interfaces;

interface ActionInterface {
    public function execute(Context $context): void;
    public function get_type(): string;
}
```

#### Creating an Action Class

```php
namespace MyPlugin\Actions;

use MilliRules\Interfaces\ActionInterface;

class SendEmailAction implements ActionInterface {
    private $config;
    private $context;

    public function __construct(array $config, Context $context) {
        $this->config = $config;
        $this->context = $context;
    }

    public function execute(Context $context): void {
        $to = $this->config['to'] ?? '';
        $subject = $this->config['subject'] ?? 'Notification';
        $message = $this->config['message'] ?? '';

        // Access context for dynamic data
        $user_email = $context['wp']['user']['email'] ?? $to;

        wp_mail($to, $subject, $message);
    }

    public function get_type(): string {
        return 'send_email';
    }
}
```

#### Registering the Namespace

```php
use MilliRules\RuleEngine;

// Register action namespace so MilliRules can find your action classes
RuleEngine::register_namespace('Actions', 'MyPlugin\Actions');
```

#### Using Class-Based Actions

```php
Rules::create('user_registration_notification')
    ->when()
        ->request_url('/wp-admin/user-new.php')
        ->request_param('action', 'createuser')
    ->then()
        ->custom('send_email', [
            'to' => 'admin@example.com',
            'subject' => 'New User Registration',
            'message' => 'A new user has registered.'
        ])
    ->register();
```

> [!NOTE]
> Class-based actions provide better organization, testability, and reusability for complex operations.

---

### 3. BaseAction Helper Class

MilliRules provides a `BaseAction` abstract class that includes placeholder resolution.

```php
namespace MyPlugin\Actions;

use MilliRules\Actions\BaseAction;

class CustomAction extends BaseAction {
    public function execute(Context $context): void {
        // Resolve placeholders in config values
        $message = $this->resolve_value($this->config['value'] ?? '');

        // Use resolved value
        error_log($message);
    }

    public function get_type(): string {
        return 'custom_action';
    }
}
```

**Using placeholder resolution**:

```php
Rules::create('dynamic_logging')
    ->when()
        ->request_url('/api/*')
    ->then()
        ->custom('custom_action', [
            'value' => 'API request to {request.uri} from {request.ip}'
        ])
    ->register();

// Logs: "API request to /api/users from 192.168.1.1"
```

See [Dynamic Placeholders](/docs/millirules/02-core-concepts/05-placeholders) for complete placeholder syntax.

---

## Common Action Patterns

### 1. Logging Actions

```php
// Simple logging
Rules::register_action('log', function($args, Context $context) {
    error_log($args['value'] ?? '');
});

// Structured logging
Rules::register_action('log_structured', function($args, Context $context) {
    $data = [
        'timestamp' => time(),
        'user' => $context->get('user.login', 'guest') ?? 'guest',
        'ip' => $context['request']['ip'] ?? 'unknown',
        'message' => $args['value'] ?? '',
    ];
    error_log(json_encode($data));
});

// Usage
Rules::create('log_actions')
    ->when()->request_url('/important/*')
    ->then()
        ->custom('log', ['value' => 'Important URL accessed'])
        ->custom('log_structured', ['value' => 'Security alert'])
    ->register();
```

---

### 2. Redirect Actions

```php
Rules::register_action('redirect', function($args, Context $context) {
    $url = $args['url'] ?? home_url();
    $status = $args['status'] ?? 302;

    if (!headers_sent()) {
        wp_redirect($url, $status);
        exit;
    }
});

// Usage
Rules::create('redirect_logged_out_users')
    ->when()
        ->request_url('/members/*')
        ->is_user_logged_in(false)
    ->then()
        ->custom('redirect', [
            'url' => wp_login_url(),
            'status' => 302
        ])
    ->register();
```

> [!WARNING]
> Redirect actions should typically be the last action in a rule, as they terminate execution with `exit`.

---

### 3. Cache Control Actions

```php
Rules::register_action('set_cache_headers', function($args, Context $context) {
    $duration = $args['duration'] ?? 3600;

    if (!headers_sent()) {
        header("Cache-Control: public, max-age={$duration}");
        header('Expires: ' . gmdate('D, d M Y H:i:s', time() + $duration) . ' GMT');
    }
});

// Usage
Rules::create('cache_api_responses')
    ->when()
        ->request_url('/api/*')
        ->request_method(['GET', 'HEAD'], 'IN')
    ->then()
        ->custom('set_cache_headers', ['duration' => 7200])
    ->register();
```

---

### 4. Database Operations

```php
Rules::register_action('log_to_database', function($args, Context $context) {
    global $wpdb;

    $table = $wpdb->prefix . 'access_log';
    $wpdb->insert($table, [
        'user_id' => $context->get('user.id', 0) ?? 0,
        'url' => $context->get('request.uri', '') ?? '',
        'timestamp' => current_time('mysql'),
    ]);
});

// Usage
Rules::create('track_premium_access')
    ->when()
        ->request_url('/premium/*')
        ->is_user_logged_in()
    ->then()
        ->custom('log_to_database')
    ->register();
```

---

### 5. WordPress Hook Triggers

```php
// Trigger WordPress actions
Rules::register_action('do_action', function($args, Context $context) {
    $hook = $args['value'] ?? '';
    $args = $args['args'] ?? [];

    if ($hook) {
        do_action($hook, ...$args);
    }
});

// Trigger WordPress filters
Rules::register_action('apply_filters', function($args, Context $context) {
    $hook = $args['value'] ?? '';
    $value = $args['filter_value'] ?? '';
    $args = $args['args'] ?? [];

    if ($hook) {
        return apply_filters($hook, $value, ...$args);
    }
});

// Usage
Rules::create('trigger_custom_hooks')
    ->when()->request_url('/checkout/*')
    ->then()
        ->custom('do_action', [
            'value' => 'my_checkout_started',
            'args' => ['checkout_page']
        ])
    ->register();
```

---

### 6. Content Modification

```php
Rules::register_action('modify_content', function($args, Context $context) {
    add_filter('the_content', function($content) use ($config) {
        $prepend = $args['prepend'] ?? '';
        $append = $args['append'] ?? '';

        return $prepend . $content . $append;
    }, $args['priority'] ?? 10);
});

// Usage
Rules::create('add_disclaimer')
    ->when()
        ->is_singular('post')
        ->post_type('product')
    ->then()
        ->custom('modify_content', [
            'prepend' => '<div class="disclaimer">Product information may vary.</div>',
            'priority' => 10
        ])
    ->register();
```

---

### 7. Conditional Execution

```php
Rules::register_action('execute_if', function($args, Context $context) {
    $condition = $args['condition'] ?? null;
    $callback = $args['callback'] ?? null;

    if (is_callable($condition) && is_callable($callback)) {
        if ($condition($context)) {
            $callback($context);
        }
    }
});

// Usage
Rules::create('conditional_action')
    ->when()->request_url('/api/*')
    ->then()
        ->custom('execute_if', [
            'condition' => function(Context $context) {
                return date('H') >= 9 && date('H') <= 17; // Business hours
            },
            'callback' => function(Context $context) {
                error_log('API accessed during business hours');
            }
        ])
    ->register();
```

---

## Action Configuration

Actions receive configuration through the `$config` array:

```php
Rules::register_action('flexible_action', function($args, Context $context) {
    // Common configuration keys
    $value = $args['value'] ?? '';           // Primary value
    $enabled = $args['enabled'] ?? true;     // Enable flag
    $priority = $args['priority'] ?? 10;     // Priority/order
    $options = $args['options'] ?? [];       // Additional options

    // Custom configuration
    $custom_param = $args['custom_param'] ?? 'default';
});

// Usage with full configuration
Rules::create('configured_action')
    ->when()->request_url('/test')
    ->then()
        ->custom('flexible_action', [
            'value' => 'test value',
            'enabled' => true,
            'priority' => 20,
            'options' => ['key' => 'value'],
            'custom_param' => 'custom value'
        ])
    ->register();
```

> [!TIP]
> Use consistent configuration key names across your actions:
> - `'value'` for the primary action value
> - `'enabled'` for enable/disable flags
> - `'priority'` for ordering within the action
> - `'options'` for nested configuration

---

## Accessing Context in Actions

The context provides access to all available data:

```php
Rules::register_action('context_aware_action', function($args, Context $context) {
    // Request data
    $url = $context->get('request.uri', '') ?? '';
    $method = $context->get('request.method', '') ?? '';
    $ip = $context['request']['ip'] ?? '';

    // Cookies
    $session = $context['request']['cookies']['session_id'] ?? '';

    // WordPress data (if available)
    $user_id = $context->get('user.id', 0) ?? 0;
    $user_login = $context->get('user.login', 'guest') ?? 'guest';
    $post_id = $context->get('post.id', 0) ?? 0;

    // Query flags
    $is_singular = $context['wp']['query']['is_singular'] ?? false;
    $is_admin = $context['wp']['query']['is_admin'] ?? false;

    // Use context data
    error_log("User {$user_login} accessed {$url} from {$ip}");
});
```

**Full context structure**:
```php
[
    'request' => [
        'method' => 'GET',
        'uri' => '/path',
        'scheme' => 'https',
        'host' => 'example.com',
        'path' => '/path',
        'query' => 'key=value',
        'referer' => 'https://example.com',
        'user_agent' => 'Mozilla/5.0...',
        'headers' => [...],
        'ip' => '192.168.1.1',
        'cookies' => [...],
        'params' => [...],
    ],
    'wp' => [  // WordPress only
        'post' => [...],
        'user' => [...],
        'query' => [...],
        'constants' => [...],
    ],
]
```

---

## Error Handling in Actions

MilliRules catches action exceptions and continues execution:

```php
Rules::register_action('safe_action', function($args, Context $context) {
    try {
        // Risky operation
        $result = risky_operation();

        if (!$result) {
            throw new Exception('Operation failed');
        }
    } catch (Exception $e) {
        error_log('Action error: ' . $e->getMessage());
        // Execution continues to next action
    }
});
```

> [!IMPORTANT]
> If an action throws an uncaught exception, MilliRules logs the error and continues to the next action. The rule is marked as executed even if actions fail.

---

## Multiple Actions in Sequence

Actions execute sequentially in definition order:

```php
Rules::create('multi_step_process')
    ->when()->request_url('/process')
    ->then()
        ->custom('log', ['value' => '1. Starting process'])
        ->custom('validate_data')
        ->custom('log', ['value' => '2. Data validated'])
        ->custom('process_data')
        ->custom('log', ['value' => '3. Data processed'])
        ->custom('send_response')
        ->custom('log', ['value' => '4. Response sent'])
    ->register();
```

**Execution flow**:
1. Log: "1. Starting process"
2. Validate data
3. Log: "2. Data validated"
4. Process data
5. Log: "3. Data processed"
6. Send response
7. Log: "4. Response sent"

---

## Best Practices

### 1. Keep Actions Focused

```php
// ✅ Good - single responsibility
Rules::register_action('log_access', function($args, Context $context) {
    error_log('Access logged');
});

Rules::register_action('update_counter', function($args, Context $context) {
    update_option('access_count', get_option('access_count', 0) + 1);
});

// ❌ Bad - multiple responsibilities
Rules::register_action('do_everything', function($args, Context $context) {
    error_log('Access logged');
    update_option('access_count', get_option('access_count', 0) + 1);
    send_email('admin@example.com', 'Access', 'Someone accessed');
    update_database();
    // Too much in one action!
});
```

### 2. Use Descriptive Action Names

```php
// ✅ Good
Rules::register_action('send_admin_notification_email', ...);
Rules::register_action('log_security_event', ...);
Rules::register_action('update_user_last_login_timestamp', ...);

// ❌ Bad
Rules::register_action('send', ...);
Rules::register_action('log', ...);
Rules::register_action('update', ...);
```

### 3. Validate Configuration

```php
Rules::register_action('safe_action', function($args, Context $context) {
    // Validate required configuration
    if (empty($args['required_value'])) {
        error_log('Action error: missing required_value');
        return;
    }

    // Validate data types
    $count = absint($args['count'] ?? 0);
    $enabled = (bool) ($args['enabled'] ?? true);

    // Proceed with validated data
    // ...
});
```

### 4. Check Prerequisites

```php
Rules::register_action('wordpress_dependent', function($args, Context $context) {
    // Check if WordPress functions are available
    if (!function_exists('wp_mail')) {
        error_log('WordPress not available');
        return;
    }

    wp_mail($args['to'], $args['subject'], $args['message']);
});
```

### 5. Use Constants for Configuration

```php
// Define action configuration constants
define('DEFAULT_EMAIL_RECIPIENT', 'admin@example.com');
define('DEFAULT_LOG_LEVEL', 'info');

Rules::register_action('send_notification', function($args, Context $context) {
    $to = $args['to'] ?? DEFAULT_EMAIL_RECIPIENT;
    $level = $args['level'] ?? DEFAULT_LOG_LEVEL;

    // Use constants for consistent configuration
});
```

---

## Common Pitfalls

### 1. Modifying Context

```php
// ❌ Wrong - context modifications don't persist
Rules::register_action('modify_context', function($args, Context $context) {
    $context['custom_value'] = 'modified';
    // This change is lost after the action completes
});

// ✅ Correct - use external state or return values
Rules::register_action('store_value', function($args, Context $context) {
    update_option('custom_value', 'modified');
});
```

> [!WARNING]
> Context is passed by value to actions. Modifications to `$context` within an action do not persist to subsequent actions.

### 2. Assuming Action Order Across Rules

```php
// ❌ Wrong - different rules, no guaranteed order
Rules::create('rule1')->order(10)->when()->then()->custom('action1')->register();
Rules::create('rule2')->order(20)->when()->then()->custom('action2')->register();
// action1 and action2 only execute if their respective rule conditions match

// ✅ Correct - actions in same rule execute in order
Rules::create('rule')->order(10)
    ->when()->request_url('*')
    ->then()
        ->custom('action1')  // Executes first
        ->custom('action2')  // Executes second
    ->register();
```

### 3. Using Exit/Die in Actions

```php
// ❌ Wrong - prevents subsequent actions
Rules::register_action('early_exit', function($args, Context $context) {
    echo 'Response';
    exit; // Stops all subsequent actions!
});

// ✅ Correct - use flags or return early
Rules::register_action('conditional_processing', function($args, Context $context) {
    if (!some_condition()) {
        return; // Skip this action, continue to next
    }

    // Process normally
});
```

---

## Next Steps

- **[Operators and Pattern Matching](/docs/millirules/02-core-concepts/04-operators)** - Master condition operators
- **[Dynamic Placeholders](/docs/millirules/02-core-concepts/05-placeholders)** - Use dynamic values in actions
- **[Creating Custom Actions](/docs/millirules/03-customization/02-custom-actions)** - Advanced action development
- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - See actions in complete examples

---

**Ready to create custom actions?** Continue to [Creating Custom Actions](/docs/millirules/03-customization/02-custom-actions) for advanced techniques and patterns.

---

Canonical: https://www.millipress.com/docs/millirules/05-reference/03-api

---
title: 'Complete API Reference'
description: 'Full API documentation for MilliRules: every public class and method with parameters, return types, and PHP examples, from Rules::create() to ArgumentSchema.'
menu_order: 30
---

# Complete API Reference

This comprehensive API reference documents all public classes, methods, interfaces, and functions in MilliRules.

## Table of Contents

- [Core Classes](#core-classes)
- [Builders](#builders)
- [Interfaces](#interfaces)
- [Base Classes](#base-classes)

---

## Core Classes

### MilliRules

Main entry point for initializing and executing rules.

**Namespace**: `MilliRules`

#### Methods

##### `init(?array $package_names = null, ?array $packages = null): array`

Initialize MilliRules and load packages.

**Parameters**:
- `$package_names` (array|null): Array of package names to load (null = auto-load all available)
- `$packages` (array|null): Array of PackageInterface instances to register (null = register defaults)

**Returns**: `array` - Array of loaded package names

**Example**:
```php
// Auto-load available packages
MilliRules::init();

// Load specific packages by name
MilliRules::init(['PHP', 'WP']);

// Load with custom package instances
$custom = new CustomPackage();
MilliRules::init(null, [$custom]);
```

---

##### `execute_rules(?array $allowed_packages = null, array $context = []): array`

Execute all registered rules.

**Parameters**:
- `$allowed_packages` (array|null): Array of package names to use (null = all loaded)
- `$context` (array): Additional context data (merges with auto-built context)

**Returns**: `array` - Execution result with statistics

```php
[
    'rules_processed' => int,   // Total rules evaluated
    'rules_skipped' => int,     // Rules skipped
    'rules_matched' => int,     // Rules with matching conditions
    'actions_executed' => int,  // Actions executed
    'context' => array,         // Execution context
]
```

**Example**:
```php
// Execute all rules
$result = MilliRules::execute_rules();

// Execute only PHP rules
$result = MilliRules::execute_rules(['PHP']);

// Execute with custom context
$result = MilliRules::execute_rules(null, ['custom_data' => 'value']);
```

---

##### `get_loaded_packages(): array`

Get names of all loaded packages.

**Returns**: `array` - Array of package names

**Example**:
```php
$packages = MilliRules::get_loaded_packages();
// ['PHP', 'WP']
```

---

##### `build_context(): array`

Build context from all loaded packages.

**Returns**: `array` - Aggregated context data

**Example**:
```php
$context = MilliRules::build_context();
/*
[
    'request' => [...],
    'wp' => [...],
]
*/
```

---

### Rules

Fluent interface for creating and registering rules.

**Namespace**: `MilliRules`

> [!NOTE]
> **Naming Convention**: All fluent API methods can be called in either `snake_case` or `camelCase`. For example, `->when_all()` and `->whenAll()` are equivalent, as are `->set_conditions()` and `->setConditions()`. This applies to all builder methods on `Rules`, `ConditionBuilder`, and `ActionBuilder`. The documentation uses `snake_case` throughout, but use whichever style fits your project.

#### Methods

##### `create(string $id, ?string $type = null): Rules`

Create a new rule.

**Parameters**:
- `$id` (string): Unique rule identifier
- `$type` (string|null): Rule type (`'php'` or `'wp'`), auto-detected if null

**Returns**: `Rules` - Rule builder instance

**Example**:
```php
$rule = Rules::create('my_rule');
$rule = Rules::create('wp_rule', 'wp');
```

---

##### `title(string $title): Rules`

Set rule title.

**Parameters**:
- `$title` (string): Human-readable title

**Returns**: `Rules` - Fluent interface

**Example**:
```php
Rules::create('my_rule')
    ->title('My Custom Rule')
    ->register();
```

---

##### `order(int $order): Rules`

Set execution order.

**Parameters**:
- `$order` (int): Order value (lower = executes first)

**Returns**: `Rules` - Fluent interface

**Example**:
```php
Rules::create('my_rule')
    ->order(10) // Execute at priority 10
    ->register();
```

---

##### `enabled(bool $enabled): Rules`

Enable or disable rule.

**Parameters**:
- `$enabled` (bool): Whether rule is enabled

**Returns**: `Rules` - Fluent interface

**Example**:
```php
Rules::create('my_rule')
    ->enabled(false) // Disable rule
    ->register();
```

---

##### `lock(): Rules`

Lock the rule to prevent overwriting or unregistering.

Locked rules cannot be overwritten by another rule with the same ID, nor can they be unregistered. This guards the entire rule — conditions, actions, and metadata — from replacement.

**Returns**: `Rules` - Fluent interface

**Example**:
```php
// Lock a safety-critical rule
Rules::create('no-cache-post')->lock()->order(0)
    ->when_all()->request_method('POST')
    ->then()->set_cache(false)->lock()
    ->register();

// Attempting to overwrite is silently rejected
Rules::create('no-cache-post')  // Same ID — rejected
    ->when_all()
    ->then()->set_cache(true)
    ->register();

// Attempting to unregister is also rejected
Rules::unregister('no-cache-post');  // Returns false
```

**Key Points**:
- Protects the rule definition (conditions + actions + metadata)
- Separate from `ActionBuilder::lock()` which locks action *execution*
- Use both together for maximum protection on core rules

---

##### `when(): ConditionBuilder`

Start building conditions with match_all logic.

**Returns**: `ConditionBuilder` - Condition builder instance

**Example**:
```php
Rules::create('my_rule')
    ->when()
        ->request_url('/api/*')
        ->request_method('GET')
    ->then()
        ->custom('action')
    ->register();
```

---

##### `when_all(): ConditionBuilder`

Start building conditions with ALL logic (AND).

**Returns**: `ConditionBuilder`

**Example**:
```php
Rules::create('my_rule')
    ->when_all()
        ->condition1()
        ->condition2()
    ->then()->custom('action')
    ->register();
```

---

##### `when_any(): ConditionBuilder`

Start building conditions with ANY logic (OR).

**Returns**: `ConditionBuilder`

**Example**:
```php
Rules::create('my_rule')
    ->when_any()
        ->condition1()
        ->condition2()
    ->then()->custom('action')
    ->register();
```

---

##### `when_none(): ConditionBuilder`

Start building conditions with NONE logic (NOT).

**Returns**: `ConditionBuilder`

**Example**:
```php
Rules::create('my_rule')
    ->when_none()
        ->condition1()
        ->condition2()
    ->then()->custom('action')
    ->register();
```

---

##### `and(): Rules`

Finalize the current condition group and prepare for the next one. Used to chain multiple condition groups with different match types. All groups are combined with AND logic.

**Returns**: `Rules` - Fluent interface (call `when_all()`, `when_any()`, or `when_none()` next)

**Example**:
```php
Rules::create('my_rule')
    ->when_any()
        ->post_type('page')
        ->post_type('post')
    ->and()->when_none()
        ->user_role('subscriber')
    ->then()
        ->custom('action')
    ->register();
```

---

##### `then(?array $actions = null): ActionBuilder`

Start building actions.

**Parameters**:
- `$actions` (array|null): Array of action configurations (optional)

**Returns**: `ActionBuilder` - Action builder instance

**Example**:
```php
Rules::create('my_rule')
    ->when()->request_url('*')
    ->then()
        ->custom('action1')
        ->custom('action2')
    ->register();
```

---

##### `on(string $hook, int $priority = 10): Rules`

Register rule on WordPress hook.

**Parameters**:
- `$hook` (string): WordPress hook name
- `$priority` (int): Hook priority

**Returns**: `Rules` - Fluent interface

**Example**:
```php
Rules::create('my_rule', 'wp')
    ->on('init', 10)
    ->when()->is_user_logged_in()
    ->then()->custom('action')
    ->register();
```

---

##### `register(): void`

Register rule with MilliRules.

If a rule with the same ID already exists, the one with the **higher `order()` wins** — and on a tie the incoming rule replaces the existing one, so a stored rule can take over a built-in registered with the same number. Since the default order is `10` on both sides, re-registering an ID replaces it unless you have set the orders apart.

A rule that loses is discarded with a warning; the ID keeps the rule that was already there. This makes the outcome independent of which file happened to load first. [Locked rules](#lock-rules) are never replaced, at any order.

Use [`discarded_orders()`](#static-discarded_ordersstring-rule_id-array) to find out whether a rule you cannot see is competing for the same ID.

**Returns**: `void`

**Example**:
```php
Rules::create('my_rule')
    ->when()->request_url('*')
    ->then()->custom('action')
    ->register(); // Must call to activate rule

// Same ID, same default order → replaces the rule
Rules::create('my_rule')
    ->when()->request_url('/api/*')
    ->then()->custom('different_action')
    ->register(); // Replaces previous 'my_rule'

// Same ID, lower order → discarded, the order 20 rule stays
Rules::create('ranked')->order(20)->then()->custom('a')->register();
Rules::create('ranked')->order(10)->then()->custom('b')->register(); // ignored
```

> **Rules registered after their phase has run**
>
> A `php`-typed rule registered after the PHP phase has already executed can never run — that phase happens once, before the framework loads. Such a rule is moved to the WordPress phase automatically, with a debug log line, so it still governs the response instead of failing silently.

---

##### `unregister(string $rule_id): bool`

Remove a rule by its ID.

**Parameters**:
- `$rule_id` (string): The ID of the rule to remove

**Returns**: `bool` - True if rule was found and removed, false otherwise

**Example**:
```php
// Remove a rule
$removed = Rules::unregister('my_rule');

// Check if removal was successful
if ($removed) {
    error_log('Rule was removed');
} else {
    error_log('Rule not found');
}

// Use case: Child theme disabling parent rule
Rules::unregister('parent_theme_cache_rule');

// Use case: Environment-specific disabling
if (wp_get_environment_type() === 'production') {
    Rules::unregister('debug_logging_rule');
}
```

---

##### `register_condition(string $type, callable $callback): ConditionMeta`

Register custom condition callback.

Returns a `ConditionMeta` instance for fluent declaration of metadata (label, description, categories, operators, arguments).

**Parameters**:
- `$type` (string): Condition type identifier
- `$callback` (callable): Callback function `function($args, Context $context): bool`

**Returns**: `ConditionMeta` — fluent metadata declaration for the registered condition

**Example**:
```php
// Simple condition (return value can be ignored)
Rules::register_condition('is_weekend', function($args, Context $context) {
    return date('N') >= 6;
});

// With full metadata for UI introspection
Rules::register_condition('is_weekend', function($args, Context $context) {
    return date('N') >= 6;
})
    ->label('Is Weekend')
    ->description('Matches on Saturdays and Sundays.')
    ->categories('date')
    ->operators('=', '!=');
```

For class-based conditions, override `set_meta()` on your `BaseCondition` subclass:

```php
class RequestUrl extends BaseCondition {
    public static function set_meta(ConditionMeta $meta): void
    {
        $meta
            ->label('Request URL')
            ->description('Match the current request URL.')
            ->categories('request')
            ->operators('=', '!=', 'LIKE', 'REGEXP', 'IN', 'NOT IN')
            ->args()
                ->string('value')->label('URL Pattern')->required();
    }
}
```

---

##### `get_condition_meta(string $type): ?ConditionMeta`

Get the full metadata for a registered condition type.

Resolves metadata from either the callback-based registry (populated by `register_condition()`) or the class-based `BaseCondition::set_meta()` method. For class-based conditions, the argument mapping from `BaseCondition::get_argument_mapping()` is automatically included.

Results are cached per type.

**Parameters**:
- `$type` (string): Condition type identifier

**Returns**: `ConditionMeta|null` — metadata for the condition, or `null` if not found

**Example**:
```php
$meta = Rules::get_condition_meta('request_url');
if ($meta) {
    $label     = $meta->get_label();            // 'Request URL'
    $operators = $meta->get_operators();         // ['=', '!=', 'LIKE', ...]
    $mapping   = $meta->get_argument_mapping();  // ['value']
    $args      = $meta->get_arguments();         // array<ArgumentSchema>
    $data      = $meta->to_array();              // For REST/JSON serialization
}
```

---

#### `ConditionMeta` — fluent condition metadata

`ConditionMeta` is the metadata container for condition types. Parallel to `ActionMeta` but with operators instead of scope.

##### Core fields

- `->label(string $label)` — human-readable name
- `->description(string $description)` — help text
- `->categories(string ...$categories)` — one or more UI grouping categories
- `->operators(string ...$operators)` — supported comparison operators (e.g., `'='`, `'!='`, `'LIKE'`, `'IN'`)
- `->argument_mapping(array $mapping)` — how builder args map to config keys (auto-set for class-based conditions)
- `->args()` → `ArgumentsBuilder` — same walking-builder pattern as ActionMeta
- `->extend(string $key, $value)` — plugin-specific metadata bag

##### `->to_array(): array` — wire format

```php
[
    'type'             => string,
    'label'            => string,
    'description'      => string,
    'categories'       => array<int, string>,
    'operators'        => array<int, string>,
    'argument_mapping' => array<int, string>,
    'arguments'        => array<int, array>,
    'extensions'       => array<string, mixed>,
]
```

---

##### `register_action(string $type, callable $callback): ActionMeta`

Register custom action callback.

Returns an `ActionMeta` instance for fluent declaration of action metadata (scope, label, description, category).

**Parameters**:
- `$type` (string): Action type identifier
- `$callback` (callable): Callback function `function($args, Context $context): void`

**Returns**: `ActionMeta` — fluent metadata declaration for the registered action

**Example**:
```php
// Simple action (return value can be ignored)
Rules::register_action('log', function($args, Context $context) {
    error_log($args['value'] ?? '');
});

// Paired actions with shared scope (value-level locking when locked)
Rules::register_action('add_flag', $addCallback)->scope('flag');
Rules::register_action('remove_flag', $removeCallback)->scope('flag');

// With full metadata for UI introspection
Rules::register_action('add_flag', $addCallback)
    ->scope('flag')
    ->label('Add Flag')
    ->description('Tag the response with a flag for bulk invalidation.')
    ->categories('flags')
    ->args()
        ->string(0)->label('Flag')->required();
```

For class-based actions, override two static methods on your `BaseAction` subclass:

- `get_scope()` — returns the lock scope as a plain string. **Must not use framework-specific functions** (e.g., translation) because the engine calls it during rule execution, which may happen during early bootstrap.
- `set_meta(ActionMeta $meta)` — configures consumer-facing metadata (label, description, categories, args). Called only by consumer code like UIs or REST endpoints, which always run after the framework has initialized.

```php
class AddFlag extends BaseAction {
    // Engine-relevant. Called during early bootstrap — plain strings only.
    public static function get_scope(): string
    {
        return 'flag';
    }

    // Consumer-relevant. Called after framework initialization.
    public static function set_meta(ActionMeta $meta): void
    {
        $meta
            ->label('Add Flag')
            ->description('Tag the response with a flag.')
            ->categories('flags');
    }

    public function execute(Context $context): void { /* ... */ }
    public function get_type(): string { return 'add_flag'; }
}
```

**Why this signature**: the engine owns the action type string (from the registration lookup), so it constructs the `ActionMeta` and passes it in. Subclasses can't accidentally set the wrong type or forget to call `parent::set_meta()` — there's no boilerplate to forget.

**Why scope is split from `set_meta()`**: the engine reads scope during rule execution, which may happen during early bootstrap before the application framework has fully initialized. If scope were set inside `set_meta()` alongside framework-dependent calls (e.g., translation functions), the engine couldn't read it safely. The split keeps the hot path runtime-safe.

---

##### `get_action_scope(string $type): string`

Get the lock scope for an action type — **engine hot path, runtime-safe**.

Fast-path accessor that never calls `set_meta()`. Used internally by `RuleEngine::build_lock_key()`. Safe to call during early bootstrap before the application framework has initialized.

**Parameters**:
- `$type` (string): Action type identifier

**Returns**: `string` — the scope identifier, or `''` for unknown or unscoped actions

**Example**:
```php
$scope = Rules::get_action_scope('add_flag');  // 'flag'
$scope = Rules::get_action_scope('set_ttl');   // '' (unscoped)
```

Resolution order:
1. Callback-based: reads from the meta set at registration time (`register_action()->scope()`).
2. Class-based: calls `$class::get_scope()` directly.

Results are cached per type.

---

##### `get_action_meta(string $type): ?ActionMeta`

Get the full metadata for a registered action type.

Resolves metadata from either the callback-based registry (populated by `register_action()`) or the class-based `BaseAction::set_meta()` method. Results are cached per type.

**May require framework functions**: for class-based actions, this calls `set_meta()`, which may use framework-specific functions (e.g., translation). Do NOT call this during early bootstrap before the framework has initialized — use `get_action_scope()` instead if you only need the scope.

**Parameters**:
- `$type` (string): Action type identifier

**Returns**: `ActionMeta|null` — metadata for the action, or `null` if not found

**Example**:
```php
$meta = Rules::get_action_meta('add_flag');
if ($meta) {
    $label       = $meta->get_label();        // 'Add Flag'
    $scope       = $meta->get_scope();        // 'flag' (synced from $class::get_scope())
    $categories  = $meta->get_categories();    // ['flags']
    $arguments   = $meta->get_arguments();    // array<ArgumentSchema>
    $icon        = $meta->get_extension('millicache:icon'); // plugin-specific
    $data        = $meta->to_array();         // For REST/JSON serialization
}
```

---

##### `get_all_condition_metas(): array`

Get metadata for all available condition types.

Discovers all condition types from both class-based (via namespace scanning) and callback-based registrations, and resolves their metadata.

Results are cached after first call. The cache is cleared when new conditions are registered.

**Returns**: `array<string, ConditionMeta>` — map of type string to ConditionMeta

**Example**:
```php
$conditions = Rules::get_all_condition_metas();

foreach ($conditions as $type => $meta) {
    echo $type;                    // 'post_type'
    echo $meta->get_label();       // 'Post Type'
    echo $meta->get_operators();   // ['=', '!=', 'IN', 'NOT IN']
}
```

---

##### `get_all_action_metas(): array`

Get metadata for all available action types.

Discovers all action types from both class-based (via namespace scanning) and callback-based registrations, and resolves their metadata.

Results are cached after first call. The cache is cleared when new actions are registered.

**Returns**: `array<string, ActionMeta>` — map of type string to ActionMeta

**Example**:
```php
$actions = Rules::get_all_action_metas();

foreach ($actions as $type => $meta) {
    echo $type;                  // 'add_flag'
    echo $meta->get_label();     // 'Add Flag'
    echo $meta->get_scope();     // 'flag'
}
```

---

##### `get_all_placeholder_metas(): array`

Get every placeholder category a rule value may use, as `{category.key}`.

Placeholders come from two places, and a caller that wants to tell a valid placeholder from a mistyped one needs both:

- **Context classes** found in the registered `Contexts` namespaces (`source: 'context'`)
- **Categories registered** with [`register_placeholder()`](#register_placeholderstring-category-callable-resolver) (`source: 'custom'`)

This matters because an unresolvable placeholder is **left in the value verbatim** rather than raising an error — so `{reqest.host}` silently turns a per-visitor value into a constant string. Validate against this catalog before storing a rule.

An empty `keys` array means the key is chosen by the caller rather than by the context — a cookie name, a query parameter, a WordPress query var. A non-empty `keys` array is closed: anything outside it will not resolve.

Contexts that are unavailable in the current environment are omitted, so a bare PHP process does not advertise `{post.id}`. Where a context class and a custom resolver share a name, the context wins, since it is the one carrying a label and a key set.

Results are **not** cached: custom resolvers can be registered at any point in the request.

`description` is one sentence naming a concrete `{category.key}`, written for both a person choosing a placeholder in a rule builder and an AI client that has to pick one without guessing. It is empty for a custom resolver, which has no way to supply one. The strings are raw English by design — consumers exclude vendored dependencies from POT extraction, so translate on your side and use these as the fallback.

**Returns**: `array<string, array{label: string, description: string, keys: array<int, string>, source: string}>` — map of category to metadata

**Example**:
```php
$placeholders = Rules::get_all_placeholder_metas();

foreach ($placeholders as $category => $meta) {
    echo $category;              // 'request'
    echo $meta['label'];         // 'Request'
    echo $meta['description'];   // 'The current HTTP request, for example {request.host} ...'
    print_r($meta['keys']);      // ['method', 'uri', 'scheme', 'host', ...]
    echo $meta['source'];        // 'context'
}

// Reject a mistyped placeholder before the rule is stored.
function placeholder_is_known(string $placeholder): bool {
    $catalog = Rules::get_all_placeholder_metas();
    [$category, $key] = array_pad(explode('.', $placeholder, 2), 2, '');

    if (! isset($catalog[$category])) {
        return false;
    }

    $keys = $catalog[$category]['keys'];

    // Empty keys = caller-named (cookie, param, header, query var).
    return empty($keys) || in_array($key, $keys, true);
}

placeholder_is_known('request.host');      // true
placeholder_is_known('reqest.host');       // false — mistyped category
placeholder_is_known('request.hostname');  // false — not a request key
placeholder_is_known('cookie.anything');   // true  — caller-named
```

---

##### `validate(array $rule): array`

Validate a rule configuration against the engine's registry.

Checks that the rule's `match_type`, condition types, operators, action types, and action arguments are all recognized by the engine. Returns an array of plain-English error strings (empty array = valid).

This validates engine-level concerns only. Storage-layer concerns (ID format, title length, order range) are the consumer's responsibility.

**Parameters**:
- `$rule` (array): The rule configuration array

**Returns**: `array<int, string>` — error messages; empty if valid

**Example**:
```php
$errors = Rules::validate([
    'match_type' => 'all',
    'conditions' => [
        ['type' => 'post_type', 'operator' => '=', 'value' => 'page'],
    ],
    'actions' => [
        ['type' => 'set_ttl', 'ttl' => 3600],
    ],
]);

if (! empty($errors)) {
    // Handle validation errors.
    foreach ($errors as $error) {
        echo $error; // "Condition #1 has unknown type 'foo'."
    }
}
```

**Validates**:
- `match_type` against `Rules::MATCH_TYPES`
- Condition types exist in the registry
- Condition operators are in the condition's declared operators (if any)
- Condition groups recursively (match_type + nested conditions)
- Action types exist in the registry
- Action arguments pass `ArgumentSchema::validate()`

---

#### Constants

##### `Rules::MATCH_TYPES`

Array of match types supported by the rule engine.

```php
Rules::MATCH_TYPES  // ['all', 'any', 'none']
```

Use this instead of hardcoding match type values:

```php
// In validation
if (! in_array($match_type, Rules::MATCH_TYPES, true)) { /* ... */ }

// In UI dropdowns
foreach (Rules::MATCH_TYPES as $type) { /* ... */ }
```

---

#### `ActionMeta` — fluent action metadata

`ActionMeta` is the declarative metadata container for an action type. Obtain it from `Rules::register_action()` (for callback-based actions) or override `BaseAction::set_meta()` (for class-based actions).

##### Core fields

- `->scope(string $scope)` — lock scope (engine-relevant; see [Scoped Locking](/docs/millirules/02-core-concepts/01-concepts#scoped-locking-for-paired-actions))
- `->label(string $label)` — human-readable name
- `->description(string $description)` — help text
- `->categories(string ...$categories)` — one or more UI grouping categories

##### `->args(): ArgumentsBuilder`

Enter the arguments declaration context. Returns an internal `ArgumentsBuilder` instance that collects argument schemas via type factories. The builder is cached — calling `args()` multiple times returns the same instance.

```php
$meta->args()
    ->integer('ttl')->format('seconds')->default(3600)->min(0)
    ->string('reason')->default('');
```

Inside the builder, each type factory (`->integer($key)`, `->string($key)`, etc.) creates a new `ArgumentSchema` and returns it for continued configuration. To declare another argument, just call another type factory — it "walks" back to the builder and starts a new one.

Preserves declaration order (no auto-sorting). Any meta-level methods called after `->args()` (like `->extend()` or `->categories()`) are automatically forwarded back to the `ActionMeta` via `__call()`, so the chain can continue seamlessly:

```php
$meta
    ->label(__('Set TTL'))
    ->args()
        ->integer('ttl')->default(3600)
        ->string('reason')->default('')
    ->extend('millicache:icon', 'clock');  // forwarded to $meta
```

See [ArgumentSchema](#argumentschema--argument-metadata) below for the per-argument API.

##### `->extend(string $key, mixed $value): self`

Attach plugin-specific metadata under a namespaced key. MilliRules stores the value but never interprets it. Use this for anything that isn't part of MilliRules core: icons, conditional visibility rules, documentation URLs, plugin-defined widgets.

```php
->extend('millicache:icon', 'clock')
->extend('seo-redirects:default_status', 301)
->extend('docs:url', 'https://example.com/actions/set-ttl')
```

**Namespacing convention**: use `plugin-slug:field-name` to avoid collisions. MilliRules does not enforce this — the convention is the contract.

##### Extension bag getters

- `->get_extension(string $key): mixed|null` — returns the value, or `null` if not set
- `->has_extension(string $key): bool` — distinguishes "set to null" from "not set"
- `->get_extensions(): array<string, mixed>` — returns the full keyed bag

##### `->to_array(): array` — wire format

```php
[
    'type'        => string,
    'scope'       => string,
    'label'       => string,
    'description' => string,
    'categories'  => array<int, string>,
    'arguments'   => array<int, array>,    // each via ArgumentSchema::to_array()
    'extensions'  => array<string, mixed>, // plugin-specific bag
]
```

This is the stable, REST-serializable format for transmitting action metadata to consumers.

---

#### `ArgumentSchema` — argument metadata

`ArgumentSchema` is the declarative format for action arguments. Consumer code never references this class directly — schemas are obtained via `$meta->args()->type($key)`. The class is internal but documented here so you understand what your `->args()` chain is producing.

Every consumer that introspects actions (UIs, CLIs, docs generators, validators) reads the same schema. MilliRules' `RuleEngine` does **not** use `ArgumentSchema` at runtime — it's purely metadata.

##### Type system

Six core types cover all engine-level data shapes:

| Type       | Coercion          | min/max semantics | Default    |
|------------|-------------------|-------------------|------------|
| `string`   | `(string)` cast   | length bounds     | `''`       |
| `integer`  | `(int)` cast      | value bounds      | `0`        |
| `number`   | `(float)` cast    | value bounds      | `0.0`      |
| `boolean`  | truthy check      | —                 | `false`    |
| `choice`   | pass-through      | —                 | first option or `null` |
| `choices`  | `(array)` + filter to valid options | — | `[]`       |

Everything else (`url`, `email`, `seconds`, `regex`, `date`, etc.) is expressible as a core type + `format`:

```php
$meta->args()
    ->integer('ttl')->format('seconds')     // TTL input
    ->string('homepage')->format('url')     // URL field
    ->string('contact')->format('email')    // email field
    ->string('pattern')->format('regex');   // regex pattern
```

MilliRules stores `format` but never interprets it. Consumers pick their own vocabulary and handle format-specific rendering/validation.

##### Creating schemas

Schemas are created exclusively via the builder's type factories, obtained from `$meta->args()`:

```php
$meta->args()
    ->string($key)      // $key is int|string
    ->integer($key)
    ->number($key)
    ->boolean($key)
    ->choice($key)
    ->choices($key);
```

You never write `new ArgumentSchema(...)` yourself.

##### Walking: chain to the next argument

Type factories are also available **on an existing schema** and delegate back to the builder to start a new argument:

```php
$meta->args()
    ->integer('ttl')->default(3600)      // schema for 'ttl'
    ->string('reason')->default('');     // ->string() walks back; new schema for 'reason'
```

This is why you can chain multiple arguments in a single fluent expression without restarting from `$meta->args()`.

##### Fluent setters (config)

- `->format(string $format)` — consumer-defined format hint
- `->label(string $label)` — human-readable name
- `->description(string $description)` — help text
- `->required(bool $required = true)` — mark as mandatory
- `->default(mixed $value)` — default value (rejects closures)
- `->min(int $min)` / `->max(int $max)` — length (string) or value (integer/number) bounds; throws on other types
- `->options(array $options)` — allowed values for `choice`/`choices` types; throws on other types

##### `options()` format

Accepts either simple or structured form:

```php
// Simple — value == label
->options(['GET', 'POST', 'PUT'])

// Structured — separate value and label
->options([
    ['value' => 'get',  'label' => 'GET Request'],
    ['value' => 'post', 'label' => 'POST Request'],
])
```

Stored internally as the structured form. `to_array()` always emits the structured form.

##### Runtime guards

Calling incompatible setters throws `InvalidArgumentException` at declaration time (i.e., at class-load time for class-based actions):

```php
$meta->args()->string('k')->min(5);              // OK: length bound
$meta->args()->boolean('k')->min(5);             // ✗ throws
$meta->args()->integer('k')->options(['a', 'b']); // ✗ throws
```

##### Getters

- `->get_key(): int|string`
- `->get_type(): string`
- `->get_format(): string`
- `->get_label(): string`
- `->get_description(): string`
- `->get_default(): mixed`
- `->has_default(): bool` — distinguishes "default is null" from "no default set"
- `->is_required(): bool`
- `->get_min(): ?int`
- `->get_max(): ?int`
- `->get_options(): array`

##### `validate(mixed $value): ?string`

Consumer utility. Returns `null` if the value is valid, or a plain English error message string if invalid. MilliRules ships no translation layer — consumers wrap the returned string in their own i18n if needed.

```php
// Retrieve schemas via the meta's get_arguments():
$schemas = Rules::get_action_meta('set_ttl')->get_arguments();
$schema  = $schemas[0];

$schema->validate(50);        // null (valid)
$schema->validate(150);       // "Argument 'ttl' must be at most 100"
$schema->validate('abc');     // "Argument 'ttl' must be an integer"
```

Note: `RuleEngine` does not call `validate()`. It's an opt-in utility for consumers (validators, UIs, CLIs).

##### `sanitize(mixed $value): mixed`

Consumer utility. Coerces a raw value to the declared type.

```php
$integer_schema->sanitize('3600');   // 3600
$boolean_schema->sanitize('yes');    // true
$choices_schema->sanitize(['a', 'invalid', 'b']);  // ['a', 'b']
```

Null values are replaced with the default if set, otherwise the type's zero value (`''`, `0`, `0.0`, `false`, first option, or `[]`).

##### `to_array(): array` — wire format

```php
[
    'key'         => int|string,
    'type'        => string,
    'format'      => string,
    'label'       => string,
    'description' => string,
    'default'     => mixed,
    'has_default' => bool,
    'required'    => bool,
    'min'         => int|null,
    'max'         => int|null,
    'options'     => array<int, array{value: mixed, label: string}>,
]
```

---

##### `register_placeholder(string $category, callable $resolver): void`

Register custom placeholder resolver.

**Parameters**:
- `$category` (string): Placeholder category
- `$resolver` (callable): Resolver function `function($context, $parts): string`

**Returns**: `void`

**Example**:
```php
Rules::register_placeholder('custom', function($context, $parts) {
    return $context['custom'][$parts[0]] ?? '';
});
```

---

## Builders

### ConditionBuilder

Fluent builder for rule conditions.

**Namespace**: `MilliRules`

#### Methods

##### `match_all(): ConditionBuilder`

Use AND logic for conditions.

**Returns**: `ConditionBuilder`

---

##### `match_any(): ConditionBuilder`

Use OR logic for conditions.

**Returns**: `ConditionBuilder`

---

##### `match_none(): ConditionBuilder`

Use NOT logic for conditions.

**Returns**: `ConditionBuilder`

---

##### `custom(string $type, $arg = null): ConditionBuilder`

Add custom condition.

**Parameters**:
- `$type` (string): Condition type
- `$arg` (mixed): Condition argument (value, config array, etc.)

**Returns**: `ConditionBuilder`

**Example**:
```php
->when()
    ->custom('is_weekend')
    ->custom('time_range', ['start' => 9, 'end' => 17])
```

---

##### `add_namespace(string $namespace): ConditionBuilder`

Add condition namespace for class resolution.

**Parameters**:
- `$namespace` (string): Fully qualified namespace

**Returns**: `ConditionBuilder`

---

##### `__call(string $method, array $args): mixed`

Magic method for dynamic condition creation.

Converts method calls to condition types:
- `request_url()` → `RequestUrlCondition`
- `is_user_logged_in()` → `IsUserLoggedInCondition`

---

### ActionBuilder

Fluent builder for rule actions.

**Namespace**: `MilliRules`

#### Methods

##### `custom(string $type, $arg = null): ActionBuilder`

Add custom action.

**Parameters**:
- `$type` (string): Action type
- `$arg` (mixed): Action argument (config array, value, etc.)

**Returns**: `ActionBuilder`

**Example**:
```php
->then()
    ->custom('log', ['value' => 'message'])
    ->custom('send_email', ['to' => 'admin@example.com'])
```

---

##### `lock(): ActionBuilder`

Mark the last action as locked.

Locked actions prevent subsequent actions from changing the same setting. How locking works depends on whether the action was registered with a **scope**:

- **Unscoped actions** (default): locks by action type — `set_ttl(300)->lock()` blocks all `set_ttl` calls
- **Scoped actions**: locks by scope + value — `add_flag('x')->lock()` only blocks operations on `'x'` within the same scope

**Returns**: `ActionBuilder`

**Example — unscoped (type-level locking)**:
```php
// Rule 1 (order: 10) - Disable cache for logged-in users
Rules::create('no-cache-logged-in')->order(10)
    ->when()->is_user_logged_in()
    ->then()->do_cache(false)->lock()  // Lock the cache setting
    ->register();

// Rule 2 (order: 20) - This cache action will be IGNORED
Rules::create('cache-api')->order(20)
    ->when()->request_url('/api/*')
    ->then()->do_cache(true)  // Blocked - do_cache is locked
    ->register();
```

**Example — scoped (value-level locking)**:
```php
// Consumer registers paired actions with shared scope
Rules::register_action('add_flag', $callback)->scope('flag');
Rules::register_action('remove_flag', $callback)->scope('flag');

// Lock a specific flag value
->then()->add_flag('system-flag')->lock()  // Locks 'flag:system-flag'

// Later rules:
->then()->add_flag('custom-flag')          // Allowed — different lock key
->then()->remove_flag('system-flag')       // Blocked — same lock key
```

**Key Points**:
- Unscoped: locks are per action type
- Scoped: locks are per scope + value (cross-type within the same scope)
- Different action types/scopes can still execute
- Lock only applies if the rule's conditions match
- Locks reset on each rule execution

---

##### `add_namespace(string $namespace): ActionBuilder`

Add action namespace for class resolution.

**Parameters**:
- `$namespace` (string): Fully qualified namespace

**Returns**: `ActionBuilder`

---

##### `__call(string $method, array $args): mixed`

Magic method for dynamic action creation.

---

## Interfaces

### PackageInterface

Interface for all packages.

**Namespace**: `MilliRules\Interfaces`

#### Methods

##### `get_name(): string`

Get unique package name.

**Returns**: `string` - Package name

---

##### `get_namespaces(): array`

Get condition and action namespaces.

**Returns**: `array` - Array of namespace strings

---

##### `is_available(): bool`

Check if package is available in current environment.

**Returns**: `bool` - True if available

---

##### `get_required_packages(): array`

Get required package names.

**Returns**: `array` - Array of package names

---

##### `build_context(): array`

Build context data for this package.

**Returns**: `array` - Context data

---

##### `get_placeholder_resolver(array $context)`

Get placeholder resolver for this package.

**Parameters**:
- `$context` (array): Execution context

**Returns**: `callable|null` - Resolver function or null

---

##### `register_rule(array $rule, array $metadata): void`

Register rule with package.

**Parameters**:
- `$rule` (array): Rule configuration
- `$metadata` (array): Rule metadata

**Returns**: `void`

---

##### `execute_rules(array $rules, array $context): array`

Execute rules for this package.

**Parameters**:
- `$rules` (array): Rules to execute
- `$context` (array): Execution context

**Returns**: `array` - Execution result

---

### ConditionInterface

Interface for all conditions.

**Namespace**: `MilliRules\Interfaces`

#### Methods

##### `matches(array $context): bool`

Check if condition matches.

**Parameters**:
- `$context` (array): Execution context

**Returns**: `bool` - True if matches

---

##### `get_type(): string`

Get condition type identifier.

**Returns**: `string` - Condition type

---

### ActionInterface

Interface for all actions.

**Namespace**: `MilliRules\Interfaces`

#### Methods

##### `execute(Context $context): void`

Execute action.

**Parameters**:
- `$context` (array): Execution context

**Returns**: `void`

---

##### `get_type(): string`

Get action type identifier.

**Returns**: `string` - Action type

---

## Base Classes

### BasePackage

Abstract base class for packages.

**Namespace**: `MilliRules\Packages`

Provides default implementations for most `PackageInterface` methods.

**Must Override**:
- `get_name(): string`
- `get_namespaces(): array`
- `is_available(): bool`

**Can Override**:
- `get_required_packages(): array` - Defaults to `[]`
- `build_context(): array` - Defaults to `[]`
- `get_placeholder_resolver()` - Defaults to `null`

---

### BaseCondition

Abstract base class for conditions.

**Namespace**: `MilliRules\Conditions`

Provides operator support and comparison logic.

#### Methods

##### `__construct(array $config, Context $context)`

Constructor.

**Parameters**:
- `$config` (array): Condition configuration
- `$context` (array): Execution context

---

##### `matches(array $context): bool`

Check if condition matches (implemented).

**Parameters**:
- `$context` (array): Execution context

**Returns**: `bool`

---

##### `abstract protected function get_actual_value(Context $context)`

Get actual value to compare (must implement).

**Parameters**:
- `$context` (array): Execution context

**Returns**: `mixed` - Actual value

---

##### `static public function compare_values($actual, $expected, string $operator = '='): bool`

Compare values using operator.

**Parameters**:
- `$actual` (mixed): Actual value
- `$expected` (mixed): Expected value
- `$operator` (string): Comparison operator

**Returns**: `bool` - True if comparison matches

---

### BaseAction

Abstract base class for actions.

**Namespace**: `MilliRules\Actions`

Provides placeholder resolution.

#### Methods

##### `__construct(array $config, Context $context)`

Constructor.

**Parameters**:
- `$config` (array): Action configuration
- `$context` (array): Execution context

---

##### `protected function resolve_value(string $value): string`

Resolve placeholders in value.

**Parameters**:
- `$value` (string): Value with placeholders

**Returns**: `string` - Resolved value

---

##### `abstract public function execute(Context $context): void`

Execute action (must implement).

**Parameters**:
- `$context` (array): Execution context

**Returns**: `void`

---

## PackageManager

Static manager for packages.

**Namespace**: `MilliRules`

#### Methods

##### `static register_package(PackageInterface $package): void`

Register package.

**Parameters**:
- `$package` (PackageInterface): Package instance

**Returns**: `void`

---

##### `static load_packages(?array $package_names = null): array`

Load packages by name.

**Parameters**:
- `$package_names` (array|null): Package names (null = load all available)

**Returns**: `array` - Loaded package names

---

##### `static get_loaded_packages(): array`

Get loaded package instances.

**Returns**: `array` - Array of PackageInterface instances

---

##### `static get_loaded_package_names(): array`

Get loaded package names.

**Returns**: `array` - Array of package names

---

##### `static get_package(string $name): ?PackageInterface`

Get package by name.

**Parameters**:
- `$name` (string): Package name

**Returns**: `PackageInterface|null` - Package instance or null

---

##### `static is_package_loaded(string $name): bool`

Check if package is loaded.

**Parameters**:
- `$name` (string): Package name

**Returns**: `bool` - True if loaded

---

##### `static has_packages(): bool`

Check if any packages are loaded.

**Returns**: `bool` - True if packages exist

---

##### `static build_context(): array`

Build context from all loaded packages.

**Returns**: `array` - Aggregated context

---

##### `static discarded_orders(string $rule_id): array`

The orders of registrations that lost this ID to another rule.

The registry only ever holds the winner, so this is what tells a caller that a rule it cannot see is competing for the same ID — for example a stored rule whose `order()` sits below a built-in's. Each distinct order appears once, however many times it was attempted.

**Parameters**:
- `$rule_id` (string): The rule ID

**Returns**: `array<int, int>` - Orders that were discarded, empty if none

**Example**:
```php
Rules::create('ranked')->order(20)->then()->custom('a')->register();
Rules::create('ranked')->order(10)->then()->custom('b')->register();

PackageManager::discarded_orders('ranked'); // [10]
```

---

##### `static has_executed(string $name): bool`

Whether a package's rules have already run this request.

A rule registered for that package afterwards can no longer execute, which is why a late `php` rule is [moved to the WordPress phase](#register-void).

**Parameters**:
- `$name` (string): Package name, e.g. `'PHP'` or `'WP'`

**Returns**: `bool` - True if that phase has run

---

## RuleEngine

Rule execution engine.

**Namespace**: `MilliRules`

#### Methods

##### `execute(array $rules, array $context, ?array $allowed_packages = null): array`

Execute rules.

**Parameters**:
- `$rules` (array): Rules to execute
- `$context` (array): Execution context
- `$allowed_packages` (array|null): Allowed package names

**Returns**: `array` - Execution result

---

##### `static register_namespace(string $type, string $namespace): void`

Register namespace for class resolution.

**Parameters**:
- `$type` (string): Type (`'condition'` or `'action'`)
- `$namespace` (string): Namespace string

**Returns**: `void`

---

## Next Steps

- **[Real-World Examples](/docs/millirules/04-advanced/01-examples)** - See API usage in complete examples
- **[Getting Started](/docs/millirules/01-getting-started/01-introduction)** - Basic usage guide
- **[Building Rules](/docs/millirules/02-core-concepts/03-building-rules)** - Fluent API guide

---

**Ready for complete examples?** Continue to [Real-World Examples](/docs/millirules/04-advanced/01-examples) to see the API in action with full working code.

---

Canonical: https://www.millipress.com/docs/millirules/05-reference/04-changelog

---
title: 'MilliRules Changelog'
description: 'Version history for the MilliRules PHP rules engine: new features, bug fixes, and breaking changes for every release, from 0.1.0 to the current version.'
menu_order: 40
---

# Changelog

## [1.3.0](https://github.com/MilliPress/MilliRules/compare/v1.2.1...v1.3.0) (2026-08-14)


### Features

* Decide same-ID collisions by order instead of load sequence ([81f39bf](https://github.com/MilliPress/MilliRules/commit/81f39bf942e043bd40fca4cf04e1828194e410eb))
* Expose a catalog of available placeholders for rule builders ([1642240](https://github.com/MilliPress/MilliRules/commit/16422409658d491bd56162e8f28dbfec32bd6686))


### Bug Fixes

* Move a php rule to the WordPress phase when that phase is over ([0821a4a](https://github.com/MilliPress/MilliRules/commit/0821a4a65b4982ff3b41a391b7f79a9e300cd009))
* Resolve {cookie.x} and {param.x} from the data the engine builds ([0d0ab85](https://github.com/MilliPress/MilliRules/commit/0d0ab85eeb01a7ec9f63d051e86a921d855f8a34))
* Skip an action whose placeholder resolved to nothing ([949e7a2](https://github.com/MilliPress/MilliRules/commit/949e7a283599ec00b983c15ae5e0d031791a96c4))


### Refactoring

* **logger:** Standardize log message formatting for consistency ([54c6ba2](https://github.com/MilliPress/MilliRules/commit/54c6ba24c87d61dec044e817a34e07aa35b25470))
* Register package placeholders when the package registers ([90385ea](https://github.com/MilliPress/MilliRules/commit/90385eaa1fc96a8fbb5ac90c78fba5776e71face))

## [1.2.1](https://github.com/MilliPress/MilliRules/compare/v1.2.0...v1.2.1) (2026-07-16)


### Bug Fixes

* never let a foreign condition or action class fatal the request ([45c11bf](https://github.com/MilliPress/MilliRules/commit/45c11bf9a351e5d3e0ad2499774b1bb42adf3f14))

## [1.2.0](https://github.com/MilliPress/MilliRules/compare/v1.1.6...v1.2.0) (2026-07-10)


### Features

* expose which rules override an earlier rule of the same ID ([e1d04d9](https://github.com/MilliPress/MilliRules/commit/e1d04d9341142f699258770a4e545af88e4f81e2))


### Refactoring

* mark overrides on the rule instead of a side array ([4fe9ac7](https://github.com/MilliPress/MilliRules/commit/4fe9ac71909dc98c33b7f99793c61225c9f3720f))

## [1.1.6](https://github.com/MilliPress/MilliRules/compare/v1.1.5...v1.1.6) (2026-05-28)


### Features

* add current_site condition for multisite blog targeting ([ee02693](https://github.com/MilliPress/MilliRules/commit/ee0269375718d44d8925796106851d6ba0061837))
* register rules from array config via Rules::register_rule() ([2e4a8a5](https://github.com/MilliPress/MilliRules/commit/2e4a8a51b8064c2d5e3843f114f2bd8675e1562e))


### Bug Fixes

* **docs:** Update placeholder syntax from colon to dot notation ([ccd1243](https://github.com/MilliPress/MilliRules/commit/ccd12437cc759553d0a06414dd25180bf34a1acc))
* honor NOT IN semantics for conditions with array values ([1da7e65](https://github.com/MilliPress/MilliRules/commit/1da7e65e97ff53a650c656a6254dac3154b57c82))
* resolve nested {request:*} and {wp:*} placeholders ([6fab125](https://github.com/MilliPress/MilliRules/commit/6fab125bdc0e0810b6b882c33d45dd54cad867e8))


### Refactoring

* drop unreachable callable guard in register_condition/register_action ([dbbf8e7](https://github.com/MilliPress/MilliRules/commit/dbbf8e7ec9d0bbc933360f9aa98293e59774eed6))

## [1.1.5](https://github.com/MilliPress/MilliRules/compare/v1.1.4...v1.1.5) (2026-05-04)


### Bug Fixes

* honor -&gt;enabled(false) for non-WordPress rules ([ca54b36](https://github.com/MilliPress/MilliRules/commit/ca54b360aaf0e9bb876bd6648521376c76afd595))

## [1.1.4](https://github.com/MilliPress/MilliRules/compare/v1.1.3...v1.1.4) (2026-05-04)


### Bug Fixes

* execute WordPress-package rules via MilliRules::execute_rules() ([8c46c56](https://github.com/MilliPress/MilliRules/commit/8c46c5649d18f29335eea5f88cfedf8260f5bf3c))

## [1.1.3](https://github.com/MilliPress/MilliRules/compare/v1.1.2...v1.1.3) (2026-05-03)


### Bug Fixes

* defer rules referencing unloaded packages instead of misregistering as Core ([2ce0628](https://github.com/MilliPress/MilliRules/commit/2ce0628db0925689cced5ab7794aded9aaef0310))

## [1.1.2](https://github.com/MilliPress/MilliRules/compare/v1.1.1...v1.1.2) (2026-04-29)


### Bug Fixes

* prevent duplicate package registration when explicit type matches auto-detected package ([29becd4](https://github.com/MilliPress/MilliRules/commit/29becd4228818fdda9958db430e23cd24ef52076))

## [1.1.1](https://github.com/MilliPress/MilliRules/compare/v1.1.0...v1.1.1) (2026-04-24)


### Bug Fixes

* **discovery:** Find actions and conditions in Strauss-prefixed host plugins ([400ea12](https://github.com/MilliPress/MilliRules/commit/400ea120236d445b7936e9ef86c353fceae08a60))

## [1.1.0](https://github.com/MilliPress/MilliRules/compare/v1.0.0...v1.1.0) (2026-04-23)


### Features

* **actions:** Add value-level locking for paired actions via ActionMeta ([74edea9](https://github.com/MilliPress/MilliRules/commit/74edea900eba3fc89bb1864e78f756192efc66d1))
* **actions:** Allow actions to declare metadata for UI-driven rule builders ([3919f2b](https://github.com/MilliPress/MilliRules/commit/3919f2b8482574df9f358b33b04c17ffd018db8d))
* Add rule validation API and metadata discovery methods ([3ad448e](https://github.com/MilliPress/MilliRules/commit/3ad448e9bbf5911f7bf89311088370504f39ee85))
* **conditions:** Add and() connector for combining condition groups with different match types ([3f37214](https://github.com/MilliPress/MilliRules/commit/3f37214cb905e98c2d03c972a3067d5c37ad1904))
* **conditions:** Add format('pattern') to name fields that support wildcards ([c82baf3](https://github.com/MilliPress/MilliRules/commit/c82baf3b41809288bacec1d1d08fe1112939106b))
* **conditions:** Add metadata to all built-in conditions and auto-generate for WordPress conditionals ([0ec5e35](https://github.com/MilliPress/MilliRules/commit/0ec5e35702c61a055962086eae8b684602279f09))
* **conditions:** Add mode/accepts to schema, auto-infer pattern operators ([24dc931](https://github.com/MilliPress/MilliRules/commit/24dc9313fd94468d256c779260c3003ccf9455cb))
* **conditions:** Allow conditions to declare metadata for UI-driven rule builders ([8c2cf46](https://github.com/MilliPress/MilliRules/commit/8c2cf463b42c09f2353812df671c36be20ba4930))
* **conditions:** Parse condition description from WP function docblocks ([5db66d9](https://github.com/MilliPress/MilliRules/commit/5db66d9d71ac0ac0524069c0aab342376ad15bf9))
* **rules:** Add rule-level locking to prevent overwriting or unregistering safety-critical rules ([ed64a28](https://github.com/MilliPress/MilliRules/commit/ed64a28c2f85555b10709cdb9fbd8dac7c34c6a7))
* **schema:** Validate and sanitize array values via also_accepts('array') ([b60f5e6](https://github.com/MilliPress/MilliRules/commit/b60f5e63c5be718e4699ff5013beaf15febfa144))
* **wp:** Group rules by hook priority and reuse engine across priorities ([45a5180](https://github.com/MilliPress/MilliRules/commit/45a518044b7185a314c216bb17502f3a57bcd5b2))


### Bug Fixes

* **actions:** Support named argument keys from data-stored rules ([5b3ac7c](https://github.com/MilliPress/MilliRules/commit/5b3ac7c9dfc71b0b59d6b2fbdf31b156a151b4df))
* **conditions:** Rename fn_args to args and trim trailing empty strings ([d40a3c7](https://github.com/MilliPress/MilliRules/commit/d40a3c7ff200d45a96065b86ea68e39752103965))
* Correct [@since](https://github.com/since) version tags from 1.2.0 to 1.1.0 ([e84498b](https://github.com/MilliPress/MilliRules/commit/e84498bd87abe434e37a6a65286ea86776625797))
* **discovery:** Exclude handler base classes from condition catalog ([29186a8](https://github.com/MilliPress/MilliRules/commit/29186a815cf9f03e841610abfb6e8e8968669760))
* **engine:** Resolve scoped lock keys from named action arguments ([50ef450](https://github.com/MilliPress/MilliRules/commit/50ef450ffb267d74a9d74b5c5a38d2b2c09a9c89))
* **packages:** Deduplicate rules when required packages change ([d7105d3](https://github.com/MilliPress/MilliRules/commit/d7105d3842801759b914ef6b6842bd672fc1c556))


### Refactoring

* **conditions:** Remove boolean mode from Is/Has conditionals ([c3cffa6](https://github.com/MilliPress/MilliRules/commit/c3cffa68b695d873016f566f4ac2b54fa092fbfc))
* **conditions:** Use compare_values() for name field pattern matching ([58a9a34](https://github.com/MilliPress/MilliRules/commit/58a9a3477151afc7da68756c97821c89eb8f59e8))

## [1.0.0](https://github.com/MilliPress/MilliRules/compare/v0.7.3...v1.0.0) (2026-03-31)


### ⚠ BREAKING CHANGES

**conditions:** The following dedicated condition classes have been removed in favor of the generic `is_*` and `has_*` conditional bridges:

- `category` → use `is_category()` instead
- `tag` → use `is_tag()` instead
- `author` → use `is_author()` instead
- `taxonomy` → use `is_tax()` instead
- `term` → use `has_term()` instead
- `template` → use `is_page_template()` instead
- `post` → use `is_single()` or `is_page()` instead

### Features

* **conditions:** Remove redundant WordPress conditions covered by is_*/has_* ([bd7eed0](https://github.com/MilliPress/MilliRules/commit/bd7eed041ab140d17ca5bd3b0ec9972f4e65f909))

## [0.7.3](https://github.com/MilliPress/MilliRules/compare/v0.7.2...v0.7.3) (2026-02-15)


### Bug Fixes

* **rules:** Always include package for set explicit type ([b2c0d23](https://github.com/MilliPress/MilliRules/commit/b2c0d233782bba937c34beffc2dbb96b167d974c))

## [0.7.2](https://github.com/MilliPress/MilliRules/compare/v0.7.1...v0.7.2) (2026-02-11)


### Bug Fixes

* **conditions:** Return wildcard type identifiers for generic WP conditionals ([0a3b0eb](https://github.com/MilliPress/MilliRules/commit/0a3b0eb4ad131e5d77b7e66ae1b5b22ef12f87fc))

## [0.7.1](https://github.com/MilliPress/MilliRules/compare/v0.7.0...v0.7.1) (2026-02-11)


### Features

* Add public getters for registered namespaces and custom types ([6d1b517](https://github.com/MilliPress/MilliRules/commit/6d1b5175a1c671ff3a03b3f25e3c786797c2eac4))


### Bug Fixes

* **ci:** Drop PHP 8.1 from CI matrix ([ab0ab6e](https://github.com/MilliPress/MilliRules/commit/ab0ab6ec06951a028a79627992a12cad3f6f8335))
* **ci:** Loosen Pest constraint to ^2.0 for PHP 8.1 compatibility ([124d4db](https://github.com/MilliPress/MilliRules/commit/124d4dbaac1030f12cb1b8578e2188753cd2c281))
* **docs:** Correct indentation in README example for better readability ([38b09b8](https://github.com/MilliPress/MilliRules/commit/38b09b8691d864a51054e3712b71d1098909de98))

## [0.7.0](https://github.com/MilliPress/MilliRules/compare/v0.6.2...v0.7.0) (2026-02-11)


### Features

* **builders:** Add method normalization for camelCase and snake_case compatibility ([fad8f3d](https://github.com/MilliPress/MilliRules/commit/fad8f3d7ebc7cee997becb882d8f0bc1c51ec9d0))

## [0.6.2](https://github.com/MilliPress/MilliRules/compare/v0.6.1...v0.6.2) (2026-02-11)


### Bug Fixes

* **package-manager:** Ensure case-insensitive package name mapping ([f6cc490](https://github.com/MilliPress/MilliRules/commit/f6cc490f3e8634992f2b90dc798935b8202c732b))

## [0.6.1](https://github.com/MilliPress/MilliRules/compare/v0.6.0...v0.6.1) (2026-02-09)


### Features

* **docs:** Add changelog file for version tracking and updates ([207f41c](https://github.com/MilliPress/MilliRules/commit/207f41c660fcfc977ad7412891f5226e320ad94d))
* **docs:** Revise package description to highlight features and improve clarity ([9612260](https://github.com/MilliPress/MilliRules/commit/961226066e4aa109e700816fa83ba5a1ee6d0c80))
* **package-manager:** Add method to retrieve all rules with package names ([db1f732](https://github.com/MilliPress/MilliRules/commit/db1f7322e7e3312520d615028bad9607b423d915))


### Documentation

* Replace ASCII diagrams with mermaid flowcharts for better visualization ([5ef0ac5](https://github.com/MilliPress/MilliRules/commit/5ef0ac5d6940949391c7cac58387cae38b7a024e))
* Update internal links to use relative paths for consistency ([bd20128](https://github.com/MilliPress/MilliRules/commit/bd201286ed3590bbad7a6137e0c56b8903e1a9ff))

## [0.6.0](https://github.com/MilliPress/MilliRules/compare/v0.5.0...v0.6.0) (2025-12-17)


### Features

* Add rule replacement by ID and Rules::unregister() method ([05c9a73](https://github.com/MilliPress/MilliRules/commit/05c9a73c5237dc7b2bfd02192663d070776e4502))
* Add rule replacement by ID, Rules::unregister(), and Release Please ([#2](https://github.com/MilliPress/MilliRules/issues/2)) ([9e80708](https://github.com/MilliPress/MilliRules/commit/9e8070820515a4b0d97e7569b65a66d372ab7ef6))


### Bug Fixes

* Remove return type declaration from resolve_builtin_placeholder method ([efc8504](https://github.com/MilliPress/MilliRules/commit/efc850418166fc3db02746d88f0dfd89285c747a))
* Rename normalize_operator to avoid PHP 7.4 static method conflict ([5e6c3e5](https://github.com/MilliPress/MilliRules/commit/5e6c3e5f4e47561a401e1ce6edf3b9f8da83a393))


## [0.5.0](https://github.com/MilliPress/MilliRules/compare/v0.4.0...v0.5.0) (2025-12-17)


### Features

* Add WordPress has_* conditional support ([06ddde9](https://github.com/MilliPress/MilliRules/commit/06ddde9))


## [0.4.0](https://github.com/MilliPress/MilliRules/compare/v0.3.1...v0.4.0) (2025-12-03)


### Features

* Add VERSION constant to MilliRules and update release workflow ([b313037](https://github.com/MilliPress/MilliRules/commit/b313037))
* Add GitHub Actions release workflow ([cf4c7a1](https://github.com/MilliPress/MilliRules/commit/cf4c7a1))
* Add fluent argument access API to action classes ([bee5af0](https://github.com/MilliPress/MilliRules/commit/bee5af0))


### Bug Fixes

* Use PHP 8.1 in release workflow for PHPStan compatibility ([ac7f1ae](https://github.com/MilliPress/MilliRules/commit/ac7f1ae))
* Remove PHP 8.0 mixed type hint for PHP 7.4 compatibility ([0c60c92](https://github.com/MilliPress/MilliRules/commit/0c60c92))
* Remove static from normalize_operator method in IsConditional ([41a1eed](https://github.com/MilliPress/MilliRules/commit/41a1eed))


### Refactoring

* Add type declarations for private properties in ArgumentValue class ([3fc1a21](https://github.com/MilliPress/MilliRules/commit/3fc1a21))


## [0.3.1](https://github.com/MilliPress/MilliRules/compare/v0.3.0...v0.3.1) (2025-12-03)


### Refactoring

* Rename query_vars context to query and remove redundant Query context ([46c7035](https://github.com/MilliPress/MilliRules/commit/46c7035))


## [0.3.0](https://github.com/MilliPress/MilliRules/compare/v0.2.1...v0.3.0) (2025-12-02)


### Features

* Add object property access support to Context::get() ([676bff5](https://github.com/MilliPress/MilliRules/commit/676bff5))


## [0.2.1](https://github.com/MilliPress/MilliRules/compare/v0.2.0...v0.2.1) (2025-11-28)


### Bug Fixes

* Context discovery fails with Mozart and other scoping tools ([cf27cf0](https://github.com/MilliPress/MilliRules/commit/cf27cf0))


## [0.2.0](https://github.com/MilliPress/MilliRules/compare/v0.1.0...v0.2.0) (2025-11-28)


### Features

* Add intelligent logging system with rate limiting ([c4e0c22](https://github.com/MilliPress/MilliRules/commit/c4e0c22))
* Add action-level locking with ->lock() method ([0a34cd7](https://github.com/MilliPress/MilliRules/commit/0a34cd7))
* Refactor comprehensive documentation ([9380a1d](https://github.com/MilliPress/MilliRules/commit/9380a1d))
* Add `load_packages` method for package loading with dependency resolution ([cac041c](https://github.com/MilliPress/MilliRules/commit/cac041c))


### Bug Fixes

* Skip disabled rules in WordPress Package registration ([6a4414f](https://github.com/MilliPress/MilliRules/commit/6a4414f))


### Refactoring

* Rename _args to args for cleaner API ([82e6341](https://github.com/MilliPress/MilliRules/commit/82e6341))
* Change placeholder syntax from colon to dot notation for consistency ([a76c5ed](https://github.com/MilliPress/MilliRules/commit/a76c5ed))
* Rename callback parameter from $config to $args for consistency ([7bac347](https://github.com/MilliPress/MilliRules/commit/7bac347))
* Remove _args wrapper from ActionBuilder for cleaner config structure ([d989bd5](https://github.com/MilliPress/MilliRules/commit/d989bd5))
* Rename `value`/`config` to `type`/`args` in BaseAction for clarity ([195b0cd](https://github.com/MilliPress/MilliRules/commit/195b0cd))
* Pass Context objects to callback actions and conditions ([127c782](https://github.com/MilliPress/MilliRules/commit/127c782))
* Update default hook and priority in Rules and registration logic ([5cb08a4](https://github.com/MilliPress/MilliRules/commit/5cb08a4))


## [0.1.0](https://github.com/MilliPress/MilliRules/releases/tag/v0.1.0) (2025-11-17)


### Features

* Initial MilliRules implementation ([d3a7b44](https://github.com/MilliPress/MilliRules/commit/d3a7b44))
* Implement lazy context loading system ([96d9977](https://github.com/MilliPress/MilliRules/commit/96d9977))
* Add generic package-based class resolution ([2bde3e5](https://github.com/MilliPress/MilliRules/commit/2bde3e5))
* Add deferred rule registration with pending queue for unloaded packages ([6eb8ce4](https://github.com/MilliPress/MilliRules/commit/6eb8ce4))
* Auto-add WP package if 'wp' type is detected without it in required packages ([d23f10d](https://github.com/MilliPress/MilliRules/commit/d23f10d))
* Add support for accessing WordPress hook arguments in execution context ([02bc150](https://github.com/MilliPress/MilliRules/commit/02bc150))


### Bug Fixes

* Add fallback for generic `is_*` conditions in RuleEngine ([a68da5f](https://github.com/MilliPress/MilliRules/commit/a68da5f))
* Simplify conditional logic in WordPress hook registration ([494eb20](https://github.com/MilliPress/MilliRules/commit/494eb20))
* Update context key to `wp.hook` for WordPress hook arguments ([ce759cb](https://github.com/MilliPress/MilliRules/commit/ce759cb))
* Add safeguard for missing `add_action` function in WordPress hook registration ([5182ce3](https://github.com/MilliPress/MilliRules/commit/5182ce3))
* Adjust `get_rules` to return rules grouped by hook in WordPress package ([68f94b9](https://github.com/MilliPress/MilliRules/commit/68f94b9))
* Correct package name from 'WordPress' to 'WP' in required package check ([60fcb4d](https://github.com/MilliPress/MilliRules/commit/60fcb4d))


### Refactoring

* Update conditions and actions to use Context object ([7b277ee](https://github.com/MilliPress/MilliRules/commit/7b277ee))
* Update core classes to use Context object ([178bbda](https://github.com/MilliPress/MilliRules/commit/178bbda))
* Update packages to use lazy context system ([4266ff5](https://github.com/MilliPress/MilliRules/commit/4266ff5))
* Replace individual condition classes with unified implementations ([4c3240b](https://github.com/MilliPress/MilliRules/commit/4c3240b))

# Acorn MilliRules

---

Canonical: https://www.millipress.com/docs/acorn-millirules/01-getting-started/01-introduction

---
title: 'Introduction'
description: 'Acorn MilliRules adds route-aware conditions, HTTP response actions, WP-CLI commands, and rule auto-discovery to Laravel routes in Acorn on the Roots stack.'
menu_order: 10
---

# Introduction

Acorn MilliRules brings the [MilliRules Engine](https://millipress.com/docs/millirules/01-getting-started/01-introduction) into your Acorn application. It adds route-aware conditions, HTTP response actions, CLI commands, and automatic rule discovery — everything you need to define and manage rules that react to Laravel routes.

## What This Package Provides

- **Route-aware conditions** — match rules by route name, route parameters, or controller class
- **HTTP response actions** — redirect requests or set response headers
- **Route context** — automatic context loading with route metadata (name, parameters, controller, URI, middleware)
- **8 Artisan commands** — list, inspect, and scaffold rules, actions, and conditions
- **Auto-discovery** — rule classes in `app/Rules/` are registered automatically
- **Middleware integration** — rules execute after route matching with zero configuration

> [!NOTE]
> This package extends MilliRules with Acorn-specific features. For general concepts like rules, conditions, actions, operators, and the fluent builder API, see the [MilliRules documentation](https://millipress.com/docs/millirules/).

## Prerequisites

| Requirement   | Version                      |
|---------------|------------------------------|
| PHP           | >= 8.1                       |
| Roots Acorn   | ^4.0 or ^5.0 or ^6.0        |
| MilliRules    | ^1.0 (auto-installed)        |

MilliRules is declared as a Composer dependency and will be installed automatically. You do not need to install it separately.

## How It Works

The execution flow in Acorn follows these steps:

1. **Service provider boots** — `ServiceProvider` initializes MilliRules, registers the Acorn package, and discovers your rule classes
2. **Auto-discovery** — rule classes in `app/Rules/*.php` are instantiated and their `register()` method is called, which registers rules with the engine
3. **Request arrives** — Laravel routes the request to a controller as usual
4. **Middleware executes** — the `ExecuteRules` middleware runs *after* the controller, so route context (name, parameters, controller) is available
5. **Rules evaluate** — MilliRules evaluates all registered rules against the current context
6. **Response modified** — actions collect response modifications (headers, redirects) via the `ResponseCollector`, and the middleware applies them to the outgoing HTTP response

> [!TIP]
> The builder API supports both **camelCase** (`->routeName()`, `->setHeader()`) and **snake_case** (`->route_name()`, `->set_header()`) method names. This documentation uses camelCase to align with Laravel conventions, but both styles work identically.

## Next Steps

- **[Installation](/docs/acorn-millirules/01-getting-started/02-installation)** — install the package, publish config and stubs, create your first rule
- **[Creating Rules](/docs/acorn-millirules/02-usage/01-creating-rules)** — learn the rule class pattern and auto-discovery
- **[Artisan Commands](/docs/acorn-millirules/02-usage/03-artisan-commands)** — explore all 8 CLI commands

---

**Ready to get started?** Continue to the [Installation guide](/docs/acorn-millirules/01-getting-started/02-installation).

---

Canonical: https://www.millipress.com/docs/acorn-millirules/01-getting-started/02-installation

---
title: 'Installation'
description: 'Install Acorn MilliRules with Composer in your Acorn project, publish config and stubs, verify the WP-CLI commands, and build your first Laravel route rule.'
menu_order: 20
---

# Installation

## Install the Package

```bash
composer require millipress/acorn-millirules
```

The service provider is registered automatically via the `extra.acorn.providers` key in the package's `composer.json` — no manual registration needed.

## Publish Config and Stubs

Publish the configuration file and stub templates:

```bash
wp acorn vendor:publish --tag=millirules
```

This publishes:

| File | Location |
|---|---|
| Configuration | `config/millirules.php` |
| Rule stub | `stubs/millirules/rule.stub` |
| Action stub | `stubs/millirules/action.stub` |
| Condition stub | `stubs/millirules/condition.stub` |

> [!TIP]
> Publishing is optional. The package works out of the box with sensible defaults. Publish only if you need to customize the middleware configuration or scaffold templates.

## Verify the Installation

Run these commands to confirm everything is registered:

```bash
# List registered packages (should show PHP, Acorn, and optionally WP)
wp acorn rules:packages

# List available action types
wp acorn rules:actions

# List available condition types
wp acorn rules:conditions
```

You should see the Acorn package listed with its `redirect`, `set_header` actions and `route_name`, `route_parameter`, `route_controller` conditions.

## Your First Rule

Let's create a rule that adds security headers to all documentation pages.

### 1. Scaffold the Rule

```bash
wp acorn rules:make:rule SecurityHeaders
```

This creates `app/Rules/SecurityHeaders.php`:

```php
<?php

namespace App\Rules;

use MilliRules\Rules;

class SecurityHeaders
{
    /**
     * Register this rule with MilliRules.
     *
     * Called automatically by the ServiceProvider.
     */
    public function register(): void
    {
        Rules::create('security-headers')
            ->when()
                // ->routeName('example.route')
                // ->requestUrl('/example/*', 'LIKE')
            ->then()
                // ->setHeader('X-Custom', 'value')
            ->register();
    }
}
```

### 2. Fill in the Rule

Replace the placeholders with real conditions and actions:

```php
<?php

namespace App\Rules;

use MilliRules\Rules;

class SecurityHeaders
{
    public function register(): void
    {
        Rules::create('security-headers')
            ->when()
                ->routeName('docs.*', 'LIKE')
            ->then()
                ->setHeader('X-Content-Type-Options', 'nosniff')
                ->setHeader('X-Frame-Options', 'DENY')
            ->register();
    }
}
```

This rule matches any route whose name starts with `docs.` and adds two security headers to the response.

### 3. Verify the Rule

```bash
# List all rules — you should see security-headers
wp acorn rules:list

# Show details for this specific rule
wp acorn rules:show security-headers
```

The `rules:show` command displays the rule's conditions, actions, and metadata:

```
Rule ID ................................... security-headers
Package ............................................. Acorn
Order .................................................. 10
Enabled ............................................... Yes
Match Type ............................................ all

Conditions (1)
  route_name LIKE docs.*

Actions (2)
  set_header {"name":"X-Content-Type-Options","value":"nosniff"}
  set_header {"name":"X-Frame-Options","value":"DENY"}
```

> [!IMPORTANT]
> Rules are auto-discovered from `app/Rules/*.php`. There is no registration step beyond creating the file — the service provider finds and calls `register()` automatically.

## Next Steps

- **[Creating Rules](/docs/acorn-millirules/02-usage/01-creating-rules)** — learn the full rule class pattern, ordering, and multiple rules per class
- **[Conditions and Actions](/docs/acorn-millirules/02-usage/02-conditions-and-actions)** — explore all built-in Acorn conditions and actions
- **[Configuration](/docs/acorn-millirules/03-customization/02-configuration)** — customize middleware groups and stubs

---

Canonical: https://www.millipress.com/docs/acorn-millirules/02-usage/01-creating-rules

---
title: 'Creating Rules'
description: 'Write rule classes for Acorn MilliRules: scaffold with rules:make, auto-discovery from app/Rules, rule ordering, and multiple rules per class in Acorn.'
menu_order: 30
---

# Creating Rules

Rules in MilliRules are PHP classes with a `register()` method that uses the MilliRules fluent builder API to define conditions and actions.

## Rule Class Anatomy

A rule class lives in the `App\Rules` namespace and has a single `register()` method:

```php
<?php

namespace App\Rules;

use MilliRules\Rules;

class DocsRedirects
{
    public function register(): void
    {
        Rules::create('docs-redirect-old-paths')
            ->when()
                ->routeName('docs.legacy')
            ->then()
                ->redirect('/docs/{route.parameters.product}', 301)
            ->register();
    }
}
```

Key points:

- **Namespace**: `App\Rules` — auto-discovered by the service provider
- **No base class**: rule classes are plain PHP classes (no interface or abstract class required)
- **`register()` method**: called automatically during service provider boot
- **`Rules::create($id)`**: starts the fluent builder with a unique rule ID
- **`->register()`**: finalizes and registers the rule with the engine

> [!IMPORTANT]
> Each rule ID must be unique across all packages. Use descriptive IDs like `docs-security-headers` or `api-rate-limit-redirect`.

## Scaffolding

Use the `rules:make:rule` command to generate a new rule class:

```bash
wp acorn rules:make:rule DocsRedirects
```

Output:

```
Rule created successfully.
 ⇂ Rule ID: docs-redirects
 ⇂ Package: Acorn
 ⇂ Auto-discovered on next request
```

The rule ID is automatically derived from the class name using kebab-case conversion (`DocsRedirects` → `docs-redirects`).

### Options

| Option | Description |
|---|---|
| `--package=Acorn` | Target package name (default: `Acorn`) |
| `--force` | Overwrite the file if it already exists |

```bash
# Overwrite an existing rule
wp acorn rules:make:rule DocsRedirects --force
```

> [!TIP]
> `rules:make` is an alias for `rules:make:rule`. Both commands are identical.

## Multiple Rules in One Class

A single class can register multiple rules:

```php
<?php

namespace App\Rules;

use MilliRules\Rules;

class DocsRules
{
    public function register(): void
    {
        Rules::create('docs-security-headers')
            ->when()
                ->routeName('docs.*', 'LIKE')
            ->then()
                ->setHeader('X-Content-Type-Options', 'nosniff')
                ->setHeader('X-Frame-Options', 'DENY')
            ->register();

        Rules::create('docs-cache-headers')
            ->when()
                ->routeName('docs.show')
            ->then()
                ->setHeader('Cache-Control', 'public, max-age=3600')
            ->register();
    }
}
```

Each `Rules::create()` / `->register()` pair defines an independent rule with its own ID, conditions, and actions.

## Rule Ordering

Rules execute in order determined by their `order` value (default: `10`). Lower values execute first:

```php
Rules::create('early-rule')
    ->order(5)
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Processed-By', 'MilliRules')
    ->register();

Rules::create('late-rule')
    ->order(20)
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Cache', 'HIT')
    ->register();
```

## Disabling Rules

Temporarily disable a rule without removing it:

```php
Rules::create('maintenance-redirect')
    ->enabled(false)
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->redirect('/maintenance')
    ->register();
```

Disabled rules appear in `rules:list` with **Enabled = No** but are skipped during execution.

## Next Steps

- **[Conditions and Actions](/docs/acorn-millirules/02-usage/02-conditions-and-actions)** — explore the built-in Acorn conditions and actions with examples
- **[Custom Conditions and Actions](/docs/acorn-millirules/03-customization/01-custom-conditions-and-actions)** — create your own condition and action types
- For details on the fluent builder API (`Rules::create()`, `->when()`, `->then()`, operators), see the [MilliRules Building Rules documentation](https://millipress.com/docs/millirules/02-core-concepts/03-building-rules)

---

Canonical: https://www.millipress.com/docs/acorn-millirules/02-usage/02-conditions-and-actions

---
title: 'Conditions and Actions'
description: 'Built-in Acorn MilliRules conditions and actions: match Laravel route names, parameters, and controllers, then set response headers or redirect requests.'
menu_order: 40
---

# Conditions and Actions

Acorn MilliRules ships with three route-aware conditions and two HTTP response actions. These are registered automatically by the Acorn package and available in the fluent builder immediately.

## Conditions

### Route Name

Match the current Laravel route name. Useful for targeting named routes like `docs.show` or groups like `docs.*`.

```php
Rules::create('docs-headers')
    ->when()
        ->routeName('docs.show')
    ->then()
        ->setHeader('X-Docs', 'true')
    ->register();
```

Pattern matching with the `LIKE` operator:

```php
Rules::create('all-docs-headers')
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Section', 'docs')
    ->register();
```

For a complete list of operators and examples, see the [Route Name reference](/docs/acorn-millirules/04-reference/01-conditions#route-name).

### Route Parameter

Check the value of a named route parameter. The first argument is the parameter name, the second is the expected value.

```php
Rules::create('millicache-product-header')
    ->when()
        ->routeParameter('product', 'millicache')
    ->then()
        ->setHeader('X-Product', 'millicache')
    ->register();
```

When only a parameter name is provided (no value), the condition checks if the parameter **exists**:

```php
Rules::create('has-product-parameter')
    ->when()
        ->routeParameter('product')
    ->then()
        ->setHeader('X-Has-Product', 'true')
    ->register();
```

For existence checks, pattern matching, and all operators, see the [Route Parameter reference](/docs/acorn-millirules/04-reference/01-conditions#route-parameter).

### Route Controller

Match the fully-qualified controller class name handling the current route.

```php
Rules::create('docs-controller-headers')
    ->when()
        ->routeController('App\Http\Controllers\DocsController')
    ->then()
        ->setHeader('X-Handler', 'docs')
    ->register();
```

Partial matching with `LIKE`:

```php
Rules::create('any-docs-controller')
    ->when()
        ->routeController('*DocsController', 'LIKE')
    ->then()
        ->setHeader('X-Handler', 'docs')
    ->register();
```

For all operators and examples, see the [Route Controller reference](/docs/acorn-millirules/04-reference/01-conditions#route-controller).

## Actions

### Set Header

Add an HTTP response header. The first argument is the header name, the second is the value.

```php
Rules::create('security-headers')
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Content-Type-Options', 'nosniff')
        ->setHeader('X-Frame-Options', 'DENY')
        ->setHeader('Referrer-Policy', 'strict-origin-when-cross-origin')
    ->register();
```

Headers support placeholders that resolve from context:

```php
Rules::create('product-header')
    ->when()
        ->routeParameter('product')
    ->then()
        ->setHeader('X-Product', '{route.parameters.product}')
    ->register();
```

For full details on placeholder support and behavior, see the [Set Header reference](/docs/acorn-millirules/04-reference/02-actions#set-header).

### Redirect

Redirect the request to a different URL. The first argument is the target URL, the second (optional) is the HTTP status code (default: `302`).

```php
Rules::create('legacy-docs-redirect')
    ->when()
        ->routeName('docs.legacy')
    ->then()
        ->redirect('/docs', 301)
    ->register();
```

Redirects support placeholders for dynamic URLs:

```php
Rules::create('product-redirect')
    ->when()
        ->routeName('docs.old-product')
    ->then()
        ->redirect('/docs/{route.parameters.product}/latest', 301)
    ->register();
```

For full details on redirect behavior and examples, see the [Redirect reference](/docs/acorn-millirules/04-reference/02-actions#redirect).

## Combining Acorn and Core Conditions

You can mix Acorn route conditions with conditions from other MilliRules packages (like the PHP package's `request_url` or `cookie` conditions) in the same rule:

```php
Rules::create('docs-logged-in-redirect')
    ->when()
        ->routeName('docs.premium')
        ->cookie('session_token', '', '!=')
    ->then()
        ->setHeader('X-Access', 'premium')
    ->register();
```

By default, all conditions must match (`match_type: all`). To match when **any** condition is true, use `matchAny()`:

```php
Rules::create('product-pages')
    ->matchAny()
    ->when()
        ->routeName('products.show')
        ->routeParameter('product', 'millicache')
    ->then()
        ->setHeader('X-Product-Page', 'true')
    ->register();
```

> [!NOTE]
> For the full list of conditions and actions available from other packages (like `request_url`, `cookie`, `request_method`), see the [MilliRules Conditions Reference](https://millipress.com/docs/millirules/05-reference/01-conditions) and [Actions Reference](https://millipress.com/docs/millirules/05-reference/02-actions).

## Next Steps

- **[Artisan Commands](/docs/acorn-millirules/02-usage/03-artisan-commands)** — list, inspect, and scaffold rules from the CLI
- **[Custom Conditions and Actions](/docs/acorn-millirules/03-customization/01-custom-conditions-and-actions)** — create your own condition and action types
- **[Conditions Reference](/docs/acorn-millirules/04-reference/01-conditions)** — full reference for all Acorn conditions
- **[Actions Reference](/docs/acorn-millirules/04-reference/02-actions)** — full reference for all Acorn actions

---

Canonical: https://www.millipress.com/docs/acorn-millirules/02-usage/03-artisan-commands

---
title: 'Artisan Commands'
description: 'Reference for all eight Acorn MilliRules WP-CLI commands: list, inspect, and scaffold rules, conditions, and actions with wp acorn rules commands.'
menu_order: 50
---

# Artisan Commands

Acorn MilliRules provides Artisan commands for managing rules from the CLI. All commands use the `rules:` prefix.

## Listing Commands

### `rules:list`

List all registered rules across loaded packages.

```bash
wp acorn rules:list
```

```
+--------------------+---------+-------+---------+-------+------------+---------+
| ID                 | Package | Order | Enabled | Match | Conditions | Actions |
+--------------------+---------+-------+---------+-------+------------+---------+
| security-headers   | Acorn   | 10    | Yes     | all   | 1          | 2       |
| docs-redirect      | Acorn   | 10    | Yes     | all   | 1          | 1       |
+--------------------+---------+-------+---------+-------+------------+---------+
```

#### Options

| Option | Description |
|---|---|
| `--package=<name>` | Filter by package name (e.g., `--package=Acorn`) |
| `--id=<pattern>` | Filter by rule ID substring (e.g., `--id=docs`) |

```bash
# Only Acorn rules
wp acorn rules:list --package=Acorn

# Rules containing "docs" in the ID
wp acorn rules:list --id=docs
```

### `rules:show`

Show detailed information about a specific rule.

```bash
wp acorn rules:show security-headers
```

```
Rule ID ................................... security-headers
Package ............................................. Acorn
Order .................................................. 10
Enabled ............................................... Yes
Match Type ............................................ all

Conditions (1)
  route_name LIKE docs.*

Actions (2)
  set_header {"name":"X-Content-Type-Options","value":"nosniff"}
  set_header {"name":"X-Frame-Options","value":"DENY"}
```

#### Arguments

| Argument | Description |
|---|---|
| `id` | The rule ID to display (required) |

### `rules:packages`

List all registered MilliRules packages with their availability and rule counts.

```bash
wp acorn rules:packages
```

```
+-------+-----------+--------+--------------+-------+
| Name  | Available | Loaded | Dependencies | Rules |
+-------+-----------+--------+--------------+-------+
| PHP   | Yes       | Yes    | -            | 0     |
| Acorn | Yes       | Yes    | PHP          | 2     |
+-------+-----------+--------+--------------+-------+
```

### `rules:actions`

List all registered action types across loaded packages.

```bash
wp acorn rules:actions
```

```
+------------+------------------+---------+------------------------------------------------------+
| Type       | Builder          | Package | Class                                                |
+------------+------------------+---------+------------------------------------------------------+
| redirect   | ->redirect()     | Acorn   | MilliRules\Acorn\...\Actions\Redirect      |
| set_header | ->setHeader()    | Acorn   | MilliRules\Acorn\...\Actions\SetHeader     |
+------------+------------------+---------+------------------------------------------------------+
```

#### Options

| Option | Description |
|---|---|
| `--package=<name>` | Filter by package name |

```bash
wp acorn rules:actions --package=Acorn
```

### `rules:conditions`

List all registered condition types across loaded packages.

```bash
wp acorn rules:conditions
```

```
+------------------+----------------------+---------+------------------------------------------------------+
| Type             | Builder              | Package | Class                                                |
+------------------+----------------------+---------+------------------------------------------------------+
| route_name       | ->routeName()        | Acorn   | MilliRules\Acorn\...\Conditions\RouteName  |
| route_parameter  | ->routeParameter()   | Acorn   | MilliRules\Acorn\...\Conditions\Route...   |
| route_controller | ->routeController()  | Acorn   | MilliRules\Acorn\...\Conditions\Route...   |
+------------------+----------------------+---------+------------------------------------------------------+
```

#### Options

| Option | Description |
|---|---|
| `--package=<name>` | Filter by package name |

```bash
wp acorn rules:conditions --package=Acorn
```

## Scaffolding Commands

### `rules:make:rule`

Scaffold a new rule class in `app/Rules/`.

```bash
wp acorn rules:make:rule SecurityHeaders
```

```
Rule created successfully.
 ⇂ Rule ID: security-headers
 ⇂ Package: Acorn
 ⇂ Auto-discovered on next request
```

The class name is converted to a kebab-case rule ID: `SecurityHeaders` → `security-headers`.

#### Arguments and Options

| Argument / Option | Description |
|---|---|
| `name` | The rule class name (e.g., `SecurityHeaders`) |
| `--package=Acorn` | Target package name (default: `Acorn`) |
| `--force` | Overwrite the file if it already exists |

> [!TIP]
> `rules:make` is an alias for `rules:make:rule`.

### `rules:make:action`

Scaffold a new action class in `app/Rules/Actions/`.

```bash
wp acorn rules:make:action CorsHeaders
```

```
Action created successfully.
 ⇂ Action type: cors_headers
 ⇂ Builder: ->corsHeaders(...)
 ⇂ Auto-discovered via App\Rules\Actions namespace
```

The class name determines the action type (`CorsHeaders` → `cors_headers`) and builder method (`->corsHeaders()`).

#### Arguments and Options

| Argument / Option | Description |
|---|---|
| `name` | The action class name (e.g., `CorsHeaders`) |
| `--force` | Overwrite the file if it already exists |

### `rules:make:condition`

Scaffold a new condition class in `app/Rules/Conditions/`.

```bash
wp acorn rules:make:condition IsAdmin
```

```
Condition created successfully.
 ⇂ Condition type: is_admin
 ⇂ Builder: ->isAdmin(...)
 ⇂ Auto-discovered via App\Rules\Conditions namespace
```

The class name determines the condition type (`IsAdmin` → `is_admin`) and builder method (`->isAdmin()`).

#### Arguments and Options

| Argument / Option | Description |
|---|---|
| `name` | The condition class name (e.g., `IsAdmin`) |
| `--force` | Overwrite the file if it already exists |

## Customizing Stubs

All scaffolding commands use stub templates that you can customize after publishing:

```bash
wp acorn vendor:publish --tag=millirules
```

This copies the stubs to `stubs/millirules/` in your project root:

| Stub | Used by |
|---|---|
| `stubs/millirules/rule.stub` | `rules:make:rule` |
| `stubs/millirules/action.stub` | `rules:make:action` |
| `stubs/millirules/condition.stub` | `rules:make:condition` |

Published stubs take priority over the package defaults. Edit them to match your project's coding style or add boilerplate code.

## Next Steps

- **[Custom Conditions and Actions](/docs/acorn-millirules/03-customization/01-custom-conditions-and-actions)** — create your own types using the scaffolding commands
- **[Configuration](/docs/acorn-millirules/03-customization/02-configuration)** — customize middleware and published stubs

---

Canonical: https://www.millipress.com/docs/acorn-millirules/03-customization/01-custom-conditions-and-actions

---
title: 'Custom Conditions and Actions'
description: 'Create custom conditions and actions for Acorn MilliRules: scaffold your own types, use the ResponseCollector API, and read Laravel route context.'
menu_order: 60
---

# Custom Conditions and Actions

Beyond the built-in types, you can create your own conditions and actions that are auto-discovered and available in the fluent builder.

## Custom Conditions

### 1. Scaffold the Condition

```bash
wp acorn rules:make:condition IsAdmin
```

This creates `app/Rules/Conditions/IsAdmin.php`:

```php
<?php

namespace App\Rules\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class IsAdmin extends BaseCondition
{
    public function get_type(): string
    {
        return 'is_admin';
    }

    protected function get_actual_value(Context $context)
    {
        // Return the value to compare. BaseCondition handles the operator + expected value.
        // $context->get('route.name'), $context->get('route.parameters.slug'), etc.
        return '';
    }
}
```

### 2. Implement the Logic

Fill in `get_actual_value()` to return the value that should be compared against the condition's expected value:

```php
<?php

namespace App\Rules\Conditions;

use MilliRules\Conditions\BaseCondition;
use MilliRules\Context;

class IsAdmin extends BaseCondition
{
    public function get_type(): string
    {
        return 'is_admin';
    }

    protected function get_actual_value(Context $context): string
    {
        $user = auth()->user();

        return $user && $user->is_admin ? 'true' : 'false';
    }
}
```

> [!NOTE]
> `get_actual_value()` should return a string (or scalar). The `BaseCondition` parent class handles all operator logic (`=`, `!=`, `LIKE`, `REGEXP`, `IN`) automatically.

### 3. Use in a Rule

The condition is auto-discovered and immediately available as `->isAdmin()` in the builder:

```php
Rules::create('admin-debug-headers')
    ->when()
        ->isAdmin('true')
    ->then()
        ->setHeader('X-Debug', 'enabled')
    ->register();
```

### 4. Verify

```bash
# Confirm the condition is registered
wp acorn rules:conditions --package=Acorn

# Confirm the rule uses it
wp acorn rules:show admin-debug-headers
```

## Custom Actions

### 1. Scaffold the Action

```bash
wp acorn rules:make:action CorsHeaders
```

This creates `app/Rules/Actions/CorsHeaders.php`:

```php
<?php

namespace App\Rules\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class CorsHeaders extends BaseAction
{
    public function get_type(): string
    {
        return 'cors_headers';
    }

    public function execute(Context $context): void
    {
        // $value = $this->get_arg(0, 'default')->string();
        //
        // Modify the HTTP response:
        // app('millirules.response')->addHeader('X-Custom', $value);
        // app('millirules.response')->setRedirect('/path', 302);
    }
}
```

### 2. Implement the Logic

Use `$this->get_arg()` to read arguments from the builder and `app('millirules.response')` to modify the HTTP response:

```php
<?php

namespace App\Rules\Actions;

use MilliRules\Actions\BaseAction;
use MilliRules\Context;

class CorsHeaders extends BaseAction
{
    public function get_type(): string
    {
        return 'cors_headers';
    }

    public function execute(Context $context): void
    {
        $origin = $this->get_arg(0, '*')->string();

        $collector = app('millirules.response');
        $collector->addHeader('Access-Control-Allow-Origin', $origin);
        $collector->addHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
        $collector->addHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
    }
}
```

### 3. Use in a Rule

The action is auto-discovered and available as `->corsHeaders()` in the builder:

```php
Rules::create('api-cors')
    ->when()
        ->routeName('api.*', 'LIKE')
    ->then()
        ->corsHeaders('https://example.com')
    ->register();
```

### 4. Verify

```bash
# Confirm the action is registered
wp acorn rules:actions --package=Acorn

# Confirm the rule uses it
wp acorn rules:show api-cors
```

## ResponseCollector API

Custom actions modify the HTTP response through the `ResponseCollector` singleton, accessed via `app('millirules.response')`. The middleware reads the collector after rule execution and applies changes to the outgoing response.

### Available Methods

| Method | Description |
|---|---|
| `addHeader(string $name, string $value)` | Queue a header to be set on the response. If the same header name is added multiple times, the last value wins. |
| `setRedirect(string $url, int $status = 302)` | Queue a redirect response. Replaces the original response entirely. If multiple redirects are queued, the last one wins. |

```php
$collector = app('millirules.response');

// Add a header
$collector->addHeader('X-Custom', 'value');

// Queue a redirect (replaces the response)
$collector->setRedirect('/new-location', 301);
```

> [!WARNING]
> A redirect replaces the entire original response. Headers are still applied to the redirect response, but the original response body is discarded.

## Using Route Context in Custom Types

Custom conditions and actions can access the route context by loading it from the `Context` object:

```php
protected function get_actual_value(Context $context): string
{
    // Load route context (lazy-loaded on first call)
    $context->load('route');

    // Access individual keys
    $routeName = $context->get('route.name', '');
    $productParam = $context->get('route.parameters.product', '');
    $controller = $context->get('route.controller', '');

    return is_string($routeName) ? $routeName : '';
}
```

The `$context->load('route')` call is idempotent — it loads route data once and subsequent calls are no-ops. See the [Route Context reference](/docs/acorn-millirules/04-reference/03-route-context) for all available context keys.

## Next Steps

- **[Configuration](/docs/acorn-millirules/03-customization/02-configuration)** — customize middleware groups and stubs
- **[Route Context Reference](/docs/acorn-millirules/04-reference/03-route-context)** — all available context keys, types, and examples
- For advanced patterns like custom operators and context providers, see the [MilliRules Custom Conditions](https://millipress.com/docs/millirules/03-customization/01-custom-conditions) and [Custom Actions](https://millipress.com/docs/millirules/03-customization/02-custom-actions) documentation

---

Canonical: https://www.millipress.com/docs/acorn-millirules/03-customization/02-configuration

---
title: 'Configuration'
description: 'Acorn MilliRules configuration reference: middleware groups, manual ExecuteRules registration on Laravel routes, and customizing scaffolding stubs.'
menu_order: 70
---

# Configuration

The configuration file controls how the MilliRules middleware is registered. Publish it with:

```bash
wp acorn vendor:publish --tag=millirules
```

This creates `config/millirules.php` in your application.

## Full Config Reference

```php
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Middleware
    |--------------------------------------------------------------------------
    |
    | Control how the MilliRules middleware is registered. The middleware
    | executes rules after route matching and applies response modifications
    | (headers, redirects) to the outgoing HTTP response.
    |
    */

    'middleware' => [

        // Set to false to disable automatic middleware registration.
        'enabled' => true,

        // Middleware groups to attach to (e.g. ['web', 'api']).
        'groups' => ['web'],
    ],

];
```

### Options

| Key | Type | Default | Description |
|---|---|---|---|
| `middleware.enabled` | `bool` | `true` | Enable or disable automatic middleware registration. Set to `false` to register the middleware manually. |
| `middleware.groups` | `string[]` | `['web']` | Middleware groups the `ExecuteRules` middleware is pushed to. Rules will only execute on routes belonging to these groups. |

## Adding Middleware Groups

To execute rules on API routes as well:

```php
'middleware' => [
    'enabled' => true,
    'groups' => ['web', 'api'],
],
```

## Disabling Automatic Middleware

Set `middleware.enabled` to `false` to take full control over where the middleware runs:

```php
'middleware' => [
    'enabled' => false,
    'groups' => [],
],
```

Then register the middleware manually on specific routes or groups:

```php
use MilliRules\Acorn\Http\Middleware\ExecuteRules;

// On a specific route
Route::get('/docs/{product}', [DocsController::class, 'show'])
    ->middleware(ExecuteRules::class);

// On a route group
Route::middleware([ExecuteRules::class])->group(function () {
    Route::get('/docs/{product}', [DocsController::class, 'show']);
    Route::get('/docs/{product}/{path}', [DocsController::class, 'page']);
});
```

> [!TIP]
> Manual registration is useful when you only want rules to execute on a subset of routes, avoiding the overhead of rule evaluation on routes that never match any conditions.

## Customizing Stubs

After publishing, you can customize the stub templates used by the scaffolding commands. Published stubs are located at:

| Stub | Path |
|---|---|
| Rule | `stubs/millirules/rule.stub` |
| Action | `stubs/millirules/action.stub` |
| Condition | `stubs/millirules/condition.stub` |

Published stubs take priority over the package defaults. The scaffolding commands check for a published stub first and fall back to the package stub if none is found.

### Available Placeholders

| Placeholder | Replaced with |
|---|---|
| `{{ namespace }}` | The generated class namespace (e.g., `App\Rules`) |
| `{{ class }}` | The generated class name (e.g., `SecurityHeaders`) |
| `{{ ruleId }}` | Kebab-case rule ID, rule stubs only (e.g., `security-headers`) |
| `{{ type }}` | Snake_case type name, action and condition stubs only (e.g., `cors_headers`) |

### Example: Customized Rule Stub

```php
<?php

namespace {{ namespace }};

use MilliRules\Rules;

class {{ class }}
{
    public function register(): void
    {
        Rules::create('{{ ruleId }}')
            ->order(10)
            ->when()
                // Add conditions here
            ->then()
                // Add actions here
            ->register();
    }
}
```

## Next Steps

- **[Conditions Reference](/docs/acorn-millirules/04-reference/01-conditions)** — full reference for all Acorn conditions
- **[Actions Reference](/docs/acorn-millirules/04-reference/02-actions)** — full reference for all Acorn actions
- **[Route Context Reference](/docs/acorn-millirules/04-reference/03-route-context)** — all available route context keys

---

Canonical: https://www.millipress.com/docs/acorn-millirules/04-reference/01-conditions

---
title: 'Conditions Reference'
description: 'Complete reference for Acorn MilliRules route conditions: route_name, route_parameter, and route_controller with all operators and builder syntax.'
menu_order: 80
---

# Conditions Reference

This is the complete reference for all conditions provided by the Acorn package. Each condition checks a value from the [route context](/docs/acorn-millirules/04-reference/03-route-context).

## Route Name

Match the current Laravel route name.

| Property        | Value                                                            |
|-----------------|------------------------------------------------------------------|
| **Type**        | `route_name`                                                     |
| **Class**       | `MilliRules\Acorn\Packages\Acorn\Conditions\RouteName` |
| **Context key** | `route.name`                                                     |
| **Operators**   | `=`, `!=`, `LIKE`, `REGEXP`, `IN`                                |

### Builder Syntax

```php
->routeName(string $value, string $operator = '=')
```

### Examples

Exact match:

```php
Rules::create('docs-show-header')
    ->when()
        ->routeName('docs.show')
    ->then()
        ->setHeader('X-Page', 'docs-show')
    ->register();
```

Pattern match with `LIKE` (uses `*` as wildcard):

```php
Rules::create('all-docs-header')
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Section', 'docs')
    ->register();
```

Regular expression:

```php
Rules::create('docs-or-api-header')
    ->when()
        ->routeName('^(docs|api)\.', 'REGEXP')
    ->then()
        ->setHeader('X-App-Section', 'content')
    ->register();
```

Match one of several values with `IN`:

```php
Rules::create('special-pages-header')
    ->when()
        ->routeName(['docs.show', 'docs.index', 'blog.show'], 'IN')
    ->then()
        ->setHeader('X-Content', 'true')
    ->register();
```

### Array Syntax

```php
['type' => 'route_name', 'value' => 'docs.show']
['type' => 'route_name', 'value' => 'docs.*', 'operator' => 'LIKE']
```

---

## Route Parameter

Check the value of a named route parameter. This is a **name-based condition**: the first argument is the parameter name, the second is the expected value.

| Property             | Value                                                                 |
|----------------------|-----------------------------------------------------------------------|
| **Type**             | `route_parameter`                                                     |
| **Class**            | `MilliRules\Acorn\Packages\Acorn\Conditions\RouteParameter` |
| **Context key**      | `route.parameters.{name}`                                             |
| **Argument mapping** | `['name', 'value']`                                                   |
| **Operators**        | `=`, `!=`, `LIKE`, `REGEXP`, `IN`, `EXISTS`, `NOT EXISTS`             |

### Builder Syntax

```php
// Existence check (parameter exists and is not empty)
->routeParameter(string $name)

// Value check
->routeParameter(string $name, string $value, string $operator = '=')
```

### Existence Check

When only a parameter name is provided, the condition checks whether the parameter **exists** (is present and non-empty):

```php
Rules::create('has-product-param')
    ->when()
        ->routeParameter('product')
    ->then()
        ->setHeader('X-Has-Product', 'true')
    ->register();
```

Explicitly check that a parameter does **not** exist:

```php
Rules::create('no-product-param')
    ->when()
        ->routeParameter('product', '', 'NOT EXISTS')
    ->then()
        ->redirect('/products')
    ->register();
```

> [!NOTE]
> The existence check works by evaluating whether the parameter's value is a non-empty string. When no value argument is given, the default operator (`=`) behaves like `EXISTS` — it checks `actual !== ''`.

### Value Check

Compare the parameter value against an expected value:

```php
Rules::create('millicache-product')
    ->when()
        ->routeParameter('product', 'millicache')
    ->then()
        ->setHeader('X-Product', 'millicache')
    ->register();
```

Pattern match:

```php
Rules::create('milli-products')
    ->when()
        ->routeParameter('product', 'milli*', 'LIKE')
    ->then()
        ->setHeader('X-Product-Family', 'milli')
    ->register();
```

Regular expression:

```php
Rules::create('versioned-paths')
    ->when()
        ->routeParameter('path', '^v[0-9]+/', 'REGEXP')
    ->then()
        ->setHeader('X-Versioned', 'true')
    ->register();
```

### Array Syntax

```php
// Existence check
['type' => 'route_parameter', 'name' => 'product']

// Value check
['type' => 'route_parameter', 'name' => 'product', 'value' => 'millicache']

// Pattern match
['type' => 'route_parameter', 'name' => 'product', 'value' => 'milli*', 'operator' => 'LIKE']
```

---

## Route Controller

Match the fully qualified class name of the controller handling the current route.

| Property        | Value                                                                  |
|-----------------|------------------------------------------------------------------------|
| **Type**        | `route_controller`                                                     |
| **Class**       | `MilliRules\Acorn\Packages\Acorn\Conditions\RouteController` |
| **Context key** | `route.controller`                                                     |
| **Operators**   | `=`, `!=`, `LIKE`, `REGEXP`, `IN`                                      |

### Builder Syntax

```php
->routeController(string $value, string $operator = '=')
```

### Examples

Exact match with the full class name:

```php
Rules::create('docs-controller-header')
    ->when()
        ->routeController('App\Http\Controllers\DocsController')
    ->then()
        ->setHeader('X-Handler', 'docs')
    ->register();
```

Partial match with `LIKE`:

```php
Rules::create('any-api-controller')
    ->when()
        ->routeController('*Api*Controller', 'LIKE')
    ->then()
        ->setHeader('X-API', 'true')
    ->register();
```

Match one of several controllers with `IN`:

```php
Rules::create('content-controllers')
    ->when()
        ->routeController([
            'App\Http\Controllers\DocsController',
            'App\Http\Controllers\BlogController',
        ], 'IN')
    ->then()
        ->setHeader('X-Content', 'true')
    ->register();
```

### Array Syntax

```php
['type' => 'route_controller', 'value' => 'App\Http\Controllers\DocsController']
['type' => 'route_controller', 'value' => '*DocsController', 'operator' => 'LIKE']
```

> [!TIP]
> The controller value is the fully qualified class name as registered in the route (e.g., `App\Http\Controllers\DocsController`). For invokable controllers, the class name is returned without a method suffix.

---

Canonical: https://www.millipress.com/docs/acorn-millirules/04-reference/02-actions

---
title: 'Actions Reference'
description: 'Complete reference for Acorn MilliRules actions: redirect and set_header, with builder syntax, placeholder support, behavior notes, and examples.'
menu_order: 90
---

# Actions Reference

This is the complete reference for all actions provided by the Acorn package. Actions modify the outgoing HTTP response through the [ResponseCollector](/docs/acorn-millirules/03-customization/01-custom-conditions-and-actions#responsecollector-api).

## Redirect

Redirect the request to a different URL.

| Property                | Value                                                        |
|-------------------------|--------------------------------------------------------------|
| **Type**                | `redirect`                                                   |
| **Class**               | `MilliRules\Acorn\Packages\Acorn\Actions\Redirect` |
| **Arguments**           | `url` (string), `status` (int, default: `302`)               |
| **Placeholder support** | Yes — `{route.parameters.*}`, `{route.name}`, etc.           |

### Builder Syntax

```php
->redirect(string $url, int $status = 302)
```

### Behavior

- A redirect **replaces the entire original response**. The controller's response body is discarded.
- If multiple `redirect` actions fire, **the last one wins** — each call to `setRedirect()` overwrites the previous.
- Headers set by `setHeader` actions are applied to the redirect response as well.
- The redirect uses `Illuminate\Http\RedirectResponse` internally.

### Examples

Simple redirect with a permanent status:

```php
Rules::create('legacy-docs-redirect')
    ->when()
        ->routeName('docs.legacy')
    ->then()
        ->redirect('/docs', 301)
    ->register();
```

Temporary redirect (default 302):

```php
Rules::create('maintenance-redirect')
    ->when()
        ->routeName('docs.maintenance')
    ->then()
        ->redirect('/maintenance')
    ->register();
```

Dynamic redirect using placeholders:

```php
Rules::create('product-redirect')
    ->when()
        ->routeName('docs.old-product')
    ->then()
        ->redirect('/docs/{route.parameters.product}/latest', 301)
    ->register();
```

> [!WARNING]
> An empty URL (empty string) is silently ignored — no redirect occurs. Always ensure the URL argument resolves to a non-empty value.

### Array Syntax

```php
['type' => 'redirect', 'url' => '/docs', 'status' => 301]
['type' => 'redirect', 'url' => '/docs/{route.parameters.product}']
```

---

## Set Header

Add an HTTP response header.

| Property                | Value                                                         |
|-------------------------|---------------------------------------------------------------|
| **Type**                | `set_header`                                                  |
| **Class**               | `MilliRules\Acorn\Packages\Acorn\Actions\SetHeader` |
| **Arguments**           | `name` (string), `value` (string)                             |
| **Placeholder support** | Yes — `{route.parameters.*}`, `{route.name}`, etc.            |

### Builder Syntax

```php
->setHeader(string $name, string $value)
```

### Behavior

- Headers are **additive** — multiple `setHeader` calls with different header names all apply.
- For the **same header name**, the last value wins — `addHeader()` overwrites previous values for a given key.
- Headers are applied to both normal responses and redirect responses.
- An empty header name is silently ignored.

### Examples

Single header:

```php
Rules::create('nosniff-header')
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Content-Type-Options', 'nosniff')
    ->register();
```

Multiple headers in one rule:

```php
Rules::create('security-headers')
    ->when()
        ->routeName('docs.*', 'LIKE')
    ->then()
        ->setHeader('X-Content-Type-Options', 'nosniff')
        ->setHeader('X-Frame-Options', 'DENY')
        ->setHeader('X-XSS-Protection', '1; mode=block')
    ->register();
```

Dynamic value using placeholders:

```php
Rules::create('product-header')
    ->when()
        ->routeParameter('product')
    ->then()
        ->setHeader('X-Product', '{route.parameters.product}')
    ->register();
```

Cache control headers:

```php
Rules::create('cache-docs')
    ->when()
        ->routeName('docs.show')
    ->then()
        ->setHeader('Cache-Control', 'public, max-age=3600')
        ->setHeader('Vary', 'Accept-Encoding')
    ->register();
```

### Array Syntax

```php
['type' => 'set_header', 'name' => 'X-Custom', 'value' => 'hello']
['type' => 'set_header', 'name' => 'X-Product', 'value' => '{route.parameters.product}']
```

> [!TIP]
> Use `setHeader` for standard HTTP headers like `Cache-Control`, `Vary`, security headers, and custom `X-*` headers. The header name and value are passed directly to `Symfony\Component\HttpFoundation\ResponseHeaderBag::set()`.

---

Canonical: https://www.millipress.com/docs/acorn-millirules/04-reference/03-route-context

---
title: 'Route Context Reference'
description: 'Reference for the Acorn MilliRules route context: Laravel route name, parameters, controller, URI, and middleware keys, with placeholder usage.'
menu_order: 100
---

# Route Context Reference

The Acorn package provides a `route` context that exposes metadata about the currently matched Laravel route. This context is used by route conditions internally and can be accessed in custom conditions, custom actions, and placeholders.

## Context Keys

The route context is loaded via `$context->load('route')` and provides the following keys:

| Key                       | Type     | Description                               | Example                                                    |
|---------------------------|----------|-------------------------------------------|------------------------------------------------------------|
| `route.name`              | `string` | The named route identifier                | `'docs.show'`                                              |
| `route.parameters`        | `array`  | All route parameters as key-value pairs   | `['product' => 'millicache', 'path' => 'getting-started']` |
| `route.parameters.{name}` | `string` | A specific route parameter by name        | `'millicache'`                                             |
| `route.controller`        | `string` | The fully-qualified controller class name | `'App\Http\Controllers\DocsController'`                    |
| `route.action`            | `string` | The controller method name                | `'show'`                                                   |
| `route.uri`               | `string` | The route URI pattern (with placeholders) | `'/docs/{product}/{path?}'`                                |
| `route.middleware`        | `array`  | Middleware applied to the route           | `['web', 'auth']`                                          |

### Route Name

The route name as defined by `Route::name()` in your routes file. Returns an empty string if the route is unnamed.

```php
// Route definition
Route::get('/docs/{product}', [DocsController::class, 'show'])->name('docs.show');

// Context value
$context->get('route.name'); // 'docs.show'
```

### Route Parameters

All resolved route parameters as an associative array. Individual parameters can be accessed using dot notation:

```php
// Route definition
Route::get('/docs/{product}/{path?}', [DocsController::class, 'show']);

// Visiting: /docs/millicache/getting-started
$context->get('route.parameters');            // ['product' => 'millicache', 'path' => 'getting-started']
$context->get('route.parameters.product');    // 'millicache'
$context->get('route.parameters.path');       // 'getting-started'
```

### Controller and Action

The controller class name and method are extracted from the route's `uses` action:

```php
// Route definition: DocsController@show
$context->get('route.controller'); // 'App\Http\Controllers\DocsController'
$context->get('route.action');     // 'show'

// Invokable controller: DocsController (no @method)
$context->get('route.controller'); // 'App\Http\Controllers\DocsController'
$context->get('route.action');     // ''
```

### URI Pattern

The raw route URI pattern with parameter placeholders intact:

```php
$context->get('route.uri'); // '/docs/{product}/{path?}'
```

### Middleware

An array of middleware names or classes applied to the route:

```php
$context->get('route.middleware'); // ['web', 'auth']
```

## Using Context in Placeholders

Action arguments support `{context.key}` placeholders that are resolved at execution time. All route context keys are available:

```php
Rules::create('dynamic-redirect')
    ->when()
        ->routeParameter('product')
    ->then()
        ->redirect('/new-docs/{route.parameters.product}', 301)
    ->register();

Rules::create('dynamic-header')
    ->when()
        ->routeParameter('product')
    ->then()
        ->setHeader('X-Product', '{route.parameters.product}')
        ->setHeader('X-Route', '{route.name}')
    ->register();
```

## Using Context in Custom Code

In custom conditions and actions, load and access the route context through the `Context` object:

```php
use MilliRules\Context;

// In a custom condition's get_actual_value() or action's execute()
protected function get_actual_value(Context $context): string
{
    // Load route context (idempotent — safe to call multiple times)
    $context->load('route');

    // Read a value with a default
    $name = $context->get('route.name', '');

    return is_string($name) ? $name : '';
}
```

```php
use MilliRules\Context;

// In a custom action
public function execute(Context $context): void
{
    $context->load('route');

    $product = $context->get('route.parameters.product', '');

    if (is_string($product) && $product !== '') {
        app('millirules.response')->addHeader('X-Product', $product);
    }
}
```

> [!TIP]
> No need to call `$context->load('route')` before accessing route keys. You can use `$context->get('route')` directly. The load is lazy — route data is built only on the first call and cached for subsequent access.

## Empty Context Behavior

When there is no matched Laravel route (e.g., a 404 page or a request handled outside the router), the route context returns empty defaults:

| Key                       | Empty value                      |
|---------------------------|----------------------------------|
| `route.name`              | `''` (empty string)              |
| `route.parameters`        | `[]` (empty array)               |
| `route.parameters.{name}` | `''` (empty string, via default) |
| `route.controller`        | `''` (empty string)              |
| `route.action`            | `''` (empty string)              |
| `route.uri`               | `''` (empty string)              |
| `route.middleware`        | `[]` (empty array)               |

Route conditions will not match against empty values unless explicitly checking for empty strings. This means rules with route conditions naturally skip unmatched requests.

---

Canonical: https://www.millipress.com/docs/acorn-millirules/04-reference/04-changelog

---
title: 'Changelog'
description: 'Acorn MilliRules release history: new features, fixes, and breaking changes across versions of the rules engine integration for Acorn and Laravel.'
menu_order: 40
---

# Changelog

## [1.1.1](https://github.com/MilliPress/Acorn-MilliRules/compare/v1.1.0...v1.1.1) (2026-07-16)


### Bug Fixes

* **deps:** require MilliRules 1.2 for the shared rules engine ([703015b](https://github.com/MilliPress/Acorn-MilliRules/commit/703015b6979ec3d1242b92866f3aad61306ea1ea))
* Remove redundant is_object() check flagged by PHPStan 2.2 ([e92b5f2](https://github.com/MilliPress/Acorn-MilliRules/commit/e92b5f20f8e8dda9b3112a7d9f8e6951316bc34c))

## [1.1.0](https://github.com/MilliPress/Acorn-MilliRules/compare/v1.0.0...v1.1.0) (2026-03-31)


### Features

* **deps:** Require MilliRules ^1.0 and support Acorn 6.x ([8452671](https://github.com/MilliPress/Acorn-MilliRules/commit/8452671bf8a8cbc7912a16a148266ba9c41a356b))

## 1.0.0 (2026-02-13)


### ⚠ BREAKING CHANGES

* The PHP namespace changed from MilliPress\AcornMilliRules to MilliRules\Acorn, and AcornMilliRulesServiceProvider was renamed to ServiceProvider. The Composer package name (millipress/acorn-millirules) remains unchanged.

### Features

* Add execution layer with ResponseCollector, built-in actions, and make commands ([e750afb](https://github.com/MilliPress/Acorn-MilliRules/commit/e750afbd3b2a752c2b69353fd6f52de4ab95bf13))
* Add rules:actions and rules:conditions CLI commands ([cc8e0a5](https://github.com/MilliPress/Acorn-MilliRules/commit/cc8e0a5288daa506c4b8a422ccf36d735d4337db))


### Bug Fixes

* **cli:** Deduplicate cross-package rules in rules:list output ([58987a6](https://github.com/MilliPress/Acorn-MilliRules/commit/58987a6a4f054d705f471dc5bba5899ce6d89b15))
* **cli:** Preserve wildcard types in builder column display ([485adc4](https://github.com/MilliPress/Acorn-MilliRules/commit/485adc4c609a015d5b92c88ce0e29b948a4199c8))
* Use project's Composer ClassLoader instead of first registered ([d00ea00](https://github.com/MilliPress/Acorn-MilliRules/commit/d00ea0083b97384e75a5785b13378c3d54e34686))


### Refactoring

* Move app action/condition namespaces under App\Rules ([f48d418](https://github.com/MilliPress/Acorn-MilliRules/commit/f48d418f2aeddb272850987d313b3271845765c8))
* Rename namespace to MilliRules\Acorn and class to ServiceProvider ([2d09b6f](https://github.com/MilliPress/Acorn-MilliRules/commit/2d09b6fb4c9d8b93b8b8aceac40d311c277fb632))
* Simplify type discovery using MilliRules 0.7.1 getters ([9cab3ae](https://github.com/MilliPress/Acorn-MilliRules/commit/9cab3aec01237187af5a928e4a772ae0a587360d))
