Skip to content

Extension points

Add a row to the launch checklist, a card to the site dashboard, a per-site admin section, an entry in the Sites status report, or a token.

To add Ship Read
A launch checklist row a #[LaunchStep] plugin sites/src/Plugin/LaunchStep/FrontPage.php
A per-site admin section a #[SiteAdminSection] plugin sites_redirect, sites_robotstxt, sites_analytics
A dashboard card the section's dashboard_card, or hook_site_dashboard_alter() sites_redirect's RedirectSection
A status report entry hook_sites_status() sites_menu's SitesMenuStatusHooks
A token a setting key, or hook_tokens() on current-site SitesTokensHooks

The plugin managers and hooks are listed on Services, plugins, hooks, events. The dashboard, the checklist and the status report render in sites_ui; the APIs live in sites.

Launch steps

A launch step is one row of the checklist on a site's dashboard. The manager plugin.manager.sites_launch_step (LaunchStepPluginManagerInterface) discovers #[LaunchStep] plugins in src/Plugin/LaunchStep/, keeps those whose applies() answers TRUE for the site and the viewer, and orders them by weight, then id. LaunchStepBase answers applies() with TRUE and has no link; a step implements isSatisfied(), which reads the site's current state. NULL means the state cannot be read: the row shows and counts toward neither side of the progress. The dashboard is cached with the tags of the sites, the site's stored overrides, memberships and role assignments. A step whose isSatisfied() reads anything else (a config object, an entity, a probe that is good for a while) implements CacheableLaunchStepInterface and returns that dependency from stateCacheability(SiteInterface $site): CacheableDependencyInterface; the checklist card carries it, together with the access results of the rows' links:

public function isSatisfied(SiteInterface $site): bool {
  return (bool) $this->configFactory->get('newsletter.sent')->get($site->id());
}

public function stateCacheability(SiteInterface $site): CacheableDependencyInterface {
  return $this->configFactory->get('newsletter.sent');
}

A plain step (LaunchStepInterface) links elsewhere with url() and action(), like dns. A guided step (GuidedLaunchStepInterface) adds fields and a submit that sites_ui wraps in a dialog at /admin/sites/{site}/launch-step/{step}, gated by Manage site settings:

<?php

declare(strict_types=1);

namespace Drupal\sites_maintenance\Plugin\LaunchStep;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\sites\Attribute\LaunchStep;
use Drupal\sites\LaunchStep\GuidedLaunchStepInterface;
use Drupal\sites\LaunchStep\LaunchStepBase;
use Drupal\sites\LaunchStep\LaunchStepInterface;
use Drupal\sites\Section\SettingLayer;
use Drupal\sites\SiteInterface;
use Drupal\sites\SiteSetting\SiteSettingsInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Writes the site's maintenance message from the dashboard.
 */
#[LaunchStep(
  id: 'maintenance_message',
  label: new TranslatableMarkup('Write the maintenance message'),
  weight: 50,
)]
final class MaintenanceMessage extends LaunchStepBase implements GuidedLaunchStepInterface, ContainerFactoryPluginInterface {

  public function __construct(
    array $configuration,
    $plugin_id,
    $plugin_definition,
    private readonly SiteSettingsInterface $settings,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
    return new static($configuration, $plugin_id, $plugin_definition, $container->get('sites.site_settings'));
  }

  /**
   * {@inheritdoc}
   */
  public function isSatisfied(SiteInterface $site): bool {
    return trim((string) $site->getSetting('maintenance_message')) !== '';
  }

  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state, SiteInterface $site): array {
    $form['message'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Maintenance message'),
      '#default_value' => $site->getSetting('maintenance_message'),
      '#required' => TRUE,
    ];
    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state, SiteInterface $site): void {
    $layer = SettingLayer::base((string) $site->id());
    $values = ['maintenance_message' => (string) $form_state->getValue('message')];
    if ($errors = $this->settings->writeErrors('sites_maintenance', $layer, $values)) {
      $form_state->set(LaunchStepInterface::OUTCOME, implode(' ', $errors));
      return;
    }
    $this->settings->write('sites_maintenance', $layer, $values, 'Maintenance message set.');
    $form_state->set(LaunchStepInterface::OUTCOME, $this->t('The maintenance message is set.'));
  }

}

