Skip to content
Back to skills

Fluentcrm Rest Options

ASecurity

Register a custom AJAX option list for FluentCRM trigger / action /

  • 22 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added June 5, 2026
developmentgophpdebuggingapi

Works with

  • cli
  • api

Security analysis

A100/100

Scanned June 5, 2026

npx -y skills add Lonsdale201/wp-agent-skills --skill fluentcrm-rest-options --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Fluentcrm Rest Options?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Fluentcrm Rest Options
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lonsdale201-fluentcrm-rest-options/badge)](https://www.skillsdirectory.com/skills/lonsdale201-fluentcrm-rest-options)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: fluentcrm-rest-options
description: Register a custom AJAX option list for FluentCRM trigger / action /
  benchmark editor pickers. Pairs `'type' => 'rest_selector', 'option_key' => '<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.
author: Soczó Kristóf
contact: mailto:lonsdale201@hotmail.com
plugin: fluent-crm
plugin-version-tested: "2.9.87"
api-stable-since: "2.5.9"
php-min: "7.4"
last-updated: "2026-05-09"
docs:
  - https://developers.fluentcrm.com/funnel-builder/custom-trigger/
source-refs:
  - app/Http/Controllers/OptionsController.php
  - app/Services/ExternalIntegrations/FluentCart/FluentCart.php
---

# 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 (per the docblock at [OptionsController.php:717](OptionsController.php)). The 3-argument signature `($options, $search, $includedIds)` and the `[{id, title}, ...]` return shape have not changed.

## 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:

```php
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 at [OptionsController.php:723](OptionsController.php) only as the fallback case. The controller has built-in handlers for ~30 known option keys (`woo_products`, `woo_categories`, `available_lists`, `tags`, `editable_statuses`, 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()`):

```php
'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
<?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

1. 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.
2. Declare the field with `'type' => 'rest_selector'` and the chosen `option_key` in your trigger / action settings.
3. Register the filter callback with **3 accepted args** (`add_filter(..., 10, 3)`).
4. 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}>`.
5. 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`, etc.) — `app/Http/Controllers/OptionsController.php:289-720`

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.