Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 57 additions & 70 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<br>

```php
// src/Twig/Components/Alert.php
namespace App\Twig\Components;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class Alert
{
public string $message;
}
```

:+1:
Comment on lines +34 to +77
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The documentation structure is ambiguous with two invalid examples followed by a single :x: marker. Consider adding clearer labels or separating the examples to make it obvious that both the non-final class (lines 34-45) and the abstract class (lines 47-58) are invalid. For example:

**Invalid: Non-final class**
```php
#[AsTwigComponent]
class Alert { }

Invalid: Abstract class

#[AsTwigComponent]
abstract class Alert { }

Valid: Final class

#[AsTwigComponent]
final class Alert { }

👍

Copilot uses AI. Check for mistakes.

<br>

### 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.
Expand Down Expand Up @@ -230,76 +287,6 @@ final class Alert

<br>

### 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 `<twig:Alert variant="success"></twig:Alert>` instead of `<twig:AlertSuccess></twig:AlertSuccess>`.

```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:

<br>

```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:

<br>

### MethodsShouldBePublicOrPrivateRule

Enforces that all methods in Twig Components are either public or private, but not protected.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
/**
* @implements Rule<Class_>
*/
final class ForbiddenInheritanceRule implements Rule
final class ClassMustBeFinalRule implements Rule
{
public function getNodeType(): string
{
Expand All @@ -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(),
];
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule;

use Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassMustBeFinalRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<ClassMustBeFinalRule>
*/
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

#[AsLiveComponent]
abstract class InvalidAbstractLiveComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
abstract class InvalidAbstractTwigComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

#[AsLiveComponent]
class InvalidNonFinalLiveComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
class InvalidNonFinalTwigComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

class NotAComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;

#[AsLiveComponent]
final class ValidLiveComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Kocal\PHPStanSymfonyUX\Tests\Rules\TwigComponent\ClassMustBeFinalRule\Fixture;

use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;

#[AsTwigComponent]
final class ValidTwigComponent
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
rules:
- Kocal\PHPStanSymfonyUX\Rules\TwigComponent\ClassMustBeFinalRule

This file was deleted.

This file was deleted.

Loading