$form holds only the step's elements; the form object, the actions and the dialog belong to sites_ui. The string under LaunchStepInterface::OUTCOME shows as a message after the submit. A step that does not apply to a site has no reachable form there. hook_sites_launch_step_info_alter() alters the definitions.

Site admin sections

A section is an admin surface of one site under /admin/sites/{site}/…. A #[SiteAdminSection] plugin in src/Plugin/SiteAdminSection/ extending SiteAdminSectionBase declares everything on its attribute; the docblock of Drupal\sites\Attribute\SiteAdminSection describes each argument. Three shapes cover the shipped sections:

Shape Declares Example
Setting section setting_plugin, section_form: SiteSettingSectionForm::class sites_analytics (tutorial)
Clone of a core admin page section_routes (SectionRoute(base, path, core_gate)), collection_route sites_robotstxt, sites_menu
Entity section entity_type, scoped_entity_access, scoped_views, collection_view, route_form_overrides, entity_handlers sites_redirect

Every section route carries _sites_admin_section_access: <id>; the cloned routes get it from Sites. The check allows an account holding any of the section's permissions on the site, platform-wide or through a per-site role. core_gate: FALSE drops the core route's own _permission from a clone.

The collection_route becomes the entry point of the section. On a site's host it serves the current site's section. On the hub it sends a delegate of one site into that site's section and offers a delegate of several sites a site chooser. A platform administrator gets the aggregate list of a content section, and for a configuration section (config_surface: TRUE) the chooser plus Global, the platform's own page. A configuration section of config entities (menus, vocabularies) implements ownsConfigEntity(), which decides which of them are the site's. hook_site_admin_section_info_alter() alters the definitions.

Dashboard cards

A section's card is its dashboard_card: a SectionCard with the route it opens, a DashboardGroup (Content, Structure, Search, Regional, People, System, Settings, Reports), a weight and a description. The dashboard shows the card when the viewer may open the route. A section that implements SectionSummaryInterface adds a line under the title from dashboardSummary(); the render array carries the cache tags of what it read.

Anything else on the dashboard goes through hook_site_dashboard_alter(array &$build, SiteInterface $site). $build holds site (the Site panel), launch_checklist and cards.

Status report entries

The Sites status report at /admin/sites/reports/status collects hook_sites_status(): entries in core's requirements format, keyed by a module-prefixed string, with a RequirementSeverity case. hook_sites_status_alter() changes another module's entries.

<?php

declare(strict_types=1);

namespace Drupal\sites_maintenance\Hook;

use Drupal\Core\Extension\Requirement\RequirementSeverity;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\State\StateInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;

/**
 * Reports the maintenance mode on the Sites status report.
 */
final class SitesMaintenanceStatusHooks {

  use StringTranslationTrait;

  public function __construct(
    private readonly StateInterface $state,
  ) {}

  /**
   * Implements hook_sites_status().
   */
  #[Hook('sites_status')]
  public function sitesStatus(): array {
    $on = (bool) $this->state->get('system.maintenance_mode');
    return [
      'sites_maintenance_mode' => [
        'title' => $this->t('Maintenance mode'),
        'value' => $on ? $this->t('On: every site shows its maintenance message.') : $this->t('Off'),
        'severity' => $on ? RequirementSeverity::Warning : RequirementSeverity::OK,
      ],
    ];
  }

}

Tokens

Each setting key is a token without code: [site-settings:KEY], and [current-site:settings:KEY] for the site the page is on. The key's label names it; a sensitive key has none. A token computed from more than one value is core's hook_token_info() and hook_tokens() on the current-site type. Read the site from $data['site']; without it, read current_site and add the site cache context to the bubbleable metadata, as SitesTokensHooks::tokens() does. The token list is generated from the installed modules.