Projection Contract¶
Reference for stage 3 of outbound delivery: the contracts a projectable domain implements, the pairings that join a domain to its projector, and the envelope a projected record travels in.
For the steps to add a domain, see Implement a Projector. For where the stage sits and what the stages around it do, see Outbound Delivery.
Interface: Projectable¶
Namespace: App\Contracts\Projectable
Declares a model as a persisted row outbound delivery can project. RecordProjector::project() takes a Projectable&Model, so the interface is the precondition for reaching the stage at all.
Implemented by Customer, Order and Product, in every case through the trait below.
Trait: IsProjectable¶
Namespace: App\Models\Concerns\IsProjectable
Satisfies Projectable by reading four columns off the model it is used on: content_changed_at, updated_at, deleted_at and deletion_reason. It declares none of them, so a model missing one fails static analysis rather than a delivery. That holds only through _ide_helper_models.php, which is where PHPStan reads a model's columns from, so a new model has to be in that file (composer ide-helper) before the check means anything.
It also composes TracksChangeSignal, so a model taking this one concern satisfies both contracts.
The model has to cast deletion_reason to DeletionReason, and must not cast content_changed_at: the composed trait does that, and a cast declared in casts() is merged over the trait's and would leave the trait's dead.
changedAt falls back to updated_at wherever content_changed_at is empty. The delivery cursor is ordered by that value, so a consumer resuming from the payload has nothing to resume from if it is null.
Interface: HasChangeSignal¶
Namespace: App\Contracts\HasChangeSignal
public function scopeChangedAfter(Builder $query, CarbonInterface $at, ?string $id): Builder;
public function scopeOldestChangeFirst(Builder $query): Builder;
Declares a row as carrying content_changed_at and walkable forward by it. Separate from Projectable because the column is written on the way in and read on the way out, so it is the seam between the two directions rather than a delivery concern. Projecting a row reads the column, which is why every projectable model implements this too, but walking a table needs no projector and DeliveryScan types its query on this contract rather than on Projectable for that reason.
The two scopes are one contract over the tuple (content_changed_at, id) and have to agree about it. A walk whose predicate orders differently from its ordering either re-reads a batch for ever or steps over a row.
Trait: TracksChangeSignal¶
Namespace: App\Models\Concerns\TracksChangeSignal
Satisfies HasChangeSignal, and casts content_changed_at to datetime through a trait initializer rather than through the model's casts(), the way SoftDeletes casts deleted_at. Laravel resolves that initializer by the trait's basename, so renaming the trait without renaming initializeTracksChangeSignal leaves the cast silently unset.
Value object: ProjectionMetadata¶
Namespace: App\Data\ProjectionMetadata
A final readonly class rather than a Spatie\LaravelData\Data: it never crosses the wire. It exists so a row can hand RecordProjector the three values the envelope needs without a projector reading columns off a model it is not supposed to know.
| Property | Type | Description |
|---|---|---|
changedAt |
CarbonInterface |
content_changed_at, or updated_at where that is empty. Never null |
deletedAt |
?CarbonInterface |
The soft-delete timestamp; null for a live row |
deletionReason |
?DeletionReason |
Why the row went away; null for a live row, and null for a soft-deleted row whose cause was never recorded |
Interface: DomainRecord¶
Namespace: App\Contracts\DomainRecord
The canonical DTO of one mirrored domain — what a transformer normalises an external record into on the way in, and what a projector produces on the way out. It is the return type of project() and the type of Projection::$record.
Membership is exactly the four classes SystemCapability::dataClass() names, one per domain, ConsentData among them as the record of the dependent customer_consents domain, and nothing below them. A nested DTO does not belong here even when it carries the same fields: LineItemData is as canonical as OrderData, since it too is the normalised form of a vendor payload, so being canonical is not what separates them — being a domain is. There is no SystemCapability case for a line item, no table it is mirrored as and no projector that produces one.
The type cannot enforce that rule. PHP interfaces are nominal, so a class joins this one only by writing implements, and nothing stops a future DTO from being given that line because its fields happen to fit. SystemCapabilityTest pins the membership against cases() for that reason, in both directions: one of the four losing its implements fails, and a fifth class carrying it fails too.
It extends Spatie\LaravelData\Contracts\TransformableData, without which toArray() on an unnarrowed record is a type error. The declared members are the fields every domain shares — sourceId, sourceSystem, id, createdAt, updatedAt and customAttributes — minus tags, which the three root DTOs carry but annotate as array<string>, so the more precise list<string> cannot be declared here without failing covariance.
createdAt and updatedAt are declared ?CarbonImmutable and mean the source's own values. They are nullable because an unreadable source datetime is nulled rather than defaulted, which is a statement about the inbound path: a projection never emits either as null.
Interface: ProjectorContract¶
Namespace: App\Contracts\ProjectorContract
public function project(Model $row): DomainRecord;
/** @return list<string> */
public static function relations(): array;
project() turns one persisted row of the projector's own domain into its canonical DTO. The parameter is Model rather than the concrete model because PHP forbids narrowing a parameter in an implementation, so every projector refuses a row of another model itself, as its first statement, and throws rather than asserting:
RecordProjector is the one caller for which the pairing is correct by construction, because it resolves the projector from the domain the row's own class yields. Nothing in the contract extends that guarantee to another caller, which is what the guard is for.
relations() is the list of relations project() reads, for a caller holding many rows to eager-load before it projects them. It is an optimisation, not a precondition: a projector with relations calls loadMissing() for the single row it is given, so an un-eager-loaded row projects to exactly the same payload at the cost of one query per relation. A scan reaching for cursor(), which cannot eager-load, pays that cost per row.
Implementations¶
Namespace: App\Services\Projectors
| Domain | Projector | relations() |
Produces | Inverse of |
|---|---|---|---|---|
customers |
CustomerProjector |
['systemConnection'] |
CustomerData |
AbstractTransformer::storeCustomer() |
customer_consents |
ConsentProjector |
[] |
ConsentData |
ConsentRecorder::write() |
orders |
OrderProjector |
['lineItems'] |
OrderData |
AbstractTransformer::storeOrder() |
products |
ProductProjector |
[] |
ProductData |
AbstractTransformer::storeProduct() |
An order's line items are sorted by source_id in memory, so two projections of the same row produce the same payload, and soft-deleted line items are excluded because the relation is an ordinary Eloquent relation on a soft-deleting model. A customer projects no consents: they are a dependent domain with a projector of their own.
The customer_consents domain¶
ConsentProjector projects one CustomerConsent row, a mirror like Customer, into a ConsentData. The row carries everything the DTO needs, including its source_system and parent_source_id, so the projector reads no relation and joins nothing. createdAt and updatedAt are the row's own timestamps: no platform reports when a consent was created apart from its customer, so there is no source value to prefer. consentUpdatedAt falls back through CustomerConsent::consentUpdatedAtOrFallback() for rows written before the column existed. subscribed and confirmed project as stored, null included, so a consumer can tell an unknown value from a withdrawal. id stays null, for the reason given for the customer.
A consent's content_changed_at is its own change signal. It moves only when a value field changes or a trashed row is revived, not when consent_updated_at moves alone and not when customer_id is linked; see Transformer Contract. No direction-matrix entry exists for the domain yet, so no rule can deliver it, and nothing calls its projector in production.
Trait: ProjectsSharedColumns¶
Namespace: App\Services\Projectors\Concerns\ProjectsSharedColumns
The columns every domain shares, read back into the DTOs the transformer wrote them from.
| Method | Reads | Returns |
|---|---|---|
customAttributes(?array $externalData) |
external_data |
Collection<int, CustomAttributeData>, empty for a null column |
address(?array $column) |
address_billing, address_shipping |
?AddressData, null for a null column |
sourceTimestamp(?CarbonImmutable $sourceColumn, ?Carbon $rowColumn) |
source_created_at, source_updated_at |
?CarbonImmutable, falling back to the row's own timestamp where the source column is null |
sourceTimestamp() is where the two cast kinds meet, which is why the conversion lives here rather than three times over: the row timestamps are cast as mutable Carbon because they are Conflux's own bookkeeping, and the source columns as immutable_datetime because they fill a typed DTO field. Its fallback is what keeps the payload valid through the change. The source column is preferred wherever it has a value and the row's own timestamp stands in for the rest, so a consumer sees a value while a full sync fills the new columns. The return type stays nullable and means it: a row carrying neither projects to null, which a persisted row cannot do, Eloquent setting created_at and updated_at on insert. See ADR-0018.
Fields that are never projected¶
| Field | Held in | Why it stays out |
|---|---|---|
id |
customers.id, orders.id, products.id |
The Conflux UUID never reaches a consumer |
customerId |
orders.customer_id |
Same UUID rule. A writer addresses the customer by customerSourceId |
productId |
line_items.product_id |
Same UUID rule, even though the column is filled whenever a matching product row exists. A writer addresses the product by productSourceId |
Disconnect, uninstall and reinstall issue a fresh UUID for the same source record, so nothing keyed on one survives a rebuild. See Implement a Projector for the rule as a projector author meets it, and Outbound Delivery for the same reasoning applied to the ledger's key.
Nothing else is left out. createdAt and updatedAt used to be documented here as two more fields that do not survive a round trip, on the grounds that they are the Conflux row's own timestamps and mean "when Conflux first stored this record". Both now mean the source's own values, read from source_created_at and source_updated_at with a fallback to the row's own timestamps where those are null, which is every record written before CFX-241. ROUND_TRIP_DROPPED in tests/Integration/Services/ProjectionRoundTripTest.php is empty for that reason, and stays there as the assertion that it is. See ADR-0018 for why the canonical payload describes the record in the source rather than in Conflux.
Entry point: RecordProjector¶
Namespace: App\Services\RecordProjector
Resolves the domain from the row's class with SystemCapability::forModel(), resolves that domain's projector out of the container, and wraps the projected DTO in a Projection together with the row's own metadata.
It holds no inventory of projectable models, so a new domain needs no edit here. A soft-deleted row is projected as a tombstone: the same shape with a Tombstone attached and the last known state inside it.
Nothing calls this in production yet. Stage 4, which is what would call it, is not built.
Envelope: Projection¶
Namespace: App\Data\Projection
A Spatie\LaravelData\Data with #[MapName(SnakeCaseMapper::class)], so the wire keys are snake_case.
| Property | Wire key | Type | Description |
|---|---|---|---|
domain |
domain |
SystemCapability |
Which domain the record belongs to. Required, never null, and always names what record actually is |
record |
record |
DomainRecord |
The canonical DTO, hydrated through RecordForDomainCast |
contentChangedAt |
content_changed_at |
string |
ISO 8601. Never empty: the delivery cursor is ordered by (content_changed_at, id), and a consumer resuming from this payload has nothing to resume from if it is null |
tombstone |
tombstone |
?Tombstone |
The deletion facts, or null for a live record |
The metadata sits on this envelope rather than on the entity DTOs, so the inbound path never sees a field it has to ignore.
| Method | Returns |
|---|---|
isTombstone() |
true where tombstone is set, so a record that is gone is distinguishable from a live one without reading the payload |
Value object: Tombstone¶
Namespace: App\Data\Tombstone
| Property | Wire key | Type | Description |
|---|---|---|---|
deletedAt |
deleted_at |
string |
ISO 8601. Required: a tombstone that does not say when is not a tombstone |
reason |
reason |
?DeletionReason |
source_event or connection_lifecycle; null where no cause was recorded |
The two facts are one optional object rather than two nullable fields on the envelope, because only one of the four combinations they could form is meaningless: a cause without an instant says a record was removed for a reason but never removed. With deletedAt required and the object itself optional, that combination cannot be constructed on any path, so isTombstone() reads one field instead of picking one of two as the authoritative marker.
A tombstone with no cause is ordinary. Every row soft-deleted before CFX-212 carries reason as
null, and those rows are not backfilled, because the cause was never stored and any value written
now would be a guess. See ADR-0019.
The property is reason rather than deletionReason because inside a tombstone the noun is already given, and tombstone.deletion_reason says it twice.
RecordProjector is what refuses a row whose two columns disagree, since a row is the one place that pairing can still arise:
The domain guard¶
The constructor throws an InvalidArgumentException when record is not exactly the class SystemCapability::dataClass() names for domain:
The class is compared rather than tested with instanceof, so a subclass of one of the four DTOs is refused too. The check covers every way an instance comes about, from() included, and it throws rather than asserting, because assert() is compiled out in production and a mislabelled payload does its damage there.
RecordForDomainCast¶
Namespace: App\Data\Casts\RecordForDomainCast
Hydrates record into the DTO its sibling domain names. Without it, spatie has nothing to build the record from: DomainRecord is an interface and cannot be instantiated, and the payload alone does not say which of the four implementations it describes.
domain is read out of the incoming properties rather than off the half-built object, so both spellings are accepted: the enum when a caller passed one, the backed value when the payload came off the wire. Anything else throws:
The cast reads the incoming value and its sibling properties, and nothing else. DataProperty and CreationContext are in the signature because Spatie\LaravelData\Casts\Cast declares them and PHP forbids dropping a parameter, and leaving the context unread is what every cast spatie ships does. One consequence follows: creation options set on the envelope do not reach the record, so Projection::factory()->withoutPropertyNameMapping()->from() maps no key on the envelope and still maps every key inside record. Nothing in the application sets such an option.
Projection does not validate an inbound envelope
Do not type-hint Projection in a controller and expect spatie to validate the request. getValidationRules() emits record => ['required'] and nothing else, so any payload carrying the key passes, whatever shape it holds. Rule generation has no discriminator to dispatch on, and DomainRecord gives it no fields to walk either. Whoever needs a validated inbound envelope has to override it.
Domain pairings: SystemCapability¶
Namespace: App\Enums\SystemCapability
Cases: products, orders, customers, and the dependent customer_consents. This enum is the single declaration of what a domain is mirrored as, what its records look like, and what turns the first into the second. All three facts are direction-neutral: the inbound transformer writes the table, reconciliation reaps it, and the outbound projector reads it.
| Method | Returns | Description |
|---|---|---|
modelClass() |
class-string<Customer\|CustomerConsent\|Order\|Product> |
The Eloquent model whose table the domain is mirrored as |
dataClass() |
class-string<CustomerData\|ConsentData\|OrderData\|ProductData> |
The canonical DTO the domain's records take, in either direction |
projectorClass() |
class-string<ProjectorContract> |
The projector that turns a row of the domain into that DTO |
forModel(Model $row) |
self |
The domain a persisted row belongs to, derived by walking modelClass() |
forRecord(DomainRecord $record) |
self |
The domain a canonical DTO belongs to, compared by exact class |
parent() |
?self |
The root domain a dependent one belongs to (customers for customer_consents), null for a root |
isDependent() |
bool |
Whether the domain has a parent |
dependents() |
list<self> |
The domains that belong to this one |
roots() |
list<self> |
Customers, orders and products: what bulk sync imports and a deletion log reports |
rootsWithDependents() |
list<self> |
Roots and dependents, each dependent directly after its parent |
SystemCapability::cases() is forbidden in app/ and database/ by an arch test, so every caller chooses between roots() and rootsWithDependents(). See ADR-0041.
No match in this enum carries a default, so a case with a missing arm fails PHPStan level 10 in the file itself rather than falling through at run time.
forModel() compares the class exactly rather than with instanceof, and throws for a row no domain is mirrored as:
A LineItem is the example that matters: it is a persisted row of a real model that travels inside an order's payload rather than as a record of its own.
See Also¶
- Implement a Projector: adding a projectable domain end to end
- Transformer Contract: the inbound inverse and the columns a projector reads back
- Outbound Delivery State: the cursor and the ledger stage 4 will keep
- Outbound Delivery: the five stages and the reasoning behind them
- ADR-0041: why consent is a dependent domain with a projector of its own
- ADR-0018: why
createdAtandupdatedAtmean the source's values, and the rule every datetime follows