Implement a Projector¶
Use this guide when a new data domain has to be able to leave Conflux. A projector turns one persisted row into its canonical DTO, the inverse of what a transformer does on the way in, and it is stage 3 of outbound delivery. Customers, orders and products each have one already. What follows is what a fourth domain has to add.
The examples use a Cart domain mirrored from a WooCommerce shop. Substitute your own.
Prerequisites¶
- The domain has a canonical DTO in
App\Datathat implementsApp\Contracts\DomainRecord(see Data Layer and the note in step 3) - A transformer writes rows of that domain (see Implement a Transformer)
- The domain's table carries
content_changed_at,updated_at,deleted_atanddeletion_reason - The domain's table carries
source_created_atandsource_updated_at, the source's own instants the projector reads back (see ADR-0018)
1. Mark the model projectable¶
Implement App\Contracts\Projectable and App\Contracts\HasChangeSignal, and use App\Models\Concerns\IsProjectable.
use App\Contracts\HasChangeSignal;
use App\Contracts\Projectable;
use App\Enums\DeletionReason;
use App\Models\Concerns\IsProjectable;
class Cart extends Model implements HasChangeSignal, Projectable
{
use HasFactory, HasUuids, IsProjectable, SoftDeletes;
#[\Override]
protected function casts(): array
{
return [
// ... the domain's own casts
'deletion_reason' => DeletionReason::class,
];
}
}
The one trait covers both interfaces, because IsProjectable composes TracksChangeSignal. It gives the model projectionMetadata(), which reads content_changed_at, updated_at, deleted_at and deletion_reason off the model's own columns and hands them over as a ProjectionMetadata, and the two scopes a consumer walks the change stream with. It declares none of those columns itself, so using it on a model missing one fails the type checker rather than a delivery.
Do not cast content_changed_at in the model. TracksChangeSignal casts it, and casts() is merged over the property the trait writes rather than before it, so a line here would win and the trait's would be dead on this model. deletion_reason stays the model's own, and is what makes that value arrive as a DeletionReason rather than a string.
The second interface is not type-checked for you
SyncRule::mirroredRecords() resolves the model class from the domain enum at runtime and annotates the result as Builder<Model&HasChangeSignal>, so PHPStan cannot see whether a given domain actually declares that interface. A model that takes the trait but forgets the implements has the methods and satisfies nothing, and nothing will say so.
Regenerate the IDE helper first
PHPStan reads a model's columns from _ide_helper_models.php, so the check above means nothing for a new model until that file knows about it:
Nothing else marks a row deliverable. RecordProjector::project() takes a Projectable&Model, so a model without the interface is refused at the call site.
2. Write the projector¶
Create the class in app/Services/Projectors/ and implement App\Contracts\ProjectorContract.
<?php
declare(strict_types=1);
namespace App\Services\Projectors;
use App\Contracts\ProjectorContract;
use App\Data\CartData;
use App\Data\CartItemData;
use App\Models\Cart;
use App\Models\CartItem;
use App\Services\Projectors\Concerns\ProjectsSharedColumns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use InvalidArgumentException;
class CartProjector implements ProjectorContract
{
use ProjectsSharedColumns;
/**
* The relations `project()` reads, declared next to the code that reads them.
*
* @return list<string>
*/
public static function relations(): array
{
return ['items'];
}
/**
* Turns a persisted cart into its canonical DTO, the inverse of
* `AbstractTransformer::storeCart()`.
*/
public function project(Model $row): CartData
{
if (! $row instanceof Cart) {
throw new InvalidArgumentException(sprintf(
'%s projects a %s, got a %s.',
self::class,
Cart::class,
$row::class,
));
}
$row->loadMissing(static::relations());
/** @var array<int, array{key: string, value: string|int|bool|null}>|null $externalData */
$externalData = $row->external_data;
return new CartData(
sourceId: $row->source_id,
sourceSystem: $row->source_system,
currency: $row->currency,
createdAt: $this->sourceTimestamp($row->source_created_at, $row->created_at),
updatedAt: $this->sourceTimestamp($row->source_updated_at, $row->updated_at),
customAttributes: $this->customAttributes($externalData),
items: $this->items($row),
);
}
/**
* The cart's live items, ordered by source id so two projections of the same cart produce the
* same payload.
*
* @return Collection<int, CartItemData>
*/
private function items(Cart $cart): Collection
{
return $cart->items
->sortBy(fn (CartItem $item): string => $item->source_id)
->values()
->map(fn (CartItem $item): CartItemData => new CartItemData(
sourceId: $item->source_id,
productSourceId: $item->product_source_id,
quantity: $item->quantity,
));
}
}
Six things in that class are not free choices.
project() takes Model, not Cart, and refuses a foreign row as its first statement. PHP forbids narrowing a parameter in an implementation, so the contract names the widest type, and widening it is exactly what puts the method within reach of a caller handing over a row of any model at all. The guard throws rather than asserting, because assert() is compiled out under zend.assertions=-1, which is production. RecordProjector is the one caller for which the pairing is correct by construction, since it resolves the projector from the domain the row's own class yields; nothing in the contract extends that guarantee to a second caller.
relations() is the list project() reads, and loadMissing() takes that same list, read through static:: so a subclass overriding it is followed. Neither can name a relation the other does not load. Answer the method even when the domain has no child rows, the way ProductProjector returns []: every domain answering it is what lets a batching caller ask them all without a fallback. Such a caller reaches the list through SystemCapability::projectorClass(). It is an optimisation and not a precondition, because loadMissing() still covers the single row, and an un-eager-loaded row projects to exactly the same payload at the cost of one query per relation.
ProjectsSharedColumns reconstructs the columns every domain shares. customAttributes() rebuilds the collection from external_data, address() rebuilds an AddressData from an address column, and sourceTimestamp() reads a source instant with a fallback to the row's own timestamp. Use them rather than reimplementing the shape the transformer wrote.
createdAt and updatedAt come from the source columns, never from the row's own. They mean when the record was created and last changed in the source, so a projector reads source_created_at and source_updated_at through sourceTimestamp(), which falls back to created_at and updated_at only where the source column is null. Reading the row's own timestamps directly would put Conflux's bookkeeping in the payload under the source's name, which is the defect ADR-0018 records. The DTO fields are ?CarbonImmutable, so pass the CarbonImmutable through rather than formatting it: the wire format is spatie's business.
Child collections are sorted in memory, never in SQL. Two projections of the same row have to produce the same payload, and a caller that eager-loaded the relation must not be made to query again by a sort pushed into the database.
The JSON columns are annotated at the point of use. Eloquent's array cast types every one of them as array<array-key, mixed>|null, while the column actually holds the narrower shape the transformer wrote. Annotate locally rather than widening the shared trait's signatures.
3. Declare the domain's three pairings¶
App\Enums\SystemCapability is the single place a domain's table, DTO and projector are declared. Add the case, then one arm in each of the three methods.
case Carts = 'carts';
public function modelClass(): string
{
return match ($this) {
// ...
self::Carts => Cart::class,
};
}
public function dataClass(): string
{
return match ($this) {
// ...
self::Carts => CartData::class,
};
}
public function projectorClass(): string
{
return match ($this) {
// ...
self::Carts => CartProjector::class,
};
}
None of the three match expressions carries a default, so a new case with a missing arm fails PHPStan level 10 in this file rather than falling through to a silent skip at run time. forModel() needs no arm at all: it walks modelClass() to find the domain a row belongs to, so the forward and the reverse reading of the pairing cannot drift apart.
That is the whole wiring. RecordProjector holds no inventory of projectable models. The row names its domain through its class, the domain names its projector, and the row carries its own metadata, so a fourth domain reaches outbound delivery without the dispatcher being edited.
The DTO the new arm names has to implement App\Contracts\DomainRecord, which is what project() returns and what Projection::$record holds:
The interface declares the six fields every domain shares — sourceId, sourceSystem, id, createdAt, updatedAt and customAttributes — so a DTO missing one fails static analysis here rather than at the first delivery. Nothing else in App\Data may carry it: membership is exactly the classes dataClass() names, and a nested DTO such as LineItemData stays out even though it is just as canonical, because it travels inside another domain's payload rather than as a record of its own. SystemCapabilityTest fails if that set and the dataClass() arms disagree in either direction.
A case on this enum is more than a projection change
SystemCapability is also the domain a system declares in its direction matrix, the domain a SyncRule carries, and the set of options the admin panel offers. Adding a case widens all of those at once.
4. What a projector must not do¶
No target format. The payload shape belongs to the DTO layer. A field only one target wants is either defined in the DTO or not sent at all, and mapping a payload into one target's request shape happens in stage 5.
No serialisation. Return the DTO. Projection is what serialises, and the snake_case wire keys come from the DTO layer's MapName conventions.
No internal Conflux identifier. id stays null, and no column holding another Conflux UUID is read either: orders.customer_id and line_items.product_id are populated and still never projected. Disconnect, uninstall and reinstall issue a fresh UUID for the same source record, so a consumer keying on one points at nothing after any rebuild. A writer addresses a record by source identity instead, through a delivery ledger keyed the same way.
No decision about deletions. A soft-deleted row projects as a tombstone: the same payload, carrying the last known state, with a Tombstone built from the row's own deleted_at and deletion_reason columns. Whether that removal reaches a target is the rule's delivery policy, not the projector's business.
5. Prove it with tests¶
A new domain has a place in each of these. The first is the only file to create; the rest take a data set entry.
| File | What it has to show |
|---|---|
tests/Integration/Services/Projectors/CartProjectorTest.php |
Every persisted field is projected; empty JSON columns become empty collections rather than null; id is null; child rows come out ordered; a soft-deleted child is excluded |
tests/Integration/Models/Concerns/IsProjectableTest.php |
The trait reads the change signal off the column, falls back to updated_at where it is empty, reports no deletion for a live row, and carries the instant and the cause for a tombstone |
tests/Unit/Services/Projectors/RowRefusalTest.php |
The projector refuses a row of another domain, with the message it throws |
tests/Unit/Services/Projectors/RelationsTest.php |
The relation names the domain's projector declares |
tests/Unit/Enums/SystemCapabilityTest.php |
The domain is in the modelClass(), dataClass() and projectorClass() data sets |
tests/Integration/Services/RecordProjectorTest.php |
A row of the new domain reaches the right payload, asserting the record class and the envelope's domain together |
tests/Integration/Services/ProjectionRoundTripTest.php |
A DTO transformed in and projected back out compares equal, minus the fields the inbound path does not persist |
Two tests need no work at all. RecordProjectorTest's zero-query batch test walks SystemCapability::cases(), so a new domain joins it as soon as the enum declares one, and a relation left out of relations() makes it fail. SystemCapabilityTest's pairing test closes over cases() the same way, and it is the only thing holding the claim that the projector a domain names produces the DTO that same domain names.
A third test in that file needs no work either, but it will fail until the new DTO carries DomainRecord: it reads every class in App\Data off the filesystem and asserts that the ones implementing the interface are exactly the ones dataClass() names. Treat a failure there as the reminder that the implements line is missing, not as a test to adjust.
Between them, RelationsTest and that batch test are two halves of one fact: the first pins the names a projector declares, the second proves those names are the relations it actually loads.
Make each test fail without the behaviour it names¶
A new test in this codebase has to be shown failing once the behaviour it is named after is removed. Three habits in the existing projector tests exist for exactly that reason, and a new domain repeats them.
Set child relations by hand rather than relying on an eager load.
// Loaded in insertion order (item-b, item-a) rather than through the projector's own query, so
// only the projector's sortBy() can produce the asserted order.
$cart->setRelation('items', collect([$itemB, $itemA]));
An eager load happens to come back in source_id order already, so a test relying on it would still pass with sortBy() deleted, and it would be pinning the database's incidental ordering rather than the projector's.
Populate the column you assert is not projected.
With the column left empty, both sides are null and a projector that read it would pass anyway. Asserting that the column is filled is what stops the claim rotting the day an inbound path starts filling it.
Count queries with beforeExecuting() rather than with the query log.
$queries = [];
DB::connection()->beforeExecuting(function (string $query) use (&$queries): void {
$queries[] = $query;
});
The hook fires as a statement is about to run, so it needs no enableQueryLog() beforehand and it still counts a query the log would only record once it had succeeded.
Run them:
See Also¶
- Projection Contract reference: the contract signatures, the envelope's fields and the domain pairings
- Outbound Delivery: where the projection stage sits and why it keeps no catalogue
- Implement a Transformer: the inbound inverse, which writes the rows a projector reads
- Data Layer: the DTO conventions a canonical payload follows