A fluent HTML5 form builder with Bootstrap 4 and Bootstrap 5 rendering.
- PHP 8.4+
composer require qubus/formuse Qubus\Form\FormBuilder;
use Qubus\Form\FormBuilder\Decorator\Bootstrap;
$form = FormBuilder::create(
options: ['id' => 'account'],
attributes: ['action' => '/accounts']
)->addDecorator(new Bootstrap(Bootstrap::VERSION_5));
$form
->field('email', 'user[email]', ['required' => true])
->attributes(['autocomplete' => 'email'])
->classes('email-field')
->end()
->field('password', 'user[password]', ['required' => true, 'minlength' => 12])
->end()
->field('select', 'user[role]', [
'items' => ['member' => 'Member', 'admin' => 'Administrator'],
]);
echo $form;field() returns the new element. Call end() to return to its parent. Existing methods
such as setOption() and setAttr() remain available, while option(), options(),
attributes(), classes(), and value() provide the concise fluent surface.
The following examples show the builder calls and their rendered output. Generated IDs combine the form ID and field name, including nested field names.
$form = FormBuilder::create(['id' => 'account']);
$form->field('email', 'user[email]', [
'description' => 'Email address',
'required' => true,
])
->attributes([
'autocomplete' => 'email',
'placeholder' => 'name@example.com',
])
->value('person@example.com');<form method="post" id="account">
<div>
<label for="account-user-email">Email address *</label>
<input type="email" placeholder="name@example.com" name="user[email]" value="person@example.com" required id="account-user-email" autocomplete="email">
</div>
</form>Textarea values are placed between the tags and escaped; they are never rendered as a value
attribute.
$form = FormBuilder::create(['id' => 'profile']);
$form->field('textarea', 'bio', [
'description' => 'Biography',
'maxlength' => 160,
])->value('Developer & writer');<form method="post" id="profile">
<div>
<label for="profile-bio">Biography</label>
<textarea maxlength="160" name="bio" id="profile-bio">Developer & writer</textarea>
</div>
</form>The configured value selects the matching item. Submitted values not present in items fail
server-side validation.
$form = FormBuilder::create(['id' => 'settings']);
$form->field('select', 'timezone', [
'description' => 'Time zone',
'placeholder' => 'Choose a time zone',
'items' => [
'America/Los_Angeles' => 'Pacific Time',
'America/New_York' => 'Eastern Time',
],
])->value('America/Los_Angeles');<form method="post" id="settings">
<div>
<label for="settings-timezone">Time zone</label>
<select name="timezone" id="settings-timezone">
<option value="" disabled>Choose a time zone</option>
<option value="America/Los_Angeles" selected>Pacific Time</option>
<option value="America/New_York">Eastern Time</option>
</select>
</div>
</form>The boolean type creates a checkbox. Its hidden input ensures an unchecked checkbox still
submits an empty value.
$form = FormBuilder::create(['id' => 'terms']);
$form->field('boolean', 'terms', [
'description' => 'I accept the terms',
'required' => true,
])->value(true);<form method="post" id="terms">
<div>
<label><input type="hidden" value="" name="terms">
<input type="checkbox" value="1" checked name="terms" required id="terms-terms"> I accept the terms *</label>
</div>
</form>$form = FormBuilder::create(['id' => 'contact'])
->addDecorator(new Bootstrap(Bootstrap::VERSION_5));
$form->field('email', 'email', [
'description' => 'Email',
'help' => 'We will never share your email.',
])->attributes(['placeholder' => 'name@example.com']);<form method="post" id="contact">
<div class="mb-3">
<label class="form-label" for="contact-email">Email</label>
<input type="email" placeholder="name@example.com" name="email" id="contact-email" class="form-control">
<div class="form-text">
We will never share your email.
</div>
</div>
</form>Use Bootstrap::VERSION_4 for Bootstrap 4. The same field uses form-group, form-control,
and Bootstrap 4 help-text conventions.
Setting required adds both the native HTML attribute and a visible suffix to the label. The
default suffix is *:
$form->field('text', 'name', ['required' => true]);
// <label for="form-id-name">Name *</label>Customize or disable the marker per field:
$form->field('text', 'name', [
'required' => true,
'required-suffix' => ' (required)',
]);
$form->field('text', 'reference', [
'required' => true,
'required-suffix' => '',
]);You can also change the default for all fields through FormBuilder::$options['required-suffix'].
The suffix is escaped as label text and is not added to placeholders.
Pass the target major version explicitly:
$form->addDecorator(new Bootstrap(Bootstrap::VERSION_4));
$form->addDecorator(new Bootstrap(Bootstrap::VERSION_5));
// The registered fluent equivalent:
$form->addDecorator('bootstrap', 5);Bootstrap 4 emits form-group, form-control, and text-muted conventions. Bootstrap 5
emits mb-3, form-select, and current input-group markup without the removed prepend and
append wrapper elements.
$form->csrf($_SESSION['csrf_token']);
if ($form->isSubmitted() && $form->isValid()) {
$values = $form->getValues();
}CSRF protection is opt-in because token storage belongs to the host application. When
enabled, a hidden token field is rendered and isSubmitted() rejects mismatches using a
timing-safe comparison. Request arrays can also be injected into isSubmitted() for tests.
Nested names such as user[address][city] are hydrated from normal PHP request arrays.
Rendered attribute values, labels, errors, textarea values, choices, and button text are
escaped. Passing literal strings to Group::add() is the explicit raw-HTML escape hatch;
never pass untrusted input to it.
Password and file values are not repopulated. Select and choice controls reject submitted values that are absent from their configured item list.
file uses FileInput; image uses ImageInput and additionally verifies that PHP recognizes
the temporary upload as an image. Upload fields support these optional validation rules:
max-size: maximum size in bytes, measured from the temporary file rather than client data.mime-types: one MIME type or an array of allowed types, detected from file contents with PHP's Fileinfo extension. Wildcards such asimage/*are supported.extensions: one filename extension or an array of allowed extensions. Matching is case-insensitive and leading dots are optional.
When accept is not set explicitly, it is generated from mime-types, or from extensions
when no MIME types are configured. The browser attribute is only a file-picker hint; the
configured options are also enforced by server-side validation.
FileInput::moveUploadedFile() accepts an explicit path or directory, sanitizes client
filenames used with directories, refuses overwrites by default, and does not delete wildcard
matches. Applications should still choose a safe storage location and generated destination
name, and apply any domain-specific content scanning needed before serving uploaded files.
Upload forms must use multipart/form-data. This Bootstrap 5 example includes a required PDF,
an optional image, help text, and a submit button:
use Qubus\Form\FormBuilder;
use Qubus\Form\FormBuilder\Decorator\Bootstrap;
$form = FormBuilder::create(
options: ['id' => 'documents'],
attributes: [
'action' => '/documents/upload',
'enctype' => 'multipart/form-data',
],
)->addDecorator(new Bootstrap(Bootstrap::VERSION_5));
$form
->field('file', 'document', [
'description' => 'Document',
'required' => true,
'help' => 'PDF files up to 10 MB.',
'max-size' => 10 * 1024 * 1024,
'mime-types' => ['application/pdf'],
'extensions' => ['pdf'],
])
->end()
->field('image', 'cover', [
'description' => 'Cover image',
'help' => 'JPEG, PNG, or WebP.',
'max-size' => 5 * 1024 * 1024,
'mime-types' => ['image/jpeg', 'image/png', 'image/webp'],
'extensions' => ['jpg', 'jpeg', 'png', 'webp'],
])
->end()
->field('submit', 'upload', [
'description' => 'Upload files',
]);
echo $form;Rendered HTML:
<form action="/documents/upload" enctype="multipart/form-data" method="post" id="documents">
<div class="mb-3">
<label class="form-label" for="documents-document">Document *</label>
<input accept="application/pdf" type="file" name="document" required id="documents-document" class="form-control">
<div class="form-text">
PDF files up to 10 MB.
</div>
</div>
<div class="mb-3">
<label class="form-label" for="documents-cover">Cover image</label>
<input accept="image/jpeg,image/png,image/webp" type="file" name="cover" id="documents-cover" class="form-control">
<div class="form-text">
JPEG, PNG, or WebP.
</div>
</div>
<div class="mb-3">
<button type="submit" id="documents-upload" class="btn btn-secondary">Upload files</button>
</div>
</form>Validate before moving either upload. moveUploadedFile() only accepts a genuine file uploaded
through PHP and refuses to replace an existing destination unless its second argument is true:
use Qubus\Form\FormBuilder\FileInput;
if ($form->isSubmitted() && $form->isValid()) {
$document = $form->get('document');
if ($document instanceof FileInput && $document->isUploaded()) {
$document->moveUploadedFile('/srv/private-uploads/generated-document.pdf');
}
}