Skip to content

Transformer Contract

Reference for the pluggable transformation and storage pipeline introduced in CFX-46. This covers the interface every platform transformer must implement, the result value objects, and the unified Eloquent models.

Interface: TransformerContract

Namespace: App\Contracts\TransformerContract

public function transform(Collection $data, SystemConnection $connection, string $syncBatchId): TransformationResult;

Accepts a Collection<int, DomainRecord> (the output of an integration's requestBulkExport()) and a SystemConnection. Persists each item to the unified Eloquent models. A collection may hold records of more than one domain: a mapper emits a parent followed by its dependents (see Dependent records). Returns a TransformationResult summarising successes and failures.

The method never throws — any exception raised while storing an individual item is captured and included in the result.

Abstract Base Class: AbstractTransformer

Namespace: App\Services\AbstractTransformer

Implements TransformerContract. Concrete transformers extend this class and may override store() to intercept the dispatch.

transform() is declared final. It iterates over the data collection, calls store() for each item, and catches any Throwable per item — meaning a failure on one record does not abort the rest of the batch. The result carries the full failure list so callers can log or retry specific items.

protected function store(DomainRecord $item, SystemConnection $connection, string $syncBatchId): StoreOutcome;

Inheriting it is enough — a minimal subclass with no overrides is sufficient; a concrete transformer only needs to override it to handle a platform-specific mapping need.

Dependent records

A mapper that reads a payload with dependent data emits [parent, ...dependents]. mapCustomerRecords() on the WooCommerce and PrestaShop transformers returns the CustomerData followed by one ConsentData per channel the payload reports. The parent comes first, because storing a consent links the customer row of the same connection.

store() routes each record by its type: CustomerData, OrderData and ProductData go to their own store*() method, and ConsentData goes to ConsentRecorder::write(SystemConnection, ConsentData): StoreOutcome. See ADR-0041.

  • The signal moves only on a value change. A consent's content_changed_at moves when a value field changes (subscribed, confirmed, the three timestamps, source, ip_address) or a trashed row is revived. consent_updated_at moving alone does not count, because WooCommerce reports the customer's modification time there and every customer edit would otherwise move the signal. Linking customer_id does not count either.
  • Counters count roots. TransformationResult and the sync batch counters count records of the batch's own domain. A consent's inserted, updated or not-touched outcome is not counted, because it arrives with its customer, and PersistBulkDataJob adds root records only to total_source_items. A dependent's failure is counted, so it stays visible.
  • A channel the source did not mention is not touched, and null never overwrites a known value, except that a withdrawal clears the confirmation of the opt-in it ends.

Result Value Objects

TransformationResult

Namespace: App\Data\TransformationResult

Property Type Description
succeeded int Number of items stored successfully
failed int Number of items that raised an exception
failures TransformationFailure[] One entry per failed item

TransformationFailure

Namespace: App\Data\TransformationFailure

Property Type Description
item DomainRecord The DTO that could not be stored
reason Throwable The exception raised during store()

Unified Eloquent Models

These models are the persistent representation of normalised platform data inside Conflux. All use HasUuids and SoftDeletes.

Every datetime column below that holds a value the source sent is a UTC instant, and every calendar date is a date. The cast tells you which side a column belongs to: a column that fills a typed DTO field is immutable_datetime or immutable_date, while Conflux's own bookkeeping columns (content_changed_at, created_at, updated_at) stay mutable Carbon. See ADR-0018.

Deleting and restoring the row of a mirrored domain

Customer, Order and Product run every query through App\Models\Builders\MirroredDomainRecordBuilder, because a soft deletion has to carry the reason it happened and the two columns must move in one statement. RecordProjector refuses a row that names a cause without having been deleted, and a sequence writing the two separately can be interrupted between them.

Call Where Effect
deleteWithReason(DeletionReason $reason) Builder Soft-deletes the matched live rows, records the cause, and moves content_changed_at where the cause calls for it. The intentional way to remove one of these rows.
restore() Builder or instance Restores and clears the cause. The bulk builder override does it directly; an instance $customer->restore() does it through the restoring listener instead.
untrash() Instance Clears both columns as attributes without writing, for a caller that folds the resurrection into its own save.
delete() Builder or instance Still works and leaves the cause null, which is a valid tombstone with no recorded cause.

AbstractTransformer un-trashes a returning record with untrash() rather than restore(). That is deliberate: restore() saves immediately, and deleted_at has to still be dirty when stampIfChanged() reads getDirty() a few lines later. A record that only came back would otherwise move no content_changed_at and be reported as NotTouched, which makes the resurrection invisible to outbound delivery.

LineItem carries neither column and uses neither method: a child row inherits its parent's cause by having none of its own. CustomerConsent is a mirror and carries both. Disconnect, cleanup, restore and purge handle consent by system_connection_id with a cause, and a customer deleted through the deletion log soft-deletes its consents with the same cause (source_event). See ADR-0019.

Customer

Namespace: App\Models\Customer
Table: customers

Column Type Description
id uuid Conflux-assigned primary key
system_connection_id uuid FK The system connection that produced this record
source_id string System-native customer ID (dedup key)
source_custom_id ?string Secondary system identifier (e.g. internal CRM ID)
source_platform string Cast to SystemDriver enum
email string —
first_name ?string —
last_name ?string —
phone ?string —
gender ?string Cast to Gender enum
title ?string —
zip ?string —
date_of_birth ?date A calendar date, cast immutable_date. Never zone-converted
address_billing ?json Serialised AddressData
address_shipping ?json Serialised AddressData
tags ?json Array of tag strings
order_count ?int Total number of orders on the source platform
total_revenue ?int Lifetime revenue in minor currency units
source_created_at ?timestamp The source's own creation instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
source_updated_at ?timestamp The source's own last-updated instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
external_data ?json Platform-specific key/value pairs from customAttributes

Unique constraint: (system_connection_id, source_id)

Relations: systemConnection() → BelongsTo<SystemConnection>, consents() → HasMany<CustomerConsent>, orders() → HasMany<Order>

CustomerConsent

Namespace: App\Models\CustomerConsent Table: customer_consents

A mirror of the dependent customer_consents domain: one connection's view of one customer's consent on one channel. Its parent is named by parent_source_id within the same connection, not by a foreign key. See ADR-0041.

Column Type Description
id uuid Primary key
system_connection_id uuid FK The connection this view belongs to
source_system string Cast to SystemDriver, so a projection needs no join
source_id string {parent_source_id}:{channel}, for example 4711:email
parent_source_id string The parent customer's (or contact's) source id within the same connection
customer_id ?uuid FK Shortcut to the parent's customer row for eager loading and the admin view. Null where the connection does not mirror customers. Identity does not rest on it
channel string Cast to ConsentChannel enum (email, sms, general)
subscribed ?bool Whether the customer is subscribed; null means the source cannot know
confirmed ?bool Whether the subscription is confirmed (double opt-in); null means unknown, which is what WooCommerce and PrestaShop report
subscribed_at ?timestamp A UTC instant, cast immutable_datetime
confirmed_at ?timestamp A UTC instant, cast immutable_datetime
unsubscribed_at ?timestamp A UTC instant, cast immutable_datetime
consent_updated_at ?timestamp When the source last reported this state as current, cast immutable_datetime. Does not move content_changed_at by itself. Nullable only for rows written before the column existed; CustomerConsent::consentUpdatedAtOrFallback() stands in for those
source ?string Cast to ConsentSource enum
ip_address ?string —
content_changed_at ?timestamp The consent's own change signal; see Dependent records
deletion_reason ?string Cast to DeletionReason; why the row was soft-deleted

