Back to blog

Form Versioning in Laravel: Keep Old Submissions Readable When Fields Change

Manuk Minasyan · · 4 min read

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:

  1. Schema format version: tells code how to interpret the JSON document.
  2. Published form revision: the exact definition served to a respondent.
  3. 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

ModelStorageHistorical fidelityTrade-off
One mutable form rowLowestPoorOld answers depend on current schema
Full schema snapshot per submissionHighestExcellentSimple reads, duplicated JSON
Immutable `form_versions` tableModerateExcellentOne 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:

  1. load that exact revision from trusted storage;
  2. verify it belongs to the form and is allowed to accept responses;
  3. compile validation from it;
  4. 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:

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:

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:

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.

Related posts