bug #3372 [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class (kachnitel)

This PR was squashed before being merged into the 2.x branch.

Discussion
----------

[LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class

| Q              | A
| -------------- | ---
| Bug fix?       | yes
| New feature?   | no
| Deprecations?  | no
| Documentation? | no
| Issues         | <!-- none yet -->
| License        | MIT

## Problem

When a component class *extends* another class that uses a trait with `#[ExposeInTemplate]` or `#[LiveProp]` attributes on **private** properties, those attributes are invisible to the metadata scanners.

PHP's `ReflectionClass::getProperties()` called on a child class does not return private properties from traits used in *ancestor* classes — they are only returned when `getProperties()` is called on the exact class that declares `use TraitName`. This is standard PHP reflection behaviour.

This affects any inheritance pattern where a base component uses `ComponentWithFormTrait` (or any custom trait with private `#[ExposeInTemplate]` properties) and app-level components extend it without re-declaring the trait.

**Symptom in `ux-twig-component`:** `Variable "form" does not exist` in the child component's Twig template.

**Symptom in `ux-live-component`:** `#[LiveProp(fieldName: 'callable()')]` declared in a trait used by a parent class is not registered with the correct `fieldName` on the child component, causing frontend model key mismatches.

## Fix

### `ux-twig-component` — `ComponentProperties::loadClassMetadata()`

Replace the single `$refClass->getProperties()` call with a loop that walks up `getParentClass()`, calling `getProperties()` at each level. Results are deduplicated by property name (child-class declaration takes priority), giving a complete view of all `#[ExposeInTemplate]` properties across the full hierarchy.

### `ux-live-component` — `LiveComponentMetadataFactory::createPropMetadatas()`

Pass `$property->getDeclaringClass()->getName()` instead of `$class->getName()` to `createLivePropMetadata()`. When a property is declared in a trait used by a parent class, the type extractor must be given the declaring class — not the leaf child class — to resolve the type correctly.

`#[LiveAction]` and `#[LiveListener]` on parent class methods are **not** affected: `getMethods()` already walks the full inheritance chain, so those are discovered correctly on child components.

## Tests

Added ``@group` trait-inheritance` tests in both packages using a minimal fixture: a trait with a private `#[ExposeInTemplate]` property / `#[LiveProp(fieldName: callable)]`, a parent component that uses the trait, and a child component that extends the parent without re-declaring the trait.

Commits
-------

799736ff76c [LiveComponent][TwigComponent] Fix reflection issues for private properties from trait and parent class
This commit is contained in:
Hugo Alliaume
2026-03-15 19:48:53 +01:00
7 changed files with 253 additions and 1 deletions

View File

@@ -74,7 +74,8 @@ class LiveComponentMetadataFactory implements ResetInterface
continue;
}
$metadatas[$propertyName] = $this->createLivePropMetadata($class->getName(), $propertyName, $property, $attribute->newInstance());
$declaringClassName = $property->getDeclaringClass()->getName();
$metadatas[$propertyName] = $this->createLivePropMetadata($declaringClassName, $propertyName, $property, $attribute->newInstance());
}
return array_values($metadatas);

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
/**
* Reproduces the pattern in ComponentWithFormTrait where a public #[LiveProp]
* with a callable fieldName is declared in a trait.
*
* Child components that extend a parent using this trait must inherit the
* LiveProp with the correct fieldName callable.
*/
trait HasLivePropTrait
{
/**
* Public #[LiveProp] with a callable fieldName — this is the pattern used
* in ComponentWithFormTrait::$formValues.
*/
#[LiveProp(writable: true, fieldName: 'getFormName()')]
public array $formValues = [];
public function getFormName(): string
{
return 'live_prop_inheritance_form';
}
}

View File

