diff --git a/config/forms.php b/config/forms.php
index c2d434f0289..563b14b40e0 100644
--- a/config/forms.php
+++ b/config/forms.php
@@ -35,6 +35,18 @@
'send_email_job' => \Statamic\Forms\SendEmail::class,
+ /*
+ |--------------------------------------------------------------------------
+ | Incomplete Submissions
+ |--------------------------------------------------------------------------
+ |
+ | Incomplete (partial) submissions are automatically deleted after a set
+ | number of days. Set this to null to prevent their automatic deletion.
+ |
+ */
+
+ 'delete_incomplete_submissions_after' => 7,
+
/*
|--------------------------------------------------------------------------
| Exporters
diff --git a/resources/js/components/forms/SubmissionListing.vue b/resources/js/components/forms/SubmissionListing.vue
index 3ca80b6df83..da157c20b5d 100644
--- a/resources/js/components/forms/SubmissionListing.vue
+++ b/resources/js/components/forms/SubmissionListing.vue
@@ -9,12 +9,17 @@
:sort-direction="sortDirection"
:preferences-prefix="preferencesPrefix"
:filters="filters"
+ push-query
>
-
-
-
+
+
+
+
+
+
+
@@ -22,11 +27,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/components/ui/Publish/Form.vue b/resources/js/components/ui/Publish/Form.vue
index 8c156bf866a..35c4b22a99d 100644
--- a/resources/js/components/ui/Publish/Form.vue
+++ b/resources/js/components/ui/Publish/Form.vue
@@ -87,6 +87,9 @@ onUnmounted(() => saveKeyBinding.destroy());
+ >
+
+
+ {{ formattedDate }}
+
+
diff --git a/src/Actions/MarkNotSpam.php b/src/Actions/MarkNotSpam.php
new file mode 100644
index 00000000000..4e869fa8db0
--- /dev/null
+++ b/src/Actions/MarkNotSpam.php
@@ -0,0 +1,47 @@
+isSpam();
+ }
+
+ public function authorize($user, $submission)
+ {
+ return $user->can('delete', $submission);
+ }
+
+ public function confirmationText()
+ {
+ /** @translation */
+ return 'Are you sure you want to mark this submission as not spam?|Are you sure you want to mark these :count submissions as not spam?';
+ }
+
+ public function buttonText()
+ {
+ /** @translation */
+ return 'Mark As Not Spam|Mark :count as Not Spam';
+ }
+
+ public function run($submissions, $values)
+ {
+ $submissions->each->complete();
+
+ return trans_choice('Submission marked as not spam|Submissions marked as not spam', $submissions->count());
+ }
+}
diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php
index 1ce97715656..dba1546141c 100644
--- a/src/Forms/Submission.php
+++ b/src/Forms/Submission.php
@@ -19,6 +19,7 @@
use Statamic\Facades\Asset;
use Statamic\Facades\File;
use Statamic\Facades\FormSubmission;
+use Statamic\Facades\Site;
use Statamic\Facades\Stache;
use Statamic\Forms\Uploaders\AssetsUploader;
use Statamic\Forms\Uploaders\FilesUploader;
@@ -114,6 +115,25 @@ public function date()
return Carbon::createFromTimestamp($this->id());
}
+ public function isIncomplete(): bool
+ {
+ return (bool) $this->get('incomplete');
+ }
+
+ public function isSpam(): bool
+ {
+ return (bool) $this->get('spam');
+ }
+
+ public function status(): string
+ {
+ return match (true) {
+ $this->isSpam() => 'spam',
+ $this->isIncomplete() => 'incomplete',
+ default => 'complete',
+ };
+ }
+
/**
* Upload files and return asset IDs.
*
@@ -152,6 +172,10 @@ public function save()
{
$isNew = is_null($this->form()->submission($this->id()));
+ // Incomplete and spam submissions are stored but skip the Creating/Created
+ // events so listeners never receive an incomplete submission.
+ $isWithheld = $this->isIncomplete() || $this->isSpam();
+
$withEvents = $this->withEvents;
$this->withEvents = true;
@@ -159,7 +183,7 @@ public function save()
$this->afterSaveCallbacks = [];
if ($withEvents) {
- if ($isNew && SubmissionCreating::dispatch($this) === false) {
+ if ($isNew && ! $isWithheld && SubmissionCreating::dispatch($this) === false) {
return false;
}
@@ -175,7 +199,7 @@ public function save()
}
if ($withEvents) {
- if ($isNew) {
+ if ($isNew && ! $isWithheld) {
SubmissionCreated::dispatch($this);
}
@@ -183,6 +207,28 @@ public function save()
}
}
+ public function complete()
+ {
+ $existed = ! is_null($this->form()->submission($this->id()));
+
+ $this->remove('incomplete')->remove('spam');
+
+ if ($this->form()->store()) {
+ $this->save();
+
+ // A promoted incomplete already existed, so save() won't fire the created
+ // event. We dispatch it here so completion always emits it once.
+ if ($existed) {
+ SubmissionCreated::dispatch($this);
+ }
+ } else {
+ SubmissionCreated::dispatch($this);
+ }
+
+ // TODO: Use $this->site() here when we add the "site" key to submissions.
+ SendEmails::dispatch($this, Site::default());
+ }
+
public function deleteQuietly()
{
$this->withEvents = false;
diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
index 5eecb60a03a..e91c8ee77fc 100644
--- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
+++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php
@@ -36,6 +36,7 @@ public function index(FilteredRequest $request, $form)
$columns = $form
->blueprint()
->columns()
+ ->prepend(Column::make('status'), 'status')
->prepend(Column::make('datestamp'), 'datestamp')
->setPreferred("forms.{$form->handle()}.columns")
->rejectUnlisted()
@@ -131,6 +132,7 @@ public function show($form, $submission)
'form' => $form,
'id' => $submission->id(),
'formTitle' => $form->title(),
+ 'status' => $submission->status(),
'date' => $submission->date()->toIso8601String(),
'blueprint' => $blueprint->toPublishArray(),
'values' => $fields->values(),
diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php
index 1b151be7c80..477d9483c71 100644
--- a/src/Http/Controllers/FormController.php
+++ b/src/Http/Controllers/FormController.php
@@ -8,13 +8,11 @@
use Illuminate\Validation\ValidationException;
use Statamic\Contracts\Forms\Submission;
use Statamic\Events\FormSubmitted;
-use Statamic\Events\SubmissionCreated;
use Statamic\Exceptions\SilentFormFailureException;
use Statamic\Facades\Asset;
use Statamic\Facades\Form;
use Statamic\Facades\Site;
use Statamic\Forms\Exceptions\FileContentTypeRequiredException;
-use Statamic\Forms\SendEmails;
use Statamic\Http\Requests\FrontendFormRequest;
use Statamic\Support\Arr;
use Statamic\Support\Str;
@@ -70,16 +68,7 @@ public function submit(FrontendFormRequest $request, $form)
return $this->formSuccess($params, $submission, true);
}
- if ($form->store()) {
- $submission->save();
- } else {
- // When the submission is saved, this same created event will be dispatched.
- // We'll also fire it here if submissions are not configured to be stored
- // so that developers may continue to listen and modify it as needed.
- SubmissionCreated::dispatch($submission);
- }
-
- SendEmails::dispatch($submission, $site);
+ $submission->complete($site);
return $this->formSuccess($params, $submission);
}
diff --git a/src/Http/Resources/CP/Submissions/ListedSubmission.php b/src/Http/Resources/CP/Submissions/ListedSubmission.php
index 2f81734d40e..323b6995e2c 100644
--- a/src/Http/Resources/CP/Submissions/ListedSubmission.php
+++ b/src/Http/Resources/CP/Submissions/ListedSubmission.php
@@ -33,6 +33,7 @@ public function toArray($request)
$this->merge($this->values([
'datestamp' => $this->resource->date()->tz(config('app.timezone'))->toIso8601String(),
])),
+ 'status' => $this->resource->status(),
'url' => cp_route('forms.submissions.show', [$form->handle(), $this->resource->id()]),
'deleteable' => User::current()->can('delete', $this->resource),
];
@@ -42,6 +43,11 @@ protected function values($extra = [])
{
return $this->columns->mapWithKeys(function ($column) use ($extra) {
$key = $column->field;
+
+ if ($key === 'status') {
+ return ['status' => $this->resource->status()];
+ }
+
$value = $extra[$key] ?? $this->resource->get($key);
if (! $field = $this->blueprint->field($key)) {
diff --git a/src/Http/Resources/CP/Submissions/Submissions.php b/src/Http/Resources/CP/Submissions/Submissions.php
index 3b51ff3106c..fd9e89386f6 100644
--- a/src/Http/Resources/CP/Submissions/Submissions.php
+++ b/src/Http/Resources/CP/Submissions/Submissions.php
@@ -6,6 +6,8 @@
use Statamic\CP\Column;
use Statamic\Http\Resources\CP\Concerns\HasRequestedColumns;
+use function Statamic\trans as __;
+
class Submissions extends ResourceCollection
{
use HasRequestedColumns;
@@ -35,6 +37,16 @@ private function setColumns()
->columns()
->ensurePrepended(Column::make('datestamp')->label('Date'));
+ $status = Column::make('status')
+ ->label(__('Status'))
+ ->listable(true)
+ ->visible(true)
+ ->defaultVisibility(true)
+ ->defaultOrder($columns->count() + 1)
+ ->sortable(false);
+
+ $columns->put('status', $status);
+
if ($key = $this->columnPreferenceKey) {
$columns->setPreferred($key);
}
diff --git a/src/Jobs/DeleteIncompleteFormSubmissions.php b/src/Jobs/DeleteIncompleteFormSubmissions.php
new file mode 100644
index 00000000000..75813ad0be5
--- /dev/null
+++ b/src/Jobs/DeleteIncompleteFormSubmissions.php
@@ -0,0 +1,30 @@
+subDays($days);
+
+ FormSubmission::query()
+ ->where('incomplete', true)
+ ->where('date', '<', $threshold)
+ ->get()
+ ->each
+ ->delete();
+ }
+}
diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php
index ea221a1bea6..a16471f21d6 100644
--- a/src/Providers/AppServiceProvider.php
+++ b/src/Providers/AppServiceProvider.php
@@ -20,6 +20,7 @@
use Statamic\Facades\Token;
use Statamic\Facades\User;
use Statamic\Fields\FieldsetRecursionStack;
+use Statamic\Jobs\DeleteIncompleteFormSubmissions;
use Statamic\Jobs\HandleEntrySchedule;
use Statamic\Notifications\ElevatedSessionVerificationCode;
use Statamic\Sites\Sites;
@@ -141,6 +142,7 @@ public function boot()
$this->registerElevatedSessionMacros();
$this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute();
+ $this->app->make(Schedule::class)->job(DeleteIncompleteFormSubmissions::class)->daily();
}
public function register()
diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php
index 51f32fd4e09..0616624794d 100644
--- a/src/Providers/ExtensionServiceProvider.php
+++ b/src/Providers/ExtensionServiceProvider.php
@@ -34,6 +34,7 @@ class ExtensionServiceProvider extends ServiceProvider
Actions\Delete::class,
Actions\DeleteFakeSubmissions::class,
Actions\DeleteMultisiteEntry::class,
+ Actions\MarkNotSpam::class,
Actions\DisableTwoFactorAuthentication::class,
Actions\DownloadAsset::class,
Actions\DownloadAssetFolder::class,
@@ -200,6 +201,7 @@ class ExtensionServiceProvider extends ServiceProvider
Scopes\Filters\Fields::class,
Scopes\Filters\Blueprint::class,
Scopes\Filters\Status::class,
+ Scopes\Filters\SubmissionStatus::class,
Scopes\Filters\Site::class,
Scopes\Filters\UserRole::class,
Scopes\Filters\UserGroup::class,
diff --git a/src/Query/Scopes/Filters/SubmissionStatus.php b/src/Query/Scopes/Filters/SubmissionStatus.php
new file mode 100644
index 00000000000..a7af9043cc0
--- /dev/null
+++ b/src/Query/Scopes/Filters/SubmissionStatus.php
@@ -0,0 +1,60 @@
+ [
+ 'type' => 'radio',
+ 'options' => $this->options()->all(),
+ ],
+ ];
+ }
+
+ public function autoApply()
+ {
+ return ['status' => 'complete'];
+ }
+
+ public function apply($query, $values)
+ {
+ match ($values['status']) {
+ 'incomplete' => $query->where('incomplete', true),
+ 'spam' => $query->where('spam', true),
+ default => $query->where('incomplete', '!=', true)->where('spam', '!=', true),
+ };
+ }
+
+ public function badge($values)
+ {
+ return $this->options()->get($values['status']);
+ }
+
+ public function visibleTo($key)
+ {
+ return $key === 'form-submissions';
+ }
+
+ protected function options()
+ {
+ return collect([
+ 'complete' => __('Complete'),
+ 'incomplete' => __('Incomplete'),
+ 'spam' => __('Spam'),
+ ]);
+ }
+}
diff --git a/tests/Actions/MarkNotSpamTest.php b/tests/Actions/MarkNotSpamTest.php
new file mode 100644
index 00000000000..e3dac458411
--- /dev/null
+++ b/tests/Actions/MarkNotSpamTest.php
@@ -0,0 +1,55 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ #[Test]
+ public function it_is_only_visible_to_spam_submissions()
+ {
+ $form = tap(Form::make('contact'))->save();
+
+ $action = new MarkNotSpam;
+
+ $this->assertFalse($action->visibleTo($form->makeSubmission()));
+ $this->assertFalse($action->visibleTo($form->makeSubmission()->set('incomplete', true)));
+ $this->assertTrue($action->visibleTo($form->makeSubmission()->set('spam', true)));
+ }
+
+ #[Test]
+ public function it_removes_the_spam_key_and_dispatches_relevant_events()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact'))->save();
+ $submission = $form->makeSubmission()->set('spam', true);
+ $submission->save();
+
+ (new MarkNotSpam)->run(collect([$submission]), []);
+
+ $this->assertFalse($submission->isSpam());
+
+ Event::assertDispatched(SubmissionCreated::class);
+ Bus::assertDispatched(SendEmails::class);
+ }
+}
diff --git a/tests/Forms/DeleteIncompleteFormSubmissionsTest.php b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php
new file mode 100644
index 00000000000..ed25ac2b853
--- /dev/null
+++ b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php
@@ -0,0 +1,89 @@
+fakeStacheDirectory.'/forms';
+ }
+
+ #[Test]
+ public function it_deletes_incomplete_submissions_older_than_the_configured_threshold()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => 7]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $oldIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-02 12:00:00');
+ $oldComplete = tap($form->makeSubmission())->save();
+
+ Carbon::setTestNow('2025-06-14 12:00:00');
+ $recentIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-15 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNull($form->submission($oldIncomplete->id()));
+ $this->assertNotNull($form->submission($recentIncomplete->id()));
+ $this->assertNotNull($form->submission($oldComplete->id()));
+ }
+
+ #[Test]
+ public function it_only_deletes_incomplete_submissions_never_complete_or_spam()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => 7]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-02 12:00:00');
+ $complete = tap($form->makeSubmission())->save();
+
+ Carbon::setTestNow('2025-06-03 12:00:00');
+ $spam = tap($form->makeSubmission()->set('spam', true))->save();
+
+ Carbon::setTestNow('2025-06-30 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNull($form->submission($incomplete->id()));
+ $this->assertNotNull($form->submission($complete->id()));
+ $this->assertNotNull($form->submission($spam->id()));
+ }
+
+ #[Test]
+ public function it_does_not_delete_anything_when_disabled()
+ {
+ config(['statamic.forms.delete_incomplete_submissions_after' => null]);
+
+ $form = tap(Form::make('contact'))->save();
+
+ Carbon::setTestNow('2025-06-01 12:00:00');
+ $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save();
+
+ Carbon::setTestNow('2025-06-30 12:00:00');
+
+ (new DeleteIncompleteFormSubmissions)->handle();
+
+ $this->assertNotNull($form->submission($incomplete->id()));
+ }
+}
diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php
index d19fde2c5d3..f8515e7cbc6 100644
--- a/tests/Forms/SubmissionTest.php
+++ b/tests/Forms/SubmissionTest.php
@@ -4,6 +4,7 @@
use Carbon\Carbon;
use Illuminate\Support\Collection;
+use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@@ -13,6 +14,8 @@
use Statamic\Events\SubmissionSaved;
use Statamic\Events\SubmissionSaving;
use Statamic\Facades\Form;
+use Statamic\Facades\Site;
+use Statamic\Forms\SendEmails;
use Tests\PreventSavingStacheItemsToDisk;
use Tests\TestCase;
@@ -240,6 +243,129 @@ public function it_deletes_quietly()
$this->assertTrue($return);
}
+ #[Test]
+ public function it_determines_its_status()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submitted = $form->makeSubmission();
+ $this->assertFalse($submitted->isIncomplete());
+ $this->assertFalse($submitted->isSpam());
+ $this->assertEquals('complete', $submitted->status());
+
+ $incomplete = $form->makeSubmission()->set('incomplete', true);
+ $this->assertTrue($incomplete->isIncomplete());
+ $this->assertFalse($incomplete->isSpam());
+ $this->assertEquals('incomplete', $incomplete->status());
+
+ $spam = $form->makeSubmission()->set('spam', true);
+ $this->assertTrue($spam->isSpam());
+ $this->assertFalse($spam->isIncomplete());
+ $this->assertEquals('spam', $spam->status());
+ }
+
+ #[Test]
+ #[DataProvider('withheldStatusProvider')]
+ public function it_does_not_dispatch_creation_events_when_saving_a_withheld_submission(string $status)
+ {
+ Event::fake();
+
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submission = $form->makeSubmission()->set($status, true);
+ $submission->save();
+
+ // Creation events shouldn't be dispatched.
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Event::assertNotDispatched(SubmissionCreated::class);
+
+ // But, saving events should.
+ Event::assertDispatched(SubmissionSaving::class);
+ Event::assertDispatched(SubmissionSaved::class);
+ }
+
+ public static function withheldStatusProvider(): array
+ {
+ return [
+ 'incomplete' => ['incomplete'],
+ 'spam' => ['spam'],
+ ];
+ }
+
+ #[Test]
+ public function created_event_is_not_automatically_dispatched_when_removing_the_incomplete_key()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+
+ $submission = $form->makeSubmission()->set('incomplete', true);
+ $submission->save();
+
+ Event::fake();
+
+ // Removing the incomplete key turns it into a "real" submission, but because
+ // the record already exists, save() alone won't dispatch Created. This is why
+ // complete() dispatches it explicitly (covered by the test below).
+ $submission->remove('incomplete');
+ $submission->save();
+
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Event::assertNotDispatched(SubmissionCreated::class);
+ Event::assertDispatched(SubmissionSaved::class);
+ }
+
+ #[Test]
+ public function completing_a_new_submission_dispatches_created_event_once()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact_us'))->save();
+ $submission = $form->makeSubmission();
+
+ $submission->complete(Site::default());
+
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Bus::assertDispatched(SendEmails::class, 1);
+
+ $this->assertNotNull($form->submission($submission->id()));
+ }
+
+ #[Test]
+ public function completing_an_incomplete_or_spam_submission_removes_the_status_key_and_dispatches_events()
+ {
+ $form = tap(Form::make('contact_us'))->save();
+ $submission = tap($form->makeSubmission()->set('incomplete', true)->set('spam', true))->save();
+
+ Bus::fake();
+ Event::fake([SubmissionCreated::class, SubmissionCreating::class]);
+
+ $submission->complete(Site::default());
+
+ $this->assertFalse($submission->isIncomplete());
+ $this->assertFalse($submission->isSpam());
+
+ // Submission already exists, so save() won't dispatch the Created event, complete() will.
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Event::assertNotDispatched(SubmissionCreating::class);
+ Bus::assertDispatched(SendEmails::class, 1);
+ }
+
+ #[Test]
+ public function completing_a_submission_for_a_non_storing_form_still_dispatches_the_created_event()
+ {
+ Bus::fake();
+ Event::fake([SubmissionCreated::class]);
+
+ $form = tap(Form::make('contact_us')->store(false))->save();
+ $submission = $form->makeSubmission();
+
+ $submission->complete(Site::default());
+
+ Event::assertDispatched(SubmissionCreated::class, 1);
+ Bus::assertDispatched(SendEmails::class, 1);
+ $this->assertNull($form->submission($submission->id()));
+ }
+
#[Test]
public function it_clones_internal_collections()
{