FluentCRM: developer overview

SkillAI & models

Orient skill for FluentCRM extension development. Covers the

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the FluentCRM: developer overview skill

What this skill tells your AI

The instructions your AI receives, as published by lonsdale201/wp-agent-skills in fluentcrm/fluentcrm-overview/SKILL.md and read by ahel’s review.

Pick this skill first when starting a new FluentCRM extension or when you don't yet know which contract to extend. It's the orient layer; it doesn't teach any single extension point in depth — it lays out the file map and points you at the focused skills (fluentcrm-funnel-trigger, fluentcrm-funnel-action, fluentcrm-funnel-benchmark, fluentcrm-rest-options).

Plugin identity

FieldValueSource
Slugfluent-crmheader
Version3.1.8fluent-crm.php
Pro slugfluentcampaign-proheader
Pro version3.1.8fluentcampaign-pro.php
Boot constantFLUENTCRM ('fluentcrm')fluent-crm.php:20
Pro constantFLUENTCAMPAIGN_PLUGIN_VERSIONfluentcampaign-pro.php
Path constant (Free)FLUENTCRM_PLUGIN_PATHfluent-crm.php:22
Path constant (Pro)FLUENTCAMPAIGN_PLUGIN_PATHfluentcampaign-pro.php:20
Min PHP (verified)7.4composer.json

Active-detection — never test for class_exists('FluentCRM') (no such class). Use:

public static function isFluentCRMActive(): bool {
    return defined('FLUENTCRM');
}

public static function isFluentCRMProActive(): bool {
    return defined('FLUENTCAMPAIGN_PLUGIN_VERSION')
        && version_compare(FLUENTCAMPAIGN_PLUGIN_VERSION, '3.1.8', '>=');
}

Use a lower Pro floor only when you have verified the exact Pro API you call. Core 3.1.8 declares FLUENTCRM_MIN_PRO_VERSION as 3.1.8, so extension code that depends on current Pro internals should not silently accept older Pro builds.

Active-detection canon for OTHER plugins (load-order trap)

Triggers / actions / benchmarks register on fluentcrm_loaded:5, which fires from FluentCRM's own plugins_loaded:10 callback. Any dep check in your registrar runs at that point — and only symbols declared at FILE LOAD are guaranteed available. WordPress runs plugins_loaded:10 callbacks in plugin registration order (non-deterministic across installs), so any symbol that's only set up INSIDE another plugin's plugins_loaded callback is racy.

Safe (declared at file load — top-level scope, no conditional require):

PluginUseWhy
WooCommerceclass_exists('WooCommerce')woocommerce.php includes class-woocommerce.php at file scope
FluentCampaign Prodefined('FLUENTCAMPAIGN_PLUGIN_VERSION')fluentcampaign-pro.php defines the constant before bootstrapping
WC Membershipsclass_exists('WC_Memberships_Loader')top-level loader class, WC_Memberships itself is loaded inside init_plugin() and is racy
LW LMSclass_exists('LightweightPlugins\\LMS\\Plugin')autoloader registered at file scope
Jet Engineclass_exists('Jet_Engine')top-level class declaration

