Skip to content

[6.x] Forms 2: Connections - #15063

Open
duncanmcclean wants to merge 101 commits into
forms-2from
forms-2-connections
Open

[6.x] Forms 2: Connections#15063
duncanmcclean wants to merge 101 commits into
forms-2from
forms-2-connections

Conversation

@duncanmcclean

@duncanmcclean duncanmcclean commented Jul 23, 2026

Copy link
Copy Markdown
Member

This pull request implements the concept of "Connections" for forms.

Connections let a form talk to the outside world when submissions come in — starting with Emails and Webhooks, and paving the way for third-party integrations.

Forms now have a "Connect" area in the Control Panel, listing the available connections along with how many of each are configured.

Emails

Emails mostly work like they did before, they've just moved to the "Connect" area. We have added a few niceities though:

  • Emails can now be triggered based on conditions.
  • The email body can now be written in the Control Panel. You can use Antlers to insert form fields.
  • The Recipient/CC/BCC/Sender/Reply-to fields now suggest form fields to avoid end-users needing to write Antlers.
CleanShot.2026-08-17.at.10.59.30.mp4

Existing email configs in form YAML are automatically converted to email connections, saved under the new connections key. Form::email() has been deprecated in favour of Form::connections().

Webhooks

Upon submission, forms can now send webhooks — a POST request containing the form handle and submission data, sent to a URL of your choice.

SSL verification can be disabled per webhook, useful for local development or when sending requests to internal services.

Like emails, webhooks can be triggered based on conditions.

CleanShot 2026-07-23 at 11 28 50

Registering custom connections

Apps and addons can register their own connections, which will show up alongside the built-in ones in the "Connect" area.

Registering a connection

A connection is a class that extends Statamic\Forms\Connections\Connection. It provides a title, description and icon for the Connect index, an optional count() for the badge on the index table, and returns a Vue component from render():

<?php

namespace App\FormConnections;

use Statamic\Contracts\Forms\Form;
use Statamic\Forms\Connections\Connection;
use Statamic\Support\VueComponent;

class Acme extends Connection
{
    protected static $title = 'Acme';
    protected $description = 'Send submissions to Acme.';
    protected $icon = 'globe-arrow';

    public function count(Form $form): ?int
    {
        return count($form->connections()->get('acme', []));
    }

    public function render(Form $form): VueComponent
    {
        return VueComponent::render('acme-connection', [
            // Props for your Vue component...
        ]);
    }
}

Connections in the FormConnections directory of apps and addons are registered automatically. Addons can also register them explicitly via the $formConnections property in their service provider.

Saving

Connections don't need any routes or controllers for saving — Statamic owns the save process.

The config makes a round trip through the connection class:

  1. When the page loads, the saved config is passed through the connection's preProcess() method and handed to its Vue component as modelValue.
  2. The component emits update:modelValue as the user makes changes.
  3. On save, the value is validated against the connection's rules(), passed through its process() method, and saved to the form under the connection's handle.

Validation errors are passed to the component via the errors prop, keyed by row index — like 0.channel.

Both preProcess() and process() return their input untouched by default, so simple connections only need to implement what they use.

Connections can register their own routes (eg. OAuth callbacks) via the routes() method — they're automatically wrapped in authorization.

Connections needing credentials can override isConfigured() — when it returns false, the edit page hides the save button so the component can render setup instructions instead.

Frontend

The render() method determines which Vue component gets rendered, along with its props. The edit page also passes it form, modelValue (the pre-processed value) and errors automatically.

If your connection supports multiple "rows" (eg. multiple emails per form), you can use the <ConnectionRows> component to get a head start.

Pass it your array of rows via v-model, your validation errors via errors, and a header slot and a body slot for each row. It takes care of the collapsible row UI and the add/duplicate/remove actions. New rows are seeded from defaults.values, and each row is given an id, enabled state and empty conditions for you.

