Site-aware entity types¶
Make your own entity type belong to sites, read and change what belongs where, and bind content that is created outside an entity form.
Membership and the canonical site are explained on Concepts; the site builder's view is Content on sites.
The membership API¶
sites.membership (Drupal\sites\Membership\SiteMembershipInterface,
autowirable) answers for every entity type, whatever stores its sites. Sites
are named by their plugin id (machine name).
$membership = \Drupal::service('sites.membership');
$membership->sitesOf($node); // ['acme', 'globex']
$membership->canonicalSiteOf($node); // 'acme', or NULL for memberless content
$membership->contains('globex', $node); // TRUE
$membership->add('globex', $node); // the first membership becomes canonical
$membership->add('globex', $node, TRUE); // add and make it canonical
$membership->setCanonical('acme', $node); // move the canonical site
$membership->remove('globex', $node); // removing the canonical promotes the oldest other site
$membership->preload('node', $nids); // one query for a listing
The write methods throw \InvalidArgumentException for an unsaved entity or
a site no storage holds; canStore($site_id, $entity_type_id) asks first.
sites.membership checks no access. Code acting for a person asks
sites.content_assignment first:
$site = \Drupal::service('plugin.manager.site')->getSite('globex');
if ($site instanceof SiteWithContentInterface
&& \Drupal::service('sites.content_assignment')->addAccess($node, $site, $account)->isAllowed()) {
$membership->add('globex', $node);
}
removeAccess() and canonicalAccess() answer the other writes; a
refusal carries its reason.
Choose a binding¶
An entity type joins sites through one of three storages:
| Binding | Sites per entity | Stored in | How a type joins |
|---|---|---|---|
| Relationship | any number, one canonical | site_content_relationship rows (Sites content) |
hook_sites_content_entity_types_alter() |
| Site field | exactly one | the entity's site_id base field |
the entity class implements SiteIdFieldEntityInterface |
| Config entity | one or more | a third-party setting | a ConfigEntitySiteMembershipStorage service |
Relationship rows¶
A content entity type with a canonical link template joins through an
alter hook:
namespace Drupal\my_module\Hook;
use Drupal\Core\Hook\Attribute\Hook;
final class MyModuleSitesHooks {
#[Hook('sites_content_entity_types_alter')]
public function contentEntityTypesAlter(array &$entity_type_ids): void {
$entity_type_ids['my_entity'] = 'my_entity';
}
}
The hook runs while site settings resolve, so it must not load sites. Real
implementation: llms_txt_sites/src/Hook/LlmsTxtSitesHooks.php.
The type then gets what nodes have: Assignable content rows on the site
type, the Sites widget, the per-bundle Add … content to a site and
Remove … content from a site permissions, scoped Views and
ContentEvent. An entity on no site answers
404 on the hosts of the sites that take its bundle and waits on the
unassigned-content report. To make site-less entities of the type available
on every site, also implement
hook_sites_shared_pool_content_entity_types_alter().
The site_id field¶
A type whose rows belong to exactly one site, like redirects and path aliases, implements the interface and uses the trait:
namespace Drupal\my_module\Entity;
use Drupal\Core\Entity\ContentEntityBase;
use Drupal\sites\Entity\SiteIdFieldEntityInterface;
use Drupal\sites\Entity\SiteIdFieldTrait;
class MyEntity extends ContentEntityBase implements SiteIdFieldEntityInterface {
use SiteIdFieldTrait;
}
Sites core adds the site_id base field, fills it with the active site when
an entity is created off the hub, and counts site-less rows (site_id NULL)
on the status report. Listing the type in a section's
scoped_entity_access adds the SiteScopedContent constraint and the
section's access rules. A membership write is an entity save and dispatches
no ContentEvent.
To adopt another module's type, swap its class in
hook_entity_type_build(), as SitesRedirectEntityTypeHooks does. Sites
installs the site_id storage when the adopting module is installed, also
when Sites and the type's module are installed already, and uninstalls it,
with its data, when the adopting module is uninstalled. The adopting module
needs no .install file for it.
Config entities¶
A config entity names its sites in a third-party setting. Register one
storage per type, as sites_webform.services.yml does:
services:
my_module.membership_storage:
class: Drupal\sites\Membership\ConfigEntitySiteMembershipStorage
arguments: ['@entity_type.manager', my_config_entity, my_module, sites, true, false]
tags:
- { name: sites.membership_storage }
The arguments: entity type id, settings provider, settings key, whether the setting is a list, whether an entity on no site is available on every site.
The content setting per bundle¶
For relationship types, the content site setting holds one key per bundle:
content:{entity_type}:{bundle}, or content:{entity_type} for a type
without bundles. Its value is -1 (unlimited sites, the default), 1 (a
single site, the default for taxonomy terms and menu links) or 0 (not
assignable). It is set on the site type and per site
(settings reference). Read
it through sites.content_support (getContentCardinality(),
isSingleSiteContent(), acceptedBundles()) or a site's getContentMap().
Content created without a form¶
An entity form writes the selection of its Sites widget. For every other
write, SiteContentAssignmentInterface::bindToCreationSite() adds a content
entity inserted on a site's host to that site, as canonical, when:
- the entity is not a user and not marked syncing (
isSyncing()); - the site's content map accepts its bundle;
- a storage can hold the site's memberships.
This covers JSON:API and REST POST requests on a site's host, code calling
save(), and Drush with a site's URL:
drush --uri=https://acme.example.com php:script create-pages.php
On the hub nothing is bound. Core's migrate destinations mark entities as syncing, so a migration binds nothing; assign migrated content yourself:
namespace Drupal\my_module\EventSubscriber;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\migrate\Event\MigrateEvents;
use Drupal\migrate\Event\MigratePostRowSaveEvent;
use Drupal\sites\Membership\SiteMembershipInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
final class BindMigratedContent implements EventSubscriberInterface {
public function __construct(
private readonly SiteMembershipInterface $membership,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public static function getSubscribedEvents(): array {
return [MigrateEvents::POST_ROW_SAVE => 'onPostRowSave'];
}
public function onPostRowSave(MigratePostRowSaveEvent $event): void {
$site_id = $event->getRow()->getSourceProperty('site');
$node = $this->entityTypeManager->getStorage('node')->load($event->getDestinationIdValues()[0]);
if ($site_id && $node) {
$this->membership->add($site_id, $node, TRUE);
}
}
}
Site-field types get their site at creation, so a migration run with --uri
files their rows on that site.
React to membership changes¶
After each membership change of a relationship type, sites.membership
dispatches Drupal\sites\Event\ContentEvent under ADD_EVENT_NAME,
REMOVE_EVENT_NAME or CANONICAL_EVENT_NAME
(events). getSite() and getEntity() name
the site and the entity; after a canonical move, getPreviousSite() names
the former canonical site.
public static function getSubscribedEvents(): array {
return [ContentEvent::REMOVE_EVENT_NAME => 'onRemove'];
}
public function onRemove(ContentEvent $event): void {
$site_id = (string) $event->getSite()->id();
$entity = $event->getEntity();
// Drop what this module keeps for $entity on $site_id.
}
Real subscribers: AliasCleanupOnRemovalSubscriber in sites_path_alias,
CanonicalSiteSubscriber in sites_content_overrides.