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 for SyncRule — management is admin-panel only, created from a system connection's flow overview and managed on the rule page (see Sync Rules resource). Rows are written automatically when a SystemConnection becomes active (see Automatic rule generation), through the admin panel, 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 SyncRuleObserver::saving() 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.

The first activation of a push-out rule — created active, or switched on and holding no cursor yet — opens its delivery cursor: delta from the moment of activation, and a backfill over what the rule already carries, bounded by that same moment. Reactivating it later, restoring it after a disconnect, or any other save of an active rule finds the cursor already there and starts no second one. See Outbound Delivery State — The cursor for what the cursor holds and how it is opened.

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
forMissingAnchor(SystemConnection $source, SystemConnection $target, SystemCapability $domain) A rule of a dependent domain has no rule of its parent domain between the same two connections; see Dependent rules

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. The same call also deactivates rules for domains that have fallen out of the connection's widget opt-in since the last run, and reactivates rules for domains that have come back into it — see Deactivating dropped domains and Reactivating restored domains.

Trigger

Two hooks of App\Observers\SystemConnectionObserver and three status transitions call the service, all through SyncRuleBlueprintService::tryGenerateFor():

Hook or transition Fires for
created() a connection created already Active — e.g. the internal-product connection from ConnectSystemConnectionController::bootstrapTenant()
Activate, Reconnect, Reactivate a connection that moves to Active: the plugin-side finalize in SystemConnectionService::activate(), a reconnect within the grace period, and the admin panel's Reactivate action for an Error connection
restored() a soft-deleted connection restored while Active — it may have missed counterparts that activated meanwhile

Generation runs synchronously, so the rules exist by the time the handshake response is sent. The observer hooks call it right after the insert or the restore. The three transitions defer it with DB::afterCommit() until the transaction that saved the new status has committed, so a rolled-back activation generates nothing. Deactivation is deliberately not hooked: taking a connection out of service leaves its rules untouched.

One further 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 both widening and narrowing the opt-in take effect once the job runs: a domain checked for the first time gets its rule created, a domain that was unchecked and is now re-checked gets its existing rule reactivated, and a newly unchecked one gets its existing rule deactivated. See Rules and the opt-in are two gates.

