diff --git a/README.md b/README.md
index d1ee3193..ce76738d 100644
--- a/README.md
+++ b/README.md
@@ -1513,6 +1513,58 @@ public function run(): array
## 2. Doctrine-specific Rules
+### NoStringTargetEntityRule
+
+A Doctrine association attribute must reference its target entity as a class constant, not a string.
+
+```yaml
+rules:
+ - Symplify\PHPStanRules\Rules\Doctrine\NoStringTargetEntityRule
+```
+
+```php
+#[ManyToOne(targetEntity: 'App\Entity\Category')]
+```
+
+:x:
+
+
+
+```php
+#[ManyToOne(targetEntity: Category::class)]
+```
+
+:+1:
+
+
+
+### NoReadonlyEntityClassRule
+
+A Doctrine entity is hydrated via reflection without the constructor, so it must not be a `readonly` class. An entity is recognized by an `#[ORM\Entity]` attribute or a public static `loadMetadata()` method.
+
+```yaml
+rules:
+ - Symplify\PHPStanRules\Rules\Doctrine\NoReadonlyEntityClassRule
+```
+
+```php
+#[ORM\Entity]
+final readonly class Product {}
+```
+
+:x:
+
+
+
+```php
+#[ORM\Entity]
+final class Product {}
+```
+
+:+1:
+
+
+
### RequireQueryBuilderOnRepositoryRule
Prevents using `$entityManager->createQueryBuilder('...')`, use `$repository->createQueryBuilder()` as safer.
@@ -2920,6 +2972,95 @@ $services->set(SomeConsumer::class)
+### PreferClassInDefinitionFetchRule
+
+A container definition fetch that names a class by a plain string should use the class constant instead. The string is only flagged when it is a real class name; a service id string is left alone.
+
+```yaml
+rules:
+ - Symplify\PHPStanRules\Rules\Symfony\PreferClassInDefinitionFetchRule
+```
+
+```php
+$container->getDefinition('App\Helper\ColumnSchemaHelper');
+```
+
+:x:
+
+
+
+```php
+$container->getDefinition(ColumnSchemaHelper::class);
+```
+
+:+1:
+
+
+
+### NoServiceSetterCallRule
+
+In a PHP config closure, a setter injection wired by hand with `->call('setX', [service(...)])` should be a `#[Required]` attribute on the setter, so autowiring calls it. Only a `setXxx()` method fed a `service()` is reported; a container parameter or a non-setter call stays.
+
+```yaml
+rules:
+ - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule
+```
+
+```php
+$services->set(SomeService::class)
+ ->call('setRepository', [service(SomeRepository::class)]);
+```
+
+:x:
+
+
+
+```php
+#[Required]
+public function setRepository(SomeRepository $someRepository): void
+{
+ $this->someRepository = $someRepository;
+}
+```
+
+:+1:
+
+
+
+### NoAutoconfiguredServiceTagRule
+
+When a PHP config closure's `defaults()` uses `autoconfigure()`, a `->tag()` that autoconfiguration already adds by interface (`console.command`, `form.type`, `kernel.event_subscriber`, `security.voter`, `twig.extension`, `validator.constraint_validator`) is redundant. Only a tag with no attributes of its own is reported.
+
+```yaml
+rules:
+ - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule
+```
+
+```php
+$services->defaults()->autoconfigure();
+
+$services->set(SomeSubscriber::class)
+ ->tag('kernel.event_subscriber');
+```
+
+:x:
+
+
+
+```php
+$services->defaults()->autoconfigure();
+
+$services->set(SomeSubscriber::class);
+```
+
+:+1:
+
+
+
+---
+
+
+
## 4. PHPUnit-specific Rules
### NoAssertFuncCallInTestsRule
diff --git a/config/doctrine-rules.neon b/config/doctrine-rules.neon
index ec4c4961..6f524bef 100644
--- a/config/doctrine-rules.neon
+++ b/config/doctrine-rules.neon
@@ -8,3 +8,7 @@ rules:
# test fixtures
- Symplify\PHPStanRules\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule
+
+ # entity mapping
+ - Symplify\PHPStanRules\Rules\Doctrine\NoStringTargetEntityRule
+ - Symplify\PHPStanRules\Rules\Doctrine\NoReadonlyEntityClassRule
diff --git a/config/symfony-config-rules.neon b/config/symfony-config-rules.neon
index 2a9af87a..6156bf52 100644
--- a/config/symfony-config-rules.neon
+++ b/config/symfony-config-rules.neon
@@ -26,6 +26,12 @@ rules:
# service('id') where an alias points 'id' at a class
- Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\PreferClassServiceReferenceRule
+ # redundant ->tag() that autoconfigure() already adds
+ - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule
+
+ # ->call('setX', [service()]) that should be a #[Required] setter
+ - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule
+
services:
-
class: Symplify\PHPStanRules\Collector\ClassTargetServiceAliasCollector
diff --git a/config/symfony-rules.neon b/config/symfony-rules.neon
index 7e1ce0b8..e7138dac 100644
--- a/config/symfony-rules.neon
+++ b/config/symfony-rules.neon
@@ -18,6 +18,7 @@ rules:
# dependency injection
- Symplify\PHPStanRules\Rules\Symfony\NoServiceJugglingRule
+ - Symplify\PHPStanRules\Rules\Symfony\PreferClassInDefinitionFetchRule
- Symplify\PHPStanRules\Rules\Symfony\NoGetInControllerRule
- Symplify\PHPStanRules\Rules\Symfony\NoGetInCommandRule
- Symplify\PHPStanRules\Rules\Symfony\NoGetDoctrineInControllerRule
diff --git a/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php b/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php
index 3a928b52..ca9a9486 100644
--- a/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php
+++ b/src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php
@@ -21,4 +21,8 @@ final class DoctrineRuleIdentifier
public const string NO_LISTENER_WITHOUT_CONTRACT = 'doctrine.noListenerWithoutContract';
public const string REQUIRE_SERVICE_PARENT_REPOSITORY = 'doctrine.requireServiceParentRepository';
+
+ public const string NO_STRING_TARGET_ENTITY = 'doctrine.noStringTargetEntity';
+
+ public const string NO_READONLY_ENTITY_CLASS = 'doctrine.noReadonlyEntityClass';
}
diff --git a/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php b/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php
index f24f685b..bf6ba302 100644
--- a/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php
+++ b/src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php
@@ -67,4 +67,10 @@ final class SymfonyRuleIdentifier
public const string FILE_NAME_MATCHES_EXTENSION = 'symfony.fileNameMatchesExtension';
public const string PREFER_CLASS_SERVICE_REFERENCE = 'symfony.preferClassServiceReference';
+
+ public const string NO_AUTOCONFIGURED_SERVICE_TAG = 'symfony.noAutoconfiguredServiceTag';
+
+ public const string NO_SERVICE_SETTER_CALL = 'symfony.noServiceSetterCall';
+
+ public const string PREFER_CLASS_IN_DEFINITION_FETCH = 'symfony.preferClassInDefinitionFetch';
}
diff --git a/src/Rules/Doctrine/NoReadonlyEntityClassRule.php b/src/Rules/Doctrine/NoReadonlyEntityClassRule.php
new file mode 100644
index 00000000..717872b3
--- /dev/null
+++ b/src/Rules/Doctrine/NoReadonlyEntityClassRule.php
@@ -0,0 +1,79 @@
+
+ */
+final class NoReadonlyEntityClassRule implements Rule
+{
+ public const string ERROR_MESSAGE = 'Entity class "%s" must not be readonly. Doctrine hydrates entities via reflection without the constructor, which a readonly class forbids. Remove the readonly modifier from the class';
+
+ private const string ENTITY_ATTRIBUTE = Entity::class;
+
+ public function getNodeType(): string
+ {
+ return Class_::class;
+ }
+
+ /**
+ * @param Class_ $node
+ *
+ * @return list
+ */
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! $node->isReadonly()) {
+ return [];
+ }
+
+ if (! $node->name instanceof Identifier) {
+ return [];
+ }
+
+ if (! $this->isEntity($node)) {
+ return [];
+ }
+
+ $identifierRuleError = RuleErrorBuilder::message(
+ sprintf(self::ERROR_MESSAGE, (string) $node->namespacedName)
+ )
+ ->identifier(DoctrineRuleIdentifier::NO_READONLY_ENTITY_CLASS)
+ ->build();
+
+ return [$identifierRuleError];
+ }
+
+ private function isEntity(Class_ $class): bool
+ {
+ foreach ($class->attrGroups as $attrGroup) {
+ foreach ($attrGroup->attrs as $attr) {
+ if ($attr->name->toString() === self::ENTITY_ATTRIBUTE) {
+ return true;
+ }
+ }
+ }
+
+ $loadMetadataMethod = $class->getMethod('loadMetadata');
+
+ return $loadMetadataMethod instanceof ClassMethod && $loadMetadataMethod->isPublic();
+ }
+}
diff --git a/src/Rules/Doctrine/NoStringTargetEntityRule.php b/src/Rules/Doctrine/NoStringTargetEntityRule.php
new file mode 100644
index 00000000..851a2cbf
--- /dev/null
+++ b/src/Rules/Doctrine/NoStringTargetEntityRule.php
@@ -0,0 +1,75 @@
+
+ */
+final class NoStringTargetEntityRule implements Rule
+{
+ public const string ERROR_MESSAGE = 'Doctrine association #[%s] uses a string targetEntity "%s"; use %s::class instead';
+
+ /**
+ * @var string[]
+ */
+ private const array ASSOCIATION_ATTRIBUTES = [
+ 'Doctrine\ORM\Mapping\ManyToOne',
+ 'Doctrine\ORM\Mapping\OneToMany',
+ 'Doctrine\ORM\Mapping\OneToOne',
+ 'Doctrine\ORM\Mapping\ManyToMany',
+ ];
+
+ public function getNodeType(): string
+ {
+ return Attribute::class;
+ }
+
+ /**
+ * @param Attribute $node
+ *
+ * @return list
+ */
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! in_array($node->name->toString(), self::ASSOCIATION_ATTRIBUTES, true)) {
+ return [];
+ }
+
+ foreach ($node->args as $arg) {
+ if (! $arg->name instanceof Identifier || $arg->name->toString() !== 'targetEntity') {
+ continue;
+ }
+
+ if (! $arg->value instanceof String_) {
+ return [];
+ }
+
+ return [
+ RuleErrorBuilder::message(
+ sprintf(self::ERROR_MESSAGE, $node->name->getLast(), $arg->value->value, $arg->value->value)
+ )
+ ->identifier(DoctrineRuleIdentifier::NO_STRING_TARGET_ENTITY)
+ ->build(),
+ ];
+ }
+
+ return [];
+ }
+}
diff --git a/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php b/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php
new file mode 100644
index 00000000..2e86cd9f
--- /dev/null
+++ b/src/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule.php
@@ -0,0 +1,193 @@
+set(SomeValidator::class)->tag('validator.constraint_validator').
+ *
+ * When the closure's defaults() opens with autoconfigure(), a service implementing ConstraintValidatorInterface is
+ * tagged either way, so the tag repeats what the interface already says. Only a tag with no attributes of its own is
+ * reported - an attribute (a priority, a validator alias, ...) says more than autoconfigure() does and keeps the tag.
+ * A file with no autoconfigure() is left alone, there the tag is the only thing registering the service.
+ *
+ * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule\NoAutoconfiguredServiceTagRuleTest
+ *
+ * @implements Rule
+ */
+final readonly class NoAutoconfiguredServiceTagRule implements Rule
+{
+ public const string ERROR_MESSAGE = 'Tag "%s" is added by autoconfigure() on its own, as "%s" is a %s - remove the ->tag() call';
+
+ private const string SERVICES_VARIABLE_NAME = 'services';
+
+ /**
+ * The tags Symfony adds on its own to a service of the given type.
+ *
+ * @var array
+ */
+ private const array AUTOCONFIGURED_TAGS = [
+ 'console.command' => Command::class,
+ 'form.type' => 'Symfony\Component\Form\FormTypeInterface',
+ 'kernel.event_subscriber' => EventSubscriberInterface::class,
+ 'security.voter' => 'Symfony\Component\Security\Core\Authorization\Voter\VoterInterface',
+ 'twig.extension' => ExtensionInterface::class,
+ 'validator.constraint_validator' => 'Symfony\Component\Validator\ConstraintValidatorInterface',
+ ];
+
+ public function __construct(
+ private ReflectionProvider $reflectionProvider,
+ ) {
+ }
+
+ public function getNodeType(): string
+ {
+ return FileNode::class;
+ }
+
+ /**
+ * @param FileNode $node
+ *
+ * @return list
+ */
+ public function processNode(Node $node, Scope $scope): array
+ {
+ $nodeFinder = new NodeFinder();
+
+ /** @var MethodCall[] $methodCalls */
+ $methodCalls = $nodeFinder->findInstanceOf($node->getNodes(), MethodCall::class);
+
+ if (! $this->hasAutoconfigureCall($methodCalls)) {
+ return [];
+ }
+
+ $ruleErrors = [];
+
+ foreach ($methodCalls as $methodCall) {
+ $tagName = $this->matchTagNameWithoutAttributes($methodCall);
+ if ($tagName === null) {
+ continue;
+ }
+
+ $autoconfiguredType = self::AUTOCONFIGURED_TAGS[$tagName] ?? null;
+ if ($autoconfiguredType === null) {
+ continue;
+ }
+
+ $className = $this->resolveTaggedClassName($methodCall);
+ if ($className === null) {
+ continue;
+ }
+
+ if (! $this->isSubclassOf($className, $autoconfiguredType)) {
+ continue;
+ }
+
+ $ruleErrors[] = RuleErrorBuilder::message(
+ sprintf(self::ERROR_MESSAGE, $tagName, $className, $autoconfiguredType)
+ )
+ ->identifier(SymfonyRuleIdentifier::NO_AUTOCONFIGURED_SERVICE_TAG)
+ // the name, not the call - a chained call starts on the line of the $services->set() above it
+ ->line($methodCall->name->getStartLine())
+ ->build();
+ }
+
+ return $ruleErrors;
+ }
+
+ /**
+ * @param MethodCall[] $methodCalls
+ */
+ private function hasAutoconfigureCall(array $methodCalls): bool
+ {
+ return array_any(
+ $methodCalls,
+ static fn (MethodCall $methodCall): bool => $methodCall->name instanceof Identifier && $methodCall->name->toString() === 'autoconfigure'
+ );
+ }
+
+ /**
+ * @return string|null the tag name of a ->tag() call that adds no attributes of its own
+ */
+ private function matchTagNameWithoutAttributes(MethodCall $methodCall): ?string
+ {
+ if (! $methodCall->name instanceof Identifier || $methodCall->name->toString() !== 'tag') {
+ return null;
+ }
+
+ $args = $methodCall->getArgs();
+ if (count($args) !== 1) {
+ return null;
+ }
+
+ return $args[0]->value instanceof String_ ? $args[0]->value->value : null;
+ }
+
+ /**
+ * Walks the call chain down to the $services->set() or $services->get() call the tag belongs to.
+ */
+ private function resolveTaggedClassName(MethodCall $methodCall): ?string
+ {
+ $currentCall = $methodCall->var;
+
+ while ($currentCall instanceof MethodCall) {
+ if ($currentCall->var instanceof Variable && $currentCall->var->name === self::SERVICES_VARIABLE_NAME) {
+ if (! $currentCall->name instanceof Identifier || ! in_array($currentCall->name->toString(), ['set', 'get'], true)) {
+ return null;
+ }
+
+ $args = $currentCall->getArgs();
+
+ return $args === [] ? null : $this->matchClassName($args[0]->value);
+ }
+
+ $currentCall = $currentCall->var;
+ }
+
+ return null;
+ }
+
+ private function matchClassName(Node $classValue): ?string
+ {
+ if (! $classValue instanceof ClassConstFetch || ! $classValue->class instanceof Name) {
+ return null;
+ }
+
+ if (! $classValue->name instanceof Identifier || $classValue->name->toLowerString() !== 'class') {
+ return null;
+ }
+
+ return $classValue->class->toString();
+ }
+
+ private function isSubclassOf(string $className, string $parentClassName): bool
+ {
+ if (! $this->reflectionProvider->hasClass($className)) {
+ return false;
+ }
+
+ return $this->reflectionProvider->getClass($className)
+ ->is($parentClassName);
+ }
+}
diff --git a/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php b/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php
new file mode 100644
index 00000000..c3b4cffb
--- /dev/null
+++ b/src/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule.php
@@ -0,0 +1,110 @@
+get(X::class)->call('setFoo', [service(Foo::class)]) -
+ * should be a #[Required] attribute on the setter instead, so autowiring calls it and the config no longer names the
+ * method by a loose string.
+ *
+ * Only a call() naming a setXxx() method that a service() feeds is reported. A call() to another method runs logic
+ * the container cannot infer, and a setter fed a container parameter has no type to autowire, so both stay a manual call.
+ *
+ * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\ConfigClosure\NoServiceSetterCallRule\NoServiceSetterCallRuleTest
+ *
+ * @implements Rule
+ */
+final class NoServiceSetterCallRule implements Rule
+{
+ public const string ERROR_MESSAGE = 'Setter call() to "%s()" wires the dependency by hand, mark the method #[Required] and let autowiring call it instead';
+
+ /**
+ * A setter is a setXxx() method, e.g. setListLeadRepository(). A "setup" or "settle" method is no setter.
+ */
+ private const string SETTER_METHOD_PATTERN = '#^set\p{Lu}#u';
+
+ private const string SERVICE_FUNCTION = 'Symfony\Component\DependencyInjection\Loader\Configurator\service';
+
+ public function getNodeType(): string
+ {
+ return MethodCall::class;
+ }
+
+ /**
+ * @param MethodCall $node
+ *
+ * @return list
+ */
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! $node->name instanceof Identifier || $node->name->toString() !== 'call') {
+ return [];
+ }
+
+ $firstArg = $node->getArgs()[0] ?? null;
+ if ($firstArg === null || ! $firstArg->value instanceof String_) {
+ return [];
+ }
+
+ $methodName = $firstArg->value->value;
+ if (preg_match(self::SETTER_METHOD_PATTERN, $methodName) !== 1) {
+ return [];
+ }
+
+ // only a setter fed a service() can move to #[Required]; a container parameter is not resolved by type
+ if (! $this->hasServiceArgument($node)) {
+ return [];
+ }
+
+ return [
+ RuleErrorBuilder::message(sprintf(self::ERROR_MESSAGE, $methodName))
+ ->identifier(SymfonyRuleIdentifier::NO_SERVICE_SETTER_CALL)
+ ->line($node->getStartLine())
+ ->build(),
+ ];
+ }
+
+ /**
+ * The arguments of a call() travel in an array, e.g. ->call('setFieldModel', [service(FieldModel::class)]).
+ * A service() reference among them is the dependency #[Required] autowiring resolves by type.
+ */
+ private function hasServiceArgument(MethodCall $methodCall): bool
+ {
+ $secondArg = $methodCall->getArgs()[1] ?? null;
+ if ($secondArg === null || ! $secondArg->value instanceof Array_) {
+ return false;
+ }
+
+ return array_any(
+ $secondArg->value->items,
+ fn (ArrayItem $arrayItem): bool => $arrayItem->value instanceof FuncCall && $this->isServiceFunction($arrayItem->value)
+ );
+ }
+
+ private function isServiceFunction(FuncCall $funcCall): bool
+ {
+ if (! $funcCall->name instanceof Name) {
+ return false;
+ }
+
+ $functionName = $funcCall->name->toString();
+
+ return $functionName === 'service' || $functionName === self::SERVICE_FUNCTION;
+ }
+}
diff --git a/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php b/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php
new file mode 100644
index 00000000..ac80f0d8
--- /dev/null
+++ b/src/Rules/Symfony/PreferClassInDefinitionFetchRule.php
@@ -0,0 +1,77 @@
+getDefinition('App\Helper\ColumnSchemaHelper') should pass ColumnSchemaHelper::class. The string is
+ * only flagged when it is a real class name, a service id string such as 'app.helper.core' is left alone.
+ *
+ * @see \Symplify\PHPStanRules\Tests\Rules\Symfony\PreferClassInDefinitionFetchRule\PreferClassInDefinitionFetchRuleTest
+ *
+ * @implements Rule
+ */
+final readonly class PreferClassInDefinitionFetchRule implements Rule
+{
+ public const string ERROR_MESSAGE = 'Fetch the definition by class constant, %s::class, rather than the string "%s"';
+
+ /**
+ * @var list
+ */
+ private const array DEFINITION_METHOD_NAMES = ['getDefinition', 'hasDefinition', 'findDefinition', 'removeDefinition'];
+
+ public function __construct(
+ private ReflectionProvider $reflectionProvider,
+ ) {
+ }
+
+ public function getNodeType(): string
+ {
+ return MethodCall::class;
+ }
+
+ /**
+ * @param MethodCall $node
+ *
+ * @return list
+ */
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! $node->name instanceof Identifier || ! in_array($node->name->toString(), self::DEFINITION_METHOD_NAMES, true)) {
+ return [];
+ }
+
+ $firstArg = $node->getArgs()[0] ?? null;
+ if (! $firstArg instanceof Arg || ! $firstArg->value instanceof String_) {
+ return [];
+ }
+
+ $className = $firstArg->value->value;
+ if (! $this->reflectionProvider->hasClass($className)) {
+ return [];
+ }
+
+ $ruleError = RuleErrorBuilder::message(
+ sprintf(self::ERROR_MESSAGE, $className, $className)
+ )
+ ->identifier(SymfonyRuleIdentifier::PREFER_CLASS_IN_DEFINITION_FETCH)
+ ->line($firstArg->getStartLine())
+ ->build();
+
+ return [$ruleError];
+ }
+}
diff --git a/stubs/Doctrine/ORM/Mapping/ManyToOne.php b/stubs/Doctrine/ORM/Mapping/ManyToOne.php
new file mode 100644
index 00000000..b60449bb
--- /dev/null
+++ b/stubs/Doctrine/ORM/Mapping/ManyToOne.php
@@ -0,0 +1,15 @@
+ $expectedErrorsWithLines
+ */
+ #[DataProvider('provideData')]
+ public function testRule(string $filePath, array $expectedErrorsWithLines): void
+ {
+ $this->analyse([$filePath], $expectedErrorsWithLines);
+ }
+
+ /**
+ * @return Iterator, mixed>>
+ */
+ public static function provideData(): Iterator
+ {
+ $loadMetadataError = sprintf(
+ NoReadonlyEntityClassRule::ERROR_MESSAGE,
+ ReportReadonlyLoadMetadataEntity::class
+ );
+ yield [__DIR__ . '/Fixture/ReportReadonlyLoadMetadataEntity.php', [[$loadMetadataError, 7]]];
+
+ $attributeError = sprintf(
+ NoReadonlyEntityClassRule::ERROR_MESSAGE,
+ ReportReadonlyAttributeEntity::class
+ );
+ yield [__DIR__ . '/Fixture/ReportReadonlyAttributeEntity.php', [[$attributeError, 9]]];
+
+ yield [__DIR__ . '/Fixture/SkipReadonlyPlainClass.php', []];
+ yield [__DIR__ . '/Fixture/SkipNonReadonlyEntity.php', []];
+ }
+
+ protected function getRule(): Rule
+ {
+ return new NoReadonlyEntityClassRule();
+ }
+}
diff --git a/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php b/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php
new file mode 100644
index 00000000..99d0d8e7
--- /dev/null
+++ b/tests/Rules/Doctrine/NoStringTargetEntityRule/Fixture/ReportStringTargetEntity.php
@@ -0,0 +1,13 @@
+ $expectedErrorsWithLines
+ */
+ #[DataProvider('provideData')]
+ public function testRule(string $filePath, array $expectedErrorsWithLines): void
+ {
+ $this->analyse([$filePath], $expectedErrorsWithLines);
+ }
+
+ /**
+ * @return Iterator, mixed>>
+ */
+ public static function provideData(): Iterator
+ {
+ $errorMessage = sprintf(
+ NoStringTargetEntityRule::ERROR_MESSAGE,
+ 'ManyToOne',
+ 'App\Entity\Category',
+ 'App\Entity\Category'
+ );
+ yield [__DIR__ . '/Fixture/ReportStringTargetEntity.php', [[$errorMessage, 11]]];
+
+ yield [__DIR__ . '/Fixture/SkipClassConstTargetEntity.php', []];
+ }
+
+ protected function getRule(): Rule
+ {
+ return new NoStringTargetEntityRule();
+ }
+}
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php
new file mode 100644
index 00000000..7d9e8fc7
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/RedundantTagConfig.php
@@ -0,0 +1,16 @@
+services();
+
+ $services->defaults()
+ ->autoconfigure();
+
+ $services->set(SomeSubscriber::class)
+ ->tag('kernel.event_subscriber');
+};
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php
new file mode 100644
index 00000000..53989efb
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipNoAutoconfigureConfig.php
@@ -0,0 +1,13 @@
+services();
+
+ $services->set(SomeSubscriber::class)
+ ->tag('kernel.event_subscriber');
+};
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php
new file mode 100644
index 00000000..d743f8a0
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Fixture/SkipTagWithAttributesConfig.php
@@ -0,0 +1,16 @@
+services();
+
+ $services->defaults()
+ ->autoconfigure();
+
+ $services->set(SomeSubscriber::class)
+ ->tag('kernel.event_subscriber', ['priority' => 10]);
+};
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php
new file mode 100644
index 00000000..c4af6e66
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/NoAutoconfiguredServiceTagRuleTest.php
@@ -0,0 +1,57 @@
+ $expectedErrorsWithLines
+ */
+ #[DataProvider('provideData')]
+ public function testRule(string $filePath, array $expectedErrorsWithLines): void
+ {
+ $this->analyse([$filePath], $expectedErrorsWithLines);
+ }
+
+ /**
+ * @return Iterator, mixed>>
+ */
+ public static function provideData(): Iterator
+ {
+ $errorMessage = sprintf(
+ NoAutoconfiguredServiceTagRule::ERROR_MESSAGE,
+ 'kernel.event_subscriber',
+ SomeSubscriber::class,
+ EventSubscriberInterface::class
+ );
+ yield [__DIR__ . '/Fixture/RedundantTagConfig.php', [[$errorMessage, 15]]];
+
+ yield [__DIR__ . '/Fixture/SkipNoAutoconfigureConfig.php', []];
+ yield [__DIR__ . '/Fixture/SkipTagWithAttributesConfig.php', []];
+ }
+
+ /**
+ * @return array
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(NoAutoconfiguredServiceTagRule::class);
+ }
+}
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php
new file mode 100644
index 00000000..16a67c76
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/Source/SomeSubscriber.php
@@ -0,0 +1,18 @@
+
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [];
+ }
+}
diff --git a/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon
new file mode 100644
index 00000000..710d4704
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoAutoconfiguredServiceTagRule/config/configured_rule.neon
@@ -0,0 +1,2 @@
+rules:
+ - Symplify\PHPStanRules\Rules\Symfony\ConfigClosure\NoAutoconfiguredServiceTagRule
diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php
new file mode 100644
index 00000000..9154f387
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SetterCallConfig.php
@@ -0,0 +1,15 @@
+services();
+
+ $services->set(SomeConsumer::class)
+ ->call('setSomeService', [service(SomeService::class)]);
+};
diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php
new file mode 100644
index 00000000..0229759d
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Fixture/SkipNonSetterAndParamConfig.php
@@ -0,0 +1,16 @@
+services();
+
+ $services->set(SomeConsumer::class)
+ ->call('configure', [service(SomeService::class)])
+ ->call('setSomeService', ['%some.parameter%']);
+};
diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php
new file mode 100644
index 00000000..57fe38a0
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/NoServiceSetterCallRuleTest.php
@@ -0,0 +1,39 @@
+ $expectedErrorsWithLines
+ */
+ #[DataProvider('provideData')]
+ public function testRule(string $filePath, array $expectedErrorsWithLines): void
+ {
+ $this->analyse([$filePath], $expectedErrorsWithLines);
+ }
+
+ /**
+ * @return Iterator, mixed>>
+ */
+ public static function provideData(): Iterator
+ {
+ $errorMessage = sprintf(NoServiceSetterCallRule::ERROR_MESSAGE, 'setSomeService');
+ yield [__DIR__ . '/Fixture/SetterCallConfig.php', [[$errorMessage, 13]]];
+
+ yield [__DIR__ . '/Fixture/SkipNonSetterAndParamConfig.php', []];
+ }
+
+ protected function getRule(): Rule
+ {
+ return new NoServiceSetterCallRule();
+ }
+}
diff --git a/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php
new file mode 100644
index 00000000..63995782
--- /dev/null
+++ b/tests/Rules/Symfony/ConfigClosure/NoServiceSetterCallRule/Source/SomeConsumer.php
@@ -0,0 +1,16 @@
+getDefinition(SomeHelper::class);
+
+ $container->getDefinition('Symplify\PHPStanRules\Tests\Rules\Symfony\PreferClassInDefinitionFetchRule\Source\SomeHelper');
+
+ $container->getDefinition('some.service.id');
+ }
+}
diff --git a/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php
new file mode 100644
index 00000000..3c51e956
--- /dev/null
+++ b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/PreferClassInDefinitionFetchRuleTest.php
@@ -0,0 +1,49 @@
+ $expectedErrorsWithLines
+ */
+ #[DataProvider('provideData')]
+ public function testRule(string $filePath, array $expectedErrorsWithLines): void
+ {
+ $this->analyse([$filePath], $expectedErrorsWithLines);
+ }
+
+ /**
+ * @return Iterator, mixed>>
+ */
+ public static function provideData(): Iterator
+ {
+ $className = SomeHelper::class;
+ $errorMessage = sprintf(PreferClassInDefinitionFetchRule::ERROR_MESSAGE, $className, $className);
+ yield [__DIR__ . '/Fixture/SomeCompilerPass.php', [[$errorMessage, 16]]];
+ }
+
+ /**
+ * @return array
+ */
+ #[Override]
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(PreferClassInDefinitionFetchRule::class);
+ }
+}
diff --git a/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php
new file mode 100644
index 00000000..085158dc
--- /dev/null
+++ b/tests/Rules/Symfony/PreferClassInDefinitionFetchRule/Source/SomeHelper.php
@@ -0,0 +1,9 @@
+