roots/wordpress-core-installer
Composer installer for WordPress core that lets you install WordPress into a custom directory, such as Bedrock's web/wp, instead of the default wordpress folder. Keeps core separate from themes and plugins for cleaner, more maintainable sites.
The package is a Composer plugin that makes WordPress core installable as a first-class dependency — like vendor/ code but in a web-facing directory. It's the glue that lets roots/wordpress (or a fork) land in web/wp instead of vendor/.
Minimal setup, three steps:
roots/wordpress:composer require roots/wordpress roots/wordpress-core-installer
composer install prints a plugin-skipped warning and installs nothing useful:"config": {
"allow-plugins": {
"roots/wordpress-core-installer": true
}
}
extra.installer-paths:"extra": {
"installer-paths": {
"web/wp": ["roots/wordpress"]
}
}
Run composer install and confirm web/wp/wp-includes/version.php exists. If web/wp is empty and vendor/roots/wordpress is full of wp-admin, the plugin didn't load.
Where to look first. The installer's behavior is a ~100-line class at vendor/roots/wordpress-core-installer/src/WordPressCoreInstaller.php — reading it once removes all mystery. After that, your own web/wp-config.php and web/index.php (Bedrock convention) are the files you'll touch day to day. composer show roots/wordpress reports the installed core version; composer diagnose catches allow-plugins mistakes.
First use case. Take an existing hand-managed WordPress site, move its core files out of git, and pin core versions in composer.lock. Or start from Bedrock, where this config is already the default and the only thing you add is the plugin entry (Bedrock still ships it because not every project pulls in the meta-package).
Bedrock-shaped baseline. The canonical config pairs this installer with composer/installers so plugins and themes also become Composer packages:
"extra": {
"installer-paths": {
"web/wp": ["roots/wordpress"],
"web/app/plugins/{$name}/": ["type:wordpress-plugin"],
"web/app/themes/{$name}/": ["type:wordpress-theme"]
}
}
The roots/wordpress package ships core without a default wp-content, which is exactly why web/app exists as a sibling — content lives outside the installed core and survives composer update.
Update workflow. Core upgrades become Composer operations, not dashboard clicks:
composer update roots/wordpress # bump WP core
composer update roots/wordpress-core-installer # bump the installer itself
git diff composer.lock # review the version jump before merging
Pin to a minor line for production ("roots/wordpress": "6.5.*") so a background composer update doesn't drag you onto a brand-new WP release mid-sprint.
CI/CD and Docker. In a build stage:
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader --no-interaction
COPY . .
RUN composer dump-autoload --classmap-authoritative
--no-scripts skips post-install-cmd hooks but the plugin still runs because it's a Composer plugin, not a script. Cache vendor/ and web/wp/ between builds — core doesn't change unless composer.lock does.
WP-CLI. Add a project-level wp-cli.yml with path: web/wp so wp plugin list, wp db export, etc. find the install. Without it, every wp command errors with "This does not seem to be a WordPress installation" even though the files are right there.
Multiple WP instances. You can point different packages to different paths ("web/wp-site-a", "web/wp-site-b") as long as each declares type: wordpress-core and installer-paths names it explicitly. Rare, but it works — the installer resolves paths per-package.
Custom forks. If you maintain a private WP mirror, set "type": "wordpress-core" in its composer.json and reference it as a vcs or path repository. The installer keys off that type; a fork missing it installs to vendor/ and leaves web/wp empty with a confusing silent failure.
The plugin must exist before the package installs. Composer loads plugins during dependency resolution, so having both in require from a clean state works. But add roots/wordpress first, install, then realize you need the installer — and you'll have core sitting in vendor/. The fix is usually rm -rf vendor web/wp composer.lock && composer install, because moving an already-installed package is unreliable.
allow-plugins is mandatory. This is the single most common failure mode. Without it, Composer prints roots/wordpress-core-installer contains a Composer plugin which is blocked and installs nothing. Non-interactive CI fails silently if allow-plugins isn't committed to composer.json.
Don't stack with johnpbloch/wordpress-core-installer. Both plugins claim type: wordpress-core. Pick one — the Roots version is the maintained fork and is what roots/wordpress targets.
installer-paths values must be arrays, even for a single package: ["roots/wordpress"], not "roots/wordpress". Typos in the key (missing trailing slash, wrong case) cause Composer to fall back to vendor/ with no error.
Changing the path after install doesn't move files. Composer tracks installed packages by version, not path, so bumping installer-paths alone leaves old files in place. Nuke the old directory (rm -rf web/wp) and reinstall, or run composer update roots/wordpress if a version change coincides.
Core files aren't magic. The package installs wp-admin, wp-includes, index.php, wp-settings.php, wp-load.php etc. It does not install wp-config.php and does not ship a default content directory. Bedrock supplies web/wp-config.php (which requires ../config/application.php) and web/index.php (which bootstraps wp-blog-header.php from web/wp). Keep those in sync with your install path — otherwise web/index.php 404s or fatal-errors on a missing wp-load.php.
Keep the installer in require, not require-dev. Production installs with --no-dev need the installer to route core into web/wp. Commit composer.lock as well, or every environment rebuilds with a different core version.
Gitignore web/wp/ and vendor/. Both are Composer artifacts. The web/wp directory is a build output just like vendor — treat them identically in your .gitignore.
Debugging installs. composer install -vvv logs which installer handles which package (Installing roots/wordpress ... into web/wp). composer config allow-plugins lists the current allowlist. composer show roots/wordpress --all shows available versions when composer update is being stubborn.
Extension points. The installer deliberately does one thing. Post-install work (symlinking wp-content across environments, clearing opcaches, generating salts) belongs in scripts.post-install-cmd / scripts.post-update-cmd, not a forked installer. If you truly need custom logic in the install path, extend Roots\WordPressCoreInstaller and register your subclass as a plugin — but this is rare enough that you should question it.
Version jumps are real WP upgrades. composer update roots/wordpress from 6.4.* to 6.5.* is a WordPress major-version migration. Run your plugin/theme regression suite before deploying. The installer makes the mechanic painless; it doesn't make the upgrade free.
How can I help you explore Laravel packages today?