Search by

marrow / form-builder

AureDulvresse

Django-style declarative forms for Marrow — define fields on the backend, validate with the framework's existing rule engine, render on the frontend.

v1.0.0 2026-09-30 15:06 UTC

This package is not auto-updated.

Last update: 2026-10-01 13:40:48 UTC


README

Marrow

Marrow Form Builder

Django-style declarative forms for Marrow.

CI Packagist Version Packagist Downloads PHP 8.2+ License MIT

Define fields on the backend, validate with the framework's existing rule engine, render on the frontend.

composer require marrow/form-builder

No further setup — there's no module to enable, no config file, nothing to register. It's a plain library: use the classes where you need them.

Why

  • No new validation engine. A field's rules is the exact same pipe-separated string Marrow\Validation\ValidatorInstance already understands (required|email|unique:users,email, ...). Every existing rule works unchanged — there's nothing form-specific to learn on the validation side.
  • No PHP-fluent HTML builder. Rendering is deliberately simple, semantic HTML (form-field, form-label, form-input, form-error classes — no framework-specific styling baked in) — style those classes yourself, or override rendering per-field for full control. A fluent Form::open()->text(...)->submit()-style API was considered and rejected: that pattern is what Laravel's own form builder looked like before being deprecated and removed, precisely because generating HTML from chained PHP calls ages badly the moment a form needs anything custom.

Defining a form

namespace Modules\Blog\Forms;

use Marrow\FormBuilder\Fields\CharField;
use Marrow\FormBuilder\Fields\ChoiceField;
use Marrow\FormBuilder\Fields\EmailField;
use Marrow\FormBuilder\Fields\TextareaField;
use Marrow\FormBuilder\Form;

class ContactForm extends Form
{
    #[CharField(label: 'Your name', rules: 'required|string|max:255')]
    public string $name;

    #[EmailField(label: 'Your email', rules: 'required|email')]
    public string $email;

    #[ChoiceField(
        label: 'Subject',
        rules: 'required|in:support,sales,other',
        choices: ['support' => 'Support', 'sales' => 'Sales', 'other' => 'Other'],
        placeholder: 'Choose one…',
    )]
    public string $subject;

    #[TextareaField(label: 'Message', rules: 'required|string|max:2000', rows: 6)]
    public string $message;
}

The declared property (public string $name;) is never read or written — it exists purely so Form can enumerate it via reflection and find the attribute on it. The attribute is the field: CharField, EmailField, etc. are plain objects that also happen to be valid PHP attributes.

Using it in a controller

use Modules\Blog\Forms\ContactForm;

class ContactController extends Controller
{
    public function show(): Response
    {
        return $this->view('@blog/contact', ['form' => new ContactForm()]);
    }

    public function submit(Request $request): Response
    {
        $form = ContactForm::fromRequest($request);

        if (!$form->isValid()) {
            return $this->view('@blog/contact', ['form' => $form]); // re-shows with errors
        }

        Mail::send(new ContactMail($form->cleanedData()));
        return $this->view('@blog/contact', ['form' => new ContactForm(), 'sent' => true]);
    }
}

Form::fromRequest($request) binds $request->all(). On a failed isValid(), every field's submitted value is retained (so re-rendering shows what the visitor typed) except password fields, which never re-populate.

Rendering in Twig

<form method="POST" action="{{ route('contact.submit') }}">
    {{ csrf_field() }}
    {{ form.render()|raw }}
    <button type="submit">Send</button>
</form>

Or field by field, for custom layout:

{{ form.field('email').render()|raw }}
{{ form.field('message').render()|raw }}

|raw is required — render() returns HTML, and Twig auto-escapes by default. The HTML itself already escapes every submitted value and error message internally (htmlspecialchars()), so this is safe.

The Form API

Method Returns
new Form($data = []) bind an array directly
Form::fromRequest($request) bind $request->all()
isValid(): bool runs validation once (cached), returns pass/fail
errors(): array ['field' => ['message', ...], ...], empty until checked
cleanedData(): array submitted values for every declared field
field(string $name): ?Field one field, for custom rendering
fields(): array every field, name => Field, in declaration order
render(): string every field rendered, in declaration order

Fields reference

Field Renders Notes
CharField <input type="text">
EmailField <input type="email"> pair with the email rule for real format validation
PasswordField <input type="password"> never re-populates on re-render, even after a failed submission
TextareaField <textarea> extra rows: int param
IntegerField <input type="number"> pair with integer/gte:n/lte:n rules
BooleanField <input type="checkbox"> label renders inline, after the input, not above it
ChoiceField <select> choices: array (value => label); placeholder becomes a disabled first <option>
HiddenField <input type="hidden"> no label, no wrapper, no error/help display

Every field constructor accepts label, rules, help, placeholder (field-specific ones add their own, e.g. TextareaField's rows and ChoiceField's choices).

Writing a custom field

namespace App\Forms\Fields;

use Attribute;
use Marrow\FormBuilder\Field;

#[Attribute(Attribute::TARGET_PROPERTY)]
class ColorField extends Field
{
    protected function renderInput(string $id): string
    {
        return '<input type="color" class="form-input" id="' . htmlspecialchars($id)
            . '" name="' . htmlspecialchars($this->name) . '" value="'
            . htmlspecialchars((string) $this->value) . '">';
    }
}

Every concrete field class needs its own #[Attribute(Attribute::TARGET_PROPERTY)] marker — PHP requires it directly on the class actually used in #[ColorField(...)] syntax; inheriting Field doesn't carry it over automatically. Override render() entirely instead of just renderInput() for a field whose whole layout differs from the label-above-input default (see BooleanField/HiddenField's source for two real examples).

Requirements

PHP 8.2+, marrow/framework.

License

MIT — see LICENSE.

Made with ❤️ by Aure Dulvresse