Project Layout and Local Development

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 site is a Composer project, not a folder of files you download and unzip. The directory you commit to Git, the directory the web server serves, and the directory Composer writes into are three different things, and mixing them up is the most common source of "it works on my machine" problems. This page explains the tree that drupal/recommended-project creates, what the Composer scaffold plugin adds to it, how to treat composer.lock, and how to set up a local environment that is pleasant to develop in: development-only settings, Twig debugging, disabled caches, Xdebug and a mail catcher. It was checked against the official documentation on 2026-10-30 and written for Drupal 11.4.x (11.4.11 at the check date), Drush 13 or 14 and PHP 8.3 or later. Drupal 10 reaches its end of life on 2026-12-09 (10.6 is its last minor), so a new site, including the Meridian Museum example used throughout this sub-section, starts on Drupal 11. Drupal 12.0.0 is planned for the week of 2026-12-07 and requires PHP 8.5.

The page assumes you have already created a project; the installation steps themselves are in Installation with Composer and DDEV. Background on Composer itself is in Composer and packages in the PHP section, and the finer points of settings.php are in settings.php and environments.

Why the tree is split in two

Older Drupal sites (up to Drupal 8.7) had index.php, core/ and sites/ in the same directory as everything else, so the web server could in principle reach any file in the project. The drupal/recommended-project template instead uses a relocated document root: only the web/ directory is the DocumentRoot, and composer.json, composer.lock, vendor/ and your exported configuration stay one level above it. A request can never name a file outside web/, so a misconfigured server cannot leak composer.lock (which reveals exact package versions to an attacker), a database dump left in the project, or a private upload.

The alternative template, drupal/legacy-project, puts everything in the project root. It exists only for hosts that cannot change their document root, it relies on the Vendor Hardening plugin to protect vendor/, and the package is marked abandoned as of Drupal 11.4. Do not choose it for a new site. Hosts that force a document root of public_html or docroot are handled by changing the web-root location and the installer-paths in composer.json (shown below), not by switching templates.

Project tree of the Meridian Museum site: composer.json, composer.lock, vendor, config/sync, private and recipes outside the web root; core, modules, themes, libraries and sites/default under web

The Composer project tree

The Meridian Museum site was created with the following command (Composer 2 and PHP 8.3 or later are required):

composer create-project drupal/recommended-project meridian-museum
cd meridian-museum
composer require drush/drush

After installation and a few weeks of work the tree looks like this. The right-hand column says whether the path is committed to Git.

Path Contents and rules Commit?

composer.json

The project’s requirements, patches, scaffold configuration and installer-paths. You edit it, usually through composer require.

Yes

composer.lock

The exact version and source reference of every package. See composer.lock discipline.

Yes

vendor/

Every PHP dependency (Symfony, Twig, Guzzle, Drush, Composer’s autoloader). Recreated by composer install.

No

web/

The document root. Everything the web server may serve.

Partly

web/core/

Drupal core, installed by Composer from drupal/core. Never edit it; changes are lost on the next update.

No

web/modules/contrib/

Modules from drupal.org such as drupal/token, installed by Composer.

No

web/modules/custom/

Your own modules, for example meridian_collection.

Yes

web/themes/contrib/ and web/themes/custom/

Contributed themes (Composer) and your own themes such as meridian.

Custom only

web/profiles/

Installation profiles, rarely used when you build on the Standard profile.

Custom only

web/libraries/

Third-party front-end libraries of type drupal-library.

No

web/sites/default/

settings.php, services.yml, and the public files directory files/. See Settings files in web/sites/default.

Partly

recipes/

Recipes installed by Composer (type drupal-recipe) and, if you want, your own such as meridian_events.

Custom only

config/sync/

The configuration synchronisation directory. Not created by the template; you create it and point $settings['config_sync_directory'] at it. See Configuration management.

Yes

private/

The private file system, outside the document root. See File system and private files.

No

drush/

Drush site aliases and drush.yml project configuration.

Yes

.ddev/

The DDEV project definition (config.yaml) and add-ons.

Yes

