Skip to content

Data Layer

Accurate reference for Conflux's spatie/laravel-data conventions.

DTOs live in App\Data\. Every DTO extends Spatie\LaravelData\Data and uses #[MapName(SnakeCaseMapper::class)] so properties are camelCase in PHP and snake_case when serialised/deserialised.

Constructor Ordering

Required fields first, optional (nullable or defaulted) fields after. This makes ::from([]) calls readable and avoids PHP named-argument noise.

public function __construct(
    public string $sourceId,          // required
    public string $email,             // required
    public array $tags = [],          // required but defaults to empty
    public ?string $firstName = null, // optional
    public ?int $orderCount = null,   // optional
) {}

Scalar Arrays vs. Data Collections

  • Plain string[] / array<string, string>: declare as public array $foo = []. Do not use #[DataCollectionOf] — that attribute only accepts classes that implement BaseData and will throw at runtime for scalar types.
  • Arrays of Data objects: use #[DataCollectionOf(SomeData::class)]. Spatie will automatically cast plain arrays of associative arrays into typed SomeData instances.
// ✅ scalar array — no attribute needed
public array $tags = [],

// ✅ nested Data objects
#[DataCollectionOf(LineItemData::class)]
public array $lineItems = [],

Monetary Values

All amounts are integers in minor currency units (cents). Never store or pass floats. Convert at the system boundary:

// incoming float from API → int
(int) round((float) $apiValue * 100)

Fields that follow this rule: price, originalPrice, unitPrice, total, totalTax, totalShipping, totalRevenue.

Datetimes

A datetime on a DTO is typed by what kind of value it is, never left as the string the platform sent.

What the value is How it is declared Examples
An instant, a point in time ?CarbonImmutable createdAt, updatedAt, datePaid, subscribedAt, confirmedAt, unsubscribedAt
A calendar date, the same day in every zone ?CarbonImmutable with #[WithCast(DateTimeInterfaceCast::class, format: '!Y-m-d')] and #[WithTransformer(DateTimeInterfaceTransformer::class, format: 'Y-m-d')] dateOfBirth
A datetime the platform sent that Conflux does not map string, inside customAttributes any shop-specific date field
public ?CarbonImmutable $createdAt,

#[WithCast(DateTimeInterfaceCast::class, format: '!Y-m-d')]
#[WithTransformer(DateTimeInterfaceTransformer::class, format: 'Y-m-d')]
public ?CarbonImmutable $dateOfBirth = null,

The ! in the cast format is required. Without it createFromFormat() fills the unparsed time fields from the current wall clock, so every date would carry the time of the sync.

createdAt and updatedAt mean the source's values on the way out, not Conflux's. The projectors read them from the source_created_at and source_updated_at columns, falling back to the row's own timestamps where those are null. Conflux's own view of change is a separate signal and lives on the delivery envelope as Projection::$contentChangedAt, so the inbound path never sees a field it has to ignore. Both DTO fields are nullable because an unreadable source datetime is nulled rather than defaulted; a projection still never emits either as null.

The wire format is DATE_ATOM, so an instant serialises as 2024-03-01T09:05:00+00:00. That comes from spatie's own default rather than from a project setting: there is no config/data.php, so date_format => DATE_ATOM from the package config applies. A calendar date serialises through its own transformer as a bare 1985-04-12, with no zone for a consumer to convert against.

Full reasoning, including the ban on Carbon::parse() for a source datetime string, is in ADR-0018. For an integration author's checklist, see Implement a System Integration.

Entity DTO Naming Conventions

All entity DTOs (CustomerData, ProductData, OrderData and the dependent-domain ConsentData) follow this shared field pattern:

Field Type Notes
sourceId string Platform-native ID, cast to string. Used as dedup key.
sourceSystem SystemDriver Always hardcoded per integration.
id ?string Conflux-generated UUID. null until the record is persisted, and null again on the way out: a projector never puts it in a payload.
customAttributes Collection<int, CustomAttributeData> Key/value pairs for platform-specific metadata, stored in the external_data column.
createdAt / updatedAt ?CarbonImmutable The source's own timestamps, parsed at the integration boundary. See Datetimes below.

That pattern is not just a convention any more. All four implement App\Contracts\DomainRecord, which declares those six fields, so a DTO missing one fails static analysis. The interface is what ProjectorContract::project() returns and what App\Data\Projection holds as its record, and its membership is exactly the four classes SystemCapability::dataClass() names. A nested DTO stays out even when its fields would fit: LineItemData is as canonical as OrderData, but it travels inside an order's payload rather than as a record of its own. tags is the one shared field the interface does not declare, because the three root DTOs annotate it as array<string> and ConsentData has none and an interface may not narrow that to list<string>. See Projection Contract for the rest.

CustomerData carries no consents. Consent is a dependent domain of its own (see ADR-0041), so a mapper emits [CustomerData, ConsentData…] and the customer payload to a target contains none.

ConsentData

ConsentData is the entity DTO of the customer_consents domain and implements DomainRecord. It shares sourceId, sourceSystem, id, customAttributes (always empty), createdAt and updatedAt with the three root DTOs, and adds the fields that make it a dependent record:

Field Type Notes
sourceId string Composed as {parentSourceId}:{channel}, for example 4711:email. Build it with ConsentData::sourceIdFor()
sourceSystem SystemDriver Hardcoded per integration, like every entity DTO
parentSourceId string The parent customer's (or contact's) source id within the same connection. It names the parent without a foreign key, because a view's parent may have no row in Conflux
channel ConsentChannel email, sms or general
consentUpdatedAt CarbonImmutable Required. See The One Required Datetime
subscribed ?bool null means the source cannot know
confirmed ?bool null means the source cannot know, which is every shop: their newsletter flag is a single opt-in
subscribedAt, confirmedAt, unsubscribedAt ?CarbonImmutable UTC instants
source, ipAddress ?ConsentSource, ?string How and from where consent was given