<script setup>
import { ConnectionRows } from '@statamic/cms';
import { Badge } from '@statamic/cms/ui';

defineEmits(['update:modelValue']);

defineProps({
    modelValue: { type: Array, default: () => [] },
    errors: { type: Object, default: () => ({}) },
    defaults: Object,
});
</script>

<template>
    <ConnectionRows
        :model-value="modelValue"
        :errors
        :defaults
        :add-label="__('Add Notification')"
        @update:model-value="$emit('update:modelValue', $event)"
    >
        <template #header="{ item: notification, collapsed }">
            <Badge size="lg" pill>{{ notification.channel || __('New Notification') }}</Badge>
        </template>

        <template #default="{ item: notification, errors }">
            <!-- Each row's fields go here... -->
        </template>
    </ConnectionRows>
</template>

The default slot hands each row its own validation errors, grouped by field handle, ready to pass along to your fields.

Logic

If you want your connection to support conditional logic, the <ConnectionRules> component renders the logic builder. Simply bind your conditions with v-model:conditions and put whatever the conditions control inside its then slot.

<template #default="{ item: notification, errors }">
    <ConnectionRules
        v-model:conditions="notification.conditions"
        :always-label="__('Always send')"
        :if-label="__('Send if...')"
    >
        <template #then>
            <!-- The fields controlled by the conditions go here... -->
        </template>
    </ConnectionRules>
</template>

On the PHP side, the Statamic\Forms\Connections\ConnectionLogic class handles the rest:

  • When editing, ConnectionLogic::preProcess($conditions) gives each condition the row ID the logic builder needs — call it from your connection's preProcess() method.
  • When saving, ConnectionLogic::process($conditions) strips out the row IDs and any incomplete conditions, and returns null when there's nothing to save — call it from your process() method.
  • When a submission comes in, ConnectionLogic::passes($config, $submission) evaluates the conditions against the submission, so you can decide whether to send anything or not.

Sending notifications

When a submission is finalized, Statamic dispatches a single job chain: file uploads are converted to assets, then each of the connection jobs run and finally temporary file uploads are deleted.

To hook into this process, you should return a job (or array of jobs) from the finalized() method:

public function finalized($submission): object|array
{
	return new SendNotificationToThirdPartyService($submission);
}

Because we're using Laravel's job chaining feature, if you need to dispatch additional jobs within one of your jobs, call $this->prependToChain($job) (from Laravel's Queueable trait) so they stay part of the chain.


Closes statamic/ideas#1176
Closes statamic/ideas#1434

Related: https://github.com/statamic/forms-pro/pull/17

duncanmcclean and others added 30 commits July 21, 2026 12:33
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…need to use antlers

Using Antlers in email address fields is still supported, but this is a slightly easier approach for end-users.
duncanmcclean and others added 17 commits August 13, 2026 17:25
- `Connection` now uses the `HasTitle` trait, so custom titles get translated like other extension types
- `ConnectionRepository::all()` matches `DictionaryRepository` and the unused `classes()` method has been removed
- dropped the redundant `ConnectionRepository` singleton binding, since facades auto-resolve repositories
- documented `connections()` on the form contract for the next breaking release and deprecated `email()`

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
whether a config should run is one question, so disabled rows now fail `passes()` rather than every caller pairing it with its own enabled check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- renamed `ConnectionList`/`ConnectionListItem`/`ConnectionLogic` to `ConnectionRows`/`ConnectionRow`/`ConnectionRules`, matching the row terminology used by replicator and grid
- `ConnectionRows` now owns adding, duplicating, removing and dirty tracking, since every connection uses the same row shape
- the initial row mapping is handled by the exported `connectionRows` helper

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
the empty states were reusing the connect index card descriptions, which just restate the page title. they now explain what you'd add and why, and the "webhooks" label is hidden when there's nothing under it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@duncanmcclean
duncanmcclean marked this pull request as ready for review August 17, 2026 09:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant