diff --git a/README.md b/README.md
index 444a612..3725c54 100644
--- a/README.md
+++ b/README.md
@@ -165,6 +165,136 @@ final class Notification
+### LivePropHydrationMethodsRule
+
+Enforces that when a `#[LiveProp]` attribute specifies `hydrateWith` and `dehydrateWith` parameters:
+- Both parameters must be specified together
+- Both methods must exist in the component class and be declared as public
+- The types must be compatible throughout the hydration/dehydration cycle:
+ - The property must have a type declaration
+ - The hydrate method must return the same type as the property
+ - The dehydrate method must accept the same type as the property as its first parameter
+ - The dehydrate method's return type must match the hydrate method's parameter type
+
+This ensures data flows correctly between frontend and backend representations.
+
+```yaml
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropHydrationMethodsRule
+```
+
+```php
+// src/Twig/Components/ProductList.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+#[AsLiveComponent]
+final class ProductList
+{
+ // Error: Missing dehydrateWith parameter
+ #[LiveProp(hydrateWith: 'hydrateFilters')]
+ public array $filters;
+}
+```
+
+```php
+// src/Twig/Components/ProductList.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+#[AsLiveComponent]
+final class ProductList
+{
+ #[LiveProp(hydrateWith: 'hydrateFilters', dehydrateWith: 'dehydrateFilters')]
+ public array $filters;
+
+ // Error: Methods are private/protected instead of public
+ private function hydrateFilters(array $data): array
+ {
+ return $data;
+ }
+
+ protected function dehydrateFilters(array $data): array
+ {
+ return $data;
+ }
+}
+```
+
+```php
+// src/Twig/Components/ShoppingCart.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+class Product
+{
+ public function __construct(public string $name, public float $price) {}
+}
+
+#[AsLiveComponent]
+final class ShoppingCart
+{
+ #[LiveProp(hydrateWith: 'hydrateProduct', dehydrateWith: 'dehydrateProduct')]
+ public Product $product;
+
+ // Error: Return type doesn't match property type
+ public function hydrateProduct(array $data): array
+ {
+ return $data;
+ }
+
+ // Error: Parameter type doesn't match property type
+ public function dehydrateProduct(string $product): array
+ {
+ return [];
+ }
+}
+```
+
+:x:
+
+
+
+```php
+// src/Twig/Components/ShoppingCart.php
+namespace App\Twig\Components;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+
+class Product
+{
+ public function __construct(public string $name, public float $price) {}
+}
+
+#[AsLiveComponent]
+final class ShoppingCart
+{
+ #[LiveProp(hydrateWith: 'hydrateProduct', dehydrateWith: 'dehydrateProduct')]
+ public Product $product;
+
+ public function hydrateProduct(array $data): Product
+ {
+ return new Product($data['name'], $data['price']);
+ }
+
+ public function dehydrateProduct(Product $product): array
+ {
+ return ['name' => $product->name, 'price' => $product->price];
+ }
+}
+```
+
+:+1:
+
+
+
## TwigComponent Rules
> [!NOTE]
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 053d645..3e7842e 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -10,6 +10,8 @@ parameters:
paths:
- tests/**/Fixture/*
identifiers:
+ - argument.type
- method.unused
- missingType.iterableValue
+ - missingType.property
- property.unused
diff --git a/phpunit.dist.xml b/phpunit.dist.xml
index 6aa26c0..aa0f6ad 100644
--- a/phpunit.dist.xml
+++ b/phpunit.dist.xml
@@ -13,6 +13,7 @@
+
diff --git a/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
new file mode 100644
index 0000000..161cdad
--- /dev/null
+++ b/src/Rules/LiveComponent/LivePropHydrationMethodsRule.php
@@ -0,0 +1,315 @@
+
+ */
+final class LivePropHydrationMethodsRule 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 hydration method names from the attribute
+ $hydrateWith = $this->getArgumentValue($livePropAttribute, 'hydrateWith');
+ $dehydrateWith = $this->getArgumentValue($livePropAttribute, 'dehydrateWith');
+
+ // Skip if both arguments are not defined
+ if ($hydrateWith === null && $dehydrateWith === null) {
+ continue;
+ }
+
+ // TODO: Is there a best way to do this?
+ $propertyName = $property->props[0]->name->toString();
+
+ // Ensure that "dehydrateWith" is specified when "hydrateWith" is specified
+ if ($hydrateWith !== null && $dehydrateWith === null) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Property "%s" has a #[LiveProp] attribute with "hydrateWith" but is missing "dehydrateWith".',
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsMustBothExist')
+ ->line($property->getLine())
+ ->tip('Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.')
+ ->build();
+
+ continue;
+ }
+
+ // Ensure that "hydrateWith" is specified when "dehydrateWith" is specified
+ if ($dehydrateWith !== null && $hydrateWith === null) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Property "%s" has a #[LiveProp] attribute with "dehydrateWith" but is missing "hydrateWith".',
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsMustBothExist')
+ ->line($property->getLine())
+ ->tip('Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.')
+ ->build();
+
+ continue;
+ }
+
+ // At this point, both $hydrateWith and $dehydrateWith are guaranteed to be non-null (validated above)
+ assert($hydrateWith !== null && $dehydrateWith !== null);
+
+ // Get method reflections using PHPStan's reflection system
+ $hydrateMethodExists = $reflClass->hasMethod($hydrateWith);
+ $dehydrateMethodExists = $reflClass->hasMethod($dehydrateWith);
+
+ // Validate that hydrate method exists
+ if (! $hydrateMethodExists) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Property "%s" references non-existent hydrate method "%s()".',
+ $propertyName,
+ $hydrateWith
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrationMethodMustExist')
+ ->line($property->getLine())
+ ->tip(sprintf('Create the public method "%s()" in the component class.', $hydrateWith))
+ ->build();
+ }
+
+ // Validate that dehydrate method exists
+ if (! $dehydrateMethodExists) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Property "%s" references non-existent dehydrate method "%s()".',
+ $propertyName,
+ $dehydrateWith
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropDehydrationMethodMustExist')
+ ->line($property->getLine())
+ ->tip(sprintf('Create the public method "%s()" in the component class.', $dehydrateWith))
+ ->build();
+ }
+
+ // Skip further validation if methods don't exist
+ if (! $hydrateMethodExists || ! $dehydrateMethodExists) {
+ continue;
+ }
+
+ // Get reflection of both methods
+ $hydrateMethodRefl = $reflClass->getMethod($hydrateWith, $scope);
+ $dehydrateMethodRefl = $reflClass->getMethod($dehydrateWith, $scope);
+
+ // Get AST nodes for line numbers
+ $hydrateMethodNode = $this->findMethod($node, $hydrateWith);
+ $dehydrateMethodNode = $this->findMethod($node, $dehydrateWith);
+
+ // Check that methods are public
+ if (! $hydrateMethodRefl->isPublic()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Hydrate method "%s()" referenced in property "%s" must be public.',
+ $hydrateWith,
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrationMethodMustBePublic')
+ ->line($hydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Make the method "%s()" public.', $hydrateWith))
+ ->build();
+ }
+
+ if (! $dehydrateMethodRefl->isPublic()) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Dehydrate method "%s()" referenced in property "%s" must be public.',
+ $dehydrateWith,
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropDehydrationMethodMustBePublic')
+ ->line($dehydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Make the method "%s()" public.', $dehydrateWith))
+ ->build();
+ }
+
+ // Check that methods have compatible types using PHPStan's type system
+ // Get the property type from reflection
+ if (! $reflClass->hasProperty($propertyName)) {
+ continue;
+ }
+
+ $propertyRefl = $reflClass->getProperty($propertyName, $scope);
+ $propertyType = $propertyRefl->getReadableType();
+
+ // Get method signatures
+ $hydrateVariant = $hydrateMethodRefl->getOnlyVariant();
+ $dehydrateVariant = $dehydrateMethodRefl->getOnlyVariant();
+
+ $hydrateParams = $hydrateVariant->getParameters();
+ $hydrateReturnType = $hydrateVariant->getReturnType();
+
+ $dehydrateParams = $dehydrateVariant->getParameters();
+ $dehydrateReturnType = $dehydrateVariant->getReturnType();
+
+ // Check that hydrate method has one parameter
+ if (count($hydrateParams) !== 1) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Hydrate method "%s()" must have one parameter.',
+ $hydrateWith
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrateMethodMustHaveParameter')
+ ->line($hydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip('Add a parameter to the hydrate method.')
+ ->build();
+ }
+
+ // Check that hydrate method return type matches property type
+ if (! $hydrateReturnType->equals($propertyType)) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Hydrate method "%s()" return type must match property "%s" type. Expected "%s", got "%s".',
+ $hydrateWith,
+ $propertyName,
+ $propertyType->describe(VerbosityLevel::typeOnly()),
+ $hydrateReturnType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrateMethodReturnTypeMismatch')
+ ->line($hydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Change the return type to ": %s".', $propertyType->describe(VerbosityLevel::typeOnly())))
+ ->build();
+ }
+
+ // Check that dehydrate method has one parameter
+ if (count($dehydrateParams) !== 1) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Dehydrate method "%s()" must have one parameter that matches property "%s" type.',
+ $dehydrateWith,
+ $propertyName
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropDehydrateMethodMustHaveParameter')
+ ->line($dehydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip('Add a parameter to the dehydrate method that matches the property type.')
+ ->build();
+ } else {
+ // Check that dehydrate method first parameter type matches property type
+ $dehydrateParamType = $dehydrateParams[0]->getType();
+ if (! $dehydrateParamType->equals($propertyType)) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Dehydrate method "%s()" first parameter type must match property "%s" type. Expected "%s", got "%s".',
+ $dehydrateWith,
+ $propertyName,
+ $propertyType->describe(VerbosityLevel::typeOnly()),
+ $dehydrateParamType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropDehydrateMethodParameterTypeMismatch')
+ ->line($dehydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf('Change the parameter type to "%s".', $propertyType->describe(VerbosityLevel::typeOnly())))
+ ->build();
+ }
+ }
+
+ // Check that hydration and dehydration methods are cross-compatible
+ // The dehydrate method's return type should match the hydrate method's parameter type
+ if (count($hydrateParams) > 0) {
+ $hydrateParamType = $hydrateParams[0]->getType();
+ if (! $dehydrateReturnType->equals($hydrateParamType)) {
+ $errors[] = RuleErrorBuilder::message(
+ sprintf(
+ 'Dehydrate method "%s()" return type must match hydrate method "%s()" first parameter type. Expected "%s", got "%s".',
+ $dehydrateWith,
+ $hydrateWith,
+ $hydrateParamType->describe(VerbosityLevel::typeOnly()),
+ $dehydrateReturnType->describe(VerbosityLevel::typeOnly())
+ )
+ )
+ ->identifier('symfonyUX.liveComponent.livePropHydrationMethodsTypeMismatch')
+ ->line($dehydrateMethodNode?->getLine() ?? $property->getLine())
+ ->tip(sprintf(
+ 'The dehydrate method should return the same type that the hydrate method accepts as its first parameter: "%s".',
+ $hydrateParamType->describe(VerbosityLevel::typeOnly())
+ ))
+ ->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;
+ }
+
+ /**
+ * 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/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php
new file mode 100644
index 0000000..0edce0e
--- /dev/null
+++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/DehydrateMethodParameterTypeMismatch.php
@@ -0,0 +1,25 @@
+ $data->value,
+ ];
+ }
+}
diff --git a/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php
new file mode 100644
index 0000000..212bd89
--- /dev/null
+++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/Fixture/ValidHydrationMethods.php
@@ -0,0 +1,25 @@
+
+ */
+final class LivePropHydrationMethodsRuleTest extends RuleTestCase
+{
+ public function testMissingBothMethods(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/MissingBothMethods.php'],
+ [
+ [
+ 'Property "data" references non-existent hydrate method "hydrateData()".',
+ 13,
+ 'Create the public method "hydrateData()" in the component class.',
+ ],
+ [
+ 'Property "data" references non-existent dehydrate method "dehydrateData()".',
+ 13,
+ 'Create the public method "dehydrateData()" in the component class.',
+ ],
+ ]
+ );
+ }
+
+ public function testMissingHydrateWith(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/MissingHydrateWith.php'],
+ [
+ [
+ 'Property "data" has a #[LiveProp] attribute with "dehydrateWith" but is missing "hydrateWith".',
+ 13,
+ 'Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.',
+ ],
+ ]
+ );
+ }
+
+ public function testMissingDehydrateWith(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/MissingDehydrateWith.php'],
+ [
+ [
+ 'Property "data" has a #[LiveProp] attribute with "hydrateWith" but is missing "dehydrateWith".',
+ 13,
+ 'Both "hydrateWith" and "dehydrateWith" must be specified together in the #[LiveProp] attribute.',
+ ],
+ ]
+ );
+ }
+
+ public function testPrivateHydrateMethod(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/PrivateHydrateMethod.php'],
+ [
+ [
+ 'Hydrate method "hydrateData()" referenced in property "data" must be public.',
+ 21,
+ 'Make the method "hydrateData()" public.',
+ ],
+ ]
+ );
+ }
+
+ public function testProtectedDehydrateMethod(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/ProtectedDehydrateMethod.php'],
+ [
+ [
+ 'Dehydrate method "dehydrateData()" referenced in property "data" must be public.',
+ 21,
+ 'Make the method "dehydrateData()" public.',
+ ],
+ ]
+ );
+ }
+
+ public function testHydrateMethodReturnTypeMismatch(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/HydrateMethodReturnTypeMismatch.php'],
+ [
+ [
+ 'Hydrate method "hydrateData()" return type must match property "data" type. Expected "array", got "string".',
+ 16,
+ 'Change the return type to ": array".',
+ ],
+ ]
+ );
+ }
+
+ public function testDehydrateMethodParameterTypeMismatch(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/DehydrateMethodParameterTypeMismatch.php'],
+ [
+ [
+ 'Dehydrate method "dehydrateData()" first parameter type must match property "data" type. Expected "array", got "string".',
+ 21,
+ 'Change the parameter type to "array".',
+ ],
+ ]
+ );
+ }
+
+ public function testHydrateMethodWithoutParameter(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/HydrateMethodWithoutParameter.php'],
+ [
+ [
+ 'Hydrate method "hydrateData()" must have one parameter.',
+ 16,
+ 'Add a parameter to the hydrate method.',
+ ],
+ ]
+ );
+ }
+
+ public function testDehydrateMethodWithoutParameter(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/DehydrateMethodWithoutParameter.php'],
+ [
+ [
+ 'Dehydrate method "dehydrateData()" must have one parameter that matches property "data" type.',
+ 21,
+ 'Add a parameter to the dehydrate method that matches the property type.',
+ ],
+ ]
+ );
+ }
+
+ public function testHydrationMethodsTypeMismatch(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/HydrationMethodsTypeMismatch.php'],
+ [
+ [
+ 'Dehydrate method "dehydrateData()" return type must match hydrate method "hydrateData()" first parameter type. Expected "array", got "string".',
+ 21,
+ 'The dehydrate method should return the same type that the hydrate method accepts as its first parameter: "array".',
+ ],
+ ]
+ );
+ }
+
+ public function testNoViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/NotAComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidHydrationMethods.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/NoHydrationMethods.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidTypes.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidComplexTypes.php'],
+ []
+ );
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(LivePropHydrationMethodsRule::class);
+ }
+}
diff --git a/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon
new file mode 100644
index 0000000..e607037
--- /dev/null
+++ b/tests/Rules/LiveComponent/LivePropHydrationMethodsRule/config/configured_rule.neon
@@ -0,0 +1,2 @@
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\LiveComponent\LivePropHydrationMethodsRule