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 aspublic array $foo = []. Do not use#[DataCollectionOf]— that attribute only accepts classes that implementBaseDataand 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 typedSomeDatainstances.
// ✅ 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:
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.