Skip to content

Grav Plugin Forms — Form State, Validation and File Uploads

DodaTech Updated 2026-06-27 8 min read

In this tutorial, you'll learn Grav plugin forms — form state management, validation rules, file upload handling, multi-step forms, and integrating custom forms with the Form plugin for data processing.

What You'll Learn

  • The Form plugin architecture and configuration
  • Creating forms in page frontmatter
  • Form field types and validation rules
  • File upload handling and security
  • Multi-step forms with conditional logic
  • Form data processing: email, database, webhooks
  • Custom form templates and layout

Why It Matters

In WordPress, forms require plugins like Contact Form 7 or Gravity Forms. In Grav, forms are built into the Form plugin using YAML configuration. You define fields, validation, and actions (email, save, Webhook) in the page frontmatter. No PHP code needed for standard forms. For advanced forms, you extend the Form plugin with custom handlers. This means form creation is a configuration task, not a development task.

Real-World Use

A product documentation site needs a "Report a Bug" form that accepts: bug title, description, severity level, browser info (auto-detected), and a screenshot upload. The form saves submissions to a JSON file for the development team to review and sends an email notification. The entire form is configured in YAML — no backend code, no database, no third-party service.

Learning Path

flowchart LR
    A["Plugin Events"] --> B["Plugin Forms
← You are here"]:::current B --> C["Plugin Admin"] C --> D["Plugin CLI"] D --> E["Multilingual"] E --> F["User Management"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Installing the Form Plugin

bin/gpm install form

The Form plugin provides the framework for creating, validating, and processing forms.

Creating a Basic Form

Forms are defined in page frontmatter. Create user/pages/06.contact/form.md:

---
title: Contact Us
form:
    name: contact-form
    fields:
        name:
            label: Name
            placeholder: Enter your name
            type: text
            validate:
                required: true

        email:
            label: Email
            placeholder: Enter your email
            type: email
            validate:
                required: true
                pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"

        subject:
            label: Subject
            type: select
            default: general
            options:
                general: General Inquiry
                support: Technical Support
                billing: Billing Question
            validate:
                required: true

        message:
            label: Message
            placeholder: Your message here
            type: textarea
            validate:
                required: true
                min: 10
                max: 5000

    buttons:
        submit:
            type: submit
            value: Send Message
        reset:
            type: reset
            value: Clear

    process:
        - email:
            subject: "New contact form submission"
            from: "{{ form.value.email }}"
            to: "support@dodatech.com"
        - save:
            file: "user://data/contacts/{{ form.value.name|slugize }}.json"
        - message: "Thank you for contacting us!"
        - reset: true
---

# Contact Us

Have a question or need help? Fill out the form below and we will get back to you.

Form Field Types

Type HTML Use
text <input type="text"> Single line text
email <input type="email"> Email address
number <input type="number"> Numeric input
textarea <textarea> Multi-line text
select <select> Dropdown list
checkbox <input type="checkbox"> Single checkbox
checkboxes Multiple checkboxes Multiple selection
radio Radio buttons Single selection from options
file <input type="file"> File upload
hidden <input type="hidden"> Hidden field
date <input type="date"> Date picker
color <input type="color"> Color picker
captcha reCAPTCHA Spam protection

Validation Rules

fields:
    email:
        type: email
        validate:
            required: true
            pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
            message: "Please enter a valid email address"

    age:
        type: number
        validate:
            required: true
            min: 18
            max: 120

    bio:
        type: textarea
        validate:
            min: 10
            max: 1000
            message: "Bio must be between 10 and 1000 characters"

    agree:
        type: checkbox
        validate:
            required: true

File Uploads

fields:
    screenshot:
        type: file
        label: Upload screenshot
        multiple: false
        destination: 'user://data/uploads/bugs'
        accept:
            - 'image/png'
            - 'image/jpeg'
            - 'image/gif'
            - 'application/pdf'
        validate:
            required: false
            max_file_size: 5242880  # 5MB

Upload Processing

process:
    - uploads:
        folder: 'user://data/uploads/bugs'
    - email:
        subject: "Bug report with attachment"
        attachments: true

Multi-Step Forms

Define multiple form pages that submit progressively:

---
title: Registration
form:
    name: multi-step-registration

    steps:
        account:
            title: Account Details
            fields:
                username:
                    type: text
                    validate:
                        required: true
                email:
                    type: email
                    validate:
                        required: true

        profile:
            title: Profile Information
            fields:
                fullname:
                    type: text
                    validate:
                        required: true
                bio:
                    type: textarea

        confirmation:
            title: Confirm and Submit
            fields:
                agree_terms:
                    type: checkbox
                    label: "I agree to the terms and conditions"
                    validate:
                        required: true

    process:
        - email:
            subject: "New registration"
            to: "admin@example.com"
        - message: "Registration complete!"

Form Data Processing Actions

Email

process:
    - email:
        subject: "Form: {{ form.value.subject }}"
        body: "{% include 'forms/data.html.twig' %}"
        from: "{{ form.value.email }}"
        to: "admin@example.com"
        cc: "team@example.com"
        bcc: "archive@example.com"
        attachments: true

Save to File

process:
    - save:
        file: "user://data/form-submissions/{{ date('Y-m-d') }}.json"

Webhook

process:
    - webhook:
        url: "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
        method: POST
        body: "New submission from {{ form.value.name }}"

Conditional Processing

process:
    - if: "form.value.priority == 'high'"
      then:
          - email:
              subject: "URGENT: High priority request"
              to: "urgent@example.com"
      else:
          - email:
              subject: "Standard request"
              to: "normal@example.com"

Custom Form Template

Override the form template in your theme:

user/themes/mytheme/templates/forms/default-form.html.twig:

<form name="{{ form.name }}" method="post" action="{{ form.action }}" enctype="multipart/form-data">
    {% for field in form.fields %}
        <div class="form-group">
            <label for="{{ field.name }}">{{ field.label }}</label>

            {% if field.type == 'text' or field.type == 'email' %}
                <input type="{{ field.type }}" name="{{ field.name }}"
                       value="{{ form.value(field.name) }}"
                       placeholder="{{ field.placeholder }}"
                       {% if field.validate.required %}required{% endif %} />

            {% elseif field.type == 'textarea' %}
                <textarea name="{{ field.name }}"
                          placeholder="{{ field.placeholder }}"
                          rows="5"
                          {% if field.validate.required %}required{% endif %}>{{ form.value(field.name) }}</textarea>

            {% elseif field.type == 'select' %}
                <select name="{{ field.name }}">
                    {% for key, option in field.options %}
                        <option value="{{ key }}" {% if form.value(field.name) == key %}selected{% endif %}>
                            {{ option }}
                        </option>
                    {% endfor %}
                </select>

            {% elseif field.type == 'file' %}
                <input type="file" name="{{ field.name }}" accept="{{ field.accept|join(',') }}" />

            {% endif %}

            {% if field.validate.required %}
                <span class="required-badge">Required</span>
            {% endif %}
        </div>
    {% endfor %}

    <div class="form-actions">
        {% for button in form.buttons %}
            <button type="{{ button.type }}" class="btn btn-{{ button.type }}">
                {{ button.value }}
            </button>
        {% endfor %}
    </div>

    {{ nonce_field('form')|raw }}
</form>

Learning Path

flowchart LR
    A["Plugin Events"] --> B["Plugin Forms
← You are here"]:::current B --> C["Plugin Admin"] C --> D["Plugin CLI"] D --> E["Multilingual"] classDef current fill:#38bdf8,color:#0f172a,stroke-width:2px

Common Mistakes

  1. Not including the nonce_field in the form: Grav's Form plugin requires a CSRF nonce field to prevent cross-site request forgery. Always include {{ nonce_field('form')|raw }} in form templates.

  2. Missing destination on file uploads: File uploads require a destination path. Without it, uploaded files are stored in a temporary location and lost after processing.

  3. Not setting max file size: Without max_file_size, large file uploads can exhaust server memory or storage. Always set a reasonable maximum size.

  4. Forgetting Process actions after form fields: The process section defines what happens after submission. Without it, the form collects data but never processes it.

  5. Using same form name on multiple pages: Each form on the site must have a unique name. Duplicate form names cause data conflicts and unexpected behavior.

Practice Questions

  1. What YAML section defines what happens after a form is submitted? Answer: The process section. It defines actions like send email, save to file, call webhook, or show a success message.

  2. How do you require a field to be filled in? Answer: Add validate.required: true to the field definition. Grav validates required fields before running the process actions.

  3. What is the purpose of the nonce_field in a form? Answer: It generates a hidden CSRF token that prevents cross-site request forgery attacks. Grav validates this token when the form is submitted.

  4. How do you create a multi-step form with the Form plugin? Answer: Define steps under the form: key. Each step is a named section with its own fields. Grav shows one step at a time with previous/next navigation.

  5. Challenge: Build a complete bug report form for a software project. Include fields for: bug title (required, text), severity (select: critical, major, minor, cosmetic), URL where bug was found (required, URL type), description (required, textarea, min 20 chars), steps to reproduce (textarea), browser and OS (autofilled via JavaScript hidden fields), screenshot upload (max 3 files, 5MB each, PNG/JPG only), and email for follow-up. Configure the form to save submissions to a JSON file, send email to the development team with attachments, and show a confirmation message. Add validation for all fields and test with sample submissions.

FAQ

Can I store form submissions in a database instead of files?

Yes. The Form plugin supports custom handlers. Create a plugin that subscribes to onFormProcessed and store submission data in MySQL, PostgreSQL, or any other database.

How do I add reCAPTCHA to a form?

Add a captcha field: type: captcha with recaptcha_site_key and recaptcha_secret_key configured in the Form plugin settings. Google reCAPTCHA must be enabled.

Can I send form data to a third-party API?

Yes. Use the webhook process action or create a custom process handler. The webhook action sends the form data as a POST request to any URL.

How do I prevent spam submissions?

Use reCAPTCHA, add honeypot fields (hidden fields that bots fill but humans do not), require JavaScript for submission, and rate-limit submissions per IP address.

Can I edit form submissions after they are saved?

The Form plugin saves submissions as read-only files. For editable submissions, use a custom plugin that stores data in a format you can edit through the Admin panel.

Mini Project

Goal: Build a complete contact and feedback system with forms.

  1. Create a contact form with name, email, subject (select), message (textarea), and file upload
  2. Configure email processing to send to multiple recipients
  3. Configure file saving to store submissions as JSON
  4. Create a multi-step registration form with 3 steps
  5. Add reCAPTCHA to all forms for spam protection
  6. Create custom form templates for each form type
  7. Add conditional logic: show/hide fields based on previous selections
  8. Create a "Report a Problem" form with priority levels
  9. Add a feedback form at the bottom of documentation pages
  10. Test all forms: submission, validation, file uploads, email delivery, and error handling

What's Next

Now you can create complex forms. Next, learn to build admin features:

Continue to Lesson 27: Plugin Admin — Custom admin pages, admin widgets, and dashboard panels.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro