diff --git a/README.md b/README.md
index 3725c54..981eee1 100644
--- a/README.md
+++ b/README.md
@@ -295,6 +295,97 @@ final class ShoppingCart
+### LivePropModifierMethodRule
+
+Enforces that when a `#[LiveProp]` attribute specifies a `modifier` parameter:
+- The method must exist in the component class and be declared as public
+- The method must have 1 or 2 parameters:
+ - First parameter: must be of type `LiveProp`
+ - Second parameter (optional): must be of type `string`
+- The method must return a `LiveProp` instance
+
+This ensures that property modifiers are correctly implemented and can safely transform LiveProp configurations at runtime.
+
+```yaml
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule
+```
+
+```php
+// src/Twig/Components/SearchComponent.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+#[AsLiveComponent]
+final class SearchComponent
+{
+ #[LiveProp(modifier: 'modifyQueryProp')]
+ public string $query;
+
+ // Error: Method is not public
+ private function modifyQueryProp(LiveProp $liveProp): LiveProp
+ {
+ return $liveProp;
+ }
+
+ // Error: Wrong return type
+ public function modifyOtherProp(LiveProp $liveProp): string
+ {
+ return 'test';
+ }
+
+ // Error: Wrong first parameter type
+ public function modifyAnotherProp(string $value): LiveProp
+ {
+ return new LiveProp();
+ }
+}
+```
+
+:x:
+
+
+
+```php
+// src/Twig/Components/SearchComponent.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+use Symfony\UX\LiveComponent\Mapping\UrlMapping;
+
+#[AsLiveComponent]
+final class SearchComponent
+{
+ #[LiveProp(modifier: 'modifyQueryProp')]
+ public string $query;
+
+ #[LiveProp]
+ public ?string $alias = null;
+
+ // Valid: with two parameters
+ public function modifyQueryProp(LiveProp $liveProp, string $name): LiveProp
+ {
+ if ($this->alias) {
+ $liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias));
+ }
+ return $liveProp;
+ }
+
+ // Valid: with one parameter
+ public function modifyOtherProp(LiveProp $liveProp): LiveProp
+ {
+ return $liveProp->writable();
+ }
+}
+```
+
+:+1:
+
+
+
## TwigComponent Rules
> [!NOTE]
diff --git a/src/NodeAnalyzer/AttributeFinder.php b/src/NodeAnalyzer/AttributeFinder.php
index 8614ea7..cdb187c 100644
--- a/src/NodeAnalyzer/AttributeFinder.php
+++ b/src/NodeAnalyzer/AttributeFinder.php
@@ -16,20 +16,6 @@
*/
final class AttributeFinder
{
- /**
- * @return Attribute[]
- */
- public static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
- {
- $attributes = [];
-
- foreach ($node->attrGroups as $attrGroup) {
- $attributes = array_merge($attributes, $attrGroup->attrs);
- }
-
- return $attributes;
- }
-
public static function findAttribute(ClassMethod | Property | ClassLike | Param $node, string $desiredAttributeClass): ?Attribute
{
$attributes = self::findAttributes($node);
@@ -68,4 +54,18 @@ public static function findAnyAttribute(ClassMethod | Property | ClassLike | Par
return null;
}
+
+ /**
+ * @return Attribute[]
+ */
+ private static function findAttributes(ClassMethod | Property | ClassLike | Param $node): array
+ {
+ $attributes = [];
+
+ foreach ($node->attrGroups as $attrGroup) {
+ $attributes = array_merge($attributes, $attrGroup->attrs);
+ }
+
+ return $attributes;
+ }
}
diff --git a/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php b/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php
index 6637b8a..be2e5c4 100644
--- a/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php
+++ b/src/Rules/LiveComponent/LiveActionMethodsShouldBePublicRule.php
@@ -25,14 +25,14 @@ public function getNodeType(): string
public function processNode(Node $node, Scope $scope): array
{
- if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) {
+ if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
return [];
}
$errors = [];
foreach ($node->getMethods() as $method) {
- if (! AttributeFinder::findAnyAttribute($method, [LiveAction::class])) {
+ if (! AttributeFinder::findAttribute($method, LiveAction::class)) {
continue;
}
diff --git a/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php b/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php
index f545c7b..d5cdffd 100644
--- a/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php
+++ b/src/Rules/LiveComponent/LiveListenerMethodsShouldBePublicRule.php
@@ -25,14 +25,14 @@ public function getNodeType(): string
public function processNode(Node $node, Scope $scope): array
{
- if (! AttributeFinder::findAnyAttribute($node, [AsLiveComponent::class])) {
+ if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
return [];
}
$errors = [];
foreach ($node->getMethods() as $method) {
- if (! AttributeFinder::findAnyAttribute($method, [LiveListener::class])) {
+ if (! AttributeFinder::findAttribute($method, LiveListener::class)) {
continue;
}
diff --git a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
index 161cdad..10d8d6f 100644
--- a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
+++ b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
@@ -140,8 +140,8 @@ public function processNode(Node $node, Scope $scope): array
$dehydrateMethodRefl = $reflClass->getMethod($dehydrateWith, $scope);
// Get AST nodes for line numbers
- $hydrateMethodNode = $this->findMethod($node, $hydrateWith);
- $dehydrateMethodNode = $this->findMethod($node, $dehydrateWith);
+ $hydrateMethodNode = $node->getMethod($hydrateWith);
+ $dehydrateMethodNode = $node->getMethod($dehydrateWith);
// Check that methods are public
if (! $hydrateMethodRefl->isPublic()) {
@@ -298,18 +298,4 @@ private function getArgumentValue(Node\Attribute $attribute, string $argumentNam
return null;
}
-
- /**
- * Finds a method AST node by name in the given class (used for line numbers).
- */
- private function findMethod(Class_ $classNode, string $methodName): ?Node\Stmt\ClassMethod
- {
- foreach ($classNode->getMethods() as $method) {
- if ($method->name->toString() === $methodName) {
- return $method;
- }
- }
-
- return null;
- }
}
diff --git a/src/Rules/LiveComponent/LivePropModifierMethodRule.php b/src/Rules/LiveComponent/LivePropModifierMethodRule.php
new file mode 100644
index 0000000..211a5cd
--- /dev/null
+++ b/src/Rules/LiveComponent/LivePropModifierMethodRule.php
@@ -0,0 +1,197 @@
+
+ */
+final class LivePropModifierMethodRule implements Rule
+{
+ public function __construct(
+ private ReflectionProvider $reflectionProvider,
+ ) {
+ }
+
+ public function getNodeType(): string
+ {
+ return Class_::class;
+ }
+
+ public function processNode(Node $node, Scope $scope): array
+ {
+ if (! AttributeFinder::findAttribute($node, AsLiveComponent::class)) {
+ return [];
+ }
+
+ if ($node->namespacedName === null) {
+ return [];
+ }
+
+ $errors = [];
+ $reflClass = $this->reflectionProvider->getClass($node->namespacedName->toString());
+
+ foreach ($node->getProperties() as $property) {
+ $livePropAttribute = AttributeFinder::findAttribute($property, LiveProp::class);
+ if (! $livePropAttribute) {
+ continue;
+ }
+
+ // Extract modifier method name from the attribute
+ $modifier = $this->getArgumentValue($livePropAttribute, 'modifier');
+
+ // Skip if modifier argument is not defined
+ if ($modifier === null) {
+ continue;
+ }
+
+ $propertyName = $property->props[0]->name->toString();
+
+ // Check if the modifier method exists
+ if (! $reflClass->hasMethod($modifier)) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Property "%s" references non-existent modifier method "%s()".',
+ $propertyName,
+ $modifier
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodMustExist')
+ ->line($property->getLine())
+ ->tip(sprintf('Create the public method "%s()" in the component class.', $modifier))
+ ->build();
+
+ continue;
+ }
+
+ // Get method reflection
+ $modifierMethodRefl = $reflClass->getMethod($modifier, $scope);
+
+ // Get AST node for line number
+ $modifierMethodNode = $node->getMethod($modifier);
+
+ // Check that method is public
+ if (! $modifierMethodRefl->isPublic()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Modifier method "%s()" referenced in property "%s" must be public.',
+ $modifier,
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodMustBePublic')
+ ->line($modifierMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Make the method "%s()" public.', $modifier))
+ ->build();
+ }
+
+ // Get method signature
+ $modifierVariant = $modifierMethodRefl->getOnlyVariant();
+ $modifierParams = $modifierVariant->getParameters();
+ $modifierReturnType = $modifierVariant->getReturnType();
+
+ // Check that modifier method has 1 or 2 parameters
+ $paramCount = count($modifierParams);
+ if ($paramCount < 1 || $paramCount > 2) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Modifier method "%s()" must have 1 or 2 parameters (LiveProp and optionally string).',
+ $modifier
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodParameterCount')
+ ->line($modifierMethodNode?->getLine() ?? $property->getLine())
+ ->tip('The modifier method should have a LiveProp parameter and optionally a string parameter.')
+ ->build();
+ } else {
+ // Check first parameter is LiveProp
+ $firstParamType = $modifierParams[0]->getType();
+ $expectedLivePropType = new ObjectType(LiveProp::class);
+
+ if (! $expectedLivePropType->isSuperTypeOf($firstParamType)->yes()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Modifier method "%s()" first parameter must be of type "%s", got "%s".',
+ $modifier,
+ LiveProp::class,
+ $firstParamType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodFirstParameterType')
+ ->line($modifierMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Change the first parameter type to "%s".', LiveProp::class))
+ ->build();
+ }
+
+ // Check second parameter is string if it exists
+ if ($paramCount === 2) {
+ $secondParamType = $modifierParams[1]->getType();
+ $expectedStringType = new StringType();
+
+ if (! $expectedStringType->isSuperTypeOf($secondParamType)->yes()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Modifier method "%s()" second parameter must be of type "string", got "%s".',
+ $modifier,
+ $secondParamType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodSecondParameterType')
+ ->line($modifierMethodNode?->getLine() ?? $property->getLine())
+ ->tip('Change the second parameter type to "string".')
+ ->build();
+ }
+ }
+ }
+
+ // Check that modifier method returns LiveProp
+ $expectedLivePropType = new ObjectType(LiveProp::class);
+ if (! $expectedLivePropType->isSuperTypeOf($modifierReturnType)->yes()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Modifier method "%s()" must return "%s", got "%s".',
+ $modifier,
+ LiveProp::class,
+ $modifierReturnType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropModifierMethodReturnType')
+ ->line($modifierMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Change the return type to ": %s".', LiveProp::class))
+ ->build();
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Extracts the value of a named argument from a LiveProp attribute.
+ */
+ private function getArgumentValue(Node\Attribute $attribute, string $argumentName): ?string
+ {
+ foreach ($attribute->args as $arg) {
+ if ($arg->name && $arg->name->toString() === $argumentName) {
+ if ($arg->value instanceof Node\Scalar\String_) {
+ return $arg->value->value;
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php b/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php
new file mode 100644
index 0000000..393a1a5
--- /dev/null
+++ b/tests/Rules/LiveComponent/LivePropModifierMethodRule/Fixture/ModifierMethodNotFound.php
@@ -0,0 +1,15 @@
+
+ */
+final class LivePropModifierMethodRuleTest extends RuleTestCase
+{
+ public function testModifierMethodNotFound(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodNotFound.php'],
+ [
+ [
+ 'Property "query" references non-existent modifier method "nonExistentMethod()".',
+ 13,
+ 'Create the public method "nonExistentMethod()" in the component class.',
+ ],
+ ]
+ );
+ }
+
+ public function testModifierMethodNotPublic(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodNotPublic.php'],
+ [
+ [
+ 'Modifier method "modifyQueryProp()" referenced in property "query" must be public.',
+ 16,
+ 'Make the method "modifyQueryProp()" public.',
+ ],
+ ]
+ );
+ }
+
+ public function testModifierMethodWrongParameterCount(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodWrongParameterCount.php'],
+ [
+ [
+ 'Modifier method "modifyQueryProp()" must have 1 or 2 parameters (LiveProp and optionally string).',
+ 16,
+ 'The modifier method should have a LiveProp parameter and optionally a string parameter.',
+ ],
+ ]
+ );
+ }
+
+ public function testModifierMethodWrongFirstParameterType(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodWrongFirstParameterType.php'],
+ [
+ [
+ 'Modifier method "modifyQueryProp()" first parameter must be of type "Symfony\UX\LiveComponent\Attribute\LiveProp", got "string".',
+ 16,
+ 'Change the first parameter type to "Symfony\UX\LiveComponent\Attribute\LiveProp".',
+ ],
+ ]
+ );
+ }
+
+ public function testModifierMethodWrongSecondParameterType(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodWrongSecondParameterType.php'],
+ [
+ [
+ 'Modifier method "modifyQueryProp()" second parameter must be of type "string", got "int".',
+ 16,
+ 'Change the second parameter type to "string".',
+ ],
+ ]
+ );
+ }
+
+ public function testModifierMethodWrongReturnType(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ModifierMethodWrongReturnType.php'],
+ [
+ [
+ 'Modifier method "modifyQueryProp()" must return "Symfony\UX\LiveComponent\Attribute\LiveProp", got "string".',
+ 16,
+ 'Change the return type to ": Symfony\UX\LiveComponent\Attribute\LiveProp".',
+ ],
+ ]
+ );
+ }
+
+ public function testNoViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/NotAComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidModifier.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidModifierWithOneParameter.php'],
+ []
+ );
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(LivePropModifierMethodRule::class);
+ }
+}
diff --git a/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon b/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon
new file mode 100644
index 0000000..7899306
--- /dev/null
+++ b/tests/Rules/LiveComponent/LivePropModifierMethodRule/config/configured_rule.neon
@@ -0,0 +1,2 @@
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropModifierMethodRule