Skip to content

(feat) Temporal Time Travel ORM using #[Temporal] Attribute#238

Merged
techmahedy merged 1 commit intodoppar:3.xfrom
techmahedy:techmahedy-3.x
Apr 5, 2026
Merged

(feat) Temporal Time Travel ORM using #[Temporal] Attribute#238
techmahedy merged 1 commit intodoppar:3.xfrom
techmahedy:techmahedy-3.x

Conversation

@techmahedy
Copy link
Copy Markdown
Member

Summary

This PR introduces the Temporal Time-Travel ORM — a first-class, zero-boilerplate audit and history system for Doppar models. By adding a single PHP attribute to any model class, every create, update, and delete operation is automatically snapshotted into a companion history table. The full history can then be queried, diffed, rewound, and restored through a fluent API that lives directly on the model.

It is inspired by temporal table concepts from SQL:2011, Hibernate Envers (Java/Spring), and the Slowly Changing Dimension pattern in data warehousing — rebuilt as a native, attribute-driven, zero-configuration ORM extension for Doppar.

Motivation

Production applications routinely need to answer questions like:

  • "What did this contract look like before the client changed it?"
  • "Who updated this record and when?"
  • "Can we roll this back to last Tuesday's state?"

Without built-in support, teams bolt on custom audit tables, observer classes, event listeners, or third-party packages — each with different APIs, different storage strategies, and significant maintenance overhead. The Temporal ORM solves all of these needs in a single, coherent, framework-native feature.

Feature Breakdown

1. #[Temporal] Attribute

A PHP 8.3 class-level attribute with two optional parameters:

#[Temporal(suffix: '_history', trackActor: false)]
class Contract extends Model {}
Parameter Default Description
suffix '_history' Appended to the base table name to form the history table name
trackActor false When true, records the authenticated user's PK on every snapshot

2. Automatic Lifecycle Hooks

TemporalManager registers after_created, after_updated, and after_deleted hooks on the model during boot. No manual hook registration is needed.

after_created  →  snapshot(model, 'created')
after_updated  →  snapshot(model, 'updated')
after_deleted  →  snapshot(model, 'deleted')

Each snapshot captures:

  • The full row as a JSON object (snapshot)
  • Which columns changed on updates (changed_cols)
  • A microsecond-precision timestamp (valid_from)
  • The actor's PK if trackActor: true (actor)

after_created timing quirk handled: Doppar fires after_created before writing the auto-increment PK back to $model->attributes. TemporalManager::snapshot() detects this case ($model->getKey() === null) and falls back to $pdo->lastInsertId(), ensuring record_id is always correct.


3. migrate:temporal Console Command

Scans the models directory for #[Temporal] classes and creates the history table for each, using driver-appropriate DDL.

php doppar migrate:temporal
php doppar migrate:temporal --show          # dry-run, prints SQL
php doppar migrate:temporal --connection=pgsql_secondary
php doppar migrate:temporal --path=app/Domain/Models

Idempotent by design: If the history table already exists, the command skips it. If the table exists but is missing the actor column (e.g. trackActor: true was added after the initial migration), it runs ALTER TABLE ... ADD COLUMN actor instead of recreating the table.

Multi-driver DDL:

Driver Timestamp type JSON type Auto-increment
MySQL DATETIME(6) JSON BIGINT UNSIGNED AUTO_INCREMENT
PostgreSQL TIMESTAMPTZ JSONB BIGSERIAL
SQLite TEXT TEXT INTEGER AUTOINCREMENT

4. TemporalBuilder — Time-Travel Query Builder

Model::at($datetime) returns a TemporalBuilder instance scoped to a point in time. It extends the standard Builder and overrides get(), first(), and find() to query the history table.

// What did contract 42 look like on 1 Jan 2024?
$contract = Contract::at('2024-01-01')->find(42);

// All active contracts as of 1 June 2024
$contracts = Contract::at('2024-06-01')
    ->where('status', 'active')
    ->orderBy('amount', 'DESC')
    ->limit(10)
    ->get();

Algorithm for get():
Uses a correlated subquery to find the row with the maximum valid_from ≤ $datetime for each distinct record_id, then excludes rows whose action = 'deleted'. Compatible with MySQL 5.7+, PostgreSQL 9+, and SQLite 3.7+.

PHP-level condition evaluation:
Because historical state lives inside a JSON snapshot column, SQL WHERE clauses cannot be applied at the database level. TemporalBuilder fetches all qualifying snapshots, hydrates them into model instances, and then applies where / orderBy / limit in PHP. Supported operators: =, !=, <>, >, >=, <, <=, IS NULL, IS NOT NULL, IN, NOT IN, BETWEEN, NOT BETWEEN, LIKE, ILIKE, NOT LIKE.

DateTime normalization:

Input Resolved to
'2024-01-01' '2024-01-01 23:59:59.999999'
'2024-01-01 15:00' '2024-01-01 15:00:59.999999'
'2024-01-01 15:00:00' '2024-01-01 15:00:00.999999'
'2024-01-01 15:00:00.123456' '2024-01-01 15:00:00.123456'

5. InteractsWithTemporal Trait — Instance API

Mixed into Model. Exposes six public methods:

::at(string $datetime): TemporalBuilder (static)

Returns a time-travel query builder. Throws RuntimeException if the model is not #[Temporal].

->history(): Collection

Returns the full chronological audit trail for the record. Each entry is a hydrated model instance with virtual metadata properties:

