Skip to content

System Connections resource (/admin/system-connections)

Manages App\Models\SystemConnection — the links between a Tenant and an external e-commerce system. Each system connection holds the credentials and configuration needed to communicate with a specific system account.

Model attributes

Attribute Type Notes
id UUID Primary key via HasUuids
tenant_id UUID (FK), nullable Owning Tenant; FK uses restrictOnDelete
system_id UUID (FK) Target System
external_user_id string User identifier on the external system
name string, nullable Optional label; not exposed on the admin form
base_url string Root URL of the external system installation
credentials array, nullable Sensitive credentials; stored as encrypted:json
status SystemConnectionState State: active, inactive, error, pending, disconnected, uninstalled. Changes only through a transition
meta array Arbitrary metadata; cast to array
configuration array, nullable Connector-widget opt-in per data domain; cast to array
last_connected_at datetime Timestamp of the last successful connection
disconnected_at datetime, nullable Set for exactly as long as status is disconnected; the grace-period deadline is derived from it
deleted_at timestamp Soft deletes enabled

Status values

Labels and badge colours come from the state classes in App\States\SystemConnection. See System Connections: Status.

Value Badge colour Meaning
active Green (success) System connection is healthy and in use
pending Amber (warning) Awaiting first successful contact
error Red (danger) The circuit breaker opened during a bulk sync (BulkSyncJob, FetchBulkPageRangeJob), or a queued bulk page found that the connection's integration cannot export in bulk (no CanExportInBulk); leave it with the Reactivate action
inactive Grey (gray) Manually disabled
disconnected Blue (info) Ended by the shop; data kept for the grace period, reversible
uninstalled Red (danger) Ended by removing the plugin; everything deleted at once

List view columns

Column Source Behaviour
ID system_connections.id Shows … and the last 8 characters, because UUIDv7 prefixes repeat across rows created close together; the full UUID is the tooltip. Copyable (copies the full UUID), searchable
Tenant tenant.name Searchable
System system.name Searchable
External User ID system_connections.external_user_id Searchable
Base URL system_connections.base_url Searchable
Status system_connections.status Badge with colours above
Last Connected At system_connections.last_connected_at Date-time, sortable
Disconnected At system_connections.disconnected_at Date-time, sortable; hidden by default
Created At system_connections.created_at Date-time, sortable; hidden by default
Updated At system_connections.updated_at Date-time, sortable; hidden by default
Deleted At system_connections.deleted_at Date-time, sortable; hidden by default

Filters: Tenant (relationship), System (relationship), Status (SystemConnectionState::options()), Trashed.

Row actions: View and Edit sit inline. One action group (tooltip "Lifecycle") holds Bulk Sync, Reactivate, Disconnect, Deactivate and Force Uninstall; see Reactivate, Deactivate, Disconnect, and Uninstall actions for when each shows.

Bulk actions: Delete (soft), Force Delete, Restore.

View page — The facts card shows the full UUID as a copyable Connection ID entry next to the tenant and the external user ID. A disconnected connection additionally shows Grace period ends, the derived deadline from SystemConnection::graceDeadline(), rendered as a relative window with the absolute date as a tooltip. The entry is hidden for every other status. Last backfill confirmation shows the latest backfill confirmation given for a rule that writes into this connection: the rule (the entry links to its page, which still opens where the rule was deleted since; the link is left out for a viewer who may not open the rule, who still sees its title), how many creates it confirmed, who confirmed it and when, as in "Customers → Workspace · rapidmail: 1,240 creates, confirmed by Dana on 2026-09-10 12:00:00 UTC". It is hidden where no backfill was ever started by hand into the connection. Every connection's infolist also shows Synced Domains: the domains SystemConnection::pullInCapabilities() returns right now — the System's capabilities, narrowed by the connector-widget opt-in and by the domains an active outgoing SyncRule moves. Displays — when nothing currently syncs. The View page also carries the flow overview described below.

Role-based visibility

UI element SuperAdmin PlatformSupport Viewer
Create button yes — —
Connect action (list page) yes — —
Edit / Delete actions yes — —
Bulk Sync yes own connections, plus sibling shop connections (ADR-0030) —
Credentials field on create yes — —
Credentials field on edit yes — —
Tenant selector options all tenants reachable tenants reachable tenants

PlatformSupport holds system_connections.view and system_connections.resync, and nothing else on this resource. Bulk Sync is therefore its only write-shaped action here, and it applies only to connections that are fully accessible: those pointing at a system the user operates, plus — since shop adapters can never have support members of their own (ADR-0008) — any shop connection reached through a sibling connection to a system the user does operate (ADR-0030). Viewer holds view alone.

Credentials — The Credentials textarea is hidden whenever the operation is not create and the user is not a SuperAdmin. Because system_connections.create is SuperAdmin-only, in practice only a SuperAdmin ever sees the field at all; the hidden() condition is the second guard rather than the first. ->hidden() only controls rendering, though — it does not stop Filament from populating the field's Livewire state, which would otherwise let a non-SuperAdmin recover the real decrypted credentials from the page's wire:snapshot (View Source or the Network tab) even with the field itself invisible. To prevent that, afterStateHydrated() hydrates a '••••••••' placeholder instead of the real value whenever the viewer is not a SuperAdmin, and ->dehydrated() is gated the same way so the placeholder can never be saved back over the real encrypted value. Once saved, credentials cannot be read back through the UI, decrypted or otherwise, by anyone but a SuperAdmin.

List page Connect action — ConnectSystemConnectionAction (label "Connect") is a header action on the list page, separate from the standard CreateAction. It drives a two-mode modal for the manual admin-side connection flow: Initiate creates a Pending connection for a chosen tenant and generates a short-lived token to hand to the plugin; Finalize activates a Pending connection given a plugin-generated token, verifying credentials before flipping it to Active. When activate() refuses because the connection changed its status or was removed in the meantime, Finalize sends the danger notification "Connection could not be activated" ("The connection changed its status or was removed in the meantime. Start the connection flow again.") and halts, instead of showing an error page. Its ->visible() is gated on auth()->user()?->can('create', SystemConnection::class), matching system_connections.create — so PlatformSupport and Viewer, who hold only system_connections.view/.resync, cannot see or use it.

Tenant selector scoping — The Tenant Select binds tenant_id to the tenant relationship and passes modifyQueryUsing a closure calling Tenant::accessibleBy($user), so the dropdown lists only tenants within the user's reach. Since the form is only reachable by a SuperAdmin, for whom that scope short-circuits to everything, this too is a safety net rather than observable behaviour.

Sibling connections — A connection that lies under a reachable tenant but does not point at a system the user operates still appears in the list. For a non-shop sibling (another internal/consuming platform) it is read-only: status, last contact, health. For a shop sibling, it is fully accessible instead, because shop adapters structurally never have support members of their own — see the exception in Own connections in full, siblings by status and ADR-0030. SystemConnectionPolicy reads view from SystemConnection::accessibleBy() and update, delete and resync from SystemConnection::fullyAccessibleBy().

Form fields (create / edit)

The create form is intended for development and debugging. System connections are normally created via API.

Field Type Validation Notes
Tenant Select (relationship) Required Bound to tenant_id; scoped to Tenant::accessibleBy($user) — see Role-based visibility above
System Select (relationship) Required Bound to system_id via system relation
External User ID TextInput Required
Base URL TextInput Required, URL
Credentials Textarea Required on create JSON; pretty-printed; stored as encrypted:json; hidden on edit for non-SuperAdmin
Status TextEntry (badge) — Edit only, and display-only: it cannot be changed in the form. Hidden on create: a connection created in the panel always starts pending. Helper text says the status changes only through the lifecycle actions in the page header and the Connect action on the list. See the actions below
Configuration KeyValue Optional Connector-widget opt-in per data domain; read by SystemConnection::pullInCapabilities()
Meta KeyValue Optional Free-form key/value pairs
Last Connected At DateTimePicker — Read-only; hidden on create

