Using PHPStan is not just about getting to level 8 with less than 100 ignored cases. Yes, there are also official extensions that improve the type support of Symfony, Doctrine, and Laravel projects.
But more rules are needed to get our PHP project into a future-proof state.
It takes less effort than getting to level 5 and we can use them since day one. That's why I love them so much.
"The more you sweat in training,
the less you bleed in combat."
I use this term to define project code quality, on its own, without developers. I think you, my reader, have your own idea of what good code quality is. If you're with the project and have time to review pull requests, it's good.
But what happens when we leave the project? Or do we get promoted to a manager position and don't have time to review PRs anymore?
That's when the real code quality shows. The project should be able to survive without us. Not just survive, but prosper and teach others to take good care of it.
The way we can make project future proof is:
Today we look closely at the last item.
Symfony framework offers 50+ packages we can use. It's challenging to understand them all and use them correctly without many years of experience. I've seen very poorly written codebases in Symfony 7 and on the other hand, very well-written Symfony 3 codebases.
The goal of these rules is to improve the codebase and keep it that way even if we leave the project (by leaving the company or selling it).
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoRequiredOutsideClassRule
The #[Require]
/@required
is a great feature to prevent dependency hell. They should be used exclusively in classes.
Why? Using required in traits can lead to highly coupled and hard-to-read code:
use Symfony\Component\DependencyInjection\Attribute\Required;
trait SomeTrait
{
#[Required]
public function autowireSomeTrait(SomeService $someService)
{
// ...
}
}
final class SomeController
{
// these traits are autowired, and inject 10 different services
// some of them with the same name, but a different type
use SomeTrait;
use AnotherTrait;
use YetAnotherTrait;
}
Use clear __construct()
injection instead.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoAbstractControllerConstructorRule
The abstract controller should not have a constructor, as it can lead to tight coupling and multi-level parent constructor parsing.
abstract class AbstractController extends Controller
{
public function __construct(
private SomeService $someService,
private AnotherService $anotherService,
private YetAnotherService $yetAnotherService,
) {
}
}
Then every single child class has to pass these services into the parent constructor:
final class ProjectController extends AbstractController
{
public function __construct(
SomeService $someService,
AnotherService $anotherService,
YetAnotherService $yetAnotherService,
private ProjectService $projectService,
private ProjectRepository $projectRepository,
) {
parent::__construct($someService, $anotherService, $yetAnotherService);
}
}
What a mess, right? Actually, we have 40+ of such child controller classes. Then we add/remove a single service in the AbstractController::__construct()
method... and we have to update all 40+ child classes.
Instead, the abstract class should use #[Require]
, and @required
autowire to promote clear and easy-to-use constructor injection in child classes with isolated dependencies:
final class ProjectController extends AbstractController
{
public function __construct(
private ProjectService $projectService,
private ProjectRepository $projectRepository,
) {
}
}
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoConstructorAndRequiredTogetherRule
Frameworks are very open and tolerate many ways to do one thing. Like using #[Require]
and __construct()
in single class together. It can happen, so we have the PHPStan rule to have our back and prevent this. Use one or the other, not both.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoFindTaggedServiceIdsCallRule
Using findTaggedServiceIds()
is a historical feature to collect many services of one type and add them to another collector service. e.g. adding all EventSubscriberInterface
to EventDispatcher
.
For many years, we have now autowire tags and attributes to handle the same operation in configs without compiler passes. Unfortunately, it's still promoted in docs.
This rule warns about this method and promotes single-line in configs instead.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoGetInControllerRule
This rule prevents service locator anti-pattern $this->get(...)
in controllers, to promote dependency injection. It's rather historical, but there are still many projects that use it.
The same rule, just for console commands:
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoGetInCommandRule
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoRoutingPrefixRule
Avoid hiding route paths in prefixes.
use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator;
return static function (RoutingConfigurator $routingConfigurator): void {
$routingConfigurator->import(__DIR__ . '/some-path')
->prefix('/some-prefix');
};
Why? This makes it hard to find the actual route path. If we look at a controller and see:
/**
* @Route("/some-path")
*/
Is it really the path or is there some magic prefix elsewhere?
Use a single place for paths in @Route
/#[Route]
. This also opens a path to PHPStan rules that work with routes in a reliable way.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoClassLevelRouteRule
Same as above, only in different locations. Avoid class-level route prefixing:
use Symfony\Component\Routing\Attribute\Route;
#[Route('/some-prefix')]
class SomeController
{
#[Route('/some-action')]
public function someAction()
{
}
}
It's easy to get lost in 200+ lines controller and jump back and forth. Use a single place in #[Route]
/@Route
to keep a single source of truth and focus. Also helps PHPStan to work with routes in a reliable way.
To add more value to a single source of truth: if we use FOSRestBundle, the global file/controller prefixes are sometimes included, and sometimes ignored.
Are you afraid of bugs in routes during this refactoring? Get covered with route smoke testing before doing anything. We use it and it works great.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoListenerWithoutContractRule
If we use listeners over subscribers, we have to do more config coding. Instead EventSubscriberInterface
contract stores all metadata in the class itself. Here is how to upgrade.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoStringInGetSubscribedEventsRule
Symfony getSubscribedEvents()
method must contain only event class references, no strings. Why?
That was a selection of 10 rules we use in every Symfony project. You can the full Symfony list here.
composer require symplify/phpstan-rules --dev
NoGetInControllerRule
.It prevents using $this->get(...)
in controllers, to promote dependency injection instead.
rules:
- Symplify\PHPStanRules\Rules\Symfony\NoGetInControllerRule
phpstan.neon
clean:# phpstan.neon
includes:
- vendor/symplify/phpstan-rules/config/symfony-rules.neon
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!