Composer-Based WordPress
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages. |
WordPress has no dependency manager of its own, yet many teams manage a site like any PHP application: composer.json
lists core, every plugin and every third-party theme, a committed composer.lock records the exact versions, and
composer install rebuilds the same site on a laptop, in CI and in production. Composer itself (constraints, the lock
file, autoloading, repositories) is explained in Composer and
Packages; this page covers what is specific to WordPress, using the Lumen Press site as the example.
The page was written against WordPress 7.1.3 (released 2026-10-06), PHP 7.4 minimum and 8.3+ recommended, Composer
2.10.3, roots/wordpress-core-installer 4.0.0, composer/installers 2.3.0, vlucas/phpdotenv 5.8.0, Roots Bedrock
1.31.7 and WP-CLI 2.12.0, and checked on 2026-10-28. That day WordPress 7.1.4 shipped as a security release (one fix
closes a remote code execution weakness in the upgrade process), so the Lumen Press project pins 7.1.4; 7.2 is in beta,
due on 8 December 2026. Every command was run in Docker with composer:2 (PHP 8.5), wordpress:cli-2.12.0-php8.4 and
mariadb:11.8.
Why manage a site with Composer
| Concern | Dashboard-managed site | Composer-managed site |
|---|---|---|
Who changes code |
Any Administrator, on the live server |
A developer, through a commit and a deployment |
Versions in each environment |
Whatever was last clicked there |
Exactly the versions in |
What is in Git |
Usually only the custom theme and plugins |
|
Rolling back |
Restore files from a backup |
Deploy the previous commit |
Private and commercial plugins |
Uploaded as ZIP files by hand |
Installed from a Git or private Composer repository |
Security updates |
Applied automatically by WordPress |
Applied by the team, through a lock-file update and a deploy |
The last row is the price: someone must watch for releases and ship them (Dependabot or Renovate help). The approach suits teams that already deploy through CI/CD; a site run by its editors alone is better served by automatic updates.
WordPress core as a Composer package
WordPress is not published on Packagist by the WordPress project. Two community packagings exist, and both are actively maintained:
| Package | What it installs | Status on 2026-10-28 |
|---|---|---|
|
|
Maintained by Roots; built by the open-source |
|
Core with the default themes, Akismet and Hello Dolly, plus beta and RC releases |
Same maintainer; for projects that want the standard download |
|
Core (with the default content) from a Git mirror of WordPress, plus |
Still maintained (7.1.3 was published on its release day; 7.1.4 was not yet listed a few hours after its release) and common in older projects; Bedrock uses |
WordPress cannot run from vendor/: the core installer plugin puts it in the directory named by
extra.wordpress-install-dir (default wordpress/). Because Composer 2.2+ refuses to run plugins that are not listed in
config.allow-plugins, the core installer and composer/installers must both be allowed, or the install stops with
"composer/installers contains a Composer plugin which is blocked by your allow-plugins config". The no-content build
has no wp-content directory, so the site defines its own (see The Lumen Press project) and installs a theme.
composer/installers and installer-paths
Plugins and themes are ordinary Composer packages whose type is one of the WordPress types known to
composer/installers, which moves them out of vendor/: wordpress-plugin to wp-content/plugins/{$name}/,
wordpress-theme to wp-content/themes/{$name}/, wordpress-muplugin to wp-content/mu-plugins/{$name}/ and
wordpress-dropin to wp-content/{$name}/ (relative to the project root).
extra.installer-paths overrides the defaults. Each key is a path pattern using {$name}, {$vendor} and
{$type}; each value lists what it applies to: a package name, a type: selector or a vendor: selector. The first
matching pattern wins, so a pattern that moves one plugin to mu-plugins must come before the general plugin rule.
installer-name in a package’s own extra renames its folder, and installer-disable in the root package turns
installers off. A wordpress-dropin lands in a folder named after the package, not as a single file in
wp-content, so drop-ins such as object-cache.php are normally copied by their plugin instead (for example by
wp redis enable, see Options, Transients and Object
Cache).
Plugins and themes from the WordPress.org directory
The plugin and theme directories are Subversion repositories, not Composer repositories. Two services mirror them as Composer repositories, with different vendor names:
| Aspect | WP Packages | WPackagist |
|---|---|---|
Repository URL |
|
|
Package names |
|
|
Operator |
Roots (the Bedrock maintainers); the default repository in Bedrock 1.31 |
The long-standing original; its ownership moved to WP Engine in March 2026, according to Roots |
Refresh |
Every five minutes, Composer 2 |
Scans the directory every hour, by its own description |
Untagged plugins ( |
Pinned to an SVN revision, so the lock file is reproducible |
A mutable trunk ZIP, so the same lock file can install different code |
Plugins closed on WordPress.org |
Removed, as on WordPress.org; listed on a status page and a closures API |
Kept installable |
Source: WP Packages: Documentation
The rows come from the WP Packages comparison and WPackagist’s home
page. Closed plugins matter most: one closed for a vulnerability can keep installing from a lock file without warning.
Moving from one to the other means replacing the repository and re-requiring each package under its new vendor name
(composer remove wpackagist-plugin/two-factor, then composer require wp-plugin/two-factor); WP Packages also
publishes a migration script.
Three properties of these mirrors are easy to miss:
-
Versions are the plugin’s SVN tags. A plugin whose current release exists only in trunk can be installed only as
dev-trunk, which defeats the point of a lock file; prefer plugins that tag their releases. -
The dist URL is the official
downloads.wordpress.orgZIP, and the lock file records no checksum for it, unlikeroots/wordpress-no-content. The lock file pins the version, not the bytes. -
composer auditknows only advisories filed against Packagist package names, not WordPress plugins and themes. The WP Sec Adv repository (https://repo-wpsecadv.typist.tech), documented by Roots, adds advisories from the Wordfence Intelligence feed so thataudit,requireandupdatesee them.
The Lumen Press project
Lumen Press keeps its site in one Git repository. Core goes to public/wp, content to public/content, and the
document root is public; vendor/, .env and composer.json sit one level above, out of the web server’s reach.
lumen-press-site/
├── composer.json, composer.lock, .env.example, .gitignore
├── .env # NOT committed: local values and secrets
├── wp-cli.yml # path: public/wp, server docroot: public
├── vendor/ # Composer: autoloader, phpdotenv
└── public/ # document root
├── index.php # committed: front controller
├── wp-config.php # committed: reads the environment
├── wp/ # Composer: WordPress core
└── content/ # WP_CONTENT_DIR
├── mu-plugins/
│ ├── lumen-mu-loader.php # committed
│ └── two-factor/ # Composer
├── plugins/ # Composer: lumen-catalog, redis-cache, query-monitor (dev)
├── themes/lumen/ # committed: the site's own block theme
└── uploads/ # content, never in Git
composer.json
{
"name": "lumenpress/lumen-press-site",
"description": "The Lumen Press website: WordPress core, plugins and themes managed with Composer.",
"type": "project",
"license": "proprietary",
"repositories": [
{
"name": "lumen-catalog",
"type": "vcs",
"url": "https://git.lumenpress.example/lumen-catalog"
},
{
"name": "wp-packages",
"type": "composer",
"url": "https://repo.wp-packages.org",
"only": ["wp-plugin/*", "wp-theme/*"]
}
],
"require": {
"php": ">=8.4",
"composer/installers": "^2.3",
"lumenpress/lumen-catalog": "^0.1",
"roots/wordpress": "7.1.4",
"vlucas/phpdotenv": "^5.6",
"wp-plugin/redis-cache": "^3.0",
"wp-plugin/two-factor": "^0.17"
},
"require-dev": {
"wp-plugin/query-monitor": "^4.1"
},
"extra": {
"wordpress-install-dir": "public/wp",
"installer-paths": {
"public/content/mu-plugins/{$name}/": ["type:wordpress-muplugin", "wp-plugin/two-factor"],
"public/content/plugins/{$name}/": ["type:wordpress-plugin"],
"public/content/themes/{$name}/": ["type:wordpress-theme"]
}
},
"config": {
"platform": {
"php": "8.4.0"
},
"optimize-autoloader": true,
"sort-packages": true,
"allow-plugins": {
"composer/installers": true,
"roots/wordpress-core-installer": true
}
},
"minimum-stability": "stable"
}
The choices behind it:
-
Core is pinned exactly (
7.1.4), so a core upgrade is always a deliberate commit.composer validatewarns about exact constraints; that warning is expected here. -
Plugins use caret ranges; the lock file still fixes the exact version (the install resolved Redis Object Cache 3.0.0, Two Factor 0.17.0 and Query Monitor 4.1.1).
-
onlyrestricts the mirror towp-plugin/*andwp-theme/*, so a package with the same name as an internal one can never be served from it (see "Filtering packages" in Repository priorities). -
Two Factor is forced into
mu-pluginsby listing it before the general plugin rule: no Editor or Administrator can deactivate two-factor login from the dashboard. -
Query Monitor is a development dependency, absent from
--no-devinstalls;config.platform.phpis the production PHP version, so a laptop with a newer PHP never locks a version that production cannot run.
wp-config.php and .env
WordPress looks for wp-config.php in ABSPATH and one directory above it, so public/wp-config.php is found for core
in public/wp/. It loads the Composer autoloader, reads an optional .env file with vlucas/phpdotenv, and moves the
content directory with WP_CONTENT_DIR and WP_CONTENT_URL (both are required, as absolute path and full URL without
a trailing slash; see wp-config.php Reference):
<?php
// Lumen Press: one level above core (public/wp), so WordPress finds it. Values come from the environment or ../.env.
$lumen_root = dirname( __DIR__ );
require_once $lumen_root . '/vendor/autoload.php';
if ( is_readable( $lumen_root . '/.env' ) ) {
// Real environment variables win over the file; the file never overwrites them.
$lumen_dotenv = Dotenv\Dotenv::createImmutable( $lumen_root );
$lumen_dotenv->load();
$lumen_dotenv->required( array( 'DB_NAME', 'DB_USER', 'DB_PASSWORD', 'DB_HOST', 'WP_HOME' ) )->notEmpty();
$lumen_dotenv->required( 'WP_ENVIRONMENT_TYPE' )->allowedValues( array( 'local', 'development', 'staging', 'production' ) );
}
define( 'DB_NAME', $_ENV['DB_NAME'] ?? getenv( 'DB_NAME' ) );
define( 'DB_USER', $_ENV['DB_USER'] ?? getenv( 'DB_USER' ) );
define( 'DB_PASSWORD', $_ENV['DB_PASSWORD'] ?? getenv( 'DB_PASSWORD' ) );
define( 'DB_HOST', $_ENV['DB_HOST'] ?? getenv( 'DB_HOST' ) );
define( 'DB_CHARSET', 'utf8mb4' );
define( 'DB_COLLATE', '' );
$table_prefix = 'wp_';
foreach ( array( 'AUTH_KEY', 'SECURE_AUTH_KEY', 'LOGGED_IN_KEY', 'NONCE_KEY', 'AUTH_SALT', 'SECURE_AUTH_SALT', 'LOGGED_IN_SALT', 'NONCE_SALT' ) as $lumen_key ) {
define( $lumen_key, $_ENV[ $lumen_key ] ?? getenv( $lumen_key ) );
}
$lumen_home = rtrim( $_ENV['WP_HOME'] ?? getenv( 'WP_HOME' ), '/' );
define( 'WP_ENVIRONMENT_TYPE', $_ENV['WP_ENVIRONMENT_TYPE'] ?? getenv( 'WP_ENVIRONMENT_TYPE' ) ?: 'production' );
define( 'WP_HOME', $lumen_home );
define( 'WP_SITEURL', $lumen_home . '/wp' );
define( 'WP_CONTENT_DIR', __DIR__ . '/content' );
define( 'WP_CONTENT_URL', $lumen_home . '/content' );
// Composer owns the code: no dashboard installs, updates or file editing; core updates arrive as a new composer.lock.
define( 'DISALLOW_FILE_MODS', true );
define( 'AUTOMATIC_UPDATER_DISABLED', true );
define( 'WP_DEBUG', 'production' !== WP_ENVIRONMENT_TYPE );
define( 'WP_DEBUG_DISPLAY', false );
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/wp/' );
}
require_once ABSPATH . 'wp-settings.php';
Dotenv::createImmutable() writes to $_ENV and $_SERVER but not to getenv(), and never overwrites a variable
the server already set. Production can therefore skip the file entirely and receive the same names from PHP-FPM, Docker
or Kubernetes; the $_ENV[...] ?? getenv() pairs read either source ($_ENV is empty for real variables when
variables_order lacks E, as in the production php.ini). ABSPATH is defined here only because core lives in a
sub-folder; the general rules for reading secrets from the environment and passing them to PHP-FPM are on
wp-config.php Reference.
The committed .env.example lists the same names with placeholder values (change-me, generate-me) and the hint to
generate each key with openssl rand -hex 32; developers copy it to .env. public/index.php is the usual front
controller of the "own directory" method: it defines WP_USE_THEMES and requires wp/wp-blog-header.php.
Private plugins from a Git repository
lumen-catalog is not published anywhere public. Its own repository at https://git.lumenpress.example/lumen-catalog
holds the composer.json shown in Plugin
Fundamentals; for the site, three fields matter: name (lumenpress/lumen-catalog), type (wordpress-plugin) and
the PSR-4 autoload rule. Each release is a Git tag (0.1.0), which the vcs repository turns into a version.
Composer reads the credentials for that host from auth.json (written by composer config
http-basic.git.lumenpress.example deploy-bot "$LUMEN_GIT_TOKEN" and ignored by Git) or from the COMPOSER_AUTH
environment variable in CI, never from composer.json (see
Composer: Authentication for privately
hosted packages).
Installed by the site, the plugin has no vendor/ folder of its own: the site’s autoloader maps
Lumen\Catalog\ to public/content/plugins/lumen-catalog/src. A main file that stopped with an admin notice
whenever its own vendor/autoload.php was missing would break here, so the one in Plugin Fundamentals checks for the
class first and works both from the release ZIP and from a Composer-managed site:
use Lumen\Catalog\Plugin;
defined( 'ABSPATH' ) || exit;
// Release ZIP: the plugin's own vendor/. Composer-managed site: the site's autoloader already knows the classes.
if ( ! class_exists( Plugin::class ) ) {
if ( ! is_readable( __DIR__ . '/vendor/autoload.php' ) ) {
return; // After adding the "missing vendor/" admin notice shown in Plugin Fundamentals.
}
require_once __DIR__ . '/vendor/autoload.php';
}
add_action( 'plugins_loaded', array( Plugin::class, 'boot' ) );
Source: Composer: Repositories, VCS
A plain Git server offers no ZIP archives, so Composer clones the plugin and leaves a .git folder in its directory;
the test server served /content/plugins/lumen-catalog/.git/HEAD with HTTP 200. Deny dot files in the web server (see
Web Servers: Apache and nginx) or host the plugin where Composer
can download archives (GitHub, GitLab, Private Packagist, Satis). Keep its Update URI header, so WordPress never
offers a same-named directory plugin as an update.
Must-use plugins in sub-folders
WordPress loads only the PHP files placed directly in mu-plugins/; it never looks inside sub-folders. A
wordpress-muplugin package (or Two Factor, forced there by installer-paths) lands in its own folder and is silently
ignored until a single-file loader includes it. Lumen Press commits this loader:
<?php
/**
* Plugin Name: Lumen MU Loader
* Description: Loads the must-use plugins that Composer installs into their own folders.
*/
defined( 'ABSPATH' ) || exit;
// Plugins may write options while loading; skip them until WordPress is installed.
if ( ! is_blog_installed() ) {
return;
}
// WordPress loads only the PHP files directly inside mu-plugins/, never files in sub-folders.
foreach ( array( 'two-factor/two-factor.php' ) as $lumen_mu_plugin ) {
$lumen_mu_file = WPMU_PLUGIN_DIR . '/' . $lumen_mu_plugin;
if ( is_readable( $lumen_mu_file ) ) {
require_once $lumen_mu_file;
} else {
error_log( "Lumen MU Loader: missing {$lumen_mu_plugin}; run composer install." );
}
}
The is_blog_installed() guard is not decoration: without it, wp core install on an empty database printed a "Table
'lumen_press.wp_options' doesn’t exist" error, because Two Factor calls register_uninstall_hook() (which writes an
option) as soon as it loads. Must-use plugins also never run activation, deactivation or uninstall hooks, so a plugin
that creates tables or roles on activation needs that step run another way. Bedrock’s autoloader (below) discovers the
folders with get_plugins(), caches the list in the bedrock_autoloader site option and fires activate_{$plugin}
once for each newly found plugin, which covers the common cases without a hand-written list.
Installing, serving and installing WordPress
From the project root, with the database from
Local Development Environments reachable as db:
docker run --rm -v "$PWD":/app -w /app composer:2 composer validate --no-check-all --strict
docker run --rm -v "$PWD":/app -w /app -e COMPOSER_AUTH composer:2 composer install
cp .env.example .env # then set the database password and generate the eight keys
# WP-CLI reads path and docroot from wp-cli.yml.
docker run --rm --network lumen -v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 \
wp core install --url=http://lumenpress.test:8080 --title="Lumen Press" \
--admin_user=lp_admin --admin_email=webmaster@lumenpress.example --skip-email
docker run --rm --network lumen -v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 \
sh -c 'wp theme activate lumen && wp plugin activate lumen-catalog redis-cache query-monitor'
docker run -d --name lumen-web --network lumen --network-alias lumenpress.test -p 8080:8080 \
-v "$PWD":/app -w /app wordpress:cli-2.12.0-php8.4 wp server --host=0.0.0.0 --port=8080
Source: WP-CLI Commands: wp server
The install placed 14 packages, each in the folder of the tree above, and WP-CLI reported:
$ wp plugin list
name status update version update_version auto_update
lumen-catalog active none 0.1.0 off
query-monitor active none 4.1.1 off
redis-cache active none 3.0.0 off
lumen-mu-loader must-use off
Source: WP-CLI Commands: wp core install
wp core version printed 7.1.4, WP_CONTENT_DIR was /app/public/content and Two_Factor_Core was loaded. Served by
wp server, / and /wp/wp-login.php answered 200, theme files came from /content/themes/lumen/ and core assets
from /wp/wp-includes/; /.env was not reachable, because it is outside the document root. In production the document
root of nginx or Apache is public/, as described in Web Servers:
Apache and nginx.
composer.lock and deterministic deploys
composer update resolves constraints and rewrites composer.lock; composer install reads only the lock file and
installs exactly what it lists. The rule for a WordPress site is the same as for any application: update on a
developer machine or in a dependency-update pull request, commit the lock file, and only ever install in CI and on
servers.
composer validate --no-check-all --strict # composer.json valid and lock file in sync
composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
composer check-platform-reqs --no-dev # the server's PHP and extensions satisfy the lock file
composer audit --locked # known advisories (add WP Sec Adv for plugins and themes)
A release build from a fresh clone installed 13 packages (Query Monitor absent) at the same versions as the developer install. Two failure modes were checked on purpose:
-
A lock file out of sync. After adding a plugin to
composer.jsonby hand without updating,composer installstopped with "The lock file is not up to date with the latest changes in composer.json" and exit code 4, andcomposer validatereported the same as a lock-file error. Add packages withcomposer require, which updates both files together. -
--strictand exact versions.composer validate --strictreturned 1 only because of the exactroots/wordpressconstraint;--no-check-allskips the constraint checks while keeping schema and lock checks strict.
Upgrading core is a one-package update, committed like any other change. With Bedrock’s default 7.1.3:
composer require roots/wordpress:7.1.4 --with-all-dependencies
wp core update-db
Both roots/wordpress packages moved to 7.1.4 and wp core update-db found the database already at version 61833.
composer audit reported no advisory for 7.1.3 at that moment, so it cannot be the only signal for security releases.
Three things work against the lock file and must be closed off:
-
The dashboard.
DISALLOW_FILE_MODSremoves the install, update and delete screens and turns automatic updates off (see Security Hardening). Without it, an update clicked in production is undone by the next deploy. -
WP-CLI. The constant does not bind WP-CLI:
wp plugin install hello-dollysucceeded on the test site withDISALLOW_FILE_MODSset. Anything installed that way is untracked and disappears on the next deploy. -
Background updates and Git. A site checked out with Git has a
.gitfolder abovepublic/wp, which WordPress treats as a version-control checkout and so skips automatic updates even without the constant (see Updates and Auto-Updates).
What to commit and what not
| Path | In Git | Why |
|---|---|---|
|
Yes |
The site’s dependency list and its exact resolution |
|
Yes |
Configuration code without secrets |
Own theme ( |
Yes |
Code that exists only in this repository |
|
No |
Rebuilt from the lock file |
|
No |
Content or runtime files; backed up, not versioned |
|
Never |
Secrets and repository credentials |
/vendor/
/public/wp/
/public/content/plugins/*
/public/content/mu-plugins/*/
/public/content/themes/*
!/public/content/themes/lumen/
/public/content/uploads/
/public/content/upgrade/
/public/content/cache/
/public/content/object-cache.php
*.log
.env
.env.*
!.env.example
auth.json
Source: Git: gitignore
With this file, git add -A on the installed project staged exactly eleven files: the two Composer files,
.env.example, .gitignore, wp-cli.yml, public/index.php, public/wp-config.php, the loader and the three theme
files. mu-plugins/*/ ignores Composer’s folders while keeping single-file must-use plugins tracked. A
third-party theme from the directory would be required as wp-theme/<slug> and stay ignored; the custom theme is
kept in the repository, as Roots also recommends.
The Roots Bedrock layout
Bedrock is the best-known ready-made Composer layout for WordPress, maintained by Roots under the MIT licence. It is an ecosystem project, not part of WordPress, and requires PHP 8.3 or later. It is created as a new project from a pinned release:
docker run --rm -v "$PWD":/app -w /app composer:2 composer create-project roots/bedrock:1.31.7 lumen-bedrock
Source: Bedrock: Installation
Bedrock 1.31.7 locked roots/wordpress 7.1.3, roots/bedrock-autoloader 1.1.0, roots/bedrock-disallow-indexing
2.1.0, roots/wp-config 1.0.0, vlucas/phpdotenv 5.8.0, oscarotero/env and wp-theme/twentytwentyfive 1.5 from WP
Packages, plus Pest and Laravel Pint for development. The layout:
lumen-bedrock/
├── composer.json, composer.lock, .env.example, wp-cli.yml
├── config/
│ ├── application.php # the real configuration (wp-config.php equivalent)
│ └── environments/ # development.php, staging.php overrides
├── vendor/
└── web/ # document root
├── index.php
├── wp-config.php # loader: autoload, config/application.php, wp-settings.php
├── wp/ # WordPress core (Composer)
└── app/ # WP_CONTENT_DIR: mu-plugins, plugins, themes, uploads
Source: Bedrock: Folder Structure
How Bedrock’s pieces map to the concepts on this page:
| Piece | Behaviour |
|---|---|
|
Loads |
|
|
Content directory |
|
Production defaults |
|
|
Collects |
|
Discourages search engines whenever |
Source: Bedrock: Environment Variables
With a .env holding the database settings, WP_ENV='development', WP_SITEURL="${WP_HOME}/wp" and eight
generated keys, wp core install succeeded; WP-CLI reported WP_CONTENT_DIR /app/web/app, environment type
development and version 7.1.3, and wp server answered 200 with theme assets under /app/themes/twentytwentyfive/.
Existing sites can be converted with the Lithify WP-CLI command that Roots documents, or by hand with a search-replace
of wp-content paths (see Importing, Exporting and Migrating).
Errors and pitfalls
| Symptom | Cause and fix |
|---|---|
"composer/installers contains a Composer plugin which is blocked by your allow-plugins config" |
Composer 2.2+ needs |
A plugin lands in the wrong folder |
|
A must-use plugin does nothing |
It sits in a sub-folder of |
White screen, theme assets 404 |
|
"Your requirements could not be resolved" for a plugin |
The plugin has no SVN tag for that version (only |
|
|
A Bedrock staging site is indexed and shows no debug output |
|
Where the books fall short
None of WordPress All-in-One For Dummies, Pro WordPress or the Ultimate WordPress Handbook runs a site with
Composer: plugins and themes are installed from the dashboard or by upload, routes a Composer site closes on purpose.
The Dummies book presents DISALLOW_FILE_MODS as only switching updates off, whereas it removes installing, updating
and deleting altogether, which is why Composer sites set it. Pro WordPress changes ABSPATH after moving
wp-config.php; the "one level up" move needs no change, and the layouts here define it only because core has its own
sub-folder.