PHP Coding Standards
This chapter walks through the core PHP formatting and structural rules that the rest of the Drupal coding standards build on. None of it needs to live in your head — PHP_CodeSniffer and the Drupal ruleset (see Chapter 12) will flag anything you miss — but knowing the reasoning behind each rule makes you faster, and turns phpcs output into something you can act on instead of just obey.
1. Indentation and Whitespace
Drupal indents with 2 spaces, never tabs. If you're coming from PSR-12 territory, this is usually the first thing that throws people off — PSR-12 uses 4.
// ❌ Incorrect: 4-space indentation.
if ($condition) {
do_something();
}
// ✅ Correct: 2-space indentation.
if ($condition) {
do_something();
}A few more whitespace rules round this out: no trailing whitespace at the end of a line, Unix line endings (\n) rather than Windows (\r\n), and exactly one newline at the end of the file. The closing ?> tag is left out entirely in files that contain only PHP — it's an easy way to end up with stray whitespace output and a "headers already sent" error you didn't ask for.
// ❌ Incorrect: file ends with a closing tag.
<?php
mymodule_function();
?>
// ✅ Correct: no closing tag, single trailing newline.
<?php
mymodule_function();2. Line Length
Code lines top out around 80 characters, though it's a soft limit — if wrapping a line would hurt readability more than help it, readability wins. Long conditions, for instance, are better split at a logical point than forced onto a rigid column count.
Comment and doc block lines don't get the same leniency — 80 characters there is a hard limit. And regardless of length, one statement per line, always.
// ❌ Incorrect: two statements on one line.
$a = 1; $b = 2;
// ✅ Correct.
$a = 1;
$b = 2;3. Operators and Spacing
Binary operators — =, +, -, *, ==, ===, ., =>, &&, ||, ?? and the rest — get a space on each side. Unary operators (!, ++, --, a negative sign) get none.
// ❌ Incorrect.
$total=$price*$quantity;
$name = 'Prefix '.$suffix;
if(!$valid){
// ✅ Correct.
$total = $price * $quantity;
$name = 'Prefix ' . $suffix;
if (!$valid) {One that trips people up coming from other PHP shops: the concatenation operator gets spaced too — 'a' . $b, not 'a'.$b.
Type casts follow the same logic: (int) $value, with a space after the cast, not (int)$value.
4. Arrays
Short array syntax — [] — is required everywhere. The old array() form has no place in new code.
// ❌ Incorrect.
$values = array('one', 'two', 'three');
// ✅ Correct.
$values = ['one', 'two', 'three'];For a multi-line array, each item gets its own line, indented one level in from the array itself, with the closing bracket lined up under the line that opened it. Add a trailing comma after the last item too — it's a small habit that keeps future diffs from touching a line they didn't need to.
// ❌ Incorrect: missing trailing comma.
$options = [
'status' => TRUE,
'type' => 'article',
'sticky' => FALSE
];
// ✅ Correct: trailing comma after the last item.
$options = [
'status' => TRUE,
'type' => 'article',
'sticky' => FALSE,
];None of this applies to short, obviously-fits-on-one-line arrays, though — the multi-line rule is in service of readability, not a box to check for its own sake.
5. Control Structures
For if, foreach, while, switch, and function bodies, the opening brace sits on the same line as the keyword, with a space before it. A control keyword always gets a space before its (, but a function name never does.
// ❌ Incorrect.
if($valid){
do_something();
}
else if ($other) {
do_other_thing();
}
// ✅ Correct.
if ($valid) {
do_something();
}
elseif ($other) {
do_other_thing();
}Also worth flagging: Drupal writes elseif as one word, not else if. In a switch statement, case labels sit one indent level in from switch, and the body of each case one level further still.
// ❌ Incorrect: case labels at the same indent as switch.
switch ($type) {
case 'article':
do_article_things();
break;
default:
do_default_things();
}
// ✅ Correct: case one level in, its body one level further.
switch ($type) {
case 'article':
do_article_things();
break;
case 'page':
do_page_things();
break;
default:
do_default_things();
}6. Function and Method Declarations
A space follows the function keyword, no space sits before the closing parenthesis of the parameter list, and each comma between parameters gets a space after it, never before.
// ❌ Incorrect.
function mymodule_process($a,$b , $c) {
}
// ✅ Correct.
function mymodule_process($a, $b, $c) {
}Default values take a space on both sides of the =, and — as you'd expect — parameters with defaults always come after the required ones:
// ❌ Incorrect: required parameter placed after one with a default.
public function importProducts(bool $overwrite = FALSE, array $items): int {
}
// ✅ Correct: parameters with defaults come after the required ones.
public function importProducts(array $items, bool $overwrite = FALSE): int {
}If a public or protected method's name doesn't already make its purpose obvious, it should carry a doc block. The exact tag format for that lives in the Documentation Standards chapter.
7. Type Declarations
On PHP 8.1+, current Drupal code is expected to declare types wherever the API allows: parameter types, return types, typed class properties, all of it.
// ❌ Incorrect: no type declarations.
class ProductImporter {
protected $logger;
protected $batchSize = 50;
public function importOne($data) {
// ...
}
}
// ✅ Correct: parameter, return, and property types declared.
class ProductImporter {
protected LoggerChannelInterface $logger;
protected int $batchSize = 50;
public function importOne(array $data): ?Product {
// ...
}
}A nullable type (?Type) belongs on a method that can legitimately return nothing, and a union type (int|string) on one where more than one concrete type is genuinely possible — neither is a shortcut around picking the one correct type when there is one.
8. Class Structure and Visibility
Every property and method states its visibility explicitly. PHP's implicit-public default doesn't get relied on, ever.
// ❌ Incorrect: no visibility keyword.
class ProductImporter {
$logger;
function importOne() {}
}
// ✅ Correct.
class ProductImporter {
protected LoggerChannelInterface $logger;
public function importOne(): void {}
}Inside a class, members follow a consistent order — constants first, then properties, then the constructor, then everything else. use statements for imported classes each get their own line, sorted alphabetically, with no leading backslash.
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\node\NodeInterface;
use GuzzleHttp\ClientInterface;