Fodo Docs
Forms

Custom Fields

Create your own field types for Fodo Forms

Custom Fields

Extend Fodo Forms with custom field types. Build specialized inputs for your specific use case, ratings, addresses, custom selectors, and more.

Overview

Custom fields require:

  1. PHP class - Handles rendering, validation, sanitization
  2. Registration - Adds field to the registry
  3. JavaScript (optional) - Builder UI and frontend behavior

Basic field structure

PHP class

<?php
namespace MyPlugin\Fields;

class RatingField implements \Fodo\Forms\Fields\FieldInterface
{
    public function getType(): string
    {
        return 'rating';
    }

    public function getLabel(): string
    {
        return __('Star Rating', 'my-plugin');
    }

    public function getIcon(): string
    {
        return 'Star';
    }

    public function render(array $field, array $form): string
    {
        // Return HTML for the field
    }

    public function validate(mixed $value, array $field): array
    {
        // Return array of error messages (empty if valid)
    }

    public function sanitize(mixed $value, array $field): mixed
    {
        // Return sanitized value
    }

    public function getJsConfig(): array
    {
        // Return config passed to JavaScript
    }
}

Registration

add_action('fodo_forms_register_fields', function($registry) {
    $registry->registerType('rating', [
        'label' => __('Star Rating', 'my-plugin'),
        'icon' => 'Star',
        'category' => 'advanced',
        'class' => \MyPlugin\Fields\RatingField::class,
    ]);
});

Complete example: star rating field

PHP implementation

<?php
namespace MyPlugin\Fields;

class RatingField
{
    public function getType(): string
    {
        return 'rating';
    }

    public function getLabel(): string
    {
        return __('Star Rating', 'my-plugin');
    }

    public function getIcon(): string
    {
        return 'Star';
    }

    public function render(array $field, array $form): string
    {
        $id = esc_attr($field['id']);
        $label = esc_html($field['label']);
        $required = !empty($field['validation']['required']);
        $maxStars = (int) ($field['maxStars'] ?? 5);
        $defaultValue = (int) ($field['defaultValue'] ?? 0);

        ob_start();
        ?>
        <div class="fodo-field fodo-field-rating" data-field-id="<?php echo $id; ?>">
            <label class="fodo-label">
                <?php echo $label; ?>
                <?php if ($required): ?>
                    <span class="fodo-required">*</span>
                <?php endif; ?>
            </label>

            <div class="fodo-rating-stars" data-max="<?php echo $maxStars; ?>">
                <?php for ($i = 1; $i <= $maxStars; $i++): ?>
                    <button
                        type="button"
                        class="fodo-star <?php echo $i <= $defaultValue ? 'active' : ''; ?>"
                        data-value="<?php echo $i; ?>"
                        aria-label="<?php echo sprintf(__('%d stars', 'my-plugin'), $i); ?>"
                    >
                        <svg viewBox="0 0 24 24" fill="currentColor" width="24" height="24">
                            <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
                        </svg>
                    </button>
                <?php endfor; ?>
            </div>

            <input
                type="hidden"
                id="field_<?php echo $id; ?>"
                name="fields[<?php echo $id; ?>]"
                value="<?php echo $defaultValue; ?>"
                <?php echo $required ? 'required' : ''; ?>
            >

            <?php if (!empty($field['description'])): ?>
                <p class="fodo-description"><?php echo esc_html($field['description']); ?></p>
            <?php endif; ?>
        </div>
        <?php
        return ob_get_clean();
    }

    public function validate(mixed $value, array $field): array
    {
        $errors = [];
        $maxStars = (int) ($field['maxStars'] ?? 5);

        if (empty($value)) {
            if (!empty($field['validation']['required'])) {
                $errors[] = __('Please select a rating.', 'my-plugin');
            }
            return $errors;
        }

        $value = (int) $value;

        if ($value < 1 || $value > $maxStars) {
            $errors[] = sprintf(
                __('Rating must be between 1 and %d.', 'my-plugin'),
                $maxStars
            );
        }

        return $errors;
    }

    public function sanitize(mixed $value, array $field): mixed
    {
        $value = (int) $value;
        $maxStars = (int) ($field['maxStars'] ?? 5);

        if ($value < 0) return 0;
        if ($value > $maxStars) return $maxStars;

        return $value;
    }

    public function getJsConfig(): array
    {
        return [
            'settings' => [
                [
                    'id' => 'maxStars',
                    'label' => __('Maximum Stars', 'my-plugin'),
                    'type' => 'number',
                    'default' => 5,
                    'min' => 1,
                    'max' => 10,
                ],
            ],
        ];
    }
}

JavaScript (frontend)

