Drupal: Introduction and Architecture

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.

This page is the foundation of the Drupal sub-section. It explains what Drupal is, how its code is organised, and what happens inside the application between the moment a web server receives a request and the moment HTML (or JSON) goes back to the client. Every later page builds on the vocabulary introduced here: module, theme, profile, recipe, service, entity, bundle, field, configuration, state, render array, cache tag, plugin, hook and event.

It was written against Drupal 11.4.x (11.4.0 was released on 1 July 2026), with Drush 13 or 14 and PHP 8.3 or later, and the facts were checked against the official documentation on 2026-10-30. The running example is the Meridian Museum, a fictitious regional science museum whose site is built in the other pages: the custom module meridian_collection, the theme meridian and the recipe meridian_events. 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 events, not as facts of the past. New sites start on Drupal 11.4 (see Versions, release cycle and support).

What Drupal is

The official overview calls Drupal a free and open-source content-management framework. The word framework is deliberate. Drupal can be installed and used as a ready-made website, but its value is that the same code base provides the building blocks to model almost any content and to expose it through almost any channel. Four traits characterise it:

Structured content

Content is not a blob of HTML. A site builder defines content types (for example exhibition, event and news for the Meridian Museum), and each type is a list of typed fields (a date range, a hero image, a reference to collection objects, a formatted text). The same content can then be listed, filtered, translated, reused in many places and delivered as HTML or JSON. The pages Content types and fields and Field types and entity references cover the site-builder side.

Fine-grained access control

Every action is guarded by a permission granted to roles (Administrator, Content Editor, Curator, Reviewer, Member), and entity access can be refined per bundle, per field, per revision state or per custom rule. See Users, roles and permissions and Access control and permissions in code.

Multilingual by design

Interface text, content entities, configuration entities and URLs can all be translated, and a site can have any number of languages. The Meridian Museum is English by default with Spanish added. See Multilingual.

API-first

Core ships JSON:API, which exposes every entity type without configuration, and a REST module with serialization. GraphQL and OAuth are contributed projects. A front end such as a Next.js "gallery kiosk" can therefore use Drupal purely as a content back end. See JSON:API and Decoupled Drupal with Next.js.

Different people meet different parts of this model. An editor sees forms and the administration toolbar; a site builder assembles content types, views, blocks and permissions in the user interface and exports the result as configuration; an administrator operates the code base with Composer and Drush; a themer writes Twig templates and libraries; and a developer writes modules in PHP. The rest of this page is written for the last three, but each concept is introduced from the user interface first.

Core, contributed projects and Drupal CMS

Drupal software is delivered in three layers, and being clear about the layers avoids many support questions.

Drupal core

The drupal/core package: the kernel, the APIs, the modules that every site needs (such as System, User, Node, Field, Views, Media, Language, Config) and a few themes and installation profiles. Core is versioned and released by the core maintainers on a fixed schedule.

Contributed ("contrib") projects

Modules, themes, profiles and recipes maintained by community members and companies and hosted on drupal.org. The official overview counts more than 40,000 modules and more than 2,500 themes. Examples used later in this sub-section are Pathauto, Admin Toolbar, Webform, Search API, Simple OAuth, GraphQL, Project Browser, Automatic Updates, Canvas and the AI module. Each project has its own version number and its own release cycle, and its *.info.yml declares which core versions it supports. Several modules that used to be in core now live in contrib (see Removed core modules and their replacements).

Drupal CMS

A curated Drupal 11 distribution, first released in January 2025, that wraps core in a set of recipes so that a site builder can begin from a useful product instead of an empty system. It is not a fork: it is core plus contributed projects plus recipes, installed with Composer. Drupal CMS 2.x makes Canvas the default page-building editor and offers the site templates Byte, Starter (using the Mercury theme) and Blank, plus an optional AI assistant. Version 1.x is no longer supported. Recipes have no update path, so once a recipe has been applied the result is ordinary Drupal configuration and content that you maintain like any other. The documentation of Drupal CMS has moved to project.pages.drupalcode.org/drupal_cms. See Drupal CMS and site templates.

A distribution is the older name for a packaged starting point (core, modules, configuration and an installation profile). The word is still used officially, and Drupal CMS is the best-known current example. The practical rule is simple: the code that you deploy is always a Composer project (drupal/recommended-project) that requires core and every contributed project explicitly, whichever starting point you used.

The license, the Drupal Association and the community

