A simple subsite setup in Drupal

Sean B
6 min readJan 13, 2021

--

For some reason I recently had different clients with a similar need to create subsites in Drupal based on some kind of context in the URL. The context would mostly be defined via a URL prefix, but in some cases this could also be a different domain. Drupal has several modules that could provide functionality related to subsites, like Domains Access, Group and Organic Groups. For contexts defined via a path prefix or domain the Persistant URL module used to be a good pick, but there does not seem to be a stable Drupal 8 release (I did create a Drupal 9 ready fork on github based on amazing work in the issue queue).

Subsite setup based on entities

After a lot of discussion about the implementation of subsites, I found for most clients a simple approach would solve their needs.

  1. Create an entity/bundle to define the subsite. This could be based on a taxonomy vocabulary, a node type, or a group if you need more advanced features like access control for the different subsites.
    - Content for the subsite must be linked to the subsite entity.
    - The subsite entity needs a field to define the path prefix (and/or domain).
    - Other fields can be added as required, like a custom logo, menu etc.
  2. Create a service to fetch the correct subsite entity based on the URL prefix. Depending on the actual needs, you can implement custom blocks, access hooks, condition plugins, and views argument default plugin that can ask the service for the current subsite.

How to create a vocabulary, node type or group is hopefully not very exciting, but I thought the service to determine the current subsite is code worth sharing.

Custom service to fetch the current subsite

Fetching the subsite from the URL is very similar to the way the language is determined for a page. Since a lot of code could depend on the current subsite, we want to determine it as soon in the request as possible. The best way to do that is to create an event subscriber for the KernelEvents::REQUEST event. The request subscriber is responsible for checking the URL and setting the subsite entity in a property on the service. We have build a settings page to allow a content manager to configure a default subsite, but this can also be determined by an empty path prefix, or a boolean field on the subsite entity for example. We simply check if we can match the first part of the current path the a prefix field on the subsite entity.

Besides the event subscriber, we need an inbound and outbound processor to change incoming and outgoing links. This inbound path processor makes sure that subsite path prefix is stripped from the path before trying to match it to a route. The outbound path processor adds the prefix back when links are added to a page, so the subsite prefix is persisted when clicking links in the menu for example.

First define the service in mymodule.services.yml:

services:
mymodule.subsite_manager:
class: Drupal\mymodule\SubsiteManager
arguments: ['@entity_type.manager', '@config.factory']
tags:
- { name: event_subscriber }
# We want our outbound processor to run just after PathProcessorLanguage.
- { name: path_processor_outbound, priority: 90 }
# We want our inbound processor to run just after PathProcessorLanguage.
- { name: path_processor_inbound, priority: 290 }

Then create the service class in /src/SubsiteManager.php:

<?php

