Skip to content

Workflow Messages


It is common to send an automated email-message whenever a constituent registers for an event, makes a pledge for a future donation, or performs a similar action.

The code is likely to look something like:

$renderedMessage = WorkflowMessage::render()
      ->setValues(['contributionID' => $this->getContributionID(), 'contactID' => $this->getContactID()])

Each automated message represents a step in a workflow, and each automated message has a few key ingredients.

For example:

  • Use Case: When a constituent registers for an event, send an email receipt.
  • Message Name: event_receipt
  • Data (Model): The Contact who registered; the Event for which they registered; and the Participant record with details about their specific registration.
      'contactId' => 100,
      'eventId' => 200,
      'participantId' => 300,
  • Template (View): The HTML or prose which thanks them for signing up, highlights key details of the event (e.g. start-time and location), and perhaps offers follow-up links (e.g. to cancel the registration or fill-out a pre-event survey).
    <p>Dear {contact.first_name},</p>
    <p>Thank you for registering for {event.title}! See you on {event.start_date}!</p>

This example involves a contract: whenever a PHP application fires event_receipt, it must supply eventId so that tokens like {event.start_date} can be evaluated. Similarly, if the administrator edits the template, they may safely expect {event.*} (eventId) to be available, but they should not expect {case.*} (caseId). The contract is important for the administrative screens (eg providing a list of relevant tokens and examples).

This chapter examines how to register workflow message templates, define their contracts, and render them.


Suppose we are building CiviCheese, the cheese-making management system for non-governmental organizations. We might send automated messages after completing each step of the cheese-making process (e.g. curdling, aging, cutting). Each automated message should be registered before usage.

Workflow names

The message will be composed as a step in some workflow, and we must choose a machine name for this step.

If we were adding a new subsystem like CiviCheese with several distinct messages, then we would choose a name for each message (eg cheese_curdling, cheese_aging, cheese_cutting).

Naming convention
  • Use lower-case alphanumerics (eg foo; not FOO or Foo)
  • Separate words with underscores (eg foo_bar; not FOO-BAR; not foo bar; not FooBar)
  • Prefix with a subsystem or entity name (eg contribution_*, case_*)
How To: Show a list of existing names (canonically)

The WorkflowMessage API can be used to browse and inspect the registered messages.

cv api4 WorkflowMessage.get -T +s name
| name                             |
| generic                          |
| contribution_invoice_receipt     |
| contribution_offline_receipt     |
| contribution_online_receipt      |
| contribution_recurring_cancelled |
| contribution_recurring_edit      |
| participant_cancelled            |
| participant_transferred          |

How To: Show a list of existing names (exhaustively)

Some messages may be informally or incompletely registered. These do not appear in WorkflowMessage API.

The MessageTemplate API provides another angle for listing messages. It should provide a mix of formally and informally defined messages.

cv api4 MessageTemplate.get -T '{"select":["workflow_name"], "groupBy":["workflow_name"]}'
| workflow_name                    |
| case_activity                    |
| contribution_dupalert            |
| contribution_offline_receipt     |
| contribution_online_receipt      |
| contribution_invoice_receipt     |
| contribution_recurring_notify    |


During installation or setup, we should register the message-templates.

Each message-template includes typical email content (msg_subject, msg_text, and msg_html). Note that including msg_text is deprecated and we have been updating these to be an empty string.

Email content allows a mix of CiviCRM token-notation and Smarty notation. Both of these examples are valid content:

<!-- CiviCRM-style tokens -->
<p>Hello {contact.first_name}!</p>
<!-- Smarty-style conditions, variables, blocks, functions -->
{if $cheeseBatchId > 10}<p>This is gonna be good.</p>{/if}

Technically, each workflow step (e.g. cheese_curdling) requires two template records -- the default template and the reserved template. These templates are nearly identical.

What is the difference between the default template and the reserved template?

At runtime, when composing a message, the system loads and evaluates the default template. Administrators may customize the default template.

The reserved template provides a reference-point -- if a customization goes awry, the administrator can refer back to the reserved template and examine differences.

How To: Show a list of existing message-templates

The MessageTemplate API can be used to browse, inspect, and update templates.

cv api4 MessageTemplate.get -T +s id,workflow_name,is_default,is_reserved
| id | workflow_name                    | is_default | is_reserved |
| 1  | case_activity                    | 1          |             |
| 2  | case_activity                    |            | 1           |
| 3  | contribution_dupalert            | 1          |             |
| 4  | contribution_dupalert            |            | 1           |
| 5  | contribution_offline_receipt     | 1          |             |
| 6  | contribution_offline_receipt     |            | 1           |

