(feat) Temporal Time Travel ORM using #[Temporal] Attribute#238
Merged
techmahedy merged 1 commit intodoppar:3.xfrom Apr 5, 2026
Merged
(feat) Temporal Time Travel ORM using #[Temporal] Attribute#238techmahedy merged 1 commit intodoppar:3.xfrom
techmahedy merged 1 commit intodoppar:3.xfrom
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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, anddeleteoperation 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:
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]AttributeA PHP 8.3 class-level attribute with two optional parameters:
suffix'_history'trackActorfalsetrue, records the authenticated user's PK on every snapshot2. Automatic Lifecycle Hooks
TemporalManagerregistersafter_created,after_updated, andafter_deletedhooks on the model during boot. No manual hook registration is needed.Each snapshot captures:
snapshot)changed_cols)valid_from)trackActor: true(actor)after_createdtiming quirk handled: Doppar firesafter_createdbefore writing the auto-increment PK back to$model->attributes.TemporalManager::snapshot()detects this case ($model->getKey() === null) and falls back to$pdo->lastInsertId(), ensuringrecord_idis always correct.3.
migrate:temporalConsole CommandScans 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/ModelsIdempotent by design: If the history table already exists, the command skips it. If the table exists but is missing the
actorcolumn (e.g.trackActor: truewas added after the initial migration), it runsALTER TABLE ... ADD COLUMN actorinstead of recreating the table.Multi-driver DDL:
DATETIME(6)JSONBIGINT UNSIGNED AUTO_INCREMENTTIMESTAMPTZJSONBBIGSERIALTEXTTEXTINTEGER AUTOINCREMENT4.
TemporalBuilder— Time-Travel Query BuilderModel::at($datetime)returns aTemporalBuilderinstance scoped to a point in time. It extends the standardBuilderand overridesget(),first(), andfind()to query the history table.Algorithm for
get():Uses a correlated subquery to find the row with the maximum
valid_from ≤ $datetimefor each distinctrecord_id, then excludes rows whoseaction = '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
WHEREclauses cannot be applied at the database level.TemporalBuilderfetches all qualifying snapshots, hydrates them into model instances, and then applieswhere/orderBy/limitin PHP. Supported operators:=,!=,<>,>,>=,<,<=,IS NULL,IS NOT NULL,IN,NOT IN,BETWEEN,NOT BETWEEN,LIKE,ILIKE,NOT LIKE.DateTime normalization:
'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.
InteractsWithTemporalTrait — Instance APIMixed into
Model. Exposes six public methods:::at(string $datetime): TemporalBuilder(static)Returns a time-travel query builder. Throws
RuntimeExceptionif the model is not#[Temporal].->history(): CollectionReturns the full chronological audit trail for the record. Each entry is a hydrated model instance with virtual metadata properties:
__history_id__actioncreated/updated/deleted__valid_from__changed_cols__actortrackActor: true)->diff(string $from, string $to): ?arrayCompares 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
nullif either snapshot does not exist.->rewindTo(string $datetime): ?staticReturns a new, unsaved model instance representing the record's state at the given datetime. The original model is not modified. Returns
nullif no snapshot exists.->restoreTo(string $datetime): boolEquivalent to
->rewindTo($datetime)->save(). Persists the historical state as a live update. Returnstrueon success,falseif no snapshot exists or the save fails. The restoration itself is recorded as a newupdatedentry in the history.->isTemporal(): bool/->historyTable(): stringInspection helpers.
6. Actor Tracking
When
trackActor: true,TemporalManager::resolveActor()is called at snapshot time:The
try/catchensures that unauthenticated contexts (console commands, queue jobs, tests) never crash — the actor is stored asnullsilently.Defensive column check: Before inserting the
actorvalue,TemporalManager::hasActorColumn()verifies the column actually exists in the history table using a per-process in-memory cache. This preventsSQLSTATE[42703]crashes on tables created beforetrackActor: truewas enabled, giving teams a safe window to runmigrate:temporalwithout 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
snapshotto 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 (PostgreSQLJSONB), and makes time-travel queries a simple indexed lookup onrecord_id+valid_fromrather 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
WHEREconditions 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 $columnCacheis safe and eliminates all overhead after the first check per history table.Files Changed — Diff Summary
Total new lines of production code: ~650
Total modified lines in existing files: 2
Testing Checklist
#[Temporal]model recordscreatedsnapshot afterModel::create()#[Temporal]model recordsupdatedsnapshot after->save()on existing record#[Temporal]model recordsdeletedsnapshot 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 correctchanges,added,removedkeys->diff()returnsnullwhen a snapshot is missing at either datetime->rewindTo()returns an unsaved clone; original model is unmodified->restoreTo()persists the historical state and records a newupdatedentrymigrate:temporalcreates history table when it does not existmigrate:temporalskips when table already exists andtrackActor: falsemigrate:temporaladdsactorcolumn when table exists andtrackActor: truemigrate:temporal --showprints SQL without executingtrackActor: truerecords authenticated user PK on each snapshottrackActor: truestoresnullactor in unauthenticated context without crashinghasActorColumn()prevents crash whenactorcolumn does not exist yetModel::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 toModel.phpadds a no-op call (registerTemporalHooks()) that returns immediately for non-temporal models.