The Go history append preserved the existing JSON. That looked reasonable: read a stored transition list, add the new transition, leave everything else alone. Unfortunately, leaving everything else alone wasn’t what the PHP implementation did.
The shared idempotency updater in my rating engine read history through an
Eloquent array cast, appended a transition, and assigned the resulting value
back to the model. That round trip could turn an old nested {} into [].
The Go translation preserved {} instead. Both could complete the operation
and return the same price while storing different histories.
It’s a small incompatibility, but it exposes a much larger migration problem. A model declaration doesn’t describe only fields. Its reads, assignments, serialization, and failure order can be part of the behavior you’re porting. The runtime decision didn’t give me permission to skip those details.
The updater wasn’t appending to the stored document
The PHP model had ordinary Eloquent array casts for the two JSON attributes which matter here:
return [
'command_outcome_reference_json' => 'array',
'status_transition_history_json' => 'array',
// Status and timestamp casts omitted.
];Its shared terminal mutator found and locked the record before reading the history. These are the source operations, with surrounding transaction setup omitted:
$record = IdempotencyRecord::query()
->whereKey($recordId)
->lockForUpdate()
->firstOrFail();
$targetRecordStatus = IdempotencyRecordStatus::from($targetStatus);
$normalizedCompletedAt = $this->normalizeInstant($completedAt);
$history = $record->status_transition_history_json ?? [];
$history[] = [
'from_status' => $record->status->value,
'to_status' => $targetStatus,
'occurred_at' => $normalizedCompletedAt->toIso8601String(),
'transition_reason' => $reason,
'request_hash' => $record->request_hash,
'lock_token' => $record->lock_token,
];The crucial line is the property read. The raw database value wasn’t being passed straight to a JSON append operator. Eloquent’s array getter decoded it into PHP values, using associative JSON decoding. Objects became PHP arrays , including empty objects nested inside existing entries.
For a stored history of [{"detail":{}}], the getter therefore exposed an
empty PHP array under detail. The mutator then made the assignments below,
omitting only the repetitive completion and transition metadata fields:
$record->response_hash = $responseHash;
$record->command_outcome_reference_json = $outcomeReference;
// Assign completion times and transition metadata here.
$record->status_transition_history_json = $history;
$record->status = $targetRecordStatus;
$record->save();Assigning history invoked the cast’s JSON encoder. An empty PHP array encodes
as []
, so the old
entry changed type when the revised history was saved. Nothing in that
sequence asked to preserve the original JSON representation.
A minimal demonstration, reducing the new transition to one field, gives:
[
{"detail": []},
{"state": "done"}
]This wasn’t PostgreSQL spontaneously changing the document. The application had read it into a representation with different distinctions, then written that representation back. Executing the actual mutator and model against an isolated PostgreSQL fixture reproduces the same old-entry conversion.
The first Go path preserved the wrong thing
The earlier Go helper validated that the stored history was a JSON array, removed its closing bracket, and appended the encoded transition. After its validation and null handling, the core was:
result := append([]byte(nil), trimmed[:len(trimmed)-1]...)
if len(bytes.TrimSpace(trimmed[1:len(trimmed)-1])) > 0 {
result = append(result, ',')
}
result = append(result, transition...)
return append(result, ']'), nilThat is shortened from the actual helper, not a recommendation to construct arbitrary JSON with byte operations. It explains this particular mismatch: the old entries weren’t decoded and reassigned through the model’s semantics. Their nested empty objects stayed objects.
A conventional Go decode-and-append implementation could disagree too.
Decoding an object into any produces a map
;
an empty map encodes as {}. Changing from raw bytes to ordinary Go maps
wouldn’t, by itself, reproduce PHP’s associative-array round trip.
The public comparison makes that smaller distinction executable with the language standard libraries. Ordinary list and null histories agree. A nested empty object changes stored JSON type, and an object-shaped history succeeds in PHP but is rejected by a Go slice decoder. The comparison deliberately uses the tempting incompatible Go implementation; a passing run means those differences were detected, not that it implements the private port’s correction.
Nor would I repair this by converting every empty object in the application to an array. The source’s behavior depended on which path read and rewrote the value. That distinction turned out to matter even within the same idempotency table.
Another updater really did append in SQL
The rating-specific PHP repository had a separate terminal path. Instead of
loading history through the shared model updater, it issued an UPDATE with
this expression:
status_transition_history_json = (
COALESCE(status_transition_history_json::jsonb, '[]'::jsonb)
|| jsonb_build_array(jsonb_build_object(
'from_status', status,
'to_status', CAST(? AS text),
'occurred_at', CAST(? AS text),
'transition_reason', CAST(? AS text),
'request_hash', request_hash,
'lock_token', lock_token
))
)::jsonThis is the actual assignment from the broader statement; other updated
columns, bindings, and RETURNING are omitted. The existing JSON is handled
by PostgreSQL’s JSONB operators, not by a PHP associative getter followed by
reassignment. Appending to [{"detail":{}}] through that expression preserves
the nested object, unlike the shared ORM-mutator case.
The table name, attribute name, and business intention were similar. The conversion boundary wasn’t. If the port made both updaters use one ORM-normalizing helper merely to remove duplication, it would fix one translation by changing the other. Shared names are a lead to investigate, not proof that two implementations have the same contract.
This is also why comparing only fresh model reads can conceal the difference. Both stored shapes can become an empty PHP array when read associatively. The raw value still differs. A test needs to decide whether it is checking storage, a getter, a returned object, or all three.
A list annotation didn’t constrain every stored history
The intended history was a list of transitions. The PHP append operation
accepted more than that intention suggested. An object-shaped JSON document
became an associative array; [] then appended a member under the next
integer key. It didn’t first run a list-shape validator.
For a synthetic stored value with a named note and a negative numeric key, the beginning was:
{
"note": {"detail": {}},
"-5": {"state": "open"}
}On the tested 64-bit PHP runtime, the terminal update retained the named
member, converted its nested empty object, and appended the new transition
under -4. A slice-only decoder rejected the document before it could do
any of that. This is a success-versus-failure mismatch, not just a disagreement
between pretty printers.
These are valid JSON values used to characterize the storage/model boundary. They aren’t claims that an ordinary public request created each shape. Tightening the accepted history shape could be a good change, but doing it during a faithful port would need an explicit decision about existing data and its readers.
The Go correction used an ordered representation rather than asking an unordered map or typed slice to represent every PHP array:
type OrderedJSONMember struct {
Key string
Value any
}
type OrderedJSONObject []OrderedJSONMemberIts decoder retained member order, recursively normalized decoded values,
recognized PHP-compatible integer keys, and exposed a list when the keys
were exactly the ordered sequence 0..n-1. The history append could then
handle a list as a list, or find the next integer key in an ordered object.
No integer keys meant starting at zero; negative-only keys still had a
largest integer. The current 64-bit maximum required an error instead of
wrapping around or overwriting an entry.
The typed record also kept raw JSON alongside convenience projections such as a list-shaped history. An associative history which couldn’t be projected into that list didn’t become nonexistent data. The raw/ordered value still had to be available to the operation which owned its semantics.
That is substantially more work than changing a model’s class name into a struct name. It is still bounded work: one representation boundary, not a reason to make every domain value imitate PHP arrays.
Lookup, append, assignment, and save had an order
The source wrapped its terminal update in DB::transaction(..., 3), found
the record with a row lock, appended to the decoded history, assigned outcome
JSON, assigned revised history, and saved. The 3 is an attempt count, not
an isolation level. The call alone doesn’t establish serializable isolation,
regardless of how confidently a comment describes the transaction.
Order mattered when more than one thing was wrong. A missing record and an
outcome containing a non-encodable NAN value failed at lookup. An existing
record whose history decoded to true, paired with that same bad outcome,
failed while appending to history. With an ordinary empty history, the bad
outcome failed during assignment to the outcome attribute instead.
Pre-encoding all JSON at the beginning would change which error won. So would validating every history as a list before looking up the row. Those might be reasonable designs for a new repository. They weren’t this repository’s behavior.
The revised Go path reflected the sequence explicitly. After its locked lookup and construction of the transition, it did these operations in order:
assignedHistory, err := appendIdempotencyModelHistory(
persisted.StatusTransitionHistoryJSON,
transitionJSON,
)
if err != nil {
return nil, err
}
encodedOutcome, err := encodeIdempotencyOutcomeJSON(
outcomeJSON,
outcome,
)
if err != nil {
if len(outcomeJSON) == 0 {
return nil, idempotencyRecordAttributeEncodingError{
column: "command_outcome_reference_json",
cause: err,
}
}
return nil, fmt.Errorf(
"encode idempotency %s outcome: %w",
targetStatus,
err,
)
}
historyJSON, err := domain.MarshalPHPDefaultArrayJSON(
assignedHistory,
)
if err != nil {
return nil, idempotencyRecordAttributeEncodingError{
column: "status_transition_history_json",
cause: err,
}
}The later SQL update and its binding construction are omitted here. The important point is that appending and encoding the resulting history aren’t one indivisible step: outcome assignment sits between them in the PHP source. A helper which serialized history too early could change precedence again. The code shown here was an in-progress correction in the version examined, not a completed parity result. The checks in this chapter didn’t establish repository integration, failure mapping, or concurrent transition behavior.
For the PHP path, the stored row also matters after a failed attempt.
Missing-record, scalar-history, integer-key-exhaustion, and outcome-encoding
failures left the existing row unchanged in the isolated database. Catching
an exception alone would have missed that question. These were sequential
checks with the actual mutator; proving concurrent behavior needs competing
transactions, not confidence inspired by lockForUpdate() in a code sample.
The getter and the stored value could both be right
There was one more useful distinction in the same operation. Suppose the outcome assigned to the model contained a native object:
$outcomeReference = [
'object' => (object) ['empty' => (object) []],
];The assignment could store {"object":{"empty":{}}}. A getter on the returned
model, and a fresh read through the same cast, exposed an empty PHP array
under empty. Reading that attribute didn’t itself rewrite the stored JSON.
The history changed because the updater read and then reassigned it; the
outcome could retain its native object in storage until a comparable
read-modify-write happened.
That gives a useful shape to the integration check: compare the operation’s returned value, inspect the raw stored column, then read through a fresh model. Don’t choose whichever view agrees and call the other one an irrelevant serialization detail. The source makes all of them observable at different boundaries.
I might choose a stricter list contract or preserve empty objects in a new design. Either could be better for its consumers. In this port, changing those rules needed a separate migration decision: allowed stored values, conversion of existing data, dependent readers, and any byte-sensitive hashes or signatures. A byte-order difference can be harmless for a JSON-value contract and important at a hashing boundary; “normalize it” doesn’t settle which contract we’re preserving.
Matching the final price wouldn’t have found this. The compatibility work had to follow what the source actually read, changed, rejected, and stored, including a distinction two quite reasonable append implementations erased.