Skip to content

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

Merged
jasonvarga merged 149 commits into
forms-2from
forms-2-connections
Oct 8, 2026
Merged

jasonvarga merged 149 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. Each kind of destination is a connector — starting with Email and Webhook, and paving the way for third-party integrations — and each one you configure on a form is a connection. A form might have two email connections and three webhook connections, each with its own settings and conditions.

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

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.
  • Custom email views receive pages and sections variables alongside fields, so multi-page forms can be rendered with their structure — each section carries the same renderable field data as fields.
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.

Outside of the local environment, webhook URLs must resolve to a public address — this is checked when saving and again when sending, so webhooks can't be pointed at internal services. Redirects aren't followed; a 3xx response counts as a failed delivery. Credentials in the URL (https://user:pass@example.com) are allowed.

CleanShot 2026-07-23 at 11 28 50

Registering custom connectors

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

Registering a connector

A connector is a class that extends Statamic\Forms\Connectors\Connector. It provides a title, description and icon for the Connect index, describes its fields with blueprint(), returns a Vue component from render(), and returns a job for each connection from job():

<?php

namespace App\FormConnectors;

use App\Jobs\SendToAcme;
use Statamic\Contracts\Forms\Submission;
use Statamic\Facades\Blueprint;
use Statamic\Forms\Connectors\Connector;
use Statamic\Support\VueComponent;

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

    public function render(): VueComponent
    {
        return VueComponent::render('acme-connector', $this->blueprintProps());
    }

    public function blueprint(): \Statamic\Fields\Blueprint
    {
        return Blueprint::make()->setContents(['fields' => [
            ['handle' => 'list', 'field' => ['type' => 'text', 'validate' => ['required']]],
        ]]);
    }

    protected function job(Submission $submission, array $connection): ?object
    {
        return new SendToAcme($submission, $connection);
    }
}

Statamic hands the connector the form it's working with and the connections it's about before calling any of its methods. They're available via $this->form() and $this->connections().

The handle is derived from the class name, with any Connector suffix removed — AcmeConnector becomes acme.

$icon is shown on the Connect index. $smallIcon is optional and used in small spaces like the breadcrumb on the edit page, falling back to $icon. Both accept an icon name or inline SVG markup. Bake any colours into the SVG itself, using light-dark() to support dark mode.

Connectors in the FormConnectors directory of apps and addons are registered automatically. Addons can also register them explicitly via the $formConnectors property in their service provider.

Connections

A connector's config is always a list of connections, saved to the form under the connector's handle:

connections:
  acme:
    -
      id: a1b2c3d4
      list: newsletter
    -
      id: e5f6g7h8
      list: sales
      enabled: false

The base Connector class takes care of each connection's id, enabled state and conditions, so you only deal with your own fields. It also provides the count badge on the Connect index.

Saving

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

The config makes a round trip through the connector class:

  1. When the page loads, the saved connections are passed through the connector'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 connector's rules(), passed through its process() method, and saved to the form under the connector's handle.

Most connectors describe their fields with a blueprint and let the base class do the rest:

public function blueprint(): \Statamic\Fields\Blueprint
{
    return Blueprint::make()->setContents(['fields' => [
        ['handle' => 'list', 'field' => ['type' => 'select', 'options' => $this->lists(), 'validate' => ['required']]],
        ['handle' => 'email_field', 'field' => ['type' => 'form_fields', 'form' => $this->form()->handle()]],
        ['handle' => 'double_opt_in', 'field' => ['type' => 'toggle', 'default' => true]],
    ]]);
}

blueprint() and job() are required. Each connection is pre-processed and processed through the blueprint's fields, and each top-level field's validate rules are applied to every connection. blueprint() is called several times per request, so memoise anything expensive (like API calls) on the instance.

