Drupal: Module Anatomy and the info.yml File
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
A Drupal module is a directory with one required file, MODULE.info.yml, and any combination of PHP classes, YAML
definitions, default configuration, templates and assets. This page explains the anatomy of that directory: every key of
the info.yml file, what the optional .module and .install files are still used for, how classes under src/ are
autoloaded, how config/install, config/optional and config/schema differ, how to scaffold a module with Drush, and
what happens internally when a module is installed and uninstalled. It is the first page of the module development
group and starts the step-by-step module of the running example, meridian_collection, which the following pages
extend with routes, services, hooks, plugins, an entity type and more.
It was written against Drupal 11.4.x, with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation, change records and the API reference on 2026-10-30. The running example is the Meridian Museum. Drupal 12.0.0 is scheduled for the week of 2026-12-07 and the end of life of Drupal 10 for 2026-12-09, so both are described as upcoming; new modules should target Drupal 11 and must not be started for Drupal 10.
Who works with module files
-
Developers write the
info.ymlfile, the PHP classes and the install code, and decide which dependencies a module declares. -
Site builders see the result: the module’s name, description and package on the Extend page, its Configure link and its lifecycle warning.
-
Administrators install and uninstall modules with Composer and Drush and keep the module list in exported configuration.
-
Maintainers of contributed modules also add
composer.json, tests and a release process, which later pages cover.
Naming and placing a module
The machine name is the identity of the module. It is used for the file names (meridian_collection.info.yml), for the
PHP namespace (Drupal\meridian_collection\), for route names, permission names, configuration prefixes and for the
function names of procedural hooks. The official guide sets these rules:
-
it starts with a letter and contains only lowercase letters, digits and underscores, with no spaces
-
it is 50 characters or fewer
-
it is unique among all modules, themes, theme engines and installation profiles on the site
-
it avoids reserved words such as
src,lib,vendor,assets,css,files,images,js,misc,templates,includes,fixturesandDrupal -
it never contains uppercase letters, because procedural hook functions would no longer be recognised
Custom modules live in modules/custom/ of the web root (web/modules/custom/ in a project that uses the
drupal/recommended-project layout). Downloaded modules are placed by Composer in modules/contrib/. Both folders are
only conventions: Drupal scans modules/, profiles/*/modules/ and sites/*/modules/ recursively, so
modules/meridian_collection also works. The older sites/all/modules location is still scanned, but new code does not
use it. The directory name does not have to equal the machine name, although every file inside must use the machine name.
Submodules are modules in a subdirectory of another module, each with its own info.yml file, for example
meridian_collection/modules/meridian_collection_demo/. They are installed independently and can depend on their parent.
The usual use is test modules, which live in tests/modules/ and are only discovered when
$settings['extension_discovery_scan_tests'] is TRUE, as it is in the PHPUnit and test runner environment.
The shape of a module
The Meridian Museum module is built across many pages. This is the layout it grows into; the files this page creates are marked in the listing that follows.
web/modules/custom/meridian_collection/
├── meridian_collection.info.yml # required: makes the directory a module
├── meridian_collection.install # procedural install, uninstall, schema and update hooks
├── meridian_collection.routing.yml # routes (see the routing page)
├── meridian_collection.services.yml # services (see the services page)
├── meridian_collection.permissions.yml # permissions
├── meridian_collection.links.menu.yml # admin menu links, including the settings link
├── config/
│ ├── install/ # default configuration imported on install
│ ├── optional/ # configuration imported only when its dependencies exist
│ └── schema/ # meridian_collection.schema.yml
├── src/ # PSR-4 root for Drupal\meridian_collection\
│ ├── Hook/ # OOP hook implementations
│ ├── Controller/
│ ├── Form/
│ ├── Entity/
│ └── Plugin/
├── templates/ # Twig templates
└── tests/
├── modules/ # test-only modules
└── src/ # Drupal\Tests\meridian_collection\ (Unit, Kernel, Functional)
Source: Creating modules
The info.yml file
MODULE.info.yml is a YAML file in the root of the module. Core reads it during discovery, validates it and keeps the
result in the extension list; it is the only file that makes a directory a module. The same file format, with a different
type, defines themes and installation profiles (the theme variant is covered on
Drupal: Theme Info Files and Regions). A colon must be followed by a space and
never preceded by one, and the quoting rules are those of YAML: a value that contains a colon, or that starts with a
character such as an asterisk or an at sign, needs quotes.
A first info file for meridian_collection
name: Meridian Collection
type: module
description: 'Manages the museum collection: objects on display, accession numbers and the sync with the collections-management system.'
package: Meridian Museum
core_version_requirement: ^11.3
php: 8.3
dependencies:
- drupal:node
- drupal:taxonomy
- drupal:media
configure: meridian_collection.settings
The following sections explain each key.
Required keys
| Key | Meaning |
|---|---|
|
The title shown on the Extend page and in Drush output. Required. It is human text, so it may contain spaces and capitals, unlike the machine name. |
|
|
|
The core versions the module works with, written as a Composer-style constraint. Required. |
core_version_requirement is the key that most often breaks an installation. Drupal compares it with the running core
version: if core does not satisfy the constraint, the module is not listed as installable on the Extend page, drush
pm:install refuses it, and Composer may refuse to place it. Typical values are ^11 (any 11.x), ^11.3 (11.3 or
later in 11), and ^10.3 || ^11 for a module that must run on both majors. A constraint such as ^8.7 || ^9 fails
to parse as a core requirement because the key was introduced in Drupal 8.7.7. The old core: 8.x key is only meaningful
for Drupal 8 projects, and a Drupal 10 or 11 module never sets it.
Pick the lower bound deliberately. A module that uses object-oriented hooks with #[Hook] needs 11.1 or later; one that
also wants ordering attributes needs 11.2; the guidance on moving away from the .module file recommends 11.3 as the
minimum to simplify the conversion. meridian_collection is a new site-specific module, so it starts at ^11.3. A site
that must stay compatible with Drupal 12 once it ships adds || ^12 only after running the module’s tests against 12;
declaring a major version the code was never tested on only moves the failure from install time to run time.
The constraint is also read by Composer when the module has a composer.json, and the official page lists a precedence:
a core requirement in composer.json wins, then core_version_requirement, then a version constraint on a core module
inside dependencies.
Descriptive keys
-
descriptionis the text under the module name on the Extend page. Keep it to one sentence. If it is missing, the description is blank. A value that contains a colon, as the example above does, is quoted. -
packagegroups modules in the Extend page. Use sentence case; core uses names such asCore,MultilingualandField types, and custom code usually usesCustomor the project name. The example usesMeridian Museumso that all the museum modules sit together in the list. -
versionandprojectare added by the Drupal.org packager when a release is built. A custom module never sets them; writing them by hand makes the update status report compare the module with a project it is not.
Dependencies
dependencies lists the modules that must be installed before this one. Each entry is project:module, where the
project is the Drupal.org project name that ships the module (drupal for core). A module from the same project may be
written as module:module and a submodule as module:submodule. A version constraint goes in parentheses, for example
webform:webform (>=6.1.0). The syntax is Drupal’s own, implemented by \Drupal\Component\Version\Constraint, and it is
not the same as Composer’s.
dependencies:
- drupal:node
- drupal:taxonomy
- drupal:media
- drupal:views
- search_api:search_api (>=1.0)
Three rules prevent most problems:
-
dependenciesdoes not download anything. A contributed dependency is added withcomposer require drupal/search_api(or in the module’s owncomposer.jsonfor a distributed module) before the module can be installed. Without it the Extend page shows the dependency as missing. -
When a module is installed from the Extend page or with Drush, its dependencies are installed first and automatically. Uninstalling goes the other way: a module cannot be uninstalled while an installed module depends on it, and
drush pm:uninstallremoves the dependent modules together with it. -
Declare a dependency only for what the module needs to run. A module that merely integrates with another (for example Search API) belongs in a submodule that depends on both, so the main module stays installable without it.
test_dependencies takes the same format but lists modules needed only by the module’s automated tests, for example
drupal:image. They are not installed on a real site. Composer is an alternative way to make test-only code available.
Keys for the admin interface
-
configureis the route name of the module’s configuration form. It adds a Configure link next to the module on the Extend page, so it must be a route that exists once the module is installed. Formeridian_collectionit points tomeridian_collection.settings, the settings form built on the routing and form pages. A typo does not stop the install but produces a missing-route error when the page is rendered. -
configure_parameterssupplies route parameters when that route needs them, as a map of names to values. -
hidden: truehides the module from the Extend page. It is meant for examples; test modules belong intests/modules/. The hidden modules are listed again when$settings['extension_discovery_scan_tests']isTRUEinsettings.php. -
required: trueforces the module to stay installed and blocks uninstalling it. Only core uses this seriously.
Platform and lifecycle keys
-
phpis the minimum PHP version. Drupal refuses to install the module on an older PHP and the Extend page says why. It is separate from the PHP version Composer enforces and is useful for a module that uses newer syntax than core does.meridian_collectionusesphp: 8.3, the baseline of this documentation. -
lifecyclemarks stability:experimental,deprecatedorobsolete. An experimental module shows a warning on the Extend page and a confirmation when it is installed. A module withdeprecatedorobsoletemust also setlifecycle_link, a URL that explains the reason and the replacement. Contributed projects are advised to express stability with release numbers and alpha, beta and rc suffixes instead oflifecycle. -
project status urlis the base URL of a custom update-status feed for a module that is not hosted on Drupal.org.
# Hypothetical retirement of a museum sub-module.
name: Meridian Legacy Labels
type: module
description: Prints the old paper-label layout. Replaced by the label view mode.
package: Meridian Museum
core_version_requirement: ^11.3
lifecycle: deprecated
lifecycle_link: https://example.org/meridian/labels-retirement
Common info.yml errors
-
A missing
core_version_requirementmakes the module invisible or uninstallable on modern core. Drupal 10 and 11 require it. -
A value that contains a colon followed by a space, an unquoted at sign or a leading asterisk breaks the YAML parser and the module disappears from the list with a parse error in the logs; quote the value.
-
A tab character instead of spaces is a YAML error.
-
The file name and the directory must agree with the machine name used elsewhere. A hook function named after a different machine name is silently never called.
-
dependenciesentries without the project prefix (nodeinstead ofdrupal:node) are legacy syntax; use theproject:moduleform. -
Editing the file does not apply instantly: after changing
dependencies,configureorpackageon a running site, rebuild the caches withdrush cache:rebuildso the extension list is refreshed.
The .module file
MODULE.module is a PHP file that core loads whenever the module is installed. Historically every hook implementation
and helper function of a module lived in it, as functions named MODULE_HOOKNAME(). Books written for Drupal 10, such as
Drupal 10 Development Cookbook (2023), still show hooks as .module functions; since 11.1 they are methods with
#[Hook] in classes under src/Hook/ (see hooks and object-oriented hooks).
The file extension itself is now on a deprecation path. A change record published in September 2026 for Drupal 11.5.0
states that the .module extension is deprecated and that automatic loading of .module files will be removed in Drupal
13. Hook implementations, hook helper functions, form callbacks and batch callbacks move to classes and services;
procedural code that other projects still call gets a deprecation notice and delegates to the new service. A module that
must keep a .module file for compatibility with older core can silence the notice with the ExtensionFileIsConverted
attribute, which does not prevent the file from being dropped in 13. Because Drupal 11.5 is scheduled for December 2026,
the 11.4.x release this page targets still loads the file silently.
The consequence for new code is simple: meridian_collection has no .module file. Its hooks are classes in src/Hook/,
its helpers are services, and the few hooks that must stay procedural live in the .install file described next.
A .module file is still correct in two cases: a Drupal 10 compatible contrib module that wraps each hook in a
#[LegacyHook] function, and an existing module that has not been converted yet.
The .install file
MODULE.install holds the code that runs when the module is installed, updated and uninstalled. Core loads it only when
it needs one of these hooks, so it costs nothing on normal requests. The official list of hooks that cannot be written as
#[Hook] methods is the list of what stays here:
-
hook_schema()defines custom database tables, which Drupal creates beforehook_install()runs and drops afterhook_uninstall() -
hook_install()andhook_uninstall()do one-time setup and cleanup -
hook_update_N()andhook_post_update_NAME()change existing sites after a code update (the next pages on the Update API cover them), withhook_update_dependencies(),hook_update_last_removed()andhook_removed_post_updates()as companions -
hook_install_tasks()andhook_install_tasks_alter()are used by installation profiles
hook_requirements() was also procedural-only, and it is deprecated since Drupal 11.3.0 in favour of three
replacements: hook_runtime_requirements() (status report), hook_update_requirements() (before database updates) and a
class that implements InstallRequirementsInterface for checks before installation. The replacements were already
available in 11.2. A module that only needs to appear on the Status report writes the runtime hook as a method of a
hook class; its pre-install checks cannot call other code of the module, which is not loaded yet.
An install file for meridian_collection
<?php
/**
* @file
* Install, uninstall and update hooks for the Meridian Collection module.
*/
declare(strict_types=1);
/**
* Implements hook_install().
*/
function meridian_collection_install(bool $is_syncing): void {
// State is not part of exported configuration, so it is initialised on
// every site, including when the install comes from a configuration import.
\Drupal::state()->set('meridian_collection.last_sync', 0);
}
/**
* Implements hook_uninstall().
*/
function meridian_collection_uninstall(bool $is_syncing): void {
// State is not removed automatically. Tables from hook_schema() and the
// module's own configuration are.
\Drupal::state()->delete('meridian_collection.last_sync');
}
Source: API reference: hook_install()
Two details matter. First, hook_install() receives $is_syncing, which is TRUE when the module is installed as part of
a configuration import. In that case configuration changes made in the hook would be applied twice or conflict with the
imported data, so such changes belong in config/install or in exported configuration instead. Second, the hook runs
after the module’s tables and config/install files exist but before the router is rebuilt, so route-based work belongs
in hook_modules_installed() or after an explicit rebuild.
The procedural \Drupal::state() call is acceptable here because .install hooks cannot be classes. Everywhere else in
meridian_collection, dependencies are injected through constructors and \Drupal:: static calls are avoided; the
services page explains how.
A frequent mistake is to leave data behind. hook_uninstall() must remove module state, key-value entries and anything
the module added to tables it does not own. Forgetting it leaves orphaned state rows, and reinstalling the module then
finds stale values. Another is to put non-install work in hook_install(), such as calling a remote API: install must be
fast, repeatable and safe when the network is down.
The src/ directory and PSR-4 autoloading
Everything under src/ is autoloaded by the PSR-4 rule that core registers for every installed module: the namespace
Drupal\MODULE\ maps to the module’s src/ directory. A class Drupal\meridian_collection\Controller\ObjectController
is therefore the file src/Controller/ObjectController.php. No composer.json entry, require statement or .module
include is needed, and the module’s name is the first segment of the namespace, in lowercase with underscores.
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Hook;
use Drupal\Core\Hook\Attribute\Hook;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
/**
* Hook implementations for the Meridian Collection module.
*/
final class MeridianCollectionHooks {
use StringTranslationTrait;
/**
* Implements hook_help().
*/
#[Hook('help')]
public function help(string $route_name, RouteMatchInterface $route_match): ?string {
if ($route_name === 'help.page.meridian_collection') {
return '<p>' . $this->t('Objects in the museum collection and their sync with the collections-management system.') . '</p>';
}
return NULL;
}
}
The directory names under src/ are conventions that the plugin system and core services rely on, not free choices:
plugin classes go under src/Plugin/<Type>/ (for example src/Plugin/Block/), forms in src/Form/, controllers in
src/Controller/, entity classes in src/Entity/, event subscribers in src/EventSubscriber/, and hook classes in
src/Hook/. Plugin discovery scans exactly those namespaces, so a block plugin placed in src/Blocks/ is never found.
Class names follow PSR-4 and the Drupal coding standards: one class per file, the file name equal to the class name, and
UpperCamelCase.
Classes in the Drupal\MODULE\Hook namespace are registered automatically as autowired services, with the class name as
the service id; other services are declared in MODULE.services.yml. A class cache keeps the discovered hook classes, so
adding a new hook class to a running site requires drush cache:rebuild. Procedural hook functions inside src/ are not
supported, and neither are conditionally defined hooks.
The test namespace is separate: tests live in tests/src/Unit, tests/src/Kernel, tests/src/Functional and
tests/src/FunctionalJavascript and use the namespace Drupal\Tests\meridian_collection\. The tests/modules/ directory
holds test-only modules.
Configuration directories: install, optional and schema
Configuration a module ships is plain YAML, one file per configuration object, named after the object
(meridian_collection.settings.yml for the object meridian_collection.settings). Three directories each have a distinct
job.
config/install
Every file in config/install/ is imported into active configuration when the module is installed. It is the place for
default settings, for content types and fields the module provides, for roles, view modes and so on. Core checks the
whole set before it creates anything, and an install stops with an exception, leaving the site unchanged, in two cases:
-
PreExistingConfigExceptionwhen an object of the same name already exists in active configuration, typically because the module was uninstalled and a leftover object remained, or because a site builder created an object with the same machine name by hand -
UnmetDependenciesExceptionwhen a shipped object lists a module, theme or other configuration underdependenciesthat is not available
During a configuration import (drush config:import) the check is skipped, because the synced storage is authoritative.
When a configuration entity belongs to a module, its dependencies: module: list should name that module so Drupal
deletes the object when the module is uninstalled and keeps the dependency graph correct.
# config/install/meridian_collection.settings.yml
sync_enabled: false
api_base_url: 'https://collections.example.org/api/v2'
batch_size: 50
display_limit: 12
Source: Configuration schema/metadata
Never put credentials in a shipped file. The API key for the collections system is read from the environment or from the Key module, and the settings form stores only the name of that key, never its value.
config/optional
Files in config/optional/ are imported only when all their dependencies are present, and are silently skipped otherwise.
The skipped object is not an error and does not stop the install. Core retries it every time another module or theme is
installed, because the missing dependency may appear later; this is why the directory suits integration with modules the
site may or may not have.
# config/optional/language.content_settings.collection_object.collection_object.yml
langcode: en
status: true
dependencies:
module:
- content_translation
- language
- meridian_collection
third_party_settings:
content_translation:
enabled: true
id: collection_object.collection_object
target_entity_type_id: collection_object
target_bundle: collection_object
default_langcode: site_default
language_alterable: false
Source: Configuration API
On an English-only site the file is skipped. When the museum later installs language and content_translation, the
file is imported at that moment and collection objects become translatable. The dependency list is what controls this, so
it must be accurate: an optional file that omits a module it really needs will be imported before the module exists and
fail with a missing-plugin or missing-entity-type error. Only configuration entities can be optional; simple
configuration such as meridian_collection.settings belongs in config/install.
config/schema
config/schema/MODULE.schema.yml describes the structure of every configuration object the module owns: its keys, their
data types and which strings are translatable. The top-level key is the object name, with type: config_object for simple
configuration or type: config_entity for entities, and a mapping lists the keys.
# config/schema/meridian_collection.schema.yml
meridian_collection.settings:
type: config_object
label: 'Meridian Collection settings'
mapping:
sync_enabled:
type: boolean
label: 'Sync with the collections-management system'
api_base_url:
type: uri
label: 'Collections API base URL'
batch_size:
type: integer
label: 'Objects per sync batch'
display_limit:
type: integer
label: 'Objects shown in the On display now block'
Source: Configuration schema/metadata
Schema is what makes configuration translatable, lets the configuration export validate values and powers the
config_inspector tool. Tests that save configuration without a schema fail with a SchemaIncompleteException. Name the
file with the module prefix so it stays unique, and write a schema entry for every file in config/install and every
key a form saves. The configuration API and schema page covers types,
dynamic keys and validation constraints in depth, and
configuration management covers export and import.
Scaffolding with drush generate module
Drush ships code generators that write the files above with the correct names and headers. They are part of the
drush/drush Composer package installed per project (Drush 13 serves Drupal 10.2 and later, Drush 14 serves 11.3 and
later); there is nothing extra to install.
# Interactive: asks for the module name, machine name, description, package, dependencies and optional parts
ddev drush generate module
# Preview the files without writing them
ddev drush generate module --dry-run
# Write into a specific directory
ddev drush generate module --destination=web/modules/custom
Source: Drush: generate module
The command takes --answer (supply the answer to a question, for scripted use), --destination (the base directory)
and --dry-run (print the code without saving it). The questions cover the human name, machine name, description and
package, the dependencies, and whether to add optional files and classes such as an install file, a controller or a
settings form. Treat the output as a starting point and review it against this page, because generator templates trail
core: check for core_version_requirement, for a .module file that a new module does not need, and for plugin
annotations where Drupal 10.2 and later use attributes. Older books such as Drupal 10 Development Cookbook (2023) show
drush generate output for Drupal 10 and Drush @command annotations with drush.services.yml files; since Drush 13
commands are attribute-based and autowired and drush.services.yml is no longer used.
Other generators in the same family (for controllers, forms, plugins, services and more) are listed by
ddev drush generate with no argument. The Drush page covers the tool in general and
the project layout page covers the DDEV environment used in
the examples.
Installing a module
A module goes through three stages before it works: its code is placed on disk, it is installed (recorded in configuration), and its permissions are granted.
# 1. Place third-party code with Composer (a custom module is already on disk)
ddev composer require drupal/search_api
# 2. Install
ddev drush pm:install meridian_collection
# Preview what would be installed, including dependencies
ddev drush pm:install --simulate meridian_collection
# 3. Export the new state so other environments get it
ddev drush config:export
Source: Drush: pm:install
pm:install has the aliases en, in, install, pm-install, pm:enable and pm-enable, and takes a
comma-delimited list of modules. The UI equivalent is the Extend page at /admin/modules: tick the module, click
Install, and confirm the dependencies it lists. Composer is the only supported way to add module code in current releases;
uploading a module archive from the UI was removed in Drupal 10.4 and 11.0. New modules get no permissions for anyone but
the administrator, so grant them at /admin/people/permissions.
Source: Installing Modules
What the installer does, in order, is shown in the diagram above. Three consequences are worth remembering:
-
The installed state is one entry in the
core.extensionconfiguration object, so it is exported and imported with the rest of the configuration. Afterdrush config:export, the list incore.extension.ymlnamesmeridian_collectionwith its weight, which orders hook execution. -
The configuration check runs before anything is created, but a PHP error inside
hook_install()happens after tables and configuration exist, so it can leave the module half installed; fix the code and uninstall before retrying. -
The container is rebuilt, so services and routes added by the module are available immediately in the next request.
Uninstalling a module
Disabling does not exist in Drupal 8 and later; a module is installed or uninstalled. Uninstalling is a deliberate
operation that deletes data, so it is separate from the Extend page’s install tab: it is the Uninstall tab at
/admin/modules/uninstall, or Drush.
ddev drush pm:uninstall --simulate meridian_collection
ddev drush pm:uninstall meridian_collection
Source: Drush: pm:uninstall
pm:uninstall has the aliases un, pmu and pm-uninstall. It also uninstalls every module that depends on the one named.
Uninstalling runs in this order: validators decide whether it is allowed, hook_uninstall() runs, the module’s tables
from hook_schema() are dropped, the module’s configuration and any configuration entity that depends on the module is
deleted, and the module leaves core.extension. The hook runs before the tables are dropped, so it can still read them.
Core refuses to uninstall a module in these situations, each reported with a reason on the Uninstall page:
-
another installed module depends on it
-
it provides a content entity type that still has entities, so for
meridian_collectionthe collection objects must be deleted first, because losing them silently would lose data -
it provides a field type, widget or plugin that stored configuration or content still uses
-
it is marked
required: true
Contrib modules can add their own validators, using the module uninstall validator interface. When the module is removed
from core.extension, deleting its directory and running composer remove is optional but keeps the codebase clean.
Core modules removed from Drupal are a separate case. Before upgrading to a release that removed a module you use (for example Statistics, Forum or Book in 11.0), install the contrib replacement and do not uninstall the old module first; the removed modules page lists them.
Version notes
-
Drupal 8.7.7 introduced
core_version_requirement; Drupal 10 and 11 require it. -
Object-oriented hooks with
#[Hook]arrived in Drupal 11.1; ordering and removal attributes in 11.2. -
hook_requirements()is deprecated since 11.3.0 for runtime, update and install checks. -
The
.moduleextension is deprecated in 11.5.0 (scheduled December 2026), with autoloading of such files removed in 13. -
Procedural implementations of
hook_install,hook_uninstall,hook_schemaand the update hooks remain supported. -
Drupal 12.0.0 is scheduled for the week of 2026-12-07 and Drupal 10 ends on 2026-12-09; start new modules on Drupal 11.
Common errors and pitfalls
-
The module is not listed on the Extend page. Check the directory is below
modules/, that the file is namedMODULE.info.yml, thatcore_version_requirementmatches the running core, that the YAML parses, and rebuild the caches. -
Install fails with
PreExistingConfigException. A configuration object with the same name already exists. Delete it or rename the shipped file; do not editcore.extensionby hand. -
Install fails with
UnmetDependenciesException. A file inconfig/installreferences a module or configuration the site lacks. Add the module todependencies, or move the file toconfig/optional. -
A hook is never called. The machine name has capitals, the function prefix differs from the module name, or the hook class is outside
src/Hookand was never registered as a service. Rebuild the caches after adding a hook class. -
A new class is not found. The directory or namespace does not follow PSR-4. The namespace must start with
Drupal\meridian_collection\and match the path belowsrc/. -
Saving configuration raises a schema error. The key is missing from
config/schema. -
Uninstall refused. Delete the content entities or uninstall the dependent module first.
Security
-
Treat the module directory as code that runs with the full privileges of the site. Only Composer-installed or reviewed code belongs in it, and the web server must not be able to write to it.
-
The
.installand.modulefiles are PHP and are never web accessible when the web root is correct; a directory listing ofmodules/should not be reachable. -
Do not ship credentials in
config/install. Default configuration is exported, committed and public in a contrib module; keys come from environment variables or the Key module. -
A
hook_install()that creates users, roles or permissions should create the least privilege required, because it runs on every site that installs the module. -
dependenciesis a trust statement: it installs other code on the site. Do not depend on abandoned projects, and check their security advisory coverage. -
hook_uninstall()should remove any data that could otherwise be read by a later module with the same name.