| name | drupal-contrib-mgmt |
| description | Comprehensive guide for managing Drupal contributed modules via Composer, including updates, patches, version compatibility, and Drupal 11 upgrades. Use when updating modules or resolving dependency issues. |
Drupal Contrib Module Management
Core Update Workflow
Standard Module Update
composer require drupal/module_name --with-all-dependencies
composer require drupal/module_name:^3.0 --with-all-dependencies
composer require drupal/module_a drupal/module_b --with-all-dependencies
drush updb -y
drush cr
Major Version Upgrades
When upgrading to a new major version (e.g., 2.x → 3.x):
- Check compatibility: Ensure module supports your Drupal core version
- Search issue queue for patches:
https://www.drupal.org/project/issues/MODULE_NAME?categories=All
- Use Drupal Lenient for version requirement issues (see below)
- Apply patches via composer.json (see Patch Management section)
- Run upgrade_status to check for deprecations
Checking Drupal 11 Compatibility
Three methods to check if a module is D11 compatible (in order of preference):
Method 1: Check .info.yml File (Fastest, Most Reliable)
cat docroot/modules/contrib/MODULE_NAME/MODULE_NAME.info.yml | grep core_version_requirement
What to look for:
core_version_requirement: ^9.5 || ^10 || ^11
core_version_requirement: ^8 || ^9 || ^10 || ^11
core_version_requirement: ^9 || ^10
Example:
$ cat docroot/modules/contrib/admin_toolbar/admin_toolbar.info.yml | grep core_version
core_version_requirement: ^9.5 || ^10 || ^11
Method 2: Use Composer Commands (Works Before Installing)
composer show drupal/MODULE_NAME --all | grep -A5 "^versions"
composer show drupal/MODULE_NAME | grep versions
What to look for:
- Version number (e.g., 3.6.2)
- Check Drupal.org for release notes mentioning D11
Method 3: Check Drupal.org Project Page
Only use as fallback when above methods aren't conclusive.
https://www.drupal.org/project/MODULE_NAME
Look for:
- Latest release notes mentioning "Drupal 11"
- Module page header showing D11 compatibility badge
- Issue queue for D11 compatibility issues
Important Notes:
- ⚠️ Module may declare D11 support but still have deprecation warnings
- ⚠️ upgrade_status warnings don't mean module is incompatible
- ⚠️ "Check manually" status often means runtime version checks (false positive)
- ✅ If .info.yml declares
^11 support, module maintainer says it works
Real-World Examples:
$ cat docroot/modules/contrib/admin_toolbar/admin_toolbar.info.yml | grep core_version
core_version_requirement: ^9.5 || ^10 || ^11
$ cat docroot/modules/contrib/audiofield/audiofield.info.yml | grep core_version
core_version_requirement: ^8 || ^9 || ^10 || ^11
Drupal Lenient Plugin
The mglaman/composer-drupal-lenient plugin allows installing modules that haven't updated their version requirements yet.
Setup
{
"require": {
"mglaman/composer-drupal-lenient": "^1.0"
},
"config": {
"allow-plugins": {
"mglaman/composer-drupal-lenient": true
}
},
"extra": {
"drupal-lenient": {
"allowed-list": [
"drupal/module_name",
"drupal/another_module"
]
}
}
}
Usage
composer require drupal/module_name --with-all-dependencies
Patch Management (cweagans/composer-patches)
IMPORTANT: Use version 2.x for reliable patch application. Version 1.x uses the patch binary which can have issues on some systems. Version 2.x uses git apply by default.
Patch Configuration
{
"require": {
"cweagans/composer-patches": "^2.0"
},
"config": {
"allow-plugins": {
"cweagans/composer-patches": true
}
},
"extra": {
"composer-exit-on-patch-failure": true,
"patches": {
"drupal/module_name": {
"Description of patch": "https://www.drupal.org/files/issues/2024-01-15/module-issue-1234567-8.patch",
"Local patch": "patches/custom-fix.patch"
}
},
"patchLevel": {
"drupal/core": "-p2"
}
}
}
Upgrading from 1.x to 2.x
If you're on version 1.x and experiencing patch failures:
composer require cweagans/composer-patches:^2.0 --with-all-dependencies
Key differences in 2.x:
- Uses
git apply instead of patch binary (more reliable)
enable-patching option removed (patching is always enabled)
- Better error messages and debugging
- CRITICAL — the
patches.lock.json apply source: v2 applies patches from patches.lock.json on composer install / composer reinstall. It does NOT read extra.patches in composer.json during those commands — only composer update and composer patches-relock re-read composer.json and regenerate the lock. So adding a patch to composer.json and running composer install applies nothing for that patch until you relock. This is the #1 cause of patches that "keep regressing": local dev looks fixed (you hand-applied it or ran update), but the next clean install — CI, a teammate, a fresh deploy — reads the stale lock and drops the patch. Always run composer patches-relock after editing extra.patches, and commit patches.lock.json.
Verifying Patches Are Applied
THREE DIFFERENT PROBLEMS, ONE SCRIPT:
-
Lock-sync staleness (the root cause): a patch is registered in composer.json extra.patches but never added to patches.lock.json because composer patches-relock was skipped. v2 applies from the lock on composer install, so the patch is silently a no-op on every clean install. The fix is the relock; the script's job is to catch the skip by asserting every local patch in composer.json is present in patches.lock.json.
-
Committed file drift: a patch IS applied to the working tree, but the resulting contrib file change is never committed to git. Pantheon (and any platform that deploys from committed git state without running composer install) never sees it, so production silently runs un-patched code. Local dev looks fine. See CLAUDE.md "Contrib/Core Patch Policy" for context.
-
Patch hash cache staleness: even with the lock in sync, a stray reinstall or vendor update can skip re-applying. Rare next to (1) and (2), but the same materialized-file check catches it.
SOLUTION: scripts/verify-patches.sh
./scripts/verify-patches.sh
./scripts/verify-patches.sh --fix
Behavior:
- Runs two checks. (1) Lock-sync: every local patch in
composer.json extra.patches must also appear in patches.lock.json — catches the skipped patches-relock. (2) Materialized-file: the patched lines must be present in the committed contrib file — catches "patched but not committed".
- Auto-derives the verification list from
composer.json extra.patches — no manual curation required. Adding a patch entry is enough; the script picks it up automatically.
- For each local patch (value starting with
patches/), it parses all +++ b/<path> headers, extracts up to 5 distinctive added lines (≥ 8 non-whitespace chars, not a substring of any - line in the same patch), and greps the target file for them. Handles the drupal/core package's core/ path-prefix quirk and is bash 3 compatible.
- URL-based patches (
https://...) are skipped with a notice — add a local mirror under patches/ if the patch is critical.
- Runs in CI before
composer install in the lint job (.github/workflows/test.yml), so it validates the COMMITTED tree — not the post-install state. This is the ordering that matters.
Adding a new patch (the relock step is the one everyone forgets):
- Drop the
.patch file in patches/
- Register it in
composer.json under extra.patches
- Run
composer patches-relock — adds the patch to patches.lock.json. WITHOUT this, step 4's composer install applies nothing (v2 reads the lock, not composer.json).
- Run
composer install to apply the patch to the working tree
git add and commit the modified contrib file along with composer.json, patches.lock.json, and the new .patch file — platforms that deploy from git (Pantheon) can't apply patches on their own, so the committed contrib file must already be in its patched form
- Run
./scripts/verify-patches.sh locally to sanity-check before pushing
- CI will re-run the same verification on every push
When verify-patches.sh reports MISSING in CI:
- Lock-sync failure → someone skipped
composer patches-relock (step 3). Fix: run it, commit patches.lock.json, push.
- Materialized-file failure → someone forgot to commit the patched contrib file (step 5). Fix:
composer patches-relock && composer install locally, git add docroot/modules/contrib docroot/core patches.lock.json, commit, and push.
Caveats:
- "Combined patches" (one
.patch file with multiple +++ b/<same_file> headers, usually squashed commits with conflicting hunks) may slip through — the script accepts any distinctive added line, so a partial match passes. If you see a patch land in patches/ with multiple hunks revising the same file, regenerate it as a clean single-commit diff instead.
- PHPCS: committing patched contrib files can trip
grumphp's pre-commit phpcs task on pre-existing sniff violations in upstream code. grumphp.yml already ignores docroot/modules/contrib, docroot/core, and docroot/libraries for this task — don't remove those ignores.
Finding Patches
Issue Queue Search: https://www.drupal.org/project/issues/MODULE_NAME?categories=All
Patch Naming Convention:
- Format:
module-issue-NODEID-COMMENT.patch
- Example:
audiofield-d11-3432063-12.patch
- Node ID is the issue number (visit
drupal.org/node/NODEID)
When Existing Patches Fail After Update:
- Extract node ID from patch filename (e.g.,
3432063 from above)
- Visit
https://www.drupal.org/node/3432063
- Look for updated patch in latest comments
- Update composer.json with new patch URL
Debugging Errors: Find Patches BEFORE Creating
CRITICAL WORKFLOW: When encountering Drupal errors, ALWAYS search for existing patches before creating your own.
Step 1: Extract the Exact Error Signature
From the error message, extract the exact error string:
TypeError: Unsupported operand types: array + null in Drupal\field_ui\Form\EntityViewDisplayEditForm
"Unsupported operand types: array + null"
Step 2: Search Drupal.org Issue Queue FIRST
https://www.drupal.org/project/drupal/issues?text=Unsupported+operand+types+array+null
https://www.drupal.org/project/drupal/issues?text=EntityViewDisplayEditForm+line+166
What to look for in search results:
- Issues with status: "Needs review" or "Reviewed & tested by the community" (RTBC)
- Recent activity (check dates)
- Patch files in comments (look for
.patch attachments)
- Merge requests (look for
!13611 references)
Step 3: Use WebFetch to Get Patch Details
WebFetch(https://www.drupal.org/project/drupal/issues/3552531)
Look for:
- Patch file URLs: Usually
https://www.drupal.org/files/issues/YYYY-MM-DD/filename.patch
- Merge request numbers: E.g.,
!13611 → https://git.drupalcode.org/project/drupal/-/merge_requests/13611
- Issue status: RTBC means ready to use
Step 4: Download and Apply Official Patch
curl -O https://www.drupal.org/files/issues/2025-10-16/field-ui--unsupported-operand-types--3552531-2.patch
mv field-ui--unsupported-operand-types--3552531-2.patch patches/
{
"extra": {
"patches": {
"drupal/core": {
"Fix TypeError: Unsupported operand types array + null in EntityViewDisplayEditForm - Issue #3552531": "patches/field-ui--unsupported-operand-types--3552531-2.patch"
}
}
}
}
composer install
Common Search Patterns
| Error Type | Search Term |
|---|
| TypeError | Exact error message in quotes |
| Deprecated function | Function name (e.g., user_roles) |
| Missing method | Class name + method name |
| Fatal error | Exact error text |
Why This Matters
- Saves time: Don't recreate existing solutions
- Better quality: Community-reviewed patches are more robust
- Upstream integration: Using official patches means easier upgrades
- Documentation: Issue threads contain context and discussion
Anti-Pattern Example
❌ What NOT to do:
- See error
- Read code
- Create patch
- Apply patch
- (Someone points out existing issue)
✅ What TO do:
- See error
- Extract exact error message
- Search drupal.org issue queue
- Find existing patch
- Apply official patch
Creating Local Patches
IMPORTANT: Always create patches from a separate clone of the contrib module repo, not from the installed version in your project.
cd ~/Sites
git clone git@git.drupal.org:project/module_name.git module_name-contrib
cd ~/Sites/module_name-contrib
git checkout 1.0.3
git diff > ~/Sites/your-project/patches/module_name-custom-fix.patch
{
"extra": {
"patches": {
"drupal/module_name": {
"Custom fix description": "patches/module_name-custom-fix.patch"
}
}
}
}
composer reinstall drupal/module_name
Why use a separate repo?
- Creates clean patches without local modifications bleeding in
- Matches the exact file structure composer expects
- Allows proper version tracking with git tags
- Enables contributing patches upstream to drupal.org
Patch format: Patches should use git diff format (includes a/ and b/ prefixes):
diff --git a/src/File.php b/src/File.php
index abc123..def456 100644
--- a/src/File.php
+++ b/src/File.php
Patch Application
composer install
composer install
composer update drupal/module_name
composer patches-repatch
For detailed patch workflows, see: references/drupal-patches-workflow.md
Drupal 11 Compatibility Workflow
Step 1: Analyze Readiness
drush upgrade_status:analyze --all
drush upgrade_status:analyze module1 module2 module3
drush upgrade_status:analyze --all --format=json > d11-report.json
drush upgrade_status:analyze --all --format=codeclimate > d11-report-ci.json
drush upgrade_status:analyze --all --ignore-contrib
drush upgrade_status:analyze --all --ignore-custom
Step 2: Identify Issues
Major Issues (blocking):
REQUEST_TIME constant → Use \Drupal::time()->getRequestTime()
user_roles() → Use \Drupal\user\Entity\Role::loadMultiple()
file_validate_extensions() → Use file.validator service
system_retrieve_file() → No replacement (refactor required)
_drupal_flush_css_js() → Use AssetQueryStringInterface::reset()
Info.yml Issues:
- Update
core_version_requirement to include ^11
- Example:
core_version_requirement: ^9 || ^10 || ^11
Step 3: Fix Custom Code
Example: Inject Time Service
use Drupal\Core\Datetime\TimeInterface;
class MyController extends ControllerBase {
protected $time;
public function __construct(TimeInterface $time) {
$this->time = $time;
}
public static function create(ContainerInterface $container) {
return new static(
$container->get('datetime.time')
);
}
public function myMethod() {
$timestamp = $this->time->getRequestTime();
}
}
Example: Replace user_roles()
$roles = user_roles(TRUE);
use Drupal\user\Entity\Role;
$roles = Role::loadMultiple();
$role_options = [];
foreach ($roles as $role_id => $role) {
if ($role_id !== 'anonymous') {
$role_options[$role_id] = $role->label();
}
}
Step 4: Create .info.yml Patches
cd docroot/modules/contrib/module_name
git diff module.info.yml > /path/to/patches/module-d11-info.patch
--- a/module.info.yml
+++ b/module.info.yml
@@ -2,7 +2,7 @@
name: Module Name
type: module
description: Module description
-core_version_requirement: ^9 || ^10
+core_version_requirement: ^9 || ^10 || ^11
Step 5: Apply Patches & Update Lenient List
{
"extra": {
"patches": {
"drupal/module_name": {
"Drupal 11 .info.yml support": "patches/module-d11-info.patch"
}
},
"drupal-lenient": {
"allowed-list": [
"drupal/module_name"
]
}
}
}
composer install
drush updb -y
drush cr
Step 6: Verify Fixes
drush upgrade_status:analyze module_name
Complete Update Checklist
Troubleshooting
Patch Won't Apply
composer show drupal/module_name
Version Conflict
Patch Already Applied
Database Update Fails
drush pm:uninstall module_name
composer require drupal/module_name --with-all-dependencies
drush pm:enable module_name
drush updb -y
Best Practices
- Always use
--with-all-dependencies for module updates
- Always run
drush updb after composer updates
- Test immediately after updates (visit pages, check logs)
- Keep patches organized in a
patches/ directory
- Document patches with descriptive names and comments
- Check issue queues first before creating custom patches
- Use upgrade_status to validate D11 compatibility
- Commit atomically: one module update per commit
- Use descriptive commit messages with patch references
- Keep drupal-lenient list minimal (only when necessary)
Production Deployment
When deploying to production environments (Pantheon, Acquia, etc.), always optimize the Composer install:
composer install --no-dev -o
Why This Matters:
--no-dev reduces codebase size by excluding testing/dev tools
-o creates optimized class maps for faster autoloading
- Reduces security surface by excluding dev dependencies
- Improves performance on production servers
Production Deployment Workflow:
composer update drupal/module_name --with-all-dependencies
composer install --no-dev -o
git add composer.json composer.lock vendor/
git commit -m "Update module_name with production optimization"
git push origin master
acli remote:drush -- cr
NEVER commit vendor/ with dev dependencies to production branches!
Developing Contrib Modules Locally
When actively developing a contrib module for drupal.org, use this workflow to avoid constantly updating via composer:
Symlink Development Workflow
cd /tmp
git clone git@git.drupal.org:project/module_name.git
cd module_name
cd /path/to/project
rm -rf docroot/modules/contrib/module_name
ln -s /tmp/module_name docroot/modules/contrib/module_name
drush cr
cd /tmp/module_name
git add -A
git commit -m "Your changes"
git push origin 1.0.x
cd /path/to/project
rm docroot/modules/contrib/module_name
composer install
Benefits:
- Test changes immediately without composer update cycles
- Keep git history in the module's own repo
- Easy to commit and push changes
- No risk of accidentally committing module code to main project
Important Notes:
- Don't forget to remove the symlink before committing project changes
- Clear Drupal cache after changes:
drush cr
- When done developing, always reinstall via composer to ensure clean state
- Useful for fixing autoloader issues, adding features, or troubleshooting
Example: Fixing recurly_commerce_api autoloader issue
cd /tmp/recurly_commerce_api
git commit -m "Add PSR-4 autoload configuration"
git push origin 1.0.x
rm docroot/modules/contrib/recurly_commerce_api
composer install
drush cr
Common Patterns
Pattern: Update Module with Known Patch
composer require drupal/module_name:^3.0 --with-all-dependencies
drush updb -y
drush cr
git add composer.json composer.lock patches/
git commit -m "Update module_name to 3.0 with D11 compatibility patch"
Pattern: Fix Contrib D11 Issue
drush upgrade_status:analyze module_name
cd docroot/modules/contrib/module_name
git diff module.info.yml > ../../../patches/module-d11-info.patch
composer install
drush cr
drush upgrade_status:analyze module_name
Pattern: Major Version Upgrade with Breaking Changes
drush sql:dump > backup-before-update.sql
composer require drupal/module_name:^3.0 --with-all-dependencies
drush updb -y
drush watchdog:show --severity=Error --count=20
Contributing Back to drupal.org
When you've developed a fix or feature that should be contributed upstream, use the issue fork workflow.
Step 1: Create Issue on drupal.org
- Go to
https://www.drupal.org/project/issues/MODULE_NAME
- Click "Create a new issue"
- Fill in:
- Title: Descriptive title of the feature/fix
- Category: Bug report, Feature request, or Task
- Priority: Normal (unless exceptional)
- Note the issue number (e.g., 3569725)
Issue Description Format
Use the standard drupal.org template with HTML formatting:
<h3 id="overview">Overview</h3>
<p>Problem description here.</p>
<ul>
<li>Bullet point one</li>
<li>Bullet point two</li>
</ul>
<h3 id="proposed-resolution">Proposed resolution</h3>
<p><strong>Behavior:</strong></p>
<ul>
<li>Feature behavior one</li>
<li>Feature behavior two</li>
</ul>
<p><strong>Technical implementation:</strong></p>
<ul>
<li><code>SomeClass</code> - description</li>
<li><code>some_function()</code> - description</li>
</ul>
<p><strong>Files changed:</strong></p>
<ul>
<li><code>path/to/file.php</code> - Description of changes</li>
</ul>
<h3 id="ui-changes">User interface changes</h3>
<p>Description of UI changes (or "None" if no UI changes).</p>
<h3 id="steps-to-test">Steps to test</h3>
<ol>
<li>First step</li>
<li>Second step</li>
<li>Expected result</li>
</ol>
Formatting reference: https://www.drupal.org/filter/tips
<code>...</code> for inline code
<strong>...</strong> for bold
<ul><li>...</li></ul> for unordered lists
<ol><li>...</li></ol> for ordered lists
<h3 id="section-name">...</h3> for section headers
<p>...</p> for paragraphs
Step 2: Create Issue Fork on drupal.org
- On the issue page, click "Create issue fork"
- Copy the Git commands provided
Step 3: Clone Module and Set Up Fork
cd ~/Sites
git clone git@git.drupal.org:project/module_name.git module_name-contrib
cd module_name-contrib
git remote add module_name-XXXXXXX git@git.drupal.org:issue/module_name-XXXXXXX.git
git fetch module_name-XXXXXXX
git checkout -b 'XXXXXXX-short-description' --track module_name-XXXXXXX/'XXXXXXX-short-description'
Step 4: Make Changes and Test
Step 5: Commit and Push
git add path/to/changed/files
git commit -m "$(cat <<'EOF'
Issue #XXXXXXX: Short description
- Bullet point of change 1
- Bullet point of change 2
- Bullet point of change 3
EOF
)"
git push module_name-XXXXXXX XXXXXXX-short-description
Step 6: Create Merge Request
After pushing, you'll see a URL in the output:
remote: To create a merge request for XXXXXXX-short-description, visit:
remote: https://git.drupalcode.org/issue/module_name-XXXXXXX/-/merge_requests/new?merge_request%5Bsource_branch%5D=XXXXXXX-short-description
- Visit that URL to create the merge request
- Return to the issue page on drupal.org
- Set issue status to "Needs review"
Commit Message Format
Drupal.org standard format:
Issue #XXXXXXX: Short description (50 chars max)
- Detail about what changed
- Another detail
- Technical implementation note
Two-Repository Workflow
When contributing to a module you also use in your project:
- Contrib Repo (
~/Sites/module-contrib/) - Clean checkout for developing and contributing
- App Repo (
~/Sites/your-app/) - Uses composer patches to apply changes
Benefits:
- Clean separation between contribution work and app usage
- Patches can be applied/removed easily via Composer
- App stays functional while iterating on the feature
Workflow:
cd ~/Sites/module-contrib
git diff > feature-name.patch
cp feature-name.patch ~/Sites/your-app/patches/
cd ~/Sites/your-app
composer reinstall drupal/module_name
cd ~/Sites/module-contrib
git add -A && git commit -m "Issue #XXXXXXX: Description"
git push fork-remote branch-name
Using Remote Patches (After MR Created)
Once a merge request exists, you can use the remote diff URL:
{
"extra": {
"patches": {
"drupal/module_name": {
"Feature (https://www.drupal.org/project/module_name/issues/XXXXXXX)": "https://git.drupalcode.org/project/module_name/-/merge_requests/XXX.diff"
}
}
}
}
Reference Links