Drupal’s PHP code is distributed under the GNU General Public License (GPL); the official documentation is licensed under Creative Commons Attribution-ShareAlike 2.0. The licence has a practical consequence for agencies and developers: custom code that is distributed (for example a module published on drupal.org, or a distribution handed to a customer) is distributed under the GPL as well, while code that stays inside one organisation’s deployment is not "distributed". The name Drupal is a registered trademark held by Dries Buytaert, the project’s founder, which is why the figures in this sub-section are original drawings and do not use the project’s logo.

The Drupal Association is the non-profit organisation that supports the project. It runs drupal.org (issue queues, the project and release infrastructure, the packaging that Composer reads) and the DrupalCon conferences. Day to day, the work is done by a community of individual and company contributors organised around issue queues, Slack, working groups ("initiatives") and local meetups. The Drupal Security Team coordinates security advisories for core and for contributed projects that opt in to security coverage. How to take part is covered in Contributing and the Drupal community.

A short history

The history matters because the code and the books you find online come from different eras, and the era decides which API to use.

Version When What changed

1.0 to 3.0

2001 to 2002

Dries Buytaert released the software he had written as a message board; it became an open-source project.

4.x to 7

2002 to 2011

The hooks era. Extensibility meant naming a PHP function modulename_hookname() in a .module file. Drupal 5 (2007) added the module .info file and the installer, Drupal 6 (2008) the form and menu improvements, and Drupal 7 (2011) the Field API, the database abstraction layer and the first entity API. Drupal 7 is out of support.

8.0

19 November 2015

A rewrite on Symfony components. Object-oriented code, namespaces, the service container, PSR-4 autoloading, YAML metadata, Twig, configuration management, Views in core, multilingual in core, the plugin system and a REST module.

8.x to 9

2015 to 2020

Semantic versioning with a new feature minor every six months. Drupal 9.0 (June 2020) was the same code as 8.9 with the deprecated APIs removed, which established the "continuous upgrade" model.

10

December 2022

CKEditor 5, Claro and Olivero as default themes, Starterkit, PHP 8.1. Classy and Stable were removed. Recipes arrived in 10.3.

11

2 August 2024

Symfony 7, PHP 8.3, object-oriented hooks (11.1) and the removal of several core modules.

Drupal CMS

January 2025

The first release of the distribution made of recipes.

11.4

1 July 2026

The current feature minor, including Symfony Runtime for the front controllers.

12

planned for the week of 2026-12-07

Removal of deprecated code and of more modules from core. See Preparing for Drupal 12.

Older books such as Drupal 10 Development Cookbook (2023) and Drupal 10 Masterclass (2023) were written between Drupal 10.0 and 10.1. They treat hooks only as .module functions, plugins only as annotations, and several now-core features (recipes, Project Browser, Automatic Updates, single directory components) as future initiatives or experiments. The older books and tutorials page lists each case; this page notes the correction where it matters for the architecture.

Versions and support in one paragraph

Since 8.0 Drupal uses semantic versioning: major.minor.patch. A feature minor ships every six months, in June and December. Bugfix patch releases usually come on the first Wednesday of a month and security releases on the third. Security advisories cover only the two most recent minors. Drupal 11.4 is supported with security fixes until June 2027, and 11.3 until December 2026. Drupal 10 ends on 2026-12-09 and 10.6 is its last minor; from 10.6 the upgrade path leads to 11.3 or later, so this documentation never recommends starting a new site on Drupal 10. Drupal 12 is expected to require PHP 8.5, Symfony 8, and to refuse upgrades from Drupal 11.2 or earlier. The full tables are in Versions, release cycle and support and Requirements and hosting.

Architecture overview

Drupal is a PHP application built on the Symfony HttpKernel component and its sibling components (Routing, DependencyInjection, EventDispatcher, HttpFoundation, Serializer, Validator, Console). It adds a content model (entities and fields), a configuration system, a render and cache system, a plugin system and a hook system on top. The figure below shows the layers; the sections that follow walk through them in the order a request meets them.

Layered diagram of Drupal: clients and web server, the Symfony kernel with middleware and routing, the core subsystems, the extension layer of contrib modules, custom modules, themes, recipes and profiles, and the database and files

The life of a request

The front controller

Every page request to a Drupal site reaches a single PHP file, index.php, in the web root (the web/ directory of a drupal/recommended-project). The web server is configured so that any URL that does not match a real file is rewritten to this file. The script itself is tiny: it prepares the autoloader and creates the kernel. Before Drupal 11.4 it looked like this:

<?php

use Drupal\Core\DrupalKernel;
use Symfony\Component\HttpFoundation\Request;

$autoloader = require_once 'autoload.php';

$kernel = new DrupalKernel('prod', $autoloader);

$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();

$kernel->terminate($request, $response);