Edit page header actions: an action group button labelled "Lifecycle" holding TriggerBulkSyncAction, ReactivateConnectionAction, DisconnectConnectionAction, DeactivateConnectionAction and UninstallConnectionAction, and a second action group holding DeleteAction (soft delete), ForceDeleteAction and RestoreAction. The panel does not render the connector widget itself. See Connection Widget for where and how it is configured.

Soft-deleted system connections remain accessible on the edit route — getRecordRouteBindingEloquentQuery() strips SoftDeletingScope so trashed records can still be restored.

Bulk Sync action

The Bulk Sync action lets an operator manually trigger a full data export for a connection. It sits in the Lifecycle group of a list row and of the edit page header, and as a header action on the view page.

Availability: the button is enabled only when the connection is healthy (status active) and at least one data scope is available to sync. It is disabled with an explanatory tooltip otherwise: "Only active connections can be synced" for any non-active status, "This connection has no system" when the linked System is missing or soft-deleted, and "No active sync rule covers this connection" when no active SyncRule names the connection as source.

Interaction: clicking the button opens a modal with a CheckboxList labelled "Data scopes". The list holds exactly the domains SystemConnection::pullInCapabilities() returns — the platform's capabilities, narrowed by the connector-widget opt-in and by active outgoing SyncRules (see Sync Rules). All of them are pre-selected. A bulk-toggle control allows selecting or deselecting all scopes at once. A submitted scope that no active SyncRule covers is rejected with a validation error on the field rather than being quietly dropped. Three field rules enforce that together, and a hand-crafted Livewire payload has to clear all three: required (at least one scope), an explicit array shape rule, and the per-item in rule Filament derives from the option list itself. The shape rule is not redundant — the derived in rule binds at capabilities.*, which Laravel only applies to an array, so without it a scalar payload would pass validation untouched and then be decoded into an array by OptionsArrayStateCast after the gate.

Behaviour: on confirmation, a single BulkSyncJob is dispatched carrying all selected capabilities. The job resolves the integration for the connection's SystemDriver via IntegrationRegistry and calls requestBulkExport() once per capability. The job runs on the rabbitmq queue asynchronously — the operator's browser is not blocked.

BulkSyncJob implements ShouldBeUnique keyed on the connection ID. If a sync for the same connection is already queued or running, a new dispatch is silently dropped — only one sync per connection can be active at a time.

Aspect Detail
Available for Connections with status active that have at least one rule-backed data scope
Modal field CheckboxList of the connection's rule-backed SystemCapability values
Jobs dispatched One BulkSyncJob per trigger (carries all selected capabilities)
Uniqueness One active sync per connection (ShouldBeUnique)
Queue driver rabbitmq
Success notification "Bulk sync triggered"

Reactivate, Deactivate, Disconnect, and Uninstall actions

Three confirmed toggle actions move a connection into one of the three end states (Inactive, Disconnected, or Uninstalled), each with different consequences. Deactivate and Disconnect take a Pending, Active or Error connection; Force Uninstall takes any connection that is not Uninstalled yet. A fourth, Reactivate, moves an Error connection back to Active. All four sit in the "Lifecycle" action group, in the list table's row actions and in the edit page's header. Apart from Reactivate for Error, no admin action moves a connection back to Active. Any other connection that left Active needs a fresh handshake (see Connection Flow).

