Skip to content
Back to skills

Fluentcrm Funnel Benchmark

ASecurity

Build a custom FluentCRM funnel benchmark — a goal/wait point

  • 22 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added June 5, 2026
developmentgophpvuenodetestingdebuggingapi

Works with

  • api

Security analysis

A100/100

Scanned June 5, 2026

npx -y skills add Lonsdale201/wp-agent-skills --skill fluentcrm-funnel-benchmark --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Fluentcrm Funnel Benchmark?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Fluentcrm Funnel Benchmark
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lonsdale201-fluentcrm-funnel-benchmark/badge)](https://www.skillsdirectory.com/skills/lonsdale201-fluentcrm-funnel-benchmark)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
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](FunnelHandler.php)) 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](FunnelHandler.php)) for each.

Practical consequences:

1. **The same `fluentcrm_funnel_arg_num_{name}` filter timing rule applies.** Register on `fluentcrm_loaded` priority below 10. Hook on `fluent_crm/after_init` and your benchmark only receives `$originalArgs[0]` — same actionArgNum=1 lock-in bug as triggers.
2. **`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).
3. **`fluentcrm_funnel_settings` option lifecycle includes benchmarks.** `resetFunnelIndexes()` queries published-funnel benchmark sequences ([FunnelHandler.php:156-167](FunnelHandler.php)) — 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](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()`.
- **"`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)` ([FunnelProcessor.php:301](FunnelProcessor.php)) — 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

```php
// 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
<?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:

```php
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](FunnelHandler.php)).

## Step 4 — How the registration plumbs through

1. `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.
2. `getBlockFields($funnel)` shapes the editor settings panel (returned via `fluentcrm_funnel_block_fields`).
3. Admin saves the funnel with your benchmark inside → `FunnelController` calls `resetFunnelIndexes()` which writes your `triggerName` into the `fluentcrm_funnel_settings` option.
4. 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)` ([FunnelHandler.php:139](FunnelHandler.php)) for each.
5. `BaseBenchMark::register()` listens on that hook ([BaseBenchMark.php:24](BaseBenchMark.php)) → invokes your `handle($benchMark, $originalArgs)`.
6. 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 `fluentcrm_addons_loaded` fires.
- **`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 `init` priority N for benchmark registration.** The block filters fire when the editor is opened (later than init); the action listener is locked in earlier (during `fluentcrm_addons_loaded`). Late registration means the picker shows your benchmark BUT the runtime listener was added with `argNum=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` / `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: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`

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.