Since Drupal 11.4 the front controllers (index.php and update.php) use the Symfony Runtime component, through a DrupalRuntime class. The script no longer creates a request, sends the response or calls terminate() itself. It only returns a closure that builds the kernel, and the runtime does the rest. The environment name is read from APP_ENV instead of being hard-coded to prod. The shape is the following:

<?php

use Drupal\Core\DrupalKernel;
use Drupal\Core\Runtime\DrupalRuntime;

$_ENV['APP_RUNTIME'] ??= $_SERVER['APP_RUNTIME'] ?? DrupalRuntime::class;
require_once 'autoload_runtime.php';

return static function (string $environment) {
  return new DrupalKernel($environment, require 'autoload.php');
};

The idea of the runtime component is to separate bootstrapping from request handling, so that the same application can run under PHP-FPM, a long-running server or the command line. The release notes of 11.4.0 state that sites with a custom front controller keep working with the old form until at least Drupal 12, but that accessing the autoloader through the global variable is deprecated. A site that ships its own index.php (for example to add monitoring) should read the change record and move to the runtime form. The exact contents of the file differ slightly between patch releases, so on a real site always copy the file shipped by the installed core version instead of the snippet above.

From the kernel to the response

DrupalKernel extends and implements the Symfony kernel contracts. Handling a request goes through these stages:

  1. Boot. The kernel loads settings.php (database credentials, hash_salt, trusted host patterns, the configuration directory), discovers the enabled modules from the core.extension configuration, and builds or loads the service container. The compiled container is cached in the bootstrap cache bin and as a PHP file, so a rebuild (drush cache:rebuild) is only needed after changing a *.services.yml file, a module’s service definitions or the list of modules.

  2. Middleware. The request passes through the HTTP middleware stack, a series of services tagged http_middleware, each of which wraps the next like layers of an onion. Core’s stack includes the reverse-proxy settings, content negotiation, the session and the page cache. The page cache middleware sits before the session middleware so that an anonymous visitor with a cached page costs no session and no application bootstrap beyond the kernel itself.

  3. Routing. If no middleware answered, the kernel dispatches KernelEvents::REQUEST. The router matches the path against the routes defined in *.routing.yml files and in route subscribers, resolves path aliases and converts route parameters ({node}) into entity objects (param converters). Access checkers (_permission, _entity_access, _custom_access) decide whether the current user may continue; a failure becomes a 403 response, a missing route a 404.

  4. Controller. The controller named by the route (_controller, _form, _entity_form, _entity_view, _title_callback) is resolved through the controller resolver, which also instantiates it through the service container so that it can receive its dependencies, and its arguments are filled from the route parameters and the request.

  5. View event. A controller normally returns a render array, not a response. Because it is not a Response, Symfony dispatches KernelEvents::VIEW, and Drupal’s MainContentViewSubscriber picks a main content renderer, based on the requested wrapper format (the _wrapper_format query parameter, which defaults to html).

  6. Rendering. The selected MainContentRendererInterface service turns the render array into a response. For a normal page this is the HTML renderer: it renders the main content, lets the active page variant (usually the Block module’s block layout) build the surrounding regions, runs the theme layer (Twig templates and preprocess functions), collects the CSS and JavaScript libraries attached along the way, and produces an HtmlResponse that carries cacheability metadata. Other renderers produce the response for AJAX requests, modal dialogs and the off-canvas tray.

  7. Response event and caches. KernelEvents::RESPONSE subscribers add headers, and the dynamic page cache stores the response in the dynamic_page_cache bin. The BigPipe module (enabled in the standard profile) replaces slow or personalised parts with placeholders and streams them afterwards.

  8. Send and terminate. The response is sent to the client, then KernelEvents::TERMINATE runs deferred work that must not delay the client, such as the automated cron (when enabled) and other post-response tasks.

sequenceDiagram autonumber participant B as Browser participant W as Web server / PHP-FPM participant K as DrupalKernel participant M as Middleware stack participant PC as Page cache (anonymous) participant R as Router and access participant C as Controller participant MR as MainContentRenderer participant DPC as Dynamic page cache B->>W: GET /exhibitions/deep-time W->>K: index.php: handle(Request) K->>M: pass through http_middleware services M->>PC: cached page for this URL? alt anonymous and cache hit PC-->>B: cached response (X-Drupal-Cache: HIT) else miss or authenticated M->>K: continue to the HttpKernel K->>R: KernelEvents::REQUEST, match route, check access R->>C: call controller C-->>K: render array with #cache metadata K->>MR: KernelEvents::VIEW selects the HTML renderer MR->>DPC: reuse cached page, placeholder the dynamic parts DPC-->>MR: HtmlResponse with cacheability metadata MR-->>K: Response K->>PC: store if cacheable for anonymous K-->>B: response, then KernelEvents::TERMINATE end

