Repository navigation
[6.x] Forms 2: Connections - #15063
Merged
Merged
Conversation
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.
# 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>
# Conflicts: # tests/Forms/EmailTest.php
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
pagesandsectionsvariables alongsidefields, so multi-page forms can be rendered with their structure — each section carries the same renderable field data asfields.CleanShot.2026-08-17.at.10.59.30.mp4
Existing
emailconfigs in form YAML are automatically converted to email connections, saved under the newconnectionskey.Form::email()has been deprecated in favour ofForm::connections().Webhooks
Upon submission, forms can now send webhooks — a POST request containing the
formhandle andsubmissiondata, 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
localenvironment, 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.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 withblueprint(), returns a Vue component fromrender(), and returns a job for each connection fromjob():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
Connectorsuffix removed —AcmeConnectorbecomesacme.$iconis shown on the Connect index.$smallIconis 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, usinglight-dark()to support dark mode.Connectors in the
FormConnectorsdirectory of apps and addons are registered automatically. Addons can also register them explicitly via the$formConnectorsproperty in their service provider.Connections
A connector's config is always a list of connections, saved to the form under the connector's handle:
The base
Connectorclass takes care of each connection'sid,enabledstate andconditions, 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:
preProcess()method and handed to its Vue component asmodelValue.update:modelValueas the user makes changes.rules(), passed through itsprocess()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:
blueprint()andjob()are required. Each connection is pre-processed and processed through the blueprint's fields, and each top-level field'svalidaterules 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 forenabledandconditions. 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 — callparent::processConnection()and adjust the result. Missing ids are generated,enabledis only stored whenfalse, and conditions are cleaned up for you.connectionFields(array $connection)returns the pre-processedFieldsfor 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
errorsprop, keyed by connection index — like0.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 returnsfalse, 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 itform,modelValue(the pre-processed connections) anderrorsautomatically.blueprintProps()gives you theblueprint, per-connectionmetaanddefaultsprops for the connector's connections ($this->connections()):The
<ConnectionList>component does most of the work. Pass it your connections viav-model, along witherrors,defaults,blueprintandmeta, 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 fromdefaults, and each one is given anid,enabledstate and emptyconditionsfor you.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:<ConnectionFields>acceptsfields(a subset of handles, in order),except,extraValues(for field conditions) andbordered. Itsbeforeandafterslots render inside the container. To lay fields out yourself, use its default slot andpick():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.errorsare keyed by the field's full path within the connection, likelistormerge_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 withv-model:conditionsand put whatever the conditions control inside itsthenslot.There's nothing to do on the PHP side — the base
Connectorclass 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 yourjob()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 asfallback(the connection's subject or heading, say), or "Always".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 (ornullto skip):Jobs must implement
ShouldQueueand have a public$middlewareproperty — using Laravel'sQueueabletrait 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:handle()method. Don't dispatch or chain further jobs that read them, as the files may be deleted before they run.queue:retrystill finds the files.public $connection— that collides with the queue connection property fromQueueable. Use a name like$config.On the
syncqueue, 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_storeto a persistent store likedatabaseorredisif 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