Install
$ agentstack add skill-lonsdale201-wp-agent-skills-fluentcrm-funnel-trigger ✓ scanned · ✓ verified — works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
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 2.9.87 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. The fluentcrm_funnel_settings option (the registry of trigger names FluentCRM listens on) and FunnelHandler::resetFunnelIndexes() are internal implementation details that have not changed shape since the FunnelHandler was introduced.
Misconception this skill corrects
> "I'll register my trigger on fluent_crm/after_init like 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:44-46](boot/app.php)). FluentCRM's own FunnelHandler::handle runs on fluentcrm_addons_loaded ([actions.php:76](actions.php)), which fires immediately after fluentcrm_loaded ([boot/app.php:41-42](boot/app.php)) — i.e. before any init hook. By the time fluent_crm/after_init fires, FunnelHandler has already read the trigger registry option and added action listeners to your trigger names, and it has already evaluated apply_filters('fluentcrm_funnel_arg_num_{name}', 1) ([FunnelHandler.php:75](FunnelHandler.php)) — with your filter not yet registered, so $argNum falls back to 1 and only the first argument from your hook ever reaches handle().
The visible bug: a trigger like lw_lms_after_grant (which fires with 5 args) gets only $user_id delivered. $courseId = (int) ($originalArgs[1] ?? 0) becomes 0. Any guard if ($courseId startFunnelSequence($funnel, $subscriberData, [...])** at the end of your handle() method.
- "
triggerNameis a label." It is the literal WP action hook name. FluentCRM's FunnelHandler doesadd_action($triggerName, ...)([FunnelHandler.php:76](FunnelHandler.php)). 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 ([FunnelHandler.php:59-79](FunnelHandler.php)). That option is rebuilt byresetFunnelIndexes()([FunnelHandler.php:144-170](FunnelHandler.php)), 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](BaseTrigger.php)). 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 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' => '' . __('Pending status sends double-optin email.', 'my-plugin') . '',
'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 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](BaseTrigger.php)) — 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](FunnelHandler.php)). - On the next request,
FunnelHandler::handle()reads that option and adds the listener:add_action($triggerName, ..., 10, $argNum)([FunnelHandler.php:75-79](FunnelHandler.php)) where$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](FunnelHandler.php)). - That action is what
BaseTrigger::register()listens on atfluentcrm_funnel_start_{$triggerName}([BaseTrigger.php:25](BaseTrigger.php)) → invokes yourhandle($funnel, $originalArgs).
Two important consequences:
- The trigger only fires for published funnels (
Funnel::where('status', 'published')— [FunnelHandler.php:109](FunnelHandler.php)). Drafts do nothing. - The listener is registered ONCE per request. Adding triggers later in the lifecycle (e.g. on
init) means your filter wasn't there when$argNumwas captured — see the timing diagram.
Critical rules
- **Register on
fluentcrm_loadedpriority 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
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: Lonsdale201
- Source: Lonsdale201/wp-agent-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.