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/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/src/NodeAnalyzer/ObservedByAnalyzer.php b/src/NodeAnalyzer/ObservedByAnalyzer.php
new file mode 100644
index 00000000..d2417dc6
--- /dev/null
+++ b/src/NodeAnalyzer/ObservedByAnalyzer.php
@@ -0,0 +1,529 @@
+>
+ */
+ private array $observerClassesByModel = [];
+
+ /**
+ * @var array
+ */
+ private array $canUpdateModelCache = [];
+
+ /**
+ * @var array
+ */
+ 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
+ {
+ $this->observerClassesByModel = [];
+ $this->canUpdateModelCache = [];
+ $this->classFilePaths = [];
+ $this->indexedFilePaths = [];
+ $this->initializedProjectRoot = null;
+ }
+
+ public function matchObserveStaticCall(StaticCall $staticCall, ?string $currentClassName = null): ?ObservedByRegistration
+ {
+ if (! $this->nodeNameResolver->isName($staticCall->name, 'observe')) {
+ return null;
+ }
+
+ $modelClass = $this->resolveObservedModelClass($staticCall, $currentClassName);
+ if (! is_string($modelClass)) {
+ return null;
+ }
+
+ $observerClasses = $this->resolveObserverClassesFromArgs($staticCall->args);
+ if ($observerClasses === null) {
+ return null;
+ }
+
+ 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;
+ }
+
+ $classReflection = call_user_func([$scope, 'getClassReflection']);
+ if (! is_object($classReflection) || ! method_exists($classReflection, 'getName')) {
+ return null;
+ }
+
+ $className = call_user_func([$classReflection, 'getName']);
+
+ return is_string($className) ? $className : null;
+ }
+
+ /**
+ * @return list
+ */
+ public function resolveObserverClassesForModel(string $modelClass, string $currentFilePath): array
+ {
+ $this->initializeFromProjectRoot($this->resolveProjectRoot($currentFilePath), $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 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), $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 array $args
+ * @return list|null
+ */
+ private function resolveObserverClassesFromArgs(array $args): ?array
+ {
+ if (count($args) !== 1) {
+ return null;
+ }
+
+ if (! $args[0] instanceof Arg) {
+ return null;
+ }
+
+ if ($args[0]->name instanceof Identifier) {
+ 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 instanceof Identifier) {
+ 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, 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->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) {
+ return;
+ }
+
+ if ($stmts === []) {
+ return;
+ }
+
+ $resolvedStmts = $this->resolveNames($stmts);
+
+ SimpleCallableNodeTraverser::traverseNodesWithCallable($resolvedStmts, function (Node $node) use ($filePath): null {
+ if (! $node instanceof Class_) {
+ return null;
+ }
+
+ $className = $this->resolveClassName($node);
+ if (! is_string($className)) {
+ return null;
+ }
+
+ if (! array_key_exists($className, $this->classFilePaths)) {
+ $this->classFilePaths[$className] = $filePath;
+ }
+
+ foreach ($node->getMethods() as $classMethod) {
+ if (! $this->isObserverRegistrationMethod($classMethod)) {
+ continue;
+ }
+
+ foreach ((array) $classMethod->stmts as $stmt) {
+ if (! $stmt instanceof Expression) {
+ continue;
+ }
+
+ $expr = $stmt->expr;
+ if (! $expr instanceof StaticCall) {
+ continue;
+ }
+
+ $observedByRegistration = $this->matchObserveStaticCall($expr, $className);
+ 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 list
+ */
+ private function resolveProjectFiles(string $projectRoot, string $currentFilePath): array
+ {
+ $configuredPaths = array_filter(
+ SimpleParameterProvider::provideArrayParameter(Option::PATHS),
+ is_string(...)
+ );
+
+ $projectPaths = [];
+
+ foreach ($configuredPaths as $configuredPath) {
+ if ($configuredPath === $projectRoot || str_starts_with($configuredPath, $projectRoot . '/')) {
+ $projectPaths[] = $configuredPath;
+ }
+ }
+
+ if ($projectPaths === []) {
+ $projectPaths = [$projectRoot];
+ }
+
+ $filePaths = $this->filesFinder->findInDirectoriesAndFiles($projectPaths, ['php']);
+
+ if (is_file($currentFilePath)) {
+ $filePaths[] = $currentFilePath;
+ }
+
+ return array_values(array_unique($filePaths));
+ }
+
+ /**
+ * @param list $observerClasses
+ * @return list
+ */
+ 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 ($class->namespacedName instanceof Name) {
+ return $class->namespacedName->toString();
+ }
+
+ if (! $class->name instanceof Identifier) {
+ return null;
+ }
+
+ return $class->name->toString();
+ }
+}
diff --git a/src/NodeFactory/ObservedByAttributeFactory.php b/src/NodeFactory/ObservedByAttributeFactory.php
new file mode 100644
index 00000000..2267785d
--- /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..0241ad16
--- /dev/null
+++ b/src/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector.php
@@ -0,0 +1,116 @@
+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|int|null
+ {
+ if (! $this->isNames($node->name, ['boot', 'booted'])) {
+ return null;
+ }
+
+ $currentClassName = $this->observedByAnalyzer->resolveCurrentClassName($node);
+
+ $hasChanged = false;
+
+ foreach ((array) $node->stmts as $key => $stmt) {
+ if (! $stmt instanceof Expression) {
+ continue;
+ }
+
+ $expr = $stmt->expr;
+ if (! $expr instanceof StaticCall) {
+ continue;
+ }
+
+ $observedByRegistration = $this->observedByAnalyzer->matchObserveStaticCall($expr, $currentClassName);
+ 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);
+
+ if ($node->stmts === []) {
+ return NodeVisitor::REMOVE_NODE;
+ }
+
+ return $node;
+ }
+}
diff --git a/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php
new file mode 100644
index 00000000..ec2d85ff
--- /dev/null
+++ b/src/Rector/Class_/ObserveCallsToObservedByAttributeRector.php
@@ -0,0 +1,158 @@
+>
+ */
+ 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 ($class->namespacedName instanceof Name) {
+ return $class->namespacedName->toString();
+ }
+
+ if (! $class->name instanceof Identifier) {
+ return null;
+ }
+
+ return $class->name->toString();
+ }
+}
diff --git a/src/ValueObject/ObservedByRegistration.php b/src/ValueObject/ObservedByRegistration.php
new file mode 100644
index 00000000..094dd8ed
--- /dev/null
+++ b/src/ValueObject/ObservedByRegistration.php
@@ -0,0 +1,16 @@
+ $observerClasses
+ */
+ public function __construct(
+ public string $modelClass,
+ public array $observerClasses,
+ ) {}
+}
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 @@
+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_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..8ea08b29
--- /dev/null
+++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_direct_observe_when_model_already_has_matching_attribute.php.inc
@@ -0,0 +1,35 @@
+
+-----
+
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_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/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles.php.inc
new file mode 100644
index 00000000..1418e156
--- /dev/null
+++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/remove_multiple_observer_registration_styles.php.inc
@@ -0,0 +1,52 @@
+
+-----
+
diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute.php.inc b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute.php.inc
new file mode 100644
index 00000000..7f469c4e
--- /dev/null
+++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/Fixture/skip_unmergeable_model_attribute.php.inc
@@ -0,0 +1,19 @@
+
diff --git a/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/RemoveModelObserveCallsFromBootRectorTest.php b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/RemoveModelObserveCallsFromBootRectorTest.php
new file mode 100644
index 00000000..31231781
--- /dev/null
+++ b/tests/Rector/ClassMethod/RemoveModelObserveCallsFromBootRector/RemoveModelObserveCallsFromBootRectorTest.php
@@ -0,0 +1,31 @@
+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/MergeExistingObservedByAttribute/AppServiceProvider.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/MergeExistingObservedByAttribute/AppServiceProvider.php
new file mode 100644
index 00000000..e32e2ff6
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/MergeExistingObservedByAttribute/AppServiceProvider.php
@@ -0,0 +1,13 @@
+
+-----
+
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/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration.php.inc
new file mode 100644
index 00000000..96efd4cb
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/simple_observe_registration.php.inc
@@ -0,0 +1,28 @@
+
+-----
+
diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute.php.inc
new file mode 100644
index 00000000..9ad162b7
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_existing_same_observed_by_attribute.php.inc
@@ -0,0 +1,14 @@
+
diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call.php.inc
new file mode 100644
index 00000000..dcec21bd
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_nested_observe_call.php.inc
@@ -0,0 +1,13 @@
+
diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model.php.inc b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model.php.inc
new file mode 100644
index 00000000..cf966c2f
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/Fixture/skip_not_a_model.php.inc
@@ -0,0 +1,11 @@
+
diff --git a/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/ObserveCallsToObservedByAttributeRectorTest.php b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/ObserveCallsToObservedByAttributeRectorTest.php
new file mode 100644
index 00000000..4dd3f2d1
--- /dev/null
+++ b/tests/Rector/Class_/ObserveCallsToObservedByAttributeRector/ObserveCallsToObservedByAttributeRectorTest.php
@@ -0,0 +1,31 @@
+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/ObservedByAttribute/AppServiceProvider.php b/tests/Sets/Laravel130/Fixture/ObservedByAttribute/AppServiceProvider.php
new file mode 100644
index 00000000..06fc65a3
--- /dev/null
+++ b/tests/Sets/Laravel130/Fixture/ObservedByAttribute/AppServiceProvider.php
@@ -0,0 +1,13 @@
+
+-----
+