Source: Cache API

You can watch these layers work from the command line. Cache headers tell you which layer answered:

# Anonymous request, shows page cache and dynamic page cache headers
curl -sI https://meridian.ddev.site/exhibitions/deep-time | grep -iE '^(x-drupal-cache|x-drupal-dynamic-cache|cache-control|age)'

# Rebuild the container and caches after changing a *.services.yml file
ddev drush cache:rebuild

The middleware stack

HTTP middleware is the Drupal API for code that must run on every request, before routing, such as a maintenance gate or a header filter. It is the Stack/Symfony kernel-decorator pattern: a service implementing HttpKernelInterface that holds the next kernel and delegates to it. The service is registered in *.services.yml and tagged with a priority, which sets its place in the stack:

services:
  meridian_collection.kiosk_header_middleware:
    class: Drupal\meridian_collection\StackMiddleware\KioskHeaderMiddleware
    tags:
      - { name: http_middleware, priority: 180 }
<?php

namespace Drupal\meridian_collection\StackMiddleware;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpKernelInterface;

/**
 * Adds a marker header to responses for the museum's gallery kiosks.
 */
class KioskHeaderMiddleware implements HttpKernelInterface {

  public function __construct(protected HttpKernelInterface $httpKernel) {}

  public function handle(Request $request, int $type = self::MAIN_REQUEST, bool $catch = TRUE): Response {
    $response = $this->httpKernel->handle($request, $type, $catch);
    if ($request->headers->get('X-Meridian-Kiosk') === '1') {
      $response->headers->set('X-Meridian-Kiosk-Seen', '1');
    }
    return $response;
  }

}

Source: Middleware API

A higher priority runs earlier. The example priority 180 is above the page cache middleware, so a kiosk request is inspected even when the page cache answers; for work that needs the session or the current user, use an event subscriber instead, because middleware runs before the user is known. A middleware service whose constructor does not accept the wrapped kernel as its first argument is a frequent source of "too few arguments" errors.

The service container

Almost everything in Drupal is a service: an object created once by the dependency-injection container and shared. The router, the entity type manager, the database connection, the logger factory, the current user, the cache backends and the mailer are all services. Services are declared in the *.services.yml file of a module (or theme, or the site) and are retrieved by their ID.

# meridian_collection/meridian_collection.services.yml
services:
  _defaults:
    autowire: true

  meridian_collection.accession_formatter:
    class: Drupal\meridian_collection\AccessionNumberFormatter

  Drupal\meridian_collection\AccessionNumberFormatter: '@meridian_collection.accession_formatter'
<?php

namespace Drupal\meridian_collection;

use Drupal\Core\Config\ConfigFactoryInterface;

/**
 * Formats museum accession numbers such as 1987.14.2 for display.
 */
class AccessionNumberFormatter {

  public function __construct(protected ConfigFactoryInterface $configFactory) {}

  public function format(string $number): string {
    $prefix = $this->configFactory->get('meridian_collection.settings')->get('accession_prefix') ?? 'MM';
    return $prefix . ' ' . $number;
  }

}

Several rules follow from how the container works:

  • Prefer constructor injection and autowiring. Core registers aliases for most of its common interfaces since 10.1, so ConfigFactoryInterface or EntityTypeManagerInterface in a constructor of an autowired service is enough. The #[Autowire(service: '...')] attribute handles the cases without an alias.

  • Do not call \Drupal::service() or other \Drupal:: static methods in classes. They are a service locator and make code hard to test. They are acceptable in procedural code (.module and .install files) and in tests. Older books such as the Development Cookbook often use \Drupal:: calls inside classes; the guidance has since moved to injection.

  • Controllers, forms and plugins are created by the container too, through ContainerInjectionInterface::create() or ContainerFactoryPluginInterface::create(), or through the autowire trait in newer code. Their dependencies are therefore also injected.

  • Changing a *.services.yml file requires a container rebuild, normally drush cache:rebuild.

  • A service decorator or a service provider (MeridianCollectionServiceProvider, in src/) can alter definitions, which is the supported way to replace a core service instead of patching core.

To inspect the container from Drush:

ddev drush php:eval "var_dump(\Drupal::hasService('meridian_collection.accession_formatter'));"
ddev drush php:eval "echo get_class(\Drupal::service('entity_type.manager')), PHP_EOL;"

Source: Drush: php:eval

The extension model: modules, themes, profiles and recipes

Drupal is extended through extensions, each a directory identified by a machine name and described by a <name>.info.yml file. There are four kinds, plus the distribution wrapper.

Kind Purpose Example for the Meridian Museum