Three points are easy to miss:

  • web/core is a Composer artefact, not a source folder. The installer-paths section in the template’s composer.json maps package types to directories: type:drupal-core to web/core, type:drupal-module to web/modules/contrib/{$name}, type:drupal-custom-module to web/modules/custom/{$name}, type:drupal-theme to web/themes/contrib/{$name}, type:drupal-library to web/libraries/{$name} and type:drupal-recipe to recipes/{$name}. These paths are applied by the composer/installers plugin, which is why it must be listed in allow-plugins.

  • Contrib is separated from custom. Git ignores contrib, never custom. A module you wrote yourself belongs in web/modules/custom/meridian_collection, and its first line of defence against being deleted by Composer is that Composer only manages the contrib directories.

  • Recipes are not modules. A recipe installed with composer require drupal/some_recipe is unpacked into recipes/ and applied once with php core/scripts/drupal recipe ../recipes/some_recipe. The drupal/core-recipe-unpack plugin, required by the template, copies the recipe’s own requirements into your composer.json so they are tracked. Recipes have no update path: after applying one, the site is ordinary Drupal. See Recipes.

The template’s composer.json for the 11.x line (condensed to the parts that shape the tree) is:

{
    "name": "drupal/recommended-project",
    "type": "project",
    "require": {
        "composer/installers": "^2.3",
        "drupal/core-composer-scaffold": "^11",
        "drupal/core-recipe-unpack": "^11",
        "drupal/core-project-message": "^11",
        "drupal/core-recommended": "^11"
    },
    "require-dev": {
        "drupal/core-dev": "^11"
    },
    "conflict": {
        "drupal/drupal": "*"
    },
    "minimum-stability": "dev",
    "prefer-stable": true,
    "config": {
        "allow-plugins": {
            "composer/installers": true,
            "drupal/core-composer-scaffold": true,
            "drupal/core-recipe-unpack": true,
            "drupal/core-project-message": true
        },
        "sort-packages": true
    },
    "extra": {
        "drupal-scaffold": {
            "locations": {
                "web-root": "web/"
            }
        },
        "installer-paths": {
            "web/core": ["type:drupal-core"],
            "web/libraries/{$name}": ["type:drupal-library"],
            "web/modules/contrib/{$name}": ["type:drupal-module"],
            "web/themes/contrib/{$name}": ["type:drupal-theme"],
            "drush/Commands/contrib/{$name}": ["type:drupal-drush"],
            "web/modules/custom/{$name}": ["type:drupal-custom-module"],
            "web/themes/custom/{$name}": ["type:drupal-custom-theme"],
            "recipes/{$name}": ["type:drupal-recipe"]
        }
    }
}

Notes on the packages:

  • drupal/core-recommended is the package to require for core. It pins the exact versions of core’s dependencies, which gives every site the same tested combination. Since the 11.x line it no longer pins Guzzle, Twig or the Symfony polyfills, so a site can update those independently. Moving from drupal/core-recommended to the plain drupal/core removes the pinning altogether, which is rarely wise because dependencies then float to new minors.

  • drupal/core-dev (a require-dev package) brings PHPUnit, PHPStan-related tooling and the Coder rules used for testing; production deployments install with --no-dev. See Automated testing.

  • minimum-stability: dev with prefer-stable: true lets Composer accept a dev dependency only when no stable one exists. Prefer an explicit @alpha or @beta suffix on the one package that needs it over lowering stability for the whole project.

  • The conflict entry on drupal/drupal stops Composer from installing the monolithic core package into the project.

  • Drush is not part of the template. Install it in each project with composer require drush/drush. Drush 13 serves Drupal 10.2 and later (and 11), and Drush 14 serves Drupal 11.3 and later (and 12). See Drush.

Changing the document root name

Some hosts require a document root called public_html or docroot. Change the location in two places, then run composer install again so core and the scaffold files move:

