Skip to content

Satellite APIs

Write a site's content override from code, attach your own per-site data to overrides, add a clone method, and build on the ECA plugins.

The other satellites add no API beyond the core extension points. Hooks and services are listed on Services, plugins, hooks, events.

Content overrides

An override is a non-default revision of an entity that carries the site's id in its site_id base field and, per language, the names of the fields the site changed in site_override_fields (the pins). The revision is a full snapshot: the canonical revision with the pinned fields copied over. A site serves its newest override revision; the hub and the entity's canonical site serve the canonical revision; Content versions covers reading it. SiteOverrideEntityTypesInterface::getEnabledEntityTypeIds() (service sites_content_overrides.entity_types) names the entity types that can carry overrides.

flowchart LR
  canonical["Canonical revision"] --> snapshot["Override revision<br/>site_id: acme<br/>site_override_fields: title"]
  pinned["Pinned fields<br/>of the edit"] --> snapshot
  snapshot -- served on --> acme["acme's host"]
  canonical -- served on --> hub["The hub and<br/>the canonical site"]

Write an override

SitesContentOverridesServiceInterface (service sites_content_overrides.service) backs the override form and the agent tools. The OverrideSet tool runs this sequence:

<?php

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\sites\SiteInterface;
use Drupal\sites_content_overrides\SitesContentOverridesServiceInterface;

/**
 * Gives a site its own title of an entity.
 */
function my_module_override_title(SitesContentOverridesServiceInterface $overrides, ContentEntityInterface $entity, SiteInterface $site, AccountInterface $account, string $title): bool {
  if (!$overrides->overrideAccess($entity, $site, $account)->isAllowed()
    || $overrides->overrideErrors($entity, $site, ['title'], [], $account) !== []) {
    return FALSE;
  }
  $editable = $overrides->loadEditableOverride($entity, $site);
  $editable->set('title', $title);
  if ($overrides->validateOverride($editable, $site, ['title'])->count() > 0) {
    return FALSE;
  }
  $overrides->saveOverride($editable, $site, []);
  return TRUE;
}
  • overrideAccess() allows a site the entity belongs to, other than its canonical site, where the account holds Override site content.
  • overrideErrors() reports fields the site may not override or the account may not edit; validateOverride() runs the entity's constraints in the site's context, so permissions held through a per-site role count.
  • saveOverride() pins the fields that differ from the canonical revision. A pin stays until revert() lifts it.
  • The write invalidates the entity's and the site's override tags; the entity's own tags stay valid, so other sites keep their cached pages.

SiteAwareEntityResolverInterface (service sites_content_overrides.site_aware_entity_resolver) handles the revisions: adoptOverrideAsCanonical() makes a site's override canonical, deleteSiteOverrideRevisions() drops it, and writeForSite() wraps a save of a site's revision made elsewhere, like a moderation transition.

Add per-site data to an override

An aspect is per-site data about an entity that a module keeps itself, like a site's URL alias. A diverging aspect counts as an override, gets a checkbox on the revert form, and is read and written through aspectValues() and setAspect(). Four hooks declare one, each answering only for its own ids:

<?php

declare(strict_types=1);

namespace Drupal\my_module\Hook;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\KeyValueStore\KeyValueStoreInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\sites\SiteInterface;

/**
 * A "featured on this site" flag as an override aspect.
 */
final class FeaturedAspectHooks {

  use StringTranslationTrait;

  public function __construct(
    private readonly KeyValueFactoryInterface $keyValue,
  ) {}

  #[Hook('sites_content_overrides_aspects')]
  public function aspects(ContentEntityInterface $entity, SiteInterface $site): array {
    return [
      'my_module_featured' => [
        'label' => $this->t('Featured'),
        'form_sections' => [['my_module_featured']],
        'overridden' => !$entity->isNew() && $this->store()->has($this->key($entity, $site)),
      ],
    ];
  }

  #[Hook('sites_content_overrides_aspect_values')]
  public function aspectValues(ContentEntityInterface $entity, SiteInterface $site): array {
    return ['my_module_featured' => ['on' => (bool) $this->store()->get($this->key($entity, $site), FALSE)]];
  }

  #[Hook('sites_content_overrides_set_aspect')]
  public function setAspect(string $aspect_id, ContentEntityInterface $entity, SiteInterface $site, array $value): bool {
    if ($aspect_id !== 'my_module_featured') {
      return FALSE;
    }
    $this->store()->set($this->key($entity, $site), !empty($value['on']));
    return TRUE;
  }

  #[Hook('sites_content_overrides_revert_aspect')]
  public function revertAspect(string $aspect_id, ContentEntityInterface $entity, SiteInterface $site): void {
    if ($aspect_id === 'my_module_featured') {
      $this->store()->delete($this->key($entity, $site));
    }
  }

  private function store(): KeyValueStoreInterface {
    return $this->keyValue->get('my_module.featured');
  }

  private function key(ContentEntityInterface $entity, SiteInterface $site): string {
    return $site->id() . ':' . $entity->getEntityTypeId() . ':' . $entity->id();
  }

}

