Skip to content

Diffs and timelines

Two read-side helpers turn a raw query into something you can reason about as a whole:

  • Timeline materialisers (asTimeline(), fullHistory()) collapse a query into an ordered, non-overlapping value object.
  • Diff helpers (diffTimelines(), diffKnowledge()) answer "what changed between two points on the recorded axis?" — what we learned between two dates.

Both are available on any temporal query — through a relation ($product->prices()) or on the model directly.

Materialising a timeline

asTimeline() runs the query and returns a Timeline — an ordered collection of TimelineSegment objects sorted by valid_from. It is a terminal call (like get()), but it gives you a structured view rather than a flat row collection:

$timeline = $product->prices()->currentKnowledge()->asTimeline();

$timeline->count();                 // number of segments
$timeline->head()?->attributes;     // ['amount' => 1000, …] of the first segment
$timeline->tail();                  // last segment
$timeline->at($instant);            // the segment covering an instant, or null

Each TimelineSegment exposes its validSpell, recordedSpell, the domain attributes (temporal columns stripped out), and an isRetraction flag. Scope the query first — currentKnowledge() for the believed-now timeline, or knownAt($date) for a past belief — then materialise.

fullHistory() is the chainable counterpart for when you want every physical row, including superseded beliefs, in canonical order (valid_from then recorded_from):

$rows = $product->prices()->fullHistory()->get();   // ordinary Eloquent collection, ordered

Use asTimeline() when you want a non-overlapping value object to walk; use fullHistory() when you want the raw rows including history.

Diffing what changed

A bitemporal store records not just values but when you came to believe them. The diff helpers compare two points on the recorded axis and report what moved.

diffTimelines($fromKnownAt, $toKnownAt) compares the whole believed timeline at two recorded dates — "between February and March, how did our picture of this entity's history change?":

$diff = $product->prices()->diffTimelines(
    fromKnownAt: '2026-02-20',     // what we believed on Feb 20
    toKnownAt: '2026-03-10',       // what we believe on Mar 10
);

diffKnowledge($validAt, $fromKnownAt, $toKnownAt) narrows the comparison to a single valid instant — "for the price effective on June 1, did our belief change between those two dates?":

$diff = $product->prices()->diffKnowledge(
    validAt: '2026-06-01',
    fromKnownAt: '2026-02-20',
    toKnownAt: '2026-03-10',
);
public function diffTimelines(
    CarbonInterface|string $fromKnownAt,
    CarbonInterface|string $toKnownAt,
): TemporalDiff

public function diffKnowledge(
    CarbonInterface|string $validAt,
    CarbonInterface|string $fromKnownAt,
    CarbonInterface|string $toKnownAt,
): TemporalDiff

Reading the result

Both return a TemporalDiff:

$diff->added;       // Collection<Model> — segments present only at `toKnownAt`
$diff->removed;     // Collection<Model> — segments present only at `fromKnownAt`
$diff->changed;     // Collection<TemporalDiffPair> — same span, different value
$diff->retracted;   // Collection<TemporalRetraction> — a span withdrawn between the two dates
$diff->unchanged;   // Collection<Model> — identical in both
$diff->isEmpty();   // true when nothing was added, removed, changed, or retracted

A TemporalDiffPair describes one segment that changed value across the two dates:

$pair = $diff->changed->first();

$pair->from;                // the earlier-belief Model
$pair->to;                  // the later-belief Model
$pair->changedAttributes;   // ['amount'] — the attribute names that differ

So a correction that raised a price from 1000 to 1200 surfaces as one changed pair whose changedAttributes contains amount, with from->amount === 1000 and to->amount === 1200.

A span that became a retraction (an anti-row) between the two dates is not a value change — surfacing it as changed would let a consumer misread the withdrawal (amount → null, is_retraction → true) as a real price change to NULL. It lands in its own retracted bucket instead:

$retraction = $diff->retracted->first();

$retraction->to;    // the anti-row believed at `toKnownAt` (is_retraction === true)
$retraction->from;  // the value row believed at `fromKnownAt`, or null when the
                    // span was both created and retracted after `fromKnownAt`

Both sides are preserved, so added ∪ changed.to ∪ retracted.to (plus unchanged) reconstructs the belief at toKnownAt, and removed ∪ changed.from ∪ retracted.from (plus unchanged) reconstructs the belief at fromKnownAt.

From the command line

The same comparison is available without writing code, for spot-checks against a running database — see bitemporal:diff-timelines:

php artisan bitemporal:diff-timelines \
    --model="App\Models\ProductPrice" \
    --entity-id=123 \
    --from-known-at="2026-02-20" \
    --to-known-at="2026-03-10"

Next: Boot guards and lints.