Skip to content

ConceptFor everyoneDrupal 11

Anatomy of a Drupal project

A Drupal project is a Composer project that happens to contain a CMS. Once you can tell which directories Composer owns and which ones you own, every other convention in these docs follows. This page maps the layout that drupal/recommended-project creates and gives you the decision rule for where each kind of change belongs.

After setting up your environment, your project looks like this:

  • composer.json the project manifest you edit
  • composer.lock exact resolved versions, committed
  • Directoryrecipes/ downloaded and custom recipes
    • …
  • Directoryvendor/ PHP dependencies, never committed
    • …
  • Directoryweb/ the web root, the only directory the server exposes
    • Directorycore/ Drupal core, installed by Composer
      • …
    • Directorymodules/
      • Directorycontrib/ modules from drupal.org, installed by Composer
        • …
      • Directorycustom/ your modules
        • …
    • Directorythemes/
      • Directorycontrib/ themes from drupal.org
        • …
      • Directorycustom/ your themes
        • …
    • Directoryprofiles/ install profiles, rarely yours
      • …
    • Directorysites/
      • Directorydefault/
        • settings.php site config, created at install
        • settings.local.php your local overrides
        • Directoryfiles/ user uploads, never committed
          • …
      • example.settings.local.php
    • index.php the single entry point, scaffolded

Two things make this layout worth understanding rather than memorizing. First, the split between web/ and everything above it is a security boundary: vendor/ and composer.json sit outside the web root, so the web server can never serve them, and teams usually move the configuration sync directory out there too. Second, almost everything in the tree is generated. vendor/ and web/core/ come from composer.lock; index.php, .htaccess, and robots.txt are copied in by the drupal/core-composer-scaffold plugin on every composer install. A teammate who clones your repository and runs composer install reconstructs all of it.

Divide the tree into two zones:

  • Composer’s zone: vendor/, web/core/, web/modules/contrib/, web/themes/contrib/. Disposable build artifacts. You never edit a file here; your edits would be silently overwritten by the next composer update. If core or a contrib module has a bug, you fix it with an update or a patch applied through Composer, not by editing the file.
  • Your zone: composer.json and composer.lock, web/modules/custom/, web/themes/custom/, recipes/, web/sites/ (minus files/), and the configuration sync directory. This is what your git repository actually contains. Everything else is reproducible from it, plus a database dump.

The same split answers “what do I deploy?”: you ship your zone, run composer install on the server, and import configuration. Databases and files/ move separately, which is why joining an existing project means importing both.

Drupal gives you five containers for changes, and picking the wrong one is the most common structural mistake in young projects:

You want to…Build a…Because
Add behavior: routes, forms, plugins, services, dataModule in web/modules/custom/Modules are the only place code that changes what the site does belongs.
Change presentation: markup, CSS, templates, layoutTheme in web/themes/custom/Themes can override every template and asset without touching behavior.
Package an assembled starting point: content types, roles, settings that new sites or features start fromRecipe in recipes/ at the project rootRecipes apply configuration once and step away; core itself ships its defaults as recipes in core/recipes/.
Change the structure of your own site: fields, content types, views, settingsConfiguration export in the config sync directoryYou export the change to YAML, commit it, and drush deploy imports it on every environment.
Transform stored data on sites that already exist, or change configuration in a module that other sites installUpdate hooks (functions Drupal runs at defined points) in a modulehook_update_N() and hook_post_update_NAME() run exactly once per site during deployment, which is what a one-time change needs.

The boundary cases resolve the same way. A content type you want on every future site: recipe. A field your production site must gain next release: exported configuration. Existing data that must move into that field: a deploy hook, which drush deploy runs after the import. A Twig template override plus the preprocess logic feeding it: theme, until the logic stops being presentational, then a module.

The layout is not a packaging convention; core’s own bootstrap depends on it.

  • Drupal\Core\DrupalKernel resolves which site directory serves a request. For almost every project that is sites/default/, but the kernel checks sites/sites.php first, which is how one codebase can serve many sites (a multisite install), each with its own settings.php and database.
  • settings.php is read by the kernel before the service container exists, which is why low-level switches (database credentials, trusted host patterns, the config sync directory, extra container_yamls) live there as plain PHP and not in admin UI configuration.
  • Extension discovery scans core/modules/, modules/, profiles/, themes/, and the site directory for *.info.yml files (the metadata file that declares each module and theme to Drupal). That is the entire reason custom code must live under web/modules/custom/ rather than vendor/: Composer’s autoloader knows about vendor/, but Drupal’s discovery does not.
  • Core eats its own recipe format: core/recipes/ contains building blocks like basic_block_type, tags_taxonomy, and editorial_workflow, and a standard recipe that composes building blocks into the same result as the Standard profile. Each is a directory with a recipe.yml, usually alongside the config it installs; standard itself is one recipe.yml that lists other recipes. Open Drupal\Core\Recipe\Recipe and a few of those directories and you have seen the whole format.
  • “It’s my repository, I can edit web/core for a quick fix.” Nothing stops you, and the next composer update reverts you. Treat Composer’s zone as read-only; patches go through Composer plugins so they are declared, repeatable, and visible in review.
  • “vendor/ and web/core/ belong in git.” They are build artifacts reconstructed from composer.lock. Committing them bloats every diff and invites exactly the local edits the previous point warns about.
  • “sites/ is only for multisite.” Every Drupal site uses sites/default/: it holds settings.php and the public files directory. Multisite is the rare case; the directory is not.
  • “Configuration lives in the database, so it deploys with the database.” Active configuration does live in the database, but it round-trips to YAML in the config sync directory precisely so you can version and deploy it without copying databases. See the configuration management workflow.
  • “An install profile is how I package my site’s defaults.” Profiles still exist and the installer requires one, but a profile stays coupled to the site forever. Recipes apply once and leave no dependency behind, which is why new assembly work targets recipes.
  • “settings.php should not be in git because it has credentials.” The file itself is fine to commit; the credentials are the problem. Keep environment-specific values in settings.local.php or environment variables, and commit the shared rest. DDEV models this with its generated settings.ddev.php.

Was this page helpful?

Drupal is a registered trademark of Dries Buytaert.