form_sections names the #parents of the elements the module adds to the override form with a form alter, so their submit handlers receive values. setAspect() refuses the canonical site and invalidates the override tags after the owner acts. Shipped implementation: sites_simple_sitemap's SitesSimpleSitemapOverrideAspectHooks.

Other hooks

To Implement Shipped example
Override below a field (one key of a map field) hook_sites_content_overrides_pins_alter(), _pin_label_alter(), _snapshot_alter() sites_content_overrides_behaviors
Serve another revision than the newest override hook_sites_content_overrides_effective_override_alter() sites_content_overrides_content_moderation
Count something else as an override hook_sites_content_overrides_has_override_for_site_alter() sites_content_overrides_content_moderation
Make a field overridable or not hook_is_sites_content_overrides_field_overrideable_alter()
Add an operation to a site's row in the overrides list hook_sites_content_overrides_operation_alter() sites_diff

Clone methods

A site can be cloned when its plugin implements CloneableSiteInterface (cloneSite() makes the new site) and ManagedSiteInterface with a site entity, as ContentSite does. For any other site the clone form answers 404.

A clone takes each content type with one clone method: a #[SitesCloneMethod] plugin in src/Plugin/SitesCloneMethod/ (manager plugin.manager.sites_clone_method). The clone batch calls getIdsByEntityBundle() for a bundle, then cloneEntity() per id. SitesCloneMethodPluginBase lists the source site's ids and provides shareEntity(), which adds the clone to the entity's sites and records the mapping. A copying method calls recordMapping() itself, so later steps (page settings, menu links) find the copy through CloneMapping (service sites_clone.mapping).

<?php

declare(strict_types=1);

namespace Drupal\my_module\Plugin\SitesCloneMethod;

use Drupal\Core\Entity\EntityPublishedInterface;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\sites_clone\Attribute\SitesCloneMethod;
use Drupal\sites_clone\SitesCloneMethodPluginBase;

/**
 * Shares the published content and leaves the rest out.
 */
#[SitesCloneMethod(
  id: 'share_published',
  label: new TranslatableMarkup('Share published content'),
  description: new TranslatableMarkup('Shares the published content with the new site and leaves unpublished content out.'),
)]
final class SharePublished extends SitesCloneMethodPluginBase {

  /**
   * {@inheritdoc}
   */
  public function supportsEntityType(string $entity_type_id): bool {
    return $this->entityTypeManager->getDefinition($entity_type_id)->entityClassImplements(EntityPublishedInterface::class);
  }

  /**
   * {@inheritdoc}
   */
  public function cloneEntity(string $entity_type_id, string|int $entity_id): void {
    $entity = $this->entityTypeManager->getStorage($entity_type_id)->load($entity_id);
    if ($entity instanceof EntityPublishedInterface && $entity->isPublished()) {
      $this->shareEntity($entity_type_id, $entity_id);
    }
  }

}

supportsEntityType() decides where the clone form and the Cloning rules offer the method. Options for one run go in buildConfigurationForm(); the configuration carries entity_type_id and bundle (see CloneContent). SiteCloner (service sites_clone.cloner) runs a clone from code: clone() creates the disabled site, batch() returns the batch that fills it. The clone form, drush sites:clone and the sites_clone:site_clone tool call both.

Preview

sites_preview (Drupal\sites_preview\SitesPreviewServiceInterface, service sites_preview.service) answers whether the request is a preview (isPreviewing()), which site it previews (getPreviewedSite()), and the path prefix of a site's preview (getPrefix('acme') is /preview/acme). Two URL options steer generated URLs:

// The page in globex's preview; on globex's own host, the plain page.
$url = $node->toUrl()->setOption('sites_preview_site', 'globex');
// Out of the preview, onto the hub.
$url = Url::fromRoute('my_module.report')->setOption('skip_sites_preview', TRUE);

URLs pinned to a site with the site_id option and admin routes leave the preview without it. sites_preview_link declares Preview Link's preview_link theme hook again, with an extra links variable and its own copy of preview-link.html.twig; keep that template in step with Preview Link 2.x.

ECA plugins

sites_eca adds three actions and one condition, and no events. The site-builder view is on Automation with ECA.

Plugin id Class Uses
sites_determine_entity_sites (action) DetermineEntitySites sites.membership: canonicalSiteOf(), sitesOf()
sites_load_user_sites (action) LoadUserSites sites.user_access: getSitesForUser() with an operation of SiteWithContentInterface::CONTENT_OPERATIONS
eca_switch_site (action) SwitchSite site_switcher: switchTo() in execute(), switchBack() in cleanupAfterSuccessors()
sites_is_default_site (condition) IsHubSite current_site: isHub()

Token data for a site is ['id' => $site->id()] plus SiteSettingPluginManagerInterface::redact($site, $site->getSettings()), which replaces each sensitive value with (redacted). An action of your own that switches the site implements ECA's CleanupInterface as SwitchSite does, so the actions after it run as that site. Everywhere else, use site_switcher's call() (The current site).