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:
- PHP class - Handles rendering, validation, sanitization
- Registration - Adds field to the registry
- 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
- Follow naming conventions: Use prefixed class names and unique field types
- Sanitize everything: Never trust user input
- Provide defaults: All settings should have sensible defaults
- Support accessibility: Use proper ARIA attributes and keyboard navigation
- Test thoroughly: Test validation, sanitization, and rendering
- Document your field: Include usage examples and configuration options