Unsafe (declared inside the dep's own callback — DO NOT use at registration time):

// WRONG — wc_memberships() function is declared inside Memberships's
// plugins_loaded:10 callback. If your registrar runs before Memberships's
// callback, function_exists returns false and your action is silently
// excluded from the editor.
return function_exists('wc_memberships');

// RIGHT — WC_Memberships_Loader is at file scope.
return class_exists('WC_Memberships_Loader');

If the dep's main object only exists post-init, find a top-level loader/registrar to test against (every well-formed plugin has one).

Free vs Pro at a glance

CapabilityFree (FluentCRM)Pro (FluentCampaign)
Contacts, lists, tags, segmentsYesYes
Email campaigns, sequences, templatesYesYes
Forms, opt-insYesYes
Funnel triggers (BaseTrigger)Yes — register from any pluginYes — many built-in (Tutor, LearnDash, LifterLMS, EDD, WishlistMember, AffiliateWP, Woo Subscriptions)
Funnel actions (BaseAction)Yes — register from any pluginYes — many built-in (advanced Woo, sequence add/remove, custom field updates)
Funnel benchmarks (BaseBenchMark)Yes — register from any pluginYes — additional built-ins
Conditional / A-B branching nodeNoYes
Companies moduleYes, experimental flag company_moduleYes
Event trackingYes, experimental flag event_trackingAdds trigger/action/conditions
MCP / Abilities API surfaceYes, guarded by wp_register_ability + mcp_enabledAdds Pro abilities
Webhook receiver / senderYesYes
SMTP / mail driver layerFree uses WP mail / Fluent SMTPPro adds advanced reporting

The four extension contracts (trigger, action, benchmark, smart code) are all in Free. Pro is mostly built-in integrations + UI features. A companion plugin extending Free works against Pro automatically.

Bootstrap order

plugins_loaded:10           ← your plugin instantiates its bootstrap
        ↓
fluentcrm_loaded            ← do_action('fluentcrm_loaded') from boot/app.php:41
                              REGISTER triggers / actions / benchmarks HERE (priority < 10)
                              FluentCampaign Pro's IntegrationHandler runs here too
        ↓
fluentcrm_addons_loaded     ← do_action('fluentcrm_addons_loaded') from boot/app.php:42
                              addon boot point; Pro Application loads here
        ↓
init:1                      ← FunnelHandler::registerFunnelItems()
                              Core actions/benchmarks/triggers are instantiated.
                              Pro IntegrationHandler also registers extra funnel
                              items on init:1.
        ↓
init:2                      ← FunnelHandler::registerEarlyActiveTriggers()
                              Registers saved trigger listeners only when the
                              fluentcrm_funnel_arg_num_{name} filter already
                              exists. This catches events fired by other init:10
                              callbacks.
        ↓
init:10                     ← FunnelHandler::handle()
                              Registers the automation runner callback.
        ↓
init:20                     ← FunnelHandler::registerActiveTriggers()
                              Fallback listener pass for triggers whose arg-num
                              filter was not ready at init:2.
        ↓
init:1000                   ← do_action('fluent_crm/after_init') from boot/app.php:44-46
                              TOO LATE for trigger/action/benchmark registration
        ↓
... runtime events fire ...

The single timing rule — instantiate every trigger / action / benchmark on fluentcrm_loaded priority < 10, or at the latest from an init:1 callback added before FluentCRM's init:2 early listener pass. fluent_crm/after_init is too late; init:10 can miss events fired by other init:10 callbacks.

File map

fluent-crm/
├── fluent-crm.php                   ← header + bootstrap entry
├── boot/
│   ├── app.php                      ← framework init, fluentcrm_loaded / addons_loaded dispatch
│   └── …
├── app/
│   ├── Api/
│   │   ├── config.php               ← FluentCrmApi keys: contacts, tags, lists, extender, companies, event_tracker
│   │   ├── Classes/Contacts.php     ← FluentCrmApi('contacts') backend
│   │   ├── Classes/Companies.php    ← FluentCrmApi('companies') backend
│   │   ├── Classes/Tracker.php      ← FluentCrmApi('event_tracker') backend
│   │   └── Classes/Extender.php     ← smart-code extender API
│   ├── Functions/helpers.php        ← FluentCrmApi(), fluentCrmDb(), fluentCrmTimestamp(), …
│   ├── Hooks/
│   │   ├── actions.php              ← built-in `add_action` registrations
│   │   ├── filters.php
│   │   └── Handlers/                ← Funnel, Subscriber, Campaign, etc. handlers
│   ├── Http/Controllers/            ← REST endpoints (FunnelController, OptionsController, …)
│   ├── Models/                      ← Subscriber, Company, EventTracker, Funnel, FunnelSequence, …
│   ├── Modules/MCP/                 ← WP Abilities/MCP tools, guarded by wp_register_ability
│   ├── Services/
│   │   ├── Funnel/
│   │   │   ├── BaseTrigger.php
│   │   │   ├── BaseAction.php
│   │   │   ├── BaseBenchMark.php
│   │   │   ├── FunnelProcessor.php
│   │   │   └── FunnelHelper.php
│   │   ├── Helper.php
│   │   └── Libs/Parser/             ← smart code parser
│   └── Views/
└── ...

fluentcampaign-pro/
├── fluentcampaign-pro.php
└── app/
    ├── Hooks/Handlers/IntegrationHandler.php   ← initFunnelActions / initBenchmarks / initTriggers at init:1
    ├── Models/Sequence*.php                    ← Pro email sequences
    ├── Modules/SMS/                            ← Pro SMS module
    ├── Modules/MCP/                            ← Pro Abilities/MCP registration
    └── Services/Integrations/
        ├── TutorLms/CourseEnrollTrigger.php    ← canonical trigger reference
        ├── TutorLms/AddToCourseAction.php      ← canonical action reference
        ├── WooCommerce/                        ← deep Woo integration
        ├── LearnDash/, LifterLms/, EDD/, AffiliateWP/, WishlistMember/
        └── …

Models you will touch

ModelClassNotes
SubscriberFluentCrm\App\Models\SubscriberCore contact. getWpUserId(), tags, lists, email, status
CompanyFluentCrm\App\Models\CompanyExperimental CRM account/company model. Relation through fc_subscriber_pivot, plus Subscriber.company_id primary company
EventTrackerFluentCrm\App\Models\EventTrackerExperimental event timeline table fc_event_tracking; API key is event_tracker
FunnelFluentCrm\App\Models\FunnelAn automation. status{published, draft, archived}
FunnelSequenceFluentCrm\App\Models\FunnelSequenceA step inside a funnel. action_name keys into your block. type{action, benchmark, conditional}
FunnelSubscriberFluentCrm\App\Models\FunnelSubscriberJoin: contact <-> funnel. Tracks current sequence position. Main status uses values such as draft, pending, active, waiting, completed, cancelled, skipped
FunnelMetricFluentCrm\App\Models\FunnelMetricPer-step audit row. DB default / successful status is completed; handlers usually write only skipped or failed
TagFluentCrm\App\Models\TagMany-to-many with Subscriber via SubscriberPivot
ListsFluentCrm\App\Models\ListsSame
WebhookFluentCrm\App\Models\WebhookInbound webhook receivers

Status strings are column-specific: FunnelHelper::changeFunnelSubSequenceStatus() writes FunnelSubscriber.last_sequence_status and uses complete by default, while the main funnel-subscriber status and successful funnel metrics use completed. Do not normalize these into one word.

Global helpers

// API surface — preferred over direct Model writes
FluentCrmApi('contacts')->createOrUpdate($data);
FluentCrmApi('contacts')->getContact($emailOrId);
FluentCrmApi('companies')->createOrUpdate($companyData);
FluentCrmApi('event_tracker')->track($eventData);

// Database query builder — bypasses Eloquent for raw queries
fluentCrmDb()->table('fc_subscribers')->where('email', $email)->first();

// FunnelHelper — the single most-used utility from companion plugins
FunnelHelper::prepareUserData($wpUserId);             // builds $subscriberData skeleton
FunnelHelper::getSubscriber($emailOrUserId);          // resolves Subscriber model
FunnelHelper::ifAlreadyInFunnel($funnelId, $subId);   // dedupe guard
FunnelHelper::removeSubscribersFromFunnel($funnelId, $subIds);  // restart helper
FunnelHelper::getUpdateOptions();                     // canonical "if exists" radio options
FunnelHelper::changeFunnelSubSequenceStatus($funnelSubId, $sequenceId, 'complete');
FunnelHelper::maybeExplodeFullName($subscriberData);  // splits "John Doe" → first/last name
FunnelHelper::createWpUserFromSubscriber($subscriber, $sendWelcomeEmail);

FluentCrmApi, fluentCrmDb, FluentCrm (service container) all defined at helpers.php.

Contact lifecycle hooks

Every contact-state change fires both a legacy underscore-named action AND a new slash-named action (introduced in 2.8). Hook the slash version in new code; the underscore versions are deprecated but still fire for backwards compatibility:

Modern hookLegacy alias (deprecated)ArgsSource
fluent_crm/contact_createdfluentcrm_contact_created$subscriberSubscriber.php:970-971, 1145-1146
fluent_crm/contact_updatedfluentcrm_contact_updated$subscriber, $dirtyFieldsSubscriber.php:1006-1007, 1148
fluent_crm/contact_email_changed(no alias)$subscriber, $oldEmailSubscriber.php:1095
fluent_crm/contact_custom_data_updatedfluentcrm_contact_custom_data_updated$newValues, $subscriber, $updateValuesSubscriber.php:669-670

Useful when you need to react to subscriber state independent of funnels — e.g. mirror contact data to an external CRM.

Smart-code extension

Custom template tags like {{my_plugin.foo}} register through the fluent_crm/extended_smart_codes filter (Helper.php:329, Extender.php:108). The Extender API:

FluentCrmApi('extender')->addSmartCode(
    'my_plugin',                                    // namespace key
    __('My Plugin Tokens', 'my-plugin'),            // group title
    [
        'my_plugin.contact_score' => 'Contact Score',
        'my_plugin.last_purchase' => 'Last Purchase Date',
    ],
    function ($code, $valueKey, $defaultValue, $subscriber) {
        // Resolve $valueKey for $subscriber. Return string.
        if ($valueKey === 'contact_score') {
            return (string) get_user_meta($subscriber->user_id, 'my_score', true);
        }
        return $defaultValue;
    }
);

Smart codes are evaluated at email render time AND inside dynamic field tokens for actions like "Update Custom Field". Fallback for unknown codes is apply_filters('fluentcrm_smartcode_fallback', $matches[0], $subscriber) — useful for legacy {{tag}} shapes.

Funnel layer architecture

                ┌──────────────────────────────────────────┐
                │  Trigger fires (a real WP action)        │
                │  e.g. lw_lms_after_grant, tutor_after_   │
                │       enrolled, woocommerce_new_order    │
                └────────────────────┬─────────────────────┘
                                     │
                ┌────────────────────▼─────────────────────┐
                │  FunnelHandler::mapTriggers              │
                │  (FunnelHandler.php:105-141)             │
                │  - looks up published funnels with       │
                │    matching trigger_name                 │
                │  - dispatches fluentcrm_funnel_start_*   │
                │  - looks up published BENCHMARK          │
                │    sequences with matching action_name   │
                │  - dispatches fluentcrm_funnel_benchmark_│
                │    start_*                               │
                └────────┬──────────────────────┬──────────┘
                         │                      │
              ┌──────────▼──────────┐  ┌────────▼──────────┐
              │ BaseTrigger::handle │  │ BaseBenchMark::    │
              │  → startFunnelSequ- │  │  handle            │
              │    ence (NEW run)   │  │  → startFunnelFrom │
              │                     │  │    SequencePoint   │
              │                     │  │    (RESUME)        │
              └──────────┬──────────┘  └────────┬──────────┘
                         │                      │
                    ┌────▼──────────────────────▼────┐
                    │ FunnelProcessor::processSequence│
                    │ (loops over sequence steps)     │
                    │  - sequence type 'action'       │
                    │      → fluentcrm_funnel_        │
                    │        sequence_handle_*        │
                    │      → BaseAction::handle()     │
                    │  - sequence type 'benchmark'    │
                    │      → wait                     │
                    │  - sequence type 'conditional'  │
                    │      → branch (Pro)             │
                    └─────────────────────────────────┘

Decision matrix — pick the right contract

You want to...Extend
Start an automation when X happensTriggerfluentcrm-funnel-trigger
Do work for a contact at a sequence stepActionfluentcrm-funnel-action
Pause the funnel until X happens, then resumeBenchmarkfluentcrm-funnel-benchmark
Add a multi-select picker field to any of the aboveREST options filterfluentcrm-rest-options
Add {{my_code.foo}} template tagsSmart code extender — see "Smart-code extension" above
React to contact state without a funnelContact lifecycle hooks — see table above
Add a custom REST endpointWP REST API — FluentCRM has no special wrapper; use register_rest_route
Add a custom contact fieldFluentCRM admin → Settings → Contact Custom Fields. No code path.

Critical rules

  • Always hook fluentcrm_loaded priority < 10 for trigger / action / benchmark registration. The single most-bitten timing trap.
  • Always seed 'settings' in getBlock() for actions and benchmarks. One key per getBlockFields()['fields'] key. Without it the editor renders an empty panel and the JS console throws TypeError: Cannot read properties of undefined. Triggers don't have this trap (defaults come from the abstract getFunnelSettingsDefaults() / getFunnelConditionDefaults() methods).
  • Do not collapse complete and completed. changeFunnelSubSequenceStatus() writes last_sequence_status = complete; the main automation run and FunnelMetric success state use completed.
  • Use getWpUserId() not ->user_id when reading the WP user from a Subscriber instance.
  • Use FluentCrmApi('contacts')->createOrUpdate() rather than directly instantiating Subscriber model in companion code. The API encapsulates list/tag attachment, double-optin handling, and lifecycle hook firing.
  • Pin Pro version detection on 2.8.0 if you depend on the modern lifecycle. Older Pro releases predate stable BaseAction semantics.
  • Don't catch fluentcrm_funnel_start_* directly. Always go through BaseTrigger; FluentCRM may add validation around that hook in future versions, and going around BaseTrigger means missing prepareEditorDetails (__force_run_actions injection, etc.).
  • Plugin-presence detection MUST use a file-load-time symbol. class_exists('TopLevelClassFromMainFile') or defined('CONST_NAME'). NEVER function_exists() for symbols declared inside another plugin's plugins_loaded callback — non-deterministic load order makes the check pass on some installs and fail on others. See "Active-detection canon" above for the per-plugin canonical list.

Common mistakes

  • Confusing the underscore vs slash hook variants — both fire today, but fluentcrm_* is on the deprecation track. New integrations should hook fluent_crm/*.
  • Reaching for class_exists('FluentCRM') to detect the plugin. There's no such class. Use defined('FLUENTCRM').
  • Bootstrap-instantiating triggers / actions on plugins_loaded without the fluentcrm_loaded indirection. FluentCRM may not be loaded yet at plugins_loaded; even if it is, your BaseTrigger::register() runs before FluentCRM's own boot, so the filters target an environment that isn't fully wired.
  • Calling internal model methods like Subscriber::find() from outside FluentCRM. Stable, but the model behaviour changes between minor versions. Prefer FluentCrmApi('contacts')->getContact() for reads.
  • Using function_exists('helper_fn') to gate dep-aware registration. The classic load-order race — works on installs where the dep loads first, silently breaks on installs where it loads second. Symptom: trigger / action appears in the editor on some sites and not others, no error. Always use the file-scope class / constant canon listed in "Active-detection canon".
  • Pro-gating an action that doesn't actually need Pro. BaseAction is in FluentCRM Free — many integration actions work without Pro. Only require isFluentCRMProActive() when the action genuinely uses Pro-only APIs (advanced reporting, sequence email scheduling, conditional branching).

Cross-references

  • Run fluentcrm-funnel-trigger for the trigger contract + the timing-trap deep dive.
  • Run fluentcrm-funnel-action for the action contract + status-string canon.
  • Run fluentcrm-funnel-benchmark for benchmark / goal-point contract.
  • Run fluentcrm-rest-options for the rest_selector option filter pattern.
  • Run fluentcrm-companies-model for company/account relations, custom company fields, and company hooks.
  • Run fluentcrm-event-tracking for FluentCrmApi('event_tracker'), event timeline filters, and the Pro tracking-event automation trigger/action.

What this skill does NOT cover

  • Email-campaign rendering / templates.
  • The block-editor email designer.
  • Webhook receiver / sender configuration.
  • Detailed Companies or Event Tracking usage.
  • Pro-only branching (conditional / A-B test) — separate contract.
  • SMTP / mail driver integration.
  • The CRM admin UI / Vue app.

References

  • FluentCRM developer docs root
  • Plugin entry — fluent-crm.php
  • Bootstrap order — boot/app.php:31-44, app/Hooks/actions.php, app/Hooks/Handlers/FunnelHandler.php
  • Model layer index — app/Models/
  • Funnel services — app/Services/Funnel/
  • Global helpers — app/Functions/helpers.php
  • Pro integration handler — fluentcampaign-pro/app/Hooks/Handlers/IntegrationHandler.php
  • Reference Pro trigger — fluentcampaign-pro/app/Services/Integrations/TutorLms/CourseEnrollTrigger.php
  • Reference Pro action — fluentcampaign-pro/app/Services/Integrations/TutorLms/AddToCourseAction.php
  • Verified source paths:
    • wp-content/plugins/fluent-crm/app/Models/Subscriber.php
    • wp-content/plugins/fluent-crm/app/Models/Company.php
    • wp-content/plugins/fluent-crm/app/Models/EventTracker.php
    • wp-content/plugins/fluent-crm/app/Services/Helper.php
    • wp-content/plugins/fluent-crm/app/Api/config.php
    • wp-content/plugins/fluent-crm/app/Api/Classes/Extender.php
    • wp-content/plugins/fluentcampaign-pro/fluentcampaign-pro.php

Signals

GitHub stars
22
Forks
2
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
fluentcrm-overview
Source
github.com/lonsdale201/wp-agent-skills