Skip to content

Sync Rules

A SyncRule is a data-driven statement that one data domain — products, orders, or customers — flows from a source SystemConnection to a target SystemConnection. It replaces hard-wired sync logic: the rule engine reads a tenant's SyncRule rows to decide what moves where, instead of that decision being baked into integration code. See Tenant Model — Sync rules: additive, data-driven for the concept.

There is no HTTP endpoint or admin panel resource for SyncRule yet — it is a data-layer model only. Rows are written either automatically when a SystemConnection becomes active (see Automatic rule generation) or directly, e.g. by a seeder or via SyncRule::create()/update().

Model attributes

Attribute Type Notes
id UUID Primary key via HasUuids
source_connection_id UUID (FK) Source SystemConnection; FK uses restrictOnDelete
target_connection_id UUID (FK) Target SystemConnection; FK uses restrictOnDelete
data_domain SystemCapability Backed enum cast; which domain this rule moves
is_active boolean Default true; false marks a rule as an unchecked draft — see Direction and tenant validation
deleted_at timestamp Soft deletes enabled

A unique index on (source_connection_id, target_connection_id, data_domain) — named sync_rules_source_target_domain_unique to fit MySQL's identifier length limit — prevents duplicate rules for the same source/target/domain combination.

Relationships

sourceConnection()

targetConnection()

Both are BelongsTo SystemConnection, keyed on source_connection_id/target_connection_id respectively rather than the default system_connection_id.

Casts

Attribute Cast
data_domain SystemCapability::class
is_active boolean

Direction and tenant validation

A SyncRule is only meaningful if the direction matrix allows the flow and both ends belong to the same tenant. Both checks run in a single static::saving() model event hook rather than a Form Request — there is no HTTP layer for SyncRule yet — so they apply the same way regardless of how a row is written (create(), update(), a factory create(), and so on).

Validation only runs when the row being saved has is_active = true. A rule saved with is_active = false is never checked. This lets a rule exist as an unvalidated draft, and lets an active, currently-valid rule be safely deactivated even while some other field on it is being changed to something that would otherwise fail validation.

Direction check (ADR-0005)

SyncRule::isDirectionFlowValid(System $source, System $target, SystemCapability $domain): bool

A static, database-free predicate mirroring System::supportsDirection() (see Systems): the source system must support pull_in or push_in for the domain, and the target system must support push_out or pull_out for the same domain. The saving() hook calls this against the persisted System behind each connection; it can also be called directly, without touching the database, e.g. in tests.

Tenant isolation check (ADR-0010)

Source and target connection must resolve to the same tenant_id. Nothing about the direction matrix inherently prevents wiring together two connections that belong to different tenants; doing so would move one company's data into another's — the same class of leak the Tenant scope exists to prevent (see Tenant Model — Function 2). This check was added to the saving() hook alongside the direction check, not as a separate layer, so a rule can never exist — even transiently — in a state that violates either invariant.

InvalidSyncRuleDirectionException

Thrown by the saving() hook when either check fails. App\Exceptions\InvalidSyncRuleDirectionException extends RuntimeException and exposes two named constructors:

Constructor Thrown when
forRule(SystemConnection $source, SystemConnection $target, SystemCapability $domain) The direction check fails
forCrossTenantRule(SystemConnection $source, SystemConnection $target) The tenant isolation check fails

Automatic rule generation

A tenant's default rules are not written by hand. App\Services\SyncRuleBlueprintService derives them whenever one of the tenant's SystemConnections becomes active, so a shop that completes the handshake starts syncing without an admin touching anything.

Trigger

App\Observers\SystemConnectionObserver calls the service from three hooks:

Hook Fires for
created() a connection created already Active — e.g. the internal-product connection from ConnectSystemConnectionController::bootstrapTenant()
updated() a connection whose status changed (wasChanged('status')) and is now Active — the plugin-side finalize in SystemConnectionService::activate(), and manual (re)activation in the admin panel
restored() a soft-deleted connection restored while Active — it may have missed counterparts that activated meanwhile

Generation runs synchronously inside the activating write — for a restore, immediately after it — so the rules exist by the time the handshake response is sent. Deactivation is deliberately not hooked: taking a connection out of service leaves its rules untouched.

One non-observer path calls the service too, indirectly: WidgetConfigurationController::update() dispatches App\Jobs\GenerateDefaultSyncRulesJob for an active connection on every widget configuration save, not only when data_types changed — generation is idempotent, so running it unconditionally is simpler than diffing the opt-in. The point of it is that widening the opt-in takes effect once the job runs. See Rules and the opt-in are two gates.