Module

Adds behaviour: routes, services, entity types, blocks, field types, hooks and events. Lives in modules/custom/ or modules/contrib/.

meridian_collection: the collection entity, the "On display now" block and the sync command.

Theme

Controls markup and presentation: Twig templates, libraries, regions, breakpoints, single directory components. Lives in themes/custom/.

meridian, generated with Starterkit.

Installation profile

A special extension that defines the modules and configuration installed at the start of a new site. Lives in profiles/.

standard (core), Drupal CMS, or a custom profile.

Recipe

A directory with a recipe.yml that applies modules and configuration (and, optionally, content) to an existing site. It is applied once with php core/scripts/drupal recipe (or Drush’s recipe command), not enabled. Lives under recipes/.

meridian_events: the event content type, fields, view and permissions.

The *.info.yml file is the extension’s manifest. For a module:

# modules/custom/meridian_collection/meridian_collection.info.yml
name: 'Meridian Collection'
type: module
description: 'Museum collection objects, the On display now block and the collection sync.'
package: 'Meridian Museum'
core_version_requirement: ^11.3
dependencies:
  - drupal:node
  - drupal:media
configure: meridian_collection.settings

A recipe is a smaller, declarative unit. The following recipe installs the modules the event content type needs, then adjusts the configuration of an existing role:

# recipes/meridian_events/recipe.yml
name: 'Meridian events'
description: 'Adds the event content type, an upcoming-events view and editor permissions.'
type: 'Site'
install:
  - node
  - datetime
  - views
config:
  actions:
    user.role.content_editor:
      grantPermissions:
        - 'create event content'
        - 'edit own event content'
ddev composer require drupal/core-recipe-unpack
ddev exec -d /var/www/html/web php core/scripts/drupal recipe ../recipes/meridian_events -v
ddev drush cache:rebuild

Source: Drupal Recipes

Recipes are the mechanism behind Drupal CMS. They shipped in core in the 10.3 release cycle and became the preferred way to package reusable site features, replacing the older habit of exporting a feature module. They leave nothing behind to update: after a recipe has run, its modules, configuration and content belong to the site, which is why a recipe cannot be "upgraded" later. The page Recipes covers writing them in depth. The Drupal 10 Masterclass book presents recipes and Project Browser as future initiatives; both exist today.

A theme may declare a base theme, regions and libraries, and an installation profile may include a theme and a set of modules. Modules are discovered by scanning directories, so a new directory appears in the extension list only after the cache is rebuilt. A common mistake is a directory name that differs from the machine name used in the *.info.yml file name; they must match.

Entities and fields

The content model rests on the Entity API. An entity type is a kind of data object with its own storage, access control and routes. Drupal distinguishes two families:

Content entities

Data produced by users and stored in dedicated tables that change at run time: nodes, users, taxonomy terms, media items, comments, files, blocks placed as custom content, and your own entity types. They are revisionable, translatable and fieldable. They are content and are not exported with configuration.

Configuration entities

Configuration records modelled as objects: content types, field storage and field definitions, views, roles, text formats, image styles. They are stored in the active configuration and can be exported to YAML.

Most content entity types are divided into bundles. The node entity type has one bundle per content type (exhibition, event, news, page); media has bundles per media type; taxonomy_term has one per vocabulary (topic, gallery). A bundle chooses which fields the entity carries. A field has three layers that map to the user interface at Structure > Content types > Exhibition > Manage fields and its two sibling tabs:

  1. Field storage: the field’s name (field_opening_date), type (daterange) and cardinality, defined once per entity type.

  2. Field definition (instance): the field on one bundle: label, required flag, help text and default value.

  3. Display configuration: the widget used in the form (Manage form display) and the formatter used for output (Manage display), per view mode.

Base fields, such as a node’s title, owner and published status, are defined in code by the entity type; configurable fields are created in the user interface or by configuration import. Both are accessed identically. Every field value is an item list of typed data, so the same API reads a text, a date range and an entity reference.

A Meridian Museum query for the five current exhibitions shows the entity API in use. The call to accessCheck() is required: entity queries no longer check access implicitly, and omitting it raises an exception in recent versions.

<?php

namespace Drupal\meridian_collection;

use Drupal\Core\Entity\EntityTypeManagerInterface;

class ExhibitionLister {

  public function __construct(protected EntityTypeManagerInterface $entityTypeManager) {}

  /**
   * Returns up to five published exhibitions, newest opening first.
   *
   * @return \Drupal\node\NodeInterface[]
   */
  public function current(): array {
    $storage = $this->entityTypeManager->getStorage('node');
    $ids = $storage->getQuery()
      ->accessCheck(TRUE)
      ->condition('type', 'exhibition')
      ->condition('status', 1)
      ->sort('field_opening_date.value', 'DESC')
      ->range(0, 5)
      ->execute();
    return $storage->loadMultiple($ids);
  }

}