Action Visible when Effect Confirmation copy Success notification
Reactivate status = error and the current user can update the record Runs Reactivate: Error to Active "Use this once the shop is reachable again. The circuit breaker is reset and the connection resumes with the next scheduled sync." "Connection reactivated"
Deactivate status is pending, active or error and the current user can update the record Runs Deactivate: to Inactive "This revokes all of its tokens and cleans up the customer, order and product data stored for it." "Connection deactivated"
Disconnect status is pending, active or error and the current user can update the record Runs Disconnect: to Disconnected "This starts the 30-day grace period. Business data is soft-deleted so it is no longer served, but credentials are kept. The operator can reconnect within this window to restore the connection and its data." "Connection disconnected — grace period started"
Force Uninstall status != uninstalled and the current user can update the record Runs Uninstall: to Uninstalled "This immediately and permanently deletes all data for this connection — customers, orders, products, tokens, and webhook history. This cannot be undone." "Connection uninstalled — all data queued for permanent deletion"

All four actions only ever call $record->status->transitionTo(). They carry no other logic. Every side effect (resetting the circuit breaker, revoking or clearing credentials, notifying the plugin, queuing data clean-up or purge jobs, deriving default sync rules) runs in the transition, the same one any other caller gets. An action shows only while its own transition can run from the connection's current status (SystemConnectionState::canTransitionToVia()) and the user can update the record, which gives the table above. A click does not silently do nothing when the status changed after the page rendered: the action ends with the failure notification "The connection changed its status in the meantime. The page now shows its current status." and the page shows the current status. The same notification ends a confirmation when the status changed while the modal was open, and when it changed during the request, between loading the record and the transition's row lock. Reactivate starts no sync: the next scheduled delta sync picks the connection up, and Bulk Sync starts one immediately. See System Connections: Reactivation from Error. Unlike Reactivate, Deactivate and Disconnect, Force Uninstall is visible for connections in any status other than Uninstalled itself, including Disconnected ones still within their grace period, so an operator can force an immediate, permanent purge instead of waiting out the window. See System Connections: Deactivation, System Connections: Disconnect, and System Connections: Uninstall for the full list of effects.

Flow overview

