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.
What and why
Section titled “What and why”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.
Mental model
Section titled “Mental model”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 nextcomposer 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.jsonandcomposer.lock,web/modules/custom/,web/themes/custom/,recipes/,web/sites/(minusfiles/), 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.
Where does a change belong?
Section titled “Where does a change belong?”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, data | Module in web/modules/custom/ | Modules are the only place code that changes what the site does belongs. |
| Change presentation: markup, CSS, templates, layout | Theme 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 from | Recipe in recipes/ at the project root | Recipes 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, settings | Configuration export in the config sync directory | You 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 install | Update hooks (functions Drupal runs at defined points) in a module | hook_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.
How core uses it
Section titled “How core uses it”The layout is not a packaging convention; core’s own bootstrap depends on it.
Drupal\Core\DrupalKernelresolves which site directory serves a request. For almost every project that issites/default/, but the kernel checkssites/sites.phpfirst, which is how one codebase can serve many sites (a multisite install), each with its ownsettings.phpand database.settings.phpis read by the kernel before the service container exists, which is why low-level switches (database credentials, trusted host patterns, the config sync directory, extracontainer_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.ymlfiles (the metadata file that declares each module and theme to Drupal). That is the entire reason custom code must live underweb/modules/custom/rather thanvendor/: Composer’s autoloader knows aboutvendor/, but Drupal’s discovery does not. - Core eats its own recipe format:
core/recipes/contains building blocks likebasic_block_type,tags_taxonomy, andeditorial_workflow, and astandardrecipe that composes building blocks into the same result as the Standard profile. Each is a directory with arecipe.yml, usually alongside the config it installs;standarditself is onerecipe.ymlthat lists other recipes. OpenDrupal\Core\Recipe\Recipeand a few of those directories and you have seen the whole format.
Common misconceptions
Section titled “Common misconceptions”- “It’s my repository, I can edit
web/corefor a quick fix.” Nothing stops you, and the nextcomposer updatereverts you. Treat Composer’s zone as read-only; patches go through Composer plugins so they are declared, repeatable, and visible in review. - “
vendor/andweb/core/belong in git.” They are build artifacts reconstructed fromcomposer.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 usessites/default/: it holdssettings.phpand 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.phpshould not be in git because it has credentials.” The file itself is fine to commit; the credentials are the problem. Keep environment-specific values insettings.local.phpor environment variables, and commit the shared rest. DDEV models this with its generatedsettings.ddev.php.
Where to go next
Section titled “Where to go next”- Put the rule into practice: build your first module in
web/modules/custom/, or your first theme inweb/themes/custom/. - Recipes and config actions covers authoring the recipes this page locates.
- Configuration management workflow explains the config sync directory’s lifecycle.
- Config, content, state, and sessions is the concept behind “what lives in the database vs in YAML”.
- The
Update API topicdocuments update and post-update hooks in full.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.