Source: Entity API

A custom entity type, like the Meridian collection_object with the fields accession_number, period, material, dimensions and on_display, is declared with a PHP attribute on the entity class and gets storage, forms, views integration and routes from handler classes. See Entity API and Custom entity types. Run queries through the entity query or the Database API (Database API and entity queries); never write raw SQL against entity tables, because the table layout changes when fields are added or become translatable.

Configuration, content, state and settings

A frequent beginner mistake is to put the wrong data in the wrong store. Drupal separates four kinds of data, and the separation determines how a site is deployed from a local machine to staging and production.

Kind What it is Where it lives Moves between environments by

Configuration

Site-building choices: content types, fields, views, roles, text formats, enabled modules, system.site

The active configuration in the database; exported as YAML files in the configuration sync directory

Export and import (drush config:export, drush config:import), committed to Git

Content

Entities created by users: nodes, media, terms, users, comments

Database tables (plus uploaded files on disk)

Database sync, the Migrate API, or content staging with Workspaces; not with configuration management

State

Machine-generated, environment-specific values: the last cron run, the maintenance flag, a deployment identifier

The key_value table through the State API

Not moved; each environment recomputes its own

Settings

Infrastructure and secrets: database connections, hash_salt, trusted hosts, file paths, cache backends, configuration overrides

settings.php and files it includes, often reading environment variables

Per environment, outside configuration management

The following snippets show one access pattern for each:

<?php

use Drupal\Core\Site\Settings;

// Configuration (read): immutable by default, reflects overrides.
$name = \Drupal::config('system.site')->get('name');

// Configuration (write): use the editable object, then save.
\Drupal::configFactory()->getEditable('meridian_collection.settings')
  ->set('accession_prefix', 'MM')
  ->save();

// State: runtime values you never export.
\Drupal::state()->set('meridian_collection.last_sync', \Drupal::time()->getRequestTime());

// Settings: read from settings.php.
$syncDir = Settings::get('config_sync_directory');

(The \Drupal:: calls above are for illustration in a script; in a class, inject ConfigFactoryInterface, StateInterface and use Settings::get() as shown in the formatter example.) In settings.php, secrets and per-environment values come from the environment, never from literals committed to Git:

<?php

$databases['default']['default'] = [
  'database' => getenv('DRUPAL_DB_NAME') ?: 'db',
  'username' => getenv('DRUPAL_DB_USER') ?: 'db',
  'password' => getenv('DRUPAL_DB_PASSWORD') ?: 'CHANGE_ME',
  'host' => getenv('DRUPAL_DB_HOST') ?: 'db',
  'port' => '3306',
  'driver' => 'mysql',
  'prefix' => '',
];
$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT') ?: 'CHANGE_ME';
$settings['config_sync_directory'] = '../config/sync';
$settings['trusted_host_patterns'] = ['^meridian\.example\.org$'];

// Per-environment override that is not exported to configuration.
$config['system.maintenance']['message'] = 'The Meridian Museum site is being updated.';

Three pitfalls follow from the table. Configuration that contains secrets (for example an API key stored in a module’s settings form) ends up in Git when exported, so secrets belong in settings.php or the Key module’s providers; the AI pages explain this. A site that changes a content type on production and then imports configuration from Git silently reverts the change. And configuration overrides in settings.php ($config[...]) take precedence over the active configuration but are not shown on the configuration forms. The commands are covered in Configuration management, the API in Configuration API and schema, and the files in settings.php and environments.

ddev drush config:get system.site name
ddev drush config:export -y
ddev drush state:get system.cron_last
ddev drush config:import -y

Render arrays and cacheability

Drupal output is built in two steps: PHP code returns a nested associative array that describes markup, and the renderer turns it into HTML. The array is called a render array. Keys that start with # are properties; keys without it are children. A property tells the renderer how to handle the element: #type selects an element plugin (table, link, html_tag, component), #theme selects a theme hook and its Twig template, #markup supplies markup that is filtered for safety, and #attached, #cache and #lazy_builder handle assets, caching and personalisation. The main content renderer described above is the final step that converts the array returned by a controller.

<?php

namespace Drupal\meridian_collection\Controller;

use Drupal\Core\StringTranslation\StringTranslationTrait;

class OnDisplayController {

  use StringTranslationTrait;

  public function build(): array {
    return [
      '#theme' => 'item_list',
      '#title' => $this->t('On display now'),
      '#items' => [$this->t('Fossil gallery'), $this->t('Meteorite hall')],
      '#cache' => [
        'tags' => ['node_list:exhibition', 'config:meridian_collection.settings'],
        'contexts' => ['languages:language_interface', 'user.permissions'],
        'max-age' => 3600,
      ],
      '#attached' => ['library' => ['meridian/gallery']],
    ];
  }

}
# meridian_collection.routing.yml
meridian_collection.on_display:
  path: '/collection/on-display'
  defaults:
    _controller: '\Drupal\meridian_collection\Controller\OnDisplayController::build'
    _title: 'On display now'
  requirements:
    _permission: 'access content'

Source: Render API

The #cache key carries the cacheability metadata, the three pieces of information the caching layers need:

Cache tags

Data dependencies. A tag such as node:42, node_list:exhibition or config:system.site is attached to everything that was built from that data. When the data changes, Drupal invalidates the tag and every cached item that carries it disappears, even if it is stored in the page cache, a CDN or Varnish (when the Purge tooling or tag headers are used).

Cache contexts

Variations. A context such as user.permissions, languages:language_interface or url.query_args:gallery states that the output differs depending on a request property. Without it, the first visitor’s version would be served to everyone.

Max-age

Time dependency, in seconds. The default Cache::PERMANENT (-1) means "until a tag is invalidated"; 0 means the item must never be cached.

Metadata bubbles up: when a child element adds a tag or a context, every parent, and finally the response, inherits it. That is why one uncacheable element (max-age 0, or a context such as user on an anonymous page) can make a whole page uncacheable. The cache is split into bins (render, page, dynamic_page_cache, config, discovery, bootstrap, entity, data, default), each of which can use a different backend (database, Redis, memcache).

Two page-level caches use this metadata:

  • Internal page cache (module page_cache) serves anonymous visitors a complete stored response before the application boots. It is the cheapest layer but only works for anonymous users and responses without personalised contexts.

  • Internal dynamic page cache (module dynamic_page_cache) stores a response for all users, with the personalised parts turned into placeholders that are filled in per request. The #lazy_builder property and #create_placeholder mark an element as a placeholder. With BigPipe these placeholders are streamed to the browser after the main page is sent.

Typical pitfalls: forgetting accessCheck or a cache context on a controller that outputs user-specific data (the data leaks between users through the cache), setting max-age to 0 on a block that is placed on every page, and writing '#cache' => ['max-age' => 0] as a quick fix instead of adding the right tag. The depth chapter is Render API and cache metadata; operational tuning is in Performance and caching.

Security note: Twig autoescapes variables, and #markup is passed through the XSS filter. Using the |raw filter on a user’s content, or Markup::create() on unsanitised strings, bypasses those protections and creates cross-site scripting vulnerabilities. See Security hardening.

Plugins

A plugin is a small class that implements one variant of a feature, discovered and instantiated by a plugin manager service. Blocks, field types, widgets and formatters, image effects, views handlers, CKEditor 5 plugins, queue workers and Migrate processes are all plugins. A plugin manager defines the plugin type: which interface the plugins implement, where to look for them (src/Plugin/Block), which attribute (or annotation) describes them, and which cache stores the discovery. That is how a module adds a feature without changing core: it adds a class in the right directory and clears the cache.

Since Drupal 10.2 core plugin types are discovered through PHP attributes. Annotations continue to work but are on a deprecation path, and the removal version is not yet fixed; in 10.6 core began carrying a fork of the parts of the abandoned doctrine/annotations library that it needs. New code uses attributes, which is a correction of the Development Cookbook, where all the plugin examples use annotations.

<?php

namespace Drupal\meridian_collection\Plugin\Block;

use Drupal\Core\Block\Attribute\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;

/**
 * Lists the objects currently on display.
 */
#[Block(
  id: 'meridian_on_display_now',
  admin_label: new TranslatableMarkup('On display now'),
  category: new TranslatableMarkup('Meridian Museum'),
)]
class OnDisplayNowBlock extends BlockBase {

  public function build(): array {
    return [
      '#markup' => $this->t('Fossil gallery and Meteorite hall.'),
      '#cache' => ['tags' => ['node_list:exhibition']],
    ];
  }

}

Source: Plugin API

After placing this file, rebuild the cache and place the block in Structure > Block layout. Common errors: a wrong namespace (the directory src/Plugin/Block must match Drupal\<module>\Plugin\Block), a duplicate id, and a missing cache rebuild so the plugin manager never sees the new class. Plugin types are explained in Plugin API and attributes and Block, field type, widget and formatter plugins.

Hooks and events

Drupal has two ways for code to react to something that happens elsewhere.

