Effective immutability with PHPStan

Posted on by Matthias Noback

This article is about a topic that didn't make the cut for my latest book, Recipes for Decoupling. It still contains some useful ideas I think, so here it is anyway!

DateTimeImmutable is mutable

I don't know where I first heard it, but PHP's DateTimeImmutable is not immutable:

<?php

$dt = new DateTimeImmutable('now');
echo $dt->getTimestamp() . "\n";

$dt->__construct('tomorrow');
echo $dt->getTimestamp() . "\n";

The result:

1656927919
1656972000

Indeed, DateTimeImmutable is not really immutable because its internal state can be modified after instantiation. After calling __construct() again, any existing object reference will have the modified state as well. But an even bigger surprise might be that if a constructor is public, it's just another method that you can call. You're not supposed to, but you can. Which is another reason to make the constructor of non-service objects private and add a public static constructor to a class that you want to be immutable:

// If we could fix `DateTimeImmutable` ;)

final class DateTimeImmutable
{
    private function __construct(string $datetime)
    {
        // ...
    }

    public static function fromString(string $datetime = 'now'): self
    {
        return new self($datetime);
    }
}

Effective immutability

Of course, we can't change DateTimeImmutable itself. And maybe we don't have to either. Because, and I think most developers will agree here: nobody calls the __construct() method anyway. This is exactly the point I want to make in this article: DateTimeImmutable is already effectively immutable because nobody mutates its state. Technically, instantiating the DateTimeImmutable class results in a mutable object, but as long as we don't use any of the state-mutating methods, we won't experience a difference between a truly immutable object and one that is actually immutable because of its class definition.

The advantage of making an object truly immutable is that we can be certain that it never gets modified, so we won't have to deal with any mistakes (bugs) related to unexpectedly modified state. The downside is that we have to do more work on the classes to make their instances truly immutable.

If we could "demote" from true to effective immutability it would save us a lot of work. And it would even enable us to keep using, for instance, the DateTime class, which is the mutable (and much older) alternative for DateTimeImmutable. The only problem is that effective immutability relies on developer discipline. And I've always found that a bad thing to rely on. We aren't machines and we will forget one instance, make one mistake, and in the long run the code quality goes down again.

Using PHPStan to prevent calls to __construct

As programmers we are lucky to live in the Age of Static Analysis, using which we can tackle many issues related to developer discipline. In PHP there are several static analysis tools. Maybe the most commonly known ones will be Psalm and PHPStan. Since most of my experience is with PHPStan I use that one. It will analyze your code, and show you errors for anything that doesn't look right. E.g. a call to a method that doesn't exist, a missing argument, an argument of the wrong type, and so on. PHPStan can be extended with your own checks, called rules. One of the rules that could be quite helpful in the context of effective immutability is a rule that says you can't call __construct() explicitly.

Considering the example we saw earlier, we want to trigger an error for calls like $dt->__construct(), but ignore any other methods calls like getTimestamp():

<?php

$dt = new DateTimeImmutable('now');
echo $dt->getTimestamp() . "\n";

$dt->__construct('tomorrow');
echo $dt->getTimestamp() . "\n";

I won't go into the details of custom rule development and the accompanying tests and test fixtures. You'll find a step-by-step instruction in Recipes for Decoupling, or in the PHPStan documentation. Here's the code for a rule that does the trick. I've added a few comments to explain what's going on:

<?php
declare(strict_types=1);

namespace Utils\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

final class NoExplicitCallToConstructorRule implements Rule
{
    public function getNodeType(): string
    {
        return MethodCall::class;
    }

    /**
     * @param MethodCall $node
     */
    public function processNode(Node $node, Scope $scope): array
    {
        if (! $node->name instanceof Identifier) {
            /*
             * We can't analyze dynamic method calls where the name
             * of the method is unknown
             */
            return [];
        }

        if ($node->name->toString() !== '__construct') {
            // This is a call to another method, not `__construct()`
            return [];
        }

        /*
         * Here we know it's a method call to `__construct()`, so we 
         * trigger an error:
         */

        return [
            RuleErrorBuilder::message(
                'Explicit call to public constructor is not allowed'
            )->build()
        ];
    }
}

The template for such a rule is to look at a bit of code, which is represented by nodes, and to return early if the code doesn't seem to violate the rule. Eventually we return an error, but only if we are sure that the code is "wrong".

When running PHPStan with the new rule enabled, we get a nice error on line 6 of the example code:

 ------ -------------------------------------------------- 
 Line   example.php
 ------ -------------------------------------------------- 
 6      Explicit call to public constructor is not allowed                      
 ------ -------------------------------------------------- 

[ERROR] Found 1 error

Of course, we have to run PHPStan as part of our build, and prevent code from being merged if it triggers any error. Once we have met this pre-condition, for a developer and for the system at runtime there is no longer a difference between effective and real immutability. They are equal as long as we can prove that our objects are not used in ways that mutate their state. If an object is used as if it's immutable, it may be considered immutable.

PHP immutability OOP PHPStan
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).