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\QueryableSegmentPluginInterfaceif 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.
FieldValueships 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 targetcrm_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:
ConfigurableInterface—getConfiguration(),setConfiguration(),defaultConfiguration().PluginFormInterface—buildConfigurationForm(),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 toresolveToIds()whenever the surrounding tree cannot be expressed as one query. - The two paths must agree.
applyToQuery()andresolveToIds()must select the same entities for the same configuration.FieldValueguarantees this structurally by implementingresolveToIds()on top ofapplyToQuery(), 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.
>,>=,<,<=,BETWEENandNOT BETWEENappear fornumericandtemporalbut not forstring: 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. temporalshares thenumericoperator 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
nodetarget,uid.entity.namereaches the author's username: the node'suidfield references auser, andnameis a field on that user. The leaf is astring, 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 queryINNER JOINs that table — so entities of a bundle without the field have no row to join and drop out for every operator,<>included.FieldValuetherefore needs no bundle constraint.- Case sensitivity is the field's decision, not the operator's. Core's
entity query rewrites
=toLIKEand<>toNOT LIKEwhen — 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) areLIKE-based and follow the same field-defined case behavior.FieldValuepasses 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 uniqueid. - [ ]
target_entity_typesdeclared only if the plugin is target-specific; leave empty for an any-target plugin. - [ ] Extends
SegmentPluginBase(or implementsSegmentPluginInterface). - [ ] Queries the storage returned by
getTargetEntityTypeId(), not a hardcoded entity type. - [ ]
resolveToIds(): arrayimplemented — always. - [ ]
summary(): stringimplemented. - [ ] Optionally,
summaryParts(): arrayfor a styled read-only view — defaults tosummary(). - [ ]
buildConfigurationForm()implemented; defaults indefaultConfiguration(). - [ ] Implements
QueryableSegmentPluginInterfaceif the logic can be expressed as target-type entity-query conditions — andapplyToQuery()only adds conditions to the group it is given. - [ ] Inverse operators exposed in configuration rather than expecting tree-level negation.