# Fluentcrm Contact Models

> Work with FluentCRM 3.x contact data through the public PHP API and ORM models. Covers Subscriber, Lists, Tag, User, ContactsQuery, createOrUpdate, list/tag attach and detach, custom fields, WP user linking, status protection, and contact hooks. Use when a plugin must create or update a contact, map a WP user, read or create lists/tags, apply tags/lists, query contacts or segments, or handle statuses such as subscribed, pending, transactional, unsubscribed, bounced, complained, and spammed. Triggers on FluentCrmApi('contacts'), Subscriber, Lists, Tag, User, ContactsQuery, attachLists, attachTags, updateStatus, fluent_crm/contact_.

- Skill: `lonsdale201/fluentcrm-contact-models` (Agent Skill)
- Install (CLI): `npx skillmds@latest add lonsdale201/fluentcrm-contact-models`
- Raw SKILL.md: https://api.skillmd.com/api/skills/lonsdale201/fluentcrm-contact-models/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: Lonsdale201 (https://skillmd.com/u/lonsdale201)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/lonsdale201/fluentcrm-contact-models

---


# FluentCRM: contact, list, tag, and user models

Use this skill for companion plugins that need to write or query FluentCRM contacts. Prefer `FluentCrmApi()` wrappers for writes, and use the ORM models for reads, reports, migrations, and carefully scoped queries.

Verification note: this skill is based on FluentCRM core 3.1.13 source. The contact/list/tag/user APIs covered here are core APIs and do not require FluentCampaign Pro.

## When to use this skill

- Creating or updating contacts from a third-party plugin, webhook, form, order, LMS event, or user registration.
- Adding or removing FluentCRM lists/tags from a contact.
- Querying contacts by list, tag, status, SMS status, company, search, custom field, or advanced filter provider.
- Mapping a WordPress user to a FluentCRM contact.
- Reviewing code that touches `Subscriber::create()`, `Subscriber::updateOrCreate()`, `attachLists()`, `attachTags()`, `fluentcrm_subscriber_statuses()`, or `ContactsQuery`.

## API entry points

Guard companion plugin code and run after FluentCRM has loaded:

```php
if (!function_exists('FluentCrmApi')) {
    return;
}

$contactApi = FluentCrmApi('contacts');
$listApi    = FluentCrmApi('lists');
$tagApi     = FluentCrmApi('tags');
```

`app/Api/config.php` registers these keys: `contacts`, `tags`, `lists`, `extender`, `companies`, and `event_tracker`.

Prefer:

```php
$contact = FluentCrmApi('contacts')->createOrUpdate([
    'email'         => sanitize_email($email),
    'first_name'    => sanitize_text_field($firstName),
    'last_name'     => sanitize_text_field($lastName),
    'user_id'       => (int) $userId,
    'status'        => 'subscribed',
    'source'        => 'my-plugin',
    'lists'         => [3],
    'tags'          => [12],
    'custom_values' => [
        'plan' => sanitize_text_field($plan),
    ],
], false, false);
```

Do not create contacts with raw `$wpdb` inserts. Direct `Subscriber::create()` skips several integration-level behaviors. `FluentCrmApi('contacts')->createOrUpdate()` delegates to `Subscriber::updateOrCreate()`, syncs lists/tags/custom fields, links a WP user by email when possible, and fires the contact lifecycle hooks.

## Contact lookup

Use the API wrapper for common lookup:

```php
$contact = FluentCrmApi('contacts')->getContact($idOrEmail);
$contact = FluentCrmApi('contacts')->getContactByUserRef($userIdOrEmail);
$contact = FluentCrmApi('contacts')->getCurrentContact();
```

`getContactByUserRef($userId)` first checks `user_id`, then falls back to the WP user's email and saves the `user_id` on the contact if found. `Subscriber::getWpUser()` performs the inverse lookup and also removes duplicate `user_id` links from other contacts.

## Status rules

Use `fluentcrm_subscriber_statuses()` for the current status list. In FluentCRM 3.1.13 the local source returns:

```php
[
    'subscribed',
    'pending',
    'unsubscribed',
    'transactional',
    'bounced',
    'complained',
    'spammed',
]
```

`fluentcrm_subscriber_editable_statuses()` excludes `bounced`, `complained`, and `spammed`. `fluentcrm_strict_statues()` returns `unsubscribed`, `bounced`, `complained`, and `spammed`.

Important write behavior:

- Without `$forceUpdate`, an existing `subscribed` contact is not downgraded by incoming `status`.
- Existing `bounced`, `complained`, and `spammed` contacts keep their status unless forced.
- Incoming `unsubscribed` is always respected.
- Use `$contact->updateStatus($status)` for an explicit status change; it fires `fluent_crm/subscriber_status_changed` and the legacy `fluentcrm_subscriber_status_to_{status}` hook.

## Double opt-in boundary

`createOrUpdate()` stores the contact and synchronizes the supplied relations,
but it does **not** send a double opt-in email. The caller must deliberately use
the two-step core flow:

```php
$contact = FluentCrmApi('contacts')->createOrUpdate([
    'email'  => sanitize_email($email),
    'status' => 'pending',
    'lists'  => $serverOwnedListIds,
    'tags'   => $serverOwnedTagIds,
], false, false);

if ($contact && $contact->status === 'pending') {
    $contact->sendDoubleOptinEmail();
}
```

Do not pass `$forceUpdate = true` from a public form merely to move an
unsubscribed/bounced/complained/spammed contact. Use
`fluentcrm-custom-optin-forms` for the re-consent status matrix, public endpoint
security, list-specific DOI precedence, confirmation hooks, and abuse controls.

## Custom fields

Pass custom fields under `custom_values`:

```php
FluentCrmApi('contacts')->createOrUpdate([
    'email'         => $email,
    'custom_values' => [
        'customer_tier' => 'gold',
        'renewal_date'  => '2026-12-31',
    ],
], false, false);
```

The third `createOrUpdate()` argument maps to `syncCustomFieldValues($values, $deleteOtherValues)`. Keep it `false` for incremental updates. Passing `true` allows empty submitted values to delete existing custom field meta.

## Lists and tags

Create or update list/tag definitions through the API wrappers:

```php
$lists = FluentCrmApi('lists')->importBulk([
    [
        'title'       => 'Customers',
        'slug'        => 'customers',
        'description' => 'Imported from My Plugin',
    ],
]);

$tags = FluentCrmApi('tags')->importBulk([
    [
        'title' => 'VIP',
        'slug'  => 'vip',
    ],
]);
```

`importBulk()` sanitizes title/slug/description, upserts by slug, and fires both legacy and current hooks:

- Lists: `fluentcrm_list_created`, `fluent_crm/list_created`, `fluentcrm_list_updated`, `fluent_crm/list_updated`
- Tags: `fluentcrm_tag_created`, `fluent_crm/tag_created`, `fluentcrm_tag_updated`, `fluent_crm/tag_updated`

Apply or remove lists/tags on a saved contact:

```php
$contact->attachLists([3, 4]);
$contact->attachTags([12]);

$contact->detachLists([4]);
$contact->detachTags([12]);
```

In 3.1.13 `attachLists()` and `attachTags()` return early for unsaved subscribers, sanitize IDs, use per-row `INSERT IGNORE`, refresh the relation, and only fire added hooks for IDs that were actually new. `detachLists()` and `detachTags()` read fresh pivot state and only fire removed hooks for rows actually deleted. `attachCompanies()` / `detachCompanies()` follow the same pivot-table pattern for the experimental Companies module, but their current hooks are legacy helper functions only; use `fluentcrm-companies-model` for company-specific APIs and hooks.

Do not pass public request values directly as lists/tags. The sanitizer accepts
names/slugs and may create missing definitions; numeric IDs are converted but do
not prove that the visitor is allowed to select that list/tag. Resolve a
server-owned choice map, verify the definitions exist, then pass only those IDs.

Current attach/detach hooks:

- `fluent_crm/contact_added_to_lists`
- `fluent_crm/contact_added_to_tags`
- `fluent_crm/contact_removed_from_lists`
- `fluent_crm/contact_removed_from_tags`

The callback receives `($subscriber, $ids)` for current hooks. Legacy helper hooks still exist and pass the ID list first.

## Query contacts

Use `ContactsQuery` through the API for segment-like reads:

```php
$contacts = FluentCrmApi('contacts')->query([
    'with'          => ['tags', 'lists'],
    'search'        => 'john',
    'tags'          => [12],
    'lists'         => [3],
    'statuses'      => ['subscribed', 'transactional'],
    'sms_statuses'  => ['sms_subscribed'],
    'custom_fields' => true,
    'sort_by'       => 'created_at',
    'sort_type'     => 'DESC',
    'limit'         => 100,
])->get();
```

`ContactsQuery` allowlists sort columns before `orderBy()`. Do not pass unsanitized request values directly to ORM `orderBy()` in custom controllers.

For advanced filters, pass `filter_type => 'advanced'` and `filters_groups_raw`; FluentCRM formats groups and dispatches `do_action_ref_array('fluentcrm_contacts_filter_' . $providerName, [&$q, $items])`. Your custom advanced-filter provider must mutate the query by reference.

## Model notes

- `Subscriber` table: `fc_subscribers`; primary contact model; appended `full_name` and `photo`.
- `Subscriber.company_id` is the primary company pointer when the Companies module is enabled. Many-to-many company membership still lives in `fc_subscriber_pivot` with `object_type = FluentCrm\App\Models\Company`.
- `Lists` table: `fc_lists`; relation `subscribers()`, helpers `totalCount()` and `countByStatus()`.
- `Tag` table: `fc_tags`; relation `subscribers()`, helpers `totalCount()` and `countByStatus()`.
- `User` model maps the WordPress `users` table with primary key `ID`, hides `user_pass` and `user_activation_key`, and appends a contact-aware `photo`.

## Hooks to preserve

When replacing direct writes, ensure these still fire where relevant:

- `fluent_crm/contact_created`
- `fluent_crm/contact_updated`
- `fluent_crm/contact_email_changed`
- `fluent_crm/subscriber_status_changed`
- `fluent_crm/contact_custom_data_updated`

## What this skill does not cover

- Automation trigger/action/benchmark registration. Use `fluentcrm-funnel-trigger`, `fluentcrm-funnel-action`, or `fluentcrm-funnel-benchmark`.
- Email sequence enrollment and funnel subscriber state. Use `fluentcrm-automation-sequence-models`.
- Smart codes and dynamic segments. Use `fluentcrm-smartcodes-segments`.
- Companies / account records. Use `fluentcrm-companies-model`.
- Event tracking. Use `fluentcrm-event-tracking`.
- Public subscription, re-consent, and double opt-in orchestration. Use `fluentcrm-custom-optin-forms`.

## References

- FluentCRM docs: Subscriber, Lists, Tag, User, and Fluent ORM pages.
- Local source: `app/Api/Classes/Contacts.php`, `app/Models/Subscriber.php`, `app/Services/ContactsQuery.php`, `app/Functions/helpers.php`.
- Official documentation: <https://developers.fluentcrm.com/database/models/subscriber>
- Official documentation: <https://developers.fluentcrm.com/database/models/lists>
- Official documentation: <https://developers.fluentcrm.com/database/models/tag>
- Official documentation: <https://developers.fluentcrm.com/database/models/user>
- Official documentation: <https://developers.fluentcrm.com/database/orm/>
- Verified source paths:
  - `fluent-crm/app/Api/config.php`
  - `fluent-crm/app/Api/Classes/Lists.php`
  - `fluent-crm/app/Api/Classes/Tags.php`
  - `fluent-crm/app/Models/Lists.php`
  - `fluent-crm/app/Models/Tag.php`
  - `fluent-crm/app/Models/User.php`

