Skip to content

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_target stores the site's value as a per-site override of system.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.
  • SiteSettingValuesInterface judges values outside the form. SiteSettingsInterface::write() (service sites.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}.config at /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_id injects for a per-site role.
  • Form class Drupal\{module}\Form\ConfigForm, form ID {module}_config.
  • A menu link with menu_name: sites and the parent sites.group.system, sites.group.search, sites.group.content or sites.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 installed sites_* module with its settings, permissions, tokens and hooks.
  • Dependencies: sites:sites and each module the setting targets in *.info.yml; drupal/sites and the same modules in composer.json. The satellites require drupal/sites at 1.x-dev.
  • CI: copy .gitlab-ci.yml, phpcs.xml.dist, phpstan.neon.dist and .cspell-project-words.txt from sites_robotstxt. A pipeline installs only the project's own require and require-dev, so list in require-dev every module the tests enable that require does not bring in, as sites_analytics lists drupal/matomo and drupal/google_tag. The pipeline runs on merge requests; phpcs fails on warnings, including Markdown lines over 80 characters.
  • Docs: a row in COVERAGE.md of 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 in reference/modules.md.