FluentCRM: register a rest_selector option list
For developers building a custom FluentCRM trigger, action, or benchmark that needs a multi-select / single-select field whose options come from your plugin (target courses, target products, target post types, target whatever). The settings-field side declares 'type' => 'rest_selector', 'option_key' => 'my_things'; the server side registers add_filter('fluentcrm_ajax_options_my_things', $callback, 10, 3). This is a small focused contract — under 100 lines of code per integration — but it has one subtle "include pre-selected IDs" trap.
API stability note
The fluentcrm_ajax_options_* filter family has been in place since FluentCRM 2.5.9. The 3-argument signature ($options, $search, $includedIds) and the [{id, title}, ...] return shape have not changed.
In FluentCRM 3.1.8, OptionsController::index() guards the comma-separated fields dispatcher with reflection: only public, non-static, zero-required-argument methods declared on OptionsController itself are invoked. Custom dynamic pickers still go through getAjaxOptions() and the fluentcrm_ajax_options_{option_key} fallback filter.
Misconception this skill corrects
"I'll filter the options by $search only — the picker handles the rest."
Wrong — the picker does NOT separately fetch labels for already-saved IDs. When the admin opens an existing trigger / action whose course_ids is [42, 99], the editor calls the same getAjaxOptions REST endpoint with $includedIds = [42, 99] and an empty $search. If your filter callback only honours $search, IDs 42 and 99 get a search query of '' against your filter — which most callbacks treat as "return everything matching empty string" (so the labels are present), but if you've added a posts_per_page cap the saved IDs may not appear in the result, and the editor renders the field as bare numbers.
The correct pattern: when $includedIds is non-empty, bypass $search and load those specific IDs unconditionally, then merge with the search-driven results. The canonical approach is to use post__in for CPT lookups so already-saved values always come back regardless of search range:
public function get_my_things($options, $search, $includedIds)
{
$includedIds = is_array($includedIds) ? array_filter(array_map('intval', $includedIds)) : [];
$args = [
'post_type' => 'my_thing',
'post_status' => 'publish',
'posts_per_page' => 50,
'orderby' => 'title',
'order' => 'ASC',
];
if (!empty($search)) {
$args['s'] = (string) $search;
}
if (!empty($includedIds)) {
// CRITICAL — load pre-selected ids unconditionally so the editor
// can render their human labels instead of raw numeric IDs.
$args['post__in'] = $includedIds;
$args['posts_per_page'] = -1;
}
foreach (get_posts($args) as $thing) {
$options[] = ['id' => $thing->ID, 'title' => $thing->post_title];
}
return $options;
}
Other AI-prone misconceptions:
- "The filter callback returns a
WP_Query / array of WP_Post objects." No — it must return array<int, array{id: scalar, title: string}>. The picker JSON-serialises the result; objects with private fields throw. Use array_map if you have model objects.
- "
option_key can be anything; the picker just calls my filter." The filter is dispatched only as the getAjaxOptions() fallback case. The controller has built-in handlers for many known option keys (woo_products, woo_categories, available_lists, tags, editable_statuses, companies, etc.). Pick a key prefixed with your plugin's slug to avoid collisions — e.g. myplugin_things, NOT just things.
- "
$includedIds is the search field's current value." No — it's the CURRENTLY SAVED value of the field for the loaded sequence/funnel. The editor sends it on the FIRST options request so the picker can render labels for what's already in the form. As the admin types, subsequent requests use $search with empty / re-supplied $includedIds.
- "The filter signature is 1 arg." No — use
add_filter('fluentcrm_ajax_options_<key>', $cb, 10, 3). Default add_filter accepts only 1 arg; you MUST pass 3 as the 4th argument or $search and $includedIds will be silently null in your callback.
- "Adding
'is_multiple' => true to the field config is enough for multi-select." Required for multi but not sufficient — the field type 'rest_selector' is what triggers the picker UI. 'multi-select' (a different type) preloads all options at once and doesn't hit your filter at all. For multi-select with AJAX search, use 'rest_selector' + 'is_multiple' => true.
When to use this skill
Trigger when ANY of the following is true:
- The diff/files declare
'type' => 'rest_selector' with an 'option_key' you control.
- The diff calls
add_filter('fluentcrm_ajax_options_*', ...).
- A new FluentCRM trigger / action / benchmark needs to pick from your plugin's CPTs / objects / categories.
- Debugging "the picker shows raw IDs instead of names when I open a saved automation" — almost always Misconception #1 (no
$includedIds handling).
- Debugging "my callback fires but
$search is null" — Misconception #4 (missing 4th arg in add_filter).
The contract in one block
Field declaration (in your trigger's getConditionFields() or action's getBlockFields()):
'thing_ids' => [
'type' => 'rest_selector',
'option_key' => 'myplugin_things', // matches the filter suffix below
'is_multiple' => true, // omit / false for single-select
'clearable' => true, // shows the "x" reset button
'label' => __('Target Things', 'my-plugin'),
'placeholder' => __('Select Things', 'my-plugin'),
'inline_help' => __('Leave blank to run on every Thing', 'my-plugin'),
],
Server callback (a single class collecting all fluentcrm_ajax_options_* callbacks for your plugin — mirror FluentCRM's own pattern):
<?php
namespace MyPlugin\Support;
final class CustomControllers
{
public function __construct()
{
add_filter('fluentcrm_ajax_options_myplugin_things', [$this, 'get_things'], 10, 3);
// ... other custom option keys ...
}
public function get_things($options, $search, $includedIds)
{
if (!Dependency::isMyServiceActive()) {
return $options;
}
$includedIds = is_array($includedIds) ? array_filter(array_map('intval', $includedIds)) : [];
$args = [
'post_type' => 'my_thing',
'post_status' => 'publish',
'posts_per_page' => 50,
'orderby' => 'title',
'order' => 'ASC',
];
if (!empty($search)) {
$args['s'] = (string) $search;
}
if (!empty($includedIds)) {
$args['post__in'] = $includedIds;
$args['posts_per_page'] = -1;
}
foreach (get_posts($args) as $thing) {
$options[] = [
'id' => $thing->ID,
'title' => $thing->post_title,
];
}
return $options;
}
}
Instantiate new CustomControllers() from your plugin bootstrap on plugins_loaded — these filters don't have the timing constraint that triggers / actions do, since they're called on-demand from REST.
Step by step
- Pick an
option_key that won't collide. Prefix with your plugin slug. Don't reuse FluentCRM's built-in keys (woo_products, available_roles, etc.) — those have hardcoded handlers in OptionsController that run BEFORE your filter and may short-circuit.
- Declare the field with
'type' => 'rest_selector' and the chosen option_key in your trigger / action settings.
- Register the filter callback with 3 accepted args (
add_filter(..., 10, 3)).
- In the callback: build a query honouring BOTH
$search (user typed in the picker) AND $includedIds (already-saved values to render labels for). Return array<int, array{id, title}>.
- Register the controller class on
plugins_loaded (or any pre-init hook); it doesn't need to ride the fluentcrm_loaded priority-5 rule that triggers/actions do.
Critical rules
- Always honour
$includedIds. Pre-selected values must always come back, even if they don't match $search. Otherwise the picker renders [42, 99] instead of human labels.
- Always pass
4 as the third arg to add_filter (for the priority+accepted_args = 10, 3). Forgetting this is the most common silent bug.
- Return shape is
array<int, array{id, title}> — id is scalar (int or string), title is plain text (escape on output, not here). No WP_Post objects.
- Guard with a dependency check. Your filter fires on every
getAjaxOptions REST request that matches the suffix, even when your plugin's feature is disabled. Return $options unchanged if not applicable.
- Don't unbounded-list. Cap with
posts_per_page (50 is conventional). The picker is search-as-you-type; loading 5000 rows on the first request kills the editor.
- Prefix your
option_key with your plugin slug. lw_lms_courses, myplugin_widgets — not courses, widgets. Built-in keys have hardcoded handlers in OptionsController that take precedence; non-prefixed keys risk silent collisions when FluentCRM adds a new built-in.
Common mistakes
- Forgetting
accepted_args = 3. add_filter(..., 10) makes $search and $includedIds null inside the callback, and the picker silently returns no options. Always pass 3 explicitly.
- Using
'multi-select' instead of 'rest_selector'. 'multi-select' preloads all options once at editor render time and doesn't call your filter. Slow for large datasets and bypasses the search.
- Passing the wrong shape.
[$id => $title] (associative) breaks the picker — it expects [{id, title}] (list of dicts).
- Including HTML in
title. The picker renders title as plain text. HTML tags appear escaped to the admin. If you need formatted labels, do it client-side.
- Skipping
array_filter(array_map('intval', $includedIds)). $includedIds arrives as strings from the JSON request; if you compare with === against integer post IDs the comparison fails. Cast at the entry point.
Cross-references
- Run
fluentcrm-funnel-trigger when the option list is consumed by a trigger condition field.
- Run
fluentcrm-funnel-action when the option list is consumed by an action settings field.
What this skill does NOT cover
- Tag / list / segment pickers — those use built-in keys (
tags, lists, segments) handled in OptionsController directly; you don't register a filter for them.
'option_selectors' field type — used for editable_statuses, gender, etc.; sourced from Helper::getOptionSelectorOptions() not the AJAX filter.
- Block-editor FluentCRM email designer option pickers — different system.
References
- Filter dispatch site —
app/Http/Controllers/OptionsController.php:723
- Reference callback (FluentCart product picker) —
app/Services/ExternalIntegrations/FluentCart/FluentCart.php:70-75
- Built-in handlers for canonical keys (
woo_products, woo_categories, companies, etc.) — app/Http/Controllers/OptionsController.php
- Official documentation: https://developers.fluentcrm.com/funnel-builder/custom-trigger/
1---2name: fluentcrm-rest-options3description: Register a custom AJAX option list for FluentCRM trigger / action / benchmark editor pickers. Pairs `'type' => 'rest_selector', 'option_key' => '...'` in a settings field with a server-side `add_filter('fluentcrm_ajax_options_{key}', $callback, 10, 3)` callback. Filter signature is ($options, $search, $includedIds) — return an array of {id, title} pairs. The fallback apply_filters call lives in OptionsController::getAjaxOptions which the editor's REST hits as the user types or opens the picker. Important — pre-selected ids must always be returned (regardless of $search) or the editor renders saved values as raw IDs instead of human labels. Use when scaffolding any FluentCRM trigger / action / benchmark with a multi-select-like field. Triggers on fluentcrm_ajax_options_, rest_selector, option_key, getAjaxOptions, OptionsController.4---56# FluentCRM: register a `rest_selector` option list78For developers building a custom FluentCRM trigger, action, or benchmark that needs a multi-select / single-select field whose options come from your plugin (target courses, target products, target post types, target whatever). The settings-field side declares `'type' => 'rest_selector', 'option_key' => 'my_things'`; the server side registers `add_filter('fluentcrm_ajax_options_my_things', $callback, 10, 3)`. This is a small focused contract — under 100 lines of code per integration — but it has one subtle "include pre-selected IDs" trap.910## API stability note1112The `fluentcrm_ajax_options_*` filter family has been in place since FluentCRM 2.5.9. The 3-argument signature `($options, $search, $includedIds)` and the `[{id, title}, ...]` return shape have not changed.1314In FluentCRM 3.1.8, `OptionsController::index()` guards the comma-separated `fields` dispatcher with reflection: only public, non-static, zero-required-argument methods declared on `OptionsController` itself are invoked. Custom dynamic pickers still go through `getAjaxOptions()` and the `fluentcrm_ajax_options_{option_key}` fallback filter.1516## Misconception this skill corrects1718> "I'll filter the options by `$search` only — the picker handles the rest."1920Wrong — the picker does NOT separately fetch labels for already-saved IDs. When the admin opens an existing trigger / action whose `course_ids` is `[42, 99]`, the editor calls the same `getAjaxOptions` REST endpoint with `$includedIds = [42, 99]` and an empty `$search`. If your filter callback only honours `$search`, IDs 42 and 99 get a search query of `''` against your filter — which most callbacks treat as "return everything matching empty string" (so the labels are present), but if you've added a `posts_per_page` cap the saved IDs may not appear in the result, and the editor renders the field as bare numbers.2122The correct pattern: when `$includedIds` is non-empty, **bypass `$search` and load those specific IDs unconditionally**, then merge with the search-driven results. The canonical approach is to use `post__in` for CPT lookups so already-saved values always come back regardless of search range:2324```php25public function get_my_things($options, $search, $includedIds)26{27 $includedIds = is_array($includedIds) ? array_filter(array_map('intval', $includedIds)) : [];2829 $args = [30 'post_type' => 'my_thing',31 'post_status' => 'publish',32 'posts_per_page' => 50,33 'orderby' => 'title',34 'order' => 'ASC',35 ];3637 if (!empty($search)) {38 $args['s'] = (string) $search;39 }40 if (!empty($includedIds)) {41 // CRITICAL — load pre-selected ids unconditionally so the editor42 // can render their human labels instead of raw numeric IDs.43 $args['post__in'] = $includedIds;44 $args['posts_per_page'] = -1;45 }4647 foreach (get_posts($args) as $thing) {48 $options[] = ['id' => $thing->ID, 'title' => $thing->post_title];49 }5051 return $options;52}53```5455Other AI-prone misconceptions:5657- **"The filter callback returns a `WP_Query` / array of `WP_Post` objects."** No — it must return `array<int, array{id: scalar, title: string}>`. The picker JSON-serialises the result; objects with private fields throw. Use `array_map` if you have model objects.58- **"`option_key` can be anything; the picker just calls my filter."** The filter is dispatched only as the `getAjaxOptions()` fallback case. The controller has built-in handlers for many known option keys (`woo_products`, `woo_categories`, `available_lists`, `tags`, `editable_statuses`, `companies`, etc.). Pick a key prefixed with your plugin's slug to avoid collisions — e.g. `myplugin_things`, NOT just `things`.59- **"`$includedIds` is the search field's current value."** No — it's the CURRENTLY SAVED value of the field for the loaded sequence/funnel. The editor sends it on the FIRST options request so the picker can render labels for what's already in the form. As the admin types, subsequent requests use `$search` with empty / re-supplied `$includedIds`.60- **"The filter signature is 1 arg."** No — use `add_filter('fluentcrm_ajax_options_<key>', $cb, 10, 3)`. Default `add_filter` accepts only 1 arg; you MUST pass `3` as the 4th argument or `$search` and `$includedIds` will be silently null in your callback.61- **"Adding `'is_multiple' => true` to the field config is enough for multi-select."** Required for multi but not sufficient — the field type `'rest_selector'` is what triggers the picker UI. `'multi-select'` (a different type) preloads all options at once and doesn't hit your filter at all. For multi-select with AJAX search, use `'rest_selector'` + `'is_multiple' => true`.6263## When to use this skill6465Trigger when ANY of the following is true:6667- The diff/files declare `'type' => 'rest_selector'` with an `'option_key'` you control.68- The diff calls `add_filter('fluentcrm_ajax_options_*', ...)`.69- A new FluentCRM trigger / action / benchmark needs to pick from your plugin's CPTs / objects / categories.70- Debugging "the picker shows raw IDs instead of names when I open a saved automation" — almost always Misconception #1 (no `$includedIds` handling).71- Debugging "my callback fires but `$search` is null" — Misconception #4 (missing `4th` arg in `add_filter`).7273## The contract in one block7475**Field declaration** (in your trigger's `getConditionFields()` or action's `getBlockFields()`):7677```php78'thing_ids' => [79 'type' => 'rest_selector',80 'option_key' => 'myplugin_things', // matches the filter suffix below81 'is_multiple' => true, // omit / false for single-select82 'clearable' => true, // shows the "x" reset button83 'label' => __('Target Things', 'my-plugin'),84 'placeholder' => __('Select Things', 'my-plugin'),85 'inline_help' => __('Leave blank to run on every Thing', 'my-plugin'),86],87```8889**Server callback** (a single class collecting all `fluentcrm_ajax_options_*` callbacks for your plugin — mirror FluentCRM's own pattern):9091```php92<?php93namespace MyPlugin\Support;9495final class CustomControllers96{97 public function __construct()98 {99 add_filter('fluentcrm_ajax_options_myplugin_things', [$this, 'get_things'], 10, 3);100 // ... other custom option keys ...101 }102103 public function get_things($options, $search, $includedIds)104 {105 if (!Dependency::isMyServiceActive()) {106 return $options;107 }108109 $includedIds = is_array($includedIds) ? array_filter(array_map('intval', $includedIds)) : [];110111 $args = [112 'post_type' => 'my_thing',113 'post_status' => 'publish',114 'posts_per_page' => 50,115 'orderby' => 'title',116 'order' => 'ASC',117 ];118119 if (!empty($search)) {120 $args['s'] = (string) $search;121 }122 if (!empty($includedIds)) {123 $args['post__in'] = $includedIds;124 $args['posts_per_page'] = -1;125 }126127 foreach (get_posts($args) as $thing) {128 $options[] = [129 'id' => $thing->ID,130 'title' => $thing->post_title,131 ];132 }133134 return $options;135 }136}137```138139Instantiate `new CustomControllers()` from your plugin bootstrap on `plugins_loaded` — these filters don't have the timing constraint that triggers / actions do, since they're called on-demand from REST.140141## Step by step1421431. Pick an `option_key` that won't collide. Prefix with your plugin slug. Don't reuse FluentCRM's built-in keys (`woo_products`, `available_roles`, etc.) — those have hardcoded handlers in `OptionsController` that run BEFORE your filter and may short-circuit.1442. Declare the field with `'type' => 'rest_selector'` and the chosen `option_key` in your trigger / action settings.1453. Register the filter callback with **3 accepted args** (`add_filter(..., 10, 3)`).1464. In the callback: build a query honouring BOTH `$search` (user typed in the picker) AND `$includedIds` (already-saved values to render labels for). Return `array<int, array{id, title}>`.1475. Register the controller class on `plugins_loaded` (or any pre-`init` hook); it doesn't need to ride the `fluentcrm_loaded` priority-5 rule that triggers/actions do.148149## Critical rules150151- **Always honour `$includedIds`.** Pre-selected values must always come back, even if they don't match `$search`. Otherwise the picker renders `[42, 99]` instead of human labels.152- **Always pass `4` as the third arg to `add_filter`** (for the priority+accepted_args = `10, 3`). Forgetting this is the most common silent bug.153- **Return shape is `array<int, array{id, title}>`** — `id` is scalar (int or string), `title` is plain text (escape on output, not here). No `WP_Post` objects.154- **Guard with a dependency check.** Your filter fires on every `getAjaxOptions` REST request that matches the suffix, even when your plugin's feature is disabled. Return `$options` unchanged if not applicable.155- **Don't unbounded-list.** Cap with `posts_per_page` (50 is conventional). The picker is search-as-you-type; loading 5000 rows on the first request kills the editor.156- **Prefix your `option_key` with your plugin slug.** `lw_lms_courses`, `myplugin_widgets` — not `courses`, `widgets`. Built-in keys have hardcoded handlers in `OptionsController` that take precedence; non-prefixed keys risk silent collisions when FluentCRM adds a new built-in.157158## Common mistakes159160- **Forgetting `accepted_args = 3`.** `add_filter(..., 10)` makes `$search` and `$includedIds` null inside the callback, and the picker silently returns no options. Always pass `3` explicitly.161- **Using `'multi-select'` instead of `'rest_selector'`.** `'multi-select'` preloads all options once at editor render time and doesn't call your filter. Slow for large datasets and bypasses the search.162- **Passing the wrong shape.** `[$id => $title]` (associative) breaks the picker — it expects `[{id, title}]` (list of dicts).163- **Including HTML in `title`.** The picker renders `title` as plain text. HTML tags appear escaped to the admin. If you need formatted labels, do it client-side.164- **Skipping `array_filter(array_map('intval', $includedIds))`.** `$includedIds` arrives as strings from the JSON request; if you compare with `===` against integer post IDs the comparison fails. Cast at the entry point.165166## Cross-references167168- Run **`fluentcrm-funnel-trigger`** when the option list is consumed by a trigger condition field.169- Run **`fluentcrm-funnel-action`** when the option list is consumed by an action settings field.170171## What this skill does NOT cover172173- Tag / list / segment pickers — those use built-in keys (`tags`, `lists`, `segments`) handled in `OptionsController` directly; you don't register a filter for them.174- `'option_selectors'` field type — used for `editable_statuses`, `gender`, etc.; sourced from `Helper::getOptionSelectorOptions()` not the AJAX filter.175- Block-editor FluentCRM email designer option pickers — different system.176177## References178179- Filter dispatch site — `app/Http/Controllers/OptionsController.php:723`180- Reference callback (FluentCart product picker) — `app/Services/ExternalIntegrations/FluentCart/FluentCart.php:70-75`181- Built-in handlers for canonical keys (`woo_products`, `woo_categories`, `companies`, etc.) — `app/Http/Controllers/OptionsController.php`182- Official documentation: <https://developers.fluentcrm.com/funnel-builder/custom-trigger/>