Indentation

January 21, 2025 ยท View on GitHub

Heredoc indentation

Heredocs and nowdocs are indented in one of four ways. Mixed indentation is enabled by default.

No heredoc indentation

Enabled when --heredoc-indent none is given.

<?php
$foo = [
    'bar' => <<<EOF
Content
EOF,
];

Line heredoc indentation

Enabled when --heredoc-indent line is given.

<?php
$foo = [
    'bar' => <<<EOF
    Content
    EOF,
];

Mixed heredoc indentation

Line indentation is applied to heredocs that start on their own line, otherwise hanging indentation is applied.

Enabled by default and when --heredoc-indent mixed is given.

<?php
$foo = <<<EOF
    Content
    EOF;
$bar =
    <<<EOF
    Content
    EOF;

Hanging heredoc indentation

Enabled when --heredoc-indent hanging is given.

<?php
$foo = <<<EOF
    Content
    EOF;
$bar =
    <<<EOF
        Content
        EOF;

Standard indentation

Code between brackets ((), [], {}) is always indented when the open bracket is followed by a line break.

Comments

The PlaceComments rule aligns comments with subsequent code unless the next code token is a close bracket or switch case:

<?php
switch ($foo) {
    //
    case 0:
        // Indented
    case 1:
        bar();
        // Indented
    case 2:
        break;
    //
    case 3:
        // Indented

        // Indented

    //
    case 4:
        baz();
        // Indented

        // Indented

    //
    case 5:
        break;
        // Indented

    //

    //
    case 6:
        qux();

        // Indented

    //
    case 7:
        break;

    //

    //
    default:
}

Hanging indentation

Hanging indentation is applied when the context of a line of code is unclear after standard indentation has been applied.

Consider this contrived example:

<?php
if (!$request->route()->isPrivate() ||
        ($request->hasUser() &&
            ($this->userHasRoute($request->user(),
                    $request->route()) ||
                $request->user()->role() === User::ADMIN_ROLE ||
                $request->user()->role() === User::EDITOR_ROLE)) ||
        Session::hasCarteBlanche()) {
    Console::log('Access granted');
}

Without pretty-php's "overhanging" indentation, more effort is required to understand how it works because the same level of indentation has been applied to adjacent lines that belong to different contexts:

<?php
if (!$request->route()->isPrivate() ||
    ($request->hasUser() &&
        ($this->userHasRoute($request->user(),
            $request->route()) ||
        $request->user()->role() === User::ADMIN_ROLE ||
        $request->user()->role() === User::EDITOR_ROLE)) ||
    Session::hasCarteBlanche()) {
    Console::log('Access granted');
}

With no hanging indentation at all, even more effort is required:

<?php
if (!$request->route()->isPrivate() ||
($request->hasUser() &&
($this->userHasRoute($request->user(),
$request->route()) ||
$request->user()->role() === User::ADMIN_ROLE ||
$request->user()->role() === User::EDITOR_ROLE)) ||
Session::hasCarteBlanche()) {
    Console::log('Access granted');
}

One solution (adopted in PSR-12 / PER) is to insert line breaks after open brackets. In many cases this is sensible, but it doesn't cover every PHP construct and isn't always desirable.

<?php
if (
    !$request->route()->isPrivate() ||
    (
        $request->hasUser() &&
        (
            $this->userHasRoute(
                $request->user(),
                $request->route()
            ) ||
            $request->user()->role() === User::ADMIN_ROLE ||
            $request->user()->role() === User::EDITOR_ROLE
        )
    ) ||
    Session::hasCarteBlanche()
) {
    Console::log('Access granted');
}

How HangingIndentation works

Using arrays as a metaphor for constructs with one or more entries that may be arbitrarily nested, the HangingIndentation rule recognises the following indentation scenarios.

.hh. represents a level of hanging indentation, and .HH. represents a level of "overhanging" indentation.

  1. Standard indentation is sufficient

    [
        ___, ___,
        ___, ___
    ];
    
  2. One level of hanging indentation is required

    [
        ___, ___
        .hh.___, ___,
        ___, ___
    ];
    
  3. One level of hanging indentation is sufficient

    [___, ___,
    .hh.___, ___];
    
  4. Two levels of hanging indentation are required

    [___, ___
    .hh..HH.___, ___,
    .hh.___, ___];
    
  5. Two levels of hanging indentation are required per level of nesting

    [___, [___,
    .hh..HH..hh.___],
    .hh.___,[___, ___
    .hh..HH..hh..HH.___,
    .hh..HH..hh.___]];
    
    [[[___
    .hh..HH..hh..HH..hh..HH.___,
    .hh..HH..hh..HH..hh.___],
    .hh..HH..hh.___],
    .hh.___]
    

Overhanging indentation is also applied to blocks that form part of a continuing structure, e.g. the if block above.

Context

After finding a token to indent, HangingIndentation creates a context for it, and if indentation for that context has already been applied, the token is not indented further.

A token's context is an array{?Token, ?Token, ?Token, 3?:Token|int, 4?:int} comprised of:

  • its parent (or null if it's a top-level token)
  • the most recent assignment operator or T_DOUBLE_ARROW in the same statement (or null if it isn't part of an expression after an assignment or =>)
  • its ternary context (or null if it isn't part of a ternary expression)
  • an optional token and/or precedence value shared by any siblings that should receive the same level of indentation

The aim is to differentiate between lines where a new expression starts, and lines where an expression continues:

<?php
fn($a, $b) =>
    $a === $b
        ? 0
        : $a <=>
            $b;

There are many more examples in the HangingIndentation unit test.