Building a consumer module
RL is a framework; on its own it stores experiment data and runs Thompson
Sampling. The actual "what gets tested" lives in consumer modules. This guide
walks through the architecture consumer modules share, using the two bundled
submodules (rl_page_title and rl_menu_link) as worked examples.
Integration patterns at a glance
| Pattern | Server-side (recommended) | Client-side |
|---|---|---|
| Decision | PHP at render time via VariantSelectorBase |
JS via Drupal.rl.decide() after page load |
| Turn | JS on viewport entry or page load via Drupal.rl.turn() |
Same |
| Reward | JS on conversion event via Drupal.rl.reward(), or PHP via ExperimentManager::recordReward() |
Same |
| Caching | CacheManager::overridePageCacheIfShorter() ensures variants rotate |
Full-page cache safe; all variants rendered, swapped client-side |
| Examples | rl_page_title, rl_menu_link, rl_sorting |
DXPR Builder |
Anatomy of a consumer module
A server-side consumer module typically provides five pieces:
- Experiment entity: a content entity implementing
VariantExperimentInterface - Variant selector service: a service extending
VariantSelectorBase - Report decorator service: a service extending
VariantExperimentDecoratorBase - Hook integration: hooks that intercept Drupal's render pipeline and swap content
- Tracking JS: a small Drupal behaviour that records turns and rewards via
Drupal.rl
1. Experiment entity
The experiment entity holds the variant data. Both rl_page_title and
rl_menu_link follow the same pattern: a content entity with
VariantExperimentInterface and VariantArmsTrait.
Key fields shared across both implementations:
label: human-readable name for admin UIs and reportsvariants_data: JSON-encoded list of variant text stringslookup_hash: computedsha256(target|langcode)backed by a UNIQUE indexenabled: publish/unpublish toggle (viaEntityPublishedTrait)langcode: scopes the experiment per language
The arm convention enforced by VariantArmsTrait:
v0= original text (read live from the source, never stored)v1..vN= stored variant texts, indexed sequentially
Each entity class provides two static helpers:
computeLookupHash(target, langcode): the UNIQUE index key for O(1) runtime lookupbuildRlExperimentId(target, langcode)(viaVariantArmsTrait::buildVariantExperimentId()): a deterministic{module}-{12-char-sha1}hash used as the key into RL's analytics tables
rl_page_title targets a normalised internal path (e.g. /node/42):
$fields['path'] = BaseFieldDefinition::create('string')
->setLabel(t('Page URL or path'))
->setRequired(TRUE)
->setSetting('max_length', 2048);
rl_menu_link targets a menu link plugin ID (e.g.
menu_link_content:some-uuid):
$fields['menu_link_plugin_id'] = BaseFieldDefinition::create('string')
->setLabel(t('Menu link plugin ID'))
->setRequired(TRUE)
->setSetting('max_length', 255);
Your module will have its own target identifier; the pattern stays the same.
2. Variant selector service
Extend VariantSelectorBase and implement four abstract methods. The base
class handles Thompson Sampling scoring, per-request caching, self-healing
registry registration, multilingual fallback, and page cache override.
Here is rl_page_title's selector, trimmed to the essentials:
class TitleVariantSelector extends VariantSelectorBase {
protected function ownerModule(): string {
return 'rl_page_title';
}
protected function entityTypeId(): string {
return 'rl_page_title_experiment';
}
protected function entityClass(): string {
return PageTitleExperiment::class;
}
protected function computeLookupHash(
string $target,
string $langcode
): string {
return PageTitleExperiment::computeLookupHash($target, $langcode);
}
public function selectForCurrentPage(): ?array {
return $this->selectForTarget(
$this->getCurrentInternalPath(),
$this->languageManager
->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE)
->getId()
);
}
}
rl_menu_link is nearly identical; only the target identifier changes
from an internal path to a menu link plugin ID.
The return value from selectForTarget() is either NULL (no active
experiment) or an array:
[
'experiment_id' => 'rl_page_title-a1b2c3d4e5f6',
'arm_id' => 'v1',
'text' => 'The winning variant text',
]
Register the service in *.services.yml:
services:
my_module.variant_selector:
class: Drupal\my_module\Service\MyVariantSelector
arguments:
- '@entity_type.manager'
- '@rl.experiment_manager'
- '@rl.cache_manager'
- '@rl.experiment_registry'
3. Report decorator service
The RL reports page (/admin/reports/rl) shows raw experiment and arm IDs by
default. A decorator maps those IDs back to something human-readable.
Extend VariantExperimentDecoratorBase and implement:
experimentIdPrefix(): the prefix your experiment IDs start with (e.g.rl_page_title-)entityTypeId(),entityClass(): which entity to loadbuildExperimentDisplay(): render array for the experiment labelbuildOriginalArmDisplay(): render array for the v0 (original) arm
Tag the service with rl_experiment_decorator:
services:
my_module.decorator:
class: Drupal\my_module\Decorator\MyDecorator
arguments: ['@entity_type.manager']
tags:
- { name: rl_experiment_decorator }
See the Experiment decorators guide for a standalone example that does not use the base class.
4. Hook integration
This is where consumer modules diverge most. The hooks depend on what you are testing.
rl_page_title uses hook_preprocess_page_title() and
hook_preprocess_html() to swap the visible and <title> tag titles:
function rl_page_title_preprocess_page_title(array &$variables) {
$result = \Drupal::service('rl_page_title.variant_selector')
->selectForCurrentPage();
if ($result && $result['text'] !== NULL) {
$variables['title'] = $result['text'];
}
}
rl_menu_link uses hook_preprocess_menu() to walk the menu tree and
replace link titles:
function rl_menu_link_preprocess_menu(array &$variables) {
if (empty($variables['items'])) {
return;
}
_rl_menu_link_process_items($variables['items']);
}
function _rl_menu_link_process_items(array &$items) {
$selector = \Drupal::service('rl_menu_link.variant_selector');
foreach ($items as &$item) {
if (isset($item['original_link']) && method_exists($item['original_link'], 'getPluginId')) {
$plugin_id = $item['original_link']->getPluginId();
$result = $selector->selectForPluginId($plugin_id);
if ($result && $result['text'] !== NULL) {
$item['title'] = $result['text'];
}
}
if (!empty($item['below'])) {
_rl_menu_link_process_items($item['below']);
}
}
}
Both modules also use hook_form_alter() to add a "variant titles" vertical
tab on entity edit forms, so editors can manage experiments without visiting a
separate admin page. They attach the experiment data to the host entity's save
flow.
Both implement hook_entity_predelete() to cascade-delete experiments and
purge analytics when the parent entity is removed.
5. Tracking JavaScript
Each consumer attaches a small JS behaviour that calls Drupal.rl.turn() and
Drupal.rl.reward(). The pattern varies by what counts as an impression and
what counts as a conversion:
| Module | Turn (impression) | Reward (conversion) |
|---|---|---|
rl_page_title |
Page load | Visitor stays 10+ seconds |
rl_menu_link |
Link enters viewport (IntersectionObserver) | Visitor clicks the link |
rl_example |
Form enters viewport | AJAX form submit (server-side recordReward()) |
rl_example_frontend |
Form enters viewport | Click event via Drupal.rl.reward() |
Pass the experiment ID and arm ID to the page via drupalSettings:
$build['#attached']['drupalSettings']['myModule'] = [
'experimentId' => $result['experiment_id'],
'armId' => $result['arm_id'],
];
Then in your JS:
Drupal.behaviors.myModuleTracking = {
attach(context) {
once('my-module-tracking', '.my-element', context)
.forEach((el) => {
var config = drupalSettings.myModule;
Drupal.rl.turn(config.experimentId, config.armId);
el.addEventListener('click', function () {
Drupal.rl.reward(config.experimentId, config.armId);
});
});
},
};
All Drupal.rl calls are batched (500ms window) and sent to rl.php in a
single request. See the JavaScript API for the full
reference.
Without VariantSelectorBase: the direct API approach
Not every integration needs a content entity and a selector service. For simpler cases (a block, a form, a widget), you can use the RL services directly. The bundled example modules demonstrate this.
rl_example tests which button text gets more newsletter signups:
public function build() {
$this->experimentRegistry->register(
'my_experiment',
'my_module',
'My A/B Test'
);
$scores = $this->experimentManager->getThompsonScores(
'my_experiment',
NULL,
['subscribe', 'updates', 'notify']
);
arsort($scores);
$best = key($scores);
$this->cacheManager->overridePageCacheIfShorter(60);
return ['#markup' => $this->buttonTexts[$best]];
}
The three required services:
| Service | Purpose |
|---|---|
rl.experiment_registry |
Register your experiment so rl.php accepts tracking events for it |
rl.experiment_manager |
Get Thompson Sampling scores, record turns and rewards |
rl.cache_manager |
Shorten the page cache so variants rotate |
rl_sorting: Views integration
RL: A/B Test Views Content
(rl_sorting) is a separate project that integrates RL with Drupal Views.
It provides a Views sort plugin that reorders view results by Thompson
Sampling score, turning any View into an engagement-optimised ranking.
The decision is server-side: the sort plugin calls getThompsonScores()
with the content IDs from the View results as arm IDs, then reorders the
rows before rendering. Tracking is client-side: an IntersectionObserver
collects visible entity IDs and sends an action=turns payload directly to
rl.php using navigator.sendBeacon(). Rewards fire via sendBeacon()
when a visitor clicks through to a listed item.
DXPR Builder integration
DXPR Builder uses the
client-side pattern. All variants are rendered into the page HTML (so the page
can be served from Varnish or Fastly), and the builder's JS calls
Drupal.rl.decide() at page load to pick the winner and swap the visible
slot. This keeps the full-page cache intact while still running experiments.
Cache considerations
Server-side selectors need the page cache to expire before variants can
rotate. VariantSelectorBase calls
CacheManager::overridePageCacheIfShorter() automatically. Both bundled
submodules default to 60 seconds; override cacheTtl() in your selector to
change it.
Client-side integrations (DXPR Builder) are fully compatible with aggressive page caching because the decision happens after load.
See the Cache management guide for details.
Putting it together: a checklist
When building a new consumer module:
- Define your experiment entity implementing
VariantExperimentInterfacewithVariantArmsTrait - Create a variant selector service extending
VariantSelectorBase - Create a report decorator extending
VariantExperimentDecoratorBaseand tagging it withrl_experiment_decorator - Hook into the render pipeline to swap content with the winning variant
- Write tracking JS that records turns on impression and rewards on conversion
- Handle cleanup: implement
hook_entity_predelete()to cascade-delete experiments when the parent entity is removed - Register experiments: the base class self-heals registration, but
explicit
register()calls in form submit handlers ensure the registry is in sync from the start