Skip to content

Temporal pivots

A bitemporal pivot models a many-to-many assignment that is itself a timeline: which roles a user held, and when — both in valid time ("from June this user was an admin") and in recorded time ("we corrected that on the 10th"). The pivot row is not a flag you flip; it is a temporal model scoped to the (parent, related) pair, with the same correction-preserving guarantees as any other timeline.

BitemporalBelongsToMany is the relation that exposes it.

The pivot model

The pivot extends Laravel's Pivot and adds the Bitemporal trait. It does not declare a $temporalEntity — its entity is the injected composite (parent, related) tuple, which the relation supplies at resolution time. (The relation-type boot guard exempts Pivot models for exactly this reason.)

use Illuminate\Database\Eloquent\Relations\Pivot;
use Vusys\Bitemporal\Bitemporal;

class UserRoleAssignment extends Pivot
{
    use Bitemporal;

    public $incrementing = true;          // it's a timeline, so rows have their own id
    protected $table = 'user_role_assignments';
    protected $guarded = [];
}

Set public $incrementing = true — unlike an ordinary pivot, each version is a distinct row with its own primary key. Any non-key columns (here a scope) are ordinary temporal attributes you can correct over time.

The relation

Declare the relation on the parent with bitemporalBelongsToMany(), telling it which pivot model carries the timeline:

use Vusys\Bitemporal\Relations\BitemporalBelongsToMany;

class User extends Model
{
    use HasBitemporalRelations;

    public function roles(): BitemporalBelongsToMany
    {
        return $this->bitemporalBelongsToMany(Role::class)
            ->using(UserRoleAssignment::class);
    }
}
public function bitemporalBelongsToMany(
    string $related,
    ?string $using = null,
    ?string $foreignPivotKey = null,
    ?string $relatedPivotKey = null,
    ?string $parentKey = null,
): BitemporalBelongsToMany

The pivot class can be passed as the $using argument or chained with ->using(...) (as above). The key names default to Laravel's conventions (user_id, role_id); override them with the remaining arguments if your columns differ. ->using() is required — a read or write before it is set throws TemporalConfigurationException.

The migration

The pivot table carries both foreign keys, any attribute columns, and the full bitemporal period set:

Schema::create('user_role_assignments', function (Blueprint $table) {
    $table->id();
    $table->bitemporalForeignFor(User::class);   // user_id, FK, restrictOnDelete
    $table->bitemporalForeignFor(Role::class);   // role_id, FK, restrictOnDelete

    $table->string('scope')->nullable();         // a temporal pivot attribute

    $table->bitemporalPeriods();                 // valid_* + recorded_* (µs) + is_retraction
    $table->timestamps();

    $table->preventBitemporalOverlaps(['user_id', 'role_id']);
});

The overlap-prevention index is keyed on both foreign keys, because the related key is folded into the assignment's dimension tuple — each (user, role) pair is its own independent timeline.

Writing assignments

The standard attach/detach/sync methods are disabled — they would destroy history. Each throws TemporalConfigurationException pointing at the temporal replacement:

Disabled Use instead
attach() attachFor()
detach() detachAt()
sync() attachFor() / detachAt()

The temporal write methods mirror the change/correct/end vocabulary from Writing, specialised to a related model:

// Assign a role from a date (open-ended).
$user->roles()->attachFor(related: $admin, validFrom: '2026-06-01');

// Assign over a closed window.
$user->roles()->attachFor(
    related: $admin,
    validFrom: '2026-06-01',
    validTo: '2026-08-01',
    attributes: ['scope' => 'eu'],
);

// End an open-ended assignment at a date.
$user->roles()->detachAt(related: $admin, validTo: '2026-09-01');

// Retroactively correct an existing assignment (value and/or window).
$user->roles()->correctAssignment(
    related: $admin,
    validFrom: '2026-06-01',
    validTo: '2026-08-01',
    attributes: ['scope' => 'global'],
);
public function attachFor(
    Model $related,
    CarbonInterface|string $validFrom,
    CarbonInterface|string|null $validTo = null,
    array $attributes = [],
): TemporalWriteCommitted

public function detachAt(
    Model $related,
    CarbonInterface|string $validTo,
): TemporalWriteCommitted

public function correctAssignment(
    Model $related,
    CarbonInterface|string|null $validFrom = null,
    CarbonInterface|string|null $validTo = null,
    array $attributes = [],
): TemporalWriteCommitted

Each returns the same committed-event object as any other write (see Events). detachAt() throws TemporalCardinalityException if there is no open-ended current assignment to end; correctAssignment() throws the same if the tuple has no existing assignment — use attachFor() to create one first.

Reading assignments

Reads use the ordinary point-in-time predicates from Reading, evaluated over the pivot timeline. The relation yields pivot models, so the related key and any pivot attributes are read off the assignment itself:

// Roles currently assigned, as we believe today.
$user->roles()->currentKnowledge()->get();

// Was this assignment in force mid-July?
$user->roles()->validAt('2026-07-01')->currentKnowledge()->get();   // 1 row
$user->roles()->validAt('2026-09-01')->currentKnowledge()->get();   // 0 rows

// The single assignment valid at a date, with its attribute.
$user->roles()
    ->validAt('2026-07-01')
    ->currentKnowledge()
    ->sole()
    ->getAttribute('scope');                                        // 'eu'

To resolve the actual Role models, join or eager-load from the related keys as you would any pivot.

Next: Diffs and timelines.