Skip to content

Start here

Contacts, subscriptions and consent, tags and lists, automations, runs and the queue — what an add-on author needs to know.

This page covers only the parts of Marker CRM’s model that an add-on touches. Class names are under the MarkerCRM\App\ namespace.

A contact (Models\Contact, table {prefix}marker_crm_contacts) is a person. It holds names, address, date_of_birth, contact_type (lead or customer by default), owner_id, and user_id when the person has a WordPress account.

A contact does not hold an email address. Addresses live on its subscriptions, below. Read one with $contact->subscription('email')?->address.

Everything that creates or changes a contact — the REST API, imports, forms, webhooks, automations — goes through one method, and your add-on should too:

use MarkerCRM\App\Services\Contact\ContactService;
$contact = ContactService::createOrUpdate([
'first_name' => 'Ada',
'last_name' => 'Lovelace',
'subscriptions' => [
['channel' => 'email', 'address' => 'ada@example.com', 'status' => 'transactional'],
],
'tags' => [12, 14], // term ids; replaces the contact's tags
'custom' => ['plan' => 'pro'], // custom field key => value
]);
  • Pass id to update a known contact. Without id, a contact with the same address is found and updated rather than duplicated.
  • A new contact needs an email address (unless marker_crm/require_contact_email returns false, when a phone number will do). A missing one throws MarkerCRM\App\Exceptions\ValidationException.
  • tags and lists replace the whole set of that type. Leave the key out to leave them alone.
  • It fires marker_crm/contact_created or marker_crm/contact_updated once the write is done — which is what starts the Contact is created trigger and every listener on it.

To look a contact up by address without creating one:

$contact = ContactService::matching([['channel' => 'email', 'address' => $email]]);

Do not alter marker_crm_contacts. For a value the site owner should see and edit, use a custom field (Contacts → Custom fields) and write it through custom. For data only your add-on reads, keep your own table, keyed by contact_id.

Each contact has one subscription per channel (Models\ContactSubscription): email, and sms. A subscription holds the address, a status, and when and how consent was given (consent_at, consent_source, consent_ip).

Email status Meaning Marketing email Account email
subscribed Opted in Yes Yes
pending Asked to confirm (double opt-in) No Yes
transactional A customer or user who never opted in to marketing No Yes
unsubscribed Opted out No No
bounced · complained · spammed The address is gone, or they reported spam No No

The contact’s own status column is a summary of its subscriptions, kept in step by ContactService. Never write it directly.

Consent is never implied. A purchase, a sign-up or a form submission without a consent box is not permission to send marketing email. When your add-on creates a contact, give the email subscription an explicit transactional status unless the person really opted in. If you leave status empty, a new contact gets the site’s default — subscribed, or pending when double opt-in is on — which is wrong for a customer who only bought something.

To change consent after the fact, use ContactService::unsubscribe($contact, ['reason' => '…']) and ContactService::confirm($contact). Both fire their own events (marker_crm/contact_unsubscribed, marker_crm/contact_confirmed).

Tags and lists are both terms (Models\Term), told apart by type. A contact holds them through term relations.

  • Tags are labels. Removing a tag deletes the relation.
  • Lists are subscriptions. Leaving a list keeps the relation with status: unsubscribed, because it records that the person opted out of that list. The Add to a list action never re-adds someone who left.

Adding or removing a term fires marker_crm/tag_added_to_contact, marker_crm/contact_added_to_list and their opposites, with ($contact, $term). Bulk changes from the contacts table fire one plural hook per term instead, with an array of contact ids. See Hooks.

You can add your own kind of term. See Term types.

An automation (Models\Automation) has:

  • Triggers — the events that let a contact in. Each saved trigger is name, settings (what the site owner chose in its fields) and conditions (the Only if rules).
  • Steps — a tree (Models\AutomationStep, linked by parent_id and branch). A step is an action you or the core registered, or one of the flow steps the engine provides: waits, Wait for an event, If / else, A/B split, goals and End the automation.
  • A re-entry policy — whether a contact may go through it again.
  • A status — draft, active or paused. Only active automations listen to triggers.

Each time a contact enters an automation, a run is created (Models\AutomationSubscriber, one row per run). The run remembers which trigger started it and that trigger’s context, in data.trigger and data.context. Inside an action, RunContext reads them for you.

A run ends as completed, left (a goal or Leave when rule took them out), cancelled or failed. Every step a run executes is logged once (Models\AutomationStepRun), so a step that was already run is never repeated, even if the queue retries the job.

Nothing in an automation runs in the request that triggered it. Entering only inserts the run and wakes the queue; the queue then walks the run through its steps. That means:

  • Your trigger’s callables run in the request where your event happens, so they must be cheap.
  • Your action’s run executes later, in a queue worker, with no logged-in user and no request to read from. Keep it to a few seconds. For slower work, hand it to a background job of your own and return StepResult::done().
  • If the queue is not running, nothing moves. Settings → Tools shows its state.

The queue runs on WP-Cron every minute (marker_crm/run_queue), and Marker CRM fires marker_crm/tick on the same clock. A listener on marker_crm/tick is the place to check whether something of yours is due, rather than scheduling a cron event of your own.

Some features can be switched off, such as companies. Ask before you rely on one:

use MarkerCRM\App\Services\ModuleService;
if (ModuleService::isEnabled('companies')) {
// …
}

marker_crm/module_enabled and marker_crm/module_disabled fire with the module’s slug when someone switches one.