{
    "extra": {
        "drupal-scaffold": {
            "locations": {
                "web-root": "docroot/"
            }
        },
        "installer-paths": {
            "docroot/core": ["type:drupal-core"],
            "docroot/libraries/{$name}": ["type:drupal-library"],
            "docroot/modules/contrib/{$name}": ["type:drupal-module"],
            "docroot/modules/custom/{$name}": ["type:drupal-custom-module"],
            "docroot/themes/contrib/{$name}": ["type:drupal-theme"],
            "docroot/themes/custom/{$name}": ["type:drupal-custom-theme"]
        }
    }
}

Every path in this page that begins with web/ then begins with your chosen name. Keep it in .gitignore, in the DDEV docroot setting and in your deployment scripts too.

Composer scaffold

Some files must exist in the document root but are not part of any module: index.php, update.php, autoload.php, .htaccess, robots.txt, web.config, and the defaults sites/default/default.settings.php and sites/default/default.services.yml. They ship inside the drupal/core package, outside web/core's normal file tree, and the drupal/core-composer-scaffold plugin copies them into web/ every time you run composer install or composer update. Because these files are written by a plugin, not by you, they are usually listed in .gitignore in projects that deploy by running Composer on the server, and committed in projects that deploy a built artefact. Choose one policy and apply it consistently.

The behaviour is configured in the top-level extra.drupal-scaffold section:

  • locations names destination roots. web-root is the only required one.

  • allowed-packages lists, in order, the packages allowed to scaffold files (drupal/core is implicitly allowed). Later entries win when two packages scaffold the same destination, and your own composer.json has the highest priority.

  • file-mapping maps a destination to a mode: replace (a plain string path), append, prepend, or skip (false).

For the Meridian Museum, the editors want an extra Disallow rule in robots.txt (Drupal 11.4 recommends adding Disallow: /search? to customised files), and the team keeps its own development.services.yml that scaffolding must not touch:

{
    "extra": {
        "drupal-scaffold": {
            "locations": {
                "web-root": "web/"
            },
            "file-mapping": {
                "[web-root]/robots.txt": {
                    "append": "assets/robots-meridian.txt"
                },
                "[web-root]/sites/development.services.yml": false
            }
        }
    }
}

Appending or prepending is preferable to excluding a file with false: an excluded file stops receiving updates, so a security fix to .htaccess (for example the 11.4.11 and 10.6.20 change that swaps assert.active for zend.assertions) would silently not reach you. If a scaffolded file must be patched, run the patch from a post-drupal-scaffold-cmd entry under the top-level scripts section. There is no stand-alone scaffold command to memorise; Composer runs the plugin as part of install and update.

A web server that serves Drupal from a subdirectory of the project, or a Windows host, matters here too. IIS support was removed in Drupal 11.0, and Windows production hosting is deprecated from 11.3. Use Apache 2.4.7 or later, or nginx, on Linux. From 11.3 a proxy or CDN in front of the site must pass the HTMX headers (HX-Request, HX-Redirect) through unchanged.

composer.lock discipline

composer.json states what you want ("drupal/token": "^1.15"), and composer.lock records what was resolved: the exact version and commit of every package, including transitive dependencies. Treat the pair as follows.

  1. Commit composer.lock. A teammate, the CI job and the production server then install the very same code with composer install. Without the lock file, each of them resolves versions afresh and gets different results.

  2. Install from the lock; update deliberately. On every machine except the one where you are performing an update, run composer install (add --no-dev and --optimize-autoloader in production). Never run composer update on a server.

  3. Update in a branch, one concern at a time. To move a single module use composer update drupal/token --with-dependencies; for core use composer update "drupal/core-*" --with-all-dependencies. Commit the changed composer.json and composer.lock together, then run drush updatedb, drush config:export and test.

  4. Use composer update --lock after editing composer.json by hand (for example a new patch entry) to regenerate the lock file’s metadata without upgrading packages.

  5. Do not mix update methods. Since Drupal 11.2 there is no supported way to update from the administration UI, and the module upload form was removed in 10.4/11.0: Composer is the only supported way to add or update code. Automatic Updates and Project Browser (contributed modules built on core’s Package Manager) still run Composer behind the scenes and need a writable codebase; Automatic Updates handles patch-level core updates only. Do not use them on a codebase you also deploy with Composer unless the workflow is agreed. See Automatic Updates and Project Browser and Updates, Composer and security advisories.

  6. Quote constraints. composer require 'drupal/ctools:^4.1' needs quotes in a shell, and double quotes are safer on Windows.

Typical daily commands for the Meridian Museum:

composer require 'drupal/token:^1.15'            # add a module
composer require --dev 'drupal/devel:^5'          # development-only helper
composer update drupal/token --with-dependencies   # update one module
composer update "drupal/core-*" --with-all-dependencies   # update core and its pinned dependencies
composer show drupal/core-recommended --all        # list available versions
composer install --no-dev --optimize-autoloader    # production install from the lock file

Common pitfalls: a lock file produced on PHP 8.5 and installed on PHP 8.3 fails because Composer checks the platform; set config.platform.php to the production PHP version so resolution always targets it (older books showed a platform.php of 8.0.7, which is below Drupal 10’s own minimum, so copy the value from your own server instead). A merge conflict in composer.lock is never fixed by hand: resolve composer.json, then run composer update --lock. And remember that a package replaced by a patch (the cweagans/composer-patches plugin, with patches listed under extra.patches) is re-patched on every composer install, so a failing patch breaks the build, which is the intended signal.

Settings files in web/sites/default

The installer creates web/sites/default/settings.php by copying default.settings.php, and the first request after installation. The directory also holds services.yml (copied from default.services.yml) and files/, the public file directory. Once the site is installed, make settings.php and the directory non-writable (chmod 444 web/sites/default/settings.php, chmod 555 web/sites/default) in production; the Status report warns while they are writable. Do the opposite before running composer install, which needs to write to the directory (chmod u+w web/sites/default).

Three files cooperate to keep configuration per environment:

  • settings.php is committed and contains everything common to all environments: the config sync directory, the private path, trusted hosts and reading secrets from the environment.

  • settings.local.php is not committed. It contains developer overrides and is loaded last by an include at the end of settings.php.

  • development.services.yml lives in web/sites/ and is a container definition file, enabled from settings.local.php.

A reasonable committed settings.php tail for the Meridian Museum follows. It contains no secrets: values come from the environment (DDEV, a container platform or a deployment tool provides them).

<?php

// Common to every environment.
$settings['config_sync_directory'] = '../config/sync';
$settings['file_private_path'] = '../private';

$settings['hash_salt'] = getenv('DRUPAL_HASH_SALT') ?: '';
$settings['trusted_host_patterns'] = [
  '^meridian-museum\.ddev\.site$',
  '^www\.meridianmuseum\.example$',
];

$databases['default']['default'] = [
  'driver' => 'mysql',
  'database' => getenv('DRUPAL_DB_NAME') ?: 'db',
  'username' => getenv('DRUPAL_DB_USER') ?: 'db',
  'password' => getenv('DRUPAL_DB_PASSWORD') ?: '',
  'host' => getenv('DRUPAL_DB_HOST') ?: 'db',
  'port' => '3306',
  'prefix' => '',
  'namespace' => 'Drupal\\mysql\\Driver\\Database\\mysql',
  'autoload' => 'core/modules/mysql/src/Driver/Database/mysql/',
];

// Developer overrides, never committed.
if (file_exists($app_root . '/' . $site_path . '/settings.local.php')) {
  include $app_root . '/' . $site_path . '/settings.local.php';
}

The setting names are explained in settings.php and environments. A hash_salt left empty makes Drupal refuse to run, which is the safe failure; a salt committed to a public repository weakens one-time login links, CSRF tokens and form tokens. When DDEV manages the file (the default), it writes settings.ddev.php, adds an include for it to settings.php and sets the database host to db; those pieces are guarded so they do nothing outside DDEV, and the generated file is Git-ignored.

Local settings: settings.local.php and development.services.yml

Core ships two example files in web/sites/: example.settings.local.php and development.services.yml. Copy the first to web/sites/default/settings.local.php, and make sure the include block in settings.php (above) is active.

cp web/sites/example.settings.local.php web/sites/default/settings.local.php

The example already enables the development container file and keeps caches enabled until you edit it, which is the point: settings.local.php is where per-developer choices live.

<?php

