Worked example: insurance policies & claims¶
Insurance is the textbook case for two time axes, because the question a claims team has to answer is never simply "what is the cover?" — it is "what cover was in force on the day of the loss, and what did we believe it was at the time we paid the claim?" A back-dated endorsement, a mistaken data entry, or a policy voided for fraud all change the answer to one of those questions without changing the other. This page models a policy's coverage as a bitemporal timeline and walks the API through the events of a claim.
The features it leans on: knownAt() and recorded time, retroactive correct(), retract() anti-rows, and diffKnowledge().
The models¶
A Policy is the entity; its PolicyCoverage is the versioned fact — the limit, deductible, and premium that apply over a stretch of valid time. Because we care what we believed and when, the coverage model tracks recorded time (the default), so nothing about a past belief is ever overwritten.
use Illuminate\Database\Eloquent\Model;
use Vusys\Bitemporal\Bitemporal;
class PolicyCoverage extends Model
{
use Bitemporal;
protected string $temporalEntity = Policy::class;
protected $guarded = [];
}
use Illuminate\Database\Eloquent\Model;
use Vusys\Bitemporal\Concerns\HasBitemporalRelations;
use Vusys\Bitemporal\Relations\BitemporalMany;
class Policy extends Model
{
use HasBitemporalRelations;
public function coverages(): BitemporalMany
{
return $this->bitemporalMany(PolicyCoverage::class);
}
}
The migration uses the Blueprint macros from Defining models:
Schema::create('policy_coverages', function (Blueprint $table) {
$table->id();
$table->bitemporalForeignFor(Policy::class); // policy_id, FK, restrictOnDelete
// Nullable so retract() can insert an anti-row (all value columns NULL).
$table->decimal('limit', 12, 2)->nullable();
$table->decimal('deductible', 10, 2)->nullable();
$table->decimal('premium', 10, 2)->nullable();
$table->bitemporalPeriods(); // valid_* + recorded_* (µs) + is_retraction
$table->timestamps();
$table->preventBitemporalOverlaps(['policy_id']);
});
Binding cover¶
The policy incepts on 1 January with a £250,000 limit. This is a genuine, forward-looking fact about the world, so it is a changeEffectiveFrom — it opens the timeline at the inception date and leaves it open-ended.
$policy->coverages()->changeEffectiveFrom(
attributes: ['limit' => 250_000, 'deductible' => 500, 'premium' => 1_200],
validFrom: '2026-01-01',
);
Paying a claim on today's knowledge¶
A fire on 14 March triggers a claim. On 20 March the adjuster settles it against the cover that was in force on the loss date, as the system understands the policy that day. Both axes matter: validAt() picks the loss date, knownAt() pins the belief to the settlement date.
$coverAtLoss = $policy->coverages()
->validAt('2026-03-14') // cover effective on the day of the fire
->knownAt('2026-03-20') // as we believed it when the claim was paid
->sole();
$payout = min($claimAmount, $coverAtLoss->limit) - $coverAtLoss->deductible;
Recording which belief the payout was based on is the whole point. When the file is reopened months later, the settlement can be reproduced exactly — even if the policy record has since changed underneath it.
A retroactive endorsement¶
In April the broker confirms the customer had bought an endorsement raising the limit to £500,000, effective all the way back to inception. This is not a change from today — it is a fix to what was always true. correct() rewrites the value over a historical window while preserving the superseded row in recorded history.
A write replaces the whole value tuple, so we repeat deductible and premium — omitting them would blank those columns (see Writing):
$policy->coverages()->correct(
attributes: ['limit' => 500_000, 'deductible' => 500, 'premium' => 1_200],
validFrom: '2026-01-01', // the higher limit applied from inception
);
Note
The £250,000 row is not deleted. It stays in the timeline with a closed recorded spell — it is what the system believed between January and April. The claim paid in March is still fully explainable against the knowledge held that day; only the current belief now shows £500,000 from inception.
Voiding cover for fraud¶
If underwriting later discovers the application was fraudulent, the policy is void ab initio — treated as never having been in force. That is not a correction of the value; it is an assertion that the period never happened. retract() inserts an anti-row over the window.
// Cover is treated as never valid from inception onward.
$policy->coverages()->retract(validFrom: '2026-01-01');
A retraction with no validTo retracts open-endedly from the given date. Reads that excludeRetractions() will now find no cover in force — while the recorded history still shows every belief the system ever held, including the one the March claim was paid against.
Auditing what changed¶
A regulator asks: for the loss on 14 March, how did our understanding of the cover change between the day we paid and today? diffKnowledge() compares the belief about a single valid instant across two points on the recorded axis.
$diff = $policy->coverages()->diffKnowledge(
validAt: '2026-03-14', // the cover effective on the loss date
fromKnownAt: '2026-03-20', // what we believed when we paid
toKnownAt: '2026-05-01', // what we believe now
);
foreach ($diff->changed as $pair) {
// $pair->from->limit === 250000, $pair->to->limit === 500000
$pair->changedAttributes; // ['limit']
}
The diff makes the story auditable: on the settlement date the limit was believed to be £250,000; today, after the endorsement, we understand it was £500,000 — and the record of both beliefs survives.
What to take away¶
Recorded time is what lets you defend a past decision. The claim was paid correctly on the knowledge available; a later endorsement corrected the value without falsifying that record; a retraction removed the cover from the current picture without erasing the history. None of the three operations overwrote what came before.