Form Versioning in Laravel: Keep Old Submissions Readable When Fields Change
Form Versioning in Laravel: Keep Old Submissions Readable When Fields Change
Rename a dynamic field after collecting responses and a subtle question appears: should an old submission display the old label or the new one? Delete an option and the problem becomes obvious—the stored value can survive while its human meaning disappears.
The fix is to bind every submission to the immutable form revision the respondent saw.
“Version” means three different things
Separate these concepts in the data model:
- Schema format version: tells code how to interpret the JSON document.
- Published form revision: the exact definition served to a respondent.
- Application/package release: the code deployed at that time.
A JSON field such as "version": "1.0" usually describes the schema dialect. It does not prove that two forms with that marker have identical labels, fields, or rules. The JSON Schema introduction illustrates this format-versus-instance distinction.
Compare three storage models
| Model | Storage | Historical fidelity | Trade-off |
|---|---|---|---|
| One mutable form row | Lowest | Poor | Old answers depend on current schema |
| Full schema snapshot per submission | Highest | Excellent | Simple reads, duplicated JSON |
| Immutable `form_versions` table | Moderate | Excellent | One relation/eager load |
For most systems, a normalized revision table is the best default. Per-submission snapshots remain reasonable for low-volume, audit-heavy workflows or when definitions reference data outside the revision.
A practical revision model
Use an append-only table containing:
form_versions
id (ULID)
form_id
revision (monotonic integer)
schema_format
schema (JSON)
checksum
published_at
created_by
Add forms.current_version_id and form_submissions.form_version_id. A draft should carry the same version ID. If exact option wording matters and options come from mutable Eloquent rows, store the selected label snapshot too.
Keep machine field codes immutable. They prevent a label rename from changing the answer key, but they do not preserve a deleted field's label, help text, option labels, or validation behavior. Revision history supplies that context.
Publish atomically
Publishing should create an immutable revision and switch the active pointer in one database transaction:
DB::transaction(function () use ($form, $draftSchema): void {
$revision = $form->versions()->create([
'revision' => $form->versions()->max('revision') + 1,
'schema_format' => '1.0',
'schema' => $draftSchema,
'checksum' => hash('sha256', json_encode($draftSchema)),
'published_at' => now(),
'created_by' => auth()->id(),
]);
$form->update(['current_version_id' => $revision->id]);
});
In production, protect the revision number and active pointer against concurrent publishers with locking or a unique constraint. Dispatch cache refreshes and notifications after commit.
Never edit a published revision in place. A correction creates a new revision; rollback changes the active pointer to a known revision and records the action.
Bind at render and verify at submit
When the public page renders, resolve one active revision and include its opaque identifier in component state. On submission:
- load that exact revision from trusted storage;
- verify it belongs to the form and is allowed to accept responses;
- compile validation from it;
- store the submission with the same revision ID.
Do not validate against revision A and label the result as revision B. A delayed Livewire request or a page open during publishing can otherwise cross the boundary.
Decide how drafts survive changes
An old draft can:
- finish on its original revision;
- migrate through a reviewed transformation;
- be forced to restart.
There is no universal answer. A tax or compliance form may require restart; an application may honor the original version for a grace period. Make the policy visible and test it. The upcoming save/resume design should bind the draft from its first save.
Mutable options need a display policy
Suppose a submission stores plan_id=3, then the plan is renamed or deleted. You can display:
- the current label, useful for operational dashboards;
- the label from the form revision;
- a label snapshot captured at submission.
Pick intentionally. Stable IDs preserve identity, not wording. If a legal acknowledgement depends on exact text, the historical snapshot matters more than current catalog data.
Retention is separate
Schema history does not mean every attachment or personal answer must live forever. Keep enough revision metadata to interpret records while applying a documented retention policy to submissions and files. See the GDPR form checklist for the wider lifecycle.
Tests that prove history works
Create a submission, then publish new revisions that rename a field, delete a field, reorder and rename options, add a required field, and change locale copy. The old response must still render against its original revision. Also test:
- simultaneous publishers cannot create two active revisions;
- failed publishing leaves the previous pointer intact;
- a tampered revision ID is rejected;
- a retired draft follows the documented finish/migrate/restart policy;
- rollback preserves all later revisions and audit events.
The important design move is small: stop treating the mutable form row as the historical source of truth. Once published definitions are immutable and every response points to one, schema evolution becomes manageable instead of destructive.