webflo/drupal-finder
A small PHP utility that locates the root of a Drupal installation from any directory, supporting both Drupal 7 and 8+. It detects the Drupal version, core path, and site directory, making it easy for tools like Drush, Composer plugins, and custom scripts to work reliably across project layouts.
webflo/drupal-finder does one thing: given a starting directory, it walks up the tree until it finds a Drupal root (and the Composer root that contains it). That is the entire surface area — locateRoot() plus three getters. It exists so that tools, scripts and Composer plugins can answer "where is Drupal?" without hardcoding web/, docroot/ or assuming the CWD is the project root.
Install it as a dev dependency:
composer require --dev webflo/drupal-finder
First use case — a script that must work regardless of whether the project uses web/, docroot/ or the project root as docroot:
<?php
require __DIR__ . '/vendor/autoload.php';
use DrupalFinder\DrupalFinder;
$finder = new DrupalFinder();
if (!$finder->locateRoot(getcwd())) {
fwrite(STDERR, "Not inside a Drupal project\n");
exit(1);
}
$drupalRoot = $finder->getDrupalRoot(); // .../web (or project root)
$composerRoot = $finder->getComposerRoot(); // .../ (where composer.json lives)
$vendorDir = $finder->getVendorDir(); // .../vendor
echo "Drupal: $drupalRoot\n";
Where to look first: read src/DrupalFinder.php end to end — it is roughly a hundred lines. Understanding the marker files it checks is more valuable than any documentation:
core/lib/Drupal.phpincludes/bootstrap.inccomposer.jsonvendor/autoload.php (or the vendor-dir config)If you are writing a package that itself lives in vendor/, look at src/DrupalFinderComposerRuntime.php instead — it derives the starting path from Composer's registered autoloaders so it does not depend on getcwd().
Validate once, then pass the root around. The finder is cheap but not free, and its state is per-instance. Resolve at the entry point of your CLI command and hand the plain string paths to the rest of your code:
$finder = new DrupalFinder();
$finder->locateRoot(getcwd());
$drupalRoot = $finder->getDrupalRoot();
// Fail fast with an actionable message rather than letting nulls leak inward.
if ($drupalRoot === false) {
throw new RuntimeException(
'Could not locate a Drupal root from ' . getcwd() . '. Run with --root=/path/to/drupal.'
);
}
Add an explicit --root escape hatch. Discovery is a convenience, not a contract. Every tool worth its salt lets the user override:
$start = $options['root'] ?? getcwd();
$finder->locateRoot($start);
Inside a vendor-installed tool, use DrupalFinderComposerRuntime. It resolves the vendor directory via Composer\Autoload\ClassLoader::getRegisteredLoaders() and locates upward from there, so it works when the tool is invoked from a subdirectory, a git hook, or a Makefile:
use DrupalFinder\DrupalFinderComposerRuntime;
$finder = new DrupalFinderComposerRuntime();
$drupalRoot = $finder->getDrupalRoot();
Composer script / plugin integration. For a Composer plugin, resolve the root from the event's getComposer()->getConfig()->get('vendor-dir') parent rather than getcwd(), or simply use the runtime finder inside a post-autoload-dump handler. For plain scripts:
"scripts": {
"drupal:cache-rebuild": "MyTool\\Scripts::rebuild"
}
and inside Scripts::rebuild() call new DrupalFinderComposerRuntime().
Derive paths from the right root. Two roots, two purposes:
| Need | Use |
|---|---|
Site files, core/, sites/default/settings.php, web-relative URLs |
getDrupalRoot() |
composer.json, vendor/, patches, project-level config, phpunit.xml at project root |
getComposerRoot() |
A common bug is joining sites/default onto the Composer root in a docroot-style project, or joining vendor/autoload.php onto the Drupal root in a project where vendor sits one level up.
Wrap it behind your own interface. The library has essentially no extension points, so if you need caching, logging or fallback behaviour, write a five-line adapter that memoises the result. This also insulates you from API churn.
Shell usage. The package ships a bin/drupal-finder executable that prints the discovered Drupal root — handy in shell scripts and CI:
cd web/sites/default && ../../../vendor/bin/drupal-finder
locateRoot() return value and getter types have shifted across versions. Depending on the release, getDrupalRoot() may return an empty string, null, or false when nothing was found. Always compare against false/empty explicitly, pin the constraint (^1.0) and check the changelog before upgrading. Never rely on truthiness of a path string alone.DrupalFinder instance retains the first result; calling locateRoot() again with a different path on the same instance is unreliable. Create a new instance per lookup.composer.json files break discovery. The walk stops at the first composer.json it encounters going upward, so a sub-package or a stray composer.json inside your docroot can yield the wrong Composer root. If you support monorepos, prefer passing the known project root in explicitly.composer install completes there is no core/lib/Drupal.php, so you get a Composer root but no Drupal root. Distinguish "no project at all" from "project not yet installed" in your error messages — the fix is different.DrupalFinderComposerRuntime requires the Composer 2 autoloader to be loaded and the package to live in the same vendor directory as the project. It does not work for globally installed (composer global) tools; fall back to getcwd() there.getVendorDir() is not getComposerRoot() . '/vendor'. Respect the returned value — projects legitimately override vendor-dir, and the getter can be false when vendor/autoload.php is absent even though composer.json was found.includes/bootstrap.inc). A stale or vendored copy of that file inside a subdirectory can misidentify the root; pass a precise start path.realpath()/str_replace('\\', '/', ...) before comparing or emitting them.require-dev dependency in an application is unproblematic, but do not link it into a distributed proprietary binary without reviewing the obligations.drupal-finder only for your own standalone scripts and Composer plugins, so you are not reimplementing detection badly or duplicating a dependency someone else already installed.How can I help you explore Laravel packages today?