Integration Contract¶
Reference for the integration communication contract. This covers the interface every platform integration must implement, the request/response DTOs, and the exceptions. The contract covers connection verification (CFX-53), outbound bulk exports (CFX-45), and inbound webhook processing (CFX-48).
Interface: IntegrationContract¶
Namespace: App\Contracts\IntegrationContract
Marker interface with no methods of its own. Every registered integration implements it, but the interface declares no capability — verification, bulk export, webhooks, and on-demand consent pull are all optional per integration and live on separate Can* interfaces instead; see Optional Capability Interfaces below. This is the same pattern App\Contracts\HasConnectionConfiguration already uses.
Abstract Base Class: AbstractIntegration¶
Namespace: App\Services\AbstractIntegration
Implements IntegrationContract. Concrete integrations extend this class. Its constructor is the only thing it provides directly — the toolkit every integration needs regardless of which capabilities it opts into:
public function __construct(
protected readonly CircuitBreaker $circuitBreaker = new CircuitBreaker,
protected readonly ConnectionTokenIssuerService $connectionTokenIssuerService = new ConnectionTokenIssuerService,
) {}
It does not inject ConsentRecorder. Integrations produce ConsentData and hand it upwards; persisting it is the transformer's job, and AbstractTransformer injects its own instance. An integration that ever needs to write consent directly injects ConsentRecorder in its own constructor rather than taking it from the base class — PrestaShopIntegration does exactly that for its consent webhook.
The reasoning behind this split — why the contract is a marker and capabilities are opt-in — is recorded in ADR-0023.
AbstractIntegration declares no capability methods of its own. Each capability — verification, bulk export, webhooks, on-demand consent pull, deletion log — is implemented by a dedicated trait in App\Services\Concerns, which a concrete integration pulls in with use alongside the matching Can* interface:
| Trait | Backs | Concrete integration must also implement |
|---|---|---|
VerifiesConnections |
CanVerifyConnections |
performVerification() |
ExportsInBulk |
CanExportInBulk |
fetchBulkExportBatches() and provideTransformerClass() (optionally override performProbeBulkExport()/performFetchBulkExportPageRange()) |
HandlesWebhooks |
CanHandleWebhooks |
processWebhook() |
PullsConsent |
CanPullConsent |
performPullConsent() |
FetchesDeletionLog |
HasDeletionLog |
performFetchDeletionLog() |
Every trait is constrained with @phpstan-require-extends AbstractIntegration — it can only be use-d on a class extending AbstractIntegration, since it reads $this->circuitBreaker — and declares its public method(s) final, so concrete integrations never override verify(), requestBulkExport(), streamBulkExport(), probeBulkExport(), fetchBulkExportPageRange(), transformerClass(), handleWebhook(), pullConsent(), or fetchDeletionLog() directly; they only implement the perform*()/process*()/provide*() hook the trait requires. The bulk-export, webhook, consent-pull, and deletion-log traits wrap their hook in the same error-handling strategy: any Throwable that is not already an IntegrationRequestException is caught and re-thrown as InvalidIntegrationResponseException. VerifiesConnections uses a different strategy — see CanVerifyConnections below.
performVerification() (required by VerifiesConnections)¶
Declared abstract on the VerifiesConnections trait, so it must be implemented by any integration that uses the trait (and, correspondingly, declares implements CanVerifyConnections). It performs the integration-specific credential check against the live system and resolves the external user/account identifier consumed by the Connection Flow. Return VerificationResult::success($externalUserId) on success. On failure, either return VerificationResult::failure($message) explicitly, or simply throw — verify() catches any Throwable and converts it into a failed VerificationResult, so an integration can rely on $response->throw() as its failure path without any local try/catch.
fetchBulkExportBatches() (required by ExportsInBulk)¶
/** @param Closure(Collection<int, DomainRecord>): void $onBatch */
abstract protected function fetchBulkExportBatches(SystemConnection $connection, ExportRequest $request, Closure $onBatch): void;
Declared abstract on the ExportsInBulk trait — implement it, alongside provideTransformerClass(), to support bulk export. It fetches records from the external system in pages, calling $onBatch once per non-empty page — page size and pagination strategy are entirely up to the integration. Called by streamBulkExport()/requestBulkExport(). Only integrations that support bulk export need to use ExportsInBulk and implement it — and must additionally declare implements CanExportInBulk (see below) so callers can discover the capability via instanceof.
provideTransformerClass() (required by ExportsInBulk)¶
/** @return class-string<TransformerContract> */
abstract protected function provideTransformerClass(): string;
Declared abstract on the ExportsInBulk trait, alongside fetchBulkExportBatches(), returning the AbstractTransformer subclass responsible for persisting the DTOs this integration produces. Backs the public, sealed transformerClass().
performProbeBulkExport() (virtual, optional)¶
protected function performProbeBulkExport(SystemConnection $connection, ExportRequest $request): BulkExportProbe;
Declared with a default body on the ExportsInBulk trait — override it to report the total page count and page size without fetching any records, enabling BulkSyncJob to fan out parallel fetchBulkExportPageRange() jobs. The default returns new BulkExportProbe(totalPages: 1, perPage: 0), so performFetchBulkExportPageRange() falls back to a single fetchBulkExportBatches() call.
performFetchBulkExportPageRange() (virtual, optional)¶
/** @param Closure(Collection<int, DomainRecord>): void $onBatch */
protected function performFetchBulkExportPageRange(SystemConnection $connection, ExportRequest $request, int $startPage, int $endPage, int $perPage, Closure $onBatch): void;
Declared with a default body on the ExportsInBulk trait — override alongside performProbeBulkExport() to fetch a specific page range, calling $onBatch once per page. The default ignores the range and delegates to fetchBulkExportBatches(), which is correct for integrations whose probe always returns a single page.
processWebhook() (required by HandlesWebhooks)¶
/** @param array<string, mixed> $payload */
abstract protected function processWebhook(SystemConnection $connection, array $payload): void;
Declared abstract on the HandlesWebhooks trait. Implement it to handle inbound webhooks. Only integrations that receive webhooks need to use HandlesWebhooks and implement it — and must additionally declare implements CanHandleWebhooks (see below) so callers can discover the capability via instanceof.
performPullConsent() (required by PullsConsent)¶
abstract protected function performPullConsent(SystemConnection $connection, string $customerSourceId): ?ConsentData;
Declared abstract on the PullsConsent trait. Implement it to read a contact's current consent state from the external system. Return null when the contact exists but has no consent state recorded. Throw ConsentTargetNotFoundException when $customerSourceId is unknown to the target system. Only integrations that support reading consent on demand need to use PullsConsent and implement it — and must additionally declare implements CanPullConsent (see below) so callers can discover the capability via instanceof.
performFetchDeletionLog() (required by FetchesDeletionLog)¶
protected function performFetchDeletionLog(SystemConnection $connection, SystemCapability $capability, ?string $since): Collection;
Declared abstract on the FetchesDeletionLog trait. Implement it to fetch the platform's log of records deleted for the given capability since the given cursor ($since, an ISO 8601 string, or null for the platform's full deletion history). Only integrations that support a deletion log need to implement it — and must additionally use FetchesDeletionLog and declare implements HasDeletionLog (see below) so callers can discover the capability via instanceof. See Deletion Log Sync for how $since is derived (it reuses the same delta cursor as ExportRequest::$modifiedAfter) and how the returned entries are consumed.
Optional Capability Interfaces¶
Some capabilities are genuinely optional — only some integrations support them — so they live on small, separate interfaces instead of bloating IntegrationContract. This is the same pattern App\Contracts\HasConnectionConfiguration already uses: callers check instanceof before calling, e.g. WidgetConfigurationController and SystemConnectionObserver check instanceof HasConnectionConfiguration and skip otherwise. Each interface below is backed by a paired trait in App\Services\Concerns (see Abstract Base Class: AbstractIntegration) that provides the final public method(s); a concrete integration opts in by use-ing the trait, implementing the corresponding perform*()/process*()/provide*() hook(s), and declaring the interface. HasConsentChannels is the exception: it declares static facts about the platform rather than an outbound operation, so there is no circuit breaker or error translation to wrap and no backing trait — an integration implements its one method directly.
CanVerifyConnections¶
Namespace: App\Contracts\CanVerifyConnections
verify()— authenticates the connection's credentials against the live system and resolves the external user/account identifier. Never throws; failures are reported through the returnedVerificationResult.
Checked by SystemConnectionService::verify(), which resolves the integration via IntegrationRegistry and — if it isn't an instance of CanVerifyConnections — returns VerificationResult::failure('This integration does not support verification.') instead of calling it. In practice all three registered integrations (WooCommerceIntegration, PrestaShopIntegration, UserPlatformIntegration) implement it, so this is currently a safety net rather than a path any of them hits.
CanExportInBulk¶
Namespace: App\Contracts\CanExportInBulk
public function requestBulkExport(SystemConnection $connection, ExportRequest $request): Collection;
/** @param Closure(Collection<int, DomainRecord>): void $onBatch */
public function streamBulkExport(SystemConnection $connection, ExportRequest $request, Closure $onBatch): void;
public function probeBulkExport(SystemConnection $connection, ExportRequest $request): BulkExportProbe;
/** @param Closure(Collection<int, DomainRecord>): void $onBatch */
public function fetchBulkExportPageRange(SystemConnection $connection, ExportRequest $request, int $startPage, int $endPage, int $perPage, Closure $onBatch): void;
/** @return class-string<TransformerContract> */
public function transformerClass(): string;
requestBulkExport()— returns aCollection<int, DomainRecord>containing every fetched record. ThrowsIntegrationRequestException(or a subclass) on any integration-layer failure. Built on top ofstreamBulkExport().streamBulkExport()— invokes$onBatchonce per fetched page, without accumulating the full result set in memory. ThrowsIntegrationRequestException(or a subclass).probeBulkExport()— returns the total page count and page size without fetching any records, so the job layer can fan out parallelfetchBulkExportPageRange()calls. ThrowsIntegrationRequestException(or a subclass).fetchBulkExportPageRange()— fetches a specific[startPage, endPage]range, invoking$onBatchonce per page. ThrowsIntegrationRequestException(or a subclass).transformerClass()— returns theTransformerContractimplementation responsible for persisting this integration's fetched data. ThrowsIntegrationRequestException(or a subclass) — same wrapping strategy as the other methods here, even thoughprovideTransformerClass()never made an outbound call.
Checked by both BulkSyncJob and FetchBulkPageRangeJob, which resolve the integration via IntegrationRegistry and — if it isn't an instance of CanExportInBulk — skip without calling it (BulkSyncJob logs a warning and returns; FetchBulkPageRangeJob marks the connection Error and cancels its batch, matching how it already reacts to a CircuitBreakerOpenException). In practice BulkSyncJob is only ever dispatched for capabilities SystemConnection::pullInCapabilities() reports as active (admin-configured via System.capabilities/SyncRule), so today this is a safety net rather than a path either registered integration hits.
CanHandleWebhooks¶
Namespace: App\Contracts\CanHandleWebhooks
/** @param array<string, mixed> $payload */
public function handleWebhook(SystemConnection $connection, array $payload): void;
Checked by ProcessWebhookJob, which resolves the integration via IntegrationRegistry, and — if it isn't an instance of CanHandleWebhooks — logs a warning and discards the webhook instead of calling it.
CanPullConsent¶
Namespace: App\Contracts\CanPullConsent
PrestaShopIntegration calls its own pullConsent() from processWebhook() (see the PrestaShop Integration reference), using it to re-fetch a customer's authoritative consent state rather than trusting the inbound webhook payload. WooCommerceIntegration implements it too, reading the connector plugin's newsletter meta for a single contact — see WooCommerce Integration → Consent; nothing calls that one yet, the capability exists so a future caller (CFX-180) does not have to re-derive each platform's consent field. UserPlatformIntegration implements it for contacts Conflux created in the workspace only: an id the delivery ledger does not hold for the connection is refused with ConsentTargetNotFoundException before any request — see User Platform Integration → Path 1. Callers must check instanceof CanPullConsent before calling.
HasConsentChannels¶
Namespace: App\Contracts\HasConsentChannels
Declares which consent channels an integration's platform can actually represent. Channels are asymmetric across platforms — PrestaShop carries a single newsletter flag, WooCommerce has no native consent field at all, while a marketing platform may track email and SMS separately — so a propagator pushing consent outward needs to know what a target can even accept.
Outbound writers implement it too, and that is where it is consumed: App\Services\Concerns\AcceptsConsentChannels answers a writer's accepts() from the writer's own supportedConsentChannels(), so a consent on a channel the target cannot express is suppressed before any request. Nothing reads an integration's declaration yet. A future caller narrowing consents by a declaration has one rule to get right, and it is the easy one to get backwards:
Undeclared means unknown, not 'supports nothing'
An integration that does not implement HasConsentChannels makes no claim about its channels, so everything must pass through unchanged. Treating a missing declaration as an empty channel list would silently stop consent from reaching every integration that has not been annotated yet — a failure mode that produces no error and no log line, only consent quietly not propagating.
WooCommerceIntegration declares [ConsentChannel::Email] (CFX-93) — one newsletter checkbox is one channel. UserPlatformIntegration declares [ConsentChannel::Email, ConsentChannel::Sms]: email is the contact's email_opt_in, SMS its suppression flag unsubscribed_from_sms, which the API accepts but never returns, so SMS is written and received by webhook but not pulled (ADR-0045). PrestaShopIntegration declares nothing; its writer declares [ConsentChannel::Email]. Tests\Support\FakeIntegration implements it (defaulting to [ConsentChannel::Email], overridable via its consentChannels constructor argument) so tests can exercise both sides.
HasFullyReportedConsentChannels¶
Namespace: App\Contracts\HasFullyReportedConsentChannels
Declares the consent channels whose full state an integration reports inbound: subscriptions
and withdrawals alike. On such a channel the connection's consent mirror stands for the platform's
current value. A channel the integration mirrors but does not report in full is one-way. Its row is
the last state seen, so it must not decide an echo or a two-way conflict. DependentEchoes asks
DependentRecord::isFullyReportedBy() about the target mirror row, and treats a row that is not
fully reported as absent. The admin consent view marks such a row Last reported, not live.
This is a different fact from HasConsentChannels. That interface says what a platform can
represent, and writers declare it too. This one says what an integration can read back. Here
too, undeclared means no claim: an integration that does not implement it is taken to report every
channel in full, which is how every mirror was treated before.
UserPlatformIntegration declares [ConsentChannel::Email], because its SMS mirror only ever
learns a withdrawal. WooCommerceIntegration and PrestaShopIntegration declare
[ConsentChannel::Email], because their newsletter flag reads both ways
(ADR-0045).
HasDeletionLog¶
Namespace: App\Contracts\HasDeletionLog
/** @return Collection<int, DeletionLogRecord> */
public function fetchDeletionLog(SystemConnection $connection, SystemCapability $capability, ?string $since): Collection;
Implemented only by integrations whose platform actually exposes a deletion log, backed by App\Services\Concerns\FetchesDeletionLog. The trait provides the final, circuit-breaker-wrapped fetchDeletionLog() and obliges the class to implement performFetchDeletionLog(). Named Has* rather than Can* because it states a fact about the platform — this shop keeps a deletion log — where the Can* interfaces name an operation the integration performs; HasConnectionConfiguration and HasConsentChannels sit on the same side of that line.
BulkSyncJob checks $integration instanceof HasDeletionLog once, right after resolving the integration for the batch, and that check gates whether FetchDeletionLogJob is dispatched at all — unlike processWebhook(), which only runs in reaction to an inbound request. An integration without a deletion log never has FetchDeletionLogJob dispatched for it in the first place; FetchDeletionLogJob itself only ever runs for a HasDeletionLog integration, a static guarantee from its sole caller that it asserts rather than re-checks.
This was a deliberate change from an earlier design that put fetchDeletionLog()/supportsDeletionLog(): bool directly on IntegrationContract, forcing every integration to carry a capability flag for something not every integration has. See Deletion Log Sync — Optional Capability via HasDeletionLog for the full rationale.
Deletion log endpoint contract¶
Every concrete HasDeletionLog integration implements performFetchDeletionLog() as an HTTP GET request following the same request/response shape — only the endpoint URL, auth, and branding differ per platform. See WooCommerce Integration — Deletion Log and PrestaShop Integration — Deletion Log for those concrete details.
Internal Consent Recorder: ConsentRecorder¶
Namespace: App\Services\ConsentRecorder
The store path of a consent mirror inside Conflux's own database: every write of a CustomerConsent row goes through it, whether the consent arrives with its customer's import or on its own by webhook. Consent is a dependent domain mirrored per connection, so a row is one connection's view of one customer's consent on one channel. The decision is recorded in ADR-0041, which supersedes ADR-0024.
It records state and nothing else. Deciding which channels a target integration can accept is a different question, answered by HasConsentChannels at the propagating caller, not here.
write() finds the connection's row by (system_connection_id, source_id), creates it if there is none, and returns what happened: Inserted, Updated or NotTouched. It applies three rules.
- A channel the source did not mention is not touched. A write names one channel through its
sourceId, so the rule holds by construction. nullnever overwrites a known value. A source reportsnullfor what it cannot know, in any value field (subscribed,confirmed, the three timestamps,source,ip_address). The one exception is a withdrawal:subscribed: falseclearsconfirmedandconfirmed_at, because they describe an opt-in that no longer exists. Kept, a later re-subscription that a shop reports withconfirmed: nullwould inherit the old confirmation and be written to Positive User as verified, without a new double opt-in.subscribed_atstays, as the record of when the ended opt-in was given.- A write older than the mirror is refused. Where the row already holds a
consent_updated_atstrictly later than the incomingconsentUpdatedAt, nothing is written, the outcome isNotTouched, and a warning is logged. An equal time applies, so a same-time re-report lands. A trashed row is revived whatever its time: the trash is itself a later observation, so the time it kept is no evidence of a newer state.
Missing information is not a withdrawal
Consent recorded for a channel the incoming data says nothing about is left untouched, deliberately. No shop platform separates channels today: PrestaShop has a single newsletter flag, WooCommerce has no native consent field at all. A sync from such a source can only ever speak for the email channel, and reconciling the full channel set against what arrived would turn "PrestaShop said nothing about SMS" into "SMS consent withdrawn".
So this is a property to preserve, not an omission to fix. It is pinned by tests in tests/Integration/Services/. An adapter must emit a ConsentData only for channels it has a real signal for, and never emit subscribed: false to mean "unknown".
The change signal. A consent moves its own content_changed_at, and only when a value field changes or a trashed row is revived. consent_updated_at moving alone does not count: WooCommerce sets it to the customer's date_modified_gmt, so counting it would move the signal on every customer edit. Linking customer_id does not count either.
Ordering within one mirror, not across writers. A mirror has exactly one writer, its own connection, so there is no ordering across writers and no written_by_system_connection_id. One source still delivers out of order: a bulk import page processed after a newer webhook, or a retried webhook after the one that followed it. The stale-write guard above orders a mirror's writes by the source's own time.
consentUpdatedAt stays required. ConsentData::$consentUpdatedAt carries the instant the source last reported this state as current. It orders the writes to one mirror, and a two-way conflict between two mirrors is settled by it, so a consent that arrives without one cannot be placed. It is a typed instant, a CarbonImmutable on the DTO and a timestamp column on the row, and a mapper reads it through SourceDateTimes like every other source datetime. SourceDateTimes returns null for a value it cannot read, and a consent with no consentUpdatedAt is omitted rather than written with a guess, the same way a channel the platform reports no signal for is. See The One Required Datetime.
Choosing consentUpdatedAt when the source has no field-level timestamp
Neither WooCommerce nor PrestaShop expose a "when did the newsletter flag last change" timestamp, only the customer record's own date_modified/date_upd. The WooCommerce mapping uses date_modified_gmt, falling back to date_created_gmt (see ADR-0026), and PrestaShop uses date_upd. That is a proxy: it moves on edits that leave the consent alone, which is why it cannot be the change signal, and it may not move when a plugin flips the flag without touching the record. An unmoved time still writes, since the guard refuses only a strictly older one. Prefer a source-provided timestamp scoped to the consent field itself whenever one is available.
Soft-deleted rows are revived, not duplicated. CustomerConsent soft-deletes while (system_connection_id, source_id) is a plain unique index, so a trashed row keeps occupying the key. write() looks the row up with withTrashed() and clears deleted_at and deletion_reason in place, the way AbstractTransformer revives a customer, and the revive counts as a change. A trashed row means the connection is in its disconnect grace period or its customer was deleted at the source (see ADR-0013), never that consent was withdrawn.
customer_id is linked where the connection mirrors the parent customer, found by parent_source_id, and left empty otherwise.
ConsentRecorder is a plain injectable class, not a Spatie\LaravelData\Data object and not part of the trait/Can* capability system above: it is never instanceof-checked. Callers receive it via constructor injection with a default value, the same "default-value" DI pattern AbstractIntegration uses for CircuitBreaker. AbstractTransformer constructor-injects ConsentRecorder $consentRecorder = new ConsentRecorder() and routes every ConsentData in store() to write(); PrestaShopIntegration injects its own for processWebhook(), which writes a single consent without a customer import.
ConsentRecorder vs. CanPullConsent
Easy to confuse — both deal with consent, but in opposite directions:
ConsentRecorderis Conflux's internal read/write layer for consent state already stored in Conflux's database. It is not per-integration and makes no outbound calls.CanPullConsent(above) is a per-integration capability for pulling a contact's consent state from the live external system on demand.
PrestaShopIntegration::processWebhook() chains the two: it calls pullConsent() to fetch state from PrestaShop, then ConsentRecorder::write() to persist it — the two collaborate but cover different halves of the round trip; neither wraps the other.
Verification DTO: VerificationResult¶
Namespace: App\Data\VerificationResult
Plain readonly class — not a Spatie\LaravelData\Data object, so it is never serialised directly and has no SnakeCaseMapper. Construct it via the named constructors rather than new:
public static function success(string $externalUserId): self;
public static function failure(?string $message = null): self;
| Property | Type | Required | Description |
|---|---|---|---|
successful |
bool |
Yes | Whether the credentials verified against the live system |
externalUserId |
?string |
No | External account/user identifier resolved during verification; stored on SystemConnection.external_user_id on success |
message |
?string |
No | Failure reason. Not currently read by any caller — RegisterSystemConnectionController responds with a fixed 422 message regardless of this value |
Request DTO: ExportRequest¶
Namespace: App\Data\ExportRequest
| Property | Type | Required | Description |
|---|---|---|---|
capability |
SystemCapability |
Yes | The data type to export |
systemConnectionId |
string |
Yes | UUID of the SystemConnection model |
modifiedAfter |
?CarbonImmutable |
No | A UTC instant, Conflux's own delta cursor; when set, the integration should return only records modified after this point. An integration that sends it to a remote API renders it to a string itself. null means fetch all records (full sync). See Delta Sync. |
SystemCapability enum¶
| Case | Value | Description |
|---|---|---|
Products |
products |
Product catalogue data |
Orders |
orders |
Order data |
Customers |
customers |
Customer/contact data |
Response DTO: BulkExportProbe¶
Namespace: App\Data\BulkExportProbe
Plain class (not a Spatie\LaravelData\Data object), returned by probeBulkExport().
| Property | Type | Description |
|---|---|---|
totalPages |
int |
Total number of pages available for this export |
perPage |
int |
Page size used by the integration's pagination |
Response DTOs¶
All DTOs use Spatie\LaravelData\Data and the SnakeCaseMapper — property names are camelCase in PHP and snake_case when serialised.
Monetary values (price, originalPrice, unitPrice, total, totalTax, totalShipping, totalSpent) are always integers in minor currency units (e.g. cents). A price of €12.99 is stored as 1299.
Datetime fields (createdAt, updatedAt, datePaid, subscribedAt, and so on) are ?CarbonImmutable, never strings. An integration parses them at its own boundary against a format it declares, through App\Services\SourceDateTimes, and an instant is always in UTC. dateOfBirth is a calendar date, so it is never converted. Both are nullable because an unreadable source value is nulled rather than defaulted. See ADR-0018 for the rule and Data Layer for the type convention.
CustomerData¶
Namespace: App\Data\CustomerData
| Property | Type | Required | Description |
|---|---|---|---|
sourceId |
string |
Yes | Platform-native customer ID (dedup key) |
sourceSystem |
SystemDriver |
Yes | The originating system |
email |
string |
Yes | Customer email address |
createdAt |
?CarbonImmutable |
Yes | The source's creation instant, in UTC. null where the source sent nothing readable |
updatedAt |
?CarbonImmutable |
Yes | The source's last-updated instant, in UTC. null where the source sent nothing readable |
tags |
string[] |
Yes (default []) |
Arbitrary labels |
customAttributes |
Collection<int, CustomAttributeData> |
Yes (default empty) | Platform-specific key/value metadata |
id |
?string |
No | Conflux-assigned UUID (null until persisted) |
sourceCustomId |
?string |
No | Secondary platform ID (e.g. external CRM reference) |
firstName |
?string |
No | — |
lastName |
?string |
No | — |
phone |
?string |
No | — |
gender |
?Gender |
No | See Gender enum below |
title |
?string |
No | Salutation or academic title |
zip |
?string |
No | Postal code (shorthand field) |
dateOfBirth |
?CarbonImmutable |
No | A calendar date at midnight, never zone-converted; serialises as a bare 1985-04-12 |
addressBilling |
?AddressData |
No | See AddressData below |
addressShipping |
?AddressData |
No | See AddressData below |
orderCount |
?int |
No | Lifetime order count |
totalSpent |
?int |
No | Lifetime spend in minor currency units |
ProductData¶
Namespace: App\Data\ProductData
| Property | Type | Required | Description |
|---|---|---|---|
sourceId |
string |
Yes | Platform-native product ID (dedup key) |
sourceSystem |
SystemDriver |
Yes | The originating system |
name |
string |
Yes | Product name |
status |
ProductStatus |
Yes | See ProductStatus enum below |
createdAt |
?CarbonImmutable |
Yes | The source's creation instant, in UTC |
updatedAt |
?CarbonImmutable |
Yes | The source's last-updated instant, in UTC |
categories |
string[] |
Yes (default []) |
Category names |
tags |
string[] |
Yes (default []) |
Arbitrary labels |
customAttributes |
Collection<int, CustomAttributeData> |
Yes (default empty) | Platform-specific metadata |
id |
?string |
No | Conflux-assigned UUID |
sourceCustomId |
?string |
No | Secondary platform ID |
sku |
?string |
No | Stock-keeping unit |
description |
?string |
No | — |
url |
?string |
No | Public product URL |
imageUrl |
?string |
No | Primary image URL |
price |
?int |
No | Current price in minor currency units |
originalPrice |
?int |
No | Pre-discount price in minor currency units |
currency |
?string |
No | ISO 4217 currency code |
brandName |
?string |
No | — |
targetGroup |
?string |
No | Platform-defined audience segment |
orderCount |
?int |
No | Number of times ordered |
OrderData¶
Namespace: App\Data\OrderData
| Property | Type | Required | Description |
|---|---|---|---|
sourceId |
string |
Yes | Platform-native order ID (dedup key) |
sourceSystem |
SystemDriver |
Yes | The originating system |
status |
string |
Yes | Platform-native status string |
currency |
string |
Yes | ISO 4217 currency code |
total |
int |
Yes | Order grand total in minor currency units |
createdAt |
?CarbonImmutable |
Yes | The source's creation instant, in UTC |
updatedAt |
?CarbonImmutable |
Yes | The source's last-updated instant, in UTC |
lineItems |
Collection<int, LineItemData> |
Yes (default empty) | See LineItemData below |
tags |
string[] |
Yes (default []) |
Arbitrary labels |
customAttributes |
Collection<int, CustomAttributeData> |
Yes (default empty) | Platform-specific metadata |
id |
?string |
No | Conflux-assigned UUID |
sourceOrderNumber |
?string |
No | Human-readable order number (distinct from sourceId) |
customerId |
?string |
No | Conflux UUID of the linked customer |
customerSourceId |
?string |
No | Platform-native ID of the linked customer |
totalTax |
?int |
No | Tax amount in minor currency units |
totalShipping |
?int |
No | Shipping cost in minor currency units |
addressBilling |
?AddressData |
No | — |
addressShipping |
?AddressData |
No | — |
datePaid |
?CarbonImmutable |
No | The payment instant, in UTC |
Sub-DTOs¶
ConsentData¶
Namespace: App\Data\ConsentData
Despite sitting under Sub-DTOs, ConsentData is no longer nested in CustomerData: it is the canonical record of the dependent customer_consents domain and implements DomainRecord. A mapper emits it next to its customer.
| Property | Type | Required | Description |
|---|---|---|---|
sourceId |
string |
Yes | {parentSourceId}:{channel}, built by ConsentData::sourceIdFor() |
sourceSystem |
SystemDriver |
Yes | The originating system |
parentSourceId |
string |
Yes | The parent customer's (or contact's) source id within the same connection |
channel |
ConsentChannel |
Yes | The consent channel — see ConsentChannel |
subscribed |
?bool |
Yes | Whether the contact has opted in; null means the source cannot know |
confirmed |
?bool |
Yes | Whether the opt-in has been confirmed (e.g. double opt-in); null means the source cannot know, which is every shop |
consentUpdatedAt |
CarbonImmutable |
Yes | The instant the source last reported this state as current, in UTC; settles a two-way conflict between mirrors (see ConsentRecorder). The one required datetime on any DTO — a mapper parses it through SourceDateTimes and omits the whole consent where that yields null |
subscribedAt |
?CarbonImmutable |
No | Instant of initial subscription, in UTC |
confirmedAt |
?CarbonImmutable |
No | Instant of confirmation, in UTC. As the evidence of a double opt-in, it is the one kind of datetime ADR-0018 says should fail the record rather than be nulled. Nothing writes a consent timestamp today, so that policy has no call site and SourceDateTimes implements no skip path: an unreadable value here is nulled like any other |
unsubscribedAt |
?CarbonImmutable |
No | Instant of opt-out, in UTC |
source |
?ConsentSource |
No | How consent was collected |
ipAddress |
?string |
No | IP address at time of consent |
AddressData¶
Namespace: App\Data\AddressData
All fields are optional (?string).
| Property | Description |
|---|---|
firstName |
— |
lastName |
— |
company |
— |
address1 |
Primary street line |
address2 |
Secondary street line (apartment, suite, etc.) |
city |
— |
region |
State, province, or region |
postalCode |
— |
country |
ISO 3166-1 alpha-2 recommended |
phone |
— |
email |
— |
vatId |
VAT identification number |
CustomAttributeData¶
Namespace: App\Data\CustomAttributeData
Represents a single platform-specific attribute. Serialises as an object in a JSON array: [{"key": "shop_id", "value": "7"}, ...].
| Property | Type | Required | Description |
|---|---|---|---|
key |
string |
Yes | Attribute name — namespace to avoid collisions (e.g. woocommerce_loyalty_points) |
value |
string\|int\|bool\|null |
Yes | Attribute value |
LineItemData¶
Namespace: App\Data\LineItemData
| Property | Type | Required | Description |
|---|---|---|---|
sourceId |
string |
Yes | Platform-native line item ID |
productSourceId |
string |
Yes | Platform-native ID of the linked product |
name |
string |
Yes | Product name at time of purchase |
quantity |
int |
Yes | Units ordered |
unitPrice |
int |
Yes | Price per unit in minor currency units |
total |
int |
Yes | Line total in minor currency units (quantity × unitPrice after discounts) |
productId |
?string |
No | Conflux UUID of the linked product |
productCustomId |
?string |
No | Secondary platform ID of the linked product |
sku |
?string |
No | Stock-keeping unit |
currency |
?string |
No | ISO 4217 currency code |
DeletionLogRecord¶
Namespace: App\Data\DeletionLogRecord
Uses Spatie\LaravelData\Data and the SnakeCaseMapper, like the other response DTOs. Represents a single record deleted on the source platform, as returned by fetchDeletionLog(). See Deletion Log Sync for how entries are matched to local records.
| Property | Type | Required | Description |
|---|---|---|---|
entityType |
DeletionLogEntityType |
Yes | The platform's own label for the deleted entity's type. Not cross-checked against SystemCapability by the consuming job — the integration is trusted to return only entries for the capability it was asked for |
entityId |
int |
Yes | Platform-native ID of the deleted record. Compared as a string against the local record's source_id |
deletedAt |
string |
Yes | Timestamp of the deletion, as reported by the platform |
Enums¶
SystemDriver¶
| Case | Value |
|---|---|
WooCommerce |
woocommerce |
PrestaShop |
prestashop |
RapidMail |
rapidmail |
UserPlatform |
user-platform |
Gender¶
| Case | Value |
|---|---|
Male |
male |
Female |
female |
Other |
other |
Unknown |
unknown |
ProductStatus¶
| Case | Value |
|---|---|
Active |
active |
Inactive |
inactive |
Deleted |
deleted |
ConsentChannel¶
Implements App\Contracts\HasLabel; label() returns the display name below.
| Case | Value | Label |
|---|---|---|
Sms |
sms |
SMS |
Email |
email |
|
General |
general |
General |
Consent is tracked per channel: customer_consents is unique on (system_connection_id, source_id) with source_id = {parent_source_id}:{channel}, and a mapper emits one ConsentData per channel its source reports after the CustomerData (see Transformer Contract). A source that only knows one channel emits a single consent; channels it says nothing about keep whatever state another system recorded — see Missing information is not a withdrawal.
Channel is not purpose
ConsentChannel models the delivery channel (email, SMS), not the purpose consent was given for. A platform that distinguishes several email-based purposes — PrestaShop, for instance, has both newsletter (its own mailing list) and optin (partner offers) — cannot express that distinction here: both are channel Email. Do not map a second purpose onto General to work around this; General means "channel-agnostic consent", not "some other purpose". Modelling purpose as a separate dimension is an open product question, not something an adapter should decide on its own.
ConsentSource¶
| Case | Value |
|---|---|
Api |
api |
Import |
import |
Admin |
admin |
User |
user |
Integration |
integration |
Offline |
offline |
DeletionLogEntityType¶
| Case | Value |
|---|---|
Product |
product |
Order |
order |
Customer |
customer |
The entity_type value a HasDeletionLog integration sends to and reads from a platform's deletion-log endpoint. SystemCapability::deletionLogEntityType() maps each capability to its case — singular wire values, unlike SystemCapability's own plural backing values.
IntegrationRegistry¶
Namespace: App\Services\IntegrationRegistry
A singleton that maps each SystemDriver to a concrete integration class. It is the only mechanism by which Conflux locates the correct integration at runtime — no integration class is referenced directly outside of AppServiceProvider.
public function register(SystemDriver $driver, string $integration): void
public function resolve(SystemDriver $driver): IntegrationContract
register()— associates aSystemDrivercase with aclass-string<IntegrationContract>. By convention, every integration is registered directly inApp\Providers\AppServiceProvider::boot()— there is no per-integration service provider.resolve()— returns an instance of the registered integration, resolved via the service container (app($class)). ThrowsRuntimeExceptionif no integration has been registered for the given driver.
The registry itself is bound as a singleton in AppServiceProvider::register():
Because it is a singleton, all register() calls in AppServiceProvider::boot() accumulate in the same instance throughout the application lifecycle.
Registration is required
An integration that is not registered in IntegrationRegistry cannot be resolved. Any BulkSyncJob or ProcessWebhookJob dispatched for its system driver will throw a RuntimeException at job execution time. Every integration implementation must register itself — see Implement a Platform Integration.
Registered Integrations¶
Both WooCommerceIntegration and PrestaShopIntegration declare implements CanExportInBulk, CanVerifyConnections, HasConnectionConfiguration, HasDeletionLog (and use ExportsInBulk, FetchesDeletionLog, VerifiesConnections); WooCommerceIntegration adds CanNotifyDeactivation. WooCommerceIntegration also declares CanPullConsent and HasConsentChannels. PrestaShopIntegration additionally declares implements CanHandleWebhooks, CanPullConsent (use HandlesWebhooks, PullsConsent). UserPlatformIntegration declares implements CanHandleWebhooks, CanPullConsent, CanVerifyConnections, HasConsentChannels (use HandlesWebhooks, PullsConsent, VerifiesConnections): it accepts a Positive User automation's consent webhook and pulls a contact's consent on demand, both only for contacts Conflux created, and writes them into the Positive User connection's own consent mirror (ADR-0044). It implements neither CanExportInBulk nor HasConnectionConfiguration: it is a Push-Out target, not a pull-in source, so it has no bulk export to implement, and it declares no connection-widget configuration fields.
SystemDriver |
Integration class | Notes |
|---|---|---|
WooCommerce |
App\Integrations\WooCommerce\WooCommerceIntegration |
performVerification() calls the conflux-connector WordPress plugin's connectivity-check endpoint. fetchBulkExportBatches() paginates GET /wp-json/wc/v3/customers via HTTP Basic Auth (credentials.consumer_key/consumer_secret); only the Customers capability is implemented. Implements HasDeletionLog and performFetchDeletionLog() — see WooCommerce Integration — Deletion Log. Implements CanPullConsent (the connector plugin's newsletter meta for one customer) but not CanHandleWebhooks. |
PrestaShop |
App\Integrations\PrestaShop\PrestaShopIntegration |
performVerification() calls the PrestaShop Webservice API (GET {base_url}/api/shops?output_format=JSON) with HTTP Basic Auth (credentials.api_key as username, empty password) and uses the connection's base URL as the external user identifier. fetchBulkExportBatches() dispatches on capability via the same Basic Auth scheme and PrestaShop's native limit=<offset>,<count> pagination syntax: Customers paginates GET {base_url}/api/customers, Products paginates GET {base_url}/api/products?display=full and resolves category names and a default currency via one-time lookups, and Orders paginates GET {base_url}/api/orders?display=full, resolves order state names/currencies/country names via one-time lookups, and loads only the addresses referenced by each fetched page. modifiedAfter delta filtering happens client-side for all three capabilities, since the API does not support filtering or sorting by date_upd on any of them. performVerification() and every export run also call resolveSourceTimezone(), which reads PS_TIMEZONE from GET {base_url}/api/configurations and remembers it in system_connections.source_timezone: PrestaShop sends date_add and date_upd without an offset, so that zone is what turns them into instants. All three capabilities are implemented. performProbeBulkExport()/performFetchBulkExportPageRange() are also overridden: since the Webservice API exposes no total-count header, the probe derives the page count from a single unpaginated id listing, enabling the same parallel FetchBulkPageRangeJob fan-out WooCommerceIntegration uses — see the PrestaShop Integration reference. Implements HasDeletionLog and performFetchDeletionLog(), calling the conflux module's front controller at GET {module_url}/deletion-log, with {module_url} read from credentials.module_url (CFX-210, moved off the Webservice API in CFX-230) — see PrestaShop Integration — Deletion Log. processWebhook() implements CanHandleWebhooks, handling an inbound consent-change webhook from the Positive Group PrestaShop module — the webhook only triggers a performPullConsent() call (CanPullConsent), which re-fetches and maps the customer's newsletter field the same way bulk export does. See the PrestaShop Integration reference. This is inbound-only: no push or propagation of consent changes exists yet. |
UserPlatform |
App\Integrations\UserPlatform\UserPlatformIntegration |
performVerification() calls GET https://<subdomain>.user.com/api/public/attributes/ — the workspace's own custom-attribute schema, never a contact list — authenticated via Authorization: Token <api_token> and an Accept header selecting API version 2. The host is built from the connection's subdomain credential alone; base_url is not read as the host, only checked against it. Uses the subdomain as the external user identifier. performPullConsent() reads GET users/{id}/ and processWebhook() takes an automation's {path, timestamp, extra} payload, both for contacts the delivery ledger holds only (OutboundDelivery::scopeCreatedAs()), mapping email_opt_in (and, on the webhook only, a set unsubscribed_from_sms as an SMS withdrawal) into the Positive User mirror. supportedConsentChannels() is [Email, Sms]. No bulk export or deletion log capability exists. See the User Platform Integration reference, ADR-0028 and ADR-0044. |
All three are registered in App\Providers\AppServiceProvider::boot(). RapidMail is an internal consuming product with no sync integration built yet, and has no IntegrationRegistry entry.
Exceptions¶
| Class | Extends | When thrown |
|---|---|---|
App\Exceptions\IntegrationRequestException |
RuntimeException |
Base class for all integration-layer errors; integrations throw this (or a subclass) for expected failure conditions |
App\Exceptions\InvalidIntegrationResponseException |
IntegrationRequestException |
Thrown by the backing trait (ExportsInBulk, HandlesWebhooks, PullsConsent, FetchesDeletionLog) when fetchBulkExportBatches(), provideTransformerClass(), performProbeBulkExport(), performFetchBulkExportPageRange(), processWebhook(), performPullConsent(), or performFetchDeletionLog() raises any Throwable that is not already an IntegrationRequestException |
App\Exceptions\ConsentTargetNotFoundException |
IntegrationRequestException |
Thrown by a performPullConsent() implementation when the target system has no contact matching the given customerSourceId; the caller should skip the sync and log a warning rather than retry. Carries App\Contracts\ProvesIntegrationReachable, so the circuit breaker treats it as a successful interaction rather than a failure — see ADR-0025 |
See also¶
- Webhook Receiver — inbound webhook endpoint that dispatches
ProcessWebhookJoband routes payloads tohandleWebhook() - Integration Contract design — reasoning behind the design decisions for both directions
- Deletion Log Sync — reasoning behind
fetchDeletionLog()and howFetchDeletionLogJobconsumes it