Best way to use Composition instead of Inheritance for Laminas Form Elements

Right now we have inputs like this, which extend Select

namespace Admin\Form\Input;

use Admin\Enum\PeriodRange as PeriodRangeEnum;
use Laminas\Filter\ToString;
use Laminas\Form\Element\Select;
use Laminas\Validator\InArray;

use function Core\Utility\identity as translate;

class PeriodRange extends Select
{
    public function __construct($name = null, iterable $options = [])
    {
        $this->label = translate('Period Range');
        parent::__construct($name, $options);
    }

    /** @return array<value-of<PeriodRangeEnum>, string> */
    public function getValueOptions(): array
    {
        return [
            PeriodRangeEnum::All->value => translate('All'),
            PeriodRangeEnum::Day->value => translate('Day'),
            PeriodRangeEnum::Week->value => translate('Week'),
            PeriodRangeEnum::Month->value => translate('Month'),
            PeriodRangeEnum::Year->value => translate('Year'),
        ];
    }

    public function getInputSpecification(): array
    {
        return [
            'name' => $this->getName(),
            'required' => false,
            'filters' => [
                ['name' => ToString::class],
            ],
            'validators' => [
                [
                    'name' => InArray::class,
                    'options' => [
                        'haystack' => array_keys($this->getValueOptions()),
                        'message' => translate('Select a valid period range'),
                    ],
                ],
            ],
        ];
    }
}

But, as Select is marked final, is there a better way we should be doing things?

In most cases, you can use a factory to prepare the element. But you are right, your example is the standard procedure in many applications where the value options are set and the specifications for the input filter are defined.

The goal for the next major version, or perhaps even sooner, is to find ways to simplify the process without having to extend the standard form elements.

See also:

Some additional background/explanation: The way to extend the standard form elements was originally intended to be. Therefore it is very easy in the current versions because in a form the form element manager is used. The form element manager can fetch form elements, fieldsets and forms without registration!

Fetch a Custom Element without Registration

The form element manager allows fetching custom elements without prior registration with the manager.

The following example creates a custom element:

final class ExampleElement extends Laminas\Form\Element
{
    // …
}

The form element manager can create these custom elements by the related class name:

$element = $formElementManager->get(ExampleElement::class);

The manager uses the factory Laminas\Form\ElementFactory to instantiate the element, and will pass the name and options array:

$element = $formElementManager->get(
    ExampleElement::class,
    [
        'name'    => '…',
        'options' => [ 
            // …
        ],
    ]
);

Usage in a Form

final class ExampleForm extends Laminas\Form\Form
{
    public function init() : void
    {
        $this->add([
            'name'    => 'example',
            'type'    => ExampleElement::class,
            'options' => [
                'label' => 'Example',
            ],
        ]);

        // …
    }
}

It is not necessary to extend the configuration!


The challenge now is to maintain this simplicity without necessarily having to extend the standard elements.

I created a trait that i can use in my Form that works, but is kind of clunky in that I have to surround the addition of the input filter with setUseInputFilterDefaults.

The alternative would be to make another function for the replacement of getInputSpecification(), and call that in the Form’s getInputFilterSpecification(), but I wanted it all-in-one like the inheritance way provided.

/**
 * Trait PeriodRangeT
 *
 * @phpstan-require-extends Form
 */
trait PeriodRangeT
{
    public const string PERIOD_RANGE_INPUT_NAME = 'period_range';

    /**
     * @param string|null $label
     * @return $this
     */
    private function addPeriodRangeInput(?string $label = null, bool $required = true): self
    {
        $this->add([
            'name' => self::PERIOD_RANGE_INPUT_NAME,
            'type' => Select::class,
            'options' => [
                'label' => $label ?? translate('Period Range'),
                'value_options' => [
                    PeriodRangeEnum::All->value => translate('All'),
                    PeriodRangeEnum::Day->value => translate('Day'),
                    PeriodRangeEnum::Week->value => translate('Week'),
                    PeriodRangeEnum::Month->value => translate('Month'),
                    PeriodRangeEnum::Quarter->value => translate('Quarter'),
                    PeriodRangeEnum::Year->value => translate('Year'),
                ],
            ],
        ]);

        $this->setUseInputFilterDefaults(false);
        $this->getInputFilter()->add([
            'name' => self::PERIOD_RANGE_INPUT_NAME,
            'required' => $required,
            'filters' => [
                ['name' => ToString::class],
            ],
            'validators' => [
                [
                    'name' => InArray::class,
                    'options' => [
                        'haystack' => array_map(static fn ($e) => $e->value, PeriodRangeEnum::cases()),
                        'message' => translate('Select a valid period range'),
                    ],
                ],
            ],
        ]);
        $this->setUseInputFilterDefaults(true);

        return $this;
    }
}

Instead of

$this->add([
    'name' => 'period_range',
    'type' => PeriodRange::class,
]);

in the init(), I call

$this->addPeriodRangeInput();

Use the translation at the view layer, as it already exists. No need to create a separate element for this usage.
This trait is not required and is a typical example of incorrect usage: methods are being called that do not exist or are defined as abstract methods.

(How this might look differently in the future is currently speculation, so I would not rewrite anything at this point.)

Use the translation at the view layer, as it already exists. No need to create a separate element for this usage.

The translate used here in the trait is just a dummy function like fn($v) => $v for xgettext.

This trait is not required and is a typical example of incorrect usage: methods are being called that do not exist or are defined as abstract methods.

The add, setUseInputFilterDefaults, and getInputFilter methods are all defined in Form, I’m not sure which methods I’m using don’t exist/are abstract.

The trait may not required, but I can’t find a better way to de-duplicate this logic across my forms (except for extending, as in my first post)

In most cases, you can use a factory to prepare the element.

I can almost do this, but I’m missing a way to add filters to the element.


For now, I’ll go back to extending the element and squelching the error for extending the php-doc final in PHPStan :slight_smile:

Correct these are defined in the Form class but your trait can be used everywhere:

(new class() {
    use PeriodRangeT;

    public function __construct()
    {
        $this->addPeriodRangeInput();
    }
});

And this is the typical example of incorrect usage. You assume that it is used in the Form class of laminas-form, but this is not specified anywhere. Therefore you must define abstract methods:

trait PeriodRangeT
{
    abstract public function add($elementOrFieldset, array $flags = []);

    abstract public function setUseInputFilterDefaults(bool $useInputFilterDefaults);

    abstract public function getInputFilter(): InputFilterInterface;
    
    // …
}

Therefore, caution is advised when using traits.

:+1: