From 2610d8303d8c1fe03e1305a9ffa37195a50832e3 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Thu, 4 Sep 2025 10:47:02 -0500 Subject: [PATCH 1/4] fix: skip privatizing Laravel Model attributes and scopes --- ...aravel_model_attributes_and_scopes.php.inc | 31 ++++++++++++ .../PrivatizeFinalClassMethodRector.php | 47 +++++++++++++++++++ .../Database/Eloquent/Attributes/Scope.php | 14 ++++++ .../Database/Eloquent/Casts/Attribute.php | 11 +++++ stubs/Illuminate/Database/Eloquent/Model.php | 11 +++++ 5 files changed, 114 insertions(+) create mode 100644 rules-tests/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector/Fixture/skip_laravel_model_attributes_and_scopes.php.inc create mode 100644 stubs/Illuminate/Database/Eloquent/Attributes/Scope.php create mode 100644 stubs/Illuminate/Database/Eloquent/Casts/Attribute.php create mode 100644 stubs/Illuminate/Database/Eloquent/Model.php diff --git a/rules-tests/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector/Fixture/skip_laravel_model_attributes_and_scopes.php.inc b/rules-tests/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector/Fixture/skip_laravel_model_attributes_and_scopes.php.inc new file mode 100644 index 00000000000..b06633a8bc3 --- /dev/null +++ b/rules-tests/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector/Fixture/skip_laravel_model_attributes_and_scopes.php.inc @@ -0,0 +1,31 @@ +shouldSkipClassMethodLaravel($classMethod)) { + return true; + } + // if has parent call, its probably overriding parent one → skip it $hasParentCall = (bool) $this->betterNodeFinder->findFirst( (array) $classMethod->stmts, @@ -149,4 +156,44 @@ function (Node $node): bool { return $hasParentCall; } + + private function shouldSkipClassMethodLaravel(ClassMethod $classMethod): bool + { + $classReflection = ScopeFetcher::fetch($classMethod)->getClassReflection(); + + if (! $classReflection instanceof ClassReflection) { + return false; + } + + if (! $classReflection->is('Illuminate\Database\Eloquent\Model')) { + return false; + } + + $name = (string) $this->getName($classMethod->name); + $returnType = $classMethod->returnType; + + // Model attributes should be protected + if ( + (bool) preg_match('/^[gs]et.+Attribute$/', $name) + || ($returnType instanceof Node && $this->isObjectType( + $returnType, + new ObjectType('Illuminate\Database\Eloquent\Casts\Attribute') + )) + ) { + return true; + } + + // Model scopes should be protected + if ( + (bool) preg_match('/^scope.+$/', $name) + || $this->phpAttributeAnalyzer->hasPhpAttribute( + $classMethod, + 'Illuminate\Database\Eloquent\Attributes\Scope' + ) + ) { + return true; + } + + return false; + } } diff --git a/stubs/Illuminate/Database/Eloquent/Attributes/Scope.php b/stubs/Illuminate/Database/Eloquent/Attributes/Scope.php new file mode 100644 index 00000000000..25827f90441 --- /dev/null +++ b/stubs/Illuminate/Database/Eloquent/Attributes/Scope.php @@ -0,0 +1,14 @@ + Date: Thu, 4 Sep 2025 11:55:21 -0500 Subject: [PATCH 2/4] fix: use StringUtils::isMatch --- .../PrivatizeFinalClassMethodRector.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php index 726aa45ad76..b5945be53ff 100644 --- a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php +++ b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php @@ -17,6 +17,7 @@ use Rector\Privatization\NodeManipulator\VisibilityManipulator; use Rector\Privatization\VisibilityGuard\ClassMethodVisibilityGuard; use Rector\Rector\AbstractRector; +use Rector\Util\StringUtils; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; @@ -25,6 +26,17 @@ */ final class PrivatizeFinalClassMethodRector extends AbstractRector { + /** + * @var string + * @see https://regex101.com/r/Dx0WN5/2 + */ + private const LARAVEL_MODEL_ATTRIBUTE_REGEX = '/^[gs]et.+Attribute$/'; + /** + * @var string + * @see https://regex101.com/r/hxOGeN/2 + */ + private const LARAVEL_MODEL_SCOPE_REGEX = '/^scope.+$/'; + public function __construct( private readonly ClassMethodVisibilityGuard $classMethodVisibilityGuard, private readonly VisibilityManipulator $visibilityManipulator, @@ -174,7 +186,7 @@ private function shouldSkipClassMethodLaravel(ClassMethod $classMethod): bool // Model attributes should be protected if ( - (bool) preg_match('/^[gs]et.+Attribute$/', $name) + StringUtils::isMatch($name, self::LARAVEL_MODEL_ATTRIBUTE_REGEX) || ($returnType instanceof Node && $this->isObjectType( $returnType, new ObjectType('Illuminate\Database\Eloquent\Casts\Attribute') @@ -185,7 +197,7 @@ private function shouldSkipClassMethodLaravel(ClassMethod $classMethod): bool // Model scopes should be protected if ( - (bool) preg_match('/^scope.+$/', $name) + StringUtils::isMatch($name, self::LARAVEL_MODEL_SCOPE_REGEX) || $this->phpAttributeAnalyzer->hasPhpAttribute( $classMethod, 'Illuminate\Database\Eloquent\Attributes\Scope' From a563ce7bd5c8b7acf0cbfc2381bc87a3f1aa63a5 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Thu, 4 Sep 2025 11:57:41 -0500 Subject: [PATCH 3/4] fix: reuse existing class reflection --- .../PrivatizeFinalClassMethodRector.php | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php index b5945be53ff..1bb28d8131d 100644 --- a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php +++ b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php @@ -31,6 +31,7 @@ final class PrivatizeFinalClassMethodRector extends AbstractRector * @see https://regex101.com/r/Dx0WN5/2 */ private const LARAVEL_MODEL_ATTRIBUTE_REGEX = '/^[gs]et.+Attribute$/'; + /** * @var string * @see https://regex101.com/r/hxOGeN/2 @@ -104,7 +105,7 @@ public function refactor(Node $node): ?Node $hasChanged = false; foreach ($node->getMethods() as $classMethod) { - if ($this->shouldSkipClassMethod($classMethod)) { + if ($this->shouldSkipClassMethod($classReflection, $classMethod)) { continue; } @@ -133,7 +134,7 @@ public function refactor(Node $node): ?Node return null; } - private function shouldSkipClassMethod(ClassMethod $classMethod): bool + private function shouldSkipClassMethod(ClassReflection $classReflection, ClassMethod $classMethod): bool { // edge case in nette framework /** @var string $methodName */ @@ -150,7 +151,7 @@ private function shouldSkipClassMethod(ClassMethod $classMethod): bool return true; } - if ($this->shouldSkipClassMethodLaravel($classMethod)) { + if ($this->shouldSkipClassMethodLaravel($classReflection, $classMethod)) { return true; } @@ -169,14 +170,8 @@ function (Node $node): bool { return $hasParentCall; } - private function shouldSkipClassMethodLaravel(ClassMethod $classMethod): bool + private function shouldSkipClassMethodLaravel(ClassReflection $classReflection, ClassMethod $classMethod): bool { - $classReflection = ScopeFetcher::fetch($classMethod)->getClassReflection(); - - if (! $classReflection instanceof ClassReflection) { - return false; - } - if (! $classReflection->is('Illuminate\Database\Eloquent\Model')) { return false; } From bd73911d937b5b946480484102011eaf7e71ddb3 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Thu, 4 Sep 2025 21:28:16 -0500 Subject: [PATCH 4/4] chore: move laravel model logic to a separate service --- .../Privatization/Guard/LaravelModelGuard.php | 79 +++++++++++++++++++ .../PrivatizeFinalClassMethodRector.php | 64 ++------------- 2 files changed, 87 insertions(+), 56 deletions(-) create mode 100644 rules/Privatization/Guard/LaravelModelGuard.php diff --git a/rules/Privatization/Guard/LaravelModelGuard.php b/rules/Privatization/Guard/LaravelModelGuard.php new file mode 100644 index 00000000000..e9e0a6b8895 --- /dev/null +++ b/rules/Privatization/Guard/LaravelModelGuard.php @@ -0,0 +1,79 @@ +is('Illuminate\Database\Eloquent\Model')) { + return false; + } + + $name = (string) $this->nodeNameResolver->getName($classMethod->name); + + return $this->isAttributeMethod($name, $classMethod) + || $this->isScopeMethod($name, $classMethod); + } + + private function isAttributeMethod(string $name, ClassMethod $classMethod): bool + { + if (StringUtils::isMatch($name, self::LARAVEL_MODEL_ATTRIBUTE_REGEX)) { + return true; + } + + if (! $classMethod->returnType instanceof Node) { + return false; + } + + return $this->nodeTypeResolver->isObjectType( + $classMethod->returnType, + new ObjectType('Illuminate\Database\Eloquent\Casts\Attribute') + ); + } + + private function isScopeMethod(string $name, ClassMethod $classMethod): bool + { + if (StringUtils::isMatch($name, self::LARAVEL_MODEL_SCOPE_REGEX)) { + return true; + } + + return $this->phpAttributeAnalyzer->hasPhpAttribute( + $classMethod, + 'Illuminate\Database\Eloquent\Attributes\Scope' + ); + } +} diff --git a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php index 1bb28d8131d..9ebbac2df15 100644 --- a/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php +++ b/rules/Privatization/Rector/ClassMethod/PrivatizeFinalClassMethodRector.php @@ -9,15 +9,13 @@ use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; use PHPStan\Reflection\ClassReflection; -use PHPStan\Type\ObjectType; -use Rector\Php80\NodeAnalyzer\PhpAttributeAnalyzer; use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PHPStan\ScopeFetcher; +use Rector\Privatization\Guard\LaravelModelGuard; use Rector\Privatization\Guard\OverrideByParentClassGuard; use Rector\Privatization\NodeManipulator\VisibilityManipulator; use Rector\Privatization\VisibilityGuard\ClassMethodVisibilityGuard; use Rector\Rector\AbstractRector; -use Rector\Util\StringUtils; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; @@ -26,24 +24,12 @@ */ final class PrivatizeFinalClassMethodRector extends AbstractRector { - /** - * @var string - * @see https://regex101.com/r/Dx0WN5/2 - */ - private const LARAVEL_MODEL_ATTRIBUTE_REGEX = '/^[gs]et.+Attribute$/'; - - /** - * @var string - * @see https://regex101.com/r/hxOGeN/2 - */ - private const LARAVEL_MODEL_SCOPE_REGEX = '/^scope.+$/'; - public function __construct( private readonly ClassMethodVisibilityGuard $classMethodVisibilityGuard, private readonly VisibilityManipulator $visibilityManipulator, private readonly OverrideByParentClassGuard $overrideByParentClassGuard, private readonly BetterNodeFinder $betterNodeFinder, - private readonly PhpAttributeAnalyzer $phpAttributeAnalyzer, + private readonly LaravelModelGuard $laravelModelGuard, ) { } @@ -105,7 +91,11 @@ public function refactor(Node $node): ?Node $hasChanged = false; foreach ($node->getMethods() as $classMethod) { - if ($this->shouldSkipClassMethod($classReflection, $classMethod)) { + if ($this->shouldSkipClassMethod($classMethod)) { + continue; + } + + if ($this->laravelModelGuard->isProtectedMethod($classReflection, $classMethod)) { continue; } @@ -134,7 +124,7 @@ public function refactor(Node $node): ?Node return null; } - private function shouldSkipClassMethod(ClassReflection $classReflection, ClassMethod $classMethod): bool + private function shouldSkipClassMethod(ClassMethod $classMethod): bool { // edge case in nette framework /** @var string $methodName */ @@ -151,10 +141,6 @@ private function shouldSkipClassMethod(ClassReflection $classReflection, ClassMe return true; } - if ($this->shouldSkipClassMethodLaravel($classReflection, $classMethod)) { - return true; - } - // if has parent call, its probably overriding parent one → skip it $hasParentCall = (bool) $this->betterNodeFinder->findFirst( (array) $classMethod->stmts, @@ -169,38 +155,4 @@ function (Node $node): bool { return $hasParentCall; } - - private function shouldSkipClassMethodLaravel(ClassReflection $classReflection, ClassMethod $classMethod): bool - { - if (! $classReflection->is('Illuminate\Database\Eloquent\Model')) { - return false; - } - - $name = (string) $this->getName($classMethod->name); - $returnType = $classMethod->returnType; - - // Model attributes should be protected - if ( - StringUtils::isMatch($name, self::LARAVEL_MODEL_ATTRIBUTE_REGEX) - || ($returnType instanceof Node && $this->isObjectType( - $returnType, - new ObjectType('Illuminate\Database\Eloquent\Casts\Attribute') - )) - ) { - return true; - } - - // Model scopes should be protected - if ( - StringUtils::isMatch($name, self::LARAVEL_MODEL_SCOPE_REGEX) - || $this->phpAttributeAnalyzer->hasPhpAttribute( - $classMethod, - 'Illuminate\Database\Eloquent\Attributes\Scope' - ) - ) { - return true; - } - - return false; - } }