This path is queued rather than synchronous — unlike the observer's activation hooks above, where the rules must exist by the time the handshake response is sent — because nothing in the widget save's response depends on the rules existing yet: the request only needs to persist the configuration. Queueing also lets a failed derivation retry instead of being lost, with an exhausted job recorded in failed_jobs rather than silently swallowed.

What gets wired

  1. Only within one tenant. Counterparts are the other Active connections sharing the connection's tenant_id; a connection without a tenant produces nothing.
  2. Only Shop ↔ internal product. SyncRuleBlueprintService::isEligiblePair() requires exactly one side to satisfy SystemDriver::canHaveSupportUsers() (ADR-0008), which rules out Shop↔Shop and Internal↔Internal without needing a second classification.
  3. Only domains both sides handle. Per connection: System::capabilities, narrowed by SystemConnection::configuredCapabilities() when the connection carries a connector-widget opt-in. The two sides' sets are intersected.
  4. Only flows the matrix permits. Both directions are attempted for every surviving domain and checked with SyncRule::isDirectionFlowValid() before the insert, so a rejected direction is skipped silently rather than raising InvalidSyncRuleDirectionException. With the matrices from SystemSeeder this yields Shop→Internal only, because an internal product supports neither pull_in nor push_in.

Created rules are always is_active = true. An auto-derived rule that arrived switched off would be indistinguishable from no rule at all.

Idempotency

The existence check runs withTrashed() over (source_connection_id, target_connection_id, data_domain). If any row exists for a triple — active, inactive, or soft-deleted — it is skipped; nothing is ever reactivated or restored. An admin who switches an auto-created rule off or deletes it therefore does not get it back on the next activation, and automatic generation only ever creates rules that never existed. This also matters at the schema level: sync_rules_source_target_domain_unique does not include deleted_at, so a soft-deleted row keeps its triple occupied for good.

A concurrent activation of two connections in the same tenant can still race past that check. The resulting UniqueConstraintViolationException is caught per rule and treated as "already exists", so an activation never fails over a duplicate.

Any other error is caught by the observer and logged at error level rather than propagated. Generation runs in the created/updated model event, after the status write is already committed and outside any transaction, so an exception escaping it would abort the rest of the activating caller — in the plugin handshake that would leave the connection Active with its connection token unconsumed and no widget token issued, which no retry can recover from. A failed generation therefore leaves the connection active with only part of its rules, and the next activation of either side of an affected pair re-derives it, since generation looks at both sides of every pair.

Who reads the rules

Rules gate the pull-in direction. SystemConnection::pullInCapabilities() is the single place that answers "which domains may this connection sync right now?", and it intersects the connection's System capabilities, its connector-widget opt-in, and the data_domains of its active outgoing rules (activeSourceSyncRules).

Consumer Effect
sync:dispatch (Scheduled Sync) Dispatches BulkSyncJob only for rule-backed domains; a connection with no active outgoing rule is skipped without error
Bulk Sync action (admin panel) Offers only rule-backed domains as data scopes, and is disabled when none remain

Rules whose target is a connection are not read anywhere yet — delivery to a target (push-out) does not exist. Nor is a target's status checked: deactivating a connection deliberately leaves its rules alone, and pull-in keeps filling Conflux's own store while a consumer is paused.

Rules and the opt-in are two gates

The widget opt-in stays an independent gate rather than being folded into the rules. Rules say what is wired; the opt-in says what the customer currently permits.

Both directions of an opt-in change are handled, but not by the same mechanism:

  • Widening re-derives. WidgetConfigurationController::update() dispatches App\Jobs\GenerateDefaultSyncRulesJob after persisting the configuration, whenever the connection is active — so a domain the customer has just switched on gets its missing rule and starts syncing once the job runs, without waiting for a re-activation. Unlike the observer, a generation failure here is not caught: it propagates out of the job so the queue retries it, and an exhausted retry lands in failed_jobs instead of being lost. The configuration save itself already succeeded and returned before the job runs, so there is nothing to roll back.
  • Narrowing deletes nothing. Re-derivation never removes or deactivates a rule, and the idempotency check above means a rule an admin switched off is never restored. A domain the customer switches off stops being offered immediately anyway, because the opt-in gate inside pullInCapabilities() filters it out on the next read. That is why the second gate has to stay: without it, the still-present rule would keep syncing a domain the customer has just revoked.

No rules means no sync at all

A connection with no outgoing rule syncs nothing — silently