Example: Register message-templates for "cheese_curdling"
$baseTpl = [
  'workflow_name' => 'cheese_curdling',
  'msg_title' => 'Cheese - Curdling finished',
  'msg_subject' => 'Curdling completed [#{$cheeseBatchId}]'),
  'msg_text' => 'Hey, {contact.first_name}! Cheese batch #{$cheeseBatchId} ({$cheeseType}) has finished curdling!',
  'msg_html' => '<p>Hey, {contact.first_name}! Cheese batch #{$cheeseBatchId} ({$cheeseType}) has finished curdling!</p>',

// Create a "reserved" template. This is a pristine copy provided for reference.
civicrm_api4('MessageTemplate', 'create',
  ['values' => $baseTpl + ['is_reserved' => 1, 'is_default' => 0],

// Create a default template. This is live. The administrator may edit/customize.
civicrm_api4('MessageTemplate', 'create',
  ['values' => $baseTpl + ['is_reserved' => 0, 'is_default' => 1],
Do message-templates require workflow_ids, option-groups, or option-values?

Historically, yes. Currently, no.

Historically, civicrm_msg_template.workflow_id referenced a civicrm_option_value (and this record often referenced a custom civicrm_option_group). Some message-templates still define these for backward compatibility.

Currently, CiviCRM does not use or require workflow_id or the related option-values. Instead, it uses workflow_name.

Data model

Templates reference data, like in the token {contact.first_name} or the Smarty variable {$cheeseBatchId}. The data model is the list of expected data, which may include:

  • Token-processing data (tokenContext). For example, setting [contactId=>123] will enable {contact.*} tokens.
  • Smarty-template data (tplParams). For example, setting [cheeseType=>'cottage'] will enable {$cheeseType}.

Prior to CiviCRM v5.43, the data model was entirely ad hoc. There was no standard way to enumerate or document data. CiviCRM v5.43 introduced an optional class-based data-model, which allows richer user-experience (better token-lists, autocompletes, validation, previews, etc). Here's an example class:

Example: Define a class model for cheese_curdling

class CRM_Cheese_WorkflowMessage_CheeseCurdling extends GenericWorkflowMessage {

  public const WORKFLOW = 'cheese_curdling';

   * @var string
   * @options brie,cheddar,cottage,gouda
   * @scope tplParams
  protected $cheeseType

   * @var int
   * @scope tplParams
  protected $cheeseBatchId;

The example shows a few important points:

  • The class name follows a convention (CRM_{$component}_WorkflowMessage_{$workflow}).
  • The base class is \Civi\WorkflowMessage\GenericWorkflowMessage.
  • Every input is a property of the class.
  • The @scope annotation shares data with the token-processor ( @scope tokenContext) or Smarty template-engine (@scope tplParams).
Question: Are adhoc models and class models interoperable?

Yes. This relies on bi-directional import/export mechanism.

  • If you have an array of tokenContext and/or tplParams data, it can be imported into a class model. Recognized values are mapped via @scope, and unrecognized values are mapped to a generic $_extras array.
  • If you have a class and need tokenContext or tplParams data, it can be exported to arrays. As with import, recognized values are mapped via @scope, and $_extras will be read for any unrecognized values.

The class supports several more techniques -- such as default values, getters, setters, and so on. For more thorough discussion, see Class modeling.


As a developer, you choose between two primary actions, rendering or sending a workflow-message. Both actions may be executed through the BAO methods for internal CiviCRM code (they may change as they are primarily internal methods):

// Render the template(s) and return the resulting strings.
$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  ' model options...',
  '...template view options...'

// Render the template(s) and send the resulting email.
$delivery = \CRM_Core_BAO_MessageTemplate::sendTemplate([
  ' model options...',
  '...template view options...',
  '...envelope options...'

Equivalently, workflow-messages may be prepared in object-oriented fashion:

// Render the template(s) and return the resulting strings.
$model = new ExampleWorkflowMessage([' model options...']);
$rendered = $model->renderTemplate(['...template view options...']);

// Render the template(s) and send the resulting email.
$model = new ExampleWorkflowMessage([' model options...']);
$delivery = $model->sendTemplate([
    '...template view options...',
    '...envelope options...'

All these methods are very similar - they evaluate a template, and they accept many of the same parameters. Parameters target a few different aspects of the message - the data (model), the template (view), and/or the email envelope.

Data model options

The data model options define the dynamic data that will be plugged into the message.

Example: Adhoc data model

With an adhoc data model, you specify a list of fields to pass through directly to the templating system (e.g. tplParams and tokenContext). There is no fixed or formal list of fields. This style works with existing callers (which predate the class-model).

$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  'workflow' => 'cheese_curdling',
  'tokenContext' => ['contactId' => 123],
  'tplParams' => ['cheeseType' => 'cottage', 'cheeseBatchId' => 456],

Note: Internally, renderTemplate() will recognize that cheese_curdling corresponds to class CRM_Cheese_WorkflowMessage_CheeseCurdling. It will pass the tokenContext and/or tplParams into the CheeseCurdling class and apply any defaults, filters, validations, etc.

Example: Class data model

The class data model provides way to populate the data using getters, setters, and other class-specific methods. Properties of the class may be inspected via ReflectionClass or via $model->getFields().

$model = (new CRM_Cheese_WorkflowMessage_CheeseCurdling())
$rendered = $model->renderTemplate();

As a message sender, you are only responsible for setting documented fields (in accordance with the CheeseCurdling contract). You do not know whether these fields are used for tokenContext, tplParams, or something else.

Example: Hybrid - Class data model w/import from adhoc arrays

Sometimes, you may have code already written for an adhoc data model - but you wish to progressively convert it to the class model.

/** @var CRM_Cheese_WorkflowMessage_CheeseCurdling $model */
$model = WorkflowMessage::create('cheese_curdling', [
  'tokenContext' => ['contactId' => 123],
  'tplParams' => ['cheeseType' => 'cottage', 'cheeseBatchId' => 456],
// ... Perform extra work, such as $model->validate(), then ...
$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  'model' => $model,

WorkflowMessage::create() accepts data from the adhoc format (tplParams, tokenContext), and it returns a $model object. This allows you to import existing data and then leverage any getters, setters, validators, etc.

The best available class will be matched by the name (eg cheese_curdling => CheeseCurdling). If there is no specific class, it will use GenericWorkflowMessage.

The adhoc tplParams and tokenContext sometimes include unrecognized fields that are missing from the class model. Unrecognized fields will be privately preserved in $model->_extras. The model cannot directly manipulate these fields, but they will be exported to the appropriate layer when rendering the template.

Reference: List of data model options

The data may be provided as a singular option:

  • model (WorkflowMessageInterface): The data model, defined as a class. The class may be exported to workflow, tplParams, tokenContext, etc.

Alternatively, the data may be an adhoc mix of:

  • workflow (string): Symbolic name of the workflow step. (For old-style workflow steps, this matches the option-value-name, a.k.a. valueName.)
  • modelProps (array): Define the list of model properties as an array. This is the same data that you would provide to model, except in array notation instead of object notation.
  • tplParams (array): This data is passed to the Smarty template evaluator.
  • tokenContext (array): This data is passed to the token processing layer. Typical values might be contactId or activityId.
  • contactId (int): Alias for tokenContext.contactId

Template view options

The template view options define the layout, formatting, and prose of the message. If no view is specified, renderTemplate() and sendTemplate() will autoload the default template. However, you may substitute alternative template options.

Example: Load a default template

If you omit any view options (messageTemplateID, messageTemplate), then it will autoload the default template.

$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  // Data model options
  'model' => $model,
Example: Load a template by ID
$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  // Data model options
  'model' => $model,

  // Template view options
  'messageTemplateID' => 123,
Example: Pass in template content
$rendered = \CRM_Core_BAO_MessageTemplate::renderTemplate([
  // Data model options
  'model' => $model,

  // Template view options
  'messageTemplate' => [
    'msg_subject' => 'Hello {contact.first_name}',
    'msg_text' => 'The cheese is getting ready! {if $cheeseType=="cottage"}Time to prepare the fruit bowl!{/if}',
    'msg_html' => '<p>The cheese is getting ready! {if $cheeseType=="cottage"}<strong>Time to prepare the fruit bowl!</strong>{/if}</p>',
Reference: List of template view options
  • messageTemplateID (int): Load the template by its ID
  • messageTemplate (array): Use a template record that we have defined, overriding any autoloaded content. Keys: msg_subject, msg_text, msg_html
  • isTest (bool): Wrap the template in a test banner
  • disableSmarty (bool): Force evaluation of messageTemplate in Token-Only language (overriding the default Token-Smarty language).
  • subject (string, deprecated): Override the default subject. (messageTemplate.msg_subject is preferred.)

Note: If neither messageTemplate nor messageTemplateID is provided, then the default template-view will be determined by workflow.

Envelope options

The envelope options describe any headers and addenda for the email message.

Reference: List of envelope options
  • from (string): The From: header
  • toName (string): The recipient’s name
  • toEmail (string): The recipient’s email - mail is sent only if set
  • cc (?): The Cc: header
  • bcc (?): The Bcc: header
  • replyTo (?): The Reply-To: header
  • attachments (?): Email attachments
  • PDFFilename (string): Filename of optional PDF version to add as attachment (do not include path)

Class modeling



Converting an existing workflow

Refactoring properties