Unique constraint: (system_connection_id, source_id) — one row per channel per parent per connection.

A migration set confirmed to null for the WooCommerce and PrestaShop rows that existed: neither platform can say an address was confirmed, and the old false and true were guesses.

Relations: customer() → BelongsTo<Customer>, systemConnection() → BelongsTo<SystemConnection>

Order

Namespace: App\Models\Order
Table: orders

Column Type Description
id uuid Conflux-assigned primary key
system_connection_id uuid FK The system connection that produced this record
customer_id ?uuid FK Linked Customer (nullable)
source_id string System-native order ID (dedup key)
source_order_number ?string Human-readable order number on the system
source_platform string Cast to SystemDriver enum
customer_source_id ?string System-native ID of the associated customer
status string System-native status string (not normalised)
currency string ISO 4217 currency code
total int Grand total in minor currency units
total_tax ?int Tax portion in minor currency units
total_shipping ?int Shipping portion in minor currency units
address_billing ?json Serialised AddressData
address_shipping ?json Serialised AddressData
tags ?json Array of tag strings
date_paid ?timestamp A UTC instant, cast immutable_datetime
source_created_at ?timestamp The source's own creation instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
source_updated_at ?timestamp The source's own last-updated instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
external_data ?json System-specific key/value pairs from customAttributes