// web/sites/default/settings.local.php -- local only, not committed.

$settings['container_yamls'][] = DRUPAL_ROOT . '/sites/development.services.yml';

// Do not generate the performance-sensitive aggregated assets while theming.
$config['system.performance']['css']['preprocess'] = FALSE;
$config['system.performance']['js']['preprocess'] = FALSE;

// Show errors on screen locally (ERROR_REPORTING_DISPLAY_ALL).
$config['system.logging']['error_level'] = 'verbose';

// Do not let test modules appear in the Extend page.
$settings['extension_discovery_scan_tests'] = FALSE;

// Optional: use the null cache backend for the render-related bins.
$settings['cache']['bins']['render'] = 'cache.backend.null';
$settings['cache']['bins']['page'] = 'cache.backend.null';
$settings['cache']['bins']['dynamic_page_cache'] = 'cache.backend.null';

and web/sites/development.services.yml:

parameters:
  http.response.debug_cacheability_headers: true
services:
  cache.backend.null:
    class: Drupal\Core\Cache\NullBackendFactory

http.response.debug_cacheability_headers makes Drupal send the X-Drupal-Cache-Tags, X-Drupal-Cache-Contexts and X-Drupal-Cache-Max-Age response headers, which is the quickest way to debug render cache metadata (see Render API and cache metadata).

What changed in 10.3: do not set Twig options in services.yml

Older books, and any tutorial copied from them, put Twig options in development.services.yml:

# Pre-10.3 approach -- do not use any more
parameters:
  twig.config:
    debug: true
    auto_reload: true
    cache: false

The drupal.org page on disabling caches describes this as the method for Drupal 10.2 and older: from 10.3, a twig.config entry in services.yml or a services file overrides the administration setting, so the checkbox on the development settings form appears to do nothing. Modernizing Drupal 10 Theme Development (2023) and the Drupal 10 Development Cookbook (2023) show the services-file approach because they were written for 10.0 and 10.1; since 10.1 there is a development settings form, and since 10.3 the supported method is that form or Drush. Keep development.services.yml for the null cache backend and the cacheability headers, and leave Twig settings out of it. The same applies to CSS and JavaScript aggregation: control it with configuration (system.performance), not with a services file.

The development settings form

Since Drupal 10.1 the page at /admin/config/development/settings (menu: Configuration > Development > Development settings) offers three switches that previously required editing files:

  • Twig development mode. Turns on Twig debug (dump() output, the HTML comments that list template suggestions and the file that rendered each fragment) and disables the Twig template cache, with automatic reload when a template changes. Both options are checked by default once the mode is on.

  • Disable Twig cache separately, when you want suggestions comments off but fresh templates.

  • Do not cache markup. Disables the render cache, the dynamic page cache and the page cache by routing those bins to the null backend.

The values are stored in the key_value table (in a development_settings collection since 10.3; in the state table in 10.1 and 10.2), not in configuration. They are therefore never exported with drush config:export and never reach staging or production, which is exactly what you want from a development switch. They are also per database: a fresh database import from production turns them off again.

Drush provides a one-command version in Drush 13.6 and later (PHP 8.3 or later):

ddev drush theme:dev on     # Twig debug on, caches off, CSS/JS aggregation off, caches cleared
ddev drush theme:dev off    # reverse all of that

Pitfalls that cost time:

  • Anonymous visitors still receive cached pages. Log in to see the effect of disabled caches, or use a private browser window with a session.

  • Browser, sendfile and OPcache can still serve stale files. Disable the browser cache in developer tools, turn off sendfile in Apache or nginx, and see OPcache, JIT and performance for OPcache timestamps.

  • Never enable these in production. Disabled caches multiply the response time many times over, and Twig debug HTML comments reveal the module and theme file paths.

  • Twig dump() and kint() output needs the development switch or the Devel module. In production the dump() function does nothing when Twig debug is off. See Twig templates and debugging.