This path is queued rather than synchronous (unlike the activation triggers 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.

  5. A dependent rule with its parent. Right after creating a rule for a root domain, the service creates the rule of each domain that depends on it, in the same direction, wherever the matrix allows that flow. A dependent rule is never created on its own. See Dependent rules.

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

Idempotency

createIfMissing()'s existence check (existingRuleKeys()) runs withTrashed() over (source_connection_id, target_connection_id, data_domain). If any row exists for a triple — active, inactive, or soft-deleted — creation is skipped: automatic generation only ever creates rules that never existed, and never resurrects a soft-deleted one. 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.

That "never created twice" guarantee is about creation only — it does not mean a deactivated rule stays off forever. Whether it does depends on why it is off:

  • Admin-deactivated, on a connection with no explicit widget opt-in. If SystemConnection::configuredCapabilities() is null, reactivateRestoredDomains() no-ops — the same guard deactivateDroppedDomains() uses, since there is no narrower opt-in for a domain to have dropped out of and come back into. A rule an admin switches off on such a connection stays off across every later generateFor() run.
  • Deactivated by the widget opt-in, then re-checked. reactivateRestoredDomains() looks for inactive, non-trashed rules whose domain is back in $ownDomains and flips them to is_active = true, subject to the same direction-matrix re-check createIfMissing() performs for a fresh row.
  • Soft-deleted, whatever the reason. Never resurrected by either createIfMissing() or reactivateRestoredDomains() — both leave a trashed row exactly as they found it.

A concurrent activation of two connections in the same tenant can still race past the creation existence 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 SyncRuleBlueprintService::tryGenerateFor() and logged at error level rather than propagated. A status change commits before generation runs, so an exception escaping it could no longer roll the activation back, but it would still surface in the caller: in the plugin handshake it would fail the whole handshake over rules a later run can derive. 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.

Deactivating dropped domains

generateFor() does not only create. Right after computing the connection's eligible domains, a private deactivateDroppedDomains() step handles the other half of an opt-in change: any of the connection's System capabilities that used to be eligible but no longer are — because the widget opt-in narrowed — get their active SyncRules switched off.

  • No-ops without an explicit opt-in. If SystemConnection::configuredCapabilities() is null, there is no narrower set to have dropped out of, so nothing runs — the same "null means all capabilities" reading eligibleDomainsFor() already applies.
  • Scope. It bulk-updates every SyncRule::active() row where the connection is either source_connection_id or target_connection_id and data_domain is one of the dropped domains, setting is_active = false. A single UPDATE, not a per-row loop.
  • Deactivates, never deletes. Rows are not soft-deleted, only flipped inactive — see Reactivating restored domains below for how re-checking the box later can bring the rule back.
  • Runs before the empty-domain early return. deactivateDroppedDomains() is called before generateFor()'s early return for $ownDomains === [], so unchecking every data type still deactivates every one of the connection's rules, not just all-but-one.
  • Not counted in the return value. generateFor() still returns only the number of rules created; a caller cannot tell from the return value alone whether any (de)activation happened.
  • Reaches every caller of generateFor(). Both GenerateDefaultSyncRulesJob (the widget-save path) and sync-rules:repair (RepairSyncRulesCommand) call SyncRuleBlueprintService::generateFor() directly, so both pick up deactivation with no separate code path.

Reactivating restored domains

Right after deactivateDroppedDomains(), generateFor() calls a private reactivateRestoredDomains() step handling the mirror case: a domain that had dropped out of the widget opt-in and is now checked again. Without this step, createIfMissing() alone cannot bring such a rule back — an existing row, active or not, already satisfies existingRuleKeys(), so it never reaches the creation branch that sets is_active = true.

  • No-ops without an explicit opt-in, or with nothing checked. Returns immediately if $ownDomains === [] or SystemConnection::configuredCapabilities() is null — the same guard deactivateDroppedDomains() uses, since a connection with no explicit opt-in never has a narrower set for a domain to have dropped out of in the first place.
  • Scope. Queries SyncRule through the model's default scope — soft-deleted rows are excluded — for rows where is_active = false, the connection is either source_connection_id or target_connection_id, and data_domain is one of $ownDomains.
  • Re-validated, not blindly flipped. For each candidate it loads sourceConnection.system/targetConnection.system, skips the row if either is missing, and re-checks SyncRule::isDirectionFlowValid() — a candidate whose systems no longer support the flow (e.g. a system's capabilities changed since deactivation) is left inactive.
  • The delivery policy is checked first. saving() skips inactive rules, so a parked rule can hold a policy its target's writer cannot carry out. Such a candidate is left inactive with the warning Left a sync rule inactive although its domain was opted back in, instead of letting the refusal escape generateFor() and cost the connection's other counterparts their rules.
  • A real model save, not a bulk update. Reactivation goes through $rule->update(['is_active' => true]) rather than a query-builder update(), so SyncRuleObserver::saving()'s cross-tenant and direction validation runs exactly as it would for a freshly created rule, and SyncRuleObserver::updated() sees the switch to active, which is what opens a push-out rule's delivery cursor on its first activation.
  • Soft-deleted rows are never touched. The query never adds withTrashed(), so a trashed row for a re-checked domain stays trashed and inactive — see Idempotency.
  • Not counted in the return value. Same as deactivateDroppedDomains(): generateFor()'s return value is only the count of rules created.
  • Reaches every caller of generateFor(). Both GenerateDefaultSyncRulesJob and sync-rules:repair pick up reactivation with no separate code path.

Both steps share a private involvingConnection() closure for the "connection is source or target" where-clause.

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 (activeOutgoingSyncRules).

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
Flow overview (admin panel) outgoingCoverageGap() / incomingCoverageGap() name, per direction, the domains no active rule covers and for which a rule could actually be created

Rules whose target is a connection do not move any data yet, because no system declares push_out. CFX-206 is building the delivery mechanism that would use them. See Outbound Delivery for its design. They are surfaced, though: the target connection's flow overview lists them, and incomingCoverageGap() names domains an incoming rule could still be added for. 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 activation triggers, 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 deactivates, re-checking reactivates, both via the same queued job (see Deactivating dropped domains and Reactivating restored domains). Neither direction deletes: a rule stays on the table, just flipped between is_active = true and false. The one case that does not come back on its own is a rule an admin deactivated by hand on a connection with no explicit widget opt-in — see Idempotency. A domain the customer switches off stops being offered immediately anyway, regardless of the queued job's timing, because the opt-in gate inside pullInCapabilities() filters it out on the next synchronous read. That is why the second gate has to stay independent of rule state: the opt-in is what stops a domain being offered on the next request, while (de)activating its SyncRule is what stops or resumes sync:dispatch and the Bulk Sync action treating the domain as wired once the queued job runs.

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. The connection's flow overview stays quiet too: outgoingCoverageGap() only names a domain when some eligibleCounterparts() connection could actually take a rule for it, and with no active internal-platform connection in the tenant there is no such counterpart. The only visible sign is 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. Unlike the missing-counterpart case above, a real counterpart connection does exist here, so outgoingCoverageGap() names the missing domain in the flow overview ("No active rule passes customers on yet.").

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 command that backfills missing rules.

Delivery policy

Two columns say what a customer wants done with a record the target already holds, and with a record that disappears at its source. The system says what is possible; the rule says what is wanted.

update_mode

Value Meaning
overwrite (default) The writer sends the record's full canonical payload on every delivery, not only the fields that changed since last time.
create_only The writer creates the record in the target once, on the first delivery, and sends no further update to it afterward.

Because create_only sends nothing after that first delivery, a later change to the record never reaches the target. Consent is not affected: it is a dependent domain with a rule of its own, whose update_mode is always overwrite, so a create_only customers rule loses no consent change, and the panel shows no warning for the combination any more (ADR-0016, note).

delete_policy

Value Meaning
ignore (default) A removal at the source is not sent on, and the target's copy is left as it is.
deactivate The record in the target is moved into whatever state that target uses to mark a record inactive, rather than being removed.
delete The record is removed from the target outright.

Whatever marker a writer uses for deactivate in the target must represent lifecycle state only, never consent or opt-in.

Otherwise a record disappearing from the shop would look, inside the target, just like that person withdrawing consent.

That is a consent error, not a bookkeeping one.

deletion_reason and delete_policy

A row's deletion_reason (CFX-212) decides whether delete_policy actually reaches that row.

Cause Propagation
source_event An explicit statement from the source that the record is gone, for example a webhook. delete_policy applies to it as configured.
connection_lifecycle The row disappeared because its connection was disconnected or uninstalled, not because the source reported anything about that record. Never propagates, whatever delete_policy says.
no cause recorded A row deleted before the cause was recorded carries none, and a plain delete() still produces one today, so nulls keep arriving rather than draining away as a legacy case. Treated as connection_lifecycle: never propagates.

See ADR-0019 for why there are two causes rather than three. See Outbound Delivery for how a policy that propagates is actually carried out.

Where a default comes from

A system declares its defaults per data domain in systems.delivery_defaults, a nullable JSON column read through System::defaultUpdateModeFor()/defaultDeletePolicyFor(), by way of System::effectiveUpdateModeFor()/effectiveDeletePolicyFor(), which fall back to UpdateMode::default()/DeletePolicy::default() where the system declares nothing. The effective values are what creation uses; a divergence compares a rule only with what the system really declares, never with that fallback. A rule created by SyncRuleBlueprintService writes the effective values explicitly at creation, and the create form in the admin panel recomputes the same declaration whenever the target connection changes (the domain is fixed by the domain card the form is opened from), so the automatic and the manual path agree. The update_mode/delete_policy column defaults, overwrite and ignore, are only the backstop for a row written outside either path, for example a direct SQL insert; the value stays overridable on any rule regardless of where it started.

When a policy is refused

A rule whose target declares push_out for that rule's domain is validated against that target's writer in the same saving() hook that already runs the direction and tenant checks. Every verb is asked for with instanceof rather than read off a declaration (ADR-0021): for a root domain create is needed unconditionally, update follows from overwrite, delete from the delete policy, and deactivate from the deactivate policy. A target with no writer registered in OutboundWriterRegistry refuses the rule outright.

A rule whose target declares only pull_out is not checked at all: that target fetches for itself and is never handed to a writer, so the question does not apply. An inactive rule is not checked either, so an unusable policy can be staged on a draft exactly as an invalid direction can.

SyncRule::isPolicyDeliverable() answers the same question without saving. The rule page uses it for a check in its Flow section: a red line under the rule's policy, shown only while the target cannot carry that policy out. SyncRule::policyUndeliverableBecause() hands that line the refusal's own explanation, such as "The target system cannot update customers, which this rule requires.". Its Edit action uses it to lock the Active toggle, with a note explaining why, on an inactive rule that cannot be activated as it stands.

See ADR-0033 for the reasoning, including why checking every active rule would have been the wrong scope.

Falling back on a generated rule

The refusal above is a hard failure for a rule created or edited directly, for example through the admin panel or SyncRule::create(): there is no neutral value to substitute when a human chose the policy on purpose, so the row is simply not saved.

SyncRuleBlueprintService::createIfMissing() treats the same refusal differently, because it is deriving the policy rather than being told it. When the target's declared policy is refused, it retries once weakening only delete_policy to DeletePolicy::default() and carrying update_mode forward exactly as declared, then lets the same saving() guard decide again rather than predicting its answer. update_mode is not reset alongside delete_policy: UpdateMode::default() is overwrite, which is exactly the mode that needs the update verb, so resetting it could never help a writer that lacks update — and where the declared pair was (create_only, delete), resetting it would promote update_mode to a mode the declaration deliberately avoided. A retry that succeeds creates the rule with delete_policy weakened and update_mode untouched, and logs a warning naming the policy that was declared and the one that was applied instead, so the pair still delivers whatever its writer can do; only the part it cannot do is dropped.

A retry that is refused too skips the pair, exactly as every refusal did before this fallback existed. The gap is then one of three things: no writer registered for the target's driver, a writer missing create — needed unconditionally — or a writer missing update where the declared update_mode is overwrite. The fallback deliberately stops there rather than cascading to (create_only, ignore), the one combination still deliverable for a writer without update: silently choosing it would turn a rule meant to keep its records current into one that never updates them — a rule that cannot be created is the safe outcome there. That outcome is logged too, with a different message, because it means something different from a successful fallback: no rule exists for the pair at all, rather than one existing with less than was declared.

See ADR-0033 for why the check this fallback retries against exists in the first place.

A downgraded rule is otherwise invisible afterwards: it is perfectly deliverable, so the rule page's Flow section shows no deliverability check for it, and existingRuleKeys() matches on the (source, target, domain) triple alone, so a later generateFor() run never upgrades it even once the writer gains the missing verb. App\Services\PolicyDivergences compares the rule's stored update_mode and delete_policy, one field at a time, with what its target currently declares for the domain (System::defaultUpdateModeFor() / defaultDeletePolicyFor()); a field the target declares nothing for is not compared. The rule page shows each divergence in its Flow section, under the value it concerns, with its consequence and the Switch and Keep actions, so a fallback is not only a log line at creation time.

A deliberate override shows as a divergence too: an operator who changes update_mode or delete_policy through the rule's Edit action away from the declaration is not falling back at all, and ADR-0016 treats a customer overriding the declared policy on their own rule as the normal way they state what they want. The page cannot tell such an override apart from a downgrade, so a divergence is a prompt to check the rule rather than proof that something needs fixing, and Keep records it as intended.

See Outbound Delivery State for what a delivery outcome looks like.

Dependent rules

A rule whose data_domain is a dependent domain, today only customer_consents, delivers records that belong to a record of its parent domain. It follows the rules above with four differences. See ADR-0042 for the reasoning and Outbound Delivery for how its records are delivered.

  • It needs an anchor rule. A non-deleted rule of the parent domain must exist between the same two connections, in either direction. SyncRule::anchorRules() lists them, the reverse direction first, and anchorRule() is the first of them, the one whose stop, active flag and route the dependent rule inherits. SyncRuleObserver::saving() refuses an active dependent rule without one (InvalidSyncRuleDirectionException::forMissingAnchor()), and ValidSyncRuleFlow refuses it in the form. Deleting the anchor rule later does not delete the dependent rule: it reads "Config problem" ("There is no customers rule between these connections") until the anchor rule is restored.
  • Its policy is fixed. update_mode is always overwrite and delete_policy always ignore. saving() sets both on every save, whatever was passed in, because only a record's latest value means anything and a dependent record absent from the source is not a withdrawal.
  • Its writer needs update only. A dependent record is only ever written onto the record its anchor names, never created, so CanUpdateRemoteRecords is the one verb checked.
  • The blueprint creates it with its parent. SyncRuleBlueprintService creates it right after the parent rule (see What gets wired). Whether its source may hand it over is decided by the parent domain, because systems.capabilities lists root domains only, and the widget opt-in still applies.

A dependent rule's first activation opens its cursor without a backfill; see Outbound Delivery State — The cursor.

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. A new demo connection starts Active; an existing one keeps its status, because a status only changes through a transition, so a re-seed does not reset a demo connection to Active. 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(), isPolicyDeliverable(), assertPolicyIsDeliverable()
app/Observers/SyncRuleObserver.php saving() validation hook; created() and updated() open a push-out rule's delivery cursor on its first activation
app/Exceptions/InvalidSyncRuleDirectionException.php Thrown by the validation hook; forRule() and forCrossTenantRule()
app/Exceptions/UnsupportedDeliveryPolicyException.php Thrown by the same hook's policy check; forMissingWriter() and forVerb()
app/Enums/UpdateMode.php Backed enum: overwrite (default), create_only
app/Enums/DeletePolicy.php Backed enum: ignore (default), deactivate, delete; propagates()
app/Models/System.php defaultUpdateModeFor()/defaultDeletePolicyFor() read delivery_defaults and answer what the system declares; effectiveUpdateModeFor()/effectiveDeletePolicyFor() fall back to the enum default and are what both creation paths call; PolicyDivergences reads only the former
app/Services/SyncRuleBlueprintService.php Writes the effective policy onto every rule it generates, and weakens only delete_policy and logs a warning when the declared policy is refused, so a rule created without a form is not silent about it
app/Filament/Resources/SystemConnections/Schemas/SyncRuleForm.php The two policy fields on the create forms and their reactive default repopulation
database/migrations/2026_07_27_084615_create_sync_rules_table.php Table schema
database/migrations/2026_09_18_100000_add_delivery_policy_to_sync_rules_table.php Adds update_mode/delete_policy
database/migrations/2026_09_18_100100_add_delivery_defaults_to_systems_table.php Adds systems.delivery_defaults
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, deactivates rules for domains dropped from the widget opt-in (deactivateDroppedDomains()), and reactivates rules for domains re-checked back into it (reactivateRestoredDomains())
app/Observers/SystemConnectionObserver.php created()/restored() call tryGenerateFor() for a connection that is Active
app/States/SystemConnection/Transitions/ Activate, Reconnect and Reactivate call tryGenerateFor() through DB::afterCommit() when a connection moves to Active
app/Http/Controllers/Api/Widget/WidgetConfigurationController.php Queues rule re-derivation for an active connection on every widget configuration save; restrictDataTypes() filters the data_types field's options to domains isDirectionFlowValid() allows to at least one eligibleCounterparts() connection
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/Console/Commands/RepairSyncRulesCommand.php sync-rules:repair — calls the blueprint service directly for every active connection, outside the widget-save and activation triggers
app/Models/SystemConnection.php outgoingSyncRules()/incomingSyncRules(), activeOutgoingSyncRules()/activeIncomingSyncRules(), outgoingCoverageGap()/incomingCoverageGap(), eligibleCounterparts(), and pullInCapabilities() — the only reader that feeds sync:dispatch
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, deactivation of dropped opt-in domains, reactivation of re-checked ones
tests/Feature/Widget/WidgetConfigurationTest.php Includes end-to-end coverage of rule re-derivation on save, e.g. re-checking a previously unchecked data type reactivates its rule

See also