dead-code-detector icon indicating copy to clipboard operation
dead-code-detector copied to clipboard

How to deal with form dataclasses that have methods called by symfony property accessor

Open aszenz opened this issue 8 months ago • 3 comments

Symfony's property accessor component automatically calls getProperty/setProperty in forms, how do we ensure these methods are not marked as dead.

aszenz avatar May 05 '25 12:05 aszenz

Do you mean that something like this:

<?php

namespace App\Form;

use App\Dto\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ProductType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('name', TextType::class, [
                'label' => 'Product Name',
            ])
            ->add('price', NumberType::class, [
                'label' => 'Price',
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Product::class,
        ]);
    }
}

should mark Product's getters and setters (for name and price) as used?

janedbal avatar May 05 '25 12:05 janedbal

should mark Product's getters and setters (for name and price) as used?

Yes exactly, this is a common pattern in lot of symfony apps.

aszenz avatar May 05 '25 12:05 aszenz

I managed to extract my custom form type's data classes via symfony's debug cmd and then marked usage for their getter/setter methods. In my project it ended up being sufficient.

<?php

declare(strict_types=1);

namespace MyApp\Tools\PHPStan;

use PHPStan\DependencyInjection\Container;
use ShipMonk\PHPStan\DeadCode\Provider\ReflectionBasedMemberUsageProvider;
use ShipMonk\PHPStan\DeadCode\Provider\VirtualUsageData;
use Symfony\Component\Process\Process;

class FormDataclassUsageProvider extends ReflectionBasedMemberUsageProvider
{
    /** @var list<class-string> */
    private array $formDataClasses = [];

    public function __construct(Container $phpstanContainer)
    {
        $this->formDataClasses = $this->extractFormDataClasses();
    }

    public function shouldMarkMethodAsUsed(\ReflectionMethod $method): ?VirtualUsageData
    {
        $declaringClass = $method->getDeclaringClass();

        if (
            \in_array($declaringClass->getName(), $this->formDataClasses, true) &&
            (
                1 === \preg_match(
                    '/^(get|set|has|is|add|remove)[A-Z].*$/',
                    $method->getName()
                )
            )
        ) {
            return VirtualUsageData::withNote('Method maybe used in Symfony form data_class via property accessor');
        }

        return null;
    }

    /**
     * @return list<class-string>
     */
    private function extractFormDataClasses(): array
    {
        $formDataClasses = [];
        $consolePath = 'bin/console';

        $process = new Process(
            command: ['php', $consolePath, 'debug:form', '--format=json'],
        );
        $process->setTimeout(2);
        $process->mustRun();
        $output = $process->getOutput();

        \assert(\json_validate($output), $output);
        $formTypesData = json_decode($output, true);

        \assert(is_array($formTypesData));
        \assert(array_key_exists('service_form_types', $formTypesData));

        $formTypeClasses = $formTypesData['service_form_types'];
        \assert(is_array($formTypeClasses));

        foreach ($formTypeClasses as $formType) {
            \assert(is_string($formType));
            $process = new Process(
                command: [
                    'php',
                    $consolePath,
                    'debug:form',
                    $formType,
                    'data_class',
                    '--format=json'
                ],
                env: $_ENV
            );
            $process->setTimeout(2);
            $process->run();
            // if the form type class cannot be instantiated, we can't fetch it's options
            // so we skip it
            if(!$process->isSuccessful()) {
                continue;
            }

            $output = $process->getOutput();

            \assert(\json_validate($output), $output);

            $formTypeData = json_decode($output, true);

            \assert(is_array($formTypeData));
            \assert(array_key_exists('default', $formTypeData));

            $dataClass = $formTypeData['default'];

            if(null === $dataClass) {
                continue;
            }
            \assert(\is_string($dataClass));
            \assert(class_exists($dataClass));

            $formDataClasses[] = $dataClass;
        }

        return $formDataClasses;
    }
}

aszenz avatar May 05 '25 20:05 aszenz