NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Forms for Laravel

Validating & submitting

The Forms facade resolves a form with its ordered groups and fields, validates request input against each field’s rules, and stores submissions:

use RoundlyConsulting\Forms\Facades\Forms;

// Eager-loads groups and fields, ordered by their `order` column.
// Throws RoundlyConsulting\Forms\Exceptions\FormNotFoundException for an unknown key.
$form = Forms::find('contact');

// Validate request input — throws Illuminate\Validation\ValidationException on failure
// (a broken field rule and a value that fails its field's type alike). Returns the validated data.
$validated = Forms::validate(form: $form, request: request());

// Persist a submission for every field. Returns a SubmissionResult.
$result = Forms::submit(
    form: $form,
    request: request(),
    sender: auth()->user(), // any Eloquent model, or null
);

$result->uuid;        // shared reference id for this submission
$result->fieldCount;  // number of fields stored
$result->submittedAt; // CarbonInterface timestamp

submit() stores; it does not validate. Call validate() first — it runs Laravel’s validator with each visible field’s rules (using field names as attribute labels and your custom messages), then type-checks the values. Both failures throw the same ValidationException, so Laravel redirects back with errors either way. A typical controller:

use Illuminate\Http\Request;
use RoundlyConsulting\Forms\Facades\Forms;

final class ContactFormController
{
    public function store(Request $request)
    {
        $form = Forms::find('contact');

        // Throws ValidationException (redirect back with errors) on failure.
        Forms::validate($form, $request);

        $result = Forms::submit($form, $request, $request->user());

        return back()->with('reference', $result->uuid);
    }
}

submit() runs in a transaction: it creates one FormSubmission aggregate and one Submission row per field, all sharing the returned uuid, then dispatches FormSubmitted. A field its conditions hide gets a row with no value — nothing the request sent for it is kept. The sender is any Eloquent model, or null for anonymous submissions.

Closed forms are rejected

Every path to a final submission rejects a form that isn’t accepting them — a non-public form, or one whose expires_at has passed — by throwing FormSubmissionClosedException: submit(), draft() (and draftTo()), finalize() and the raw createSubmission(). finalize() checks again, so a draft saved while the form was open can’t be finalized after it closes. Pass bypassClosed: true for trusted internal or admin submissions:

use RoundlyConsulting\Forms\Exceptions\FormSubmissionClosedException;

try {
    Forms::submit($form, request(), $user);
} catch (FormSubmissionClosedException $e) {
    // the form is not public, or its expires_at has passed
}

// trusted internal/admin submissions skip the check
Forms::submit($form, request(), $user, bypassClosed: true);
Forms::draft($form, request(), $user, bypassClosed: true);
Forms::finalize($uuid, bypassClosed: true);

Lower-level API

// Write a single per-field row directly (imports, seeds) — the value is stored as-is.
// A new uuid is generated unless you pass one; rows that share a uuid form one submission.
$first = Forms::createSubmission($nameField, ['value' => 'Ann'], $user, bypassClosed: true);
Forms::createSubmission($emailField, ['value' => '[email protected]'], $user, uuid: $first->uuid, bypassClosed: true);

Forms::submission($first->uuid)->get()->values; // ['name' => 'Ann', 'email' => '[email protected]']

createSubmission() writes one row directly and obeys the closed-form rule unless you pass bypassClosed: true. Rows that share a uuid are filed under one final submission, created by the first of them, so Forms::submission($uuid) reads it and Forms::review($uuid) reviews it like any other. A malformed uuid, or one that names another form’s submission or a draft, throws SubmissionNotFoundException. Prefer an injected manager or the actions? See DI and actions.

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.