Hooks are Drupal’s original extension points. Core or a module "invokes" a hook, for example hook_help, hook_form_alter or hook_entity_presave, and every module that implements it is called. For fifteen years an implementation was a function named modulename_hookname() in a .module file. Since Drupal 11.1 a hook is implemented as a method with the #[Hook('...')] attribute on a class in the module’s src/Hook directory, which is registered and autowired as a service, so it receives its dependencies through the constructor. The change record is Support for object oriented hook implementations using autowired services; some ModuleHandler methods deprecated.

<?php

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.
 */
class CollectionHooks {

  use StringTranslationTrait;

  #[Hook('help')]
  public function help(string $route_name, RouteMatchInterface $route_match): ?string {
    if ($route_name === 'help.page.meridian_collection') {
      return '<p>' . $this->t('Manages the museum collection objects.') . '</p>';
    }
    return NULL;
  }

}

Version notes for hooks: ordering, removal and replacement attributes arrived in 11.2 and replace hook_module_implements_alter; themes can implement OOP hooks from 11.3 (preprocess hooks from 11.2); #[LegacyHook] keeps a module compatible with 10.x while it supports both. hook_install, hook_uninstall, hook_schema, hook_requirements, hook_update_N and hook_post_update_NAME must stay procedural, and conditionally defined hooks or procedural hooks placed in src/ are not supported. The older way is not removed yet (the change record says procedural support is expected to remain until Drupal 13 at the earliest), so the books remain readable but should not be copied for new code.

Events are the Symfony event dispatcher. A class implementing EventSubscriberInterface is declared as a service with the event_subscriber tag and subscribes to named events, which core uses for kernel events, routing, configuration changes, entity type changes and more. Events carry typed objects and are easier to unit-test than hooks.

services:
  meridian_collection.response_subscriber:
    class: Drupal\meridian_collection\EventSubscriber\ResponseSubscriber
    tags:
      - { name: event_subscriber }
<?php

namespace Drupal\meridian_collection\EventSubscriber;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;

class ResponseSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [KernelEvents::RESPONSE => ['onResponse', -10]];
  }

  public function onResponse(ResponseEvent $event): void {
    $event->getResponse()->headers->set('X-Meridian', 'collection');
  }

}

When to use which: hooks are still the way to alter things core exposes only through hooks (forms, entity presave, theme preprocess); events are the way to react to the framework itself (requests, responses, routing, configuration). Both are covered in Hooks and OOP hooks and Events and subscribers.

A local site to explore

Everything above can be tried on a local site. A minimal DDEV configuration for the museum project follows; the complete installation is in Installation with Composer and DDEV.

# .ddev/config.yaml
name: meridian
type: drupal11
docroot: web
php_version: "8.3"
database:
  type: mariadb
  version: "10.11"
webserver_type: nginx-fpm
ddev start
ddev composer create-project drupal/recommended-project:^11.4 .
ddev composer require drush/drush:^14
ddev drush site:install --account-name=admin --site-name="Meridian Museum" -y
ddev launch

Two version facts matter here. The standard profile of Drupal 11.4 no longer creates the Article and Page content types, so tutorials that begin with "add an article" must first create a content type. And drupal/legacy-project is abandoned in 11.4; use drupal/recommended-project. Composer is the only supported way to add or update code: uploading a module from the user interface was removed in 10.4 and 11.0, and updating from the user interface in 11.2. The contributed Project Browser and Automatic Updates modules use core’s Package Manager and need a writable code base; Automatic Updates applies patch-level core updates only, and it is not available for multisite or as an unattended cron job.

Where tutorials are out of date

Some statements still found online or in print contradict the architecture described here:

  • "Edit index.php to bootstrap the kernel in your own way": since 11.4 the front controller uses Symfony Runtime. Existing controllers keep working until at least Drupal 12.

  • "Create a plugin with an annotation": use attributes (10.2 and later).

  • "Put hook functions in the .module file": use #[Hook] classes in src/Hook (11.1 and later); only a small set of hooks must remain procedural.

  • "Call \Drupal::service() from your class": inject the service.

  • "Enable the SDC module": single directory components are part of the render system since 10.3.

  • "Use the Article content type": the standard profile no longer creates it (11.4).

  • "Upload a module in Extend": removed, use Composer.

Drupal 7 specifics (for example hook_menu, variable_get and db_query) do not apply to any supported version; see Migrating from Drupal 7. For a cross-CMS view of market share and other metrics, see CMS comparison, and for the decision between Drupal and other CMSs Comparison and decision guide. The PHP language features used above (attributes, namespaces, Composer) are covered in PHP attributes, Namespaces and autoloading and Composer and packages.