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 untilrevert()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).