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.yml file, 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, fixtures and Drupal

  • 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)
flowchart TD A[Discovery scans modules directories for MODULE.info.yml] --> B[Info file parsed and validated] B --> C{Install requested from the Extend page or Drush} C --> D[Dependencies resolved and installed first] D --> E[Default config checked: no pre-existing names and no unmet dependencies] E -- check fails --> X[Install stops with an exception and nothing is changed] E -- ok --> F[Tables created from hook_schema] F --> G[config/install imported] G --> H[hook_install runs] H --> I[config/optional imported when its dependencies are present] I --> J[Container and router rebuilt, module appears in core.extension]

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

name

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.

type

module, theme or profile. Required. A module with type: theme is not discovered as a module.

core_version_requirement

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

  • description is 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.

  • package groups modules in the Extend page. Use sentence case; core uses names such as Core, Multilingual and Field types, and custom code usually uses Custom or the project name. The example uses Meridian Museum so that all the museum modules sit together in the list.

  • version and project are 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:

  • dependencies does not download anything. A contributed dependency is added with composer require drupal/search_api (or in the module’s own composer.json for 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:uninstall removes 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

  • configure is 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. For meridian_collection it points to meridian_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_parameters supplies route parameters when that route needs them, as a map of names to values.

  • hidden: true hides the module from the Extend page. It is meant for examples; test modules belong in tests/modules/. The hidden modules are listed again when $settings['extension_discovery_scan_tests'] is TRUE in settings.php.

  • required: true forces the module to stay installed and blocks uninstalling it. Only core uses this seriously.

Platform and lifecycle keys

  • php is 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_collection uses php: 8.3, the baseline of this documentation.

  • lifecycle marks stability: experimental, deprecated or obsolete. An experimental module shows a warning on the Extend page and a confirmation when it is installed. A module with deprecated or obsolete must also set lifecycle_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 of lifecycle.

  • project status url is 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_requirement makes 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.

  • dependencies entries without the project prefix (node instead of drupal:node) are legacy syntax; use the project:module form.

  • Editing the file does not apply instantly: after changing dependencies, configure or package on a running site, rebuild the caches with drush cache:rebuild so 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 before hook_install() runs and drops after hook_uninstall()

  • hook_install() and hook_uninstall() do one-time setup and cleanup

  • hook_update_N() and hook_post_update_NAME() change existing sites after a code update (the next pages on the Update API cover them), with hook_update_dependencies(), hook_update_last_removed() and hook_removed_post_updates() as companions

  • hook_install_tasks() and hook_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');
}

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:

  • PreExistingConfigException when 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

  • UnmetDependenciesException when a shipped object lists a module, theme or other configuration under dependencies that 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

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

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'

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

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

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.

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.extension configuration object, so it is exported and imported with the rest of the configuration. After drush config:export, the list in core.extension.yml names meridian_collection with 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

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_collection the 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 .module extension 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_schema and 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 named MODULE.info.yml, that core_version_requirement matches 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 edit core.extension by hand.

  • Install fails with UnmetDependenciesException. A file in config/install references a module or configuration the site lacks. Add the module to dependencies, or move the file to config/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/Hook and 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 below src/.

  • 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 .install and .module files are PHP and are never web accessible when the web root is correct; a directory listing of modules/ 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.

  • dependencies is 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.