├── .github └── workflows │ └── code_analysis.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── fix ├── composer.json ├── composer.lock ├── ecs.php ├── phpstan.neon.dist ├── phpunit.xml ├── resources └── cover.png └── utils └── PHPStan ├── src ├── ActionAllowedParameterRule.php ├── ActionReturnsResponseRule.php ├── AutoIncrementingModelIdRule.php ├── CollectFunctionCalls.php ├── CollectVariableNamesVisitor.php ├── ControllerDetermination.php ├── DeterminationBasedOnSuffix.php ├── DoctrineEntityWithAnnotation.php ├── EntityDetermination.php ├── ForbiddenImplementsRule.php ├── ForbiddenMethodCallRule.php ├── ForbiddenObjectTypeInCommandRule.php ├── ForbiddenOverrideRule.php ├── ForbiddenParentClassRule.php ├── ForbiddenTwigFunctionsRule.php ├── ForbiddenTwigVarsRule.php ├── FriendOf.php ├── FriendRule.neon ├── FriendRule.php ├── ModelCreatedWithArrayRule.php ├── NoContainerGetRule.php ├── NoEntityInTemplateRule.php ├── NoErrorSilencingRule.php ├── OneActionPerControllerRule.php └── UseEmailValidatorRule.php └── tests ├── ActionAllowedParameterRule ├── ActionAllowedParameterRuleTest.php ├── ActionReturnsResponseRuleTest.php ├── Fixtures │ ├── action-does-not-return-response.php │ ├── controller-has-more-than-one-action.php │ ├── parameter-type-not-allowed.php │ ├── skip-action-only-has-allowed-parameter.php │ ├── skip-action-returns-response.php │ ├── skip-controller-has-no-actions.php │ ├── skip-controller-has-one-action.php │ ├── skip-controller-has-private-methods.php │ ├── skip-not-a-controller.php │ └── skip-not-a-public-method.php └── OneActionPerControllerRuleTest.php ├── AutoIncrementingModelIdRule ├── AutoIncrementingModelIdRuleTest.php └── Fixtures │ ├── ModelHasAutoIncrementingId.php │ ├── ModelHasImplicitAutoIncrementingId.php │ ├── SkipModelHasNonAutoIncrementingId.php │ └── SkipNotAModel.php ├── ForbiddenImplementsRule ├── Fixtures │ ├── class-implements-forbidden-interface.php │ └── skip-class-implements-allowed-interface.php └── ForbiddenImplementsRuleTest.php ├── ForbiddenMethodCallRule ├── Fixtures │ ├── CallToForbiddenMethod.php │ ├── SkipDifferentClass.php │ └── SkipDifferentMethod.php └── ForbiddenMethodCallRuleTest.php ├── ForbiddenObjectTypeInCommandRule ├── Fixtures │ ├── AnActualCommand.php │ └── NotACommand.php └── ForbiddenObjectTypeInCommandRuleTest.php ├── ForbiddenOverrideRule ├── Fixtures │ ├── class-has-forbidden-override.php │ ├── skip-ancestor-does-not-match.php │ └── skip-method-name-does-not-match.php └── ForbiddenOverrideRuleTest.php ├── ForbiddenParentClassRule ├── Fixtures │ ├── extends-abstract-controller.php │ ├── skip-class-extends-nothing.php │ └── skip-class-extends-something-else.php └── ForbiddenParentClassRuleTest.php ├── ForbiddenTwigFunctionsRule ├── Fixtures │ ├── okay.html.twig │ ├── skip-not-a-call-to-render.php │ ├── skip-not-a-call-to-twig-environment.php │ ├── skip-not-a-constant-string.php │ ├── skip-template-is-okay.php │ ├── twig-template-uses-forbidden-function.php │ └── uses-forbidden-function.html.twig └── ForbiddenTwigFunctionsRuleTest.php ├── ForbiddenTwigVarsRule ├── Fixtures │ ├── okay.html.twig │ ├── skip-not-a-call-to-render.php │ ├── skip-not-a-call-to-twig-environment.php │ ├── skip-not-a-constant-string.php │ ├── skip-template-is-okay.php │ ├── twig-template-uses-forbidden-var.php │ └── uses-forbidden-var.html.twig └── ForbiddenTwigVarsRuleTest.php ├── FriendRule ├── Fixtures │ ├── ATrueFriend.php │ ├── ClassWithFriendAttribute.php │ └── NotAFriend.php └── FriendRuleTest.php ├── ModelCreatedWithArrayRule ├── Fixtures │ ├── AModel.php │ ├── NotAModel.php │ ├── model-created-with-array.php │ ├── model-created-with-no-arguments.php │ ├── skip-model-created-with-multiple-arguments.php │ ├── skip-model-created-with-non-array-argument.php │ ├── skip-not-a-model.php │ └── skip-not-create.php └── ModelCreatedWithArrayRuleTest.php ├── NoContainerGetRule ├── Fixtures │ ├── container-get.php │ ├── skip-container-get-in-controller.php │ ├── skip-different-method.php │ └── skip-different-object.php ├── NoContainerGetRuleTest.php └── phpstan.neon ├── NoEntityInTemplateRule ├── Fixtures │ ├── AnEntity.php │ ├── entity-passed-to-twig.php │ ├── skip-no-entity-passed.php │ ├── skip-not-a-call-to-render.php │ ├── skip-not-a-call-to-twig-environment.php │ └── skip-not-a-constant-string.php └── NoEntityInTemplateRuleTest.php ├── NoErrorSilencingRule ├── Fixtures │ └── error-silencing.php └── NoErrorSilencingRuleTest.php └── UseEmailValidatorRule ├── Fixtures ├── skip-validate-has-no-strings.php ├── skip-validate-string-does-not-contain-email.php └── validate-string-contains-email.php └── UseEmailValidatorRuleTest.php /.github/workflows/code_analysis.yaml: -------------------------------------------------------------------------------- 1 | name: "Code Analysis" 2 | 3 | on: 4 | pull_request: null 5 | push: 6 | branches: 7 | - "main" 8 | 9 | jobs: 10 | code_analysis: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | include: 15 | - name: "Tests" 16 | dependencies: "highest" 17 | run: | 18 | composer exec -- phpunit 19 | - name: "Static Analysis" 20 | dependencies: "highest" 21 | run: | 22 | composer exec -- phpstan 23 | 24 | name: ${{ matrix.name }} 25 | runs-on: "ubuntu-latest" 26 | 27 | steps: 28 | - uses: "actions/checkout@v2" 29 | 30 | # See https://github.com/shivammathur/setup-php 31 | - uses: "shivammathur/setup-php@v2" 32 | with: 33 | php-version: "8.1" 34 | coverage: "none" 35 | 36 | # Composer install and cache - https://github.com/ramsey/composer-install 37 | - uses: "ramsey/composer-install@v1" 38 | with: 39 | dependency-versions: ${{ matrix.dependencies }} 40 | 41 | - run: ${{ matrix.run }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | .phpunit.result.cache 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Matthias Noback 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPStan rules from the book "Recipes for Decoupling" by Matthias Noback 2 | 3 | In the book ["Recipes for Decoupling"](https://leanpub.com/recipes-for-decoupling/) we discuss how to decouple from web and CLI frameworks, ORMs, validation libraries, and test frameworks. While doing so, we develop a number of custom rules for use with PHPStan. That way we can use automated static analysis to keep our code decoupled in the long run. 4 | 5 | This project contains all the final versions of each of the rules from the book. They are not production-ready but feel free to use them as a starting point for your own custom rules. 6 | 7 | [![Cover of "Recipes for Decoupling"](resources/cover.png)](https://leanpub.com/recipes-for-decoupling/) 8 | -------------------------------------------------------------------------------- /bin/fix: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | # Run several times to fix things fixed by the first fix run 6 | vendor/bin/ecs check --fix 7 | vendor/bin/ecs check --fix 8 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "php": "^8.1", 4 | "twig/twig": "^3.4", 5 | "symfony/console": "^6.1", 6 | "illuminate/database": "^9.19" 7 | }, 8 | "require-dev": { 9 | "phpunit/phpunit": "^9.5", 10 | "phpstan/phpstan": "^1.7", 11 | "symplify/easy-coding-standard": "^11.0", 12 | "symplify/coding-standard": "^11.0", 13 | "illuminate/http": "^9.19" 14 | }, 15 | "license": "MIT", 16 | "autoload-dev": { 17 | "psr-4": { 18 | "Utils\\PHPStan\\": "utils/PHPStan/src", 19 | "Utils\\PHPStan\\Tests\\": "utils/PHPStan/tests/" 20 | } 21 | }, 22 | "config": { 23 | "allow-plugins": { 24 | "phpstan/extension-installer": true 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([__DIR__ . '/utils', __DIR__ . '/ecs.php']); 11 | 12 | $config->skip([StandardizeHereNowDocKeywordFixer::class, '*/Fixtures/*']); 13 | 14 | $config->sets([SetList::CONTROL_STRUCTURES, SetList::PSR_12, SetList::COMMON, SetList::SYMPLIFY]); 15 | }; 16 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | paths: 3 | - utils/PHPStan/src/ 4 | # Needs to be 9 5 | level: 7 6 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | ./utils/PHPStan/tests 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /resources/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthiasnoback/recipes-for-decoupling-phpstan-rules/eab24cba178e3b84501bbaaa59854d3d361b912d/resources/cover.png -------------------------------------------------------------------------------- /utils/PHPStan/src/ActionAllowedParameterRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class ActionAllowedParameterRule implements Rule 18 | { 19 | public function __construct( 20 | private readonly string $allowedParameterType, 21 | private readonly ControllerDetermination $determination, 22 | ) { 23 | } 24 | 25 | public function getNodeType(): string 26 | { 27 | return InClassMethodNode::class; 28 | } 29 | 30 | /** 31 | * @param InClassMethodNode $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | if (! $node->getOriginalNode()->isPublic()) { 36 | // The method is not a controller action 37 | return []; 38 | } 39 | 40 | if ( 41 | ! $this->determination->isController($scope->getClassReflection()) 42 | ) { 43 | // We're not inside a controller 44 | return []; 45 | } 46 | 47 | $errors = []; 48 | 49 | foreach ($node->getOriginalNode()->params as $param) { 50 | if ( 51 | 52 | ! (new ObjectType($this->allowedParameterType)) 53 | ->isSuperTypeOf($scope->getType($param->var)) 54 | ->yes() 55 | ) { 56 | $errors[] = 57 | RuleErrorBuilder::message( 58 | sprintf( 59 | 'Controller actions can only have parameters of type "%s"', 60 | $this->allowedParameterType, 61 | ) 62 | )->build(); 63 | } 64 | } 65 | 66 | return $errors; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ActionReturnsResponseRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class ActionReturnsResponseRule implements Rule 20 | { 21 | public function __construct( 22 | private readonly string $requiredReturnType, 23 | private readonly ControllerDetermination $determination, 24 | ) { 25 | } 26 | 27 | public function getNodeType(): string 28 | { 29 | return InClassMethodNode::class; 30 | } 31 | 32 | /** 33 | * @param InClassMethodNode $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | if (! $node->getOriginalNode()->isPublic()) { 38 | // The method is not a controller action 39 | return []; 40 | } 41 | 42 | if ( 43 | ! $this->determination->isController($scope->getClassReflection()) 44 | ) { 45 | // We're not inside a controller 46 | return []; 47 | } 48 | 49 | $methodReflection = $scope->getFunction(); 50 | if (! $methodReflection instanceof MethodReflection) { 51 | /* 52 | * This shouldn't happen, since this rule subscribes 53 | * to `InClassMethodNode`... 54 | */ 55 | return []; 56 | } 57 | 58 | $returnType = ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType(); 59 | 60 | if ((new ObjectType($this->requiredReturnType)) 61 | ->isSuperTypeOf($returnType) 62 | ->yes()) { 63 | // The action already returns a Response 64 | return []; 65 | } 66 | 67 | return [ 68 | RuleErrorBuilder::message( 69 | sprintf( 70 | 'Method %s::%s() should return %s', 71 | $methodReflection->getDeclaringClass() 72 | ->getName(), 73 | $methodReflection->getName(), 74 | $this->requiredReturnType, 75 | ) 76 | )->build(), 77 | ]; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /utils/PHPStan/src/AutoIncrementingModelIdRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class AutoIncrementingModelIdRule implements Rule 20 | { 21 | public function getNodeType(): string 22 | { 23 | return ClassPropertiesNode::class; 24 | } 25 | 26 | /** 27 | * @param ClassPropertiesNode $node 28 | */ 29 | public function processNode(Node $node, Scope $scope): array 30 | { 31 | if (! $scope->isInClass()) { 32 | // Should not happen 33 | return []; 34 | } 35 | 36 | if (! in_array(Model::class, $scope->getClassReflection() ->getParentClassesNames(), true)) { 37 | // This class does not extend `Model` 38 | return []; 39 | } 40 | 41 | $type = $this->defaultValueTypeOfIncrementingProperty($node, $scope); 42 | 43 | if (! $type instanceof ConstantBooleanType) { 44 | /* 45 | * The default value of `$incrementing` is not 46 | * recognized as a boolean, we can't analyze it 47 | */ 48 | return []; 49 | } 50 | 51 | if ($type->getValue() === false) { 52 | /* 53 | * Indeed, we want the default value of `$incrementing` 54 | * to be `false` 55 | */ 56 | return []; 57 | } 58 | 59 | return [RuleErrorBuilder::message('This model has an auto-incrementing ID')->build()]; 60 | } 61 | 62 | private function defaultValueTypeOfIncrementingProperty(ClassPropertiesNode $node, Scope $scope): Type 63 | { 64 | foreach ($node->getProperties() as $property) { 65 | if ($property->getName() !== 'incrementing') { 66 | continue; 67 | } 68 | 69 | return $scope->getType($property->getDefault()); 70 | } 71 | 72 | /* 73 | * The default value inherited from the `Model` parent 74 | * class is supposed to be true 75 | */ 76 | return new ConstantBooleanType(true); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /utils/PHPStan/src/CollectFunctionCalls.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private array $functionCalls = []; 18 | 19 | public function enterNode(Node $node, Environment $env): Node 20 | { 21 | if ($node instanceof FunctionExpression) { 22 | $this->functionCalls[] = $node; 23 | } 24 | return $node; 25 | } 26 | 27 | /** 28 | * @return list 29 | */ 30 | public function functionCalls(): array 31 | { 32 | return $this->functionCalls; 33 | } 34 | 35 | public function leaveNode(Node $node, Environment $env): ?Node 36 | { 37 | return $node; 38 | } 39 | 40 | public function getPriority(): int 41 | { 42 | return 10; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /utils/PHPStan/src/CollectVariableNamesVisitor.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | private array $variableNames = []; 18 | 19 | public function enterNode(Node $node, Environment $env): Node 20 | { 21 | if ($node instanceof NameExpression) { 22 | $this->variableNames[] = $node; 23 | } 24 | return $node; 25 | } 26 | 27 | /** 28 | * @return list 29 | */ 30 | public function variableNames(): array 31 | { 32 | return $this->variableNames; 33 | } 34 | 35 | public function leaveNode(Node $node, Environment $env): ?Node 36 | { 37 | return $node; 38 | } 39 | 40 | public function getPriority(): int 41 | { 42 | return 10; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ControllerDetermination.php: -------------------------------------------------------------------------------- 1 | getName(), $this->suffix); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /utils/PHPStan/src/DoctrineEntityWithAnnotation.php: -------------------------------------------------------------------------------- 1 | getResolvedPhpDoc() ->getPhpDocString(), '@ORM\Entity',); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/src/EntityDetermination.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ForbiddenImplementsRule implements Rule 17 | { 18 | private string $forbiddenInterface; 19 | 20 | public function __construct(string $forbiddenInterface) 21 | { 22 | $this->forbiddenInterface = $forbiddenInterface; 23 | } 24 | 25 | public function getNodeType(): string 26 | { 27 | return InClassNode::class; 28 | } 29 | 30 | /** 31 | * @param InClassNode $node 32 | */ 33 | public function processNode(Node $node, Scope $scope): array 34 | { 35 | if (! $node->getClassReflection()->implementsInterface($this->forbiddenInterface)) { 36 | return []; 37 | } 38 | 39 | return [ 40 | RuleErrorBuilder::message( 41 | sprintf('Class implements forbidden interface %s', $this->forbiddenInterface,) 42 | )->build(), 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenMethodCallRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ForbiddenMethodCallRule implements Rule 19 | { 20 | private ObjectType $class; 21 | 22 | public function __construct( 23 | string $class, 24 | private readonly string $method, 25 | ) { 26 | $this->class = new ObjectType($class); 27 | } 28 | 29 | public function getNodeType(): string 30 | { 31 | return MethodCall::class; 32 | } 33 | 34 | /** 35 | * @param MethodCall $node 36 | */ 37 | public function processNode(Node $node, Scope $scope): array 38 | { 39 | if (! $node->name instanceof Identifier) { 40 | // Dynamic method name, can not be analyzed 41 | return []; 42 | } 43 | 44 | if ($node->name->toString() !== $this->method) { 45 | // The method is a different one 46 | return []; 47 | } 48 | 49 | if (! $this->class->isSuperTypeOf($scope->getType($node->var))->yes() 50 | ) { 51 | // The class does not match the expected type 52 | return []; 53 | } 54 | 55 | return [ 56 | RuleErrorBuilder::message( 57 | sprintf('Call to forbidden method %s::%s()', $this->class->getClassName(), $this->method,) 58 | )->build(), 59 | ]; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenObjectTypeInCommandRule.php: -------------------------------------------------------------------------------- 1 | 17 | */ 18 | final class ForbiddenObjectTypeInCommandRule implements Rule 19 | { 20 | private readonly ObjectType $forbiddenType; 21 | 22 | public function __construct(string $forbiddenType) 23 | { 24 | $this->forbiddenType = new ObjectType($forbiddenType); 25 | } 26 | 27 | public function getNodeType(): string 28 | { 29 | return MethodCall::class; 30 | } 31 | 32 | /** 33 | * @param MethodCall $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | if (! $this->forbiddenType->isSuperTypeOf($scope->getType($node->var))->yes()) { 38 | return []; 39 | } 40 | 41 | if (! $scope->isInClass()) { 42 | // The call is made outside a class; okay for now 43 | return []; 44 | } 45 | 46 | if (in_array(Command::class, $scope->getClassReflection() ->getParentClassesNames(), true)) { 47 | // One of the parent classes of this class is `Command` 48 | return []; 49 | } 50 | 51 | // This call is inside a class that is not a `Command` 52 | return [ 53 | RuleErrorBuilder::message( 54 | sprintf( 55 | 'Object of type %s is used in a class that does not extend %s', 56 | $this->forbiddenType->getClassName(), 57 | Command::class 58 | ) 59 | )->build(), 60 | ]; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenOverrideRule.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ForbiddenOverrideRule implements Rule 17 | { 18 | public function __construct( 19 | private readonly string $overrideFromClass, 20 | private readonly string $overrideMethod, 21 | ) { 22 | } 23 | 24 | public function getNodeType(): string 25 | { 26 | return ClassMethod::class; 27 | } 28 | 29 | /** 30 | * @param ClassMethod $node 31 | */ 32 | public function processNode(Node $node, Scope $scope): array 33 | { 34 | if ($node->name->toString() !== $this->overrideMethod) { 35 | // The method name does not match 36 | return []; 37 | } 38 | 39 | if (! in_array($this->overrideFromClass, $scope->getClassReflection() ->getParentClassesNames(), true)) { 40 | // The method name matches, but not the class 41 | return []; 42 | } 43 | 44 | return [ 45 | RuleErrorBuilder::message( 46 | sprintf( 47 | 'Overriding method %s::%s() is not allowed', 48 | $this->overrideFromClass, 49 | $this->overrideMethod, 50 | ) 51 | )->build(), 52 | ]; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenParentClassRule.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ForbiddenParentClassRule implements Rule 17 | { 18 | public function __construct( 19 | private readonly string $forbiddenParentClass, 20 | ) { 21 | } 22 | 23 | public function getNodeType(): string 24 | { 25 | return Class_::class; 26 | } 27 | 28 | /** 29 | * @param Class_ $node 30 | */ 31 | public function processNode(Node $node, Scope $scope): array 32 | { 33 | if ($node->extends === null) { 34 | // This class does not `extend` anything 35 | return []; 36 | } 37 | 38 | if ( 39 | $node->extends->toString() !== $this->forbiddenParentClass 40 | ) { 41 | // The extended class is not the forbidden parent class 42 | return []; 43 | } 44 | 45 | return [ 46 | RuleErrorBuilder::message( 47 | sprintf('Parent class %s is forbidden', $this->forbiddenParentClass,), 48 | )->build(), 49 | ]; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenTwigFunctionsRule.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class ForbiddenTwigFunctionsRule implements Rule 24 | { 25 | /** 26 | * @param list $forbiddenFunctions 27 | */ 28 | public function __construct( 29 | private readonly string $templateDir, 30 | private readonly array $forbiddenFunctions, 31 | ) { 32 | } 33 | 34 | public function getNodeType(): string 35 | { 36 | return MethodCall::class; 37 | } 38 | 39 | /** 40 | * @param MethodCall $node 41 | */ 42 | public function processNode(Node $node, Scope $scope): array 43 | { 44 | $twigEnvironment = new ObjectType(Environment::class); 45 | if (! $twigEnvironment 46 | ->isSuperTypeOf($scope->getType($node->var)) 47 | ->yes()) { 48 | // The object is not a Twig `Environment` instance 49 | return []; 50 | } 51 | 52 | if (! $node->name instanceof Identifier 53 | || $node->name->toString() !== 'render') { 54 | // The method is called dynamically, or is not `render()` 55 | return []; 56 | } 57 | 58 | if (! isset($node->getArgs()[0])) { 59 | // The method call has no arguments 60 | return []; 61 | } 62 | 63 | $firstArgument = $node->getArgs()[0]; 64 | $firstArgumentType = $scope->getType($firstArgument->value); 65 | if (! $firstArgumentType instanceof ConstantStringType) { 66 | // The first argument is not a constant string 67 | return []; 68 | } 69 | 70 | $templateName = $firstArgumentType->getValue(); 71 | 72 | // Load the template 73 | $loader = new FilesystemLoader($this->templateDir); 74 | 75 | $source = $loader->getSourceContext($templateName); 76 | 77 | // Parse the template 78 | $twig = new Environment($loader, [ 79 | 'debug' => true, 80 | ]); 81 | $twig->addExtension(new DebugExtension()); 82 | 83 | $nodeTree = $twig->parse($twig->tokenize($source)); 84 | 85 | // Traverse the node tree and collect all variable names 86 | $visitor = new CollectFunctionCalls(); 87 | $nodeTraverser = new NodeTraverser($twig, [$visitor]); 88 | $nodeTraverser->traverse($nodeTree); 89 | 90 | $errors = []; 91 | 92 | foreach ($visitor->functionCalls() as $functionCall) { 93 | $functionName = $functionCall->getAttribute('name'); 94 | 95 | if (in_array($functionName, $this->forbiddenFunctions, true,)) { 96 | $errors[] = RuleErrorBuilder::message(sprintf( 97 | 'Template uses forbidden function %s', 98 | $functionName, 99 | )) 100 | ->file( 101 | $functionCall->getSourceContext() 102 | ->getPath() 103 | ?: $functionCall->getSourceContext() 104 | ->getName() 105 | ) 106 | ->line($functionCall->getTemplateLine()) 107 | ->build(); 108 | } 109 | } 110 | 111 | return $errors; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ForbiddenTwigVarsRule.php: -------------------------------------------------------------------------------- 1 | 21 | */ 22 | final class ForbiddenTwigVarsRule implements Rule 23 | { 24 | /** 25 | * @param list $forbiddenVariables 26 | */ 27 | public function __construct( 28 | private readonly string $templateDir, 29 | private readonly array $forbiddenVariables, 30 | ) { 31 | } 32 | 33 | public function getNodeType(): string 34 | { 35 | return MethodCall::class; 36 | } 37 | 38 | /** 39 | * @param MethodCall $node 40 | */ 41 | public function processNode(Node $node, Scope $scope): array 42 | { 43 | $twigEnvironment = new ObjectType(Environment::class); 44 | if (! $twigEnvironment 45 | ->isSuperTypeOf($scope->getType($node->var)) 46 | ->yes()) { 47 | // The object is not a Twig `Environment` instance 48 | return []; 49 | } 50 | 51 | if (! $node->name instanceof Identifier 52 | || $node->name->toString() !== 'render') { 53 | // The method is called dynamically, or is not `render()` 54 | return []; 55 | } 56 | 57 | if (! isset($node->getArgs()[0])) { 58 | // The method call has no arguments 59 | return []; 60 | } 61 | 62 | $firstArgument = $node->getArgs()[0]; 63 | $firstArgumentType = $scope->getType($firstArgument->value); 64 | if (! $firstArgumentType instanceof ConstantStringType) { 65 | // The first argument is not a constant string 66 | return []; 67 | } 68 | 69 | $templateName = $firstArgumentType->getValue(); 70 | 71 | // Load the template 72 | $loader = new FilesystemLoader($this->templateDir); 73 | 74 | $source = $loader->getSourceContext($templateName); 75 | 76 | // Parse the template 77 | $twig = new Environment($loader); 78 | $nodeTree = $twig->parse($twig->tokenize($source)); 79 | 80 | // Traverse the node tree and collect all variable names 81 | $visitor = new CollectVariableNamesVisitor(); 82 | $nodeTraverser = new NodeTraverser($twig, [$visitor]); 83 | $nodeTraverser->traverse($nodeTree); 84 | 85 | $errors = []; 86 | 87 | foreach ($visitor->variableNames() as $nameNode) { 88 | $variableName = $nameNode->getAttribute('name'); 89 | if (in_array($variableName, $this->forbiddenVariables, true,)) { 90 | $errors[] = RuleErrorBuilder::message(sprintf('Template uses forbidden var %s', $variableName,)) 91 | ->file($nameNode->getSourceContext() ->getPath() ?: $nameNode->getSourceContext() ->getName()) 92 | ->line($nameNode->getTemplateLine()) 93 | ->build(); 94 | } 95 | } 96 | 97 | return $errors; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /utils/PHPStan/src/FriendOf.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class FriendRule implements Rule 21 | { 22 | public function __construct(private readonly ReflectionProvider $reflectionProvider) 23 | { 24 | } 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | if (! $node->name instanceof Identifier) { 37 | // Dynamic method call, we can't analyze it 38 | return []; 39 | } 40 | 41 | $objectType = $scope->getType($node->var); 42 | if (! $objectType instanceof ObjectType) { 43 | /* 44 | * We can't find out what type of object this method is 45 | * called on 46 | */ 47 | return []; 48 | } 49 | 50 | try { 51 | $methodReflection = $this->reflectionProvider 52 | ->getClass($objectType->getClassName()) 53 | ->getNativeReflection() 54 | ->getMethod($node->name->toString()); 55 | } catch (ReflectionException) { 56 | // Could not find the actual method in the code, nothing to analyze 57 | return []; 58 | } 59 | 60 | $friendOfAttributes = $methodReflection->getAttributes(FriendOf::class); 61 | if ($friendOfAttributes === []) { 62 | /* 63 | * The method has no `#[FriendOf]` attributes, so it's 64 | * okay to call this method 65 | */ 66 | return []; 67 | } 68 | 69 | $thisClassType = new ObjectType($scope->getClassReflection() ->getName()); 70 | 71 | foreach ($friendOfAttributes as $attribute) { 72 | /** @var FriendOf $instance */ 73 | $instance = $attribute->newInstance(); 74 | $friendClassType = (new ObjectType($instance->friendClass)); 75 | 76 | if ($friendClassType->isSuperTypeOf($thisClassType)->yes()) { 77 | return []; 78 | } 79 | } 80 | 81 | return [ 82 | RuleErrorBuilder::message( 83 | sprintf( 84 | 'Method call %s::%s() is only allowed inside friend classes', 85 | $objectType->getClassName(), 86 | $methodReflection->getName() 87 | ) 88 | )->build(), 89 | ]; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /utils/PHPStan/src/ModelCreatedWithArrayRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class ModelCreatedWithArrayRule implements Rule 21 | { 22 | public function getNodeType(): string 23 | { 24 | return StaticCall::class; 25 | } 26 | 27 | /** 28 | * @param StaticCall $node 29 | */ 30 | public function processNode(Node $node, Scope $scope): array 31 | { 32 | if (! $this->isCallToModelCreate($node, $scope)) { 33 | return []; 34 | } 35 | 36 | if (count($node->getArgs()) > 1) { 37 | // Multiple arguments were provided: great! 38 | return []; 39 | } 40 | 41 | if (count($node->getArgs()) === 0) { 42 | return [ 43 | RuleErrorBuilder::message( 44 | 'Model is created with no arguments, ' . 45 | 'use explicit arguments instead' 46 | )->build(), 47 | ]; 48 | } 49 | 50 | $firstArgument = $node->getArgs()[0]; 51 | if (! $scope->getType($firstArgument->value)->isArray() 52 | ->yes()) { 53 | // The only argument is not an array 54 | return []; 55 | } 56 | 57 | return [ 58 | RuleErrorBuilder::message( 59 | 'Model is created with an array argument, ' . 60 | 'use explicit arguments instead' 61 | )->build(), 62 | ]; 63 | } 64 | 65 | private function isCallToModelCreate(StaticCall $node, Scope $scope): bool 66 | { 67 | if (! $node->class instanceof Name) { 68 | // The class part is dynamic, we can't do anything with it 69 | return false; 70 | } 71 | 72 | $type = $scope->resolveTypeByName($node->class); 73 | if (! (new ObjectType(Model::class)) 74 | ->isSuperTypeOf($type) 75 | ->yes()) { 76 | // The class is definitely not a model 77 | return false; 78 | } 79 | 80 | if (! $node->name instanceof Identifier) { 81 | // It's a dynamic method call 82 | return false; 83 | } 84 | 85 | return $node->name->toString() === 'create'; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /utils/PHPStan/src/NoContainerGetRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class NoContainerGetRule implements Rule 20 | { 21 | public function __construct( 22 | private readonly ControllerDetermination $determination, 23 | ) { 24 | } 25 | 26 | public function getNodeType(): string 27 | { 28 | return MethodCall::class; 29 | } 30 | 31 | /** 32 | * @param MethodCall $node 33 | */ 34 | public function processNode(Node $node, Scope $scope): array 35 | { 36 | $objectType = $scope->getType($node->var); 37 | $containerType = new ObjectType(ContainerInterface::class); 38 | 39 | if (! $containerType->isSuperTypeOf($objectType)->yes()) { 40 | return []; 41 | } 42 | 43 | if (! $node->name instanceof Identifier) { 44 | // This is a dynamic method call, let's ignore it 45 | return []; 46 | } 47 | 48 | if ($node->name->name !== 'get') { 49 | // Not a call to `ContainerInterface::get()`, ignore it 50 | return []; 51 | } 52 | 53 | if ($scope->isInClass() 54 | && $this->determination->isController($scope->getClassReflection())) { 55 | /* 56 | * We're allowed to call `ContainerInterface::get()` 57 | * inside controllers 58 | */ 59 | return []; 60 | } 61 | 62 | return [RuleErrorBuilder::message('Don\'t use the container as a service locator')->build()]; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /utils/PHPStan/src/NoEntityInTemplateRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | final class NoEntityInTemplateRule implements Rule 21 | { 22 | public function __construct( 23 | private readonly EntityDetermination $determine, 24 | ) { 25 | } 26 | 27 | public function getNodeType(): string 28 | { 29 | return MethodCall::class; 30 | } 31 | 32 | /** 33 | * @param MethodCall $node 34 | */ 35 | public function processNode(Node $node, Scope $scope): array 36 | { 37 | $twigEnvironment = new ObjectType(Environment::class); 38 | if (! $twigEnvironment 39 | ->isSuperTypeOf($scope->getType($node->var)) 40 | ->yes()) { 41 | // The object is not a Twig `Environment` instance 42 | return []; 43 | } 44 | 45 | if (! $node->name instanceof Identifier 46 | || $node->name->toString() !== 'render') { 47 | // The method is called dynamically, or is not `render()` 48 | return []; 49 | } 50 | 51 | if (! isset($node->getArgs()[1])) { 52 | // The method call has no second argument 53 | return []; 54 | } 55 | 56 | $templateVars = $node->getArgs()[1] 57 | ->value; 58 | 59 | $arrayType = $scope->getType($templateVars); 60 | if (! $arrayType instanceof ConstantArrayType) { 61 | return []; 62 | } 63 | 64 | $valueTypes = $arrayType->getValueTypes(); 65 | 66 | $errors = []; 67 | 68 | foreach ($valueTypes as $valueType) { 69 | if (! $valueType instanceof ObjectType) { 70 | continue; 71 | } 72 | 73 | if ( 74 | $this->determine->isEntity($valueType->getClassReflection()) 75 | ) { 76 | $errors[] = RuleErrorBuilder::message( 77 | sprintf( 78 | 'Entity of type %s should not ' . 79 | 'be passed to a template', 80 | $valueType->getClassName(), 81 | ), 82 | )->build(); 83 | } 84 | } 85 | 86 | return $errors; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /utils/PHPStan/src/NoErrorSilencingRule.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class NoErrorSilencingRule implements Rule 17 | { 18 | public function getNodeType(): string 19 | { 20 | return ErrorSuppress::class; 21 | } 22 | 23 | public function processNode(Node $node, Scope $scope): array 24 | { 25 | return [RuleErrorBuilder::message('You should not use the silencing operator (@)')->build()]; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /utils/PHPStan/src/OneActionPerControllerRule.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | final class OneActionPerControllerRule implements Rule 18 | { 19 | public function __construct( 20 | private readonly ControllerDetermination $determination, 21 | ) { 22 | } 23 | 24 | public function getNodeType(): string 25 | { 26 | return InClassNode::class; 27 | } 28 | 29 | /** 30 | * @param InClassNode $node 31 | */ 32 | public function processNode(Node $node, Scope $scope): array 33 | { 34 | if ( 35 | ! $this->determination->isController($node->getClassReflection()) 36 | ) { 37 | // This class is not a controller 38 | return []; 39 | } 40 | 41 | $actionMethods = array_filter( 42 | $node->getOriginalNode() 43 | ->getMethods(), 44 | fn (ClassMethod $method) => $method->isPublic() 45 | ); 46 | 47 | if (count($actionMethods) > 1) { 48 | return [ 49 | RuleErrorBuilder::message( 50 | sprintf('Controller %s should have only one action', $scope->getClassReflection()->getName(),) 51 | )->build(), 52 | ]; 53 | } 54 | 55 | return []; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /utils/PHPStan/src/UseEmailValidatorRule.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | final class UseEmailValidatorRule implements Rule 22 | { 23 | public function getNodeType(): string 24 | { 25 | return MethodCall::class; 26 | } 27 | 28 | /** 29 | * @param MethodCall $node 30 | */ 31 | public function processNode(Node $node, Scope $scope): array 32 | { 33 | if (! $this->isCallToRequestValidate($node, $scope)) { 34 | return []; 35 | } 36 | 37 | if (count($node->getArgs()) === 0) { 38 | // No arguments provided, nothing to analyze 39 | return []; 40 | } 41 | 42 | $firstArgumentNode = $node->getArgs()[0]; 43 | $firstArgumentType = $scope->getType($firstArgumentNode->value); 44 | 45 | if (! $firstArgumentType instanceof ConstantArrayType) { 46 | // No constant array provided, we can't analyze this 47 | return []; 48 | } 49 | 50 | foreach ($firstArgumentType->getValueTypes() as $valueType) { 51 | if (! $valueType instanceof ConstantStringType) { 52 | // The value is not a plain string 53 | continue; 54 | } 55 | 56 | $parts = explode('|', $valueType->getValue()); 57 | if (! in_array('email', $parts, true)) { 58 | // The string doesn't contain 'email' 59 | continue; 60 | } 61 | 62 | // Return an error on the first use of "email": 63 | 64 | return [RuleErrorBuilder::message('Use App\Models\Email::validator() instead of "email"')->build()]; 65 | } 66 | 67 | return []; 68 | } 69 | 70 | private function isCallToRequestValidate(MethodCall $node, Scope $scope): bool 71 | { 72 | if (! (new ObjectType(Request::class)) 73 | ->isSuperTypeOf($scope->getType($node->var)) 74 | ->yes()) { 75 | // The object is not an instance of `Request` 76 | return false; 77 | } 78 | 79 | if (! $node->name instanceof Identifier) { 80 | // It's a dynamic method call, we can't analyze it 81 | return false; 82 | } 83 | 84 | return $node->name->toString() === 'validate'; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ActionAllowedParameterRule/ActionAllowedParameterRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 18 | [__DIR__ . '/Fixtures/parameter-type-not-allowed.php'], 19 | [ 20 | [ 21 | 'Controller actions can only have parameters of type "Symfony\Component\HttpFoundation\Request"', 22 | 11, 23 | ], 24 | ] 25 | ); 26 | } 27 | 28 | public function testSkipNotAPublicMethod(): void 29 | { 30 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-public-method.php'], []); 31 | } 32 | 33 | public function testSkipNotAController(): void 34 | { 35 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []); 36 | } 37 | 38 | public function testActionOnlyHasAllowedParameter(): void 39 | { 40 | $this->analyse([__DIR__ . '/Fixtures/skip-action-only-has-allowed-parameter.php'], []); 41 | } 42 | 43 | protected function getRule(): Rule 44 | { 45 | return new ActionAllowedParameterRule(Request::class, new DeterminationBasedOnSuffix('Controller'),); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ActionAllowedParameterRule/ActionReturnsResponseRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 18 | [__DIR__ . '/Fixtures/action-does-not-return-response.php'], 19 | [['Method SomeController::someAction() should return Symfony\Component\HttpFoundation\Response', 9]] 20 | ); 21 | } 22 | 23 | public function testSkipNotAPublicMethod(): void 24 | { 25 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-public-method.php'], []); 26 | } 27 | 28 | public function testSkipNotAController(): void 29 | { 30 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []); 31 | } 32 | 33 | public function testSkipActionReturnsResponse(): void 34 | { 35 | $this->analyse([__DIR__ . '/Fixtures/skip-action-returns-response.php'], []); 36 | } 37 | 38 | protected function getRule(): Rule 39 | { 40 | return new ActionReturnsResponseRule(Response::class, new DeterminationBasedOnSuffix('Controller'),); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ActionAllowedParameterRule/Fixtures/action-does-not-return-response.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/controller-has-more-than-one-action.php'], 18 | [['Controller SomeController should have only one action', 8]] 19 | ); 20 | } 21 | 22 | public function testSkipControllerHasOneAction(): void 23 | { 24 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-one-action.php'], []); 25 | } 26 | 27 | public function testSkipControllerHasNoActions(): void 28 | { 29 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-no-actions.php'], []); 30 | } 31 | 32 | public function testSkipNotAController(): void 33 | { 34 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-controller.php'], []); 35 | } 36 | 37 | public function testSkipControllerHasPrivateMethods(): void 38 | { 39 | $this->analyse([__DIR__ . '/Fixtures/skip-controller-has-private-methods.php'], []); 40 | } 41 | 42 | protected function getRule(): Rule 43 | { 44 | return new OneActionPerControllerRule(new DeterminationBasedOnSuffix('Controller'),); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/AutoIncrementingModelIdRule/AutoIncrementingModelIdRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/ModelHasAutoIncrementingId.php'], 17 | [['This model has an auto-incrementing ID', 9]] 18 | ); 19 | } 20 | 21 | public function testModelHasImplicitAutoIncrementingId(): void 22 | { 23 | $this->analyse( 24 | [__DIR__ . '/Fixtures/ModelHasImplicitAutoIncrementingId.php'], 25 | [['This model has an auto-incrementing ID', 9]] 26 | ); 27 | } 28 | 29 | public function testSkipModelHasNonAutoIncrementingId(): void 30 | { 31 | $this->analyse([__DIR__ . '/Fixtures/SkipModelHasNonAutoIncrementingId.php'], []); 32 | } 33 | 34 | public function testSkipNotAModel(): void 35 | { 36 | $this->analyse([__DIR__ . '/Fixtures/SkipNotAModel.php'], []); 37 | } 38 | 39 | protected function getRule(): Rule 40 | { 41 | return new AutoIncrementingModelIdRule(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/AutoIncrementingModelIdRule/Fixtures/ModelHasAutoIncrementingId.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/class-implements-forbidden-interface.php'], 18 | [['Class implements forbidden interface PHPUnit\Runner\BeforeFirstTestHook', 9]] 19 | ); 20 | } 21 | 22 | public function testSkipClassImplementsAllowedInterface(): void 23 | { 24 | $this->analyse([__DIR__ . '/Fixtures/skip-class-implements-allowed-interface.php'], []); 25 | } 26 | 27 | protected function getRule(): Rule 28 | { 29 | return new ForbiddenImplementsRule(BeforeFirstTestHook::class); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/CallToForbiddenMethod.php: -------------------------------------------------------------------------------- 1 | createMock(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/SkipDifferentClass.php: -------------------------------------------------------------------------------- 1 | createMock(); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenMethodCallRule/Fixtures/SkipDifferentMethod.php: -------------------------------------------------------------------------------- 1 | assertTrue(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenMethodCallRule/ForbiddenMethodCallRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/CallToForbiddenMethod.php'], 18 | [['Call to forbidden method PHPUnit\Framework\TestCase::createMock()', 13]] 19 | ); 20 | } 21 | 22 | public function testSkipDifferentMethod(): void 23 | { 24 | $this->analyse([__DIR__ . '/Fixtures/SkipDifferentMethod.php'], []); 25 | } 26 | 27 | public function testSkipDifferentClass(): void 28 | { 29 | $this->analyse([__DIR__ . '/Fixtures/SkipDifferentClass.php'], []); 30 | } 31 | 32 | protected function getRule(): Rule 33 | { 34 | return new ForbiddenMethodCallRule(TestCase::class, 'createMock',); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/Fixtures/AnActualCommand.php: -------------------------------------------------------------------------------- 1 | getArgument('arg'); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/Fixtures/NotACommand.php: -------------------------------------------------------------------------------- 1 | getArgument('arg'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenObjectTypeInCommandRule/ForbiddenObjectTypeInCommandRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/NotACommand.php'], 18 | [ 19 | [ 20 | 'Object of type Symfony\Component\Console\Input\InputInterface is used in a class that does not extend Symfony\Component\Console\Command\Command', 21 | 13, 22 | ], 23 | ] 24 | ); 25 | } 26 | 27 | public function testSkipInputOutputUsedInCommand(): void 28 | { 29 | $this->analyse([__DIR__ . '/Fixtures/AnActualCommand.php'], []); 30 | } 31 | 32 | protected function getRule(): Rule 33 | { 34 | return new ForbiddenObjectTypeInCommandRule(InputInterface::class); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenOverrideRule/Fixtures/class-has-forbidden-override.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/class-has-forbidden-override.php'], 18 | [['Overriding method PHPUnit\Framework\TestCase::setUpBeforeClass() is not allowed', 11]] 19 | ); 20 | } 21 | 22 | public function testSkipAncestorDoesNotMatch(): void 23 | { 24 | $this->analyse([__DIR__ . '/Fixtures/skip-ancestor-does-not-match.php'], []); 25 | } 26 | 27 | public function testSkipMethodNameDoesNotMatch(): void 28 | { 29 | $this->analyse([__DIR__ . '/Fixtures/skip-method-name-does-not-match.php'], []); 30 | } 31 | 32 | protected function getRule(): Rule 33 | { 34 | return new ForbiddenOverrideRule(TestCase::class, 'setUpBeforeClass'); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenParentClassRule/Fixtures/extends-abstract-controller.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/extends-abstract-controller.php'], 18 | [['Parent class Symfony\Bundle\FrameworkBundle\Controller\AbstractController is forbidden', 7]] 19 | ); 20 | } 21 | 22 | public function testExtendsNothing(): void 23 | { 24 | $this->analyse([__DIR__ . '/Fixtures/skip-class-extends-nothing.php'], []); 25 | } 26 | 27 | public function testExtendsSomethingElse(): void 28 | { 29 | $this->analyse([__DIR__ . '/Fixtures/skip-class-extends-something-else.php'], []); 30 | } 31 | 32 | protected function getRule(): Rule 33 | { 34 | return new ForbiddenParentClassRule(AbstractController::class,); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/okay.html.twig: -------------------------------------------------------------------------------- 1 | {{ date(okay) }} 2 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-call-to-render.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | 'uses-forbidden-var.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-call-to-twig-environment.php: -------------------------------------------------------------------------------- 1 | twig->render( 11 | 'uses-forbidden-var.html.twig', 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-not-a-constant-string.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | $template, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/skip-template-is-okay.php: -------------------------------------------------------------------------------- 1 | twig->render( 13 | 'okay.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/twig-template-uses-forbidden-function.php: -------------------------------------------------------------------------------- 1 | twig->render( 13 | 'uses-forbidden-function.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/Fixtures/uses-forbidden-function.html.twig: -------------------------------------------------------------------------------- 1 | {{ dump(something) }} 2 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigFunctionsRule/ForbiddenTwigFunctionsRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/twig-template-uses-forbidden-function.php'], 17 | [['Template uses forbidden function dump', 1]] 18 | ); 19 | } 20 | 21 | public function testSkipNotACallToTwigEnvironment(): void 22 | { 23 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []); 24 | } 25 | 26 | public function testSkipNotACallToRender(): void 27 | { 28 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []); 29 | } 30 | 31 | public function testSkipNotAConstantString(): void 32 | { 33 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []); 34 | } 35 | 36 | public function testTemplateIsOkay(): void 37 | { 38 | $this->analyse([__DIR__ . '/Fixtures/skip-template-is-okay.php'], []); 39 | } 40 | 41 | protected function getRule(): Rule 42 | { 43 | return new ForbiddenTwigFunctionsRule(__DIR__ . '/Fixtures', ['dump']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/okay.html.twig: -------------------------------------------------------------------------------- 1 | {{ okay.userIdentifier }} 2 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-call-to-render.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | 'uses-forbidden-var.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-call-to-twig-environment.php: -------------------------------------------------------------------------------- 1 | twig->render( 11 | 'uses-forbidden-var.html.twig', 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-not-a-constant-string.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | $template, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/skip-template-is-okay.php: -------------------------------------------------------------------------------- 1 | twig->render( 13 | 'okay.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/twig-template-uses-forbidden-var.php: -------------------------------------------------------------------------------- 1 | twig->render( 13 | 'uses-forbidden-var.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/Fixtures/uses-forbidden-var.html.twig: -------------------------------------------------------------------------------- 1 | {{ app.user.userIdentifier }} 2 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ForbiddenTwigVarsRule/ForbiddenTwigVarsRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/twig-template-uses-forbidden-var.php'], 17 | [['Template uses forbidden var app', 1]] 18 | ); 19 | } 20 | 21 | public function testSkipNotACallToTwigEnvironment(): void 22 | { 23 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []); 24 | } 25 | 26 | public function testSkipNotACallToRender(): void 27 | { 28 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []); 29 | } 30 | 31 | public function testSkipNotAConstantString(): void 32 | { 33 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []); 34 | } 35 | 36 | public function testTemplateIsOkay(): void 37 | { 38 | $this->analyse([__DIR__ . '/Fixtures/skip-template-is-okay.php'], []); 39 | } 40 | 41 | protected function getRule(): Rule 42 | { 43 | return new ForbiddenTwigVarsRule(__DIR__ . '/Fixtures', ['app']); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/FriendRule/Fixtures/ATrueFriend.php: -------------------------------------------------------------------------------- 1 | internalMethod(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/FriendRule/Fixtures/ClassWithFriendAttribute.php: -------------------------------------------------------------------------------- 1 | internalMethod(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/FriendRule/FriendRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/NotAFriend.php'], 17 | [ 18 | [ 19 | 'Method call Utils\PHPStan\Tests\FriendRule\Fixtures\ClassWithFriendAttribute::internalMethod() is only allowed inside friend classes', 20 | 13, 21 | ], 22 | ] 23 | ); 24 | } 25 | 26 | public function testSkipATrueFriend(): void 27 | { 28 | $this->analyse([__DIR__ . '/Fixtures/ATrueFriend.php'], []); 29 | } 30 | 31 | public static function getAdditionalConfigFiles(): array 32 | { 33 | return [__DIR__ . '/../../src/FriendRule.neon']; 34 | } 35 | 36 | protected function getRule(): Rule 37 | { 38 | return self::getContainer()->getByType(FriendRule::class); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/ModelCreatedWithArrayRule/Fixtures/AModel.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/model-created-with-array.php'], 17 | [['Model is created with an array argument, use explicit arguments instead', 9]] 18 | ); 19 | } 20 | 21 | public function testModelCreatedWithNoArguments(): void 22 | { 23 | $this->analyse( 24 | [__DIR__ . '/Fixtures/model-created-with-no-arguments.php'], 25 | [['Model is created with no arguments, use explicit arguments instead', 7]] 26 | ); 27 | } 28 | 29 | public function testSkipModelCreatedWithMultipleArguments(): void 30 | { 31 | $this->analyse([__DIR__ . '/Fixtures/skip-model-created-with-multiple-arguments.php'], []); 32 | } 33 | 34 | public function testSkipModelCreatedWithNonArrayArgument(): void 35 | { 36 | $this->analyse([__DIR__ . '/Fixtures/skip-model-created-with-non-array-argument.php'], []); 37 | } 38 | 39 | public function testSkipNotAModel(): void 40 | { 41 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-model.php'], []); 42 | } 43 | 44 | public function testSkipNotCreate(): void 45 | { 46 | $this->analyse([__DIR__ . '/Fixtures/skip-not-create.php'], []); 47 | } 48 | 49 | protected function getRule(): Rule 50 | { 51 | return new ModelCreatedWithArrayRule(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/Fixtures/container-get.php: -------------------------------------------------------------------------------- 1 | get('logger'); 10 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-container-get-in-controller.php: -------------------------------------------------------------------------------- 1 | container->get('logger'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-different-method.php: -------------------------------------------------------------------------------- 1 | set('logger', null); 10 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/Fixtures/skip-different-object.php: -------------------------------------------------------------------------------- 1 | get('logger'); 8 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/NoContainerGetRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/container-get.php'], 17 | [['Don\'t use the container as a service locator', 9]] 18 | ); 19 | } 20 | 21 | public function testSkipOtherMethods(): void 22 | { 23 | $this->analyse( 24 | [__DIR__ . '/Fixtures/skip-different-method.php'], 25 | [ 26 | // we expect no errors 27 | ] 28 | ); 29 | } 30 | 31 | public function testSkipOtherObjects(): void 32 | { 33 | $this->analyse( 34 | [__DIR__ . '/Fixtures/skip-different-object.php'], 35 | [ 36 | // we expect no errors 37 | ] 38 | ); 39 | } 40 | 41 | public function testSkipContainerGetInController(): void 42 | { 43 | $this->analyse( 44 | [__DIR__ . '/Fixtures/skip-container-get-in-controller.php'], 45 | [ 46 | // we expect no errors 47 | ] 48 | ); 49 | } 50 | 51 | public static function getAdditionalConfigFiles(): array 52 | { 53 | return [__DIR__ . '/phpstan.neon']; 54 | } 55 | 56 | protected function getRule(): Rule 57 | { 58 | return self::getContainer()->getByType(NoContainerGetRule::class,); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoContainerGetRule/phpstan.neon: -------------------------------------------------------------------------------- 1 | services: 2 | - 3 | class: Utils\PHPStan\NoContainerGetRule 4 | tags: 5 | - phpstan.rules.rule 6 | - 7 | class: Utils\PHPStan\DeterminationBasedOnSuffix 8 | arguments: 9 | suffix: "Controller" 10 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/AnEntity.php: -------------------------------------------------------------------------------- 1 | twig->render( 14 | 'template.html.twig', 15 | [ 16 | 'anEntity' => new AnEntity(), 17 | ] 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-no-entity-passed.php: -------------------------------------------------------------------------------- 1 | twig->render( 13 | 'template.html.twig', 14 | [ 15 | 'notAnEntity' => new DateTimeImmutable(), 16 | ] 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-call-to-render.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | 'uses-forbidden-var.html.twig', 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-call-to-twig-environment.php: -------------------------------------------------------------------------------- 1 | twig->render( 11 | 'uses-forbidden-var.html.twig', 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/Fixtures/skip-not-a-constant-string.php: -------------------------------------------------------------------------------- 1 | twig->load( 13 | $template, 14 | ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoEntityInTemplateRule/NoEntityInTemplateRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 17 | [__DIR__ . '/Fixtures/entity-passed-to-twig.php'], 18 | [[ 19 | 'Entity of type Utils\PHPStan\Tests\NoEntityInTemplateRule\Fixtures\AnEntity should not be passed to a template', 20 | 13, 21 | ]] 22 | ); 23 | } 24 | 25 | public function testSkipNoEntityPassed(): void 26 | { 27 | $this->analyse([__DIR__ . '/Fixtures/skip-no-entity-passed.php'], []); 28 | } 29 | 30 | public function testSkipNotACallToTwigEnvironment(): void 31 | { 32 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-twig-environment.php'], []); 33 | } 34 | 35 | public function testSkipNotACallToRender(): void 36 | { 37 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-call-to-render.php'], []); 38 | } 39 | 40 | public function testSkipNotAConstantString(): void 41 | { 42 | $this->analyse([__DIR__ . '/Fixtures/skip-not-a-constant-string.php'], []); 43 | } 44 | 45 | protected function getRule(): Rule 46 | { 47 | return new NoEntityInTemplateRule(new DoctrineEntityWithAnnotation()); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/NoErrorSilencingRule/Fixtures/error-silencing.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/error-silencing.php'], 17 | [['You should not use the silencing operator (@)', 5]] 18 | ); 19 | } 20 | 21 | protected function getRule(): Rule 22 | { 23 | return new NoErrorSilencingRule(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/skip-validate-has-no-strings.php: -------------------------------------------------------------------------------- 1 | validate( 13 | [ 14 | 'email_address' => Email::validator() 15 | ] 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/skip-validate-string-does-not-contain-email.php: -------------------------------------------------------------------------------- 1 | validate( 12 | [ 13 | 'email_address' => 'required' 14 | ] 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/UseEmailValidatorRule/Fixtures/validate-string-contains-email.php: -------------------------------------------------------------------------------- 1 | validate( 12 | [ 13 | 'email_address' => 'required|email' 14 | ] 15 | ); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /utils/PHPStan/tests/UseEmailValidatorRule/UseEmailValidatorRuleTest.php: -------------------------------------------------------------------------------- 1 | analyse( 16 | [__DIR__ . '/Fixtures/validate-string-contains-email.php'], 17 | [['Use App\Models\Email::validator() instead of "email"', 11]] 18 | ); 19 | } 20 | 21 | public function testSkipValidateHasNoStrings(): void 22 | { 23 | $this->analyse([__DIR__ . '/Fixtures/skip-validate-has-no-strings.php'], []); 24 | } 25 | 26 | public function testSkipValidateStringDoesNotContainEmail(): void 27 | { 28 | $this->analyse([__DIR__ . '/Fixtures/skip-validate-string-does-not-contain-email.php'], []); 29 | } 30 | 31 | protected function getRule(): Rule 32 | { 33 | return new UseEmailValidatorRule(); 34 | } 35 | } 36 | --------------------------------------------------------------------------------