Worked example: tax & regulatory rates¶
Tax is the domain where the past genuinely does change. A rate set in the budget can be amended by legislation passed months later and back-dated, and yet every return already filed under the old rate must remain reproducible — because that is what the taxpayer actually filed, and what an auditor will check against. You cannot overwrite the rate and you cannot forget the amendment; you need both, on different axes. This page models a jurisdiction's tax rates as a bitemporal timeline.
The features it leans on: retroactive correct(), a dimension for rate category, supersedeTimeline() for a full restatement from an authority, and backfill()->importHistoricalKnowledge() for seeding past beliefs, not just past values.
The models¶
A TaxJurisdiction is the entity; TaxRate is the versioned fact. A jurisdiction has several categories of rate (standard, reduced, zero) that move independently, so category is a dimension.
class TaxRate extends Model
{
use Bitemporal;
protected string $temporalEntity = TaxJurisdiction::class;
protected array $temporalDimensions = ['category'];
protected $guarded = [];
}
class TaxJurisdiction extends Model
{
use HasBitemporalRelations;
public function rates(): BitemporalMany
{
return $this->bitemporalMany(TaxRate::class);
}
}
Schema::create('tax_rates', function (Blueprint $table) {
$table->id();
$table->bitemporalForeignFor(TaxJurisdiction::class);
$table->string('category'); // 'standard' | 'reduced' | 'zero' — the dimension
$table->decimal('rate', 5, 4); // e.g. 0.2000 for 20%
$table->bitemporalPeriods();
$table->timestamps();
$table->preventBitemporalOverlaps(['tax_jurisdiction_id'], ['category']);
});
A rate set in the budget¶
The standard rate rises to 20% from the start of the new tax year. Announced ahead of time, applying going forward — a changeEffectiveFrom.
$jurisdiction->rates()
->forDimensions(['category' => 'standard'])
->changeEffectiveFrom(
attributes: ['rate' => 0.2000],
validFrom: '2026-04-06',
);
Legislation that back-dates a change¶
In July, a finance act lowers the standard rate to 17.5%, back-dated to 6 April. Returns for the first quarter were already filed at 20%. The rate was always 17.5% from April — a correct() over the historical window — but the filings made in good faith at 20% must survive on the recorded axis.
$jurisdiction->rates()
->forDimensions(['category' => 'standard'])
->correct(
attributes: ['rate' => 0.1750],
validFrom: '2026-04-06',
);
A Q1 return filed on 30 April is still fully reproducible — its rate is whatever the system believed on the filing date, recovered by pinning both axes:
$rateAsFiled = $jurisdiction->rates()
->forDimensions(['category' => 'standard'])
->validAt('2026-04-30') // the period the return covered
->knownAt('2026-04-30') // as the law was understood when filed
->sole();
$rateAsFiled->rate; // 0.2000 — the figure on the original return
Note
The correction does not falsify the return; it records that our current understanding differs from the one in force at filing time. Reconciling the two — issuing the refund for the over-collected 2.5% — is now a defensible calculation with both figures provable from the store.
A full restatement from the authority¶
Sometimes the tax authority does not send a delta — it republishes the entire rate schedule for a category. Rather than diff it into a series of changes, supersedeTimeline() takes the complete set of desired rows and makes the timeline match them in one transaction.
$jurisdiction->rates()
->forDimensions(['category' => 'reduced'])
->supersedeTimeline([
['rate' => 0.0500, 'valid_from' => '2024-04-06', 'valid_to' => '2026-04-06'],
['rate' => 0.0000, 'valid_from' => '2026-04-06', 'valid_to' => null],
]);
The previously-recorded rows are closed on the recorded axis, not deleted — the old picture is still queryable via knownAt().
Seeding historical beliefs, not just values¶
When you stand the system up against decades of prior rates, most of it is a clean value history and belongs in backfill()->timeline(). But occasionally you need to reconstruct not just what the rate was, but what was believed at the time — because an old return was filed on a rate that a later amendment changed, and you want that belief on record. importHistoricalKnowledge() accepts rows that already carry their recorded spells.
// Clean value history — no belief reconstruction needed.
$jurisdiction->rates()
->forDimensions(['category' => 'standard'])
->backfill()
->timeline([
['rate' => 0.1750, 'valid_from' => '2010-01-01', 'valid_to' => '2011-01-04'],
['rate' => 0.2000, 'valid_from' => '2011-01-04', 'valid_to' => '2026-04-06'],
]);
// Reconstruct a past belief: from April we believed 20%, until the July amendment
// corrected it to 17.5% — both recorded spells stamped explicitly.
$jurisdiction->rates()
->forDimensions(['category' => 'standard'])
->backfill()
->importHistoricalKnowledge([
[
'rate' => 0.2000,
'valid_from' => '2026-04-06', 'valid_to' => null,
'recorded_from' => '2026-04-06', 'recorded_to' => '2026-07-15',
],
[
'rate' => 0.1750,
'valid_from' => '2026-04-06', 'valid_to' => null,
'recorded_from' => '2026-07-15', 'recorded_to' => null,
],
]);
The difference is the whole point of importing through backfill() rather than replaying corrections: timeline() stamps the recorded axis as "now", while importHistoricalKnowledge() lets you set it, so a migrated system reproduces the beliefs the old one held, not just its final values.
What to take away¶
Bitemporal storage is what lets a jurisdiction's past be corrected and audited at the same time. A back-dated finance act rewrites the effective rate through correct(), while every return already filed stays reproducible via knownAt(). A full restatement goes in with supersedeTimeline() without erasing the prior picture, and legacy beliefs come across faithfully with importHistoricalKnowledge() — so no amount of retroactive legislation ever costs you the record of what you once believed.
Next: back to Concepts, or browse the full reference.