name: fluentcrm-funnel-benchmark description: 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 (register on fluentcrm_loaded priority below 10); benchmarks share the action listener with triggers via FunnelHandler::mapTriggers, so 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. author: Soczó Kristóf contact: mailto:lonsdale201@hotmail.com plugin: fluent-crm plugin-version-tested: "2.9.87" api-stable-since: "2.6" php-min: "7.4" last-updated: "2026-05-09" docs: - https://developers.fluentcrm.com/funnel-builder/custom-benchmark/ source-refs: - app/Services/Funnel/BaseBenchMark.php - app/Services/Funnel/FunnelProcessor.php - app/Services/Funnel/Benchmarks/TagAppliedBenchmark.php - app/Hooks/Handlers/FunnelHandler.php
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 2.9.87.
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() (FunnelHandler.php:105-141) 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} (FunnelHandler.php:139) for each.
Practical consequences:
- The same
fluentcrm_funnel_arg_num_{name}filter timing rule applies. Register onfluentcrm_loadedpriority below 10. Hook onfluent_crm/after_initand your benchmark only receives$originalArgs[0]— same actionArgNum=1 lock-in bug as triggers. triggerNamecollision 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_settingsoption lifecycle includes benchmarks.resetFunnelIndexes()queries published-funnel benchmark sequences (FunnelHandler.php:156-167) — saving a funnel that uses your benchmark adds the trigger name to the listener registry.
Other AI-prone misconceptions:
- "
triggerNameon a benchmark is the benchmark's identifier." Wrong.triggerNameis the WP action name the benchmark listens for — same semantics asBaseTrigger::triggerName. Pick a hook your plugin already fires (or an existing FluentCRM contact-state hook likefluentcrm_contact_added_to_tags); don't invent a name unique to the benchmark unless you're also firingdo_action('your_name', ...)from a dispatcher. - "
getBlock()doesn't need a'settings'key." Same trap as BaseAction. The'settings'hash on thegetBlock()return is the seed for new instances dragged into the funnel; the editor's Vue components bind directly tosettings.<field_key>. Omit it and dragging the block in throwsTypeError: Cannot read properties of undefined (reading '<your_first_field>')instart.js, leaving the panel empty.BaseBenchMark::addBenchmarkat BaseBenchMark.php:53-62 does NOT inject defaults. Seed every field key, including the auto-renderedtype(Optional/Essential) andcan_enter(direct-entry) when you reference them via$this->benchmarkTypeField()/$this->canEnterField(). - "
handle()on a benchmark is identical to a trigger'shandle()." Different signature —handle($benchMark, $originalArgs)where$benchMarkis the FunnelSequence row of the benchmark step (not a Funnel). Inside the handler, after matching, call(new FunnelProcessor())->startFunnelFromSequencePoint($benchMark, $subscriber)(FunnelProcessor.php:301) — NOTstartFunnelSequence. The latter starts a NEW funnel run; the former resumes the existing run from the matched benchmark. - "
assertCurrentGoalStateis optional." Functionally yes (returns$assertedunchanged 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. - "
OptionalvsEssentialis 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 thetypefield in settings ('optional'/'required') and consumed byFunnelProcessorwhen deciding whether to advance past an unmet goal. - "
can_enteris 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
startFunnelSequenceinstead ofstartFunnelFromSequencePoint— 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>')instart.js/boot.js— almost always a missing'settings'seed ingetBlock(). 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_addons_loaded fires. 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 (FunnelHandler.php:156-167).
Step 4 — How the registration plumbs through
getBlock()return is added tofluentcrm_funnel_blockswithtype === 'benchmark'(set byBaseBenchMark::addBenchmarkat BaseBenchMark.php:53-62) — that's how the editor's "Goal" section sees your block.getBlockFields($funnel)shapes the editor settings panel (returned viafluentcrm_funnel_block_fields).- Admin saves the funnel with your benchmark inside →
FunnelControllercallsresetFunnelIndexes()which writes yourtriggerNameinto thefluentcrm_funnel_settingsoption. - When
do_action($triggerName, ...)fires at runtime,FunnelHandler::mapTriggers()looks up benchmarkFunnelSequencerows withaction_name === $triggerName(published funnels only) and dispatchesdo_action('fluentcrm_funnel_benchmark_start_'.$triggerName, $benchMark, $originalArgs)(FunnelHandler.php:139) for each. BaseBenchMark::register()listens on that hook (BaseBenchMark.php:24) → invokes yourhandle($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_loadedpriority < 10. Same rule as triggers and actions. Thefluentcrm_funnel_arg_num_{name}filter must land beforefluentcrm_addons_loadedfires. triggerNameis the WP action hook name, identical semantics to BaseTrigger.actionArgNummatches the hook's argument count. Wrong value = silently dropped args insidehandle()— same trap that bit triggers.handle()callsstartFunnelFromSequencePoint, notstartFunnelSequence. The former resumes the EXISTING run; the latter starts a NEW run.- Always seed
'settings'ingetBlock()— one entry pergetBlockFields()['fields']key. Without it the editor renders an empty panel and Vue throwsTypeError: Cannot read properties of undefined. Same trap as BaseAction. - Defaults belong in
getBlock()['settings'], notgetBlockFields(). Misalignment = settings keys with no default values. - Implement
assertCurrentGoalStatefor 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 byFunnelHandler::mapTriggers()only.
Common mistakes
- Calling
startFunnelSequenceinstead ofstartFunnelFromSequencePoint. 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
initpriority N for benchmark registration. The block filters fire when the editor is opened (later than init); the action listener is locked in earlier (duringfluentcrm_addons_loaded). Late registration means the picker shows your benchmark BUT the runtime listener was added withargNum=1. Subtler than the trigger version because the visible bug is "no contact ever passes the goal" rather than "trigger doesn't fire". - Returning
true/falsefromhandle()thinking it gates the funnel.handle()'s return value is ignored. Match → callstartFunnelFromSequencePoint. No match → return early; the funnel stays paused. - Forgetting
assertCurrentGoalState. Falls back to the BaseBenchMark default (returns$assertedunchanged) 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-triggerfor the trigger contract (the timing diagram lives there and applies here too). - Run
fluentcrm-funnel-actionfor actions (the per-step work nodes; benchmarks are wait/branch nodes between them). - Run
fluentcrm-rest-optionswhen your benchmark uses'rest_selector'for option pickers.
What this skill does NOT cover
- Funnel conditions (
funnel_conditionaction — 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:301 - Reference benchmark (Tag Applied) —
app/Services/Funnel/Benchmarks/TagAppliedBenchmark.php - Listener bootstrap + benchmark dispatch —
app/Hooks/Handlers/FunnelHandler.php:105-141 - ResetFunnelIndexes (benchmark sequence discovery) —
app/Hooks/Handlers/FunnelHandler.php:156-167