Upgrade a 4.x project to 5.x
This guide is for maintainers of a Drupal module or theme created from a 4.x release of this template, the Drupal Extension Scaffold. Follow it to upgrade the project to Eddy 5.x. It covers what changed and what each change asks of you.
5.x is a bigger step than a usual update. After 4.19.0, the last 4.x release, the template became Eddy and moved to drevops/eddy. The scripts in .devtools/ became the drevops/eddy-tooling Composer package, and CircleCI support went away. The upgrade rewrites most of your build and CI files at once, and a few of the changes need decisions only you can make.
From 4.15.0, the AGENTS.md of a 4.x project holds the URL of the update skill on the 1.x branch of the old repository, AlexSkrypnyk/drupal_extension_scaffold. Earlier releases have no update skill at all. The repository has no 1.x branch, so the URL returns 404 and a 4.x project can't update itself. In Upgrade with Claude Code, you fetch the new skill once by hand. The steps in Upgrade by hand use no skill.
Before you start
Prepare the following before you upgrade:
-
Find the 4.x release your project is on, since several of the following steps depend on it.
git log -i --grep=scaffold --format='%h %s'lists your scaffold update commits. The subject of the newest one names the release, such asUpdated scaffold to 4.18.0. -
Commit or stash your work in progress. The upgrade changes most of the files in the project root. The update skill starts only on a clean working tree, apart from untracked files in
.claude/and.idea/. -
Find the edits you made to
.devtools/and to thescripts/*-cloudflared.shhooks. A project created from or updated to 4.18.0 or 4.19.0 can have those hooks. The upgrade deletes.devtools/and the hooks, so each edit has to move (see.devtools/is a package now).git log -p -- .devtools 'scripts/*-cloudflared.sh'shows every change to those files, template updates included, so look for the commits that are yours. When your edits went in with the template update commits, the log can't separate them. The update skill separates them when it can read your project's release and that release is 4.14.0 or later. Each file you changed then comes up as a modify/delete conflict, and the pull request lists the file. -
If your project runs on CircleCI, keep its project settings at hand. Its deployment settings move to GitHub Actions (see CircleCI is gone).
-
Check the PHP versions your CI tests. The
drevops/eddy-toolingpackage that replaces.devtools/requires PHP 8.3 or newer. Composer can't install it on an older PHP, so a job such as Drupal 10 on PHP 8.2 fails at "Install the tooling". Raising those jobs to PHP 8.3 leaves the older PHP untested, so decide before you start whether your extension still supports it. The update skill's pull request lists the jobs that run a PHP older than 8.3. -
On a release from 4.15.0 to 4.18.0 that deploys from GitHub Actions, set the
DEPLOY_PROCEEDsecret to0before you start, so the upgrade pull request's test runs don't deploy your default branch to your Drupal.org repository (see Deployment). Set it back to1right before you merge. When the update skill finds aDEPLOY_REMOTEsecret, it waits to push the upgrade branch until you confirm thatDEPLOY_PROCEEDis0or tell it to push anyway. Its pull request then lists what's left to do around the merge.
Upgrade with Claude Code
The update-consumer-eddy skill does most of the work. It renders 2 releases with your project's answers: the latest one and the one your project is on. Then it merges the difference into your project and opens a pull request. Your own files stay as they are, and so do the changes you made to the template's files, such as the versions Renovate bumped.
The skill reads the release your project is on from your last scaffold update commit. Without that commit, or with a release older than 4.14.0, you decide on every difference between your files and the new release. On a project without a GitHub Actions workflow, the skill reads your Drupal versions from .circleci/config.yml.
A 4.x project can't fetch the skill, so fetch it once yourself, from the project root:
-
Delete the old skill directory:
rm -rf .claude/skills/update-consumer-drupal-extension-scaffoldThe
AGENTS.mdof 4.15.0 to 4.19.0 gives acurl -sSLcommand for the download, which saves the 404 page as the skill. -
Download the new skill:
mkdir -p .claude/skills/update-consumer-eddycurl -fsSL -o .claude/skills/update-consumer-eddy/SKILL.md \https://raw.githubusercontent.com/drevops/eddy/main/.eddy/skills/update-consumer-eddy/SKILL.md -
If Claude Code was already running when you downloaded the skill, restart it. A session that started before the download might not list the skill, and reports "Unknown skill: update-consumer-eddy".
-
Start Claude Code in the project and run
/update-consumer-eddy, or ask Claude to run theupdate-consumer-eddyskill.On its first run, the skill might add a few command permissions to
.claude/settings.local.jsonand ask you to restart Claude Code. Run it again after the restart. When Claude Code blocks the write to that file, as in auto mode, the skill prints the permissions for you to add yourself. It carries on when the session runs commands without asking anyway.Without a tag, the skill reads the 10 most recently published releases on GitHub. It skips drafts and the releases marked as pre-releases, then upgrades to the highest version left. Only that mark makes a release a pre-release here, so an alpha or a release candidate published without it counts as a regular release. On the releases page, the skill's choice is the highest version without a "Pre-release" label. To upgrade to a particular release, name its tag after the command:
/update-consumer-eddy <tag>.To add Drupal 12 as well, ask for it in the same request, such as
/update-consumer-eddy add Drupal 12(see Drupal 12). -
Go through What changed while you review the pull request that the skill opens.
-
Make the changes the skill leaves to you. It removes
.devtools/,.circleci/and the Cloudflare hooks, and its pull request lists the files among them that you changed:- Move your edits to
.devtools/and the Cloudflare hooks (see.devtools/is a package now). - On a CircleCI project, move the deployment settings, update your branch protection and stop the CircleCI builds (see CircleCI is gone).
- Move your edits to
The pull request also holds these changes, each in a commit of its own, so review them too:
- On a project with FunctionalJavascript tests, their base class brought in line with 5.x
- When you asked for Drupal 12, the Drupal 12 code changes
The upgrade rewrites AGENTS.md, so from then on "update Eddy" works the way it does in any 5.x project.
Upgrade by hand
Follow the manual steps in Update your extension, with these differences for a 4.x project:
- The installer is
install.php. In 4.x, it wasinit.php, apart frominit.shin 4.8.0 to 4.11.x. It asks no question about a CI provider or the Cloudflare hooks. It reads pre-filled answers fromINSTALL_*variables, where 4.19.0 readDEX_*and 4.14.0 to 4.18.0 readPROMPTY_*. Earlier releases took them as positional arguments.php install.php --helplists the variables. - Skip the comparison that finds the template files the new release dropped, since it needs an install of the release your project is on. Instead, before you copy the new files in, delete whichever of these your project has:
.devtools/,.circleci/,phpmd.xml,scripts/provision-cloudflared.sh,scripts/start-cloudflared.sh,scripts/stop-cloudflared.shand.claude/skills/update-consumer-drupal-extension-scaffold/. A project withphpmd.xml, which 4.4.0 to 4.11.x shipped, also has aphpmd/phpmdentry to remove fromcomposer.dev.json. Nothing reads itsCI_PHPMD_IGNORE_FAILURErepository variable, so delete that variable too if it exists. scripts/holds your own hooks, but takescripts/eddy-toolingfrom the release. It's the installer of the tooling package, which 5.x adds.- Merge the new
composer.dev.jsoninto yours, so your own dependencies and patches survive..devtools/is a package now lists the entries 5.x adds.
Then work through What changed for the decisions the merge leaves to you.
What changed
Each of the following sections covers 1 change: what it was in 4.x, what it is in 5.x and what your project needs.
Name and repository
In 4.x, the template was the Drupal Extension Scaffold at AlexSkrypnyk/drupal_extension_scaffold. From 4.8.0, it kept its own files in .scaffold/, and from 4.15.0 your AGENTS.md fetched the update-consumer-drupal-extension-scaffold skill from there, on the 1.x branch.
In 5.x, it's Eddy at drevops/eddy, on the main branch. Its own files live in .eddy/, which never reaches your project. The update skill is update-consumer-eddy, and the installer is install.php.
What to do:
- Delete
.claude/skills/update-consumer-drupal-extension-scaffold/. - In
AGENTS.md, replace the "Updating the scaffold" section with the one Eddy ships. Its download command,curl -fsSL, fails on an HTTP error instead of saving the error page. - In
.gitignore, delete the.claude/skills/update-consumer-drupal-extension-scaffold/line. The.claude/*rule before it already ignores everything in.claude/exceptsettings.json, the new skill's directory included. - In
README.md, credit Eddy in the closing line instead of the Drupal Extension Scaffold.
The update skill makes all 4 changes for you, and a hand merge of the release's files makes the last 3.
.devtools/ is a package now
From 4.5.0, every 4.x project carried its own copy of the build, run and deploy scripts in .devtools/. The set grew with the releases:
- 4.5.0 to 4.7.x:
build-codebase.sh,deploy.sh,provision.sh,start-server.shandtest.sh - 4.8.0 to 4.11.x:
assemble.sh,deploy.sh,provision.sh,start.shandstop.sh - 4.12.0 to 4.15.x:
assemble,deploy,provision,startandstop, withhelpers.phpbehind them - 4.16.0 and later:
infoas well, and from 4.18.0,browserandqrcode
The command wrappers and CI ran them from .devtools/, so a fix reached your project only when you merged the next release. You merged it by hand before 4.15.0, and with the update skill after.
In 5.x, the scripts ship as the drevops/eddy-tooling Composer package, so fixes arrive through its version constraint instead. The package requires PHP 8.3 or newer (see Before you start). scripts/eddy-tooling installs the package into vendor/, and each script becomes a command in vendor/bin/: .devtools/assemble is vendor/bin/eddy-assemble, and .devtools/browser start and stop are eddy-browser-start and eddy-browser-stop.
ahoy and make run scripts/eddy-tooling before the commands that need it, and CI runs it as a step of its own. The script skips Composer while nothing it depends on has changed, so running it every time adds little time. For details, see Tooling package.
What to do:
- Delete
.devtools/and addscripts/eddy-toolingfrom the release. - Add the 5.x entries to
composer.dev.json, next to your own:drevops/eddy-toolingandcweagans/composer-patchesinrequire-dev, with the constraints from the release'scomposer.dev.json.cweagans/composer-patchesinconfig.allow-plugins.extra.eddy.drupal-version, set to the newest Drupal major you build that has a stable release, such as"11". A local build uses it whenDRUPAL_VERSIONisn't set, and Drupal 11 when neither is.- The release's
drush/drushconstraint, which adds Drush 14 for Drupal 12. extra.eddy.version, set to the release you upgrade to, such as"5.0.0". The update skill reads it to find the release your project is on.- When you keep PHPUnit without FunctionalJavascript tests, the Mink packages that Functional tests need in
require-dev:behat/mink,behat/mink-browserkit-driver,symfony/browser-kit,symfony/css-selectorandsymfony/dom-crawler. From 4.16.3, 4.x removed them along with those tests.
- Move each edit you made to
.devtools/:- A project-specific step, such as importing extra configuration after the site is installed, becomes a lifecycle script:
scripts/assemble-*.sh,scripts/provision-*.sh,scripts/start-*.shorscripts/stop-*.sh. - A change to what a command itself does becomes a patch for
drevops/eddy-toolingincomposer.dev.json, as Patch the package describes. The commands live in the package'ssrc/directory, such assrc/eddy-assemble. They've changed a lot since 4.x, so write the patch against the current source. This is the trickiest part of the upgrade.
- A project-specific step, such as importing extra configuration after the site is installed, becomes a lifecycle script:
- Replace every
.devtools/<script>call in your own scripts, docs and CI steps withvendor/bin/eddy-<script>. Where no wrapper or CI step runsscripts/eddy-toolingfirst, run it before the call.
The build changed in 3 ways you might notice:
- It applies the patches you declare. From 4.14.0 to 4.19.0, the build didn't install
cweagans/composer-patches. Unless your project required the plugin itself, Composer silently skipped every patch incomposer.jsonandcomposer.dev.json. In 5.x the patches apply, and a patch that no longer applies fails the build (see Patch dependencies). - It pins Drupal core to 1 exact release, and resolves every other dependency with
minimum-stability: devandprefer-stable: true(see How versions are resolved).DRUPAL_VERSIONtakes values such as11,11.1.0or11@beta, but not@dev, because a development branch isn't a release. assemblestops a webserver still running from an earlier build after its checks pass, where 4.x ranstopfirst. So a failed check leaves your site running. Afterassembleon its own, runstart(see Whatassembledoes).
start and stop changed too. From 4.12.0, they stopped whatever listened on the project's port, and from 4.8.0 to 4.11.x, every PHP process. In 5.x, they stop only the webserver that serves the project's own build/web. The server of another project that recorded the same port keeps running, and start fails and names that server (see Projects that share a machine). browser-stop treats the WebDriver backend the same way, so it leaves the browser of another project running.
Cloudflare tunnel hooks
In 4.18.0 and 4.19.0, init.php could keep 3 hooks that ran a Cloudflare quick tunnel: scripts/provision-cloudflared.sh, scripts/start-cloudflared.sh and scripts/stop-cloudflared.sh. Its prompt kept them by default, so an update to either release could add them to an older project too.
In 5.x, eddy-start, eddy-provision and eddy-stop run the tunnel themselves when CLOUDFLARE_TUNNEL=1 is set, in the shell or in .env (see Public tunnel). They also fix a bug in the hooks: the hooks made *.trycloudflare.com the only trusted host, so after a tunnel provision the site answered 400 on http://localhost:<port>.
What to do:
- Delete the 3 hooks. They act on the same tunnel as the commands, and the start hook can restart it.
- Move any edit you made to them the way you move an edit to
.devtools/. A step of your own becomes a lifecycle script. A change to how the tunnel works becomes a patch fordrevops/eddy-tooling.
A project that was never on 4.18.0 or 4.19.0 never had these hooks.
CircleCI is gone
From 4.8.0, the 4.x installer asked which CI provider to use. A CircleCI project got .circleci/config.yml instead of the GitHub Actions workflows and kept its deployment key in CircleCI, with the key's fingerprint in the config. It deployed every branch that passed its tests.
In 5.x, GitHub Actions is the only CI, and every project gets .github/workflows/test.yml, deploy.yml, draft-release-notes.yml and assign-author.yml.
What to do, if your project ran on CircleCI:
- Delete
.circleci/. The upgrade adds the GitHub Actions workflows. - Move the deployment settings to the repository's Settings > Secrets and variables > Actions, as Set up deployment describes:
- The private deployment key becomes the
DEPLOY_SSH_KEYsecret. GitHub Actions doesn't need its fingerprint. Without a copy of the private key, generate a new key pair instead. DEPLOY_REMOTE,DEPLOY_USER_NAME,DEPLOY_USER_EMAILandDEPLOY_PROCEEDbecome secrets.DEPLOY_BRANCH, if you set it, becomes a repository variable.CODECOV_TOKEN, if you upload coverage to Codecov, becomes a secret.- Each
CI_*_IGNORE_FAILUREtoggle you set becomes a repository variable (see Allow a CI step to fail).
- The private deployment key becomes the
- Check which branches deploy. GitHub Actions deploys tags and pushes to the branches in the
pushfilter oftest.yml. That filter lists1.xunless you change it, and a branch outside it stops deploying. - Update your branch protection rules, or the ruleset that protects your default branch. Swap the
ci/circleci: <job>checks for the GitHub Actions jobs that Branch protection lists for your Drupal majors. From 4.18.0, each CircleCI job has a GitHub Actions job of the same name. - Stop building the project in CircleCI. Otherwise every push gets a failing "CircleCI Pipeline" check that can't find a configuration.
A project on GitHub Actions can skip this section.
Deployment
Deployment changed several times during 4.x:
- Before 4.19.0, the GitHub Actions
Deployworkflow pushed whatever your default branch held when it ran. - From 4.15.0 to 4.17.0, a release tag pushed your default branch to a branch named after the tag.
- From 4.15.0 to 4.18.0, a pull request's successful test run deployed too. It pushed your default branch to a branch of your Drupal.org repository named after the pull request's branch. In 4.18.0, a
DEPLOY_BRANCHrepository variable, when set, names the branch instead. - In 4.19.0 and on CircleCI, a release tag also force-pushed the tagged commit to a branch of your Drupal.org repository. A tag behind that branch's tip rewound it. A test run that finished late, or that you re-ran, could rewind a branch the same way.
- In 4.19.0 and in CircleCI tag builds, the first deployment to a new branch failed.
- The
Deployworkflow failed on every run until you set up deployment. - Every deployment also force-pushed every tag of your repository, so a tag whose own test run failed still reached your Drupal.org repository.
In 5.x, deployment works like this:
- Only a push to your repository deploys. A pull request's test run never does.
- A tag deploys only the tag and leaves your branches alone.
DEPLOY_BRANCHapplies to branch pushes only. - A branch push deploys only while its commit is still the tip of the branch. An older commit is skipped with a notice, so it never replaces a newer one.
- Deployments of the same branch run 1 at a time, so 2 of them can't push out of order.
- The first deployment of a branch creates it.
- A branch deployment pushes no tags. A tag reaches your Drupal.org repository only through its own deployment, after its own test run passes (see Tags that don't deploy).
- Until the
DEPLOY_REMOTEsecret is set, theDeployworkflow skips its steps and reports a notice instead of failing.
What to do:
- If your release process relied on a tag push to update a branch, push the branch as well.
- GitHub starts no test run for a push of more than 3 tags, or for a tag that a workflow creates with
GITHUB_TOKEN. Those tags don't deploy. Push tags 3 at a time at most, and deploy a tag that missed its deployment as Tags that don't deploy describes. - On a release from 4.15.0 to 4.18.0, the upgrade pull request still deploys the 4.x way described in this section. GitHub runs a
workflow_runworkflow, such asDeploy, from the copy on your default branch, so the 4.xdeploy.ymlruns until you merge. To skip that, set theDEPLOY_PROCEEDsecret to0before you start, and back to1right before you merge. - On a release from 4.15.0 to 4.18.0, each pull request that deployed left a branch of the same name in your Drupal.org repository. After you've merged, delete those branches with
git push <remote> --delete <branch>, where<remote>is your Drupal.org repository. Keep the branches you release from, such as1.x, and the branch thatDEPLOY_BRANCHnames. - On a release from 4.15.0 to 4.17.0, also delete every branch named after a release tag from your Drupal.org repository. Those releases created 1 for each tag they deployed.
Deployment describes the 5.x behavior in full.
FunctionalJavascript tests
From 4.13.0 to 4.17.0, FunctionalJavascript tests ran in a Selenium container. selenium-start started it on your machine, and CI ran it as a service. Their base class, <MachineName>JsTestBase, pointed the browser at host.docker.internal on macOS and at 172.17.0.1 on other systems, which is where a container finds the host. The tests reached the browser at the endpoint in phpunit.xml, http://localhost:4444.
4.18.0 made the Chrome on your machine the default browser and gave each project its own WebDriver port. It renamed the class to <MachineName>FunctionalJavascriptTestBase and pointed the browser at localhost unless WEBDRIVER_BACKEND is selenium. It also added a getMinkDriverArgs() that sends the tests to the project's WebDriver port and runs the local Chrome headless. On CircleCI, <MachineName>JsTestBase and the 4.18.0 class both skip setting the address.
In 5.x, the class is the 4.18.0 one without the CircleCI check, and its getMinkDriverArgs() declares the string|false return type that Drupal 12 needs.
No 4.x update changed the files in your project's tests/. So your project still has the class it was created with, even after an update to 4.18.0 or 4.19.0.
What to do, if your project has FunctionalJavascript tests: bring their base class in line with the 5.x copy. Keep its name, so the tests that extend it stay as they are. What that takes depends on the class you have:
-
If it has no
getMinkDriverArgs(), as in a project created from 4.13.0 to 4.17.0, make these changes:- Replace the
CIRCLECIcheck insetUp(), and the comment before it, with the base URL setup of the 5.x copy. - Add the 5.x copy's
getMinkDriverArgs(). - In
CONTRIBUTING.md, replace<MachineName>FunctionalJavascriptTestBasewith the name of your class.
Until you make them,
ahoy testandmake testsend the local Chrome to an address that doesn't reach the webserver. Every FunctionalJavascript test then fails insetUp(), such as withnet::ERR_NAME_NOT_RESOLVEDon a Mac. CI keeps passing anyway. The 5.xtest.ymlruns the browser in a Selenium container, which is what the class was written for. So a green pull request doesn't prove the class works. - Replace the
-
If it has one, as in a project created from 4.18.0 or 4.19.0, make 3 changes to it:
- Declare
string|falseas its return type. - Drop the
CIRCLECIcheck insetUp(), but keep the base URL setup inside it. - Change
.devtools/browsertoeddy-browser-startin a comment.
- Declare
The update skill brings the class in line in a commit of its own, and points CONTRIBUTING.md at it.
A project created before 4.13.0 never had the class. If yours has FunctionalJavascript tests of its own, have them extend a class like the 5.x copy, for the reasons in WebDriver ports.
Drupal 12
5.x can test your extension on Drupal 12 next to Drupal 10 and 11. It's opt-in: the upgrade keeps the Drupal majors your CI tests today. To add Drupal 12, select 12 when install.php asks for the Drupal versions, or ask for it when you run the update skill, such as /update-consumer-eddy add Drupal 12. After you're on 5.x, the skill can add it later the same way, even when there's no newer release to update to.
Drupal 12 brings 5 CI jobs (lint-d12, test-php-min-d12-stable, test-php-max-d12-stable, test-php-min-d12-legacy and test-php-max-d12-canary) and a README badge, plus a PHP 8.5 badge if you don't test Drupal 11. It also needs 3 changes in your own code, which no template merge makes. When you add Drupal 12 with the update skill, the skill makes them after its template merge. Each is a commit of its own, so review them in the pull request. Otherwise, make them yourself:
- Add
^12tocore_version_requirementin every*.info.ymlyour project has, including those of submodules such as an example module. Without^12, every Drupal 12 Functional and FunctionalJavascript test that enables such a submodule fails at install. - Declare
string|falseas the return type of everygetMinkDriverArgs()override, such as the one in the base class of your FunctionalJavascript tests (see FunctionalJavascript tests). Drupal 12 declares that type on the parent method, so an override without it is a fatal error. Drupal 10 and 11 accept the typed override too. - Give your tests the PHPUnit attributes that Drupal 12 needs. Pair
@groupwith#[Group]and@dataProviderwith#[DataProvider], the way the example tests do. Drupal 10 runs PHPUnit 9.6, which reads only the annotations, and Drupal 12 runs PHPUnit 12, which reads only the attributes.@coversand@coversDefaultClassonly narrow code coverage, so the example tests leave them without an attribute. Kernel, Functional and FunctionalJavascript test classes also need#[RunTestsInSeparateProcesses], which Drupal 12 requires and the example tests have carried since 4.18.0.
Drupal 12 requires PHP 8.5, and CI runs every Drupal 12 job on it. Adding Drupal 12 leaves extra.eddy.drupal-version on the newest major you test that has a stable release. So a plain ahoy build keeps working on the PHP you have. To build Drupal 12 locally on PHP 8.5, run DRUPAL_VERSION=12 ahoy build. On an older PHP, the build stops before it changes anything and names the PHP version the release needs.
Your own tests can run into more of what changed in Drupal 12. A contrib module that adds Drupal 12 next to 10 and 11 is most likely to meet these breaks. Each fix works on all 3 majors:
- Update path dumps. The only database dumps that Drupal 12 ships in
core/modules/system/tests/fixtures/update/aredrupal-11.3.0.bare.standard.php.gzanddrupal-11.3.0.filled.standard.php.gz. Drupal 10.6 and 11.0 to 11.3 don't have the 11.3.0 dumps. Drupal 11.4 has them next to thedrupal-10.3.0.*ones that Drupal 12 dropped. For an update path test on every major, set$databaseDumpFilesto the 11.3.0 dump when its file exists, and to the 10.3.0 dump otherwise. - Typed
KernelTestBaseproperties. Drupal 12 declares$strictConfigSchemaasbool, along with properties such as$containerand$siteDirectorythat Drupal 10 and 11 leave untyped. PHP rejects a redeclaration whose type differs from the parent's, so no redeclaration works on all 3 majors. Set the value insetUp()beforeparent::setUp()instead, such as$this->strictConfigSchema = FALSE;. text_with_summary. The field type, itstext_textarea_with_summarywidget and itstext_summary_or_trimmedformatter moved from core to the contribdrupal/text_with_summarymodule, andnode_add_body_field()is gone. That module supports only Drupal 11.5 and 12, so a module that needs it on Drupal 12 can't require it on Drupal 10. When only your tests use the field type, a lifecycle script can add the contrib module to the builds whose core doesn't have it.- Traits in
#[CoversClass]. PHPUnit 12 rejects a trait named in#[CoversClass]with a warning that it "is not a valid target for code coverage". With the template'sfailOnWarning="true", PHPUnit turns that warning into a failed job, even when every test passes. Name a trait with#[CoversTrait]instead. PHPUnit 11 has that attribute, and PHPUnit 10.5 skips attributes whose classes it lacks. PHPUnit 9 ignores attributes. - The
filtermodule in Kernel tests.TextItemBase::applyDefaultValue()gets the fallback text format from thefiltermodule. So a Kernel test that creates an entity with a text field needsfilterin$modules, because Kernel tests don't enable dependencies. Core's11.5.xbranch has the same change, so the Drupal 11canaryjob runs into it after an 11.5 beta or release candidate is out. - Mocks without expectations. Drupal 12 builds run PHPUnit 12.5, which reports a notice for every mock object a test creates without an expectation.
expects($this->any())doesn't count as an expectation. The template'sphpunit.xmlonly reports the notices, so a module with hundreds of such mocks gets hundreds of notices in every run. Drupal 12 core's ownphpunit.xml.distfails the run on them. Create test doubles that only return values withcreateStub(), which every PHPUnit version that Drupal 10 to 12 runs provides. A test class that keeps such mocks on purpose can opt out with#[AllowMockObjectsWithoutExpectations], which older PHPUnit versions ignore.
Check that the dependencies in your composer.json support Drupal 12 too, and add the new jobs to your branch protection rules or ruleset (see Branch protection). Continuous integration explains how the Drupal 12 jobs follow Drupal releases.
Everything else
The rest of the template changed too, and an upgrade merges these files like any template update:
-
.ahoy.ymlandMakefilechanged in these ways:- They call
vendor/bin/eddy-*and install the tooling first. - When you keep FunctionalJavascript tests,
teststarts the browser before PHPUnit, so it needs either Chrome or Docker withWEBDRIVER_BACKEND=selenium.test-unit,test-kernelandtest-functionaldon't. resetstops only the project's own webserver, and its browser when you keep FunctionalJavascript tests. The 4.xresetstopped every PHP process you were running. The 5.x one also removesvendor/, which is how a local checkout picks up a newer patch release of the tooling.make startdiscovers a free port the wayahoy startdoes. The 4.xMakefileexportedWEBSERVER_PORT ?= 8000, so it always took port 8000.- Both wrappers give a variable set in your shell precedence over its value in
.env, the other way around from 4.x. So a variable you export from your shell profile overrides the.envof every project (see Environment variables). - Ahoy reads
.envas a Bash script, so put quotes around a value that holds a space.
- They call
-
.github/workflows/test.ymlinstalls the tooling and callsvendor/bin/eddy-*. Its Composer cache stores packages. The 4.x cache step pointed at a directory Composer never wrote to, so that cache stayed empty. -
.github/workflows/deploy.ymlinstalls the tooling and callsvendor/bin/eddy-deploy(see Deployment). -
phpunit.xml,phpunit.d10.xml,phpcs.xml,phpstan.neon,rector.phpandjest.config.jschanged in these ways since 4.18.0, apart from their comments, so keep any settings you added to them:- Each names only the custom directory of your extension's type,
web/modules/customorweb/themes/custom. - Both PHPUnit files count a module's
.moduleand.installfiles, or a theme's.themefile, toward coverage. - 4.19.0 dropped the
force="true"of theMINK_DRIVER_ARGS_WEBDRIVERentries in both PHPUnit files. So in 5.x, a value you export overrides the one in the file. On 4.18.0 or earlier, the file's value won.
A project without FunctionalJavascript tests loses those entries altogether, since only those tests read them. A project that doesn't test Drupal 10 loses
phpunit.d10.xml, which only a Drupal 10 build reads. - Each names only the custom directory of your extension's type,
-
.twig-cs-fixer.phpand the lint scripts ofpackage.jsonalso name only the custom directory of your extension's type. -
.editorconfigloses its[Makefile*]section without aMakefile, and its[package.json]section without the npm tools..gitignoreloses/.chromedriverwithout FunctionalJavascript tests. -
renovate.jsonturns on the weekly lock file maintenance (see Dependency updates). Without the npm tools, it loses the npm manager and its package rules instead. -
.cspell.jsonaddsisigandcweagans, whichcomposer.dev.jsoncontains. It dropskillallandpkill, which nothing in 5.x uses. It also drops theignorePathsthat pointed into.scaffold/, and the words of the tools you don't keep, such aslullabotwithout FunctionalJavascript tests. It swapsautologoutforlinkit, the example module the template'scomposer.jsonsuggests, so keepautologoutif your project still uses that word. Merge it with your own words. -
AGENTS.mddescribes the tooling commands,vendor/andscripts/eddy-toolingin place of.devtools/, drops CircleCI and carries the new "Updating the scaffold" section. -
CONTRIBUTING.mddocuments only the command wrappers you keep, or thevendor/bin/eddy-*commands when you keep neither. Carry your own notes over from the old copy. -
README.mddrops the CircleCI badge, and shows Drupal badges only for the majors you test and PHP badges only for the PHP versions they support. It states the license as GPL-2.0-or-later instead of reading it from GitHub, and credits Eddy. -
.gitattributesdrops its.circleciand.devtoolslines, and the lines of a command wrapper, the npm files orphpunit.d10.xmlthat your project doesn't have.
After the upgrade
Run these checks before you merge the upgrade pull request:
- Build the project with
ahoy buildormake build, which install the tooling intovendor/first. Without a wrapper, runscripts/eddy-tooling, then the commands that Build the site shows under No wrapper. - Lint it with
ahoy lintormake lint. Without a wrapper, run the commands that Coding standards shows. - Test it with
ahoy testormake test. Without a wrapper, run the commands that Testing shows. - Check that every CI job passes on the pull request.
- Check that the branch filters in
.github/workflows/test.ymland.github/workflows/draft-release-notes.ymllist the branches you build from. - Check that your branch protection rules or ruleset require the jobs that Branch protection lists for the Drupal majors you test, and no
ci/circleci: <job>checks. - If you set the
DEPLOY_PROCEEDsecret to0for the upgrade, set it back to1.
After you merge it, confirm that the next Deploy run reaches your Drupal.org repository, or reports a notice that says why it didn't. On a release from 4.15.0 to 4.18.0, also delete the branches that pull requests left in your Drupal.org repository. On 4.15.0 to 4.17.0, delete the branches named after release tags too (see Deployment).