flowchart TD A[Request] --> B{Logged in?} B -- no --> C[Page cache may answer] B -- yes --> D{Do not cache markup?} D -- yes --> E[Render and page caches use the null backend] D -- no --> F[Dynamic page cache and render cache answer] E --> G{Twig development mode?} F --> G G -- yes --> H[Twig debug output, no Twig file cache] G -- no --> I[Compiled Twig templates reused]

DDEV for Drupal

DDEV runs the web server, PHP, the database and helper services in Docker containers and mounts your project into them, so the whole team shares one environment definition. The Drupal 11 quick start uses the drupal11 project type (Drupal 10 has drupal10, and drupal12 is available for the unreleased Drupal 12):

mkdir meridian-museum && cd meridian-museum
ddev config --project-type=drupal11 --docroot=web
ddev start
ddev composer create-project drupal/recommended-project
ddev composer require drush/drush
ddev drush site:install --account-name=admin --account-pass=CHANGE_ME -y
ddev launch

ddev config writes .ddev/config.yaml, which you commit. A pinned configuration for the Meridian Museum is:

# .ddev/config.yaml
name: meridian-museum
type: drupal11
docroot: web
php_version: "8.3"
webserver_type: nginx-fpm
database:
  type: mariadb
  version: "10.11"
nodejs_version: "22"
composer_version: "2"
xdebug_enabled: false

Match php_version and the database to production. Drupal 11 needs PHP 8.3 or later; MariaDB 10.11 is a supported choice for 11.x, whereas the Drupal 10 Development Cookbook (2023) used MariaDB 10.3, which Drupal 10 and 11 no longer support. Drupal 12 requires PHP 8.5, MariaDB 10.11 and PostgreSQL 18 or 19 according to the requirements page (the beta 1 release notes give a different PostgreSQL value, so check the final release notes before upgrading the container). Put Node.js in nodejs_version only if the theme has a build step; see Front-end tooling. How DDEV compares with a Docker Compose setup is covered in Docker and containers.

Day-to-day DDEV commands

ddev start                      # start containers
ddev stop                       # stop them, keep data
ddev describe                   # URLs, ports, database credentials
ddev drush cr                   # run Drush inside the web container
ddev composer install           # run Composer inside the container, with its PHP version
ddev exec php core/scripts/drupal recipe ../recipes/meridian_events
ddev snapshot                   # database snapshot before risky work
ddev export-db --file=backup.sql.gz
ddev launch $(ddev drush uli)   # open the site logged in as user 1 (a one-time link)

Run Composer and Drush through ddev so that the PHP version and extensions match the container, not whatever your laptop has installed. The drush uli link is a one-time login URL and is sensitive until used, so do not paste it into chat or issue trackers.

Xdebug in DDEV

Xdebug is installed in the web container but disabled by default because it slows requests down noticeably. Switch it on only while debugging:

ddev xdebug on        # enable until the project is restarted
ddev xdebug status
ddev xdebug off
ddev xdebug toggle

Your IDE must listen on Xdebug’s default port 9003. In PhpStorm, enable "Start Listening for PHP Debug Connections", set a breakpoint, load a page, and map the project directory to /var/www/html in the container; map the whole project (not just web/) so breakpoints in vendor/ work. In VS Code, install the PHP Debug extension and use the "Listen for Xdebug" launch configuration with a path mapping from ${workspaceFolder} to /var/www/html. If another process already owns port 9003, create .ddev/php/xdebug_client_port.ini with the line xdebug.client_port=9000 and point the IDE at 9000. ddev utility xdebug-diagnose is an experimental helper that checks the configuration and network path.

Drupal-specific tips:

  • The best places for breakpoints are web/core/lib/Drupal/Core/Routing/ for routing problems, web/core/lib/Drupal/Core/Render/Renderer.php for render arrays, and your own controllers and hook classes in web/modules/custom/meridian_collection/src.

  • Composer disables Xdebug by default; set COMPOSER_ALLOW_XDEBUG=1 to debug Composer plugins and the scaffold step.

  • Drush commands are CLI requests. Run ddev xdebug on, then ddev drush meridian:sync, and the IDE stops in the command class.

  • Cached containers and compiled Twig templates move code to temporary files, so a breakpoint in a Twig-generated class may not fire; put xdebug_break(); in the code instead, or clear caches first.

  • PHPUnit runs under Xdebug the same way: ddev xdebug on and then ddev exec vendor/bin/phpunit -c web/core web/modules/custom/meridian_collection/tests.

  • Never leave Xdebug listening on a shared development server or a staging site: it allows remote code execution to whoever can reach the debug port.

General PHP debugging is covered in Debugging and profiling.

Mailpit in DDEV

Drupal sends password resets, contact form notifications and workflow emails. During development you want to read them without sending any to real addresses. DDEV includes Mailpit, which receives all mail that PHP sends from the container (the same mechanism that sendmail uses) and shows it in a web interface at the project’s port 8026, for example https://meridian-museum.ddev.site:8026. Open it with:

ddev mailpit

For Symfony Mailer and Symfony Mailer Lite, DDEV’s generated settings.ddev.php already points the transport at Mailpit, so no configuration is needed. For an SMTP module, set the server to localhost and the port to 1025. Mailpit does not capture mail when the site is configured to use a real SMTP server or an external provider, so remove such configuration (or override it in settings.local.php) locally to avoid sending to real people. Drupal’s Mail System and its email theming are covered in Email and error pages theming. If you use plus-addressing in test accounts (editor+1@example.test), Mailpit tags messages by the part after the plus; disable that with ddev config global --web-environment-add=MP_TAGS_DISABLE=plus-addresses.

.gitignore

Commit the files you author and ignore everything Composer or Drupal generates or writes at runtime. A .gitignore for the Meridian Museum:

# Composer-managed code
/vendor/
/web/core/
/web/modules/contrib/
/web/themes/contrib/
/web/profiles/contrib/
/web/libraries/
/recipes/*/
!/recipes/meridian_events/
/drush/Commands/contrib/

# Scaffold files (omit these lines when you commit them)
/web/.htaccess
/web/index.php
/web/update.php
/web/autoload.php
/web/robots.txt
/web/web.config
/web/sites/default/default.settings.php
/web/sites/default/default.services.yml
/web/sites/example.settings.local.php
/web/sites/example.sites.php

# Environment-specific and runtime
/web/sites/*/settings.local.php
/web/sites/*/settings.ddev.php
/web/sites/*/files/
/private/
/web/sites/simpletest/

# Tooling
/node_modules/
/.idea/
/.vscode/
*.sql
*.sql.gz
.DS_Store

Details that matter:

  • Never commit settings.php with credentials or hash_salt. Commit it only after moving secrets into the environment, as above. If one was committed in the past, rotate the credentials and the salt; removing the file from history is not enough.

  • Do not commit .sql dumps or files/. They contain personal data and sessions.

  • Commit config/sync. It is the deployable description of the site and is what staging and production import. See Deployment and CI/CD.

  • Do commit composer.lock. A .gitignore copied from a tutorial sometimes lists it by mistake.

  • .ddev/.gitignore is generated by DDEV and ignores its own temporary files; commit .ddev/config.yaml and add-ons but leave the generated ignore file alone.

Common errors

Symptom Cause and fix

composer install fails with "Failed to open stream: Permission denied" in web/sites/default

The directory was made read-only after installation. Run chmod u+w web/sites/default, install, then restore it.

The installer keeps asking for database settings

settings.php is missing or not writable during installation. Copy default.settings.php to settings.php and make it writable only while installing, or run drush site:install.

Twig debug comments or fresh templates do not appear

Anonymous session, the development form is off, or a leftover twig.config in a services file overrides it (10.3+). Remove it, log in and clear caches.

Changes to a module’s *.services.yml are ignored

The container is cached. Run drush cr (rebuild), since service definitions are compiled.

vendor/ or composer.lock reachable by URL

The document root is the project root instead of web/. Fix the web server configuration.

Mail arrives at a real address from a development site

A real SMTP or provider configuration was imported from production. Override it in settings.local.php or with ddev drush config:set to a Mailpit transport.

Breakpoints are never hit

Xdebug is off (ddev xdebug status), the IDE is not listening on 9003, or the path mapping does not cover the project. Run ddev utility xdebug-diagnose.