Fluentcrm funnel trigger
Skill Lonsdale201/wp-agent-skills/fluentcrm/fluentcrm-funnel-trigger
A community-maintained collection of agent skills for WordPress plugin and theme development.
npx -y skills add Lonsdale201/wp-agent-skills --skill fluentcrm-funnel-triggerAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 21 stars21 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Build a custom FluentCRM funnel trigger by extending BaseTrigger. Covers the four abstract methods (getTrigger, getFunnelSettingsDefaults, getSettingsFields, handle), the auto-injected __force_run_actions field, the canonical isProcessable / run_multiple / ifAlreadyInFunnel guard, the fluentcrm_funnel_will_process_{name} filter parity, source_trigger_name / source_ref_id metadata for FunnelProcessor::startFunnelSequence. Important — instantiate on fluentcrm_loaded priority below 10, NEVER on fluent_crm/after_init. FluentCRM 3.1.8 registers active trigger listeners on init:2 when the fluentcrm_funnel_arg_num_{name} filter is already present, then falls back on init:20; late registration can miss init:10 events or multi-arg hooks. Use when scaffolding a CRM integration. Triggers on BaseTrigger, fluentcrm_funnel_triggers, fluentcrm_funnel_start_, fluentcrm_funnel_arg_num_, FunnelProcessor, FunnelHelper, fluentcrm_funnel_settings, source_trigger_name.
SKILL.md
25.9 KB, ~5.8k tokens by cl100k_base, as published. Nobody here has run it
FluentCRM: register a custom funnel trigger
For developers building a companion plugin that needs to start a FluentCRM automation when something happens (course enrolled, SaaS webhook arrived, custom CPT published, third-party event). Extends FluentCrm\App\Services\Funnel\BaseTrigger and registers via the fluentcrm_funnel_triggers filter chain. This skill maps the contract verified end-to-end against FluentCRM 3.1.8 source, with the lifecycle order written out in full because timing is where this contract silently breaks.
API stability note
The BaseTrigger abstract class, the fluentcrm_funnel_triggers / fluentcrm_funnel_start_* / fluentcrm_funnel_editor_details_* / fluentcrm_funnel_arg_num_* hook family, and the FunnelProcessor::startFunnelSequence() entry point have been stable across the 2.7+ line. In FluentCRM 3.1.8, active trigger listeners are registered by FunnelHandler::registerEarlyActiveTriggers() on init:2 when the arg-count filter is already present, with a fallback registerActiveTriggers() pass on init:20.
Misconception this skill corrects
"I'll register my trigger on
fluent_crm/after_initlike the other addons do — it's a clean post-boot hook."
Wrong hook. fluent_crm/after_init runs on init priority 1000 (boot/app.php). In 3.1.8, FluentCRM's FunnelHandler registers core funnel items on init:1, then performs an early active-trigger listener pass on init:2 and a fallback pass on init:20. The early pass only registers trigger listeners whose fluentcrm_funnel_arg_num_{name} filter already exists. By the time fluent_crm/after_init fires, both listener passes have already run.
The visible bug: a trigger like lw_lms_after_grant (which fires with 5 args) either misses events fired during init:10, or gets only $user_id delivered if the fallback listener captured the default arg count. $courseId = (int) ($originalArgs[1] ?? 0) becomes 0. Any guard if ($courseId <= 0) return; silently drops every event.
The fix is one line: instantiate your BaseTrigger subclass on fluentcrm_loaded priority 5 instead of fluent_crm/after_init. FluentCampaign Pro registers its funnel items on init:1, which lands before the init:2 early active-trigger pass.
Other AI-prone misconceptions:
- "I'll fire
fluentcrm_funnel_start_{my_trigger}directly to start the funnel." No. That action is dispatched byFunnelHandler::mapTriggers()after it has already validated that a published funnel matches the trigger name. Calling it directly bypasses the funnel lookup, the per-funnel sequence config, and the benchmark dispatch. Always go through(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [...])at the end of yourhandle()method. - "
triggerNameis a label." It is the literal WP action hook name. FluentCRM'sregisterActiveTriggers()doesadd_action($triggerName, ...). Either settriggerNameto a real WP action that already fires (e.g.tutor_after_enrolled), or pick a custom name and firedo_action('my_custom_event', $arg1, $arg2)yourself from a bridge — see "Custom-name pattern" below. - "FluentCRM listens to my hook the moment my plugin loads." Wrong. FluentCRM only adds a listener for trigger names present in the
fluentcrm_funnel_settingsWP option. That option is rebuilt byresetFunnelIndexes(), which runs when a funnel is created / updated / status-changed viaFunnelController. No published funnel using your trigger name → no listener → your trigger silently doesn't fire. When you ship a new trigger, the user must publish (or re-save) at least one funnel using it before listeners exist. - "Setting
actionArgNumis enough." It is necessary but not sufficient. The integer is fed into a filter (fluentcrm_funnel_arg_num_{name}) registered byBaseTrigger::register(). The filter has to be in place before FunnelHandler reads it — see Misconception #1. - "I need to add the
__force_run_actionstoggle myself." No —BaseTrigger::prepareEditorDetails()injects it automatically (BaseTrigger.php:53-69). Just declare your trigger-specific fields; the "Run actions even if contact is unsubscribed" toggle appears at the bottom of the settings panel for free. - "
isProcessableis just business-logic filtering." It also enforces the standard "If contact already in this funnel, skip / restart" semantics that every built-in trigger implements. Skip theFunnelHelper::ifAlreadyInFunnelguard and your trigger creates duplicateFunnelSubscriberrows on every event. See "Canonical isProcessable" below.
When to use this skill
Trigger when ANY of the following is true:
- Building a companion plugin that needs to start a FluentCRM automation in response to a third-party event (LMS enrollment, SaaS webhook, CPT publish, payment processed, custom WP action).
- The diff/files reference
BaseTrigger,fluentcrm_funnel_triggers,fluentcrm_funnel_start_*,FunnelProcessor::startFunnelSequence,FunnelHelper::prepareUserData,FunnelHelper::ifAlreadyInFunnel. - Debugging "my trigger appears in the picker but the automation never runs" — almost always a timing issue (Misconception #1) or a missing-publish issue (Misconception #3).
- Reviewing code that hooks
fluent_crm/after_initto register triggers.
Lifecycle in one diagram
plugins_loaded:10 ← your plugin instantiates its bootstrap
↓
fluentcrm_loaded ← do_action('fluentcrm_loaded') from boot/app.php:41
REGISTER TRIGGERS HERE (priority < 10)
↓
fluentcrm_addons_loaded ← do_action('fluentcrm_addons_loaded') from boot/app.php:42
addon boot point
↓
init:1 ← FunnelHandler::registerFunnelItems()
Core trigger/action/benchmark classes register.
Pro IntegrationHandler also registers here.
↓
init:2 ← FunnelHandler::registerEarlyActiveTriggers()
→ reads fluentcrm_funnel_settings option
→ registers only trigger names whose
fluentcrm_funnel_arg_num_{name} filter exists
↓
init:10 ← FunnelHandler::handle()
→ registers automation runner callback
↓
init:20 ← FunnelHandler::registerActiveTriggers()
fallback listener pass for late arg filters
↓
init:1000 ← do_action('fluent_crm/after_init') from boot/app.php:44-46
TOO LATE for trigger registration
↓
... runtime events fire ...
The single rule: fluentcrm_funnel_arg_num_{name} filter must be in place before the init:2 early active-trigger pass if the underlying event can fire during init. The simplest way to guarantee that is to instantiate your BaseTrigger subclass on fluentcrm_loaded priority 5.
Step 1 — Register on the right hook
<?php
namespace MyPlugin\Modules\Triggers;
use HelloWP\MyPlugin\Support\Dependency;
use HelloWP\MyPlugin\Settings\SettingsRepository;
final class TriggerManager
{
public function __construct()
{
// CRITICAL — this runs before FunnelHandler's init:2 early listener
// pass. If our BaseTrigger subclass is not instantiated by then,
// the fluentcrm_funnel_arg_num_{name} filter is missing and init-time
// events can be missed or reduced to the default one accepted arg.
add_action('fluentcrm_loaded', [$this, 'registerTriggers'], 5);
}
public function registerTriggers(): void
{
if (Dependency::isMyServiceActive() && SettingsRepository::isEnabled('triggers', 'my_event')) {
new MyEventTrigger();
}
}
}
new MyEventTrigger() invokes the parent constructor, which calls register() (BaseTrigger.php:14-33) — that's where the four filter / action / arg-num registrations happen.
Step 2 — Extend BaseTrigger
<?php
namespace MyPlugin\Modules\Triggers;
use FluentCrm\App\Services\Funnel\BaseTrigger;
use FluentCrm\App\Services\Funnel\FunnelHelper;
use FluentCrm\App\Services\Funnel\FunnelProcessor;
use FluentCrm\Framework\Support\Arr;
final class MyEventTrigger extends BaseTrigger
{
public function __construct()
{
// Real WP action — fired from somewhere else (your plugin, a third
// party, WP core). FluentCRM will add_action() to this name.
$this->triggerName = 'my_plugin_thing_happened';
// priority for the fluentcrm_funnel_triggers filter (controls picker order)
$this->priority = 12;
// Argument count this hook fires with. Verified against the do_action
// call site — see Step 4 if you control the dispatch.
$this->actionArgNum = 3;
parent::__construct();
}
public function getTrigger()
{
return [
'category' => __('My Service', 'my-plugin'),
'label' => __('Thing Happened', 'my-plugin'),
'description' => __('Fires when "the thing" happens in My Service.', 'my-plugin'),
'icon' => 'fc-icon-trigger',
];
}
public function getFunnelSettingsDefaults()
{
return [
'subscription_status' => 'subscribed',
];
}
public function getSettingsFields($funnel)
{
return [
'title' => __('Thing Happened (My Service)', 'my-plugin'),
'sub_title' => __('Starts when My Service emits the event.', 'my-plugin'),
'fields' => [
'subscription_status' => [
'type' => 'option_selectors',
'option_key' => 'editable_statuses',
'is_multiple' => false,
'label' => __('Subscription Status', 'my-plugin'),
'placeholder' => __('Select Status', 'my-plugin'),
],
'subscription_status_info' => [
'type' => 'html',
'info' => '<b>' . __('Pending status sends double-optin email.', 'my-plugin') . '</b>',
'dependency' => [
'depends_on' => 'subscription_status',
'operator' => '=',
'value' => 'pending',
],
],
],
];
}
public function getFunnelConditionDefaults($funnel)
{
return [
'update_type' => 'update', // skip_all_actions | skip_update_if_exist | update
'thing_ids' => [],
'run_multiple' => 'no',
];
}
public function getConditionFields($funnel)
{
return [
'update_type' => [
'type' => 'radio',
'label' => __('If Contact Already Exists?', 'my-plugin'),
'help' => __('What happens when the contact is already in the database.', 'my-plugin'),
'options' => FunnelHelper::getUpdateOptions(),
],
'thing_ids' => [
'type' => 'rest_selector',
'option_key' => 'my_plugin_things', // see fluentcrm-rest-options skill
'is_multiple' => true,
'label' => __('Target Things', 'my-plugin'),
'inline_help' => __('Leave blank to run on every event.', 'my-plugin'),
],
'run_multiple' => [
'type' => 'yes_no_check',
'label' => '',
'check_label' => __('Restart automation multiple times for the same contact.', 'my-plugin'),
'inline_help' => __('Without this, contacts already in the funnel are skipped.', 'my-plugin'),
],
];
}
public function handle($funnel, $originalArgs)
{
$userId = (int) ($originalArgs[0] ?? 0);
$thingId = (int) ($originalArgs[1] ?? 0);
if ($userId <= 0 || $thingId <= 0) {
return;
}
$subscriberData = FunnelHelper::prepareUserData($userId);
$subscriberData['source'] = __('My Service', 'my-plugin');
if (empty($subscriberData['email']) || !is_email($subscriberData['email'])) {
return;
}
$willProcess = $this->isProcessable($funnel, $thingId, $subscriberData);
// Required parity with FluentCampaign Pro triggers — third-party
// plugins use this filter to add their own gating without forking.
$willProcess = apply_filters(
'fluentcrm_funnel_will_process_' . $this->triggerName,
$willProcess, $funnel, $subscriberData, $originalArgs
);
if (!$willProcess) {
return;
}
// Merge funnel-level settings (subscription_status etc.) into subscriberData
// and translate to the wire format FunnelProcessor expects.
$subscriberData = wp_parse_args($subscriberData, $funnel->settings);
$subscriberData['status'] = !empty($subscriberData['subscription_status'])
? $subscriberData['subscription_status']
: 'subscribed';
unset($subscriberData['subscription_status']);
(new FunnelProcessor())->startFunnelSequence($funnel, $subscriberData, [
'source_trigger_name' => $this->triggerName,
'source_ref_id' => $thingId,
]);
}
private function isProcessable($funnel, int $thingId, array $subscriberData): bool
{
$conditions = (array) $funnel->conditions;
if (Arr::get($conditions, 'update_type') === 'skip_all_if_exist'
&& FunnelHelper::getSubscriber($subscriberData['email'])
) {
return false;
}
$thingIds = Arr::get($conditions, 'thing_ids', []);
if (!empty($thingIds) && !in_array($thingId, array_map('intval', (array) $thingIds), true)) {
return false;
}
$subscriber = FunnelHelper::getSubscriber($subscriberData['email']);
if ($subscriber && FunnelHelper::ifAlreadyInFunnel($funnel->id, $subscriber->id)) {
$multipleRun = Arr::get($conditions, 'run_multiple') === 'yes';
if ($multipleRun) {
FunnelHelper::removeSubscribersFromFunnel($funnel->id, [$subscriber->id]);
} else {
return false;
}
}
return true;
}
}
Step 3 — When to use a real WP action vs a custom one
Two valid shapes:
Real WP action (preferred when one exists). Set $triggerName to the action name a third-party plugin / WP core / your own plugin already fires. FluentCRM picks it up automatically:
$this->triggerName = 'tutor_after_enrolled'; // real Tutor LMS hook
$this->actionArgNum = 2; // matches do_action('tutor_after_enrolled', $courseId, $userId)
No bridging needed.
Custom action with a bridge (for synthesised / filtered events). When the underlying event needs filtering before the trigger runs (e.g. "only fire on order created, not on every status change"), pick a unique custom name and fire it yourself:
// In a separate "dispatcher" class loaded on fluentcrm_loaded too:
add_action('woocommerce_new_order', function ($orderId, $order) {
// Skip if no published funnel uses this trigger — saves DB lookups.
$hasActiveFunnel = \FluentCrm\App\Models\Funnel::where('status', 'published')
->where('trigger_name', 'my_custom_woo_status_changed')
->exists();
if (!$hasActiveFunnel) {
return;
}
do_action('my_custom_woo_status_changed', $orderId, $order);
}, 22, 2);
The trigger class then listens on my_custom_woo_status_changed with actionArgNum = 2. Same lifecycle rules apply — register on fluentcrm_loaded priority 5.
Step 4 — How FluentCRM connects the dots
Follow the chain:
- Your
getTrigger()return value is filtered intofluentcrm_funnel_triggers(BaseTrigger.php:21) — that's how the picker UI sees your trigger. - When the admin saves a funnel using your trigger name,
FunnelControllercallsFunnelHandler::resetFunnelIndexes()which writes your trigger name into thefluentcrm_funnel_settingsoption (FunnelHandler.php:144-170). - On the next request,
FunnelHandler::registerEarlyActiveTriggers()reads that option oninit:2and adds the listener whenhas_filter('fluentcrm_funnel_arg_num_'.$triggerName)is true.FunnelHandler::registerActiveTriggers()repeats the pass oninit:20as a fallback. In both passes$argNum = apply_filters('fluentcrm_funnel_arg_num_'.$triggerName, 1). - When
do_action($triggerName, ...args)fires, the listener callsmapTriggers()which dispatchesdo_action("fluentcrm_funnel_start_{$triggerName}", $funnel, $originalArgs)(FunnelHandler.php:120). - That action is what
BaseTrigger::register()listens on atfluentcrm_funnel_start_{$triggerName}(BaseTrigger.php:25) → invokes yourhandle($funnel, $originalArgs).
Two important consequences:
- The trigger only fires for published funnels (
Funnel::where('status', 'published')— FunnelHandler.php:109). Drafts do nothing. - The listener is registered once per request. Adding triggers later in the lifecycle means your filter may miss the early pass; events fired before the fallback pass will not reach your handler.
Critical rules
- Register on
fluentcrm_loadedpriority < 10. Notfluent_crm/after_init. Avoidinitunless you are deliberately using priority 1 and know it runs before theinit:2active-trigger listener pass. Your plugin's bootstrap onplugins_loadedshould set up theadd_action('fluentcrm_loaded', ...)chain, not register triggers eagerly. triggerNameis the WP action hook name, not a label. Use the real third-party action when one exists; use a custom name + your own dispatcher when you need filtered events.actionArgNummust match whatdo_action()actually passes. If the hook fires with 5 args (e.g.lw_lms_after_grant), set 5; if it fires with 2, set 2. Wrong value = silently dropped args insidehandle().- Always go through
FunnelProcessor::startFunnelSequence(FunnelProcessor.php:66) at the end ofhandle(). Neverdo_action('fluentcrm_funnel_start_*')yourself; FluentCRM'smapTriggersis the only legitimate caller. - Pass
source_trigger_nameandsource_ref_idin the third arg ofstartFunnelSequence— the funnel report UI keys off these to show "started by event X / ref Y". - Always implement the
ifAlreadyInFunnelguard. Without it, every event re-enrols the same contact and creates duplicateFunnelSubscriberrows. The canonical pattern is in Step 2; copy it verbatim and adjust the field reads. - Always include the
apply_filters('fluentcrm_funnel_will_process_' . $triggerName, ...)line. Third-party plugins (and your own future code) use this filter to gate execution; missing it breaks the parity with FluentCampaign Pro triggers and surprises integrators. __force_run_actionsis auto-injected by BaseTrigger. Don't redeclare it. Don't reference its semantics in your settings UI either — the parent class adds the toggle and its inline help.- Tell the user to (re)publish the funnel after install. A new trigger doesn't appear in the listener registry until at least one funnel using it is published. The first time you ship the trigger, instruct the admin to open the funnel in the editor and click Save / Update — that triggers
resetFunnelIndexes(). - Do not collapse
completeandcompleted. Doesn't apply directly to triggers, but downstream actions uselast_sequence_status = completewhile full automation runs and funnel metrics usecompleted. - Plugin-presence detection MUST use a file-load-time symbol — a top-level class declared in the dependency's main file (
class_exists('TopLevelClass')) or a constantdefine()'d at file scope (defined('CONST_NAME')). NEVERfunction_exists('helper')— those helpers are typically declared inside the dependency's ownplugins_loadedcallback. Two plugins onplugins_loaded:10run in registration order (non-deterministic), so a function-based check passes when the dep loaded first and fails when it loaded second — the trigger silently disappears from the picker on half the requests. If the dep's main class only exists post-init, find a top-level loader/registrar (e.g. WC Memberships exposesWC_Memberships_Loaderat file scope whileWC_Membershipsitself is loaded insideinit_plugin()).
Common mistakes
- Hooking ordinary
initpriority for trigger registration. Catches the editor side (the trigger appears in the picker) but may lose the runtime side. In 3.1.8, the early active-trigger pass isinit:2; anything later can miss same-request events fired by otherinitcallbacks. - Forgetting the dispatcher class for custom-name triggers. The trigger class registers FluentCRM's listeners, but FluentCRM doesn't fire your custom hook. You need a separate small class hooked on the underlying event that calls
do_action('my_custom_event', ...). prepareUserData(0)for guest events.prepareUserDatais fine with0— it returns an empty array — but you must merge an explicitemailinto the result yourself before checkingis_email. Otherwise you drop guest-driven events that have a valid email but no WP user (e.g. anonymous review submissions, public form integrations).- Hardcoding
subscription_status = 'subscribed'. The admin sets this per-funnel via the auto-injected status field; respect it. The settings merge inhandle()(wp_parse_args($subscriberData, $funnel->settings)) is what wires it through. - Not gating the dispatcher with
Funnel::where('status', 'published'). Without the gate, your dispatcher does work on every event even when no automation listens. Cheap to check, common-sense optimisation. - Storing trigger-specific state in
$funnel->settingsvs$funnel->conditions.settings= funnel-level config that maps onto subscriber data (status, source).conditions= per-event gating (target IDs, run_multiple). Get the split wrong and the editor renders fields in the wrong panel. - Using
function_exists()for dep detection at registration time. The classic load-order race — the helper function is declared inside the dep's ownplugins_loadedcallback, which may or may not have run by the time yourfluentcrm_loaded:5listener fires. Symptom: the trigger appears in the picker on some installs / page loads and not others. Fix: switch to a file-scopeclass_existsordefinedcheck (see Critical rules).
Cross-references
- Run
fluentcrm-funnel-actionwhen you need to add a custom block in the funnel sequence (an action that fires per step, not per event). - Run
fluentcrm-rest-optionswhen your trigger or action uses'type' => 'rest_selector'for option pickers — you'll need to register the correspondingfluentcrm_ajax_options_{key}filter. - See
fluentcrm-overview(when written) for the full lifecycle / Free vs Pro / file map context.
What this skill does NOT cover
- Building custom funnel actions (BaseAction) — see
fluentcrm-funnel-action. - Building custom benchmarks (BaseBenchMark, the "wait for X" branching nodes) — separate contract, separate registration filter.
- Custom smart codes / template tags (
{{my_code.foo}}). - The
fluent_crm/contact_*lifecycle hooks (subscriber state changes outside the funnel system). - Free vs Pro feature splits — the trigger contract is identical in both, only the editor UI varies.
- Block-editor email templates / styling — out of scope for the funnel layer.
References
- FluentCRM developer docs — custom triggers
- BaseTrigger contract —
app/Services/Funnel/BaseTrigger.php - FunnelProcessor entry point —
app/Services/Funnel/FunnelProcessor.php:66 - Helper utilities (prepareUserData, ifAlreadyInFunnel, getUpdateOptions, removeSubscribersFromFunnel, getSubscriber, maybeExplodeFullName) —
app/Services/Funnel/FunnelHelper.php - Listener bootstrap +
mapTriggers—app/Hooks/Handlers/FunnelHandler.php - Bootstrap order (
fluentcrm_loaded→fluentcrm_addons_loaded→init:1/2/10/20→fluent_crm/after_init) —boot/app.php,app/Hooks/actions.php,app/Hooks/Handlers/FunnelHandler.php - Reference Pro trigger (canonical pattern) —
fluentcampaign-pro/app/Services/Integrations/TutorLms/CourseEnrollTrigger.php