// Rating field interaction
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('.fodo-rating-stars').forEach(container => {
        const stars = container.querySelectorAll('.fodo-star');
        const input = container.parentElement.querySelector('input[type="hidden"]');

        stars.forEach(star => {
            star.addEventListener('click', () => {
                const value = parseInt(star.dataset.value);
                input.value = value;

                // Update visual state
                stars.forEach((s, index) => {
                    s.classList.toggle('active', index < value);
                });
            });

            // Hover preview
            star.addEventListener('mouseenter', () => {
                const value = parseInt(star.dataset.value);
                stars.forEach((s, index) => {
                    s.classList.toggle('hover', index < value);
                });
            });

            star.addEventListener('mouseleave', () => {
                stars.forEach(s => s.classList.remove('hover'));
            });
        });
    });
});

CSS

.fodo-rating-stars {
    display: flex;
    gap: 4px;
}

.fodo-star {
    background: none;
    border: none;
    padding: 0;
    cursor: pointer;
    color: #d1d5db;
    transition: color 0.15s;
}

.fodo-star:hover,
.fodo-star.hover {
    color: #fbbf24;
}

.fodo-star.active {
    color: #f59e0b;
}

.fodo-star svg {
    display: block;
}

Field settings

Available setting types

getJsConfig(): array {
    return [
        'settings' => [
            // Text input
            [
                'id' => 'placeholder',
                'label' => 'Placeholder',
                'type' => 'text',
                'default' => '',
            ],
            // Number input
            [
                'id' => 'maxStars',
                'label' => 'Max Stars',
                'type' => 'number',
                'default' => 5,
                'min' => 1,
                'max' => 10,
            ],
            // Select dropdown
            [
                'id' => 'style',
                'label' => 'Style',
                'type' => 'select',
                'options' => [
                    ['value' => 'stars', 'label' => 'Stars'],
                    ['value' => 'hearts', 'label' => 'Hearts'],
                ],
                'default' => 'stars',
            ],
            // Checkbox
            [
                'id' => 'allowHalf',
                'label' => 'Allow Half Ratings',
                'type' => 'checkbox',
                'default' => false,
            ],
            // Color picker
            [
                'id' => 'activeColor',
                'label' => 'Active Color',
                'type' => 'color',
                'default' => '#f59e0b',
            ],
        ],
    ];
}

Enqueueing assets

add_action('fodo_forms_enqueue_scripts', function() {
    // Frontend JS
    wp_enqueue_script(
        'my-rating-field',
        plugins_url('js/rating-field.js', __FILE__),
        ['fodo-forms-frontend'],
        '1.0.0',
        true
    );

    // Frontend CSS
    wp_enqueue_style(
        'my-rating-field',
        plugins_url('css/rating-field.css', __FILE__),
        ['fodo-forms-frontend'],
        '1.0.0'
    );
});

add_action('admin_enqueue_scripts', function($hook) {
    if (!str_contains($hook, 'fodo-forms')) return;

    // Builder JS
    wp_enqueue_script(
        'my-rating-field-builder',
        plugins_url('js/rating-field-builder.js', __FILE__),
        ['fodo-forms-builder'],
        '1.0.0',
        true
    );
});

Testing custom fields

Unit tests

class RatingFieldTest extends WP_UnitTestCase
{
    private RatingField $field;

    public function setUp(): void
    {
        parent::setUp();
        $this->field = new RatingField();
    }

    public function test_validates_required_field()
    {
        $fieldConfig = ['validation' => ['required' => true]];

        $errors = $this->field->validate('', $fieldConfig);
        $this->assertNotEmpty($errors);

        $errors = $this->field->validate(3, $fieldConfig);
        $this->assertEmpty($errors);
    }

    public function test_sanitizes_value()
    {
        $fieldConfig = ['maxStars' => 5];

        $this->assertEquals(3, $this->field->sanitize(3, $fieldConfig));
        $this->assertEquals(5, $this->field->sanitize(10, $fieldConfig));
        $this->assertEquals(0, $this->field->sanitize(-1, $fieldConfig));
    }

    public function test_renders_correct_html()
    {
        $fieldConfig = [
            'id' => 'rating',
            'label' => 'Rate Us',
            'maxStars' => 5,
        ];

        $html = $this->field->render($fieldConfig, []);

        $this->assertStringContainsString('fodo-field-rating', $html);
        $this->assertStringContainsString('Rate Us', $html);
        $this->assertStringContainsString('data-max="5"', $html);
    }
}

Best practices

  1. Follow naming conventions: Use prefixed class names and unique field types
  2. Sanitize everything: Never trust user input
  3. Provide defaults: All settings should have sensible defaults
  4. Support accessibility: Use proper ARIA attributes and keyboard navigation
  5. Test thoroughly: Test validation, sanitization, and rendering
  6. Document your field: Include usage examples and configuration options