Resolving segments from your module
This is the module's entire consumer-facing contract: depend on
entity_segment, inject the entity_segment.resolver service, and call
resolve() once per segment.
The service
# entity_segment.services.yml
entity_segment.resolver:
class: Drupal\entity_segment\SegmentResolver
arguments: ['@entity_type.manager', '@plugin.manager.entity_segment']
Type-hint the interface, not the class:
# your_module.services.yml
your_module.mailer:
class: Drupal\your_module\Mailer
arguments: ['@entity_segment.resolver']
use Drupal\entity_segment\SegmentInterface;
use Drupal\entity_segment\SegmentResolverInterface;
class Mailer {
public function __construct(
protected readonly SegmentResolverInterface $segmentResolver,
) {}
public function recipients(SegmentInterface $segment): array {
// int[] — IDs of the segment's TARGET entity type, deduplicated and sorted
// ascending. For a crm_contact segment these are contact IDs; for a user
// segment, user IDs.
return $this->segmentResolver->resolve($segment);
}
}
resolve()
interface SegmentResolverInterface {
/**
* Resolves a segment to the IDs of the target-type entities it selects.
*/
public function resolve(SegmentInterface $segment): array;
}
It takes a \Drupal\entity_segment\SegmentInterface (the segment entity) and
returns int[]: the IDs of the entities of the segment's target entity type
that it selects, deduplicated and sorted ascending. The target type is the
one named by the segment's bundle (segment_type) and read back with
SegmentInterface::getTargetEntityTypeId() — the resolver threads it through the
whole walk, so the query it runs and the IDs it returns are of that type, never a
fixed entity type. The order of a group's children never affects the result.
Throws:
\Drupal\Component\Plugin\Exception\PluginException— if the tree names a segment plugin that cannot be instantiated, for example because the module providing it has been uninstalled since the segment was saved.\InvalidArgumentException— if the tree contains a node whosetypeis neithergroupnorcondition. The entity-level tree constraint makes such a tree unsavable, so this indicates data that bypassed validation.
⚠️ The access boundary — read this
resolve()output is NOT access-filtered.The returned IDs reflect the segment's logic and nothing else. Access to the target entities is your responsibility.
- Do not treat the result as a list of entities the current user is permitted to see.
- Do not render, export, or otherwise expose it without applying the target type's access yourself.
Access control on the Segment entity governs who may read and edit the segment. It never governs which target entities the segment may contain. The resolver runs its queries with
accessCheck(FALSE), deliberately.This is the most likely way this API gets misused. If your use case shows the selected entities to a user, filter them yourself.
resolveAccessible() — the access-filtered companion
When you need to show a segment's audience to a specific user, do not filter
resolve()'s output by hand. The resolver ships the access-filtered companion:
interface SegmentResolverInterface {
/**
* Resolves a segment to the IDs the account is allowed to `view`.
*/
public function resolveAccessible(
SegmentInterface $segment,
AccountInterface $account,
): array;
}
It runs resolve() for the raw audience and then keeps only the IDs whose
target entity passes view access for $account. The result is a subset of
resolve()'s, in the same deduplicated ascending order, so it is safe to render
or export for that account where the raw result is not.
Like resolve() it is live and uncached; additionally it loads and
access-checks every audience entity, so it is O(audience) on every call by
design. It is meant for per-viewer exposure of a bounded audience, not for
resolving very large segments — cache it yourself if you call it repeatedly.
The audience-access chokepoint — entity_segment.audience_access
Rather than choosing between resolve() and resolveAccessible() (and
re-deciding who may see the raw set) in each consumer, exposure surfaces route
through one helper, entity_segment.audience_access
(\Drupal\entity_segment\AudienceAccess). It is the single place the
raw-vs-filtered rule lives, and it is what every shipped integration (Views,
Token, JSON:API, VBO, ECA) calls — see the
Integrations section of the README.
use Drupal\entity_segment\AudienceAccess;
class Report {
public function __construct(
protected readonly AudienceAccess $audienceAccess,
) {}
public function membersFor(SegmentInterface $segment, AccountInterface $account): array {
// Viewer-safe: the subset $account may `view`. Delegates to
// resolveAccessible(). Always safe to render/export for $account.
return $this->audienceAccess->filtered($segment, $account);
}
}
filtered($segment, $account)→ the access-filtered audience (delegates toresolveAccessible()). Always viewer-safe.raw($segment, $account)→ the full, unfiltered audience only when the account holds the segment type's membership permission; otherwise an empty array (it does not throw).rawAllowed($segment, $account)→ whether the account holds that permission. Call it first when you must tell a denial apart from a genuinely empty raw audience (both come back as[]).
The membership permission is per segment type:
view resolved <segment_type_id> segment membership (restrict access),
built by SegmentPermissions from the segment's bundle. For a crm_contact
segment it is view resolved crm_contact segment membership.
⚠️ The raw path is NOT access-filtered.
raw()— and, underneath it,resolve()— returns the segment's membership with noviewcheck on the target entities, so it can reveal the existence of entities the account could not otherwise see. That is why it is gated behind the membership permission: it is for trusted use only. Grantview resolved <type> segment membershiponly to roles you trust with the unfiltered audience, and preferfiltered()whenever the audience is shown to a user. The same warning applies to the raw Views mode, the[segment:raw-members]tokens, and the/resolved-members/rawJSON:API resource — all thin wrappers over this same raw path.
Set math is yours
This module ships resolve() for a single segment only. There is
deliberately no resolveMultiple() and no include/exclude parameter — this
was an explicit decision, not an omission.
Sending to segments A and B but not C is your module's job:
$ids = array_diff(
array_merge(
$this->segmentResolver->resolve($a),
$this->segmentResolver->resolve($b),
),
$this->segmentResolver->resolve($c),
);
(resolve() returns each set already deduplicated and sorted; array_merge()
across two of them can reintroduce duplicates, so array_unique() /
array_values() the result if you care.)
Note that combining across segments only makes sense when they share a target type — merging contact IDs with user IDs would produce a meaningless set. The resolver does not check this for you; it is on the caller to combine only segments of the same target.
Empty segments resolve to no entities
A segment with no conditions — the empty root group
['type' => 'group', 'conjunction' => 'AND', 'children' => []] that
SegmentInterface::getConditions() returns by default — resolves to an empty
array: no entities.
It deliberately does not mean "every entity". An unconfigured segment is far more likely to be a half-built one than a deliberate request to select the whole target base, and an empty result is the safer answer for a consumer about to send email. The same rule applies to an empty group at any depth.
Resolution is live, and uncached
Every call re-queries. There is no cache bin, no materialized table and no cron — a segment always reflects the data as it is now, and two calls with unchanged data will both hit the database.
If you resolve the same segment repeatedly, cache it yourself. Note also that a segment containing a non-queryable plugin loads every matching ID into memory on every resolve, with no pagination (see docs/plugin-authors.md).
resolve()'s signature is what you depend on, so a cache or a streaming return
could be introduced later without touching your call sites.
Stability
This module is alpha and makes no backwards-compatibility promise. The resolver contract, the plugin interfaces and the stored tree format may change. See the README.