Skip to main content

Bundled extensions

Your project is 1 extension, and it can bundle others. A theme can bring a module that provides its blocks, and a module can bring a theme for its tests. Either can carry extensions that only its tests use. Eddy doesn't set this up for you: the configurations the installer generates name only your extension's type, so bundling another extension takes a few manual steps, which this page walks through.

Where they go​

Put each bundled extension in a directory of its own. The following table lists the directories, relative to the root of your project:

DirectoryWhat goes thereIn a release
modules/<name>/A module that ships with your extensionYes
themes/<name>/A theme that ships with your extensionYes
tests/modules/<name>/A module that only your tests useNo
tests/themes/<name>/A theme that only your tests useNo

While you keep PHPUnit, .gitattributes keeps tests/ out of the release archives that Drupal.org packages, so a site that installs a release of yours doesn't get the test-only extensions. Without PHPUnit, the installer removes tests/ and its .gitattributes entry, so a tests/ directory you add later ships in your releases until you add tests export-ignore to .gitattributes.

How Drupal finds them​

assemble assembles the site in the build directory, and the paths on this page that start with web/ are relative to it. It creates your extension's directory at web/modules/custom/<your_extension>, or web/themes/custom/<your_extension> for a theme. The directory holds a symlink to every top-level item of your project apart from build, vendor and hidden items, so a bundled extension arrives inside yours.

Drupal finds extensions at any depth below the site's web/modules and web/themes directories, and it reads each one's type from its own .info.yml. A module inside a theme's directory is found as a module, and a theme inside a module's directory as a theme. Drupal core relies on this too: the System module holds dozens of test themes.

So there's no mapping step. Adding one anyway, such as a second symlink to the same module in web/modules/custom, makes Drupal find the module twice and keep 1 copy, with no warning.

A few limits apply:

  • Drupal doesn't look inside hidden directories or directories named src, lib, vendor, assets, css, files, images, js, misc, templates, includes, fixtures, Drupal or config. The site's settings add node_modules and bower_components to that list. A bundled extension can't live in any of them.
  • Outside test runs, Drupal skips tests/ directories, unless $settings['extension_discovery_scan_tests'] is TRUE. A test-only extension exists for PHPUnit but not for your development site, so put one you also need on the site in your project's modules/ or themes/ directory instead.
  • Machine names are shared by the whole site, so give a bundled extension a name no other extension uses. Of 2 extensions of the same type that share 1 name, Drupal keeps 1 and drops the other with no warning. When a module and a theme share 1 name, the one that installs second fails.

Enable a bundled extension on your site​

provision enables your extension, the drupal/* extensions in suggest and the extensions of the install profile, and nothing else. To enable a bundled extension, add a provision-*.sh lifecycle script to scripts/, which runs after your extension is enabled:

scripts/provision-bundled.sh
#!/usr/bin/env bash
##
# Enable the module that the theme bundles.

set -eu

build/vendor/bin/drush -l "$(vendor/bin/eddy-info site-url)" pm:enable my_theme_blocks -y

Make the script executable with chmod +x. For a bundled theme, use theme:enable instead of pm:enable.

A theme can't list a bundled module in its dependencies. provision enables the theme before your script runs, and Drupal stops with an error when a theme's modules aren't installed yet. Leave the module out of dependencies and enable it from the script. Without the dependency, a site that installs a release of your theme doesn't get the module enabled either, so tell the maintainers of those sites to enable the module, such as in your README.

Use a bundled extension in tests​

Test runs find extensions under tests/ too, so a test uses a bundled extension the way it uses any other. A Kernel test lists a bundled module in $modules:

protected static $modules = ['my_theme_test'];

To install a bundled theme in a Kernel test:

  1. List system in $modules.

  2. Call $this->installConfig(['system']) in setUp().

  3. Install the theme through the theme installer, in setUp() after the installConfig() call or in a test method:

    $this->container->get('theme_installer')->install(['my_module_test_theme']);

A Functional test can also set $defaultTheme to a bundled theme.

What the tooling covers​

The tool configurations name only the custom directory of your extension's type, web/modules/custom or web/themes/custom, and everything you bundle sits under your extension inside it. That decides what runs without changes:

  • PHPCS, PHPStan, Rector, Twig CS Fixer, ESLint and Stylelint check your bundled extensions along with your own code, and CSpell checks the whole project apart from hidden paths.
  • PHPUnit runs the tests in your extension's tests/src only. A bundled extension's own tests run after you add their directories to the testsuites of phpunit.xml, such as web/themes/custom/*/modules/*/tests/src/Unit, and to phpunit.d10.xml when you target Drupal 10. Add the matching tests directory to the <exclude> of the coverage source as well, so the test classes don't count as uncovered code.
  • PHPUnit counts the .php files of a bundled extension toward coverage, and the hook files of a bundled extension of your own type: .module and .install in a module, .theme in a theme. Other files, such as .inc files, don't count. A bundled theme's .theme file in a module, or a bundled module's .module and .install files in a theme, count only after you add a <directory suffix=".theme">, <directory suffix=".module"> or <directory suffix=".install"> line for them.
  • Jest runs the *.test.js files in the js/ directory of each extension directly inside the dirs of jest.config.js. To run a bundled extension's tests, add the directory that holds it, such as web/themes/custom/my_theme/modules, to dirs.

assemble copies these configuration files into build, so run it again after you edit one.

The PHP and JavaScript tools check nothing outside your extension's type directory in the site, and CSpell checks only your project, so an extension of the other type that comes in through suggest can't fail your checks.