You can still customise individual steps with the per-connection hooks:

  • connectionRules() returns extra validation rules for a single connection, keyed by field handle ('list' => [new ValidList]). They're merged with the blueprint's rules and the rules for enabled and conditions. Rules for grid sub-fields ('rows.*.email' => ['required']) belong here, since only top-level blueprint rules are applied automatically.
  • preProcessConnection(array $connection) prepares a single connection's fields for the editor.
  • processConnection(array $connection) prepares a single connection's fields for saving — call parent::processConnection() and adjust the result. Missing ids are generated, enabled is only stored when false, and conditions are cleaned up for you.
  • connectionFields(array $connection) returns the pre-processed Fields for a connection. Override it to adjust values before both the editor values and the fieldtype meta are built (eg. migrating a legacy value).
  • connectionMeta(array $connection) returns a connection's fieldtype meta. Override it to add per-connection data, like options that depend on the connection's own values.

Use $this->form() wherever you need the form. Without a blueprint, the processing hooks return the connection untouched.

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

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

Connectors needing credentials can override isConfigured() — when it returns false, the edit page hides the save button so the component can render setup instructions instead. Credentials belong in your config / .env, not in the form.

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 connections) and errors automatically. blueprintProps() gives you the blueprint, per-connection meta and defaults props for the connector's connections ($this->connections()):

public function render(): VueComponent
{
    return VueComponent::render('acme-connector', [
        ...$this->blueprintProps(),
        'apiError' => $this->apiError,
    ]);
}

The <ConnectionList> component does most of the work. Pass it your connections via v-model, along with errors, defaults, blueprint and meta, plus a header slot. It takes care of the collapsible UI, the expand and collapse all button, the add/duplicate/remove actions, and each connection's fieldtype meta. New connections are seeded from defaults, and each one is given an id, enabled state and empty conditions for you.

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

defineEmits(['update:modelValue']);

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

<template>
    <ConnectionList
        :model-value="modelValue"
        :errors
        :defaults
        :blueprint
        :meta
        name="acme"
        :add-label="__('Add Notification')"
        :description="__('Post to a channel whenever this form receives a submission.')"
        :always-label="__('Always send')"
        :if-label="__('Send if...')"
        @update:model-value="$emit('update:modelValue', $event)"
    >
        <template #header="{ item: notification, collapsed }">
            <Badge size="lg" pill>{{ notification.channel || __('New Notification') }}</Badge>
        </template>
    </ConnectionList>
</template>

Without a default slot, each connection renders the logic builder followed by the blueprint's fields. Pass :rules="false" to leave out the logic builder.

For anything more custom, provide a default slot and use <ConnectionFields>, which renders a connection's fields in a publish container. It picks up the connection, blueprint, meta and errors from the list automatically:

<template #default="{ item }">
    <ConnectionRules v-model:conditions="item.conditions">
        <template #then>
            <ConnectionFields :except="['internal_notes']" :extra-values="{ plan: plans[item.plan] }">
                <template #before>
                    <Alert v-if="!item.list" :text="__('Choose a list.')" />
                </template>
            </ConnectionFields>
        </template>
    </ConnectionRules>
</template>

<ConnectionFields> accepts fields (a subset of handles, in order), except, extraValues (for field conditions) and bordered. Its before and after slots render inside the container. To lay fields out yourself, use its default slot and pick():

<ConnectionFields>
    <template #default="{ pick }">
        <PublishFieldsProvider :fields="pick('list', 'email_field')"><PublishFields /></PublishFieldsProvider>
        <MyDivider />
        <PublishFieldsProvider :fields="pick('tags')"><PublishFields /></PublishFieldsProvider>
    </template>
</ConnectionFields>

Each connection is its own publish container, so fieldtypes can read sibling values (like the selected list) from the publish context. Render one <ConnectionFields> per connection — use the slot rather than several instances.

The default slot also receives { item, index, errors, meta } if you'd rather build the publish container yourself. errors are keyed by the field's full path within the connection, like list or merge_fields.0.tag.

