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_atmoves when a value field changes (subscribed,confirmed, the three timestamps,source,ip_address) or a trashed row is revived.consent_updated_atmoving alone does not count, because WooCommerce reports the customer's modification time there and every customer edit would otherwise move the signal. Linkingcustomer_iddoes not count either. - Counters count roots.
TransformationResultand 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, andPersistBulkDataJobadds root records only tototal_source_items. A dependent's failure is counted, so it stays visible. - A channel the source did not mention is not touched, and
nullnever 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_idis resolved at import time by looking upProductwith(system_connection_id, product_source_id). If the product has not been synced yet,product_idremains 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 aSystemDrivercase with aclass-string<AbstractTransformer>. Called once per platform inAppServiceProvider::boot().resolve()— returns an instance of the registered transformer, resolved via the service container (app($class)). ThrowsRuntimeExceptionif no transformer has been registered for the given driver.
The registry is bound as a singleton in AppServiceProvider::register():
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.