From 7d919d3007635c98c639bf39f82d393c9d8084eb Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Mon, 21 Sep 2026 10:57:35 +0200 Subject: [PATCH] [symfony] NoNullableServiceInConstructorRule - allow nullable in data-holder namespaces (Event, DTO, Token, Exception, Helper, Form\Type, Badge, ...) --- README.md | 2 +- .../NoNullableServiceInConstructorRule.php | 35 ++++++++++++++++++- .../Fixture/Event/SkipEventDataHolder.php | 15 ++++++++ ...NoNullableServiceInConstructorRuleTest.php | 1 + 4 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 tests/Rules/Symfony/NoNullableServiceInConstructorRule/Fixture/Event/SkipEventDataHolder.php diff --git a/README.md b/README.md index 7c557926..d1ee3193 100644 --- a/README.md +++ b/README.md @@ -2852,7 +2852,7 @@ public function handle(): void ### NoNullableServiceInConstructorRule -A constructor service dependency must not be nullable - a service is always provided by the container, so `?SomeService` only hides that it is really required. Nullable is allowed on an abstract class (a child fills the dependency) and on values that are not services: scalars, arrays, exceptions (`?Throwable $previous`) and date value objects. +A constructor service dependency must not be nullable - a service is always provided by the container, so `?SomeService` only hides that it is really required. Nullable is allowed on an abstract class (a child fills the dependency), on values that are not services (scalars, arrays, exceptions like `?Throwable $previous`, date value objects), and on data-holder classes in an `Entity`, `Event`, `DTO`, `Message`, `DAO`, `Token`, `Exception`, `Helper`, `ValueObject`, `Form\Type` or `Badge` namespace. ```yaml rules: diff --git a/src/Rules/Symfony/NoNullableServiceInConstructorRule.php b/src/Rules/Symfony/NoNullableServiceInConstructorRule.php index 084c879e..c1218424 100644 --- a/src/Rules/Symfony/NoNullableServiceInConstructorRule.php +++ b/src/Rules/Symfony/NoNullableServiceInConstructorRule.php @@ -28,7 +28,8 @@ * A service is always provided by the container, so "?SomeService $service" or "SomeService|null $service" only hides * that it is really required. Nullable is allowed on an abstract class, whose optional dependency is filled by a child. * A nullable scalar, array, exception ("$previous" is nullable by PHP convention) or date value object is left alone, - * as those are values, not services. + * as those are values, not services. Data-holder classes in an Entity, Event, DTO, Message, DAO, Token, Exception, + * Helper, ValueObject, Form\Type or Badge namespace are skipped whole - their constructors carry values, not services. * * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\NoNullableServiceInConstructorRule\NoNullableServiceInConstructorRuleTest * @@ -38,6 +39,25 @@ { public const string ERROR_MESSAGE = 'Constructor service "%s" of type "%s" is nullable. A service is always provided, make it non-nullable'; + /** + * Data-holder namespaces whose constructors carry nullable values, not container services. + * + * @var string[] + */ + private const array SKIPPED_NAMESPACE_PARTS = [ + '\\Entity\\', + '\\Event\\', + '\\DTO\\', + '\\Message\\', + '\\DAO\\', + '\\Token\\', + '\\Exception\\', + '\\Helper\\', + '\\ValueObject\\', + '\\Form\\Type\\', + '\\Badge\\', + ]; + public function __construct( private ReflectionProvider $reflectionProvider, ) { @@ -70,6 +90,11 @@ public function processNode(Node $node, Scope $scope): array return []; } + // a data-holder namespace (event, DTO, token, exception, helper, form type, badge, ...) carries values, not services + if ($this->isSkippedNamespace($classReflection->getName())) { + return []; + } + $paramTypes = $this->resolveParamClassTypes($node, $scope); $ruleErrors = []; @@ -167,6 +192,14 @@ private function matchNullableServiceName(Identifier|Name|ComplexType|null $type return null; } + private function isSkippedNamespace(string $className): bool + { + return array_any( + self::SKIPPED_NAMESPACE_PARTS, + static fn (string $skippedNamespacePart): bool => str_contains($className, $skippedNamespacePart) + ); + } + /** * A nullable class type that is not really a service: an exception ("$previous") or a date value object. */ diff --git a/tests/Rules/Symfony/NoNullableServiceInConstructorRule/Fixture/Event/SkipEventDataHolder.php b/tests/Rules/Symfony/NoNullableServiceInConstructorRule/Fixture/Event/SkipEventDataHolder.php new file mode 100644 index 00000000..c5f4e309 --- /dev/null +++ b/tests/Rules/Symfony/NoNullableServiceInConstructorRule/Fixture/Event/SkipEventDataHolder.php @@ -0,0 +1,15 @@ +