namespace Drupal\mymodule;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\PathProcessor\InboundPathProcessorInterface;
use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
use Drupal\Core\Render\BubbleableMetadata;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Get the active subsite from the request based on URL.
*/
class SubsiteManager implements EventSubscriberInterface, OutboundPathProcessorInterface, InboundPathProcessorInterface {

const SUBSITE_ENTITY_TYPE = 'node';

const SUBSITE_PATH_FIELD = 'field_path';

/**
* The entity type manager.
*
*
@var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;

/**
* The configuration factory.
*
*
@var \Drupal\Core\Config\ConfigFactoryInterface
*/
protected $configFactory;

/**
* The active subsite.
*
*
@var \Drupal\Core\Entity\EntityInterface
*/
protected $subsite;

/**
* Contruct the SubsiteManager.
*
*
@param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* The entity type manager.
*
@param \Drupal\Core\Config\ConfigFactoryInterface $configFactory
* The configuration factory.
*/
public function __construct(EntityTypeManagerInterface $entityTypeManager, ConfigFactoryInterface $configFactory) {
$this->entityTypeManager = $entityTypeManager;
$this->configFactory = $configFactory;
}

/**
* {
@inheritdoc}
*/
public static function getSubscribedEvents() {
return [
// Runs as soon as possible in the request but after
// LanguageRequestSubscriber (priority 255) because we want the language
// prefix to come before the subsite prefix.
KernelEvents::REQUEST => ['onKernelRequestSetSubsite', 254],
];
}

/**
* Get the active subsite from the request.
*
*
@param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event
* The event to process.
*/
public function onKernelRequestSetSubsite(GetResponseEvent $event) {
// Set the default subsite as fallback.
if ($default_subsite = $this->getDefaultSubsite()) {
$this->setCurrentSubsite($default_subsite);
}

$request = $event->getRequest();
$request_path = urldecode(trim($request->getPathInfo(), '/'));

// First strip the language when it's processor is available. This is only
// the case when more than 1 language is installed.
if (\Drupal::hasService('path_processor_language')) {
$request_path = \Drupal::service('path_processor_language')->processInbound($request_path, $request);
}

// Get the first part of the path to check if it matches to a subsite page.
$path_args = array_filter(explode('/', $request_path));
$prefix = array_shift($path_args);

if (!$prefix) {
return;
}

// If the prefix matches to a subsite page, set it in the property.
$subsites = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->loadByProperties([
'type' => 'subsite',
static::SUBSITE_PATH_FIELD => $prefix,
]);
$subsite = reset($subsites);
if ($subsite) {
$this->setCurrentSubsite($subsite);
}
}

/**
* {
@inheritdoc}
*/
public function processInbound($path, Request $request) {
// Get the first part of the path to check if it matches to a subsite page.
$path_args = explode('/', trim($path, '/'));
$prefix = array_shift($path_args);

// If we don't have a prefix, or if the prefix is the only thing in the path,
// keep the current path as it is. This is a subsite homepage.
if (!$prefix || $path === '/' . $prefix) {
return $path;
}

// Only when dealing with a subsite page, change the request.
$subsites = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->loadByProperties([
'type' => 'subsite',
static::SUBSITE_PATH_FIELD => $prefix,
]);
$subsite = reset($subsites);
if (!$subsite) {
return $path;
}

return '/' . implode('/', $path_args);
}

/**
* {
@inheritdoc}
*/
public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) {
if (!empty($options['subsite'])) {
$subsite = $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->load($options['subsite']);
$options['prefix'] = $this->addPrefix($options['prefix'], $subsite->get(static::SUBSITE_PATH_FIELD)->value);
}
$subsite = $this->getCurrentSubsite();
if ($subsite && empty($options['subsite']) && !$this->isDefaultSubsite() && $path !== '/' . $subsite->get(static::SUBSITE_PATH_FIELD)->value) {
$options['subsite'] = $subsite->id();
$options['prefix'] = $this->addPrefix($options['prefix'], $subsite->get(static::SUBSITE_PATH_FIELD)->value);
}
return $path;
}

/**
* Create the updated path prefix.
*
*
@param string $existing_prefix
* The existing path prefix.
*
@param string $subsite_prefix
* The Subsite path prefix.
*
*
@return string
* The combined path prefix.
*/
protected function addPrefix($existing_prefix, $subsite_prefix) {
$existing_prefix = trim($existing_prefix, '/');
$subsite_prefix = trim($subsite_prefix, '/');
$combined_prefixes = array_filter([$existing_prefix, $subsite_prefix]);
$prefix = implode('/', $combined_prefixes) . '/';
return $prefix !== '/' ? $prefix : '';
}

/**
* Set the current subsite.
*
*
@param \Drupal\Core\Entity\EntityInterface $subsite
* The current subsite.
*/
public function setCurrentSubsite(EntityInterface $subsite) {
$this->subsite = $subsite;
}

/**
* Get the current subsite.
*
*
@return \Drupal\Core\Entity\EntityInterface
* The current subsite.
*/
public function getCurrentSubsite() {
return $this->subsite;
}

/**
* Get the default subsite.
*/
public function getDefaultSubsite() {
// Fetch a default subsite based on a config page.
$default_subsite_id = $this->configFactory->get('mymodule.settings')->get('default');
return $default_subsite_id ? $this->entityTypeManager->getStorage(static::SUBSITE_ENTITY_TYPE)->load($default_subsite_id) : NULL;
}

/**
* Check if the current subsite is the default subsite.
*/
public function isDefaultSubsite() {
return $this->subsite && $this->getDefaultSubsite()->id() === $this->subsite->id();
}

}

As mentioned before, you can implement custom blocks, access hooks, condition plugins, and views argument default plugin that can ask the service for the current subsite. You can create blocks to show the correct content in the header (like logo or menu) and fetch the correct content in views etc. You can really make it as complex as needed.

--

--

Sean B

Freelance Drupal Developer. Passionate about Open Source. Loves working on challenging projects with talented people. Maintainer of Media in Drupal 8 core.