Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<br>

```php
#[ManyToOne(targetEntity: Category::class)]
```

:+1:

<br>

### 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:

<br>

```php
#[ORM\Entity]
final class Product {}
```

:+1:

<br>

### RequireQueryBuilderOnRepositoryRule

Prevents using `$entityManager->createQueryBuilder('...')`, use `$repository->createQueryBuilder()` as safer.
Expand Down Expand Up @@ -2920,6 +2972,95 @@ $services->set(SomeConsumer::class)

<br>

### 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:

<br>

```php
$container->getDefinition(ColumnSchemaHelper::class);
```

:+1:

<br>

### 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:

<br>

```php
#[Required]
public function setRepository(SomeRepository $someRepository): void
{
$this->someRepository = $someRepository;
}
```

:+1:

<br>

### 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:

<br>

```php
$services->defaults()->autoconfigure();

$services->set(SomeSubscriber::class);
```

:+1:

<br>

---

<br>

## 4. PHPUnit-specific Rules

### NoAssertFuncCallInTestsRule
Expand Down
4 changes: 4 additions & 0 deletions config/doctrine-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@ rules:

# test fixtures
- Symplify\PHPStanRules\Rules\Doctrine\RequireQueryBuilderOnRepositoryRule

# entity mapping
- Symplify\PHPStanRules\Rules\Doctrine\NoStringTargetEntityRule
- Symplify\PHPStanRules\Rules\Doctrine\NoReadonlyEntityClassRule
6 changes: 6 additions & 0 deletions config/symfony-config-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/symfony-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/Enum/RuleIdentifier/DoctrineRuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
6 changes: 6 additions & 0 deletions src/Enum/RuleIdentifier/SymfonyRuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
79 changes: 79 additions & 0 deletions src/Rules/Doctrine/NoReadonlyEntityClassRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Rules\Doctrine;

use Doctrine\ORM\Mapping\Entity;
use PhpParser\Node;
use PhpParser\Node\Identifier;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Enum\RuleIdentifier\DoctrineRuleIdentifier;

/**
* A Doctrine entity is hydrated via reflection without the constructor, so a readonly class breaks loading and
* proxying. An entity is recognized by an #[ORM\Entity] attribute or a public static loadMetadata() method.
*
* @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\NoReadonlyEntityClassRule\NoReadonlyEntityClassRuleTest
*
* @implements Rule<Class_>
*/
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<IdentifierRuleError>
*/
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();
}
}
75 changes: 75 additions & 0 deletions src/Rules/Doctrine/NoStringTargetEntityRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Rules\Doctrine;

use PhpParser\Node;
use PhpParser\Node\Attribute;
use PhpParser\Node\Identifier;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Enum\RuleIdentifier\DoctrineRuleIdentifier;

/**
* A Doctrine association attribute must reference its target entity as a class constant (Target::class), not a string.
* A string skips IDE navigation, refactoring and static analysis, and hides typos until runtime.
*
* @see \Symplify\PHPStanRules\Tests\Rules\Doctrine\NoStringTargetEntityRule\NoStringTargetEntityRuleTest
*
* @implements Rule<Attribute>
*/
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<IdentifierRuleError>
*/
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 [];
}
}
Loading
Loading