Architecture
This is a walkthrough of how Behat Steps works: what the pieces are, and how a Gherkin step travels from a feature file to a browser or a Drupal API. Each section is traced from the sources it names, and the diagrams sit next to the prose that explains them.
This document and its diagrams are generated and maintained by an AI agent via the update-architecture-docs skill in .claude/skills/update-architecture-docs/SKILL.md. The content is derived from the source code. If this documentation and the code disagree, the code wins.
Looking for a reference instead? Web steps and Drupal steps list the steps, Web helpers and Drupal helpers list the toolbox behind them, and Configuration lists the options and tags. The step and helper pages are generated, and so are the option and tag tables in Configuration.
Diagram sources
Every diagram is a PlantUML source in docs/content/architecture/, rendered to a committed light .svg. Both are tracked, so a reviewer sees the source diff and the rendered result. Select a diagram, or its file in the table, to open it at full size.
| Source | Renders | Shows |
|---|---|---|
architecture.puml | architecture.svg | The 3 library layers with the Drupal half of the 2 lower ones, the 3 contexts at the package root, the consuming project, and the runtime around them |
class-traits.puml | class-traits.svg | Every step trait in both namespaces, and the context hierarchy they mix into |
class-context.puml | class-context.svg | The context hierarchy and the contracts its initializer injects through, its services and prerequisite declarations, the helper traits, representative step traits, and the exceptions a failing step throws |
class-backends.puml | class-backends.svg | The Behat-free backend layer: the base contract and the capabilities any backend may provide at its root, the blackbox backend, and under Backend\Drupal the 2 Drupal backends, the Drupal-only capabilities and the Core bridge |
class-browser.puml | class-browser.svg | The browser capability layer and the 2 classes that resolve it, the HTTP client factory, and the browserkit_http browser driver factory |
dataflow-step.puml | dataflow-step.svg | A step running in a consuming project |
dataflow-http.puml | dataflow-http.svg | A step sending its own request through the page, detached or bare client |
dataflow-docs.puml | dataflow-docs.svg | docs.php reflecting, validating and rendering every reference document |
dataflow-docs-site.puml | dataflow-docs-site.svg | The documentation site building from docs/content/, linking repository files on GitHub, and being checked and published |
dataflow-tests.puml | dataflow-tests.svg | The fixture Drupal site, the nested Behat harness, and the coverage merge |
Regenerate every SVG after editing any source:
plantuml -tsvg docs/content/architecture/*.puml
PlantUML must be on the PATH. Install it with brew install plantuml on macOS or apt-get install plantuml on Debian and Ubuntu.
PlantUML is a Java application and needs a JRE or JDK 11 or later. macOS does not ship one, so check with java -version and install a JDK if that command is not found - the Homebrew formula pulls OpenJDK in as a dependency, which covers most setups. Class, component and activity diagrams also need Graphviz, which Homebrew pulls in the same way.
Three layers
The library is no longer just a bag of traits. It's 3 layers, stacked, and the boundary between them is the most important thing to understand here. Each layer is a namespace directly under DrevOps\BehatSteps, and the 3 contexts a project extends sit beside them at the package root.
src/Backend/ - the backend layer. Talks to the site. Knows nothing about Behat. Its root holds what any backend may provide: BackendInterface, BlackboxBackend, EntityStub, the creation alias contracts, the exceptions, the random generator and 11 capability interfaces. Backend\Drupal holds everything that is Drupal's: DrupalBackend and DrushBackend, Core with its field handlers and Drupal creation aliases, and the 24 capabilities only a Drupal backend provides.
src/Extension/ - the extension layer. Wires the backend layer into a Behat suite: the extension and its service container, the backend, user and scenario tag registries, the authenticators, the contracts the context initializer injects through, option resolution and the extension parameters, trait prerequisites, the entity and user creation hooks, the browser capabilities, and the HTTP clients a step sends its own requests through. Extension\Drupal holds the Drupal backend provider, which contributes the drupal and drush settings, the Drupal login defaults and the 2 backends' service definitions, and the node, term and language creation hooks.
src/Steps/ - the vocabulary. The step traits, split into Steps\Web and Steps\Drupal, the 13 step-free helper traits under Steps\Helper, split the same way, and Steps\Exception\AssertionException, what a session-less trait throws. This is the layer a consuming project registers, or mixes into a context of its own.
WebRawContext, WebContext and DrupalContext - the package root. The 3 contexts are the 1 place that reaches every layer: WebRawContext exposes the extension's services to the traits written against it, and the other 2 compose the vocabulary. They sit above the layers rather than inside 1 of them, and ClassNamingTest holds the root to those 3.
The layering rule is enforced, not just documented. scripts/lint-layers.php declares 6 boundaries and the namespaces each one excludes: src/Backend may not reference Behat, Mink, Extension, Steps or the 3 contexts; src/Extension may not reference Steps or the 3 contexts, apart from ClassGenerator, which writes extends WebRawContext into the starter class it generates; the root of each of those 2 layers, outside its Drupal sub-namespace, may not reference Drupal or either Drupal sub-namespace, apart from BehatStepsExtension, the composition root that lists the shipped backend providers; src/Steps/Web with WebRawContext, WebContext, src/Steps/Exception and every trait under src/Steps/Helper/Web may not reference Drupal; and src/Steps/Drupal with src/Steps/Helper/Drupal and DrupalContext may not reference Drupal either, nor any class under Backend\Drupal but its capabilities, and of those not CoreCapabilityInterface, which declares getCore(). When a forbidden and an allowed prefix both match a name, the longer one decides, which is how that 1 capability stays out while the rest are let in. LintLayersTest fails a file under src/ that no boundary reads, so a new directory or a new class at the package root can't sit outside them all. The step traits are held to their calls too: a Drupal step trait never calls getCore(), which hands out the in-process core, and no step trait calls a function PHP doesn't define or a target only runtime knows, so Drupal's procedural API stays out as well. ahoy lint runs it alongside PHP_CodeSniffer, PHPStan, Rector, gherkinlint and scripts/lint-traits.php, which holds a step trait to composing no other step trait and a helper trait to registering no Gherkin. So the backend layer stays usable without Behat loaded, the neutral root of each layer without Drupal, a Drupal step trait reaches Drupal only through a capability, and the check catches the first import or call that would break any of those rather than the tenth.
The dependency footprint reflects the shift. composer.json requires PHP 8.3+, Behat 3.33 or 4, Mink and the BrowserKit driver, plus friends-of-behat/mink-extension and 8 Symfony components, BrowserKit, HttpClient and Mime among them for the requests steps send themselves, and no Drupal package at all. This is a framework now, not a trait bag.
The backend layer
A backend is the thing that actually talks to the site. BackendInterface is deliberately tiny - getRandom(), bootstrap(), isBootstrapped() - and everything else a backend can do is expressed as a separate capability interface. getRandom() returns the package's own RandomGeneratorInterface from Backend\Random, so the contract names no Drupal type. The 11 under Backend\Capability are the ones any backend may provide: authentication, cache, content, cron, creation aliases, entity lookup, language, mail, the request environment, roles and users. The 24 under Backend\Drupal\Capability only a Drupal backend provides: batch, blocks, config, config entities, Drupal's API in this process, draggable views, Drush commands, entity, bundle and field definitions, field handler registration, files, the log, menu links, modules, nodes, path aliases, queues, queue items, redirects, Search API, search indexes, state, terms, user activity and workflows.
That one is the odd one out. Resolving CoreCapabilityInterface bootstraps Drupal in this process, and its getCore() hands out Core itself, which stays the in-process backend's own contract. So no step resolves it, and the layer lint rejects it under src/Steps and in DrupalContext. A step that has to run in-process asks for a capability only DrupalBackend provides instead, such as path aliases or user activity, which also names what a backend of a project's own would implement. The library's own test harness is what still resolves it, for the test-only steps that call Drupal's API directly.
3 backends implement different slices of that set. DrupalBackend bootstraps Drupal in-process and implements 34 of the 35, every one but Drush commands. DrushBackend shells out and implements the 14 Drush can service in full, Drush commands included. A backend declares a capability only when it serves every method of it, so adding a queue item and indexing 1 entity sit on capabilities of their own that DrushBackend doesn't declare, and a step needing one reaches the in-process backend whatever the order lists first. Both sit under Backend\Drupal. BlackboxBackend implements the base contract only, for testing a remote site with no backend access at all, and sits at the root with the contracts.
The request environment is the capability a trait hands to every backend rather than the first. ConfigOverrideTrait signals an override with the X-Config-No-Override request header, which the browser carries only to the pages it loads. So before each step the trait also passes the header to each backend in the scenario's order that provides RequestEnvironmentCapabilityInterface, without bootstrapping one: DrupalBackend writes it to $_SERVER for the Drupal it runs, and DrushBackend to the environment of every command it starts. Whichever backend then serves the step, the site code it runs sees the same signal the browser sends.
This is what makes a step's requirements explicit rather than implicit, and it is also how a backend is chosen. A step names the capability it needs - a step that creates a node asks for NodeCapabilityInterface - and BackendRegistry walks the scenario's backend order and hands back the first backend implementing it, bootstrapping only that one. When none does, the step fails with UnsupportedBackendActionException naming the capability and the order, instead of a fatal error somewhere deeper.
The order itself comes from the extension configuration: its backends list is both the allow-list and the precedence order, and a @backend:NAME tag moves one of those names to the front for a single scenario or feature. A step never names a backend, so the shipped vocabulary carries over unchanged to a backend a project registers itself.
DrupalBackend delegates the messy part - turning a Gherkin table into a saved entity, or new values into an update of one - to Backend\Drupal\Core. That's where the field handlers live, one per field type (DatetimeHandler, EntityReferenceHandler, ImageHandler, LinkHandler, AddressHandler and a couple of dozen more), along with the classifiers that pick a handler and the parser that walks a stub's fields. A project lists handlers of its own under the drupal.field_handlers option, and the provider registers them through the backend's FieldHandlerCapabilityInterface, which hands each to Core after the shipped ones, so an entry adds a handler for a new field type or replaces a shipped one. Backend\Alias holds the creation alias contracts and RolesAlias, which both Drupal backends use, and Backend\Drupal\Core\Alias resolves the Drupal-specific values: an author name into a uid, a parent term name into a tid, a vocabulary machine name into the term's vid. A project lists aliases of its own under the drupal.creation_aliases and drush.creation_aliases options, and the provider registers each on its backend through CreationAliasCapabilityInterface, after the shipped ones. Core runs the aliases for every entity it creates from field values, and its field parser leaves a registered alias's column for the alias to resolve; DrushBackend keeps aliases of its own and runs the ones for users, the only entity it creates. A config entity has no fields to expand, so ConfigEntityCapabilityInterface creates, updates and copies one through its entity storage instead, which still runs the save hooks a write to the config object would skip.
DrupalBackend builds that Core itself, in its constructor, because the Drupal major it detects there decides the class: the one the drupal.core option names, otherwise the newest version core Core{N}\Core at or below that major, otherwise the base Core. No version core ships, so a site that names no class through drupal.core gets the base Core. setCore() takes any CoreInterface, and rejects one whose nearest Core of the package is the one for another major, so a project's Core built on the package's can't skip the changes a major needs.
The helpers a step calls take and return stubs, never Drupal objects. A lookup through QueryTrait returns IDs, or stubs carrying them under the entity type's id key, and the step hands a stub to the capability that updates, reads or deletes the entity. So a step that changes an entity runs on any backend providing that capability, the same as one that creates it.
The extension layer
BehatStepsExtension is a Behat extension registered under the behat_steps config key, and it replaces the Drupal Extension entirely. It loads the service definitions, registers the backends named in the Behat configuration, validates the backends list against those registrations, wires the services, defines the shared HTTP transport, and aliases the library's DocumentElement over Mink's own. It names no site technology itself: each backend provider, an implementation of BackendProviderInterface, adds its backends' settings nodes to the configuration tree, the login text and selector defaults for the sites those backends reach, the service definitions of each backend whose settings are present, and the wiring those definitions need once every service is loaded. Extension\Drupal\ServiceContainer\DrupalBackendProvider is the shipped provider - the drupal and drush nodes, the /user login defaults, the 2 service definition files, the drupal.core class it hands to the in-process backend, and the registration of the field handlers drupal.field_handlers lists and of the creation aliases drupal.creation_aliases and drush.creation_aliases list - and BehatStepsExtension is the composition root that lists it.
BackendListener builds the backend order once per scenario, before the first step: it takes the configured backends list, moves every @backend: name to the front, and hands the result to BackendRegistry. A tag naming a backend the list does not hold fails there, at scenario start, so a typo cannot quietly run the wrong backend. A @driver: tag fails there too, naming the @backend: tag to use instead. It publishes the scenario's tags to ScenarioTagRegistry in the same pass, which Behat dispatches before the first BeforeScenario hook, so a tag that sets a trait option reaches a step as well as a hook. Both registries live in Extension\Registry, beside UserRegistry, which keeps the users a scenario created and the one logged in.
SkipTagListener runs on the same event, just before it. A @behat-steps-skip: tag has to name a trait that a context of the suite composes, because a hook reads it by its trait's name alone, so a tag carrying a hook method name, a misspelled trait or a trait no context composes would switch nothing off. The listener collects the traits from the context classes the event's environment holds, through their parent classes and the traits those compose in turn, once per list of classes. It fails the run there instead, naming the tag.
Extension\Config holds option resolution, which WebRawContext delegates to rather than carrying. ConfigSchemaReader is the only piece that reflects: it walks a context class for <prefix>ConfigSchema() methods and returns the Option objects they declare, cached per class. TraitOptionResolver layers the declaration defaults, the extension's steps section and the context's config argument, and exposes a typed read per declared type. TagOverrideResolver applies the tags an option declares, plus the @behat-steps-skip:<Trait> tag every enabled option carries, and holds the pattern a skip tag's value has to match. A resolver depends on the context class that declared the options and on the config argument that context was given, so TraitOptionResolverFactory builds one per context; registering another factory under behat_steps.config.resolver_factory replaces resolution everywhere at once.
ParametersTrait sits beside them and reads the extension's own parameters: the interface text, the selectors and the login settings. WebRawContext composes it, and so does Authenticator, which is a container service rather than a context. The contract it fills, ParametersAwareInterface, sits in Extension\Context with the other 2 the context initializer checks for, but the trait stays in Config, so no container service imports the context namespace. Every type under src/Extension sits in a sub-namespace like this, and Tag is the 1 type at the root, because every layer reads tags through it. ClassNamingTest fails any other.
Extension\Prerequisite holds what a trait needs from the site, which is a separate question from whether the trait is switched on. Prerequisite is 1 declaration: a capability interface, an optional static closure that takes a backend providing it and returns whether the prerequisite holds, and a description that both the failure message and the step reference quote. A trait returns its declarations from <prefix>Prerequisites(), named like its <prefix>ConfigSchema(), and PrerequisiteReader calls that method once per context class and trait for the run, since a context can redeclare it. It caches the declarations but never an answer, because a tag, a step or an out-of-process command can install or uninstall a module at any time.
Extension\Hook holds the entity and user creation hooks a project declares with #[BeforeEntityCreate], #[AfterEntityCreate], #[BeforeUserCreate] and #[AfterUserCreate], and Extension\Drupal\Hook the node, term and language ones. Each attribute names the call class it registers through HookAttributeInterface::getCallClass(), so HookAttributeReader builds a call without a map of attributes to calls and imports no Drupal hook.
Browser sessions come from Mink's own extension, which the suite registers alongside this one. BehatStepsExtension::initialize() hands it a BrowserKitFactory, and Mink keys its browser driver factories by name, so that factory replaces Mink's own for browserkit_http. Behat calls initialize() once every extension is activated and before it builds any configuration tree, so the swap holds whichever order the suite lists the 2 extensions - it's the same hook the Chrome extension uses to add its browser driver.
The factory keeps Mink's configuration tree and builds each session exactly as Mink does: an HttpBrowser on a client of its own, carrying the session's http_client_parameters for every host. It adds 1 thing, which is recording those options. BehatStepsExtension::process() turns them into 1 transport service, behat_steps.http_client, once Mink has built every session, through HttpClientFactory::createTransport(), with the options scoped to the base_url host. The detached and bare clients send through that transport. Sessions that declare different options stop the run there, because the transport carries 1 set.
The browser half resolves by capability, the same way the backend half does. Extension\Browser\Capability holds 5 interfaces - cookies, the page's HTTP client, JavaScript, the keyboard and request headers - and Extension\Browser\Adapter holds 1 adapter per shipped browser driver family, because a browser driver comes from another package and can't implement them itself. BrowserCapabilityResolver offers the session's browser driver to each adapter in turn, registered adapters first. WebRawContext::browserDriverFor() returns the adapter that answers, or throws UnsupportedDriverActionException naming the capability.
Requests a step sends itself go through Extension\Http. WebRawContext hands out 3 clients, and all 3 are BrowserKit browsers. httpPageClient() resolves the session's own HttpBrowser through the HTTP client capability, so its response becomes the page. httpDetachedClient() and httpBareClient() come from HttpClientFactoryInterface, the behat_steps.http_client_factory service, which builds a fresh HttpBrowser on the shared transport each time, with any options the caller passes taking precedence over the session's. The detached one carries an HttpIdentity - the session's cookies, the request header bag and the base_url credentials - and sends its headers and credentials to the base_url host only. HTTP clients tells the same story from the consuming project's side.
The 3 contexts at the package root are one chain. WebRawContext is its root and registers no steps:
- Backend access:
backendFor(), which resolves the capability a step names, andgetBackend()for the rare caller that wants one suite backend by name. - Option resolution:
getOptionBool(),getOptionInt(),getOptionFloat(),getOptionString()andgetOptionArray()each read a trait's option at the type its declaration defaults to, through the declaration default, the extension'sstepssection, the context'sconfigargument and the scenario's tags.getOption()covers a declaration that defaults toNULLand so names no type. - Extension parameters:
getParameter(),getText()andgetSelector(), fromParametersTrait. - Basic authentication:
getBasicAuthenticator(), becauseBasicAuthTraitis a web trait and calls it. - Browser access:
browserDriverFor()andbrowserDriverHas(), which resolve a browser capability. - HTTP clients:
httpPageClient(),httpDetachedClient()andhttpBareClient(). - Prerequisite checks:
assertPrerequisites()throws for the first declaration that doesn't hold, naming it. A hook passes its scope as well, and when its trait has anenabledoption the failure also names that option and the skip tag that switch the trait off; a step's failure names neither, because they stop only hooks.prerequisitesMet()answers the same question without throwing, for a teardown. Each check goes throughanyBackendFor(), which returns a backend the scenario already reached, when it provides the capability, before the first one listed, so checking starts a backend only when none the scenario reached provides the capability. A trait adapting to an optional module asks through it too. - The hook dispatcher and
skipTag(). - 3 of the web helper traits:
LastStepTrait,RequestHeadersTraitandStringTrait.
WebContext extends it and composes every trait under src/Steps/Web. DrupalContext extends WebContext and composes every trait under src/Steps/Drupal. The Drupal scenario lifecycle does not sit on the context: each step trait composes the helper traits it needs, so the lifecycle arrives with the traits that use it:
- Entity creation (
entityLifecycleCreateNode,authCreateUser,entityLifecycleCreateTerm,entityLifecycleCreate,entityLifecycleCreateLanguage), each dispatching before/after hooks so a project can adjust a stub in flight. - Cleanup:
entityLifecycleAfterScenariodeletes what the scenario created, in reverse, andauthAfterScenariodeletes its users and then its roles, running the role cleanup even when deleting the users fails. - Authentication:
authLogin,authLogout,authIsLoggedIn, delegated toAuthenticator.
Every helper member carries its trait's prefix, so two helpers mixed into one context cannot collide and a reader can tell from a call site which trait has to be composed.
Note where cleanup lives. It is the lifecycle trait's job, not a step trait's, so it arrives with whichever step traits create the thing being torn down. A suite that extends WebContext, or composes no entity-creating trait, never runs those hooks at all.
Authentication splits along the same line, with both halves in Extension\Auth. Authenticator holds a Drupal session and lives behind UserAwareInterface, which DrupalContext declares; BasicAuthenticator needs only Mink and a base URL, so WebRawContext carries it through getBasicAuthenticator() and a suite with no Drupal site still gets basic auth. Neither authenticator is a context, so each reaches the Mink session through MinkAwareTrait in Extension\Browser, and Authenticator reads the login text and selectors through ParametersTrait.
Authenticator is the browser capability layer's other consumer. A real browser can return from the login click before it loads the next page, so Authenticator builds a BrowserCapabilityResolver of its own, and when the session runs JavaScript it waits at least JAVASCRIPT_LOGIN_WAIT seconds for a login signal: first for the browser to leave the login form or show the logged-in selector, and, as a last resort, for that selector or a logout link on the homepage. It's a separate resolver from the one on WebRawContext, so an adapter a project registers on its context doesn't reach it.
A consumer extends exactly one class, and registering two of them is fatal: DrupalContext inherits WebContext's 28 traits, so both registered would register every web step twice. WebContext::assertOneContext() runs on BeforeSuite and names that rather than letting Behat report a RedundantStepException about an arbitrary step. ContextCompositionTest holds the directory-to-context coverage in both directions and holds the chain to one composition of each trait.
The step vocabulary
Traits live in 2 places, and the split is meaningful:
src/Steps/Web/inDrevOps\BehatSteps\Steps\Web- 28 traits that talk to Mink and know nothing about Drupal.PathTrait,ElementTrait,JsonTrait,RegionTrait,CommandTraitand friends.src/Steps/Drupal/inDrevOps\BehatSteps\Steps\Drupal- 29 traits that only mean something on a Drupal site.ContentTrait,UserTrait,MediaTrait,DrushTrait,WatchdogTrait, and so on. All butBatchTraitandBigPipeTraitgo through a backend; those 2 drive the page and wait for the batch progress bar and the BigPipe placeholders only Drupal renders.
That directory split isn't just tidiness. docs.php reads a trait's context straight off its subdirectory under src/Steps, so a file's location decides which index it lands in, and it also decides which shipped context has to compose the trait. Placement follows the subject rather than the dependency, so a suite for a site that isn't Drupal registers WebContext and gets no step it can't use. The helper traits under src/Steps/Helper/Web/ and src/Steps/Helper/Drupal/ are library code, not vocabulary, and are documented in the toolbox reference instead.
Each trait carries its steps as PHP attributes - #[Given], #[When], #[Then] from Behat\Step\* - sitting directly on the method that implements them. There's no .yml mapping and no separate registration step. The docblock above the method isn't decoration either: docs.php parses it, and the @code example inside it is mandatory.
Every trait declares what it needs from its host with @phpstan-require-extends: 54 name WebRawContext because they reach for a backend, the extension configuration, a browser capability or an HTTP client, and 10 name Mink's RawMinkContext because a session is all they touch. A step trait also composes the helper traits its body calls, and ContextCompositionTest fails one that calls a helper member it has not composed. Mix a trait into a class without that ancestry and PHPStan says so before a test ever runs. The BareMinkContext fixture composes the 7 web step traits among those 10 into a bare RawMinkContext subclass, and ContextCompositionTest holds it against the annotations, so a requirement that tightens is caught.
A step body is a thin wrapper over those helpers and the trait's own: it parses its arguments, calls helpers and guards on what they return. StepBodyTest parses every step trait and fails a step that calls another step of its trait, or that holds a switch, a match, a try, a closure, a loop doing more than dispatching 1 call, or more than 4 statements besides its guards. That's what keeps the toolbox complete: every behavior a step has is a helper a project's own step definitions can call.
Step traits never use other step traits. Shared logic goes in a trait under src/Steps/Helper/ named for its concern - last-step tracking, the request header bag, string shaping and step argument parsing, table transposition and token replacement, fixture file resolution, heading lookup, the JavaScript error registry, and the whole Drupal scenario lifecycle - and nowhere else. A trait's directory is its classification: src/Steps/Web and src/Steps/Drupal register Gherkin, src/Steps/Helper registers none, and scripts/lint-traits.php makes that a lint rather than a convention. A helper trait composed by a step trait and by the context under it holds one property slot, so both reach the same state.
Flow 1: a step runs
Nothing in this library is invoked directly. Behat owns the loop, and the traits are just where the matching methods happen to live.
When Behat starts, it instantiates every registered context, injects those services through BackendAwareInitializer, which calls the setters of whichever of ParametersAwareInterface, UserAwareInterface and BackendAwareInterface the context implements, and scans each class for step attributes - including every attribute inherited through a use statement. Matching a Gherkin line to a method is then ordinary Behat behavior. The trait method runs with $this bound to the context, so $this->getSession() reaches Mink and $this->backendFor() reaches the first backend in the scenario's order that provides the capability the step names.
Because a step attribute is inherited through use, two registered contexts composing the same trait register its steps twice and Behat fails with a RedundantStepException. That is why each class in the chain composes a trait the ones above it do not, and why a project that hand-composes a trait registers its own context instead of the shipped one that already carries it.
Assertions fail by throwing, and which exception is part of the public contract:
- A trait with a Mink session throws
ExpectationException, passing the browser driver as the second argument so the message carries page context. A missing element, on the page or in an XML response, throwsElementNotFoundException. An attribute, a JSON path or a table column isn't an element, so a missing one throwsExpectationException. - A trait with no Mink session -
CommandTrait,Drupal\ConfigTrait,DrushTrait,ModuleTrait,QueueTrait,RedirectTrait,StateTrait,WatchdogTrait- throwsDrevOps\BehatSteps\Steps\Exception\AssertionException, which needs no browser driver. - A bad step argument or unmet prerequisite is not an assertion failure and throws
\RuntimeException. - A capability the session's browser driver lacks throws Mink's
UnsupportedDriverActionException. A capability no backend in the scenario's order provides throws the backend layer's ownUnsupportedBackendActionException.
A trait's hooks can be switched off from a feature file. @behat-steps-skip:JavascriptTrait on a scenario or a feature switches off every hook JavascriptTrait registers: each scenario hook asks skipTag(__TRAIT__, $scope), which reads the tag together with the trait's enabled option. A hook that acts returns when its trait is skipped. A step hook or a transform sees no scenario tags, so a trait that needs the answer there has its BeforeScenario hook record it in a property instead, as DateTrait does for its transforms. A scenario hook that only resets its trait's own state has nothing to switch off and carries no guard; SkipGuardTest lists each one with the reason, and fails a new hook that asks the guard in neither shape and isn't listed. Entity cleanup honors the same convention through @behat-steps-skip:EntityLifecycleTrait, plus @behat-steps-entity-cleanup-skip:<entity_type> for leaving one type in place.
Switching a trait off and failing on its prerequisites stay separate. A setup hook returns on skipTag() first, so an opted-out trait asks nothing of any backend, and only then calls assertPrerequisites(), so an opted-in trait whose prerequisites don't hold fails the scenario at its start and its steps are skipped. WatchdogTrait is the case in the diagram above: it needs LogCapabilityInterface and the dblog module, so a profile listing only blackbox fails at scenario start unless the trait is switched off, while one listing drush runs the check over Drush. A step checks its trait's prerequisites as it runs, so a suite that never runs a webform step never needs webform, and a teardown asks prerequisitesMet() instead, so it can't replace a failure the scenario already recorded.
Every one of those tags is read through Tag, which strips the leading @ first. Behat 3 removes it by default and Behat 4 keeps it, so going through Tag is what lets the same tag match on both.
Flow 2: a step sends its own request
Most steps read the page the Mink session holds. A few send a request of their own from PHP - a file download, the hreflang return-link check, the accessibility engine fetch, a REST call - and each one goes through 1 of the 3 clients. The method a trait calls names which.
The transport comes first, and it's built once per run. Mink calls BrowserKitFactory::buildDriver() for every browserkit_http session it's configured with. The factory records the session's options and returns what Mink's own factory returns: a BrowserKitDriver over an HttpBrowser on a client created from those options, which applies them to every host. BehatStepsExtension::process() then defines behat_steps.http_client from the recorded options, scoped to the base_url host, and the detached and bare clients send through it. So a certificate setting that lets the page load also lets a download through.
Then a step asks for a client. FileDownloadTrait asks for the detached one: WebRawContext reads the session's cookies through the cookie capability, which all 3 adapters provide, adds the request header bag and the base_url credentials, and hands that HttpIdentity to the factory. The response lands in the trait's own state, so the next page assertion still reads the page. RestTrait asks for the page client instead, and only a BrowserKit session has one, so under Selenium2 or Chrome the step throws. AccessibilityTrait asks for the bare client, which carries nothing of the scenario and reaches the CDN with Symfony's defaults, since the transport applies the session options to the base_url host only.
Under @javascript the detached and bare clients work the same way, as a sidecar. The browser holds the page, and the client sends from PHP next to it, taking its settings from http_client_parameters rather than from the browser's capabilities. So a suite whose sessions are all JavaScript still declares a browserkit_http session when those requests need options of their own.
Flow 3: the reference documentation generates itself
Every reference document is generated, and docs.php is the only thing that generates them. It's a plain procedural script - top-level functions, no classes - and it runs against the fixture site's autoloader because it needs to reflect over real Drupal-dependent traits.
Each run renders the step reference and the toolbox reference twice. STEPS_FILE and HELPERS_FILE name the first copy, STEPS.md and HELPERS.md at the repository root, each with an index per context followed by a section for every trait or class. The second copy is for the documentation site, under PAGES_DIRECTORY, docs/content/, in 1 section directory for the steps and 1 for the helpers of each context: web-steps/, drupal-steps/, web-helpers/ and drupal-helpers/. Each section holds an index of its pages in its README.md and 1 page per trait or class beside it. CONFIGURATION_FILE names the third target, docs/content/guides/configuration.md, for the option and tag tables.
The root files, the 4 section indexes and the configuration guide keep their generated content between a start and an end marker, with 2 pairs in the configuration guide. A page is generated whole instead. render_step_pages() and render_helper_pages() render it, and reference_page() builds its path from the kind of page, the context and the name without the Trait suffix, so the steps of Drupal\BigPipeTrait get docs/content/drupal-steps/big-pipe.md. page_section() files the toolbox under the generic context, so the WebRawContext page sits in web-helpers/.
A page takes its name from the @title tag in the docblock of its trait or class. parse_class_comment() reads the tag and keeps it out of the description, so @title Batch API names both pages of Drupal\BatchTrait and changes nothing in STEPS.md. The front matter carries that name as the sidebar label, the name and the section as the page title, and the position page_positions() gives the page in title order. render_page_indexes() lists each section's pages in the same order, so the sidebar and the index agree. stale_pages() lists a page in any section directory that no trait or class renders any more, leaving each README.md alone, and the run removes it, so a renamed trait leaves no page behind. Only the targets whose content actually changed are written.
The trick is that it doesn't scan the filesystem for step definitions. It reflects over WebContext and DrupalContext, which between them compose every trait in the library. That makes composition the source of truth, and it documents exactly what a project gets by registering them: a trait file that exists but was never added to its context throws rather than being quietly skipped.
The same reflection pass yields both halves of the package, and visibility is what separates them. A public method carrying a Behat\Step\* attribute is vocabulary and goes to the step reference; a public method carrying no Behat attribute at all is toolbox and goes to the toolbox reference, unless its docblock withdraws it with @internal. A protected method is an implementation detail and appears in neither. A trait's steps are filtered by the trait-name prefix every member already carries, and its helpers by the file that declares them as well, so a helper borrowed from a composed helper trait is published under that helper trait alone. TOOLBOX_CLASSES adds WebRawContext to the toolbox half, because a project's own step definitions are written against its methods as much as against a trait's helpers. A class is read without the prefix filter, since its methods carry no trait name, but with the file filter, so the helper traits it composes aren't repeated under it.
The option and tag tables come from the code rather than from the reflection pass: the options are read off the config tree BehatStepsExtension::configure() builds, and the tags off tag_registry(). Neither can be added without appearing in the reference.
Each trait's entry in the step reference also carries a Prerequisites table and an Options table, below its description. docs.php reads both through PrerequisiteReader and ConfigSchemaReader, the readers the runtime uses, so a malformed declaration fails generation with the runtime's own message instead of being documented. In STEPS.md their headings name the trait, as ### Drupal\BlockTrait prerequisites does, so a search result that lands on one says which trait it belongs to. A trait's own page titles them Prerequisites and Options, because the whole page belongs to the trait.
Every Source, Example, Steps and Helpers link in the references is relative to the file holding it. relative_link() takes the linking file and the target, both relative to the repository root, drops the leading directories they share, and climbs out of the rest of the linking file's directory with ../. So STEPS.md links the source of PathTrait as src/Steps/Web/PathTrait.php, the trait's page docs/content/web-steps/path.md links it as ../../../src/Steps/Web/PathTrait.php, and its helpers page links its steps as ../web-steps/path.md.
The validation half matters more than the rendering half. Every step method declares its actor in an @actor:person or @actor:system docblock tag, and extraction stops with a RuntimeException naming the method when that tag is missing, unknown or repeated. The rest is where the numbered step rules in CONTRIBUTING.md stop being a style guide and start being enforced, and every check reads only the step text, the actor and the placeholder names, so none needs a tag of its own.
The keyword checks reject a step whose wording disagrees with its actor (a person step opens with I , a system step doesn't), a Given or When that says should, and a @Then step without should or whose method name lacks Assert. The placement checks hold each value to its side of its noun. A placeholder in front of a noun has to be in kind_placeholders(), the short list of placeholders that say what kind or how many, such as :content_type and :count, and every other placeholder follows its noun. A thing with a kind in front of it takes its name in a with phrase, a table follows the thing it describes rather than the verb, and the value follows only have, has, with or and. The naming checks reject a placeholder that isn't snake_case or that placeholder_synonyms() replaces, :text for anything but text on the page, a step that opens with a placeholder or is a regular expression, an acronym from step_acronyms() that isn't in capitals, and a phrase rejected_step_phrases() lists, from the wrong verb for a link or a button to the current.
A step that takes a table has to introduce it with the following. A table and a block of text both end their step with a colon, so extract_info() records which steps take a table through method_takes_table(), which reads the type of the method's last parameter. Add a method with 2 step attributes, a step with no @code example, a published helper with no summary, a trait or class with a page but no @title, and a getenv() name that docs/content/guides/configuration.md never mentions, and each of these is a hard error. tag_registry() does the same job for tags, guarding against separator drift so that @module:views never quietly becomes @module-views. What no check can judge is whether a step reads as plain English, so the rules about meaning, such as whether a Given names a state or which preposition fits, are settled in review.
One of those checks is less obvious than the rest. validate_step_patterns() compiles every registered pattern through Behat's own TurnipPatternPolicy and runs each documented @code example past all of them. An example that matches 2 definitions is a hard error, and so is a step whose own example no longer matches it. That catches the failure Behat itself won't: a pattern like the queue :queue processes all items also satisfies the queue :queue processes :count item(s), and Behat only notices when a scenario actually runs the shadowed step.
Run with --fail-on-change (that's ahoy lint-docs), the script regenerates the blocks and the pages in memory and exits non-zero if they don't match what's committed or a stale page is left, naming the targets that drifted and writing nothing. So the documentation can't drift, because a drifted build is a red build.
The documentation site
docs/ is a Docusaurus 3 site. Its pages sit in docs/content/, and the sidebar follows their folder tree. Docusaurus reads each .md page as CommonMark rather than MDX, because markdown.format is detect, so the raw HTML in the generated references renders on the site as it does on GitHub. Every target above but STEPS.md and HELPERS.md sits in the content the site builds from, so docs.php writes straight into it. A section directory holds its pages flat beside its README.md, so the sidebar stays 2 levels deep, and its _category_.json names the sidebar category, such as Web steps or Drupal helpers. The navbar's Docs item opens the sidebar at the introduction, and its Steps and Helpers menus link the 4 section indexes.
A page links another page, or any other file in the repository, by its relative path, which is also how relative_link() writes the generated links. The site serves only the pages, though, so docs/src/remark/repositoryLinks.js, a remark plugin, rewrites every relative link that leaves docs/content/ into a GitHub URL for that file or directory. It runs before Docusaurus resolves links, since Docusaurus would reject a link to a Markdown file outside docs/content/. The URL points at RELEASE_VERSION in a release build, at DOCS_REF in a CI build and at main in a local one, and a link to a file the repository doesn't hold stops the build. Docusaurus stops it too, on a broken page link or anchor.
docs/src/theme/ overrides 4 parts of the theme. MDXComponents renders every table through ScrollableTable from docs/src/components/, which takes keyboard focus when the table is wider than the page. prism-include-languages lets Prism's Gherkin grammar find a step keyword at the start of a line, not only after indentation. The mobile menu keeps keyboard focus inside while it's open and closes on Escape. The search box gets the ARIA attributes its autocomplete widget leaves out.
The home page at / is the 1 page that isn't Markdown: docs/src/pages/index.js, a React page with its words in lists at the top of the file. Pages and docs share the site root, so the introduction, the README.md at the top of docs/content/, takes the /introduction slug, and onDuplicateRoutes: 'throw' fails the build when a doc's slug claims a page's path. The page's counts come from docs/src/referenceCounts.js, a local plugin: it counts the pages docs.php wrote into the 4 section directories, leaving each README.md out, and the <summary> entries on them, 1 per step or helper. It publishes the counts as global data, so the page follows every regeneration without an edit, and it throws when a section holds no steps or helpers. The page's feature file, install command and icons are the FeatureFile, CopyCommand and Icon components in docs/src/components/, and the feature file highlights its Gherkin with the dark theme of docs/src/codeThemes.js in both color modes.
The checks and the 2 deploys in the diagram run in CI, as Continuous integration describes.
STEPS.md and HELPERS.md ship in the Composer package, where a consuming project's AI agent reads them from vendor/, and .gitattributes keeps all of docs/ out of it. docs/tests/unit/distributionArchive.test.js asks git check-attr about both files, about every tracked file under docs/ and about each directory above them, and fails when either reference stops shipping or a file under docs/ starts shipping. In vendor/, a Source link still resolves, because src/ ships, while an Example link points into tests/, which doesn't.
Flow 4: how the library tests itself
This is the interesting part, and it's genuinely a bit unusual. A library of Drupal test steps can't be tested without a Drupal site, so the repository builds one - and then, for the failure paths, runs Behat inside Behat.
Building the fixture site
scripts/provision.php creates a throwaway Drupal site under build/. The DRUPAL_VERSION variable picks the core major, 11 unless set, and names the fixture directory it copies from - tests/behat/fixtures_drupal/d11/ or d12/. It then merges the library's own Composer requirements into that fixture's composer.json - including every package named in suggest, because the fixture site has to exercise all the traits at once - dropping any package the fixture already pins, so the fixture's constraint is the one that survives. It installs Drupal with drush si standard, appends a couple of $config overrides to settings.php so ConfigOverrideTrait has something real to read, runs the fixture's own drupal-post-install Composer script, copies tests/behat/fixtures/ into the site's files directory, and confirms the site bootstraps before handing back.
The drupal-post-install script imports the fixture's config/sync with drush cim, then deletes the sample nodes that draggableviews_demo creates on install, because the listing scenarios expect a site with no nodes. Straight after the script, provisioning checks that the import landed, by reading a config entity only the fixture defines. drush cim can enable the modules, abort on a fatal while it is creating config entities, and still exit 0, which leaves a site that boots and has none of the content types the suite asserts on.
The BEHAT variable picks the Behat major, 3 unless set. composer.json allows both Behat 3.33 and Behat 4, and composer update --with="behat/behat:^${BEHAT}" narrows the fixture to one. A combination then drops what it cannot install: a Behat 4 build on Drupal 11 removes dvdoug/behat-code-coverage, whose Behat 4 releases need phpunit/php-code-coverage 12 while Drupal 11 holds the fixture on PHPUnit 11.
Drupal 12 needs contrib relaxed at 2 layers, because most contrib releases don't declare it. For the Composer solve, d12/composer.json lists every contrib module whose drupal/core requirement excludes Drupal 12 under extra.drupal-lenient.allowed-list for mglaman/composer-drupal-lenient to strip the core constraint from; a plugin only shapes a solve it is already installed for, so provisioning installs it globally first - Composer loads global plugins for local projects. For Drupal itself, which reads core_version_requirement from each extension and refuses to enable one that excludes the running major, provisioning appends || ^12 to that key wherever a contrib module in build/ lacks it, leaving the fixture sources untouched.
The Drupal 12 fixture pins ~12.0.0-beta1, and that release changed 2 more things the fixture works around. It moved Olivero, Claro and Search out of core, so d12/composer.json installs them from contrib under the same machine names. It also stopped shipping core's test files in the dist package, which the PHPUnit bootstrap, the Kernel suite and core's test modules need, so the fixture sets preferred-install to take drupal/core from source. Provisioning passes no --prefer-dist, because that flag would override the per-package setting.
Behat then runs from inside build/ but with the project-root behat.php, which is why several paths in the config look one level off.
The suite
behat.php wires up FeatureContext (DrupalContext plus the test-only steps and overrides), BehatCliContext (the nested runner), Mink's own MinkContext, the screenshot extension, and a PHP built-in server that serves tests/behat/fixtures/ on port 8888 for the traits that need a static file and no Drupal at all. The BehatStepsExtension settings list the drupal, drush and blackbox backends, the Drush root and global options, the message selectors, the named regions, and the path mappings. The coverage extension is registered only when it is installed, so a Behat 4 build runs without it. Behat 4 reads only PHP configuration, and Behat 3.33 reads the same file.
Default sessions run through BrowserKit, on Mink's HttpBrowser. @javascript scenarios run through Selenium2, or through headless Chrome over the DevTools Protocol if you use the chrome_headless profile - which inherits everything and swaps only the JavaScript session, so the same suite proves the steps are portable across browser drivers.
Behat inside Behat
Asserting that a step fails correctly is awkward from inside the same run: the failure would fail your own scenario. So scenarios tagged @test-trait:SomeTrait take a detour.
BehatCliTrait::behatCliBeforeScenario reads the trait names out of the tag, writes a minimal WebRawContext subclass composing just those traits into a temporary directory, and BehatCliContext runs a real behat subprocess against it. The generated context starts from the step-free root and composes AuthTrait and StaticCacheTrait, the two helpers no step trait body calls into, because a tag names a trait from either half. The outer scenario then asserts on the subprocess's exit code and output. The subprocess reads a behat.php that BehatCliTrait writes, and the generated context declares its steps and hooks as PHP attributes, because Behat 4 ignores docblock annotations. One quirk worth knowing: nested PyStrings are written with ''' and converted to """ on the way out, because you can't nest """ inside """ in Gherkin.
Coverage, and the file everyone reads wrong
Because half the failure paths are only ever exercised inside subprocesses, coverage arrives in 2 pieces and has to be stitched together.
The outer run writes .logs/coverage/behat/. Each nested subprocess drops its own file into .logs/coverage/behat_cli/phpcov/. scripts/merge-coverage.php folds them together and writes the merged report to .logs/coverage/behat_cli/cobertura.xml.
That merged file is the real number. The behat/ one only ever shows the direct scenarios, so it reads lower - which is exactly the kind of thing that sends someone off chasing coverage that already exists. scripts/check-coverage.php defaults to the merged file for that reason.
Alongside all this, tests/phpunit/ holds ordinary unit tests for the parts that don't need a browser: docs.php itself, the backend layer, the helper traits, and the convention tests at the root of tests/phpunit/src/ that hold the naming, member order, public surface, data provider, test suite, layer, context-composition, step-body and step-coverage rules.
The step-coverage rule is the one that reads the feature files rather than the source. StepScenarioCoverageTest parses every scenario the suite would run - outline rows expanded, the tag filter behat.php declares applied, and the steps a @test-trait scenario hands to its nested run included - and matches each pattern DrupalContext registers against those steps through Behat's own pattern policies. A step nothing reaches fails the unit suite. It's the other half of validate_step_patterns(): that check stops one pattern shadowing another, and this one makes sure some scenario runs every step, since that's the only moment Behat matches a pattern against anything.
Continuous integration
.github/workflows/test.yml has 3 jobs. lint and test run inside the project's own Docker Compose stack, so CI and local development execute the same commands, and docs runs the documentation site's npm scripts on Node 24, outside the stack.
lint runs on PHP 8.4. It provisions the fixture site, checks that composer.json is normalized, and runs ahoy lint - composer validate, parallel-lint, PHP_CodeSniffer, PHPStan, Rector in dry-run mode, gherkinlint over the feature files, and scripts/lint-layers.php for the 6 layer boundaries - followed by ahoy lint-docs, which is the docs.php --fail-on-change gate.
The test matrix is PHP 8.3, 8.4 and 8.5 against Drupal 11, each run twice - once with normal dependency resolution and once with lowest - on Behat 3, and the same 6 combinations again on Behat 4, which the behat matrix key passes to scripts/provision.php as BEHAT. 3 more legs run the suite through the chrome_headless profile with normal dependencies: PHP 8.3 and 8.5 on Behat 3, and PHP 8.4 on Behat 4, so between them they cover every PHP version and both majors.
2 further legs run Drupal 12, which the drupal_version matrix key passes as DRUPAL_VERSION, on PHP 8.5 and Behat 4 with normal and lowest dependencies - the whole grid that major allows, since core 12 requires PHP 8.5 and Symfony 8 while the newest Behat 3 caps Symfony at 7. Every leg names the majors it runs, as in Test PHP 8.5, Drupal 12, Behat 4, Deps normal, and the branch ruleset requires those names.
The unit and kernel suites run on every leg without a profile, since a profile changes how the Behat suite runs and not what PHPUnit covers. Coverage is collected on the 2 legs flagged coverage - PHP 8.3 / normal on Behat 3, through Selenium2 and through headless Chrome - and Codecov merges the uploads into one report. Test artifacts - logs, screenshots, coverage - come back from every leg, passing or failing.
docs builds the site with DOCS_REF set to the pull request's head commit, or to the pushed commit, so a link to a file the pull request adds resolves. It then runs npm test and npm run test:accessibility. npm test is the Jest unit tests followed by CSpell over every page under docs/content/ but the generated pages and index regions, plus docs/README.md and the root README.md. npm run test:accessibility serves the build and runs axe against the WCAG A and AA rules on every page, in light and dark mode at a desktop and a phone width, and checks how the search dropdown and the mobile menu behave. When the Netlify secrets are set, the job deploys the build to Netlify, as the development site on a push to main and as a preview otherwise.
.github/workflows/release-docs.yml publishes the site to GitHub Pages, on every tag push and on a manual run naming a tag or branch. It checks that ref out and builds with RELEASE_VERSION set to its name, so the pages and their GitHub links come from the same tag or branch. That variable also puts the version in the footer, lets search engines index the site and loads Google Analytics, which no other build does. The site's URL and base path come from the repository's GitHub Pages settings, through DOCS_URL and DOCS_BASE_URL.
Regenerating this document
After a structural change - a new layer or namespace, a change to how a context composes the lifecycle, a new backend or capability, a change to how docs.php discovers steps, a change to the fixture harness or the CI matrix - ask the AI agent to "update architecture docs". It re-traces the affected diagrams and prose from the current code via the update-architecture-docs skill, and re-renders the SVGs in the same pass.