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

Forms::fake() swaps the manager for FormsFake — a recording FormsManager subtype, so injected managers get it too — and returns it as your handle. It still performs against the database: the rows you’d expect are really created, events fire, and a review still opens its approvals request. The fake records every call on top — through the facade, an injected manager, the builders, Forms::submission(), the HasForms trait and forms:sync:

use RoundlyConsulting\Approvals\Models\ApprovalRequest;
use RoundlyConsulting\Forms\Facades\Forms;
use RoundlyConsulting\Forms\Models\FormSubmission;

$fake = Forms::fake();

// ... exercise your application code that submits the form ...

$fake->assertSubmitted($form);
$fake->assertSubmitted($form, fn ($result, $form) => $result->fieldCount === 3);
$fake->assertSubmittedCount(1);
$fake->assertNotSubmitted($otherForm);
$fake->assertReviewOpened(fn (ApprovalRequest $request, FormSubmission $submission) => $request->quorum === 2);

Every assertion

RecordsAssertAssert none
submit(), $user->submitTo()assertSubmitted(?Form, ?callable), assertSubmittedCount(int), assertNotSubmitted(?Form)assertNothingSubmitted()
draft(), $user->draftTo()assertDrafted(?Form, ?callable)assertNothingDrafted()
finalize(), submission()->finalize()assertFinalized(?string $uuid)assertNothingFinalized()
define()assertFormDefined(string $key)assertNoFormDefined()
create(), define()->create()assertFormCreated(?callable)assertNoFormCreated()
update()->save()assertFormUpdated(string $key)assertNoFormUpdated()
createSubmission()assertSubmissionCreated(?callable)assertNoSubmissionCreated()
sync(), forms:syncassertSynced()assertNothingSynced()
review()->…->open()assertReviewOpened(?callable)assertNothingReviewed()
use RoundlyConsulting\Approvals\Models\ApprovalRequest;
use RoundlyConsulting\Forms\DataTransferObjects\SubmissionResult;
use RoundlyConsulting\Forms\Models\Form;
use RoundlyConsulting\Forms\Models\FormSubmission;
use RoundlyConsulting\Forms\Models\Submission;

// submit(), $user->submitTo()
$fake->assertSubmitted($form, fn (SubmissionResult $result, Form $form) => $result->fieldCount === 3);
$fake->assertSubmittedCount(1);
$fake->assertNotSubmitted($otherForm);
$fake->assertNothingSubmitted();

// draft(), $user->draftTo()
$fake->assertDrafted($form, fn (SubmissionResult $result, Form $form) => $result->fieldCount > 0);
$fake->assertNothingDrafted();

// finalize(), Forms::submission($uuid)->finalize()
$fake->assertFinalized($draft->uuid);
$fake->assertNothingFinalized();

// define(), create(), update()->save()
$fake->assertFormDefined('contact');
$fake->assertNoFormDefined();
$fake->assertFormCreated(fn (Form $form) => $form->key === 'contact');
$fake->assertNoFormCreated();
$fake->assertFormUpdated('contact');     // only once save() has run
$fake->assertNoFormUpdated();

// createSubmission()
$fake->assertSubmissionCreated(fn (Submission $row) => $row->uuid === $uuid);
$fake->assertNoSubmissionCreated();

// sync(), php artisan forms:sync
$fake->assertSynced();
$fake->assertNothingSynced();

// review()->…->open()
$fake->assertReviewOpened(fn (ApprovalRequest $request, FormSubmission $submission) => $request->quorum === 2);
$fake->assertNothingReviewed();
  • assertSubmitted() and assertDrafted() match by form key; their callbacks receive the SubmissionResult and the Form and must return true for one recorded call.
  • assertFormCreated() sees forms stored through Forms::create() and define()->create(); forms created inside sync() satisfy assertSynced() only.
  • assertFormDefined($key) passes when define($key, …) ran or a form with that key was created.
  • An update is recorded when save() runs — Forms::update($key) alone records nothing, so assertFormUpdated() passes only after save().
  • assertReviewOpened() callbacks receive the ApprovalRequest and the FormSubmission.

Test helpers

The InteractsWithForms trait adds fakeForms(), submitForm($form, $values, $sender) and draftForm(...). Values are nested per group or flat by group_key.field_key (one style per group) — the helper wraps them in the form key for you:

uses(RoundlyConsulting\Forms\Testing\InteractsWithForms::class);

$fake = $this->fakeForms();
$this->submitForm($form, ['details' => ['name' => 'Ann']]);
$this->submitForm($form, ['details.name' => 'Ann']);   // the same submission, flat
$fake->assertSubmitted($form);

Pest matchers

Opt into the expectation matchers from your tests/Pest.php:

RoundlyConsulting\Forms\Testing\FormExpectations::register();

expect($form)->toBeAcceptingSubmissions();
expect($expiredForm)->toBeExpired();

A full example

use RoundlyConsulting\Forms\Facades\Forms;
use RoundlyConsulting\Forms\GroupBuilder;

it('stores a contact submission', function () {
    $fake = Forms::fake();

    $form = Forms::define('contact', 'Contact us')
        ->public()
        ->group('details', 'Details', function (GroupBuilder $g): void {
            $g->field('name', 'Name')->required();
        })
        ->create();

    $this->post('/contact', ['contact' => ['details' => ['name' => 'Ann']]])
        ->assertRedirect();

    $fake->assertSubmitted($form, fn ($result) => $result->fieldCount === 1);
    expect(Forms::submissions($form)->first()->value('name'))->toBe('Ann');
});

Factories

Every model has a factory, with states for the common cases:

use RoundlyConsulting\Forms\Models\Form;
use RoundlyConsulting\Forms\Models\FormSubmission;
use RoundlyConsulting\Forms\Models\Submission;

Form::factory()->public()->create();
Form::factory()->withExpiration()->create();

Submission::factory()->draft()->create();
FormSubmission::factory()->pending()->create();

Run your suite with the forms migrations (and the dependency packages’ migrations) loaded, e.g. via RefreshDatabase. To run the package’s own tests:

composer test

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.