Skip to content

Queries and Views

Scope your own queries and views to a site, add the Sites filter and column to a view, and know what a site's host does to queries without your help.

The site builder's view of the same options is Blocks and views.

What a site's host does on its own

  • Entity queries. Where the site's Filter listings by the active site setting (filter_views) is on, SitesEntityQueryHooks scopes every entity query of a relationship type with accessCheck(TRUE), JSON:API collections included, to the site's content. The hub and accessCheck(FALSE) queries stay unscoped.
  • Views follow their Filter by site option.
  • Content versions: with Sites content overrides, entity queries and views read the version the site serves (Content versions).

Results that depend on the site vary by the site cache context; a listing of one site's content depends on MembershipCacheTags::site($site_id). See Caching.

QueryableSiteInterface

A Site plugin that can narrow a query to its content implements Drupal\sites\QueryableSiteInterface. SiteWithContentBase implements it with no-op defaults; ContentSite joins the site_content_relationship rows.

Method Does
queryAlter($query, $entity_type) keeps the rows of the site's content
supportsCanonicalQuery(), canonicalQueryAlter($query, $entity_type) keeps the content whose canonical site this is
supportsMultiSiteQuery(), multiSiteQueryAlter($query, $entity_type, $sites, $canonical_only) scopes to several sites of the same class in one join

The methods add joins and conditions only, never ORDER BY or a range: Views combines several sites with UNION, and a UNION branch takes neither. A plugin that overrides queryAlter() keeps multiSiteQueryAlter() in step or answers FALSE in supportsMultiSiteQuery(). ContentSite needs the entity's data table or base table in the query and leaves other queries unchanged.

Scope your own entity query

To scope a query whatever the site's setting says, tag it and call the active site in the tag's alter hook:

$nids = \Drupal::entityQuery('node')
  ->accessCheck(TRUE)
  ->condition('type', 'event')
  ->addTag('my_module_site_scoped')
  ->execute();
namespace Drupal\my_module\Hook;

use Drupal\Core\Database\Query\AlterableInterface;
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\sites\QueryableSiteInterface;
use Drupal\sites\SiteProxyInterface;

final class MyModuleQueryHooks {

  public function __construct(
    private readonly SiteProxyInterface $currentSite,
    private readonly EntityTypeManagerInterface $entityTypeManager,
  ) {}

  #[Hook('query_my_module_site_scoped_alter')]
  public function scopeToSite(AlterableInterface $query): void {
    $site = $this->currentSite->getSite();
    if ($query instanceof SelectInterface && $site instanceof QueryableSiteInterface) {
      $site->queryAlter($query, $this->entityTypeManager->getDefinition($query->getMetaData('entity_type')));
    }
  }

}

On the hub the active site is no QueryableSiteInterface and the query stays unscoped. A database query on the entity's tables takes the same call.

The Filter by site query option

The query extender SitesContentSql (#[ViewsQueryExtend(id: 'sites_content')]) needs the contrib Views Query module, which runs every extender on every SQL view. It adds Filter by site to a view's Query settings, stored per display:

display:
  default:
    display_options:
      query:
        type: views_query
        options:
          sites_content: enabled
Value Scope
'' (Site default) On a site's host: the site's content when its filter_views setting is on, and always for the admin overviews of sites.settings:overview_views. On the hub: the sites chosen in the Sites filter; for an account without a real global administer sites, its own sites.
enabled always the active site, or on the hub the chosen or the account's sites
disabled never

Site default scopes only views of a relationship type. Where scoping applies and no site resolves, for example a forged filter value, the query gets 1 = 0 and lists nothing. Several sites become one join per Site plugin class (multiSiteQueryAlter()) or UNION legs, with DISTINCT against duplicates. The extender adds the site, user.permissions, user.roles, user.sites and url.query_args:canonical_only cache contexts and the membership tags of the sites in reach.

The cache plugin Tag based, per site (sites_tag) drops a cached listing of one site's content only when that site's content changes.

The Sites filter and field

SitesScopedViewsHooks::viewsDataAlter() adds two handlers to the base table of every entity type sites can hold (node, users, media, ...):

  • Sites filter (Drupal\sites\Plugin\views\filter\Sites, id sites): carries the site choice and adds no SQL of its own; the extender reads it. Exposed on the hub, it offers the sites the viewer can access, as a site_select element (Forms), under the identifier site_filter; on a site's host the host is the scope. Expose canonical site checkbox (expose_canonical) adds Originating content only, which switches the scoped sites to canonicalQueryAlter().
  • Sites summary field (Drupal\sites\Plugin\views\field\Sites, id sites): renders the canonical site and a count of the other sites, with the list behind it. It reads sites.membership and adds no SQL.

Both handlers hide themselves off the hub (hide_if_not_default) and for a viewer who can access one site only (hide_if_single); both options are on by default.

The base tables of site-field types (redirects, path aliases) get the filter only: their admin section scopes the listing on the site_id column.