Logic

<ConnectionList> renders the <ConnectionRules> logic builder for you unless you provide a default slot. To use it yourself, bind the connection's conditions with v-model:conditions and put whatever the conditions control inside its then slot.

<template #default="{ item: notification }">
    <ConnectionRules
        v-model:conditions="notification.conditions"
        :always-label="__('Always send')"
        :if-label="__('Send if...')"
    >
        <template #then>
            <ConnectionFields />
        </template>
    </ConnectionRules>
</template>

There's nothing to do on the PHP side — the base Connector class prepares conditions for the editor, cleans them up when saving, and evaluates them when a submission comes in. Disabled connections and connections whose conditions don't pass never reach your job() method.

Statamic also exports the <ConnectionSummary> component, which describes a connection's conditions in its header when collapsed — like "If Enquiry Type equals Sales". When a connection has no conditions, it shows whatever you pass as fallback (the connection's subject or heading, say), or "Always".

<script setup>
import { ConnectionSummary } from '@statamic/cms';
</script>

<template #header="{ item: notification, collapsed }">
    <Badge size="lg" pill>{{ notification.channel || __('New Notification') }}</Badge>
    <ConnectionSummary v-show="collapsed" :conditions="notification.conditions" :fallback="notification.heading" />
</template>

Sending

When a submission is finalized, file uploads are converted to assets, then a job is dispatched for each connection that is enabled and whose conditions pass. Each job is dispatched independently, so one failing connection doesn't stop the others.

Before finalized() is called, Statamic binds the form and resolves the connector's connections — the form's own, or an entry's override when unique instances is enabled (#15255). They're available via $this->connections().

Your connector's job() method is called once per connection and should return a job (or null to skip):

protected function job(Submission $submission, array $connection): ?object
{
    return new SendToAcme($submission, $connection);
}

Jobs must implement ShouldQueue and have a public $middleware property — using Laravel's Queueable trait covers both. Statamic hooks into that middleware to keep track of when every connection's job has succeeded, and only then deletes the submission's temporary file uploads. That means:

  • Any work that needs the uploaded files must finish inside your job's handle() method. Don't dispatch or chain further jobs that read them, as the files may be deleted before they run.
  • Retrying a failed job with queue:retry still finds the files.
  • Don't store the connection on your job as public $connection — that collides with the queue connection property from Queueable. Use a name like $config.

On the sync queue, a failing connection is logged and the visitor still sees a successful submission, but there are no retries. Use a real queue if you need them.

The success tracking uses the cache. Set statamic.forms.connections_cache_store to a persistent store like database or redis if your default cache store isn't shared between your web and queue processes.

Temporary form uploads that never get cleaned up (for example, when a job fails and is never retried) are swept after a week.


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

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.
jasonvarga and others added 28 commits September 23, 2026 11:53
# Conflicts:
#	resources/js/bootstrap/fieldtypes.js
#	src/Providers/ExtensionServiceProvider.php
# Conflicts:
#	resources/js/bootstrap/components.js
#	src/Forms/Form.php
#	src/Providers/AddonServiceProvider.php
#	tests/Forms/FormTest.php
CreateAssetsFromFileUploads still implemented ShouldQueue after it was taken out of the connection chain, so dispatchSync ran it against a serialized copy of the submission and the converted asset paths never reached the connection jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A single Bus::chain meant one failing connection stopped every connection after it, and on the sync queue gave the visitor a 500 after the submission was saved. Each connection job is now dispatched on its own. Temporary files are deleted once every job has succeeded, tracked by a per-submission countdown in the cache (configurable via statamic.forms.connections_cache_store), so a failed job's files survive for a later retry. The Email connection now returns one SendEmail job per email, and SendEmails is deprecated. Connection jobs must implement ShouldQueue and use the Queueable trait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he Queueable trait

