A simple subsite setup in Drupal

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 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.

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.

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 }
<?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();
}

}

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

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store