Skip to content

Custom field types

When the built-in fields are not enough, write your own field class, register it, and use it in any form like a built-in field.

This guide builds a money field: an amount with a currency in front, which accepts "1,500,000" and stores 1500000.

1. Write the field class

Most fields extend InputField (one <input>) or Field:

app/Forms/Fields/MoneyField.php
<?php

declare(strict_types=1);

namespace App\Forms\Fields;

use Ephraitech\Forms\Config\Forms;
use Ephraitech\Forms\Contracts\SchemaConfigurableInterface;
use Ephraitech\Forms\Fields\InputField;

/**
 * An amount with a currency in front, such as "UGX 1,500,000".
 */
class MoneyField extends InputField implements SchemaConfigurableInterface
{
    public const TYPE = 'money';

    // A view name with a path is loaded from app/Views, not from the theme.
    public const VIEW = 'forms/fields/money';

    protected string $currency = 'UGX';

    public function currency(string $currency): static
    {
        $this->currency = $currency;

        return $this;
    }

    public function getCurrency(): string
    {
        return $this->currency;
    }

    // Shows a numeric keypad on phones.
    public function getAttributes(): array
    {
        return array_merge(['inputmode' => 'decimal'], parent::getAttributes());
    }

    // "1,500,000" or "1 500 000" becomes "1500000" before validation and saving.
    protected function normaliseScalar(mixed $value, Forms $config): mixed
    {
        if (is_string($value)) {
            $value = str_replace([',', ' '], '', $value);
        }

        return parent::normaliseScalar($value, $config);
    }

    // "UGX 1,500,000" when showing stored data with $form->present().
    public function presentValue(mixed $value, Forms $config): array|string
    {
        return is_numeric($value) ? $this->currency . ' ' . number_format((float) $value) : parent::presentValue($value, $config);
    }

    // Lets schemas use {"type": "money", "currency": "USD"}.
    public static function schemaProperties(): array
    {
        return ['currency' => 'currency'];
    }

    public function schemaValues(): array
    {
        return $this->currency === 'UGX' ? [] : ['currency' => $this->currency];
    }
}

Each part is optional; override only what your field needs:

Member Purpose
TYPE The field's type name
VIEW Its template. A name with a path, like forms/fields/money, is loaded from app/Views; a plain name, like field_input, comes from the active theme
Setters such as currency() Your field's own options; return static so calls can be chained
getAttributes() Extra HTML attributes
normaliseScalar() Cleans submitted values before validation and in validated()
presentValue() How a stored value is shown by $form->present()
schemaProperties(), schemaValues() Extra keys for forms stored as schemas

2. Write its view

app/Views/forms/fields/money.php
<?php
/**
 * View for App\Forms\Fields\MoneyField. It receives the same variables as the
 * package's own field views: $field, $id, $inputName, $label, $value, $error,
 * $help, $attributes, $requiredMarker, $bare, and the $e() and $class()
 * helpers.
 *
 * @var \App\Forms\Fields\MoneyField $field
 */
?>
<?php if (! $bare): ?>
<div<?= $class('group') ?>>
    <label for="<?= $e($id) ?>"<?= $class('label') ?>><?= $e($label) ?><?= $requiredMarker ?></label>
<?php endif ?>
    <div class="input-group<?= $error !== null ? ' has-validation' : '' ?>">
        <span class="input-group-text"><?= $e($field->getCurrency()) ?></span>
        <input type="text" name="<?= $e($inputName) ?>" id="<?= $e($id) ?>" value="<?= $e($value) ?>"<?= $class('control') ?><?= $attributes ?>>
<?php if (! $bare && $error !== null): ?>
        <div id="<?= $e($errorId) ?>"<?= $class('error') ?>><?= $e($error) ?></div>
<?php endif ?>
    </div>
<?php if (! $bare && $help !== null): ?>
    <div id="<?= $e($helpId) ?>"<?= $class('help') ?>><?= $e($help) ?></div>
<?php endif ?>
<?php if (! $bare): ?>
</div>
<?php endif ?>

Custom views receive the same variables as the package's own field views. Always print values with $e(), which escapes them.

3. Register it

Add the class to $fieldTypes in app/Config/Forms.php:

app/Config/Forms.php
    public array $fieldTypes = [
        'money' => MoneyField::class,
    ];

4. Use it

The registered name becomes a method on every form:

app/Forms/QuoteForm.php
<?php

declare(strict_types=1);

namespace App\Forms;

use Ephraitech\Forms\Form;

class QuoteForm extends Form
{
    protected function define(): void
    {
        $this->text('item', 'Item')->rules('required');

        // money() exists because MoneyField is registered in app/Config/Forms.php.
        $this->money('budget', 'Budget')
            ->rules('required|is_natural_no_zero')
            ->placeholder('1,500,000');

        $this->money('deposit', 'Deposit')
            ->currency('USD')
            ->rules('permit_empty|is_natural');

        $this->submit('Request a quote');
    }
}

money() returns your field, so its own setters and the usual ones (rules(), placeholder(), width()) can be chained. It also works inside rows and fieldsets, with addField('money', 'budget'), and in schemas as {"type": "money", "currency": "USD"}.

Submitting "1,500,000" validates against is_natural_no_zero and validated() returns '1500000'. $form->present() shows a stored 1500000 as "UGX 1,500,000".

Common mistakes

Choosing a name that already exists. A name such as text or render is refused when the form is built, because the built-in field or Form method would always win. Pick a new name such as money or phoneNumber.

Printing values without $e(). Every value in a field view can contain text a visitor typed. Escape it.

Using the field in admin-written schemas without allowing it. Schemas written by admins only accept the types their policy allows. Add yours with SchemaPolicy::restricted()->allowTypes('money').