Drupal Coding Standards

Why Coding Standards Matter

The moment more than one developer starts working on a Drupal project, you notice everyone codes with their own habits — someone indents with tabs, someone aligns arrays differently, someone never gives a second thought to line endings. This is exactly where coding standards step in: the goal isn't just for the code to run, but for someone else to open it and understand what's going on right away.

And these standards aren't limited to PHP. Drupal has its own rules for practically every layer, from JavaScript to CSS, from Twig to YAML, all the way to database queries.

On a solo or small project, these details can seem easy to ignore. But that changes as the team grows and new people start touching the code. When everyone applies their own preference, code review turns into a line-by-line formatting argument — when what should actually be discussed is what the code does, not how it looks.

php
// ❌ Incorrect.
if($node->isPublished()){
$title=$node->label();
}

// ✅ Correct.
if ($node->isPublished()) {
$title = $node->label();
}

Both versions do the same thing, really. The difference shows up six months later, in how quickly another developer opening this file can make sense of it.

PHP Coding Standards

The most detailed rule set in Drupal shows up on the PHP side — everything from indentation to array syntax is spelled out.

The most basic rule: 2-space indentation, no tabs. Spaces go around operators, arrays use the short syntax ([]), and multi-line arrays get a trailing comma at the end — a small thing, but it keeps git diffs from ballooning for no reason.

php
// ❌ Incorrect: no trailing comma, not one item per line.
$options = ['status' => TRUE, 'type' => 'article'];

// ✅ Correct: one item per line, trailing comma.
$options = [
'status' => TRUE,
'type' => 'article',
];

Typing parameters and return values has become close to standard practice in modern Drupal code.

php
// ❌ Incorrect: no type hints.
public function getTitle($node) {
return $node->label();
}

// ✅ Correct: parameter and return types declared.
public function getTitle(NodeInterface $node): string {
return $node->label();
}

Writing it this way makes it clear what the method expects, and it lets tools like PHPStan catch mistakes before the code ever runs.

Documentation Standards

Past a certain point, why the code was written matters just as much as the code itself.

Drupal supports DocBlocks for classes, methods, interfaces, and certain files. But you don't need a comment on every line — some points genuinely deserve explaining, and the rest is already clear from the code itself.

php
/**
* Returns active products.
*/
public function getActiveProducts(): array {
// ...
}

If a method takes parameters or can throw an exception, @param, @return, and @throws tags get added. The point isn't to translate the code into a sentence — it's to give the reader a shortcut.

Object-Oriented Code and Architecture Standards

Modern Drupal is built largely on an object-oriented architecture.

When writing controllers, plugins, event subscribers, or service classes, you're expected to obtain the services you need through dependency injection.

Say we have an import service that needs both an API client and a logger.

php
// ❌ Incorrect: static service call buried inside a method.
public function importAll() {
$logger = \Drupal::service('logger.factory')->get('product_import');
$logger->error('Import failed.');
}

// ✅ Correct: services are injected through the constructor.
public function __construct(LoggerChannelFactoryInterface $logger_factory) {
$this->logger = $logger_factory->get('product_import');
}

public function importAll() {
$this->logger->error('Import failed.');
}

It looks like a small change, but the payoff is real: you can see at a glance which services a class depends on, and mocking those services in a test becomes much easier — and taking both dependencies through the constructor like this makes the class more readable at the same time.

Naming Conventions

Naming conventions serve a pretty simple purpose: being able to guess what something is just by looking at its name.

Module machine names are written in lowercase with underscores:

text
product_import
custom_search
weather_api

For class names:

text
ProductImporter
ProductImporterInterface

and for method names:

text
getProducts()
importProducts()
updateProduct()

is the pattern that's followed. Procedural functions commonly use the module name as a prefix too:

php
function product_import_cron() {
}

This small habit goes a long way toward preventing name collisions, especially on larger projects where dozens of modules run side by side.

YAML File Standards

It doesn't take long into a Drupal project before you start running into YAML files.

The ones you'll see most are .info.yml, .services.yml, .routing.yml, and .libraries.yml. And YAML doesn't joke around with indentation — even a single space can make a difference.

yaml
# ❌ Incorrect: no indentation, YAML can't parse the nesting.
product_import.settings:
path: '/admin/config/product-import'
defaults:
_title: 'Product Import'

# ✅ Correct: 2-space indentation shows the nesting.
product_import.settings:
  path: '/admin/config/product-import'
  defaults:
    _title: 'Product Import'

Use a tab character or the wrong indentation and Drupal may fail to read the file at all — and the error it gives you usually isn't much help. Modules that generate configuration also need to remember to define a config schema.

JavaScript Coding Standards

On the JavaScript side, the Drupal.behaviors system is where the real action happens.

The reason is simple: Drupal pages don't always reload from scratch. After an AJAX request, only part of the page might get rebuilt while the rest stays exactly as it was.

javascript
// ❌ Incorrect: runs again on every AJAX partial rebuild.
Drupal.behaviors.example = {
attach(context) {
console.log('This fires every time context is processed.');
},
};

// ✅ Correct: once() keeps it from running twice on the same element.
Drupal.behaviors.example = {
attach(context) {
once('example', '.my-element', context).forEach((element) => {
// JavaScript logic.
});
},
};

once() is used so the same code doesn't run twice on an element that's already been processed. And when data needs to move from the backend to JavaScript, writing it to drupalSettings is the usual approach instead of defining a global variable.

If you ever need to pass an API URL or a few module settings over to JavaScript, this is the first method that should come to mind.

CSS Coding Standards

CSS can get messy fast in a large Drupal theme, which is why a component-based approach pays off.

Being able to tell a component's relationship to its sub-elements just from the class name saves real time for whoever comes back to that CSS file months later.

css
/* ❌ Incorrect: no clear relationship between class names. */
.card {
}

.title {
}

.featured {
}

/* ✅ Correct: BEM-style naming shows the relationship. */
.card {
}

.card__title {
}

.card--featured {
}

See .card__title and you already know it belongs to .card, no need to go digging through another file. Stylelint can also check these conventions automatically.

Twig Standards

A Twig template's job is actually pretty narrow: show the user data that's already been prepared.

Squeezing complex business logic into Twig can be tempting, but it's usually a choice you end up regretting. Handling that logic in preprocess functions or on the PHP side keeps the template simple and makes the logic itself testable.

twig
{% if title %}
<h1>{{ title }}</h1>
{% endif %}

Every piece of text shown to the user should go through Drupal's translation system:

twig
{# ❌ Incorrect: hardcoded text, never translated. #}
Read more

{# ✅ Correct: routed through the translation system. #}
{{ 'Read more'|t }}

Since Twig already applies automatic escaping, you rarely need the |raw filter. Using it carelessly on content coming from a user or an editor can amount to leaving the door open for XSS.

twig
{# ❌ Incorrect: |raw on user-supplied content opens the door to XSS. #}
{{ comment.body|raw }}

{# ✅ Correct: let Twig's automatic escaping do its job. #}
{{ comment.body }}

SQL and Database Standards

Using the API layers Drupal provides instead of touching the database directly is almost always the safer route.

Data coming from the user should never be dropped straight into a SQL query — it's one of the most classic ways to leave the door open for SQL injection.

Instead of writing something like this:

php
// ❌ Incorrect: user input concatenated directly into SQL.
$query = "SELECT * FROM users WHERE name = '$name'";
$result = db_query($query);

// ✅ Correct: a placeholder lets the Database API handle escaping.
$result = \Drupal::database()->query('SELECT * FROM {users_field_data} WHERE name = :name', [':name' => $name]);

This is what Drupal's Database API is for. When working with entities, the Entity API is usually preferred over raw SQL as well. Say you want to list all published content — using Entity Query instead of hand-writing a query against the node_field_data table keeps the code closer to Drupal's own entity logic.

Security-Related Standards

Part of what coding standards are for is security directly — some mistakes don't just affect code quality, they affect the site's security too.

Printing user-supplied data straight into HTML is one of those. Take a name field submitted through a form — it might contain something like this:

text
<script>alert('test')</script>

Print that straight to the page and the script runs — this is exactly why Drupal's escaping and XSS filtering mechanisms exist. Using placeholders in translatable strings matters for the same reason:

php
// ❌ Incorrect: user input concatenated directly into the string.
$this->t('Welcome ' . $username);

// ✅ Correct: a placeholder lets Drupal escape the value automatically.
$this->t('Welcome @username', [
'@username' => $username,
]);

Building forms with Drupal's Form API also brings along security measures like CSRF protection automatically.

Automated Tooling

Expecting someone to manually check all of these rules every time isn't realistic — eyes get tired, and standards slip through.

PHP_CodeSniffer and Coder are the tools most commonly used for this in Drupal projects.

bash
vendor/bin/phpcs \
--standard=Drupal,DrupalPractice \
web/modules/custom

This command scans custom modules and lists what doesn't follow the standard; phpcbf can fix some of it automatically. But phpcs only cares about style — catching actual logic errors, like a wrong method call or a mismatched data type, is where PHPStan and phpstan-drupal come in, and they do it without the code ever running.

CI/CD Enforcement

If we only run PHPCS locally, we're leaving a significant part of the check up to each developer's own initiative.

Which is why it makes sense to move the same checks into a CI/CD pipeline too.

yaml
phpcs:
stage: test
script:
- vendor/bin/phpcs --standard=Drupal,DrupalPractice web/modules/custom

This check fires automatically whenever a merge request is opened. If someone submits code that doesn't meet the standard, the pipeline turns red and the problem gets caught before it ever reaches the main branch. The same pipeline can carry PHPStan, tests, and other quality checks too.

Most Common Coding Standard Mistakes

The same mistakes repeat from project to project with surprising consistency:

  • using 4 spaces instead of 2,
  • using tabs,
  • continuing to use the old array() syntax,
  • forgetting the trailing comma in multi-line arrays,
  • malformed namespaces or class names,
  • skipping dependency injection in service classes,
  • writing translatable text directly instead of through the translation system,
  • unnecessary use of |raw in Twig,
  • skipping once() in JavaScript behaviors,
  • still relying on deprecated Drupal APIs.

You can still find lines like this in older Drupal projects:

php
// ❌ Old syntax: array().
$items = array(
'one',
'two',
);

// ✅ Current syntax: short array literal.
$items = [
'one',
'two',
];

On its own, this difference looks negligible. But repeated hundreds of times across a codebase with thousands of lines, it adds up to a real maintenance cost.

Conclusion

It's easy to look at all of this as a bureaucratic checklist at first glance. But here's what actually plays out in practice: as a project grows, the gap between a codebase that follows these standards and one that doesn't widens fast. In one, a new developer can get productive within a week. In the other, they spend weeks wondering why something was built the way it was.

The good news is you don't have to memorize most of it. Set up PHPCS, PHPStan, ESLint, and Stylelint once, and they take care of most of the standards for you — leaving your time for what actually matters: what the code does.

Official source: https://project.pages.drupalcode.org/coding_standards/

Latest update: 17.09.2026 16:33