null never overwrites a known value, in any value field, and never counts as a difference. The one exception is a withdrawal: subscribed: false clears confirmed and confirmed_at, because they describe an opt-in that no longer exists, and a later re-subscription the source cannot confirm must not inherit them. A writer handed subscribed: null sends nothing; it never sends a withdrawal. Inbound, createdAt and updatedAt are null, because no platform reports when a consent was created apart from its customer. ConsentProjector fills them from the row's own timestamps.

It also carries two provenance fields the inbound path leaves null and CustomerProjector fills from the connection the row hangs on:

Field Type Notes
sourceConnectionName ?string Which connection the customer was read from, by name. sourceSystem is the same thing one level coarser and two connected WooCommerce shops answer it identically — a target that creates one record per source record needs to tell them apart. The connection's name and not its id: an id is reissued when a connection is set up again, and it means nothing to a person reading it in the target.
sourceLocale ?string The source shop's locale, from system_connections.source_locale, which each integration establishes during connection verification. Its one consumer today is outbound phone-number normalisation, which needs a region for a number in national notation and cannot ask the source shop for one.

The asymmetry is deliberate: they are read off the connection rather than stored per record, so a projection legitimately returns values the inbound DTO never carried. ProjectionRoundTripTest excludes them for that reason, and names it.

ProductData identifiers and provenance

Beside sku, a product carries its barcode and, like a customer, the connection it was read from:

Field Type Notes
ean ?string The product's EAN or GTIN, stored in the nullable products.ean column. PrestaShop maps ean13, WooCommerce maps global_unique_id, which also stays a custom attribute. Rows synced before the column existed carry null until the next full sync
sourceConnectionName ?string The connection's name, or its base_url host where it has none. Inbound it is null; ProductProjector fills it from the connection the row hangs on, through connectionName() in the ProjectsSharedColumns trait that CustomerProjector shares

ProductProjector loads the systemConnection relation for it. Positive User reads ean as the fallback for a product's custom_id and sourceConnectionName for its conflux_source attribute, see User Platform Outbound Writer.

The One Required Datetime

Every source datetime is a typed instant parsed through SourceDateTimes — see Datetimes — and every one of them is nullable, because an unreadable value is nulled rather than defaulted. ConsentData::$consentUpdatedAt is the single exception: it is required.

It is required because it is the only source datetime Conflux reads rather than carries. When two consent mirrors disagree about the same contact, the later consentUpdatedAt settles which state wins. That conflict rule belongs to the delivery work and is not built yet, but the field has to be there for it: a null would not make a consent state less precise, it would leave the conflict unplaceable, and the only remaining options would be to guess or to invent a timestamp.

It also orders writes again, but only within one mirror. Consent used to be decided by last write wins across several writers (ADR-0024); a consent mirror has exactly one writer, its own connection, so there is no ordering across writers. One source still delivers out of order, though: a bulk import page can be processed after a newer webhook, and a retried webhook after the one that followed it. So ConsentRecorder refuses a write whose consentUpdatedAt lies strictly before the one the mirror holds, and applies one at an equal time.

Being required does not make it parse differently. A mapper reads it through the same RecordDateTimes as every other field, against the one format that integration declares, and SourceDateTimes still returns null for a value it cannot read — recording the failure for the aggregated warning. What the mapper does with that null is where the difference sits: it omits the whole ConsentData, the same way it omits a channel the platform reports no signal for. The customer record still lands, minus that channel.

That is the shape to copy for a new field code branches on: make it required, parse it through SourceDateTimes like everything else, and drop the value it belongs to where the parse fails. Never a private format list on the DTO — ADR-0018 rules out an ordered fallback chain, because the order would silently decide the meaning of an ambiguous value. The consent model is recorded in ADR-0041, which supersedes ADR-0024.

The Delivery Envelope

Outbound delivery does not hand these DTOs on by themselves. App\Data\Projection wraps one of them together with the facts a payload cannot carry about itself: the SystemCapability its record belongs to, the change timestamp the delivery cursor is ordered by, and the deletion marker that makes a record a tombstone. The metadata sits on the envelope rather than on the entity DTOs, so the inbound path never sees a field it has to ignore.

Projection is the one DTO here with a union-typed property. Its domain discriminator is what RecordForDomainCast hydrates record through, and the constructor refuses a record the domain does not name. App\Data\ProjectionMetadata is deliberately not a Data at all: it carries three values from a row to the envelope and never crosses the wire.

For the field-by-field specification, see the Projection Contract.

PHPStan: Null-Narrowing in Pest Tests

PHPStan level 10 does not narrow nullable DTO properties through Pest's ->toBeInstanceOf() chain. After asserting a nullable property is an instance of a class, use assert() to narrow the type before accessing sub-properties:

// ❌ PHPStan error: $customer->addressBilling is AddressData|null
expect($customer->addressBilling)->toBeInstanceOf(AddressData::class)
    ->and($customer->addressBilling->postalCode)->toBe('10115');

// ✅ narrow first
expect($customer->addressBilling)->toBeInstanceOf(AddressData::class);
$billing = $customer->addressBilling;
assert($billing instanceof AddressData);
expect($billing->postalCode)->toBe('10115');

For arrays annotated with #[DataCollectionOf(Foo::class)], PHPStan already knows the element type — a redundant assert($item instanceof Foo) will trigger instanceof.alwaysTrue. Skip the assert and access properties directly.