pear/html_quickform2
HTML_QuickForm2 is a PHP library for creating, validating, and rendering HTML forms, with server-side and client-side validation, multipage wizards, and pluggable elements and renderers.
composer require pear/html_quickform2:^2.5
Nothing registers itself. There is no service provider, facade, config file, Blade directive or publish command — this is a framework-agnostic PEAR library that happens to be Composer-installable. Since 2.1 it is pure-autoload, so no require_once is needed (and you should not add one; mixing the PEAR-installed copy with the Composer copy in the same process is a support nightmare).
Where to look, in order:
vendor/pear/html_quickform2/docs/examples/ — the only real usage reference; the PEAR manual pages linked from the README are ancient but still accurate for the core API.vendor/pear/html_quickform2/src/HTML/QuickForm2/ — Element/ (what type strings map to what class), Rule/ (what validators ship), Renderer/ (output and plugins).vendor/pear/html_quickform2/Release/v2.5.0.md — read this before blaming the library for a deprecation. 2.5.0 is a compatibility-only release (PHP 8.5/8.6 clean, PHPUnit-Polyfills bump); there is no new API surface in recent history, so don't hunt for features.A single-page "contact" form posted back to the same URI, built in the controller, echoed in Blade.
// app/Http/Controllers/ContactController.php
use Illuminate\Http\Request;
public function contact(Request $request)
{
$form = new HTML_QuickForm2('contact');
$form->setAttribute('action', route('contact'))
->setAttribute('method', 'post');
// Defaults come from the request so a failed submit repopulates itself
$form->addDataSource(new HTML_QuickForm2_DataSource_Array($request->all()));
$fieldset = $form->addElement('fieldset')->setLabel('Contact us');
$name = $fieldset->addElement('text', 'name', ['size' => 50])->setLabel('Your name');
$email = $fieldset->addElement('text', 'email', ['size' => 50])->setLabel('Email');
$message = $fieldset->addElement('textarea', 'message', ['rows' => 6])->setLabel('Message');
$fieldset->addElement('submit', 'send', ['value' => 'Send']);
$name->addFilter('trim');
$name->addRule('required', 'Please enter your name');
$email->addFilter('trim');
$email->addRule('required', 'Please enter your email')
->addRule('email', 'That does not look like an email address');
$message->addRule('required', 'Please write something');
if ($form->validate()) { // true only for a POST of this form
$values = $form->exportValues(); // ['name' => ..., 'email' => ...]
// persist, then redirect (PRG)
}
return view('contact', ['form' => $form]);
}
{{-- resources/views/contact.blade.php --}}
{!! $form !!}
Two things make this work on day one:
$form->validate() calls isSubmitted() internally, which checks an auto-generated hidden _qf__<formname> field. On a plain GET it returns false without you writing any method check — that's why the same route can serve GET and POST. Use Route::match(['get','post'], ...) or set action explicitly.echo $form / {!! $form !!} invokes the default renderer via __toString(). No view composer, no partial, no escaping surprises — but also no Tailwind/Bootstrap classes, so expect to style it yourself.The single biggest day-to-day win is treating a form as a value object in app/Forms/, not as something the container knows about.
// app/Forms/AbstractForm.php
abstract class AbstractForm
{
protected HTML_QuickForm2 $form;
public function __construct(protected Request $request)
{
$this->form = new HTML_QuickForm2(static::class);
$this->form->addDataSource(new HTML_QuickForm2_DataSource_Array($request->all()));
$this->build();
}
abstract protected function build(): void;
public function submitted(): bool { return $this->form->validate(); }
public function values(): array { return $this->form->exportValues(); }
public function render(): HtmlString { return new HtmlString((string) $this->form); }
}
Then a controller is three lines and the form is unit-testable without HTTP:
$form = new ContactForm($request);
if ($request->isMethod('post') && $form->submitted()) {
Contact::create($form->values());
return redirect()->route('contact')->with('status', 'Thanks!');
}
return view('contact', ['form' => $form]);
Never register a form instance as a singleton. Forms are stateful (values, errors, submit state). Under Octane a cached instance leaks values between requests. Build a fresh object per request.
There is no old() integration — HTML_QuickForm2_DataSource_Array fed from $request->all() is the old-input mechanism, because QuickForm2 uses the data source both as defaults and as the submitted values. Consequences:
address[street], tags[]).$request->all(), but a JSON payload that only contains a nested data key will silently not match anything and validate() will fail on required.$request->only([...]) or $request->except(['page', 'sort']) instead.setConstant($value) is for values the user must not be able to override (id, CSRF token, computed hashes) — the data source and filters never touch constants. setValue() is a default and will be overridden by input.QuickForm2 renders its own <form> tag, so @csrf inside a Blade template won't land inside it. Add the token as a hidden element with a constant value:
$form->addElement('hidden', '_token')->setConstant(csrf_token());
Laravel's VerifyCsrfToken middleware then validates it exactly as it would a @csrf field. (The auto _qf__<formname> hidden field is separate and is what drives isSubmitted().)
| Need | Call |
|---|---|
| Single-line text | addElement('text', 'name', ['size' => 40]) |
| Password | addElement('password', 'pw') |
| Select / radio / checkbox lists | addElement('select', 'city', null, $options) — the 4th $data argument carries options (verify in Element/Select.php if you're on an old 2.x) |
| Static copy / headings | addElement('static', 'intro')->setContent('<p>…</p>') |
| Container | addElement('fieldset')->setLabel('Legend text') |
| Group (nested values) | addElement('group', 'address'), then ->addElement(...) on it |
| Hidden marker | addElement('hidden', 'step')->setValue('2') |
HTML5 input types (email, number, date, tel) are explicitly not shipped — the README says "planned". Either pass the attribute on a text element and check what the renderer emits, or subclass HTML_QuickForm2_Element_Input_Text and override the rendered type. This is the most common reason people bounce off the package in 2025: you get HTML4 markup plus whatever attributes you bolt on, and mobile keyboards are driven by type.
addRule($type, $message, $options) lives on the element, not the form. (The $form->addRule('username', 'Required', 'required') shape is HTML_QuickForm 1 API — a frequent migration bug.)required, notempty, email, regex, length/minlength/maxlength, compare, callback, plus the logical combinators mentioned in the README. compare is the password-confirmation rule: $confirm->addRule('compare', 'Passwords differ', ['operand' => 'password']).callback is your escape hatch into Laravel-land, and it is where you should do anything non-trivial rather than fight regex:$vat->addRule('callback', 'Unknown VAT number', [
'callback' => fn ($value) => VatService::exists($value),
]);
trim, htmlspecialchars, custom callables) run when the value is read, so addFilter('trim') before addRule('required') behaves the way you want. Keep filter-then-rule ordering as a habit and you'll never need to reason about it.exportValues() is a plain array. Never Model::create($form->exportValues()). Whitelist explicitly or run the exported array through Validator::make().The default output is deliberately plain markup with generic element / label classes. Two sane paths:
{!! $form !!} inside a styled card, then target the emitted classes in CSS. Fine for one or two forms.HTML_QuickForm2_Renderer::factory('default') gives you a renderer with a plugin system and options; for Tailwind or Bootstrap you'll get further, faster, by extending HTML_QuickForm2_Renderer_Default and overriding renderElement() / renderForm() than by registering plugins:$renderer = HTML_QuickForm2_Renderer::factory('default');
$renderer->setOption('group_errors', true); // collects errors rather than per-element
echo $form->render($renderer);
Check the "Form output customization" manual page and Renderer/Default.php for the exact option names in your installed version — they are not discoverable from the README.
The self-contained JS lives at vendor/pear/html_quickform2/js/ (there is no vendor:publish). Copy quickform.js into public/js/ (or import it in resources/js/app.js), load it on the page, and let the renderer emit the setup script for elements that have rules. Verify with view-source that (a) the script tag is present, (b) it appears after quickform.js. Under Livewire, Turbo or any partial-page navigation the setup script may only run once — re-initialise after navigation, or just rely on server-side validation there.
Core QuickForm2 only makes multipage "possible"; the HTML_QuickForm2_Controller class came from a sibling PEAR package. In a Laravel app you are better off with session-backed steps, Livewire, or one form per step plus a session array — the controller port is not worth the archaeology.
No namespaces, no stubs, unhappy static analysis. Every class is a global HTML_QuickForm2_* with underscore naming. PHPStan/Larastan won't find them unless you add the vendor path to scanFiles/paths, and you'll get class.notFound noise on return types like HTML_QuickForm2_Element. Add the path once and move on; there are no official stubs.
addElement()'s argument order. addElement($type, $name, $attributes, $data). Attributes (third) are HTML attributes plus library keys the element consumes; $data (fourth) is element-specific payload (options for select/radio/checkbox). Passing options as the third argument "sometimes works" depending on version — pass them fourth.
Group values are nested. A group named address with member street submits as address[street] and exports as ['address' => ['street' => ...]]. Flat $request->validate(['street' => ...]) rules will not match. Multi-select/checkbox elements export PHP arrays, so $request->all() round-trips fine but your DB column needs json_encode.
Disabled vs readonly vs frozen. A disabled attribute means the browser does not submit the field, so QuickForm2 falls back to the data source — a required rule on it may fail (or quietly pass with a stale value) unpredictably. For "visible but not editable", use readonly, or freeze the element; reserve disabled for values you also setConstant().
Errors are only available after validate(). $form->getElement('email')->getError() returns nothing until validation has run. In tests, drive validate() first, then assert per element. For a quick "why did this fail" in dev, loop $form->getElements() and dump name + error rather than looking for a debug flag.
The _qf__<formname> hidden field is real. It's how isSubmitted() works, and it's why $form->validate() returns false on GET. It also means a functional test posting via HTML_QuickForm2_DataSource_Array must include '_qf__myform' => '1', or the form will appear unsubmitted. If your form name contains characters a browser would mangle, rename it.
Escaping. The renderer escapes labels and messages, but static elements' content and anything you feed through setLabel()/message strings is your responsibility once you start putting HTML or user data in it. Sanitise anything derived from $request.
Deprecations will come from your code, not theirs. 2.5.0 is verified clean on PHP 8.5 and 8.6. If you see Passing null to parameter of type array offset or spl_object_hash() is deprecated, it's your custom element/validator/renderer. Run php -d error_reporting=E_ALL -d display_errors=1 artisan serve once when you first add a custom element and fix it there rather than in production logs.
Don't mix install methods. Composer install has no require_once; the PEAR tarball does. If you inherit a project that require_once 'HTML/QuickForm2.php' and autoloads, you can end up with two class definitions or a version skew where the renderer and element come from different copies. Pick one.
Maintenance risk is the real gotcha. 23 stars, zero dependents, single-maintainer PEAR package, releases that are compatibility-only. For a long-lived Laravel app, weigh it honestly: Blade + FormRequest + a UI kit covers 90% of what this does, with types and IDE support. QuickForm2 earns its place when (a) you have dozens of structurally similar admin forms and want one declarative definition driving validation + rendering, (b) you're porting a legacy QF1/PEAR application, or (c) you genuinely want rule definitions co-located with markup and a renderer pipeline. If it becomes load-bearing, pin ^2.5, vendor the source into packages/, and budget for the fork — there's no ecosystem to fall back on.
How can I help you explore Laravel packages today?