The countdown only relies on the job's public $middleware property, which Laravel runs for queued jobs. Checking for it directly accepts jobs that define the property themselves and catches ones where it isn't public.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The base Connection::preProcess() returned rows untouched, so addon connections without their own preProcess() gave the CP rows with no conditions or enabled keys, and duplicating a row threw. The defaults now live in a shared preProcessRow() that Email and Webhook use too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A "Send if" rule with a field but an empty value was dropped on save, so on reload it reset to "Always send" and the connection fired every time. Rules are now kept as long as they have a field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l views

The preview endpoint rendered whatever config it was sent without the validation saving uses. It now validates with the connection's rules first. Those rules now also restrict the html and text views to what the template field offers: no namespaced views, and only views inside statamic.forms.email_view_folder when it's set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving a connection returned the processed config and the editor ignored it, keeping client-only ids and incomplete conditions the server had stripped, which were then sent back on the next save. The endpoint now returns the pre-processed values and the editor replaces its state with them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Making it extend Support\VueComponent added parameter and return types, so addon widgets overriding render() or toArray() with the released signatures would fatal on upgrade. It's a standalone deprecated class again with the 6.x signatures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outside the local environment, webhook URLs are now validated with RemoteUrlValidator before sending, so they can't target loopback, private, link-local or reserved addresses (e.g. cloud metadata), and the request is pinned to the validated IPs so DNS can't be rebound in between. Credentials in the URL are allowed. Sending requires the curl extension for pinning. Redirects are no longer followed and any 3xx response counts as a failed delivery. The local environment skips validation so webhooks to local sites keep working.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Outside the local environment, saving a webhook connection with a URL that resolves to an internal address now fails validation, so it's caught when configuring it rather than only failing at send time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… Connection

Every connection's config is now a list of rows. Non-list configs are normalised into rows by Connection::normalizeRows() wherever they enter (Form::connections(), setConfig(), preProcess(), process()), so the single-object shape can no longer reach the base class. The base Connection now owns count(), preProcess(), process(), rules() and finalized(), with preProcessRow(), processRow(), rowRules() and job() hooks for subclasses. Email and Webhook use the hooks instead of duplicating the plumbing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A connector is the integration (Email, Webhook, Log…) and a connection is one configured instance of a connector on a form. The base class, repository, facade, addon/extension registration, CP controller props and route params, Vue components and public JS exports now use that vocabulary, and "row" naming in the base-class hooks and the list components is replaced with "connection".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connector gains a public blueprint() method. When it returns a blueprint, the base class pre-processes and processes each connection through its fields and merges the top-level fields' validation rules into rules(). connectionFields(), connectionMeta() and blueprintProps() let connectors adjust values and meta per connection and build the blueprint, meta and defaults props for their Vue component. Email and Webhook use the new hooks instead of hand-building them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fields

ConnectionList now accepts the connector's blueprint and meta. Without a default slot it renders the logic builder and the connection's fields, so connector components only need a header. The new ConnectionFields component renders one connection's fields and is exported for custom layouts. ConnectionList also keeps a copy of each connection's meta, so new and duplicated connections no longer share and mutate the default meta, and validation errors keep their full path so nested fields like grids show them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Laravel only compiles rules like Rule::forEach() per item when they're a key's entire value. Wrapping them in an array made them validate against the whole data set, so they silently stopped failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Connectors now hold their form and connections, set with setForm() and setConnections() (or forForm() for a form's stored connections), so render(), blueprint(), count(), rules() and the per-connection hooks no longer take the form as an argument. render() and count() use the connections they were given instead of reading the form's stored ones, so callers like entry-level overrides can pass their own. setConfig() and config() are replaced by setConnections() and connections().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l pipeline

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jasonvarga
jasonvarga merged commit 5cb39e6 into forms-2 Oct 8, 2026
61 checks passed
@jasonvarga
jasonvarga deleted the forms-2-connections branch October 8, 2026 22:19
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.

3 participants