@@ -0,0 +1,27 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
/**
* Child component that extends LivePropInheritanceParent without redeclaring:
* - HasLivePropTrait
* - save() method with #[LiveAction] and #[LiveListener]
*
* All of those must still be discoverable on this class.
*/
#[AsLiveComponent]
class LivePropInheritanceChild extends LivePropInheritanceParent
{
// Intentionally empty: does NOT redeclare the trait or override save().
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveAction;
use Symfony\UX\LiveComponent\Attribute\LiveListener;
use Symfony\UX\LiveComponent\DefaultActionTrait;
/**
* Parent component that:
* - uses HasLivePropTrait (contains a public #[LiveProp(fieldName: callable)])
* - declares a #[LiveAction] + #[LiveListener] on a method
*
* Child classes that extend this without redeclaring the trait or the method
* must still have the LiveProp registered with the correct fieldName, and the
* action/listener discoverable.
*/
#[AsLiveComponent]
class LivePropInheritanceParent
{
use DefaultActionTrait;
use HasLivePropTrait;
#[LiveAction]
#[LiveListener('save')]
public function save(): void
{
// no-op for testing
}
}

View File

@@ -0,0 +1 @@
<div>FormValues: {{ formValues|json_encode }}</div>

View File

@@ -0,0 +1 @@
<div>FormValues: {{ formValues|json_encode }}</div>

View File

@@ -0,0 +1,146 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\UX\LiveComponent\Tests\Integration;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Metadata\LegacyLivePropMetadata;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\LivePropInheritanceChild;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\LivePropInheritanceParent;
/**
* Regression tests for the PHP reflection gap that caused #[LiveProp] attributes
* declared in traits used by a *parent* class to be registered without their
* fieldName callable on a *child* component, and #[LiveAction]/#[LiveListener]
* methods from parent classes to be undiscoverable on child components.
*
* @group trait-inheritance
*/
final class LivePropInheritanceTest extends KernelTestCase
{
/**
* Sanity check: the parent component itself must have the LiveProp with
* the correct fieldName callable.
*/
public function testParentComponentHasLivePropWithFieldName()
{
self::bootKernel();
$prop = $this->findPropMetadata(LivePropInheritanceParent::class, 'formValues');
$this->assertNotNull($prop, 'formValues LiveProp must be registered on the parent component.');
$component = new LivePropInheritanceParent();
$this->assertSame(
'live_prop_inheritance_form',
$prop->calculateFieldName($component, 'formValues'),
'fieldName callable must resolve correctly on the parent component.'
);
}
/**
* Regression test for Bug #2: the child component must have the same
* LiveProp with the correct fieldName callable, even though it does not
* redeclare the trait.
*
* Before the fix the LiveProp was either not registered at all for the child,
* or registered without the fieldName, causing the frontend key mismatch.
*/
public function testChildComponentInheritsLivePropWithFieldName()
{
self::bootKernel();
$prop = $this->findPropMetadata(LivePropInheritanceChild::class, 'formValues');
$this->assertNotNull(
$prop,
'formValues LiveProp must be registered on the child component.'
);
$component = new LivePropInheritanceChild();
$this->assertSame(
'live_prop_inheritance_form',
$prop->calculateFieldName($component, 'formValues'),
'The fieldName callable must be preserved on the child component when '.
'#[LiveProp] is declared in a trait used by the parent class.'
);
}
/**
* Sanity check: the save() action must be allowed on the parent component.
*/
public function testParentComponentHasSaveAction()
{
self::bootKernel();
$this->assertTrue(
AsLiveComponent::isActionAllowed(LivePropInheritanceParent::class, 'save'),
'save() must be a recognised LiveAction on the parent component.'
);
}
/**
* Regression test for Bug #3: the save() action declared with #[LiveAction]
* on the parent class must also be allowed on the child component.
*/
public function testChildComponentInheritsLiveAction()
{
self::bootKernel();
$this->assertTrue(
AsLiveComponent::isActionAllowed(LivePropInheritanceChild::class, 'save'),
'save() must be a recognised LiveAction on the child component when it is '.
'declared with #[LiveAction] on the parent class.'
);
}
/**
* Regression test for Bug #3: the save() listener declared with
* #[LiveListener("save")] on the parent class must appear in the child's
* live listeners.
*/
public function testChildComponentInheritsLiveListener()
{
self::bootKernel();
$child = new LivePropInheritanceChild();
$listeners = AsLiveComponent::liveListeners($child);
$this->assertContains(
['action' => 'save', 'event' => 'save'],
$listeners,
'The save listener must be registered on the child component when '.
'#[LiveListener] is declared on the parent class method.'
);
}
/**
* @param class-string $componentClass
*/
private function findPropMetadata(string $componentClass, string $propName): LivePropMetadata|LegacyLivePropMetadata|null
{
/** @var LiveComponentMetadataFactory $factory */
$factory = self::getContainer()->get('ux.live_component.metadata_factory');
$props = $factory->createPropMetadatas(new \ReflectionClass($componentClass));
foreach ($props as $prop) {
if ($prop->getName() === $propName) {
return $prop;
}
}
return null;
}
}