Writing a Segment plugin

A Segment plugin is a single configurable predicate that selects a set of entities of the segment's target entity type — "field X is Y", "has received email Z", "is a member of group G". Segments compose these predicates into a condition tree (AND/OR groups) to describe an audience.

A plugin is target-aware, not tied to one entity type. The same plugin can serve a segment that targets user, one that targets node, and one that targets crm_contact; the segment's target entity type is handed to the plugin as context before it builds its form or resolves. A plugin may restrict itself to specific targets, but the plugins that ship here do not need to.

The worked example that ships with this module is src/Plugin/EntitySegment/FieldValue.php, the generic any-target field predicate. Read it alongside this guide — everything below is visible in that file.

Anatomy

  • Put the class in src/Plugin/EntitySegment/ of your module. That is the discovery namespace (Plugin/EntitySegment), set by \Drupal\entity_segment\SegmentPluginManager.
  • Annotate it with the #[EntitySegment] attribute (\Drupal\entity_segment\Attribute\EntitySegment).
  • Implement \Drupal\entity_segment\SegmentPluginInterface, normally by extending \Drupal\entity_segment\SegmentPluginBase.
  • Additionally implement \Drupal\entity_segment\QueryableSegmentPluginInterface if your logic can be expressed as entity-query conditions on the target type. See Push-down.

The attribute takes id, and optional label, description, target_entity_types and deriver:

#[EntitySegment(
  id: 'field_value',
  label: new TranslatableMarkup('Field value'),
  description: new TranslatableMarkup('Selects entities where a field has a given value.'),
)]
class FieldValue extends SegmentPluginBase implements QueryableSegmentPluginInterface {

Declaring supported targets: target_entity_types

target_entity_types is the plugin's list of the content entity type IDs it can select. It decides which segments the plugin is offered on:

  • Empty (the default) means any content entity type. The plugin is offered on every segment, whatever its target. FieldValue ships this way — it works off the target's field definitions, so it is genuinely target-agnostic and declares no targets.
  • A non-empty list restricts the plugin to those targets. A CRM-only plugin — one that only makes sense against contacts — declares target_entity_types: ['crm_contact'], and the plugin manager offers it only on segments that target crm_contact. A plugin may list several (['user', 'crm_contact']).
// Offered only on segments whose target is crm_contact.
#[EntitySegment(
  id: 'received_email',
  label: new TranslatableMarkup('Has received email'),
  target_entity_types: ['crm_contact'],
)]

SegmentPluginManager::getDefinitionsForTarget($target_entity_type_id) is what applies this filter: a definition applies to a target when its target_entity_types is empty or contains that target. The condition-tree builder calls it so the "add condition" list only ever shows applicable plugins.

Receiving the target: setTargetEntityTypeId()

Declaring a target only gates availability. The plugin also needs to know, at run time, which target type the current segment selects, so it queries the right storage. That is delivered as explicit context:

public function setTargetEntityTypeId(string $entity_type_id): void;
public function getTargetEntityTypeId(): ?string;

The callers — the resolver, the condition-tree builder, the results table and the builder forms — call setTargetEntityTypeId() from the segment's target before invoking buildConfigurationForm(), applyToQuery() or resolveToIds(). Your plugin reads it back with getTargetEntityTypeId() and uses it wherever it would otherwise hardcode an entity type — the storage it queries, the field definitions it offers, the traversal it walks.

SegmentPluginBase implements both accessors over a protected $targetEntityTypeId property, so a subclass just reads $this->getTargetEntityTypeId(). A plugin restricted to a single target may still call the accessor and can rely on it being one of its declared targets.

SegmentPluginInterface

interface SegmentPluginInterface extends PluginInspectionInterface, ConfigurableInterface, PluginFormInterface {

  /**
   * Sets the target entity type ID this plugin operates on.
   */
  public function setTargetEntityTypeId(string $entity_type_id): void;

  /**
   * Returns the target entity type ID this plugin operates on.
   */
  public function getTargetEntityTypeId(): ?string;

  /**
   * Resolves this condition to the target-type IDs it matches.
   */
  public function resolveToIds(): array;

  /**
   * Returns a human-readable summary of the configured condition.
   */
  public function summary(): string;

}

resolveToIds() is the universal fallback path — every plugin must implement it, including plugins that also implement QueryableSegmentPluginInterface. The resolver can always ask any plugin for the target-type IDs it matches, and that answer is the definitive one.

summary() returns the short human-readable description of the configured condition ("Country is Belgium") that the segment builder UI shows.

summaryParts() returns that same description broken into labeled parts, for the read-only conditions view on a segment's canonical page to style each part. It is optional: SegmentPluginBase defaults it to a single summary part equal to summary(), so a plugin that does nothing still renders as a one-line row. Override it to return field, operator, and value parts (as FieldValue does) whose joined form equals summary().

Configuration and form handling come from the parent interfaces:

  • ConfigurableInterfacegetConfiguration(), setConfiguration(), defaultConfiguration().
  • PluginFormInterfacebuildConfigurationForm(), validateConfigurationForm(), submitConfigurationForm().

SegmentPluginBase implements the configuration triad, the target-type accessors, and no-op validateConfigurationForm() / submitConfigurationForm(). It also implements ContainerFactoryPluginInterface, so override create() to inject services. Subclasses must implement resolveToIds(), summary() and buildConfigurationForm(); declare your defaults in defaultConfiguration().

Where your subform lands in the condition builder

Nothing in this section is a contract — no plugin interface changed — but it explains what your buildConfigurationForm() output sits inside, so you can decide how it should read.

The builder wraps every condition in a row holding exactly three things: the condition-plugin selector, your configuration container, and the row's remove button. Your elements flow into that row as a wrapping line, whatever shape they have; the builder cannot know a third-party plugin's shape, so the arrangement is left to the stylesheet and a subform it does not own degrades to a second line rather than overflowing.

The shipped FieldValue renders its own controls with an invisible title (#title_display: invisible) rather than no title at all — the labels are present, just not drawn — so its field, operator and value read as one sentence along the row. Follow that only if you want the same look; a plugin that keeps its visible labels is perfectly well behaved and simply stacks labeled controls inside the row.

If you do hide your labels, hide them — do not remove them. The builder names the row (role="group" with a generated name such as "Condition 2.1"), and that name identifies the condition, not each control within it. A control with no label reaches assistive technology unnamed, so a row of three unlabeled selects is a row of three indistinguishable comboboxes no matter how well the row itself is named. Invisible titles, never absent ones.

Push-down: QueryableSegmentPluginInterface

interface QueryableSegmentPluginInterface extends SegmentPluginInterface {

  /**
   * Applies this configured condition to a target-type query condition group.
   */
  public function applyToQuery(ConditionInterface $condition): void;

}

(ConditionInterface here is \Drupal\Core\Entity\Query\ConditionInterface.)

Implementing this interface is an opt-in signal, detected by the resolver with instanceof — there is deliberately no boolean flag on SegmentPluginInterface. It tells the resolver that your logic can be expressed directly as conditions on the target type's entity query, which lets the resolver fold a wholly-queryable segment into exactly one SQL query however deeply it nests, instead of loading and intersecting ID sets in PHP.

Rules:

  • The resolver owns the query. applyToQuery() receives a condition group and must only add conditions to it. Do not alter the group's conjunction, and do not touch the query outside it. You do not build your own query here.
  • You must still implement resolveToIds(). Push-down is an optimization, not a replacement: the resolver falls back to resolveToIds() whenever the surrounding tree cannot be expressed as one query.
  • The two paths must agree. applyToQuery() and resolveToIds() must select the same entities for the same configuration. FieldValue guarantees this structurally by implementing resolveToIds() on top of applyToQuery(), querying the storage of its target type:
public function resolveToIds(): array {
  $query = $this->entityTypeManager->getStorage($this->getTargetEntityTypeId())
    ->getQuery()
    ->accessCheck(FALSE);

  $group = $query->andConditionGroup();
  $this->applyToQuery($group);
  $query->condition($group);

  return array_values($query->execute());
}

Cost warning: the set-based fallback

A non-queryable plugin's resolveToIds() loads every matching ID into memory, on every resolve. There is no pagination and no cache — the resolver never caches, and resolution is live on every call. On a large target base that is the whole matching ID set, materialized in PHP, every time anyone resolves a segment your plugin appears in.

This is an accepted limitation of this release. Implement QueryableSegmentPluginInterface wherever your logic allows it; only fall back to a purely set-based plugin when the logic genuinely cannot be expressed as entity-query conditions on the target type.

Negation convention

The condition tree grammar is AND/OR only. There is no NOT, and no negate flag anywhere in this API.

Plugins own negation themselves, by exposing their own inverse operators in their configuration: is / is not, has received / has not received. A plugin that needs to express "not X" does so inside its own resolveToIds() and, if queryable, applyToQuery() — never by asking the tree to invert it.

This is what lets a queryable plugin emit <> directly rather than forcing the resolver to complement against the entire target universe.

Type-aware operators

FieldValue does not offer a fixed operator list. The operators it exposes depend on the data type of the leaf property being queried, so a string field and a number field present different, appropriate choices.

getOperatorOptions(?string $field_name = NULL) resolves the selected field path to its leaf property's TypedData data type — via getDataType() on the property definition — maps that data type to a category, and returns the operators for that category. A bare field resolves through its main property; a compound field's field.property path resolves through the named property. A data type not in the map falls through to the unknown category and its safe-minimum set. When no field is selected, the safe minimum is returned.

The data-type-to-category map (FieldValue::DATA_TYPE_CATEGORIES):

Category Data types
string string, string_long, email, uri, telephone
numeric integer, float, decimal, timestamp
temporal datetime_iso8601
boolean boolean
unknown anything not listed above

The category-to-operators map (FieldValue::CATEGORY_OPERATORS), in display order, with the label each operator carries in the UI:

Category Operators (label)
string = (is), <> (is not), STARTS_WITH (starts with), CONTAINS (contains), ENDS_WITH (ends with), IN (is one of), NOT IN (is not one of), IS NULL (is empty), IS NOT NULL (is not empty)
numeric = (is), <> (is not), > (greater than), >= (greater than or equal to), < (less than), <= (less than or equal to), BETWEEN (between), NOT BETWEEN (not between), IN (is one of), NOT IN (is not one of), IS NULL (is empty), IS NOT NULL (is not empty)
temporal identical to numeric
boolean = (is), <> (is not), IS NULL (is empty), IS NOT NULL (is not empty)
unknown = (is), <> (is not), IS NULL (is empty), IS NOT NULL (is not empty)

Two design decisions are baked into these sets:

  • Ordered comparisons are withheld from strings by design. >, >=, <, <=, BETWEEN and NOT BETWEEN appear for numeric and temporal but not for string: lexicographic ordering of arbitrary strings is not "standard string comparison" and would mislead. The substring operators (STARTS_WITH, CONTAINS, ENDS_WITH) are, conversely, offered only for strings.
  • temporal shares the numeric operator set because ISO-8601 date strings sort chronologically, so range and ordered comparisons are meaningful on them.

Field paths and reference traversal

A FieldValue condition does not have to compare a field on the target entity itself. The field_name it stores is a field path rooted at the segment's target type that may descend through reference fields, so a condition can compare a property on a referenced entity.

A reference hop is expressed with the literal segment .entity.: it steps from a reference field to the entity it points at, and the path continues on that target. The generic shape is <reference_field>.entity.<field>, and hops may chain: <ref>.entity.<ref>.entity.<field>.<property>.

  • One hop. On a node target, uid.entity.name reaches the author's username: the node's uid field references a user, and name is a field on that user. The leaf is a string, so the operator set is the string one (=, CONTAINS, STARTS_WITH, ...).
  • Multiple hops. Each .entity. is one hop, and a path may chain them to any depth, descending from referenced entity to referenced entity until it reaches a real (non-reference) leaf property.

(The Contact module documents a live contact example — an email address reached through a contact's emails reference — but nothing about traversal is contact-specific; the same rules apply to any target.)

The rules that govern which paths resolve:

  • Traversal is forward-only. A reference field is followed to its target. A reverse reference — a field on some other entity that points back at the target entity — cannot be walked, because the entity query has no join to follow in that direction. Data reachable only by walking a reference backwards is not reachable this way.
  • Reference fields are offered only as descend hops, never as a comparison leaf. A reference field is never presented in the selector as an id-comparison leaf — you cannot pick a reference field itself and compare its target id. It is offered only as a .entity. descend hop into its target, and the leaf you eventually compare is a real (non-reference) property on that target.
  • Only content-entity references are descendable; config references are omitted entirely. A reference whose target is a content entity can be descended into and so appears as a hop. A reference to a config entity — a bundle/type reference such as a node's type — is not descendable, and because references are never leaves either, such a reference is omitted from the selector entirely: it appears as neither a hop nor a leaf. A path that tries to descend into one is rejected with "Cannot descend into \"…\".".
  • Multi-value hops are existential. A hop through a multi-value reference is an any match: the target entity matches if any referenced entity satisfies the rest of the path. An entity with three referenced items matches if any one of the three does.
  • There is no depth limit. Paths may chain .entity. hops arbitrarily deep. Each hop is a JOIN, so a deeper path costs more at the database, but a wholly-queryable traversal still resolves in a single target-type query — push-down (see Push-down) is preserved across every hop.

The leaf of a path — the field, optionally narrowed to one of its properties — is compared with the type-aware operators described above: a traversed leaf behaves exactly like a direct field, its operator set decided by the leaf property's data type. uid.entity.name therefore offers the same string operators a direct string field on the target would.

Value model and operator arity

The configured value is stored uniformly as an array for every operator — defaultConfiguration() seeds it as []. How many elements it holds is a function of the operator's arity (FieldValue::OPERATOR_ARITY):

Arity Operators Stored value
single =, <>, >, >=, <, <=, STARTS_WITH, CONTAINS, ENDS_WITH one element
pair BETWEEN, NOT BETWEEN exactly two elements (min, max)
many IN, NOT IN a list of one or more elements
none IS NULL, IS NOT NULL empty array (no value)

applyToQuery() is the single place this array is reshaped into what the entity query's condition() expects: $value[0] for single, [$value[0], $value[1]] for pair, the whole array for many, and NULL for none. Storing one shape and reshaping in one place is what keeps applyToQuery() and resolveToIds() provably identical for every operator.

Behavior worth knowing

  • <> excludes entities of a bundle that lacks the field. A field that varies by bundle is a configurable field in its own table, and the entity query INNER JOINs that table — so entities of a bundle without the field have no row to join and drop out for every operator, <> included. FieldValue therefore needs no bundle constraint.
  • Case sensitivity is the field's decision, not the operator's. Core's entity query rewrites = to LIKE and <> to NOT LIKE when — and only when — the field's storage declares itself case-insensitive (case_sensitive === FALSE), which is the default for most string fields. So on a typical text field both operators match case-insensitively; on a case-sensitive field they do not. The substring operators (STARTS_WITH, CONTAINS, ENDS_WITH) are LIKE-based and follow the same field-defined case behavior. FieldValue passes the operator straight through and takes whatever the field defines. See \Drupal\Core\Entity\Query\Sql\Condition::translateCondition().

Empty groups

A group with no children selects no entities[] — at the root and at any depth. An unconfigured segment is far more likely to be half-built than a deliberate request for the entire target base, and an empty result is the safer answer for a consumer about to send email. You do not need to handle this in your plugin; it is stated here so the tree's semantics are not a surprise.

Checklist

  • [ ] Class in src/Plugin/EntitySegment/, #[EntitySegment] attribute with a unique id.
  • [ ] target_entity_types declared only if the plugin is target-specific; leave empty for an any-target plugin.
  • [ ] Extends SegmentPluginBase (or implements SegmentPluginInterface).
  • [ ] Queries the storage returned by getTargetEntityTypeId(), not a hardcoded entity type.
  • [ ] resolveToIds(): array implemented — always.
  • [ ] summary(): string implemented.
  • [ ] Optionally, summaryParts(): array for a styled read-only view — defaults to summary().
  • [ ] buildConfigurationForm() implemented; defaults in defaultConfiguration().
  • [ ] Implements QueryableSegmentPluginInterface if the logic can be expressed as target-type entity-query conditions — and applyToQuery() only adds conditions to the group it is given.
  • [ ] Inverse operators exposed in configuration rather than expecting tree-level negation.