Skip to content

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.