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)¶
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¶
- Only within one tenant. Counterparts are the other
Activeconnections sharing the connection'stenant_id; a connection without a tenant produces nothing. - Only Shop ↔ internal product.
SyncRuleBlueprintService::isEligiblePair()requires exactly one side to satisfySystemDriver::canHaveSupportUsers()(ADR-0008), which rules out Shop↔Shop and Internal↔Internal without needing a second classification. - Only domains both sides handle. Per connection:
System::capabilities, narrowed bySystemConnection::configuredCapabilities()when the connection carries a connector-widget opt-in. The two sides' sets are intersected. - 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 raisingInvalidSyncRuleDirectionException. With the matrices fromSystemSeederthis yields Shop→Internal only, because an internal product supports neitherpull_innorpush_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()dispatchesApp\Jobs\GenerateDefaultSyncRulesJobafter 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 infailed_jobsinstead 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
SyncRuleis 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
SyncRulefits in the target picture - Systems —
direction_matrix,SyncDirection,supportsDirection() - System Connections — the connections a rule links