SyncRuleBlueprintService only wires Shop ↔ internal-product pairs, and only among active connections. An active shop in a tenant that holds no active internal-platform connection therefore has no outgoing rules, so pullInCapabilities() is empty and neither sync:dispatch nor the Bulk Sync action moves any data. Nothing logs a warning and nothing surfaces in the panel beyond the disabled Bulk Sync button and its "No active sync rule covers this connection" tooltip.

The same holds for any connection that was already active before automatic generation existed: its rules were never derived, and generation only runs again on one of the three activation hooks — creating a connection active, changing its status to active, or restoring it (see Automatic rule generation) — or on a widget configuration save. Until one of those happens, the connection stays wired to nothing.

The two causes have different fixes. A missing counterpart is only fixed by activating that counterpart — re-activating the shop or saving its widget configuration queues generation again, but finds nothing to pair with. A connection that predates automatic generation is fixed by any of the three activation hooks, or by having the shop owner save the connector widget's configuration, which queues a re-derivation even when nothing in the configuration changed. That queued job runs asynchronously — it is not guaranteed to have completed by the time the save request returns. There is no backfill command.

Factory states

SyncRuleFactory's default state produces a persisted, active, direction-valid rule. It builds a source SystemConnection normally, then a target SystemConnection pinned to the same tenant_id via a dependent-attribute closure — SystemConnectionFactory otherwise assigns each connection an independent random tenant, which would fail the tenant isolation check by default.

State Effect
inactive() Sets is_active to false
SyncRule::factory()->create();            // active, direction-valid, same-tenant source/target
SyncRule::factory()->inactive()->create(); // is_active = false, unvalidated

Seeder

SyncRuleSeeder provisions demo data for the "Acme GmbH" tenant seeded by TenantSeeder: one active SystemConnection per system seeded by SystemSeeder (woocommerce, prestashop, rapidmail, user-platform), plus 8 valid, active default rules — WooCommerce and PrestaShop each sync customers to Rapidmail, and all three domains (customers, orders, products) to User Platform. It uses updateOrCreate() throughout, so re-running it is idempotent. Registered in DatabaseSeeder, after SystemSeeder and TenantSeeder.

Note

DatabaseSeeder uses WithoutModelEvents, so the saving() validation hook does not fire during a real php artisan db:seed run — the seeded rows are simply written as-is. tests/Integration/Models/SyncRuleTest.php's SyncRuleSeeder test group is what actually proves the seeded data passes validation: it runs SystemSeeder, TenantSeeder, and SyncRuleSeeder directly, outside DatabaseSeeder, so model events do fire there.

Source files

File Purpose
app/Models/SyncRule.php Eloquent model: casts, sourceConnection()/targetConnection(), isDirectionFlowValid(), saving() validation hook
app/Exceptions/InvalidSyncRuleDirectionException.php Thrown by the validation hook; forRule() and forCrossTenantRule()
database/migrations/2026_07_27_084615_create_sync_rules_table.php Table schema
database/factories/SyncRuleFactory.php Factory: default state + inactive()
database/seeders/SyncRuleSeeder.php Demo connections + default rules for "Acme GmbH"
app/Services/SyncRuleBlueprintService.php Derives a tenant's default rules on connection activation
app/Observers/SystemConnectionObserver.php Calls the blueprint service when a connection becomes active
app/Http/Controllers/Api/Widget/WidgetConfigurationController.php Queues rule re-derivation for an active connection on every widget configuration save
app/Jobs/GenerateDefaultSyncRulesJob.php Queued job that calls the blueprint service for the widget-save path; lets failures escape so the queue retries and an exhausted attempt lands in failed_jobs
app/Models/SystemConnection.php activeSourceSyncRules() relation and pullInCapabilities() — the only reader of rules
app/Console/Commands/DispatchSyncCommand.php Dispatches bulk syncs for rule-backed domains only
tests/Unit/Models/SyncRuleTest.php Casts, relation shapes, full isDirectionFlowValid() truth table (16 direction combinations)
tests/Integration/Models/SyncRuleTest.php Factory, relationships, direction/tenant validation across create/reactivate/edit scenarios, seeder output
tests/Unit/Services/SyncRuleBlueprintServiceTest.php Pair-eligibility truth table (16 driver combinations)
tests/Integration/Services/SyncRuleBlueprintServiceTest.php Pairing, domain derivation, matrix filtering, idempotency

See also

  • ADR-0004 — the direction matrix a SyncRule is validated against
  • ADR-0005 — why a rule is validated against the matrix
  • ADR-0010 — why source and target must share a tenant
  • ADR-0008 — the driver classification pair eligibility reuses
  • Tenant Model — the concept and where SyncRule fits in the target picture
  • Systemsdirection_matrix, SyncDirection, supportsDirection()
  • System Connections — the connections a rule links