The connection's View page shows the connection's facts in one card (tenant, system, status and the connection's timestamps), then one card per domain its system supports in some direction, plus one for any domain a rule still uses after the system dropped it. A domain card starts with its Main data band, which has three columns:

  • Into connection: the rules whose target is this connection. The domain's records from other connections end up here: Conflux writes them in, or this connection fetches them.
  • The connection itself, in the order the records move: its name and system; under Conflux reads, what Conflux reads here ("Last bulk sync" for a pull_in domain, "Receives webhooks" for a push_in one, or "Conflux does not read this domain from this connection"); under Copy in Conflux, how many records Conflux mirrors for the domain (linked to the domain's list, filtered to this connection), which is what the rules out of the connection pass on; and Import history, a modal with the domain's bulk sync batches (the connection's batches of every domain are in the Import history section below the flow). Import history shows only for a domain the connection's system declares pull_in for.
  • Out of connection: the rules whose source is this connection. Conflux reads the domain's records here and passes them on: it writes them into the target, or the target fetches them.

Both side columns carry the connection's name in their heading ("Into Shop Süd", "Out of Shop Süd") and say in one line under it what happens to the records on that side.

A connection is named by its own name where it has one. Neither handshake sets a name, so most connections have none. On its own page such a connection is named by the external user id it was connected with, or by the last 8 characters of its id where it has neither; its system stands right under it. Where nothing beside it says which system it is, as for the connection on a rule's other side, its system comes first and the host of its base URL follows in parentheses: "WooCommerce (shop-mueller.de)", or "WooCommerce (user-123)" without a base URL. A connection that was deleted keeps its rules, which still name it: as "Deleted connection (…a1b2c3d4)", with the last 8 characters of its id.

The two sides are separate flows that meet only in the connection's system: a record the rules on the left write into it is not part of the copy the rules on the right pass on, unless the connection's own sync reads it back later. So an arrow joins each side to the connection, not the two sides to each other: one into the connection from the left, one out of it to the right. An arrow is solid while an active rule moves records on its side, faint and dashed while the direction matrix allows the side but no active rule uses it, and absent where the matrix rules the side out. Hovering it says which ("Written into Shop Süd", "Read from Shop Süd and passed on", "No active rule passes customers on yet").

Each rule shows the connection on its other side, linked to the rule page where the user may open it, its delivery mode (Push out where Conflux writes into the target, Pull out where the target fetches for itself) and its status. Hovering the mode names who acts, with the rule's target by name: "Conflux writes into Mueller CRM" or "Mueller CRM fetches from Conflux". The status is left out where it would only repeat the mode, which is the case for an active pull-out rule. An active push-out rule whose backfill is still running also carries a Backfill badge, whose tooltip says it is delivering the records the rule already carried when the backfill started; an inactive or deleted rule shows none, since nothing moves its backfill on. Rules are listed by urgency, then by name. A column with more than 5 rules folds the ones reading OK, No run yet, Inactive or Pull out into one expandable row ("7 more: 2 Inactive · 5 OK"); every other status stays listed. Deleted rules sit in their own collapsed group at the end of the column.

Where the direction matrix allows a rule on a side but none exists, the empty column says so: "No connection sends customers here yet." on the left, "Shop Süd's customers go nowhere yet." on the right. Where the matrix rules the side out, no rule can be missing, so the column names the system instead: "User Platform does not give customers out, so nothing can leave here." on the right of a receiving system, "WooCommerce does not take customers in, so nothing can arrive here." on the left of a shop.

Below the main data, a domain card has one band per dependent domain of the card's domain: the parent's child records, which travel on rules of their own. Customers has one, Customer consents. The band has the same three columns as the main data and the same create buttons, rule ordering and compaction (it is built by the same ConnectionFlow::column()), but it is drawn dashed and indented under the main data and captioned "belongs to Customers". It appears under the same condition as a domain card: the connection's system supports the dependent domain in some direction, or rules for it exist. A system that dropped consent but still has consent rules keeps the band, and so does its Customers card.

The band's middle column shows how many consent rows Conflux mirrors for the connection, linked to the connection's customers list. Its inbound line reads "Bulk sync: arrives with the Customers import" in place of "Last bulk sync", because consent is never fetched on its own; "Receives webhooks" shows as usual where the system declares push_in. The band has no Import history.

A consent rule writes onto the customers a customers rule holds, so Add source and Add target on the band need an anchor: a non-deleted customers rule between the two connections, in either direction. The counterpart list offers only connections that have one. Where free counterparts exist but none has a customers rule, the button stays visible and disabled, and its tooltip reads "Create a customers rule to a connection first; a consent rule writes onto the customers that rule holds." The form has no update mode or delete policy: a consent rule always overwrites in the target and never deletes.

Add source and Add target open the create form with this connection fixed on its side and the domain fixed by the card. The form has no domain field: the heading names the domain and the connection ("New customers rule into Shop Süd", "New customers rule out of Shop Süd"), and the domain is submitted as a hidden value that must match the card's, so a request forging another domain fails on the field. The counterpart list offers only connections a rule can still be created with: an eligible counterpart (SyncRuleForm::counterparts()), whose direction matrix lets the domain flow in this direction, with no rule for the source, target and domain yet. A deleted rule counts, because the unique index spans deleted rules too.

Each button shows only where the direction matrix allows that direction for the domain on this connection and the user may create rules (SyncRulePolicy::create()). Where no rule can be created from it, it stays visible but disabled, and its tooltip says why:

Case Tooltip on Add source / Add target
No connection of the tenant that the user can manage can be paired with this one "No other connection of this tenant that you can manage can be paired with this one."
None of them can carry the domain in this direction "No connection of this tenant can send customers here." / "No connection of this tenant can receive customers from here."
Every one that can already has a rule for the domain "Every connection that can send customers here already has a rule." / "Every connection that can receive customers from here already has a rule."

Where one of those rules is deleted, the last tooltip adds "A deleted rule can be restored on its rule page." The panel looks the counterparts and this connection's rules up once per render and answers every card from them. A column no active rule covers says so ("No active rule brings customers here yet.", "No active rule passes customers on yet."), from SystemConnection::incomingCoverageGap() and outgoingCoverageGap().

Everything else about a rule, editing included, happens on its rule page.

Webhook URL and secret

For a connection whose integration takes inbound webhooks, the facts card shows Webhook URL, the address the platform posts to (copyable). Super admins also see Webhook secret (copyable; "Not generated yet" where there is none). The secret is not rendered for anyone else, neither in the page nor in its Livewire snapshot.

A Positive User connection without a secret gets a Generate webhook secret header action, for super admins only. It runs the logic verification uses, and the action is hidden once a secret exists: an existing secret is never replaced, because the automation holds a copy. A PrestaShop connection shows the URL (and, to super admins, its secret or "Not generated yet") but has no generate action; a WooCommerce connection takes no webhooks and shows neither. See Setting up the consent webhook.

Import history

Below the flow overview, a collapsible Import history section lists every bulk sync batch of the connection across all domains, newest first. A batch that carries no domain is listed too, with "No domain" in its Domain column, and a Domain filter narrows the table to one domain. The columns are otherwise the same as in a domain card's Import history modal, which shows the same table narrowed to that card's domain, without the Domain column and filter. With no batch yet, the section says "No bulk sync for this connection yet"; the modal says "No bulk sync for this domain yet".

Both tables come from ConnectionImportHistory. Its connection and domain are locked, so the browser cannot point a mounted table at another connection or domain. It is only mounted by the connection page, whose record lookup is scoped to the connections the user may access (SystemConnectionResource::getEloquentQuery()), so a user who may not see a connection gets a 404 before any batch is read.

Source files

File Purpose
app/Filament/Resources/SystemConnections/SystemConnectionResource.php Resource definition, query customisation
app/Filament/Resources/SystemConnections/Schemas/SystemConnectionForm.php Form schema
app/Filament/Resources/SystemConnections/Schemas/SystemConnectionView.php View page infolist, including the Synced Domains entry
app/Filament/Resources/SystemConnections/Tables/SystemConnectionsTable.php Table columns, filters, actions
app/Filament/Resources/SystemConnections/Schemas/SyncRuleForm.php Sync rule create form: counterpart select, fixed domain, pair validation, the counterparts a rule can still be created with
app/Filament/Resources/SystemConnections/Livewire/ConnectionFlowPanel.php Flow overview, create actions, import history action
app/Filament/Resources/SystemConnections/Livewire/ConnectionImportHistory.php Bulk sync batches of the connection, across domains or narrowed to one
app/Services/ConnectionFlow.php Builds the stripes: columns, order, compaction, deleted group, facets
app/Filament/Resources/SystemConnections/Pages/ListSystemConnections.php List page
app/Filament/Resources/SystemConnections/Pages/CreateSystemConnection.php Create page
app/Filament/Resources/SystemConnections/Pages/ViewSystemConnection.php Detail page
app/Filament/Resources/SystemConnections/Pages/EditSystemConnection.php Edit page with header actions
app/Filament/Resources/SystemConnections/Actions/ReactivateConnectionAction.php Reactivate action, Error to Active
app/Filament/Resources/SystemConnections/Actions/DeactivateConnectionAction.php Deactivate toggle action
app/Filament/Resources/SystemConnections/Actions/DisconnectConnectionAction.php Disconnect toggle action
app/Filament/Resources/SystemConnections/Actions/UninstallConnectionAction.php Force Uninstall toggle action
app/Filament/Resources/SystemConnections/Actions/Concerns/TransitionsConnectionStatus.php Shared transitionsTo() for the four status actions: visibility through canTransitionToVia(), and the failure notification when the status changed meanwhile
app/States/SystemConnection/Transitions/ The transitions the actions run, each with its side effects
app/Filament/Resources/SystemConnections/Actions/ConnectSystemConnectionAction.php List page "Connect" action — manual initiate/finalize flow

See also