DDEV add-on providing code quality validation commands for Drupal module development. The commands and their default configurations match the Drupal GitLab CI template from the Drupal Association, so code that passes locally will pass in CI.
All tools are installed at container build time — they are available immediately after ddev restart with no per-start installation overhead.
There is overlap with other Drupal-focused DDEV add-ons. The key difference is the project type each one is designed for.
| Add-on | Best for | Typical project layout |
|---|---|---|
UltraBob/ddev-drupal-code-quality |
Full Drupal website projects where your site repo already contains Drupal code and custom code. | Existing site/project repo; installs code-quality configs and IDE shims in-place. |
mandclu/ddev-module-developer |
Drupal contrib module/theme development but compatible with working on multiple projects in a single DDEV environment. | Standard Drupal or Drupal CMS install, with contrib projects cloned in manually or using other strategies like the --prefer-source composer flag. |
ddev/ddev-drupal-contrib |
Drupal contrib module/theme development where the contrib project is the center of the repo. | Contrib project repo with Drupal scaffolded around it (symlink workflow). |
justafish/ddev-drupal-core-dev / joachim-n/ddev-drupal-core-dev |
Drupal core development. | Drupal core checkout or core-dev project template. |
- If you haven't already, install Docker and DDEV.
- Set up a Drupal project with DDEV, either by following the Drupal CMS quickstart or the Drupal core quickstart.
- Add this add-on and restart:
ddev add-on get mandclu/ddev-module-developer
ddev restart- Optional — for
eslintandstylelintto use Drupal core's own configuration files (the same ones used in CI), install core's JavaScript dependencies:
ddev exec "cd web/core && yarn install"Without this step both commands still work using the bundled fallback configurations.
This add-on provides the following DDEV commands, all running inside the web container.
ddev checks— Run all Drupal GitLab CI checks in sequence and print a summary. See Running all checks below.ddev checks-fixes— Auto-fix code style violations by runningphpcbf,eslint --fix, andstylelint --fixin sequence. See Automatically fix coding standard violations below.ddev parallel-lint— Run php-parallel-lint to check PHP files for syntax errors.ddev phpcs— Run PHP_CodeSniffer against Drupal coding standards.ddev phpcbf— Auto-fix phpcs violations using PHP Code Beautifier and Fixer.ddev phpstan— Run PHPStan static analysis with the Drupal extension.ddev phpmd— Run PHP Mess Detector.ddev rector— Run Drupal Rector to find and fix deprecated API usage.ddev phpunit— Run PHPUnit tests.ddev stylelint— Run Stylelint on CSS/SCSS files.ddev eslint— Run ESLint on JavaScript and YAML files, with Prettier formatting checks.ddev cspell— Run CSpell spell-checking across project files.
Pass a path as the first argument to any command to target a specific file or directory:
ddev checks web/modules/custom/mymodule
ddev parallel-lint web/modules/custom/mymodule
ddev phpcs web/modules/custom/mymodule
ddev phpstan analyse web/modules/custom/mymodule
ddev phpunit web/modules/custom/mymodule
ddev stylelint 'web/modules/custom/mymodule/**/*.css'
ddev eslint web/modules/custom/mymodule/js
ddev phpmd web/modules/custom/mymodule textRun without a path argument from inside a module directory to target that module:
cd web/modules/custom/mymodule
ddev checks
ddev phpcs
ddev phpunitIn addition to relative paths, every command accepts an absolute path on the host as an argument and rewrites it to the equivalent path inside the container. This is convenient for IDE "external tools", file watchers, and AI agents that naturally work with full host paths:
ddev phpcs /Users/me/Sites/myproject/web/modules/custom/mymodule
ddev phpstan analyse /Users/me/Sites/myproject/web/modules/custom/mymoduleThis behavior comes from module-developer/lib/init.sh, which every command
sources as its first step (init.sh is also the home for any future shared
utilities). The match is host-root agnostic — because the host project path is not
known inside the container, it strips leading path components until the remainder
resolves under the project root, choosing the longest (most specific) match. It is
a no-op for relative paths, for flags, and for absolute paths that do not resolve
under the project root.
Note
Two limitations follow from matching by suffix:
- Glob wildcards (e.g.
/abs/path/**/*.cssforstylelint) are left untouched, since the path cannot be matched as it stands. Pass those patterns relative to the project root instead. - Coincidental suffixes: an absolute path that does not exist in the container
but whose trailing component happens to equal a top-level project entry (e.g.
/unrelated/webmatching the docroot) is rewritten to that entry rather than passed through. For paths that are not inside the project, pass them relative orcdto them rather than passing an unrelated absolute path.
ddev checks runs the same sequence of code quality jobs that the Drupal GitLab Templates pipeline runs, in the same order:
- PHP Lint (
parallel-lint) — fast syntax check; catches parse errors before heavier tools run. - PHP CodeSniffer — Drupal coding standards.
- PHPStan — static analysis.
- ESLint — JavaScript and YAML formatting.
- Stylelint — CSS/SCSS validation.
- CSpell — spell checking.
- PHPUnit — unit and functional tests, but only when all preceding checks pass and the Drupal test bootstrap is installed. Automatically skipped otherwise.
Each tool prints its normal output inline. At the end, a summary shows the result for every check:
══════════════════════════════════════════════════════
✓ PHP Lint
✓ PHP CodeSniffer
✓ PHPStan
✓ ESLint
✓ Stylelint
✓ CSpell
- PHPUnit (skipped — Drupal test bootstrap not installed)
Result: passed (1 skipped, 6 passed)
ddev checks exits 0 when all checks pass and 1 when any check fails.
Pass --bonus to also run checks that are not part of the Drupal GitLab CI pipeline but are available in this add-on:
ddev checks --bonus
ddev checks --bonus web/modules/custom/mymoduleBonus checks run after the CI checks and do not affect the phpunit gate. They are treated as hard failures (no allow_failure equivalent):
- PHP Mess Detector — code quality and complexity metrics.
- PHP Compatibility — detects PHP version compatibility issues.
Some projects configure certain CI jobs to allow failure (for example, during a transition period). This add-on respects the same variables the Drupal GitLab CI pipeline uses. Set them in the variables: block of your .gitlab-ci.yml:
| Variable | Effect |
|---|---|
_ALL_VALIDATE_ALLOW_FAILURE: "1" |
All CI validation checks show failures as warnings |
_PHPCS_ALLOW_FAILURE: "1" |
phpcs failures show as warnings |
_PHPSTAN_ALLOW_FAILURE: "1" |
phpstan failures show as warnings |
_ESLINT_ALLOW_FAILURE: "1" |
eslint failures show as warnings |
_STYLELINT_ALLOW_FAILURE: "1" |
stylelint failures show as warnings |
_CSPELL_ALLOW_FAILURE: "1" |
cspell failures show as warnings |
_PHPUNIT_ALLOW_FAILURE: "1" |
phpunit failures show as warnings |
When a check is configured to allow failure and it fails, it shows as ⚠ in the summary. If every failure was a warning (no hard failures), ddev checks exits 0 with Result: passed with warnings.
Per-tool variables take precedence over _ALL_VALIDATE_ALLOW_FAILURE, matching how GitLab CI applies these settings.
ddev checks also respects SKIP_* variables from .gitlab-ci.yml. A skipped check is shown as - in the summary and does not affect the exit status:
| Variable | Effect |
|---|---|
SKIP_COMPOSER_LINT: "1" |
Skips parallel-lint |
SKIP_PHPCS: "1" |
Skips phpcs |
SKIP_PHPSTAN: "1" |
Skips phpstan |
SKIP_ESLINT: "1" |
Skips eslint |
SKIP_STYLELINT: "1" |
Skips stylelint |
SKIP_CSPELL: "1" |
Skips cspell |
SKIP_PHPUNIT: "1" |
Skips phpunit |
ddev phpunit requires drupal/core-dev to be installed in your project. If it is not already present, add it with:
ddev composer require --dev drupal/core-dev -WThe -W flag (--with-all-dependencies) is needed to allow Composer to adjust the versions of shared packages (such as sebastian/diff) to satisfy phpunit's constraints alongside those of drupal/core.
If your project has a phpunit.xml or phpunit.xml.dist in the project root, ddev phpunit uses it as-is. Without one, the command bootstraps from web/core/tests/bootstrap.php and sets the following DDEV-standard defaults so all test types work out of the box:
| Environment variable | Default value |
|---|---|
SIMPLETEST_BASE_URL |
$DDEV_PRIMARY_URL |
SIMPLETEST_DB |
mysql://db:db@db/db |
BROWSERTEST_OUTPUT_DIRECTORY |
web/sites/simpletest/browser_output |
Override any of these by setting them in .ddev/config.yaml under web_environment before running the command.
By default, ddev phpunit passes --display-all-issues to PHPUnit so that deprecations, notices, and warnings are shown inline rather than summarised. To suppress this output, set PHPUNIT_DISPLAY_ALL_ISSUES=0 in .ddev/config.yaml:
web_environment:
- PHPUNIT_DISPLAY_ALL_ISSUES=0Each command follows a three-level priority for its configuration:
- Project config — if a config file exists in your project root (e.g.
phpcs.xml,phpstan.neon,.stylelintrc.json), it is used as-is. - Drupal core config — for
eslintandstylelint, ifcore/node_modulesis installed, the command usescore/.eslintrc.passing.jsonandcore/.stylelintrc.jsonrespectively — the same files used in the GitLab CI pipeline. - Bundled defaults — if neither of the above is found, the add-on's own default configs in
.ddev/module-developer/config/are used. These match the Drupal GitLab Templates defaults.
Place a config file in your project root to override the bundled default for any tool:
| Tool | Config file(s) |
|---|---|
| phpcs / phpcbf | phpcs.xml or phpcs.xml.dist |
| phpstan | phpstan.neon or phpstan.neon.dist |
| rector | rector.php or rector.php.dist |
| stylelint | .stylelintrc.json (or any supported format) |
| eslint | .eslintrc.json (or any supported format) |
| cspell | .cspell.json |
The bundled phpcs.xml matches the GitLab Templates default: only the Drupal standard is enforced; DrupalPractice is commented out and can be enabled if required.
The bundled phpstan.neon runs at level 0, matching the GitLab Templates default. To raise the level, add a phpstan.neon to your project root:
parameters:
level: 2The Drupal GitLab CI Docker image pre-installs mglaman/phpstan-drupal, so the template's phpstan.neon does not reference it. In DDEV there is no such pre-built image, so when no project-level config is found the phpstan command automatically injects the extension. If you supply your own phpstan.neon and want the Drupal extension, add the includes explicitly:
includes:
- /usr/local/composer/vendor/mglaman/phpstan-drupal/extension.neon
- /usr/local/composer/vendor/mglaman/phpstan-drupal/rules.neonMost tools support an ignore file in the project root:
- phpcs:
<exclude-pattern>entries in yourphpcs.xml - stylelint:
.stylelintignore - eslint:
.eslintignore - cspell:
ignorePathsin.cspell.json
ddev checks-fixes runs all three auto-fixers in sequence and prints a summary — the quickest way to clean up a module before running ddev checks:
ddev checks-fixes
ddev checks-fixes web/modules/custom/mymoduleThe individual fixers can also be run directly:
ddev phpcbf web/modules/custom/mymodule
ddev stylelint --fix
ddev stylelint --fix 'web/modules/custom/mymodule/**/*.css'
ddev eslint --fix
ddev eslint --fix web/modules/custom/mymodule/jsSet up a pre-commit hook that runs phpcbf before every commit:
- Create
.git/hooks/pre-commitif it does not already exist. - Add the following:
#!/usr/bin/env bash
ddev phpcbf -q- Make it executable:
chmod +x .git/hooks/pre-commit.
"Error: unknown command"
Commands are only available when the DDEV project type is drupal. Confirm the type in .ddev/config.yaml:
type: drupalTip
Run ddev restart after editing .ddev/config.yaml.
ESLint or Stylelint reports missing plugin errors
The bundled fallback configs use globally installed plugins. If you are pointing ESLint or Stylelint at a custom config that references plugins not installed globally, either install those plugins inside the container or add them to your project's package.json and run yarn install / npm install.
PHPStan cannot find Drupal classes
Make sure drupal_root is set correctly. When using the bundled config the phpstan command sets this automatically from $DDEV_DOCROOT. If you supply your own phpstan.neon, add:
parameters:
drupal:
drupal_root: /var/www/html/webAdjust the path to match your project's docroot.
Tests are written in Bats. Install the test helper submodules first:
git submodule update --initThen run the tests from the project root:
./tests/bats/bin/bats ./testsTests are triggered automatically on every push and run nightly. Contributions and bug reports are welcome.
Contributed and maintained by Martin Anderson-Clutz (@mandclu).