Write a satellite module¶
Build a satellite that gives each site its own value of a configuration key, edit that value in a site admin section, test it, and publish the module.
A satellite is a module in a project of its own that depends on sites and
adds one capability per site through the extension points.
The example here, sites_maintenance, gives each site its own maintenance
message. sites_robotstxt has the
same shape and is the module to read next: one setting plugin with a
config_target, a section, and unit, kernel and functional tests.
flowchart LR
form["Section form<br/>/admin/sites/{site}/maintenance"] -- save --> row["The site's override row"]
row --> config["system.maintenance:message<br/>on the site's host"]
hub["The hub"] --> global["The platform's message"]
Scaffold the module¶
sites_maintenance.info.yml:
name: 'Sites maintenance'
type: module
description: 'Gives each site its own maintenance message.'
package: Sites
core_version_requirement: ^11.2 || ^12
dependencies:
- sites:sites
composer.json:
{
"name": "drupal/sites_maintenance",
"type": "drupal-module",
"license": "GPL-2.0-or-later",
"require": {
"drupal/core": "^11.2 || ^12",
"drupal/sites": "1.x-dev"
},
"require-dev": {
"drupal/core-dev": "^11.2 || ^12"
}
}
Declare the setting¶
src/Plugin/SiteSetting/SitesMaintenance.php:
<?php
declare(strict_types=1);
namespace Drupal\sites_maintenance\Plugin\SiteSetting;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\sites\Attribute\SiteSetting;
use Drupal\sites\Plugin\SiteSetting\SiteSettingPluginBase;
use Drupal\sites\SiteSetting\SiteSettingValuesInterface;
/**
* A site's own maintenance message.
*/
#[SiteSetting(
id: 'sites_maintenance',
label: new TranslatableMarkup('Maintenance message'),
description: new TranslatableMarkup('The message visitors of this site see in maintenance mode.'),
weight: 80,
settings: [
'maintenance_message' => [
'label' => new TranslatableMarkup('Maintenance message'),
'default' => '',
'config_target' => 'system.maintenance:message',
],
],
)]
final class SitesMaintenance extends SiteSettingPluginBase implements SiteSettingValuesInterface {
/**
* {@inheritdoc}
*/
public function configOverrides(): array {
$message = $this->resolvedSetting('maintenance_message');
return is_string($message) && trim($message) !== '' ? parent::configOverrides() : [];
}
/**
* {@inheritdoc}
*/
public function buildSettingsForm(array $form, FormStateInterface $form_state): array {
$form['maintenance_message'] = [
'#type' => 'textarea',
'#title' => $this->t('Maintenance message'),
'#description' => $this->t('Leave empty to show the platform message.'),
'#default_value' => $this->settings['maintenance_message'],
];
return $form;
}
/**
* {@inheritdoc}
*/
public function validateValues(array $values): array {
return $this->typeErrors($values);
}
}
- Setting keys share one namespace with the keys of every other plugin, so prefix them. Setting plugins lists the definition keys.
config_targetstores the site's value as a per-site override ofsystem.maintenance:message. Code that reads the configuration on the site's host gets the site's message; the hub keeps the platform's. See Config overrides.configOverrides()returns nothing for an empty value. Without that guard the default''overrides the platform's message on every site.SiteSettingValuesInterfacejudges values outside the form.SiteSettingsInterface::write()(servicesites.site_settings), which the agent tools and launch steps write through, refuses a plugin without it.- The key becomes the token
[site-settings:maintenance_message].
The plugin already has a section: every setting plugin gets one at
/admin/sites/{site}/settings/{plugin id}, with a card in the Settings
group of the site dashboard.
Give it a section of its own¶
A section of its own puts the card into another dashboard group, can sum up
the site's value on the card (SectionSummaryInterface) and can give the form
its own path. The derived section is dropped once a section names the plugin
as its setting_plugin.
sites_maintenance.routing.yml:
sites_maintenance.site_settings:
path: '/admin/sites/{site}/maintenance'
defaults:
_form: 'Drupal\sites\Form\SiteSettingSectionForm'
_title: 'Maintenance message'
requirements:
_sites_admin_section_access: 'maintenance'
options:
_admin_route: TRUE
parameters:
site:
type: 'site'
src/Plugin/SiteAdminSection/MaintenanceSection.php:
<?php
declare(strict_types=1);
namespace Drupal\sites_maintenance\Plugin\SiteAdminSection;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\sites\Access\SitesPermissions;
use Drupal\sites\Attribute\SiteAdminSection;
use Drupal\sites\Form\SiteSettingSectionForm;
use Drupal\sites\Section\DashboardGroup;
use Drupal\sites\Section\SectionCard;
use Drupal\sites\Section\SiteAdminSectionBase;
/**
* A site's maintenance message, edited by the site's people.
*/
#[SiteAdminSection(
id: 'maintenance',
label: new TranslatableMarkup('Maintenance message'),
permissions: [SitesPermissions::MANAGE_SETTINGS],
section_form: SiteSettingSectionForm::class,
setting_plugin: 'sites_maintenance',
no_capability_reason: 'No site settings capability on this site.',
config_surface: TRUE,
dashboard_card: new SectionCard(
route: 'sites_maintenance.site_settings',
group: DashboardGroup::System,
weight: 30,
description: new TranslatableMarkup('The message visitors see in maintenance mode.'),
),
)]
final class MaintenanceSection extends SiteAdminSectionBase {
}
The _sites_admin_section_access requirement opens the route to accounts
that hold one of the section's permissions on the {site}. Without the
routing file, Sites registers the card's route at
/admin/sites/{site}/settings/sites_maintenance. Either way it adds the
translation and environment pages below the form route (…/translate,
…/environments). config_surface: TRUE hides the section on sites whose
type offers none of the plugin's keys. Other section shapes, such as a clone
of a core admin page, are on
Extension points.
A platform configuration form¶
Settings that hold for every site, like those of sites_tagify and
sites_content_overrides, go on a form under Sites configuration:
- Route
{module}.configat/admin/sites/configuration/{name}, with the requirement_sites_real_global_permission: 'administer sites configuration'and the option_admin_route: TRUE. The real-global check ignores permissions a?site_idinjects for a per-site role. - Form class
Drupal\{module}\Form\ConfigForm, form ID{module}_config. - A menu link with
menu_name: sitesand the parentsites.group.system,sites.group.search,sites.group.contentorsites.group.structure.
Test it¶
tests/src/Kernel/MaintenanceMessageTest.php:
<?php
declare(strict_types=1);
namespace Drupal\Tests\sites_maintenance\Kernel;
use Drupal\Tests\sites\Kernel\SitesKernelTestBase;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
/**
* A site's maintenance message overrides the platform's on that site only.
*/
#[RunTestsInSeparateProcesses]
#[Group('sites_maintenance')]
final class MaintenanceMessageTest extends SitesKernelTestBase {
/**
* {@inheritdoc}
*/
protected static $modules = ['sites_maintenance'];
/**
* Each site reads its own message; a site without one, the platform's.
*/
public function testMessageFollowsTheSite(): void {
$this->installConfig(['system']);
$this->config('system.maintenance')->set('message', 'Platform down.')->save();
$this->createSiteEntity('alpha', ['sites_maintenance' => ['maintenance_message' => 'Alpha is down.']]);
$this->createSiteEntity('beta');
$message = fn (): string => $this->container->get('config.factory')->get('system.maintenance')->get('message');
$this->assertSame('Alpha is down.', $this->withSite('alpha', $message));
$this->assertSame('Platform down.', $this->withSite('beta', $message));
}
}
SitesKernelTestBase enables sites and sites_content and installs the
site schemas; createSiteEntity() and withSite() come from its traits. A
functional test of the section extends SitesFunctionalTestBase, as
sites_robotstxt's SitesRobotsTxtSectionTest does. See
Testing.
Publish it¶
- Name: a
sites_*machine name. The generated reference pages list every installedsites_*module with its settings, permissions, tokens and hooks. - Dependencies:
sites:sitesand each module the setting targets in*.info.yml;drupal/sitesand the same modules incomposer.json. The satellites requiredrupal/sitesat1.x-dev. - CI: copy
.gitlab-ci.yml,phpcs.xml.dist,phpstan.neon.distand.cspell-project-words.txtfromsites_robotstxt. A pipeline installs only the project's ownrequireandrequire-dev, so list inrequire-devevery module the tests enable thatrequiredoes not bring in, assites_analyticslistsdrupal/matomoanddrupal/google_tag. The pipeline runs on merge requests; phpcs fails on warnings, including Markdown lines over 80 characters. - Docs: a row in
COVERAGE.mdof the sites_docs project naming the page that owns the feature, then that page, usually under Build > Add capabilities. It opens with a!!! info "Needs …"box linking the module's entry inreference/modules.md.