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_indomain, "Receives webhooks" for apush_inone, 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 declarespull_infor. - 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¶
- Admin Panel — panel overview, authentication, auto-discovery
- System Connections — model reference, including the Deactivation, Disconnect, and Uninstall side effects
- Connection Flow — Deactivation and Reconnection — why deactivation, disconnect, and uninstall are each enforced this way
- Policies —
SystemConnectionPolicy,SyncRulePolicy, itsresyncgate, and the full/status-only split - Roles & Permissions — the roles, the permission matrix, and why reach is derived
- Sync Rules — the
SyncRulemodel, direction/tenant validation, and automatic rule generation