From a9506874ab9f07f1cd68ce06e4a1bb6d15379b03 Mon Sep 17 00:00:00 2001 From: Ivan Vasechko Date: Tue, 30 Jun 2026 12:18:43 +0000 Subject: [PATCH 1/5] Add Laravel 13 ObservedBy observer rectors --- config/sets/laravel130-attributes.php | 4 + src/NodeAnalyzer/ObservedByAnalyzer.php | 475 ++++++++++++++++++ .../ObservedByAttributeFactory.php | 35 ++ .../RemoveModelObserveCallsFromBootRector.php | 111 ++++ ...bserveCallsToObservedByAttributeRector.php | 157 ++++++ src/ValueObject/ObservedByRegistration.php | 16 + ...rve_and_keep_other_boot_statements.php.inc | 48 ++ ...e_and_keep_other_boot_statements_model.php | 11 + ...tiple_observer_registration_styles.php.inc | 55 ++ ...le_observer_registration_styles_models.php | 15 + .../skip_unmergeable_model_attribute.php.inc | 19 + ...skip_unmergeable_model_attribute_model.php | 13 + ...oveModelObserveCallsFromBootRectorTest.php | 31 ++ .../config/configured_rule.php | 10 + ...rge_existing_observed_by_attribute.php.inc | 37 ++ ...xisting_observed_by_attribute_provider.php | 17 + .../simple_observe_registration.php.inc | 28 ++ .../simple_observe_registration_provider.php | 17 + .../Fixture/skip_nested_observe_call.php.inc | 13 + .../skip_nested_observe_call_provider.php | 19 + ...veCallsToObservedByAttributeRectorTest.php | 31 ++ .../config/configured_rule.php | 10 + .../Fixture/observed_by_attribute.php.inc | 28 ++ .../observed_by_attribute_provider.php | 17 + 24 files changed, 1217 insertions(+) create mode 100644 src/NodeAnalyzer/ObservedByAnalyzer.php create mode 100644 src/NodeFactory/ObservedByAttributeFactory.php create mode 100644 src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php create mode 100644 src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php create mode 100644 src/ValueObject/ObservedByRegistration.php create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements.php.inc create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements_model.php create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles.php.inc create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles_models.php create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute.php.inc create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute_model.php create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/RemoveModelObserveCallsFromBootRectorTest.php create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/config/configured_rule.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute_provider.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration_provider.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call_provider.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/ObserveCallsToObservedByAttributeRectorTest.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/config/configured_rule.php create mode 100644 tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc create mode 100644 tests/Sets/Laravel130/Fixture/observed_by_attribute_provider.php diff --git a/config/sets/laravel130-attributes.php b/config/sets/laravel130-attributes.php index 10edbad7..6607d938 100644 --- a/config/sets/laravel130-attributes.php +++ b/config/sets/laravel130-attributes.php @@ -22,6 +22,7 @@ use RectorLaravel\Rector\Class_\HiddenPropertyToHiddenAttributeRector; use RectorLaravel\Rector\Class_\JobConnectionPropertyToJobConnectionAttributeRector; use RectorLaravel\Rector\Class_\MaxExceptionsPropertyToMaxExceptionsAttributeRector; +use RectorLaravel\Rector\Class_\ObserveCallsToObservedByAttributeRector; use RectorLaravel\Rector\Class_\PreserveKeysPropertyToPreserveKeysAttributeRector; use RectorLaravel\Rector\Class_\QueuePropertyToQueueAttributeRector; use RectorLaravel\Rector\Class_\SignaturePropertyToSignatureAttributeRector; @@ -34,6 +35,7 @@ use RectorLaravel\Rector\Class_\VisiblePropertyToVisibleAttributeRector; use RectorLaravel\Rector\Class_\WithoutIncrementingPropertyToWithoutIncrementingAttributeRector; use RectorLaravel\Rector\Class_\WithoutTimestampsPropertyToWithoutTimestampsAttributeRector; +use RectorLaravel\Rector\ClassMethod\RemoveModelObserveCallsFromBootRector; return static function (RectorConfig $rectorConfig): void { $rectorConfig->import(__DIR__ . '/../config.php'); @@ -53,6 +55,8 @@ $rectorConfig->rule(FillablePropertyToFillableAttributeRector::class); $rectorConfig->rule(GuardedPropertyToGuardedAttributeRector::class); $rectorConfig->rule(HiddenPropertyToHiddenAttributeRector::class); + $rectorConfig->rule(ObserveCallsToObservedByAttributeRector::class); + $rectorConfig->rule(RemoveModelObserveCallsFromBootRector::class); $rectorConfig->rule(TablePropertyToTableAttributeRector::class); $rectorConfig->rule(TouchesPropertyToTouchesAttributeRector::class); $rectorConfig->rule(VisiblePropertyToVisibleAttributeRector::class); diff --git a/src/NodeAnalyzer/ObservedByAnalyzer.php b/src/NodeAnalyzer/ObservedByAnalyzer.php new file mode 100644 index 00000000..1fda2f43 --- /dev/null +++ b/src/NodeAnalyzer/ObservedByAnalyzer.php @@ -0,0 +1,475 @@ +> + */ + private array $observerClassesByModel = []; + + /** + * @var array + */ + private array $canUpdateModelCache = []; + + /** + * @var array + */ + private array $classFilePaths = []; + + private ?string $initializedProjectRoot = null; + + public function __construct( + private readonly RectorParser $rectorParser, + private readonly NodeNameResolver $nodeNameResolver, + ) {} + + public function reset(): void + { + $this->observerClassesByModel = []; + $this->canUpdateModelCache = []; + $this->classFilePaths = []; + $this->initializedProjectRoot = null; + } + + public function matchObserveStaticCall(StaticCall $staticCall): ?ObservedByRegistration + { + if (! $this->nodeNameResolver->isName($staticCall->name, 'observe')) { + return null; + } + + $modelClass = $this->nodeNameResolver->getName($staticCall->class); + if (! is_string($modelClass)) { + return null; + } + + $observerClasses = $this->resolveObserverClassesFromArgs($staticCall->args); + if ($observerClasses === null) { + return null; + } + + return new ObservedByRegistration($modelClass, $observerClasses); + } + + /** + * @return list + */ + public function resolveObserverClassesForModel(string $modelClass, string $currentFilePath): array + { + $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath)); + + return $this->observerClassesByModel[$modelClass] ?? []; + } + + /** + * @return list|null + */ + public function resolveExistingObservedByClasses(Class_ $class): ?array + { + $observerClasses = []; + $hasObservedByAttribute = false; + + foreach ($class->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if (! $this->nodeNameResolver->isName($attr->name, self::OBSERVED_BY_ATTRIBUTE)) { + continue; + } + + $hasObservedByAttribute = true; + $resolvedObserverClasses = $this->resolveObserverClassesFromAttribute($attr); + if ($resolvedObserverClasses === null) { + return null; + } + + $observerClasses = array_merge($observerClasses, $resolvedObserverClasses); + } + } + + if (! $hasObservedByAttribute) { + return []; + } + + return $this->uniqueObserverClasses($observerClasses); + } + + public function isLikelyEloquentModelClass(Class_ $class): bool + { + if (! $class->extends instanceof Node\Name) { + return false; + } + + $extendedClass = $this->nodeNameResolver->getName($class->extends); + if (! is_string($extendedClass)) { + return false; + } + + return in_array($extendedClass, [ + 'Illuminate\\Database\\Eloquent\\Model', + 'Illuminate\\Foundation\\Auth\\User', + 'Model', + 'Authenticatable', + ], true) + || str_ends_with($extendedClass, '\\Model') + || str_ends_with($extendedClass, '\\Authenticatable'); + } + + /** + * @param list $observerClasses + */ + public function canUpdateModel(string $modelClass, array $observerClasses, string $currentFilePath): bool + { + $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath)); + + $cacheKey = $modelClass . '|' . implode('|', $observerClasses); + if (array_key_exists($cacheKey, $this->canUpdateModelCache)) { + return $this->canUpdateModelCache[$cacheKey]; + } + + $fileName = $this->classFilePaths[$modelClass] ?? null; + if (! is_string($fileName)) { + return $this->canUpdateModelCache[$cacheKey] = false; + } + + $class = $this->findClassInFile($fileName, $modelClass); + if (! $class instanceof Class_) { + return $this->canUpdateModelCache[$cacheKey] = false; + } + + if (! $this->isLikelyEloquentModelClass($class)) { + return $this->canUpdateModelCache[$cacheKey] = false; + } + + $existingObservedByClasses = $this->resolveExistingObservedByClasses($class); + if ($existingObservedByClasses === null) { + return $this->canUpdateModelCache[$cacheKey] = false; + } + + return $this->canUpdateModelCache[$cacheKey] = true; + } + + /** + * @param Arg[] $args + * @return list|null + */ + private function resolveObserverClassesFromArgs(array $args): ?array + { + if (count($args) !== 1) { + return null; + } + + if ($args[0]->name !== null) { + return null; + } + + return $this->resolveObserverClassesFromExpr($args[0]->value); + } + + /** + * @return list|null + */ + private function resolveObserverClassesFromAttribute(Attribute $attribute): ?array + { + if (count($attribute->args) !== 1) { + return null; + } + + if ($attribute->args[0]->name !== null) { + return null; + } + + return $this->resolveObserverClassesFromExpr($attribute->args[0]->value); + } + + /** + * @return list|null + */ + private function resolveObserverClassesFromExpr(Expr $expr): ?array + { + if ($expr instanceof ClassConstFetch) { + if (! $this->nodeNameResolver->isName($expr->name, 'class')) { + return null; + } + + $observerClass = $this->nodeNameResolver->getName($expr->class); + if (! is_string($observerClass)) { + return null; + } + + return [$observerClass]; + } + + if (! $expr instanceof Array_) { + return null; + } + + $observerClasses = []; + + foreach ($expr->items as $item) { + if ($item === null) { + return null; + } + + if ($item->key !== null) { + return null; + } + + $itemValue = $item->value; + if (! $itemValue instanceof ClassConstFetch) { + return null; + } + + if (! $this->nodeNameResolver->isName($itemValue->name, 'class')) { + return null; + } + + $observerClass = $this->nodeNameResolver->getName($itemValue->class); + if (! is_string($observerClass)) { + return null; + } + + $observerClasses[] = $observerClass; + } + + if ($observerClasses === []) { + return null; + } + + return $this->uniqueObserverClasses($observerClasses); + } + + private function initializeFromProjectRoot(string $projectRoot): void + { + if ($this->initializedProjectRoot === $projectRoot) { + return; + } + + $this->observerClassesByModel = []; + $this->canUpdateModelCache = []; + $this->classFilePaths = []; + $this->initializedProjectRoot = $projectRoot; + + foreach ($this->findPhpFiles($projectRoot) as $filePath) { + $this->collectObserveRegistrationsFromFile($filePath); + } + } + + private function collectObserveRegistrationsFromFile(string $filePath): void + { + try { + $stmts = $this->rectorParser->parseFile($filePath); + } catch (Throwable) { + return; + } + + if ($stmts === []) { + return; + } + + $resolvedStmts = $this->resolveNames($stmts); + + SimpleCallableNodeTraverser::traverseNodesWithCallable($resolvedStmts, function (Node $node) use ($filePath): null { + if ($node instanceof Class_) { + $className = $this->resolveClassName($node); + if (is_string($className) && ! array_key_exists($className, $this->classFilePaths)) { + $this->classFilePaths[$className] = $filePath; + } + + return null; + } + + if (! $node instanceof ClassMethod) { + return null; + } + + if (! $this->nodeNameResolver->isName($node->name, 'boot')) { + return null; + } + + foreach ((array) $node->stmts as $stmt) { + if (! $stmt instanceof Expression) { + continue; + } + + if (! $stmt->expr instanceof StaticCall) { + continue; + } + + /** @var StaticCall $staticCall */ + $staticCall = $stmt->expr; + $observedByRegistration = $this->matchObserveStaticCall($staticCall); + if (! $observedByRegistration instanceof ObservedByRegistration) { + continue; + } + + $existingObserverClasses = $this->observerClassesByModel[$observedByRegistration->modelClass] ?? []; + $this->observerClassesByModel[$observedByRegistration->modelClass] = $this->uniqueObserverClasses([ + ...$existingObserverClasses, + ...$observedByRegistration->observerClasses, + ]); + } + + return null; + }); + } + + /** + * @param array $stmts + * @return array + */ + private function resolveNames(array $stmts): array + { + $nameResolver = new NameResolver(null, [ + 'replaceNodes' => false, + 'preserveOriginalNames' => true, + ]); + + $nodeTraverser = new NodeTraverser; + $nodeTraverser->addVisitor($nameResolver); + + /** @var array $resolvedStmts */ + $resolvedStmts = $nodeTraverser->traverse($stmts); + + return $resolvedStmts; + } + + private function resolveProjectRoot(string $currentFilePath): string + { + $directory = is_dir($currentFilePath) ? $currentFilePath : dirname($currentFilePath); + + while ($directory !== dirname($directory)) { + if (is_file($directory . '/composer.json')) { + return $directory; + } + + $directory = dirname($directory); + } + + return dirname($currentFilePath); + } + + private function findClassInFile(string $filePath, string $className): ?Class_ + { + try { + $stmts = $this->rectorParser->parseFile($filePath); + } catch (Throwable) { + return null; + } + + if ($stmts === []) { + return null; + } + + $resolvedStmts = $this->resolveNames($stmts); + $foundClass = null; + + SimpleCallableNodeTraverser::traverseNodesWithCallable($resolvedStmts, function (Node $node) use ($className, &$foundClass): null { + if (! $node instanceof Class_) { + return null; + } + + if ($this->resolveClassName($node) !== $className) { + return null; + } + + $foundClass = $node; + + return null; + }); + + return $foundClass; + } + + /** + * @return iterable + */ + private function findPhpFiles(string $projectRoot): iterable + { + $directories = [$projectRoot]; + + while ($directories !== []) { + $directory = array_pop($directories); + if (! is_string($directory)) { + continue; + } + + $items = scandir($directory); + if ($items === false) { + continue; + } + + sort($items); + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $path = $directory . '/' . $item; + + if (is_dir($path)) { + if (in_array($item, ['.git', '.idea', '.vscode', 'build', 'vendor'], true)) { + continue; + } + + $directories[] = $path; + continue; + } + + if (! str_ends_with($path, '.php')) { + continue; + } + + yield $path; + } + } + } + + /** + * @param list $observerClasses + * @return list + */ + private function uniqueObserverClasses(array $observerClasses): array + { + return array_values(array_unique($observerClasses)); + } + + private function resolveClassName(Class_ $class): ?string + { + if (property_exists($class, 'namespacedName') && $class->namespacedName instanceof Name) { + return $class->namespacedName->toString(); + } + + if ($class->name === null) { + return null; + } + + return $class->name->toString(); + } +} diff --git a/src/NodeFactory/ObservedByAttributeFactory.php b/src/NodeFactory/ObservedByAttributeFactory.php new file mode 100644 index 00000000..6cfc7e78 --- /dev/null +++ b/src/NodeFactory/ObservedByAttributeFactory.php @@ -0,0 +1,35 @@ + $observerClasses + */ + public function create(array $observerClasses): AttributeGroup + { + $items = []; + + foreach ($observerClasses as $observerClass) { + $items[] = new ArrayItem(new ClassConstFetch(new FullyQualified($observerClass), 'class')); + } + + return new AttributeGroup([ + new Attribute( + new FullyQualified('Illuminate\\Database\\Eloquent\\Attributes\\ObservedBy'), + [new Arg(new Array_($items))] + ), + ]); + } +} diff --git a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php new file mode 100644 index 00000000..f32086d5 --- /dev/null +++ b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php @@ -0,0 +1,111 @@ +bootSomethingElse(); + } +} +CODE_SAMPLE, + <<<'CODE_SAMPLE' +use App\Models\User; +use App\Observers\UserObserver; + +class AppServiceProvider +{ + public function boot(): void + { + $this->bootSomethingElse(); + } +} +CODE_SAMPLE + )] + ); + } + + /** + * @return array> + */ + public function getNodeTypes(): array + { + return [ClassMethod::class]; + } + + /** + * @param ClassMethod $node + */ + public function refactor(Node $node): ?Node + { + if (! $this->isName($node->name, 'boot')) { + return null; + } + + $hasChanged = false; + + foreach ((array) $node->stmts as $key => $stmt) { + if (! $stmt instanceof Expression) { + continue; + } + + if (! $stmt->expr instanceof StaticCall) { + continue; + } + + /** @var StaticCall $staticCall */ + $staticCall = $stmt->expr; + + $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($staticCall); + if (! $observedByRegistration instanceof ObservedByRegistration) { + continue; + } + + if (! $this->observedByAnalyzer->canUpdateModel($observedByRegistration->modelClass, $observedByRegistration->observerClasses, $this->getFile()->getFilePath())) { + continue; + } + + unset($node->stmts[$key]); + $hasChanged = true; + } + + if (! $hasChanged) { + return null; + } + + $node->stmts = array_values((array) $node->stmts); + + return $node; + } +} diff --git a/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php new file mode 100644 index 00000000..020195f6 --- /dev/null +++ b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php @@ -0,0 +1,157 @@ +> + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Node + { + $className = $this->resolveClassName($node); + if (! is_string($className)) { + return null; + } + + $observerClassesFromObserveCalls = $this->observedByAnalyzer->resolveObserverClassesForModel($className, $this->getFile()->getFilePath()); + if ($observerClassesFromObserveCalls === []) { + return null; + } + + if (! $this->observedByAnalyzer->isLikelyEloquentModelClass($node)) { + return null; + } + + $existingObservedByClasses = $this->observedByAnalyzer->resolveExistingObservedByClasses($node); + if ($existingObservedByClasses === null) { + return null; + } + + $mergedObserverClasses = array_values(array_unique([ + ...$existingObservedByClasses, + ...$observerClassesFromObserveCalls, + ])); + + if ($mergedObserverClasses === $existingObservedByClasses) { + return null; + } + + $observedByAttributeGroup = $this->observedByAttributeFactory->create($mergedObserverClasses); + $matchingAttributeGroupKeys = []; + + foreach ($node->attrGroups as $key => $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($this->isName($attr->name, 'Illuminate\\Database\\Eloquent\\Attributes\\ObservedBy')) { + $matchingAttributeGroupKeys[] = $key; + break; + } + } + } + + if ($matchingAttributeGroupKeys === []) { + $node->attrGroups[] = $observedByAttributeGroup; + + return $node; + } + + $firstMatchingAttributeGroupKey = array_shift($matchingAttributeGroupKeys); + if (! is_int($firstMatchingAttributeGroupKey)) { + return null; + } + + $node->attrGroups[$firstMatchingAttributeGroupKey] = $observedByAttributeGroup; + + foreach ($matchingAttributeGroupKeys as $matchingAttributeGroupKey) { + unset($node->attrGroups[$matchingAttributeGroupKey]); + } + + $node->attrGroups = array_values($node->attrGroups); + + return $node; + } + + private function resolveClassName(Class_ $class): ?string + { + if (property_exists($class, 'namespacedName') && $class->namespacedName instanceof Name) { + return $class->namespacedName->toString(); + } + + if ($class->name === null) { + return null; + } + + return $class->name->toString(); + } +} diff --git a/src/ValueObject/ObservedByRegistration.php b/src/ValueObject/ObservedByRegistration.php new file mode 100644 index 00000000..6212247a --- /dev/null +++ b/src/ValueObject/ObservedByRegistration.php @@ -0,0 +1,16 @@ + $observerClasses + */ + public function __construct( + public string $modelClass, + public array $observerClasses, + ) {} +} diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements.php.inc new file mode 100644 index 00000000..36ffb8f7 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements.php.inc @@ -0,0 +1,48 @@ +bootSomethingElse(); + } + + private function bootSomethingElse(): void + { + } +} + +final class UserObserver +{ +} + +?> +----- +bootSomethingElse(); + } + + private function bootSomethingElse(): void + { + } +} + +final class UserObserver +{ +} + +?> diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements_model.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements_model.php new file mode 100644 index 00000000..ca264a8a --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_and_keep_other_boot_statements_model.php @@ -0,0 +1,11 @@ + +----- + diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles_models.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles_models.php new file mode 100644 index 00000000..61f5cb56 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles_models.php @@ -0,0 +1,15 @@ + diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute_model.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute_model.php new file mode 100644 index 00000000..84f0d0ab --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute_model.php @@ -0,0 +1,13 @@ +doTestFile($filePath, true); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/config/configured_rule.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/config/configured_rule.php new file mode 100644 index 00000000..4d9e1a71 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/config/configured_rule.php @@ -0,0 +1,10 @@ +rule(RemoveModelObserveCallsFromBootRector::class); +}; diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute.php.inc new file mode 100644 index 00000000..5582aa3a --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute.php.inc @@ -0,0 +1,37 @@ + +----- + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute_provider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute_provider.php new file mode 100644 index 00000000..cb215a9a --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/merge_existing_observed_by_attribute_provider.php @@ -0,0 +1,17 @@ + +----- + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration_provider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration_provider.php new file mode 100644 index 00000000..f9523e20 --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration_provider.php @@ -0,0 +1,17 @@ + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call_provider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call_provider.php new file mode 100644 index 00000000..acdb1336 --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call_provider.php @@ -0,0 +1,19 @@ +doTestFile($filePath, true); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/config/configured_rule.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/config/configured_rule.php new file mode 100644 index 00000000..a81cadd3 --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/config/configured_rule.php @@ -0,0 +1,10 @@ +rule(ObserveCallsToObservedByAttributeRector::class); +}; diff --git a/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc b/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc new file mode 100644 index 00000000..46b5b9da --- /dev/null +++ b/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc @@ -0,0 +1,28 @@ + +----- + diff --git a/tests/Sets/Laravel130/Fixture/observed_by_attribute_provider.php b/tests/Sets/Laravel130/Fixture/observed_by_attribute_provider.php new file mode 100644 index 00000000..31a0f090 --- /dev/null +++ b/tests/Sets/Laravel130/Fixture/observed_by_attribute_provider.php @@ -0,0 +1,17 @@ + Date: Tue, 30 Jun 2026 12:21:20 +0000 Subject: [PATCH 2/5] Add ObservedBy docs and extra safety fixtures --- docs/rector_rules_overview.md | 43 ++++++++++++++++++- ...del_already_has_matching_attribute.php.inc | 38 ++++++++++++++++ ...l_already_has_matching_attribute_model.php | 12 ++++++ ...xisting_same_observed_by_attribute.php.inc | 14 ++++++ ...ng_same_observed_by_attribute_provider.php | 17 ++++++++ .../Fixture/skip_not_a_model.php.inc | 11 +++++ .../Fixture/skip_not_a_model_provider.php | 17 ++++++++ 7 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute_model.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute_provider.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model_provider.php diff --git a/docs/rector_rules_overview.md b/docs/rector_rules_overview.md index cb0f5d96..dd71f73a 100644 --- a/docs/rector_rules_overview.md +++ b/docs/rector_rules_overview.md @@ -1,4 +1,4 @@ -# 106 Rules Overview +# 108 Rules Overview ## AbortIfRector @@ -1284,6 +1284,25 @@ Swap the use of NotBooleans used with `filled()` and `blank()` to the correct he
+## ObserveCallsToObservedByAttributeRector + +Changes manual model `observe()` registrations in boot methods to use the `ObservedBy` attribute + +- class: [`RectorLaravel\Rector\Class_\ObserveCallsToObservedByAttributeRector`](../src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php) + +```diff + use App\Observers\UserObserver; ++use Illuminate\Database\Eloquent\Attributes\ObservedBy; + use Illuminate\Foundation\Auth\User as Authenticatable; + ++#[ObservedBy([UserObserver::class])] + class User extends Authenticatable + { + } +``` + +
+ ## NowFuncWithStartOfDayMethodCallToTodayFuncRector Use `today()` instead of `now()->startOfDay()` @@ -1357,6 +1376,28 @@ Changes the queue property to use the Queue attribute
+## RemoveModelObserveCallsFromBootRector + +Removes direct model `observe()` registrations from boot methods when they can be represented by `ObservedBy` + +- class: [`RectorLaravel\Rector\ClassMethod\RemoveModelObserveCallsFromBootRector`](../src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php) + +```diff + use App\Models\User; + use App\Observers\UserObserver; + + class AppServiceProvider + { + public function boot(): void + { +- User::observe(UserObserver::class); + $this->bootSomethingElse(); + } + } +``` + +
+ ## Redirect301ToPermanentRedirectRector Change "redirect" call with 301 to "permanentRedirect" diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc new file mode 100644 index 00000000..6fe38b7f --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc @@ -0,0 +1,38 @@ + +----- + diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute_model.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute_model.php new file mode 100644 index 00000000..cabee501 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute_model.php @@ -0,0 +1,12 @@ + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute_provider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute_provider.php new file mode 100644 index 00000000..1d2965cb --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute_provider.php @@ -0,0 +1,17 @@ + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model_provider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model_provider.php new file mode 100644 index 00000000..211fdcaf --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model_provider.php @@ -0,0 +1,17 @@ + Date: Tue, 30 Jun 2026 12:24:48 +0000 Subject: [PATCH 3/5] Remove empty boot methods after observer cleanup --- .../RemoveModelObserveCallsFromBootRector.php | 7 +++- ...del_already_has_matching_attribute.php.inc | 3 -- ...empty_boot_with_override_attribute.php.inc | 36 +++++++++++++++++++ ...pty_boot_with_override_attribute_model.php | 11 ++++++ ...tiple_observer_registration_styles.php.inc | 3 -- 5 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute.php.inc create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute_model.php diff --git a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php index f32086d5..310b6d15 100644 --- a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php +++ b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php @@ -8,6 +8,7 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Expression; +use PhpParser\NodeVisitor; use RectorLaravel\AbstractRector; use RectorLaravel\NodeAnalyzer\ObservedByAnalyzer; use RectorLaravel\Tests\Rector\ClassMethod\RemoveModelObserveCallsFromBootRector\RemoveModelObserveCallsFromBootRectorTest; @@ -67,7 +68,7 @@ public function getNodeTypes(): array /** * @param ClassMethod $node */ - public function refactor(Node $node): ?Node + public function refactor(Node $node): Node|int|null { if (! $this->isName($node->name, 'boot')) { return null; @@ -106,6 +107,10 @@ public function refactor(Node $node): ?Node $node->stmts = array_values((array) $node->stmts); + if ($node->stmts === []) { + return NodeVisitor::REMOVE_NODE; + } + return $node; } } diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc index 6fe38b7f..8ea08b29 100644 --- a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc @@ -26,9 +26,6 @@ namespace RectorLaravel\Tests\Rector\ClassMethod\RemoveModelObserveCallsFromBoot final class AppServiceProvider { - public function boot(): void - { - } } final class UserObserver diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute.php.inc new file mode 100644 index 00000000..8d0e02ab --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute.php.inc @@ -0,0 +1,36 @@ + +----- + diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute_model.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute_model.php new file mode 100644 index 00000000..f34b4bc7 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_empty_boot_with_override_attribute_model.php @@ -0,0 +1,11 @@ + Date: Tue, 30 Jun 2026 12:50:55 +0000 Subject: [PATCH 4/5] Support booted static observer migration --- src/NodeAnalyzer/ObservedByAnalyzer.php | 179 +++++++++++------- .../RemoveModelObserveCallsFromBootRector.php | 6 +- ...remove_model_booted_static_observe.php.inc | 40 ++++ .../model_booted_static_observe.php.inc | 44 +++++ .../Fixture/observed_by_attribute.php.inc | 4 + 5 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_model_booted_static_observe.php.inc create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/model_booted_static_observe.php.inc diff --git a/src/NodeAnalyzer/ObservedByAnalyzer.php b/src/NodeAnalyzer/ObservedByAnalyzer.php index 1fda2f43..de09fffc 100644 --- a/src/NodeAnalyzer/ObservedByAnalyzer.php +++ b/src/NodeAnalyzer/ObservedByAnalyzer.php @@ -18,7 +18,10 @@ use PhpParser\Node\Stmt\Namespace_; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; +use Rector\Configuration\Option; +use Rector\Configuration\Parameter\SimpleParameterProvider; use Rector\Contract\DependencyInjection\ResettableInterface; +use Rector\FileSystem\FilesFinder; use Rector\NodeNameResolver\NodeNameResolver; use Rector\PhpDocParser\NodeTraverser\SimpleCallableNodeTraverser; use Rector\PhpParser\Parser\RectorParser; @@ -44,11 +47,17 @@ final class ObservedByAnalyzer implements ResettableInterface */ private array $classFilePaths = []; + /** + * @var array + */ + private array $indexedFilePaths = []; + private ?string $initializedProjectRoot = null; public function __construct( private readonly RectorParser $rectorParser, private readonly NodeNameResolver $nodeNameResolver, + private readonly FilesFinder $filesFinder, ) {} public function reset(): void @@ -56,16 +65,17 @@ public function reset(): void $this->observerClassesByModel = []; $this->canUpdateModelCache = []; $this->classFilePaths = []; + $this->indexedFilePaths = []; $this->initializedProjectRoot = null; } - public function matchObserveStaticCall(StaticCall $staticCall): ?ObservedByRegistration + public function matchObserveStaticCall(StaticCall $staticCall, ?string $currentClassName = null): ?ObservedByRegistration { if (! $this->nodeNameResolver->isName($staticCall->name, 'observe')) { return null; } - $modelClass = $this->nodeNameResolver->getName($staticCall->class); + $modelClass = $this->resolveObservedModelClass($staticCall, $currentClassName); if (! is_string($modelClass)) { return null; } @@ -78,12 +88,39 @@ public function matchObserveStaticCall(StaticCall $staticCall): ?ObservedByRegis return new ObservedByRegistration($modelClass, $observerClasses); } + public function resolveCurrentClassName(Node $node): ?string + { + $parentNode = $node->getAttribute('parent'); + while ($parentNode instanceof Node) { + if ($parentNode instanceof Class_) { + return $this->resolveClassName($parentNode); + } + + $parentNode = $parentNode->getAttribute('parent'); + } + + $scope = $node->getAttribute('scope'); + if (! is_object($scope) || ! method_exists($scope, 'getClassReflection')) { + return null; + } + + /** @var object{getClassReflection: callable(): mixed} $scope */ + $classReflection = $scope->getClassReflection(); + if (! is_object($classReflection) || ! method_exists($classReflection, 'getName')) { + return null; + } + + $className = $classReflection->getName(); + + return is_string($className) ? $className : null; + } + /** * @return list */ public function resolveObserverClassesForModel(string $modelClass, string $currentFilePath): array { - $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath)); + $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath), $currentFilePath); return $this->observerClassesByModel[$modelClass] ?? []; } @@ -145,7 +182,7 @@ public function isLikelyEloquentModelClass(Class_ $class): bool */ public function canUpdateModel(string $modelClass, array $observerClasses, string $currentFilePath): bool { - $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath)); + $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath), $currentFilePath); $cacheKey = $modelClass . '|' . implode('|', $observerClasses); if (array_key_exists($cacheKey, $this->canUpdateModelCache)) { @@ -264,24 +301,31 @@ private function resolveObserverClassesFromExpr(Expr $expr): ?array return $this->uniqueObserverClasses($observerClasses); } - private function initializeFromProjectRoot(string $projectRoot): void + private function initializeFromProjectRoot(string $projectRoot, string $currentFilePath): void { if ($this->initializedProjectRoot === $projectRoot) { + if (! isset($this->indexedFilePaths[$currentFilePath]) && is_file($currentFilePath)) { + $this->collectObserveRegistrationsFromFile($currentFilePath); + } + return; } $this->observerClassesByModel = []; $this->canUpdateModelCache = []; $this->classFilePaths = []; + $this->indexedFilePaths = []; $this->initializedProjectRoot = $projectRoot; - foreach ($this->findPhpFiles($projectRoot) as $filePath) { + foreach ($this->resolveProjectFiles($projectRoot, $currentFilePath) as $filePath) { $this->collectObserveRegistrationsFromFile($filePath); } } private function collectObserveRegistrationsFromFile(string $filePath): void { + $this->indexedFilePaths[$filePath] = true; + try { $stmts = $this->rectorParser->parseFile($filePath); } catch (Throwable) { @@ -295,44 +339,46 @@ private function collectObserveRegistrationsFromFile(string $filePath): void $resolvedStmts = $this->resolveNames($stmts); SimpleCallableNodeTraverser::traverseNodesWithCallable($resolvedStmts, function (Node $node) use ($filePath): null { - if ($node instanceof Class_) { - $className = $this->resolveClassName($node); - if (is_string($className) && ! array_key_exists($className, $this->classFilePaths)) { - $this->classFilePaths[$className] = $filePath; - } - + if (! $node instanceof Class_) { return null; } - if (! $node instanceof ClassMethod) { + $className = $this->resolveClassName($node); + if (! is_string($className)) { return null; } - if (! $this->nodeNameResolver->isName($node->name, 'boot')) { - return null; + if (! array_key_exists($className, $this->classFilePaths)) { + $this->classFilePaths[$className] = $filePath; } - foreach ((array) $node->stmts as $stmt) { - if (! $stmt instanceof Expression) { + foreach ($node->getMethods() as $classMethod) { + if (! $this->isObserverRegistrationMethod($classMethod)) { continue; } - if (! $stmt->expr instanceof StaticCall) { - continue; - } + foreach ((array) $classMethod->stmts as $stmt) { + if (! $stmt instanceof Expression) { + continue; + } - /** @var StaticCall $staticCall */ - $staticCall = $stmt->expr; - $observedByRegistration = $this->matchObserveStaticCall($staticCall); - if (! $observedByRegistration instanceof ObservedByRegistration) { - continue; - } + if (! $stmt->expr instanceof StaticCall) { + continue; + } - $existingObserverClasses = $this->observerClassesByModel[$observedByRegistration->modelClass] ?? []; - $this->observerClassesByModel[$observedByRegistration->modelClass] = $this->uniqueObserverClasses([ - ...$existingObserverClasses, - ...$observedByRegistration->observerClasses, - ]); + /** @var StaticCall $staticCall */ + $staticCall = $stmt->expr; + $observedByRegistration = $this->matchObserveStaticCall($staticCall, $className); + if (! $observedByRegistration instanceof ObservedByRegistration) { + continue; + } + + $existingObserverClasses = $this->observerClassesByModel[$observedByRegistration->modelClass] ?? []; + $this->observerClassesByModel[$observedByRegistration->modelClass] = $this->uniqueObserverClasses([ + ...$existingObserverClasses, + ...$observedByRegistration->observerClasses, + ]); + } } return null; @@ -407,48 +453,34 @@ private function findClassInFile(string $filePath, string $className): ?Class_ } /** - * @return iterable + * @return list */ - private function findPhpFiles(string $projectRoot): iterable + private function resolveProjectFiles(string $projectRoot, string $currentFilePath): array { - $directories = [$projectRoot]; + $configuredPaths = array_filter( + SimpleParameterProvider::provideArrayParameter(Option::PATHS), + static fn (mixed $path): bool => is_string($path) + ); - while ($directories !== []) { - $directory = array_pop($directories); - if (! is_string($directory)) { - continue; - } + $projectPaths = []; - $items = scandir($directory); - if ($items === false) { - continue; + foreach ($configuredPaths as $configuredPath) { + if ($configuredPath === $projectRoot || str_starts_with($configuredPath, $projectRoot . '/')) { + $projectPaths[] = $configuredPath; } + } - sort($items); - - foreach ($items as $item) { - if ($item === '.' || $item === '..') { - continue; - } - - $path = $directory . '/' . $item; - - if (is_dir($path)) { - if (in_array($item, ['.git', '.idea', '.vscode', 'build', 'vendor'], true)) { - continue; - } + if ($projectPaths === []) { + $projectPaths = [$projectRoot]; + } - $directories[] = $path; - continue; - } + $filePaths = $this->filesFinder->findInDirectoriesAndFiles($projectPaths, ['php']); - if (! str_ends_with($path, '.php')) { - continue; - } - - yield $path; - } + if (is_file($currentFilePath)) { + $filePaths[] = $currentFilePath; } + + return array_values(array_unique($filePaths)); } /** @@ -460,6 +492,25 @@ private function uniqueObserverClasses(array $observerClasses): array return array_values(array_unique($observerClasses)); } + private function isObserverRegistrationMethod(ClassMethod $classMethod): bool + { + return $this->nodeNameResolver->isNames($classMethod->name, ['boot', 'booted']); + } + + private function resolveObservedModelClass(StaticCall $staticCall, ?string $currentClassName): ?string + { + if ($currentClassName !== null && $staticCall->class instanceof Name && $this->nodeNameResolver->isNames($staticCall->class, ['self', 'static'])) { + return $currentClassName; + } + + $modelClass = $this->nodeNameResolver->getName($staticCall->class); + if (! is_string($modelClass)) { + return null; + } + + return $modelClass; + } + private function resolveClassName(Class_ $class): ?string { if (property_exists($class, 'namespacedName') && $class->namespacedName instanceof Name) { diff --git a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php index 310b6d15..1f7fec98 100644 --- a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php +++ b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php @@ -70,10 +70,12 @@ public function getNodeTypes(): array */ public function refactor(Node $node): Node|int|null { - if (! $this->isName($node->name, 'boot')) { + if (! $this->isNames($node->name, ['boot', 'booted'])) { return null; } + $currentClassName = $this->observedByAnalyzer->resolveCurrentClassName($node); + $hasChanged = false; foreach ((array) $node->stmts as $key => $stmt) { @@ -88,7 +90,7 @@ public function refactor(Node $node): Node|int|null /** @var StaticCall $staticCall */ $staticCall = $stmt->expr; - $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($staticCall); + $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($staticCall, $currentClassName); if (! $observedByRegistration instanceof ObservedByRegistration) { continue; } diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_model_booted_static_observe.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_model_booted_static_observe.php.inc new file mode 100644 index 00000000..d02ee5c6 --- /dev/null +++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_model_booted_static_observe.php.inc @@ -0,0 +1,40 @@ + +----- + diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/model_booted_static_observe.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/model_booted_static_observe.php.inc new file mode 100644 index 00000000..b51fc98a --- /dev/null +++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/model_booted_static_observe.php.inc @@ -0,0 +1,44 @@ + +----- + diff --git a/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc b/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc index 46b5b9da..765ac79c 100644 --- a/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc +++ b/tests/Sets/Laravel130/Fixture/observed_by_attribute.php.inc @@ -8,6 +8,10 @@ use Illuminate\Foundation\Auth\User as Authenticatable; class User extends Authenticatable { + public static function booted(): void + { + static::observe(UserObserver::class); + } } ?> From 6caa85e5bece667f6f66d81f048dc57f6c44e101 Mon Sep 17 00:00:00 2001 From: Ivan Vasechko Date: Tue, 30 Jun 2026 13:21:30 +0000 Subject: [PATCH 5/5] Polish ObservedBy observer migration --- src/NodeAnalyzer/ObservedByAnalyzer.php | 59 ++++++++++--------- .../ObservedByAttributeFactory.php | 4 +- .../RemoveModelObserveCallsFromBootRector.php | 8 +-- ...bserveCallsToObservedByAttributeRector.php | 5 +- src/ValueObject/ObservedByRegistration.php | 2 +- .../Eloquent/Attributes/ObservedBy.php | 20 +++++++ stubs/Illuminate/Database/Eloquent/Model.php | 2 + stubs/Illuminate/Foundation/Auth/User.php | 11 ++++ .../User.php} | 0 .../User.php} | 0 .../UserObserver.php | 9 +++ .../User.php} | 0 .../Post.php} | 4 -- .../User.php | 11 ++++ .../User.php} | 0 .../UserObserver.php | 9 +++ .../AppServiceProvider.php} | 4 -- .../MergeExistingObservedByAttribute/User.php | 11 ++++ .../UserObserver.php | 9 +++ .../AppServiceProvider.php} | 4 -- .../SimpleObserveRegistration/User.php | 11 ++++ .../UserObserver.php | 9 +++ .../AppServiceProvider.php} | 6 +- .../User.php | 11 ++++ .../UserObserver.php | 9 +++ .../AppServiceProvider.php} | 6 +- .../Fixture/SkipNestedObserveCall/User.php | 11 ++++ .../SkipNestedObserveCall/UserObserver.php | 9 +++ .../AppServiceProvider.php} | 4 -- .../Fixture/SkipNotAModel/User.php | 10 ++++ .../Fixture/SkipNotAModel/UserObserver.php | 9 +++ .../AppServiceProvider.php} | 4 -- .../Fixture/ObservedByAttribute/User.php | 11 ++++ .../ObservedByAttribute/UserObserver.php | 9 +++ 34 files changed, 223 insertions(+), 68 deletions(-) create mode 100644 stubs/Illuminate/Database/Eloquent/Attributes/ObservedBy.php create mode 100644 stubs/Illuminate/Foundation/Auth/User.php rename tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/{remove_direct_observe_and_keep_other_boot_statements_model.php => RemoveDirectObserveAndKeepOtherBootStatements/User.php} (100%) rename tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/{remove_direct_observe_when_model_already_has_matching_attribute_model.php => RemoveDirectObserveWhenModelAlreadyHasMatchingAttribute/User.php} (100%) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/RemoveDirectObserveWhenModelAlreadyHasMatchingAttribute/UserObserver.php rename tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/{remove_empty_boot_with_override_attribute_model.php => RemoveEmptyBootWithOverrideAttribute/User.php} (100%) rename tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/{remove_multiple_observer_registration_styles_models.php => RemoveMultipleObserverRegistrationStyles/Post.php} (88%) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/RemoveMultipleObserverRegistrationStyles/User.php rename tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/{skip_unmergeable_model_attribute_model.php => SkipUnmergeableModelAttribute/User.php} (100%) create mode 100644 tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/SkipUnmergeableModelAttribute/UserObserver.php rename tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/{merge_existing_observed_by_attribute_provider.php => MergeExistingObservedByAttribute/AppServiceProvider.php} (90%) create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/MergeExistingObservedByAttribute/User.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/MergeExistingObservedByAttribute/UserObserver.php rename tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/{simple_observe_registration_provider.php => SimpleObserveRegistration/AppServiceProvider.php} (90%) create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SimpleObserveRegistration/User.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SimpleObserveRegistration/UserObserver.php rename tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/{skip_existing_same_observed_by_attribute_provider.php => SkipExistingSameObservedByAttribute/AppServiceProvider.php} (90%) create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipExistingSameObservedByAttribute/User.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipExistingSameObservedByAttribute/UserObserver.php rename tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/{skip_nested_observe_call_provider.php => SkipNestedObserveCall/AppServiceProvider.php} (85%) create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipNestedObserveCall/User.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipNestedObserveCall/UserObserver.php rename tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/{skip_not_a_model_provider.php => SkipNotAModel/AppServiceProvider.php} (89%) create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipNotAModel/User.php create mode 100644 tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/SkipNotAModel/UserObserver.php rename tests/Sets/Laravel130/Fixture/{observed_by_attribute_provider.php => ObservedByAttribute/AppServiceProvider.php} (88%) create mode 100644 tests/Sets/Laravel130/Fixture/ObservedByAttribute/User.php create mode 100644 tests/Sets/Laravel130/Fixture/ObservedByAttribute/UserObserver.php diff --git a/src/NodeAnalyzer/ObservedByAnalyzer.php b/src/NodeAnalyzer/ObservedByAnalyzer.php index de09fffc..d2417dc6 100644 --- a/src/NodeAnalyzer/ObservedByAnalyzer.php +++ b/src/NodeAnalyzer/ObservedByAnalyzer.php @@ -11,11 +11,12 @@ use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\StaticCall; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Expression; -use PhpParser\Node\Stmt\Namespace_; +use PhpParser\Node\VariadicPlaceholder; use PhpParser\NodeTraverser; use PhpParser\NodeVisitor\NameResolver; use Rector\Configuration\Option; @@ -33,7 +34,7 @@ final class ObservedByAnalyzer implements ResettableInterface private const string OBSERVED_BY_ATTRIBUTE = 'Illuminate\\Database\\Eloquent\\Attributes\\ObservedBy'; /** - * @var array> + * @var array> */ private array $observerClassesByModel = []; @@ -43,7 +44,7 @@ final class ObservedByAnalyzer implements ResettableInterface private array $canUpdateModelCache = []; /** - * @var array + * @var array */ private array $classFilePaths = []; @@ -104,19 +105,18 @@ public function resolveCurrentClassName(Node $node): ?string return null; } - /** @var object{getClassReflection: callable(): mixed} $scope */ - $classReflection = $scope->getClassReflection(); + $classReflection = call_user_func([$scope, 'getClassReflection']); if (! is_object($classReflection) || ! method_exists($classReflection, 'getName')) { return null; } - $className = $classReflection->getName(); + $className = call_user_func([$classReflection, 'getName']); return is_string($className) ? $className : null; } /** - * @return list + * @return list */ public function resolveObserverClassesForModel(string $modelClass, string $currentFilePath): array { @@ -126,7 +126,7 @@ public function resolveObserverClassesForModel(string $modelClass, string $curre } /** - * @return list|null + * @return list|null */ public function resolveExistingObservedByClasses(Class_ $class): ?array { @@ -158,7 +158,7 @@ public function resolveExistingObservedByClasses(Class_ $class): ?array public function isLikelyEloquentModelClass(Class_ $class): bool { - if (! $class->extends instanceof Node\Name) { + if (! $class->extends instanceof Name) { return false; } @@ -178,7 +178,7 @@ public function isLikelyEloquentModelClass(Class_ $class): bool } /** - * @param list $observerClasses + * @param list $observerClasses */ public function canUpdateModel(string $modelClass, array $observerClasses, string $currentFilePath): bool { @@ -212,8 +212,8 @@ public function canUpdateModel(string $modelClass, array $observerClasses, strin } /** - * @param Arg[] $args - * @return list|null + * @param array $args + * @return list|null */ private function resolveObserverClassesFromArgs(array $args): ?array { @@ -221,7 +221,11 @@ private function resolveObserverClassesFromArgs(array $args): ?array return null; } - if ($args[0]->name !== null) { + if (! $args[0] instanceof Arg) { + return null; + } + + if ($args[0]->name instanceof Identifier) { return null; } @@ -229,7 +233,7 @@ private function resolveObserverClassesFromArgs(array $args): ?array } /** - * @return list|null + * @return list|null */ private function resolveObserverClassesFromAttribute(Attribute $attribute): ?array { @@ -237,7 +241,7 @@ private function resolveObserverClassesFromAttribute(Attribute $attribute): ?arr return null; } - if ($attribute->args[0]->name !== null) { + if ($attribute->args[0]->name instanceof Identifier) { return null; } @@ -245,7 +249,7 @@ private function resolveObserverClassesFromAttribute(Attribute $attribute): ?arr } /** - * @return list|null + * @return list|null */ private function resolveObserverClassesFromExpr(Expr $expr): ?array { @@ -362,13 +366,12 @@ private function collectObserveRegistrationsFromFile(string $filePath): void continue; } - if (! $stmt->expr instanceof StaticCall) { + $expr = $stmt->expr; + if (! $expr instanceof StaticCall) { continue; } - /** @var StaticCall $staticCall */ - $staticCall = $stmt->expr; - $observedByRegistration = $this->matchObserveStaticCall($staticCall, $className); + $observedByRegistration = $this->matchObserveStaticCall($expr, $className); if (! $observedByRegistration instanceof ObservedByRegistration) { continue; } @@ -386,8 +389,8 @@ private function collectObserveRegistrationsFromFile(string $filePath): void } /** - * @param array $stmts - * @return array + * @param array $stmts + * @return array */ private function resolveNames(array $stmts): array { @@ -399,7 +402,7 @@ private function resolveNames(array $stmts): array $nodeTraverser = new NodeTraverser; $nodeTraverser->addVisitor($nameResolver); - /** @var array $resolvedStmts */ + /** @var array $resolvedStmts */ $resolvedStmts = $nodeTraverser->traverse($stmts); return $resolvedStmts; @@ -459,7 +462,7 @@ private function resolveProjectFiles(string $projectRoot, string $currentFilePat { $configuredPaths = array_filter( SimpleParameterProvider::provideArrayParameter(Option::PATHS), - static fn (mixed $path): bool => is_string($path) + is_string(...) ); $projectPaths = []; @@ -484,8 +487,8 @@ private function resolveProjectFiles(string $projectRoot, string $currentFilePat } /** - * @param list $observerClasses - * @return list + * @param list $observerClasses + * @return list */ private function uniqueObserverClasses(array $observerClasses): array { @@ -513,11 +516,11 @@ private function resolveObservedModelClass(StaticCall $staticCall, ?string $curr private function resolveClassName(Class_ $class): ?string { - if (property_exists($class, 'namespacedName') && $class->namespacedName instanceof Name) { + if ($class->namespacedName instanceof Name) { return $class->namespacedName->toString(); } - if ($class->name === null) { + if (! $class->name instanceof Identifier) { return null; } diff --git a/src/NodeFactory/ObservedByAttributeFactory.php b/src/NodeFactory/ObservedByAttributeFactory.php index 6cfc7e78..2267785d 100644 --- a/src/NodeFactory/ObservedByAttributeFactory.php +++ b/src/NodeFactory/ObservedByAttributeFactory.php @@ -8,14 +8,14 @@ use PhpParser\Node\Attribute; use PhpParser\Node\AttributeGroup; use PhpParser\Node\Expr\Array_; -use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\ArrayItem; +use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Name\FullyQualified; final class ObservedByAttributeFactory { /** - * @param list $observerClasses + * @param list $observerClasses */ public function create(array $observerClasses): AttributeGroup { diff --git a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php index 1f7fec98..0241ad16 100644 --- a/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php +++ b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php @@ -83,14 +83,12 @@ public function refactor(Node $node): Node|int|null continue; } - if (! $stmt->expr instanceof StaticCall) { + $expr = $stmt->expr; + if (! $expr instanceof StaticCall) { continue; } - /** @var StaticCall $staticCall */ - $staticCall = $stmt->expr; - - $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($staticCall, $currentClassName); + $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($expr, $currentClassName); if (! $observedByRegistration instanceof ObservedByRegistration) { continue; } diff --git a/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php index 020195f6..ec2d85ff 100644 --- a/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php +++ b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php @@ -5,6 +5,7 @@ namespace RectorLaravel\Rector\Class_; use PhpParser\Node; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt\Class_; use RectorLaravel\AbstractRector; @@ -144,11 +145,11 @@ public function refactor(Node $node): ?Node private function resolveClassName(Class_ $class): ?string { - if (property_exists($class, 'namespacedName') && $class->namespacedName instanceof Name) { + if ($class->namespacedName instanceof Name) { return $class->namespacedName->toString(); } - if ($class->name === null) { + if (! $class->name instanceof Identifier) { return null; } diff --git a/src/ValueObject/ObservedByRegistration.php b/src/ValueObject/ObservedByRegistration.php index 6212247a..094dd8ed 100644 --- a/src/ValueObject/ObservedByRegistration.php +++ b/src/ValueObject/ObservedByRegistration.php @@ -7,7 +7,7 @@ final readonly class ObservedByRegistration { /** - * @param list $observerClasses + * @param list $observerClasses */ public function __construct( public string $modelClass, diff --git a/stubs/Illuminate/Database/Eloquent/Attributes/ObservedBy.php b/stubs/Illuminate/Database/Eloquent/Attributes/ObservedBy.php new file mode 100644 index 00000000..bc9f5d69 --- /dev/null +++ b/stubs/Illuminate/Database/Eloquent/Attributes/ObservedBy.php @@ -0,0 +1,20 @@ + $observer + */ + public function __construct( + public string|array $observer, + ) {} +} diff --git a/stubs/Illuminate/Database/Eloquent/Model.php b/stubs/Illuminate/Database/Eloquent/Model.php index eed1bc1e..614ab7db 100644 --- a/stubs/Illuminate/Database/Eloquent/Model.php +++ b/stubs/Illuminate/Database/Eloquent/Model.php @@ -37,6 +37,8 @@ public static function query(): Builder return new Builder; } + public static function observe(string $observer): void {} + /** * Exists in the Illuminate/Database/Eloquent/Concerns/HasTimestamps trait * Put here for simplicity diff --git a/stubs/Illuminate/Foundation/Auth/User.php b/stubs/Illuminate/Foundation/Auth/User.php new file mode 100644 index 00000000..897420f1 --- /dev/null +++ b/stubs/Illuminate/Foundation/Auth/User.php @@ -0,0 +1,11 @@ +