Property Description
__history_id Row ID in the history table
__action created / updated / deleted
__valid_from Microsecond timestamp
__changed_cols Array of changed column names (updates only)
__actor PK of the user who made the change (requires trackActor: true)

->diff(string $from, string $to): ?array

Compares the record's state at two points in time. Returns a structured diff:

[
    'from'    => '2024-01-01',
    'to'      => '2024-06-01',
    'changes' => ['status' => ['from' => 'draft', 'to' => 'active']],
    'added'   => [],
    'removed' => [],
]

Returns null if either snapshot does not exist.

->rewindTo(string $datetime): ?static

Returns a new, unsaved model instance representing the record's state at the given datetime. The original model is not modified. Returns null if no snapshot exists.

->restoreTo(string $datetime): bool

Equivalent to ->rewindTo($datetime)->save(). Persists the historical state as a live update. Returns true on success, false if no snapshot exists or the save fails. The restoration itself is recorded as a new updated entry in the history.

->isTemporal(): bool / ->historyTable(): string

Inspection helpers.


6. Actor Tracking

When trackActor: true, TemporalManager::resolveActor() is called at snapshot time:

private static function resolveActor(): mixed
{
    try {
        return auth()->user()?->getKey();
    } catch (\Throwable) {
        return null;
    }
}

The try/catch ensures that unauthenticated contexts (console commands, queue jobs, tests) never crash — the actor is stored as null silently.

Defensive column check: Before inserting the actor value, TemporalManager::hasActorColumn() verifies the column actually exists in the history table using a per-process in-memory cache. This prevents SQLSTATE[42703] crashes on tables created before trackActor: true was enabled, giving teams a safe window to run migrate:temporal without downtime.


Design Decisions

Why a PHP attribute instead of a trait or interface?

Attributes are purely declarative — they carry no behavior and cannot conflict with existing model traits or interfaces. A trait would add public methods unconditionally; an interface would require implementation. The #[Temporal] attribute marks intent at the class level, and the framework reads it via Reflection to activate behavior, matching Doppar's existing pattern (e.g. #[Hook], #[Immutable], #[Route]).

Why a separate history table per model instead of a single audit log table?

A shared audit table grows unboundedly and requires snapshot to be a generic blob with no schema. A per-model history table keeps rows co-located with the source table, allows driver-native JSON indexing (PostgreSQL JSONB), and makes time-travel queries a simple indexed lookup on record_id + valid_from rather than a filtered scan of millions of rows across all models.

Why PHP-level filtering in TemporalBuilder?

The historical state lives inside a JSON snapshot column. Pushing SQL WHERE conditions into the database would require JSON path expressions that differ between MySQL, PostgreSQL, and SQLite and are difficult to compose generically. PHP-level filtering keeps the builder simple, portable, and consistent with how application code already expresses conditions. For most audit use cases the dataset size is small enough that this is not a performance concern.

Why is hasActorColumn() cached in memory?

The column existence check requires a round-trip to the database on every snapshot write if not cached. Since the schema does not change at runtime, a per-process static array $columnCache is safe and eliminates all overhead after the first check per history table.


Files Changed — Diff Summary

src/Phaseolies/Database/Temporal/
├── Attributes/
│   └── Temporal.php                        [NEW]  — PHP 8 attribute, 2 options
├── TemporalManager.php                     [NEW]  — snapshot engine, DDL builder
├── TemporalBuilder.php                     [NEW]  — time-travel query builder
└── InteractsWithTemporal.php               [NEW]  — public model API trait

src/Phaseolies/Console/Commands/Migrations/
└── MigrateTemporalCommand.php              [NEW]  — Pool console command

src/Phaseolies/Database/Entity/
└── Model.php                               [MOD]  — +2 lines: use trait, call registerTemporalHooks()

Total new lines of production code: ~650
Total modified lines in existing files: 2


Testing Checklist

  • #[Temporal] model records created snapshot after Model::create()
  • #[Temporal] model records updated snapshot after ->save() on existing record
  • #[Temporal] model records deleted snapshot after ->delete()
  • ::at('2024-01-01')->find($id) returns correct historical state
  • ::at('2024-01-01')->get() excludes records deleted before that date
  • ::at()->where()->orderBy()->limit() applies conditions correctly in PHP
  • ->history() returns entries in chronological order with all virtual properties
  • ->diff($from, $to) returns correct changes, added, removed keys
  • ->diff() returns null when a snapshot is missing at either datetime
  • ->rewindTo() returns an unsaved clone; original model is unmodified
  • ->restoreTo() persists the historical state and records a new updated entry
  • migrate:temporal creates history table when it does not exist
  • migrate:temporal skips when table already exists and trackActor: false
  • migrate:temporal adds actor column when table exists and trackActor: true
  • migrate:temporal --show prints SQL without executing
  • trackActor: true records authenticated user PK on each snapshot
  • trackActor: true stores null actor in unauthenticated context without crashing
  • hasActorColumn() prevents crash when actor column does not exist yet
  • All behavior is consistent across MySQL, PostgreSQL, and SQLite
  • Model::withoutHook()->create() does NOT record a temporal snapshot (by design)

Breaking Changes

None. The feature is entirely opt-in via #[Temporal]. Models without the attribute are completely unaffected. No existing API is modified or removed. The two-line change to Model.php adds a no-op call (registerTemporalHooks()) that returns immediately for non-temporal models.

@techmahedy techmahedy added the feat new feature label Apr 5, 2026
@techmahedy techmahedy merged commit be2eacb into doppar:3.x Apr 5, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feat new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant