FluentCRM: register a custom funnel benchmark
For developers building a funnel wait point — a node placed inside an automation that pauses the contact until a specific event matches the configured criteria (tag applied, course completed, payment received, custom event from your plugin). Unlike a trigger (which STARTS a funnel) or an action (which DOES something), a benchmark gates progress mid-flow. Extends FluentCrm\App\Services\Funnel\BaseBenchMark. Verified against FluentCRM 3.1.8.
API stability note
BaseBenchMark, the registration triplet (fluentcrm_funnel_blocks + fluentcrm_funnel_block_fields + fluentcrm_funnel_benchmark_start_{name}), the assertCurrentGoalState filter, and FunnelProcessor::startFunnelFromSequencePoint() have been stable since FluentCRM 2.6.0 (when goal-state assertion was introduced). The Optional vs Essential semantics and the can_enter direct-entry mechanism are part of that 2.6 baseline.
Misconception this skill corrects
"Benchmarks are special wait nodes — they have their own action listener separate from triggers."
Wrong. Benchmarks listen on the same WP action as triggers — FunnelHandler::mapTriggers() handles both in one pass. After dispatching fluentcrm_funnel_start_{triggerName} for matching trigger funnels, it queries FunnelSequence for benchmark sequences with action_name === $triggerName whose funnel is published, and dispatches fluentcrm_funnel_benchmark_start_{triggerName} for each.
Practical consequences:
- The same
fluentcrm_funnel_arg_num_{name} filter timing rule applies. Instantiate on fluentcrm_loaded priority below 10. Hook on fluent_crm/after_init and your benchmark misses both active-trigger listener passes; hook too late in init and events fired by other init callbacks can be missed or reduced to the default one accepted arg.
triggerName collision is fine between a trigger and a benchmark — they coexist on the same action. TagAppliedBenchmark::triggerName = 'fluentcrm_contact_added_to_tags' is used by both the trigger flow ("Tag Applied" trigger that starts a funnel) and the benchmark flow ("Tag Applied" wait point).
fluentcrm_funnel_settings option lifecycle includes benchmarks. resetFunnelIndexes() queries published-funnel benchmark sequences — saving a funnel that uses your benchmark adds the trigger name to the listener registry.
Other AI-prone misconceptions:
- "
triggerName on a benchmark is the benchmark's identifier." Wrong. triggerName is the WP action name the benchmark listens for — same semantics as BaseTrigger::triggerName. Pick a hook your plugin already fires (or an existing FluentCRM contact-state hook like fluentcrm_contact_added_to_tags); don't invent a name unique to the benchmark unless you're also firing do_action('your_name', ...) from a dispatcher.
- "
getBlock() doesn't need a 'settings' key." Same trap as BaseAction. The 'settings' hash on the getBlock() return is the seed for new instances dragged into the funnel; the editor's Vue components bind directly to settings.<field_key>. Omit it and dragging the block in throws TypeError: Cannot read properties of undefined (reading '<your_first_field>') in start.js, leaving the panel empty. BaseBenchMark::addBenchmark at BaseBenchMark.php:53-62 does NOT inject defaults. Seed every field key, including the auto-rendered type (Optional/Essential) and can_enter (direct-entry) when you reference them via $this->benchmarkTypeField() / $this->canEnterField().
- "
handle() on a benchmark is identical to a trigger's handle()." Different signature — handle($benchMark, $originalArgs) where $benchMark is the FunnelSequence row of the benchmark step (not a Funnel). Inside the handler, after matching, call (new FunnelProcessor())->startFunnelFromSequencePoint($benchMark, $subscriber) — NOT startFunnelSequence. The latter starts a NEW funnel run; the former resumes the existing run from the matched benchmark.
- "
assertCurrentGoalState is optional." Functionally yes (returns $asserted unchanged by default), but skipping it breaks the FluentCRM admin's "is the goal already met" UI for contacts already on the funnel — that filter is what reports back "yes, this contact has the tag" so the goal point appears completed.
- "
Optional vs Essential is just a label." It changes funnel flow. Optional: contacts can pass through without hitting the goal — useful for analytics ("did they convert?"). Essential: contacts wait at the goal indefinitely until matched — used to gate downstream actions on a real event. The split is administered via the type field in settings ('optional' / 'required') and consumed by FunnelProcessor when deciding whether to advance past an unmet goal.
- "
can_enter is a UI cosmetic." It's a real control flow toggle. With 'can_enter' => 'yes', contacts NOT on the funnel who match the trigger's criteria are inserted directly at the benchmark and continue from there. With 'no', the benchmark only matters for contacts already on the funnel. Default 'yes' for canonical behaviours like Tag Applied.
When to use this skill
Trigger when ANY of the following is true:
- Building a custom benchmark for an event your plugin emits or that FluentCRM already emits but you want to surface as a goal node.
- Code references
BaseBenchMark, fluentcrm_funnel_benchmark_start_*, assertCurrentGoalState, startFunnelFromSequencePoint, benchmarkTypeField, canEnterField.
- Reviewing benchmark code that calls
startFunnelSequence instead of startFunnelFromSequencePoint — that's the most common semantic bug (see "Common mistakes").
- Debugging "the goal panel renders empty when I drag the block in" or a console error like
TypeError: Cannot read properties of undefined (reading '<field_key>') in start.js / boot.js — almost always a missing 'settings' seed in getBlock(). See "Misconception: getBlock() doesn't need a 'settings' key".
Triggers vs Actions vs Benchmarks — a one-line decider
- Trigger — starts a funnel from outside. The contact is not yet enrolled when the trigger fires.
- Action — does work for an already-enrolled contact at a specific sequence step.
- Benchmark — gates an already-enrolled contact's progress, waiting for a real event. May ALSO direct-enrol matching contacts when
can_enter === 'yes'.
If you find yourself wanting "an action that conditionally pauses", you want a benchmark.
Step 1 — Register on the right hook
// Inside your TriggerManager / ActionManager / BenchmarkManager class:
add_action('fluentcrm_loaded', [$this, 'registerBenchmarks'], 5);
Same lifecycle constraint as triggers and actions. The fluentcrm_funnel_arg_num_{name} filter must be in place before FluentCRM's init:2 early active-trigger listener pass. See fluentcrm-funnel-trigger for the lifecycle diagram.
Step 2 — Extend BaseBenchMark
<?php
namespace MyPlugin\Modules\Benchmarks;
use FluentCrm\App\Services\Funnel\BaseBenchMark;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
use FluentCrm\Framework\Support\Arr;
final class MyEventBenchmark extends BaseBenchMark
{
public function __construct()
{
// The WP action this benchmark listens for. If your plugin doesn't
// already fire one, see Step 3 about dispatchers — pick a custom name
// and do_action() it yourself from a bridge.
$this->triggerName = 'my_plugin_thing_happened';
$this->actionArgNum = 2; // matches do_action('my_plugin_thing_happened', $thingId, $subscriber)
$this->priority = 20;
parent::__construct();
}
public function getBlock()
{
return [
'title' => __('Thing Happened', 'my-plugin'),
'description' => __('Wait until "thing" happens for the contact.', 'my-plugin'),
'icon' => 'fc-icon-tag_applied',
// CRITICAL — 'settings' seeds new block instances. The editor's
// Vue components bind directly to settings.<field_key>; omit
// this and dragging the block in throws
// `TypeError: Cannot read properties of undefined (reading
// '<first_field>')` in start.js, leaving the panel empty.
// Keys MUST match getBlockFields()['fields'] keys exactly,
// including the auto-rendered `type` and `can_enter` fields.
'settings' => [
'thing_ids' => [],
'type' => 'optional', // 'required' for Essential
'can_enter' => 'yes', // 'no' to disable direct entry
],
];
}
public function getBlockFields($funnel)
{
return [
'title' => __('Thing Happened', 'my-plugin'),
'sub_title' => __('Wait until selected things happen for the contact.', 'my-plugin'),
'fields' => [
'thing_ids' => [
'type' => 'rest_selector',
'option_key' => 'my_plugin_things',
'is_multiple' => true,
'label' => __('Target Things', 'my-plugin'),
'inline_help' => __('Leave blank to match any thing.', 'my-plugin'),
],
'type' => $this->benchmarkTypeField(), // Optional vs Essential — provided by BaseBenchMark
'can_enter' => $this->canEnterField(), // direct-entry toggle — provided by BaseBenchMark
],
];
}
public function handle($benchMark, $originalArgs)
{
$thingId = (int) ($originalArgs[0] ?? 0);
$subscriber = $originalArgs[1] ?? null;
if (!$thingId || !$subscriber) {
return;
}
$settings = $benchMark->settings;
$thingIds = (array) Arr::get($settings, 'thing_ids', []);
// Empty filter → match anything. Otherwise must match one of the configured ids.
if (!empty($thingIds) && !in_array($thingId, array_map('intval', $thingIds), true)) {
return;
}
// Resume the funnel run from THIS benchmark — NOT startFunnelSequence.
// startFunnelFromSequencePoint advances the existing funnel-subscriber
// past the goal point; startFunnelSequence creates a NEW run.
(new FunnelProcessor())->startFunnelFromSequencePoint($benchMark, $subscriber);
}
/**
* Filter — called when FluentCRM needs to know whether a contact ALREADY
* meets the goal criteria (e.g. they had the tag before they entered the
* funnel). Used for the admin "is the goal complete?" indicator and for
* deciding whether to skip past the goal automatically.
*/
public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)
{
if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {
return $asserted;
}
// Replace this with the actual "does the contact already meet the
// criteria?" check for your plugin's domain.
$userId = (int) ($funnelSubscriber->subscriber->user_id ?? 0);
$thingIds = (array) Arr::get($benchmark->settings, 'thing_ids', []);
if (empty($thingIds)) {
return false; // nothing configured = nothing to assert
}
return my_plugin_user_has_any_thing($userId, $thingIds);
}
}
Step 3 — When to use a custom-named benchmark
Same rule as triggers: if a real WP action already exists with the right shape, set triggerName to that action name and you're done. Otherwise pick a unique name and dispatch from a bridge:
add_action('my_real_plugin_event', function ($subscriberId, $thingId) {
// Skip if no published funnel uses this benchmark.
$hasActive = \FluentCrm\App\Models\FunnelSequence::where('action_name', 'my_plugin_thing_happened')
->whereHas('funnel', function ($q) { return $q->where('status', 'published'); })
->exists();
if (!$hasActive) {
return;
}
$subscriber = \FluentCrm\App\Models\Subscriber::find($subscriberId);
if (!$subscriber) {
return;
}
do_action('my_plugin_thing_happened', $thingId, $subscriber);
}, 20, 2);
Note the existence check uses FunnelSequence (not Funnel) and matches action_name — that's how resetFunnelIndexes() discovers benchmarks.
Step 4 — How the registration plumbs through
getBlock() return is added to fluentcrm_funnel_blocks with type === 'benchmark' (set by BaseBenchMark::addBenchmark at BaseBenchMark.php:53-62) — that's how the editor's "Goal" section sees your block.
getBlockFields($funnel) shapes the editor settings panel (returned via fluentcrm_funnel_block_fields).
- Admin saves the funnel with your benchmark inside →
FunnelController calls resetFunnelIndexes() which writes your triggerName into the fluentcrm_funnel_settings option.
- When
do_action($triggerName, ...) fires at runtime, FunnelHandler::mapTriggers() looks up benchmark FunnelSequence rows with action_name === $triggerName (published funnels only) and dispatches do_action('fluentcrm_funnel_benchmark_start_'.$triggerName, $benchMark, $originalArgs) for each.
BaseBenchMark::register() listens on that hook (BaseBenchMark.php:24) → invokes your handle($benchMark, $originalArgs).
- Your handler matches criteria →
startFunnelFromSequencePoint($benchMark, $subscriber) resumes the funnel.
The assertCurrentGoalState filter is dispatched separately when the admin loads a contact's funnel detail view, or when the funnel processor checks "is the goal already met" before deciding what to do.
Critical rules
- Register on
fluentcrm_loaded priority < 10. Same rule as triggers and actions. The fluentcrm_funnel_arg_num_{name} filter must land before the init:2 early active-trigger listener pass.
triggerName is the WP action hook name, identical semantics to BaseTrigger.
actionArgNum matches the hook's argument count. Wrong value = silently dropped args inside handle() — same trap that bit triggers.
handle() calls startFunnelFromSequencePoint, not startFunnelSequence. The former resumes the EXISTING run; the latter starts a NEW run.
- Always seed
'settings' in getBlock() — one entry per getBlockFields()['fields'] key. Without it the editor renders an empty panel and Vue throws TypeError: Cannot read properties of undefined. Same trap as BaseAction.
- Defaults belong in
getBlock()['settings'], not getBlockFields(). Misalignment = settings keys with no default values.
- Implement
assertCurrentGoalState for any benchmark whose criteria can be true at the time the contact entered the funnel. Tag-applied is the canonical example: a contact may already have the tag when they hit the wait point. Without the assertion, the admin UI shows the goal as pending forever.
- Use
$this->benchmarkTypeField() and $this->canEnterField() for the type/can_enter fields. They render the canonical Optional/Essential and direct-entry UI; reinventing them confuses admins who know the standard widgets.
- Don't fire
fluentcrm_funnel_benchmark_start_* directly. Same constraint as triggers — that action is dispatched by FunnelHandler::mapTriggers() only.
Common mistakes
- Calling
startFunnelSequence instead of startFunnelFromSequencePoint. Most common semantic bug — the contact gets re-enrolled in the funnel from the start instead of resuming past the goal. UI shows the same contact in the funnel twice with two different progress positions.
- Hooking ordinary
init priority for benchmark registration. The block filters may still work when the editor opens, but the runtime listener can miss the init:2 early pass. Subtler than the trigger version because the visible bug is "no contact ever passes the goal" rather than "trigger doesn't fire".
- Returning
true / false from handle() thinking it gates the funnel. handle()'s return value is ignored. Match → call startFunnelFromSequencePoint. No match → return early; the funnel stays paused.
- Forgetting
assertCurrentGoalState. Falls back to the BaseBenchMark default (returns $asserted unchanged) which means "no, the goal is never asserted from prior state". For tag-applied / list-applied / role-changed style benchmarks you almost certainly need a real implementation.
- Treating
can_enter === 'yes' as the default for all benchmarks. It's the right default for "Tag Applied", "List Applied", "Course Completed" — anything where the goal CAUSES enrollment. For benchmarks like "Email Opened" inside a sequence that already started, you may want 'no' so contacts who randomly open ANY email don't get inserted at the wait point.
Cross-references
- Run
fluentcrm-funnel-trigger for the trigger contract (the timing diagram lives there and applies here too).
- Run
fluentcrm-funnel-action for actions (the per-step work nodes; benchmarks are wait/branch nodes between them).
- Run
fluentcrm-rest-options when your benchmark uses 'rest_selector' for option pickers.
What this skill does NOT cover
- Funnel conditions (
funnel_condition action — the if/else branch node). Different contract, related to but distinct from benchmarks.
- The A/B testing branching node (
funnel_ab_testing — FluentCampaign Pro).
- Triggers (BaseTrigger). See
fluentcrm-funnel-trigger.
- Actions (BaseAction). See
fluentcrm-funnel-action.
References
- BaseBenchMark contract —
app/Services/Funnel/BaseBenchMark.php
- Resume entry point —
app/Services/Funnel/FunnelProcessor.php
- Reference benchmark (Tag Applied) —
app/Services/Funnel/Benchmarks/TagAppliedBenchmark.php
- Listener bootstrap + benchmark dispatch —
app/Hooks/Handlers/FunnelHandler.php
- ResetFunnelIndexes (benchmark sequence discovery) —
app/Hooks/Handlers/FunnelHandler.php
- Official documentation: https://developers.fluentcrm.com/funnel-builder/custom-benchmark/
1---2name: fluentcrm-funnel-benchmark3description: Build a custom FluentCRM funnel benchmark — a goal/wait point inside a sequence that pauses execution until a matching event occurs (tag applied, list joined, course completed, custom event). Extends BaseBenchMark. Covers the three abstract methods (getBlock, getBlockFields, handle), Optional vs Essential semantics, the can_enter direct-entry toggle, the assertCurrentGoalState filter, and FunnelProcessor::startFunnelFromSequencePoint as the canonical resume entry — NOT startFunnelSequence (that starts a new run). Important — same lifecycle rule as triggers (instantiate on fluentcrm_loaded priority below 10); benchmarks share the action listener with triggers via FunnelHandler::mapTriggers, so the init:2 fluentcrm_funnel_arg_num_{name} timing applies. Use when a funnel needs to wait for a contact-state change. Triggers on BaseBenchMark, fluentcrm_funnel_benchmark_start_, assertCurrentGoalState, startFunnelFromSequencePoint, benchmarkTypeField, canEnterField.4---56# FluentCRM: register a custom funnel benchmark78For developers building a funnel **wait point** — a node placed inside an automation that pauses the contact until a specific event matches the configured criteria (tag applied, course completed, payment received, custom event from your plugin). Unlike a trigger (which STARTS a funnel) or an action (which DOES something), a benchmark *gates* progress mid-flow. Extends `FluentCrm\App\Services\Funnel\BaseBenchMark`. Verified against FluentCRM 3.1.8.910## API stability note1112`BaseBenchMark`, the registration triplet (`fluentcrm_funnel_blocks` + `fluentcrm_funnel_block_fields` + `fluentcrm_funnel_benchmark_start_{name}`), the `assertCurrentGoalState` filter, and `FunnelProcessor::startFunnelFromSequencePoint()` have been stable since FluentCRM 2.6.0 (when goal-state assertion was introduced). The Optional vs Essential semantics and the `can_enter` direct-entry mechanism are part of that 2.6 baseline.1314## Misconception this skill corrects1516> "Benchmarks are special wait nodes — they have their own action listener separate from triggers."1718Wrong. Benchmarks listen on the **same** WP action as triggers — `FunnelHandler::mapTriggers()` handles both in one pass. After dispatching `fluentcrm_funnel_start_{triggerName}` for matching trigger funnels, it queries `FunnelSequence` for benchmark sequences with `action_name === $triggerName` whose funnel is published, and dispatches `fluentcrm_funnel_benchmark_start_{triggerName}` for each.1920Practical consequences:21221. **The same `fluentcrm_funnel_arg_num_{name}` filter timing rule applies.** Instantiate on `fluentcrm_loaded` priority below 10. Hook on `fluent_crm/after_init` and your benchmark misses both active-trigger listener passes; hook too late in `init` and events fired by other `init` callbacks can be missed or reduced to the default one accepted arg.232. **`triggerName` collision is fine** between a trigger and a benchmark — they coexist on the same action. `TagAppliedBenchmark::triggerName = 'fluentcrm_contact_added_to_tags'` is used by both the trigger flow ("Tag Applied" trigger that starts a funnel) and the benchmark flow ("Tag Applied" wait point).243. **`fluentcrm_funnel_settings` option lifecycle includes benchmarks.** `resetFunnelIndexes()` queries published-funnel benchmark sequences — saving a funnel that uses your benchmark adds the trigger name to the listener registry.2526Other AI-prone misconceptions:2728- **"`triggerName` on a benchmark is the benchmark's identifier."** Wrong. `triggerName` is the WP action name the benchmark listens for — same semantics as `BaseTrigger::triggerName`. Pick a hook your plugin already fires (or an existing FluentCRM contact-state hook like `fluentcrm_contact_added_to_tags`); don't invent a name unique to the benchmark unless you're also firing `do_action('your_name', ...)` from a dispatcher.29- **"`getBlock()` doesn't need a `'settings'` key."** Same trap as BaseAction. The `'settings'` hash on the `getBlock()` return is the seed for new instances dragged into the funnel; the editor's Vue components bind directly to `settings.<field_key>`. Omit it and dragging the block in throws **`TypeError: Cannot read properties of undefined (reading '<your_first_field>')`** in `start.js`, leaving the panel empty. `BaseBenchMark::addBenchmark` at [BaseBenchMark.php:53-62](BaseBenchMark.php) does NOT inject defaults. Seed every field key, including the auto-rendered `type` (Optional/Essential) and `can_enter` (direct-entry) when you reference them via `$this->benchmarkTypeField()` / `$this->canEnterField()`.30- **"`handle()` on a benchmark is identical to a trigger's `handle()`."** Different signature — `handle($benchMark, $originalArgs)` where `$benchMark` is the FunnelSequence row of the benchmark step (not a Funnel). Inside the handler, after matching, call `(new FunnelProcessor())->startFunnelFromSequencePoint($benchMark, $subscriber)` — NOT `startFunnelSequence`. The latter starts a NEW funnel run; the former resumes the existing run from the matched benchmark.31- **"`assertCurrentGoalState` is optional."** Functionally yes (returns `$asserted` unchanged by default), but skipping it breaks the FluentCRM admin's "is the goal already met" UI for contacts already on the funnel — that filter is what reports back "yes, this contact has the tag" so the goal point appears completed.32- **"`Optional` vs `Essential` is just a label."** It changes funnel flow. **Optional**: contacts can pass through without hitting the goal — useful for analytics ("did they convert?"). **Essential**: contacts wait at the goal indefinitely until matched — used to gate downstream actions on a real event. The split is administered via the `type` field in settings (`'optional'` / `'required'`) and consumed by `FunnelProcessor` when deciding whether to advance past an unmet goal.33- **"`can_enter` is a UI cosmetic."** It's a real control flow toggle. With `'can_enter' => 'yes'`, contacts NOT on the funnel who match the trigger's criteria are inserted directly at the benchmark and continue from there. With `'no'`, the benchmark only matters for contacts already on the funnel. Default 'yes' for canonical behaviours like Tag Applied.3435## When to use this skill3637Trigger when ANY of the following is true:3839- Building a custom benchmark for an event your plugin emits or that FluentCRM already emits but you want to surface as a goal node.40- Code references `BaseBenchMark`, `fluentcrm_funnel_benchmark_start_*`, `assertCurrentGoalState`, `startFunnelFromSequencePoint`, `benchmarkTypeField`, `canEnterField`.41- Reviewing benchmark code that calls `startFunnelSequence` instead of `startFunnelFromSequencePoint` — that's the most common semantic bug (see "Common mistakes").42- Debugging **"the goal panel renders empty when I drag the block in"** or a console error like `TypeError: Cannot read properties of undefined (reading '<field_key>')` in `start.js` / `boot.js` — almost always a missing `'settings'` seed in `getBlock()`. See "Misconception: `getBlock()` doesn't need a `'settings'` key".4344## Triggers vs Actions vs Benchmarks — a one-line decider4546- **Trigger** — starts a funnel from outside. The contact is not yet enrolled when the trigger fires.47- **Action** — does work for an already-enrolled contact at a specific sequence step.48- **Benchmark** — gates an already-enrolled contact's progress, waiting for a real event. May ALSO direct-enrol matching contacts when `can_enter === 'yes'`.4950If you find yourself wanting "an action that conditionally pauses", you want a benchmark.5152## Step 1 — Register on the right hook5354```php55// Inside your TriggerManager / ActionManager / BenchmarkManager class:56add_action('fluentcrm_loaded', [$this, 'registerBenchmarks'], 5);57```5859Same lifecycle constraint as triggers and actions. The `fluentcrm_funnel_arg_num_{name}` filter must be in place before FluentCRM's `init:2` early active-trigger listener pass. See `fluentcrm-funnel-trigger` for the lifecycle diagram.6061## Step 2 — Extend BaseBenchMark6263```php64<?php65namespace MyPlugin\Modules\Benchmarks;6667use FluentCrm\App\Services\Funnel\BaseBenchMark;68use FluentCrm\App\Services\Funnel\FunnelProcessor;69use FluentCrm\Framework\Support\Arr;7071final class MyEventBenchmark extends BaseBenchMark72{73 public function __construct()74 {75 // The WP action this benchmark listens for. If your plugin doesn't76 // already fire one, see Step 3 about dispatchers — pick a custom name77 // and do_action() it yourself from a bridge.78 $this->triggerName = 'my_plugin_thing_happened';7980 $this->actionArgNum = 2; // matches do_action('my_plugin_thing_happened', $thingId, $subscriber)81 $this->priority = 20;8283 parent::__construct();84 }8586 public function getBlock()87 {88 return [89 'title' => __('Thing Happened', 'my-plugin'),90 'description' => __('Wait until "thing" happens for the contact.', 'my-plugin'),91 'icon' => 'fc-icon-tag_applied',92 // CRITICAL — 'settings' seeds new block instances. The editor's93 // Vue components bind directly to settings.<field_key>; omit94 // this and dragging the block in throws95 // `TypeError: Cannot read properties of undefined (reading96 // '<first_field>')` in start.js, leaving the panel empty.97 // Keys MUST match getBlockFields()['fields'] keys exactly,98 // including the auto-rendered `type` and `can_enter` fields.99 'settings' => [100 'thing_ids' => [],101 'type' => 'optional', // 'required' for Essential102 'can_enter' => 'yes', // 'no' to disable direct entry103 ],104 ];105 }106107 public function getBlockFields($funnel)108 {109 return [110 'title' => __('Thing Happened', 'my-plugin'),111 'sub_title' => __('Wait until selected things happen for the contact.', 'my-plugin'),112 'fields' => [113 'thing_ids' => [114 'type' => 'rest_selector',115 'option_key' => 'my_plugin_things',116 'is_multiple' => true,117 'label' => __('Target Things', 'my-plugin'),118 'inline_help' => __('Leave blank to match any thing.', 'my-plugin'),119 ],120 'type' => $this->benchmarkTypeField(), // Optional vs Essential — provided by BaseBenchMark121 'can_enter' => $this->canEnterField(), // direct-entry toggle — provided by BaseBenchMark122 ],123 ];124 }125126 public function handle($benchMark, $originalArgs)127 {128 $thingId = (int) ($originalArgs[0] ?? 0);129 $subscriber = $originalArgs[1] ?? null;130131 if (!$thingId || !$subscriber) {132 return;133 }134135 $settings = $benchMark->settings;136 $thingIds = (array) Arr::get($settings, 'thing_ids', []);137138 // Empty filter → match anything. Otherwise must match one of the configured ids.139 if (!empty($thingIds) && !in_array($thingId, array_map('intval', $thingIds), true)) {140 return;141 }142143 // Resume the funnel run from THIS benchmark — NOT startFunnelSequence.144 // startFunnelFromSequencePoint advances the existing funnel-subscriber145 // past the goal point; startFunnelSequence creates a NEW run.146 (new FunnelProcessor())->startFunnelFromSequencePoint($benchMark, $subscriber);147 }148149 /**150 * Filter — called when FluentCRM needs to know whether a contact ALREADY151 * meets the goal criteria (e.g. they had the tag before they entered the152 * funnel). Used for the admin "is the goal complete?" indicator and for153 * deciding whether to skip past the goal automatically.154 */155 public function assertCurrentGoalState($asserted, $benchmark, $funnelSubscriber)156 {157 if (!$funnelSubscriber || !$funnelSubscriber->subscriber) {158 return $asserted;159 }160161 // Replace this with the actual "does the contact already meet the162 // criteria?" check for your plugin's domain.163 $userId = (int) ($funnelSubscriber->subscriber->user_id ?? 0);164 $thingIds = (array) Arr::get($benchmark->settings, 'thing_ids', []);165166 if (empty($thingIds)) {167 return false; // nothing configured = nothing to assert168 }169170 return my_plugin_user_has_any_thing($userId, $thingIds);171 }172}173```174175## Step 3 — When to use a custom-named benchmark176177Same rule as triggers: if a real WP action already exists with the right shape, set `triggerName` to that action name and you're done. Otherwise pick a unique name and dispatch from a bridge:178179```php180add_action('my_real_plugin_event', function ($subscriberId, $thingId) {181 // Skip if no published funnel uses this benchmark.182 $hasActive = \FluentCrm\App\Models\FunnelSequence::where('action_name', 'my_plugin_thing_happened')183 ->whereHas('funnel', function ($q) { return $q->where('status', 'published'); })184 ->exists();185 if (!$hasActive) {186 return;187 }188 $subscriber = \FluentCrm\App\Models\Subscriber::find($subscriberId);189 if (!$subscriber) {190 return;191 }192 do_action('my_plugin_thing_happened', $thingId, $subscriber);193}, 20, 2);194```195196Note the existence check uses `FunnelSequence` (not `Funnel`) and matches `action_name` — that's how `resetFunnelIndexes()` discovers benchmarks.197198## Step 4 — How the registration plumbs through1992001. `getBlock()` return is added to `fluentcrm_funnel_blocks` with `type === 'benchmark'` (set by `BaseBenchMark::addBenchmark` at [BaseBenchMark.php:53-62](BaseBenchMark.php)) — that's how the editor's "Goal" section sees your block.2012. `getBlockFields($funnel)` shapes the editor settings panel (returned via `fluentcrm_funnel_block_fields`).2023. Admin saves the funnel with your benchmark inside → `FunnelController` calls `resetFunnelIndexes()` which writes your `triggerName` into the `fluentcrm_funnel_settings` option.2034. When `do_action($triggerName, ...)` fires at runtime, `FunnelHandler::mapTriggers()` looks up benchmark `FunnelSequence` rows with `action_name === $triggerName` (published funnels only) and dispatches `do_action('fluentcrm_funnel_benchmark_start_'.$triggerName, $benchMark, $originalArgs)` for each.2045. `BaseBenchMark::register()` listens on that hook ([BaseBenchMark.php:24](BaseBenchMark.php)) → invokes your `handle($benchMark, $originalArgs)`.2056. Your handler matches criteria → `startFunnelFromSequencePoint($benchMark, $subscriber)` resumes the funnel.206207The `assertCurrentGoalState` filter is dispatched separately when the admin loads a contact's funnel detail view, or when the funnel processor checks "is the goal already met" before deciding what to do.208209## Critical rules210211- **Register on `fluentcrm_loaded` priority < 10.** Same rule as triggers and actions. The `fluentcrm_funnel_arg_num_{name}` filter must land before the `init:2` early active-trigger listener pass.212- **`triggerName` is the WP action hook name**, identical semantics to BaseTrigger.213- **`actionArgNum` matches the hook's argument count.** Wrong value = silently dropped args inside `handle()` — same trap that bit triggers.214- **`handle()` calls `startFunnelFromSequencePoint`, not `startFunnelSequence`.** The former resumes the EXISTING run; the latter starts a NEW run.215- **Always seed `'settings'` in `getBlock()`** — one entry per `getBlockFields()['fields']` key. Without it the editor renders an empty panel and Vue throws `TypeError: Cannot read properties of undefined`. Same trap as BaseAction.216- **Defaults belong in `getBlock()['settings']`, not `getBlockFields()`.** Misalignment = settings keys with no default values.217- **Implement `assertCurrentGoalState` for any benchmark whose criteria can be true at the time the contact entered the funnel.** Tag-applied is the canonical example: a contact may already have the tag when they hit the wait point. Without the assertion, the admin UI shows the goal as pending forever.218- **Use `$this->benchmarkTypeField()` and `$this->canEnterField()` for the type/can_enter fields.** They render the canonical Optional/Essential and direct-entry UI; reinventing them confuses admins who know the standard widgets.219- **Don't fire `fluentcrm_funnel_benchmark_start_*` directly.** Same constraint as triggers — that action is dispatched by `FunnelHandler::mapTriggers()` only.220221## Common mistakes222223- **Calling `startFunnelSequence` instead of `startFunnelFromSequencePoint`.** Most common semantic bug — the contact gets re-enrolled in the funnel from the start instead of resuming past the goal. UI shows the same contact in the funnel twice with two different progress positions.224- **Hooking ordinary `init` priority for benchmark registration.** The block filters may still work when the editor opens, but the runtime listener can miss the `init:2` early pass. Subtler than the trigger version because the visible bug is "no contact ever passes the goal" rather than "trigger doesn't fire".225- **Returning `true` / `false` from `handle()` thinking it gates the funnel.** `handle()`'s return value is ignored. Match → call `startFunnelFromSequencePoint`. No match → return early; the funnel stays paused.226- **Forgetting `assertCurrentGoalState`.** Falls back to the BaseBenchMark default (returns `$asserted` unchanged) which means "no, the goal is never asserted from prior state". For tag-applied / list-applied / role-changed style benchmarks you almost certainly need a real implementation.227- **Treating `can_enter === 'yes'` as the default for all benchmarks.** It's the right default for "Tag Applied", "List Applied", "Course Completed" — anything where the goal CAUSES enrollment. For benchmarks like "Email Opened" inside a sequence that already started, you may want `'no'` so contacts who randomly open ANY email don't get inserted at the wait point.228229## Cross-references230231- Run **`fluentcrm-funnel-trigger`** for the trigger contract (the timing diagram lives there and applies here too).232- Run **`fluentcrm-funnel-action`** for actions (the per-step work nodes; benchmarks are wait/branch nodes between them).233- Run **`fluentcrm-rest-options`** when your benchmark uses `'rest_selector'` for option pickers.234235## What this skill does NOT cover236237- Funnel **conditions** (`funnel_condition` action — the if/else branch node). Different contract, related to but distinct from benchmarks.238- The **A/B testing** branching node (`funnel_ab_testing` — FluentCampaign Pro).239- Triggers (BaseTrigger). See `fluentcrm-funnel-trigger`.240- Actions (BaseAction). See `fluentcrm-funnel-action`.241242## References243244- BaseBenchMark contract — `app/Services/Funnel/BaseBenchMark.php`245- Resume entry point — `app/Services/Funnel/FunnelProcessor.php`246- Reference benchmark (Tag Applied) — `app/Services/Funnel/Benchmarks/TagAppliedBenchmark.php`247- Listener bootstrap + benchmark dispatch — `app/Hooks/Handlers/FunnelHandler.php`248- ResetFunnelIndexes (benchmark sequence discovery) — `app/Hooks/Handlers/FunnelHandler.php`249- Official documentation: <https://developers.fluentcrm.com/funnel-builder/custom-benchmark/>