FluentCRM: developer overview
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
| Field | Value | Source |
|---|---|---|
| Slug | fluent-crm |
header |
| Version | 3.1.8 |
fluent-crm.php |
| Pro slug | fluentcampaign-pro |
header |
| Pro version | 3.1.8 |
fluentcampaign-pro.php |
| Boot constant | FLUENTCRM ('fluentcrm') |
fluent-crm.php:20 |
| Pro constant | FLUENTCAMPAIGN_PLUGIN_VERSION |
fluentcampaign-pro.php |
| Path constant (Free) | FLUENTCRM_PLUGIN_PATH |
fluent-crm.php:22 |
| Path constant (Pro) | FLUENTCAMPAIGN_PLUGIN_PATH |
fluentcampaign-pro.php:20 |
| Min PHP (verified) | 7.4 | composer.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):
| Plugin | Use | Why |
|---|---|---|
| WooCommerce | class_exists('WooCommerce') |
woocommerce.php includes class-woocommerce.php at file scope |
| FluentCampaign Pro | defined('FLUENTCAMPAIGN_PLUGIN_VERSION') |
fluentcampaign-pro.php defines the constant before bootstrapping |
| WC Memberships | class_exists('WC_Memberships_Loader') |
top-level loader class, WC_Memberships itself is loaded inside init_plugin() and is racy |
| LW LMS | class_exists('LightweightPlugins\\LMS\\Plugin') |
autoloader registered at file scope |
| Jet Engine | class_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
| Capability | Free (FluentCRM) | Pro (FluentCampaign) |
|---|---|---|
| Contacts, lists, tags, segments | Yes | Yes |
| Email campaigns, sequences, templates | Yes | Yes |
| Forms, opt-ins | Yes | Yes |
| Funnel triggers (BaseTrigger) | Yes — register from any plugin | Yes — many built-in (Tutor, LearnDash, LifterLMS, EDD, WishlistMember, AffiliateWP, Woo Subscriptions) |
| Funnel actions (BaseAction) | Yes — register from any plugin | Yes — many built-in (advanced Woo, sequence add/remove, custom field updates) |
| Funnel benchmarks (BaseBenchMark) | Yes — register from any plugin | Yes — additional built-ins |
| Conditional / A-B branching node | No | Yes |
| Companies module | Yes, experimental flag company_module |
Yes |
| Event tracking | Yes, experimental flag event_tracking |
Adds trigger/action/conditions |
| MCP / Abilities API surface | Yes, guarded by wp_register_ability + mcp_enabled |
Adds Pro abilities |
| Webhook receiver / sender | Yes | Yes |
| SMTP / mail driver layer | Free uses WP mail / Fluent SMTP | Pro 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
| Model | Class | Notes |
|---|---|---|
| Subscriber | FluentCrm\App\Models\Subscriber |
Core contact. getWpUserId(), tags, lists, email, status |
| Company | FluentCrm\App\Models\Company |
Experimental CRM account/company model. Relation through fc_subscriber_pivot, plus Subscriber.company_id primary company |
| EventTracker | FluentCrm\App\Models\EventTracker |
Experimental event timeline table fc_event_tracking; API key is event_tracker |
| Funnel | FluentCrm\App\Models\Funnel |
An automation. status ∈ {published, draft, archived} |
| FunnelSequence | FluentCrm\App\Models\FunnelSequence |
A step inside a funnel. action_name keys into your block. type ∈ {action, benchmark, conditional} |
| FunnelSubscriber | FluentCrm\App\Models\FunnelSubscriber |
Join: contact <-> funnel. Tracks current sequence position. Main status uses values such as draft, pending, active, waiting, completed, cancelled, skipped |
| FunnelMetric | FluentCrm\App\Models\FunnelMetric |
Per-step audit row. DB default / successful status is completed; handlers usually write only skipped or failed |
| Tag | FluentCrm\App\Models\Tag |
Many-to-many with Subscriber via SubscriberPivot |
| Lists | FluentCrm\App\Models\Lists |
Same |
| Webhook | FluentCrm\App\Models\Webhook |
Inbound 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 hook | Legacy alias (deprecated) | Args | Source |
|---|---|---|---|
fluent_crm/contact_created |
fluentcrm_contact_created |
$subscriber |
Subscriber.php:970-971, 1145-1146 |
fluent_crm/contact_updated |
fluentcrm_contact_updated |
$subscriber, $dirtyFields |
Subscriber.php:1006-1007, 1148 |
fluent_crm/contact_email_changed |
(no alias) | $subscriber, $oldEmail |
Subscriber.php:1095 |
fluent_crm/contact_custom_data_updated |
fluentcrm_contact_custom_data_updated |
$newValues, $subscriber, $updateValues |
Subscriber.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 happens | Trigger — fluentcrm-funnel-trigger |
| Do work for a contact at a sequence step | Action — fluentcrm-funnel-action |
| Pause the funnel until X happens, then resume | Benchmark — fluentcrm-funnel-benchmark |
| Add a multi-select picker field to any of the above | REST options filter — fluentcrm-rest-options |
Add {{my_code.foo}} template tags |
Smart code extender — see "Smart-code extension" above |
| React to contact state without a funnel | Contact lifecycle hooks — see table above |
| Add a custom REST endpoint | WP REST API — FluentCRM has no special wrapper; use register_rest_route |
| Add a custom contact field | FluentCRM admin → Settings → Contact Custom Fields. No code path. |
Critical rules
- Always hook
fluentcrm_loadedpriority < 10 for trigger / action / benchmark registration. The single most-bitten timing trap. - Always seed
'settings'ingetBlock()for actions and benchmarks. One key pergetBlockFields()['fields']key. Without it the editor renders an empty panel and the JS console throwsTypeError: Cannot read properties of undefined. Triggers don't have this trap (defaults come from the abstractgetFunnelSettingsDefaults()/getFunnelConditionDefaults()methods). - Do not collapse
completeandcompleted.changeFunnelSubSequenceStatus()writeslast_sequence_status = complete; the main automation run andFunnelMetricsuccess state usecompleted. - Use
getWpUserId()not->user_idwhen reading the WP user from aSubscriberinstance. - Use
FluentCrmApi('contacts')->createOrUpdate()rather than directly instantiatingSubscribermodel in companion code. The API encapsulates list/tag attachment, double-optin handling, and lifecycle hook firing. - Pin Pro version detection on
2.8.0if 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 missingprepareEditorDetails(__force_run_actionsinjection, etc.). - Plugin-presence detection MUST use a file-load-time symbol.
class_exists('TopLevelClassFromMainFile')ordefined('CONST_NAME'). NEVERfunction_exists()for symbols declared inside another plugin'splugins_loadedcallback — 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 hookfluent_crm/*. - Reaching for
class_exists('FluentCRM')to detect the plugin. There's no such class. Usedefined('FLUENTCRM'). - Bootstrap-instantiating triggers / actions on
plugins_loadedwithout thefluentcrm_loadedindirection. FluentCRM may not be loaded yet atplugins_loaded; even if it is, yourBaseTrigger::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. PreferFluentCrmApi('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.
BaseActionis in FluentCRM Free — many integration actions work without Pro. Only requireisFluentCRMProActive()when the action genuinely uses Pro-only APIs (advanced reporting, sequence email scheduling, conditional branching).
Cross-references
- Run
fluentcrm-funnel-triggerfor the trigger contract + the timing-trap deep dive. - Run
fluentcrm-funnel-actionfor the action contract + status-string canon. - Run
fluentcrm-funnel-benchmarkfor benchmark / goal-point contract. - Run
fluentcrm-rest-optionsfor therest_selectoroption filter pattern. - Run
fluentcrm-companies-modelfor company/account relations, custom company fields, and company hooks. - Run
fluentcrm-event-trackingforFluentCrmApi('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.phpwp-content/plugins/fluent-crm/app/Models/Company.phpwp-content/plugins/fluent-crm/app/Models/EventTracker.phpwp-content/plugins/fluent-crm/app/Services/Helper.phpwp-content/plugins/fluent-crm/app/Api/config.phpwp-content/plugins/fluent-crm/app/Api/Classes/Extender.phpwp-content/plugins/fluentcampaign-pro/fluentcampaign-pro.php