diff --git a/README.md b/README.md
index 0b41cec..5e8ffc3 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,63 @@ Each rule can be enabled individually by adding it to your `phpstan.dist.neon` c
> [!NOTE]
> All these rules also apply to LiveComponents (classes annotated with `#[AsLiveComponent]`).
+### ClassMustBeFinalRule
+
+Enforces that all Twig Component classes must be declared as `final`.
+This prevents inheritance and promotes composition via traits, ensuring better code maintainability and avoiding tight coupling between components.
+
+```yaml
+rules:
+ - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassMustBeFinalRule
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+class Alert
+{
+ public string $message;
+}
+```
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+abstract class Alert
+{
+ public string $message;
+}
+```
+
+:x:
+
+
+
+```php
+// src/Twig/Components/Alert.php
+namespace App\Twig\Components;
+
+use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
+
+#[AsTwigComponent]
+final class Alert
+{
+ public string $message;
+}
+```
+
+:+1:
+
+
+
### ClassNameShouldNotEndWithComponentRule
Forbid Twig Component class names from ending with "Component" suffix, as it creates redundancy since the class is already identified as a component through the `#[AsTwigComponent]` attribute.
@@ -230,76 +287,6 @@ final class Alert
-### ForbiddenInheritanceRule
-
-Forbids the use of class inheritance in Twig Components. Composition via traits should be used instead.
-This promotes better code reusability and avoids tight coupling between components.
-
-> [!TIP]
-> Another alternative is to use [Class Variant Authority](https://symfony.com/bundles/ux-twig-component/current/index.html#component-with-complex-variants-cva) to create variations of a base component without inheritance or traits,
-> for example `` instead of ``.
-
-```yaml
-rules:
- - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenInheritanceRule
-```
-
-```php
-// src/Twig/Components/Alert.php
-namespace App\Twig\Components;
-
-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
-
-abstract class BaseComponent
-{
- public string $name;
-}
-
-#[AsTwigComponent]
-class Alert extends BaseComponent
-{
-}
-```
-
-:x:
-
-
-
-```php
-// src/Twig/Components/Alert.php
-namespace App\Twig\Components;
-
-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
-
-trait CommonComponentTrait
-{
- public string $name;
-}
-
-#[AsTwigComponent]
-final class Alert
-{
- use CommonComponentTrait;
-}
-```
-
-```php
-// src/Twig/Components/Alert.php
-namespace App\Twig\Components;
-
-use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
-
-#[AsTwigComponent]
-final class Alert
-{
- public string $name;
-}
-```
-
-:+1:
-
-
-
### MethodsShouldBePublicOrPrivateRule
Enforces that all methods in Twig Components are either public or private, but not protected.
diff --git a/src/Rules/TwigComponent/ForbiddenInheritanceRule.php b/src/Rules/TwigComponent/ClassMustBeFinalRule.php
similarity index 53%
rename from src/Rules/TwigComponent/ForbiddenInheritanceRule.php
rename to src/Rules/TwigComponent/ClassMustBeFinalRule.php
index c3529d3..ad89630 100644
--- a/src/Rules/TwigComponent/ForbiddenInheritanceRule.php
+++ b/src/Rules/TwigComponent/ClassMustBeFinalRule.php
@@ -16,7 +16,7 @@
/**
* @implements Rule
*/
-final class ForbiddenInheritanceRule implements Rule
+final class ClassMustBeFinalRule implements Rule
{
public function getNodeType(): string
{
@@ -29,12 +29,20 @@ public function processNode(Node $node, Scope $scope): array
return [];
}
- if ($node->extends !== null) {
+ if ($node->isAbstract()) {
return [
- RuleErrorBuilder::message('Using class inheritance in a Twig component is forbidden, use traits for composition instead.')
- ->identifier('symfonyUX.twigComponent.forbiddenClassInheritance')
- ->line($node->extends->getLine())
- ->tip('Consider using traits to share common functionality between Twig components.')
+ RuleErrorBuilder::message('Twig component class must be final, not abstract.')
+ ->identifier('symfonyUX.twigComponent.classMustBeFinal')
+ ->tip('Make the class final and use traits for composition instead of inheritance.')
+ ->build(),
+ ];
+ }
+
+ if (! $node->isFinal()) {
+ return [
+ RuleErrorBuilder::message('Twig component class must be final.')
+ ->identifier('symfonyUX.twigComponent.classMustBeFinal')
+ ->tip('Add the "final" keyword to the class declaration to prevent inheritance.')
->build(),
];
}
diff --git a/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php b/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php
new file mode 100644
index 0000000..fe7821f
--- /dev/null
+++ b/tests/Rules/TwigComponent/ClassMustBeFinalRule/ClassMustBeFinalRuleTest.php
@@ -0,0 +1,90 @@
+
+ */
+final class ClassMustBeFinalRuleTest extends RuleTestCase
+{
+ public function testViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/InvalidNonFinalTwigComponent.php'],
+ [
+ [
+ 'Twig component class must be final.',
+ 9,
+ 'Add the "final" keyword to the class declaration to prevent inheritance.',
+ ],
+ ]
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/InvalidNonFinalLiveComponent.php'],
+ [
+ [
+ 'Twig component class must be final.',
+ 9,
+ 'Add the "final" keyword to the class declaration to prevent inheritance.',
+ ],
+ ]
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/InvalidAbstractTwigComponent.php'],
+ [
+ [
+ 'Twig component class must be final, not abstract.',
+ 9,
+ 'Make the class final and use traits for composition instead of inheritance.',
+ ],
+ ]
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/InvalidAbstractLiveComponent.php'],
+ [
+ [
+ 'Twig component class must be final, not abstract.',
+ 9,
+ 'Make the class final and use traits for composition instead of inheritance.',
+ ],
+ ]
+ );
+ }
+
+ public function testNoViolations(): void
+ {
+ $this->analyse(
+ [__DIR__ . '/Fixture/NotAComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidTwigComponent.php'],
+ []
+ );
+
+ $this->analyse(
+ [__DIR__ . '/Fixture/ValidLiveComponent.php'],
+ []
+ );
+ }
+
+ public static function getAdditionalConfigFiles(): array
+ {
+ return [__DIR__ . '/config/configured_rule.neon'];
+ }
+
+ protected function getRule(): Rule
+ {
+ return self::getContainer()->getByType(ClassMustBeFinalRule::class);
+ }
+}
diff --git a/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php b/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php
new file mode 100644
index 0000000..4c0c6fe
--- /dev/null
+++ b/tests/Rules/TwigComponent/ClassMustBeFinalRule/Fixture/InvalidAbstractLiveComponent.php
@@ -0,0 +1,12 @@
+
- */
-final class ForbiddenInheritanceRuleTest extends RuleTestCase
-{
- public function testViolations(): void
- {
- $this->analyse(
- [__DIR__ . '/Fixture/ComponentWithInheritance.php'],
- [
- [
- 'Using class inheritance in a Twig component is forbidden, use traits for composition instead.',
- 15,
- 'Consider using traits to share common functionality between Twig components.',
- ],
- ]
- );
-
- $this->analyse(
- [__DIR__ . '/Fixture/LiveComponentWithInheritance.php'],
- [
- [
- 'Using class inheritance in a Twig component is forbidden, use traits for composition instead.',
- 15,
- 'Consider using traits to share common functionality between Twig components.',
- ],
- ]
- );
- }
-
- public function testNoViolations(): void
- {
- $this->analyse(
- [__DIR__ . '/Fixture/NotAComponent.php'],
- []
- );
-
- $this->analyse(
- [__DIR__ . '/Fixture/ComponentWithoutInheritance.php'],
- []
- );
-
- $this->analyse(
- [__DIR__ . '/Fixture/ComponentUsingTrait.php'],
- []
- );
-
- $this->analyse(
- [__DIR__ . '/Fixture/LiveComponentWithoutInheritance.php'],
- []
- );
- }
-
- public static function getAdditionalConfigFiles(): array
- {
- return [__DIR__ . '/config/configured_rule.neon'];
- }
-
- protected function getRule(): Rule
- {
- return self::getContainer()->getByType(ForbiddenInheritanceRule::class);
- }
-}
diff --git a/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon b/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon
deleted file mode 100644
index f43631d..0000000
--- a/tests/Rules/TwigComponent/ForbiddenInheritanceRule/config/configured_rule.neon
+++ /dev/null
@@ -1,2 +0,0 @@
-rules:
- - Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ForbiddenInheritanceRule