Finalize Classes - Automated and Safe

Final classes have many great benefits for future human readers of your code.

They have even more benefits for static analysis and Rector rules.

But what if we have a project with 1000+ classes and 10 minutes and want to automate the finalization process safely?

Why are final classes so valuable for automated tools? Let's see this code:

class Conference
{
    protected $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }

    // ...
}

Static analysis and Rector could do so much here... but they're not sure:


Let's see what Rector can do if we add a final keyword:

-final class Conference
+final readonly class Conference
 {
-    protected $name;
-
-     public function __construct(string $name)
+     public function __construct(private string $name)
-     {
-        $this->name = $name;
-    }

     // ...
 }

We've just shifted from PHP 7.0-like code to PHP 8.2-like.


Need for Automation

In the case of a massive project with many classes, we would not get much work done manually. That's why we need automation to handle this for us.

Rector to the rescue? There was once a rule in Rector called FinalizeClassesWithoutChildrenRector. It was pretty helpful, but it also did many false positive changes, so I deprecated it.


What now?

First Principles

We removed a buggy Rector rule, but that doesn't help new projects implement final fast and safely. What is the minimal goal we want to achieve?


We need a tool that can:

1. Find classes that are Parents

class SomeClass extends ParentClassThatCannotBeFinal
{
}

2. Find all Doctrine Entities

Defined in attribute:

use Doctrine\ORM\Mapping\Entity;

#[Entity]
class ThisIsEntity
{
}

...docblock:

use Doctrine\ORM\Mapping\Entity;

/**
 * @Entity
 */
class ThisIsAlsoEntity
{
}

...but also in YAML mapping configs:

class ThisIsAlsoEntityWithMappingInYAML
{
}

3. Find classes that Mocked

Those are extended by mocking framework, so they have to be skipped.

namespace PHPUnit\Framework\TestCase;

final class SomeTest extends TestCase
{
    public function test()
    {
        $someMock = $this->createMock(SomeRepository::class);
    }
}

This is optional, as we separate tests and source code.

4. Handle this in a Static Way

Imagine having this amazing tool with all the features mentioned above, but when you run composer require, it will conflict with Symfony 7 vs. Symfony 5 in your project.

Last but not least, we want to make the tool available the most PHP devs. We have to:


Building The Solution

What needs to be done? When we check the first principles, it seems pretty straightforward:

Note: If you're new to AST, check out this super fun and practical talk by Marcel Pociot about Parsing PHP for fun and profit.


Introducing the Swiss Knife toolkit

The first prototype took about 3 days to build. Then, it took 2 more months of internal testing on real projects to improve with feedback. Today, I'm proud to share it with the public.

We use this technique in every project we help upgrade, so we called it accordingly - a swiss knife.

1. Install package

composer require rector/swiss-knife --dev

2. Run Command

vendor/bin/swiss-knife finalize-classes app tests --dry-run

Use --dry-run on the first run to be safe.

Does all seem reasonable to you? Let's roll:

vendor/bin/swiss-knife finalize-classes app tests

3. Skip Mocked classes

It is better to separate tests leaking requirements to your source code and use bypass final. But maybe you don't want to deal with it right now.

That's why we have the --skip-mocked option that keeps all mocked classes without final:

vendor/bin/swiss-knife finalize-classes app tests --skip-mocked

That's it!


Protip: add the --dry-run to your CI to spot these classes early and delegate work to your CI.


Is there a spot that was finalized incorrectly? Create an issue in the Github; we'll cover it.


Happy coding!




Do you learn from my contents or use open-source packages like Rector every day?
Consider supporting it on GitHub Sponsors. I'd really appreciate it!