Unique constraint: (system_connection_id, source_id)

Relations: systemConnection() → BelongsTo<SystemConnection>, customer() → BelongsTo<Customer>, lineItems() → HasMany<LineItem>

LineItem

Namespace: App\Models\LineItem Table: line_items

Column Type Description
id uuid Primary key
order_id uuid FK Owning order
product_id ?uuid FK Resolved Product — null if the product has not been synced
source_id string System-native line item ID (dedup key within the order)
product_source_id string System-native product ID used to resolve product_id
name string Product name at time of order
quantity int —
unit_price int Price per unit in minor currency units
total int Line total in minor currency units
product_custom_id ?string Secondary system product identifier
sku ?string —
currency ?string ISO 4217 currency code

Unique constraint: (order_id, source_id)

Relations: order() → BelongsTo<Order>, product() → BelongsTo<Product>

product_id is resolved at import time by looking up Product with (system_connection_id, product_source_id). If the product has not been synced yet, product_id remains null until the order is re-imported after the product sync.

Product

Namespace: App\Models\Product
Table: products

Column Type Description
id uuid Conflux-assigned primary key
system_connection_id uuid FK The system connection that produced this record
source_id string System-native product ID (dedup key)
source_custom_id ?string Secondary system identifier
source_platform string Cast to SystemDriver enum
name string —
description ?text —
url ?string Product page URL on the system
image_url ?string Primary product image URL
status string Cast to ProductStatus enum
sku ?string —
price ?int Current price in minor currency units
original_price ?int Pre-discount price in minor currency units
currency ?string ISO 4217 currency code
brand_name ?string —
target_group ?string —
categories ?json Array of category strings
tags ?json Array of tag strings
order_count ?int Number of times this product has been ordered
source_created_at ?timestamp The source's own creation instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
source_updated_at ?timestamp The source's own last-updated instant, in UTC. Cast immutable_datetime; null for a record written before CFX-241
external_data ?json System-specific key/value pairs from customAttributes

Unique constraint: (system_connection_id, source_id)

Relations: systemConnection() → BelongsTo<SystemConnection>

Default store() Implementation

AbstractTransformer provides a concrete store() method that handles all three entity types via a match expression. It uses updateOrCreate keyed on (system_connection_id, source_id) so that re-running a bulk export updates existing records rather than creating duplicates.

Platform-specific transformers extend AbstractTransformer and override store() only when they need behaviour that differs from the default. Until that need arises, a minimal subclass with no overrides is sufficient.

TransformerRegistry

Namespace: App\Services\TransformerRegistry

A singleton that maps each SystemDriver to a concrete transformer class. It mirrors IntegrationRegistry and is the only mechanism by which Conflux locates the correct transformer at runtime.

public function register(SystemDriver $driver, string $transformer): void
public function resolve(SystemDriver $driver): AbstractTransformer
  • register() — associates a SystemDriver case with a class-string<AbstractTransformer>. Called once per platform in AppServiceProvider::boot().
  • resolve() — returns an instance of the registered transformer, resolved via the service container (app($class)). Throws RuntimeException if no transformer has been registered for the given driver.

The registry is bound as a singleton in AppServiceProvider::register():

$this->app->singleton(TransformerRegistry::class);

PersistBulkDataJob receives the registry via constructor injection and calls resolve() at job execution time to obtain the transformer for the connection's system driver.

Registration is required

A transformer that is not registered in TransformerRegistry cannot be resolved. Any PersistBulkDataJob dispatched for its system driver will throw a RuntimeException at execution time. Every transformer implementation must be registered — see Implement a System Transformer.

PersistBulkDataJob

Namespace: App\Jobs\PersistBulkDataJob
Queue connection: rabbitmq

The job that bridges the bulk-fetch stage and the persistence stage. BulkSyncJob dispatches it after each successful requestBulkExport() call when the result is non-empty.

public function __construct(
    public readonly Collection $data,           // Collection<int, DomainRecord>
    public readonly SystemConnection $systemConnection,
) {}

On execution, it resolves the correct transformer from TransformerRegistry using the system driver of the associated SystemConnection, then calls transform(). If the connection has no associated system, the job exits silently.

The job runs on the rabbitmq connection, which places it on a different queue from BulkSyncJob (which runs on the default redis-backed queue). This separates the fetch stage from the persistence stage and allows them to scale independently.