A balance reservation could be pending, captured, released, or expired. Only a pending reservation could move to one of the other three states.
That gave the system three commands:
capture reservation
release reservation
expire reservationIt did not get one method called updateReservation() with a status argument.
The names said which transition the caller intended. The responses included the
resulting status, so a caller did not have to guess what a successful request
had produced.
This was better than a generic update. It was not yet a complete account of what happened.
A repeated release returned the same released reservation. A release after
capture returned a conflict. Both outcomes preserved the state, but for
different reasons. A response containing only status: released or a method
returning only true cannot express that difference.
The command name should reveal the requested change. Its result should reveal the observed one.
A setter hides the transition
It is tempting to model a small lifecycle as a writable field:
$reservation->update([
'status' => $request->string('status'),
]);The database accepts the assignment. The domain questions remain unanswered:
- Which states may become
captured? - Is capture safe to repeat?
- Can a captured reservation be released?
- Who is allowed to expire a reservation?
- What else must change with the reservation?
In the retained implementation, capture, release, and expiry also changed a related financial record and its lines. The reservation state was not an isolated attribute. It summarized a transition with several effects.
A generic setter makes every caller responsible for remembering those effects. It also lets a request describe an impossible jump as easily as a valid one:
released -> captured
expired -> pending
captured -> releasedThe problem is not the string column. The problem is presenting a transition as unconstrained data assignment.
Command names carry intent
These interfaces ask different questions:
updateReservation($id, ['status' => 'released']);
releaseReservation($id, $reason);The first says what value the caller wants stored. The second says what the caller is trying to accomplish.
That difference gives the implementation somewhere to put policy:
public function release(Reservation $reservation, ?string $reason): Result
{
// lock current state
// decide whether release is allowed
// transition related records together
// report the outcome
}It also gives logs, metrics, authorization rules, and tests a useful event to name. “Reservation updated” says little. “Reservation release rejected because it was already captured” identifies the attempted operation and the reason it did not happen.
This does not require a class for every verb. A function, application service, HTTP endpoint, or message handler can own the command. What matters is that the boundary preserves intent instead of immediately flattening it into fields.
State names constrain the valid moves
The retained lifecycle had one branching state:
capture
pending ------------> captured
|
| release
+------------------> released
|
| expire
+------------------> expiredCaptured, released, and expired were terminal. That map is small enough to read without a library. It is also important enough not to leave implicit across controller branches.
An explicit transition map has two jobs:
- enumerate the moves the system permits; and
- make every other move invalid by default.
The second job is easy to lose. A list of happy paths does not tell a future caller what happens when it repeats a command or arrives late. For each command, write down all current-state outcomes:
| Command | Current state | Outcome |
|---|---|---|
| capture | pending | transition to captured |
| capture | released or expired | reject |
| release | pending | transition to released |
| release | released or expired | preserve current state |
| release | captured | reject |
The table exposes a detail which method names alone cannot settle: repeated and late commands need deliberate semantics.
Success does not always mean change
Suppose release() returns true. The caller still cannot tell which of these
occurred:
pending -> released
released -> released
expired -> expiredThe retained release operation treated the latter two as successful no-ops and returned the current reservation. That is a defensible choice for an idempotent cleanup command. It lets a caller say “ensure this is no longer pending” without turning repetition into an error.
The result should still admit that no transition occurred:
new TransitionResult(
command: 'release',
changed: false,
from: 'released',
to: 'released',
reason: 'already_released',
);A rejected release after capture needs a different result:
new TransitionResult(
command: 'release',
changed: false,
from: 'captured',
to: 'captured',
reason: 'captured_is_terminal',
);Both results describe unchanged state. Only one says the requested condition was already satisfied.
An HTTP API may express rejection through a conflict response and successful repetition through a normal response. An in-process API may use a result type or a narrow exception for the rejected case. Either way, callers should not have to infer the difference from a boolean.
Return the state which the transaction committed
Another weak result is a mutated object passed into the method:
release($reservation);
if ($reservation->status === 'released') {
// Was it released here, or was this object stale?
}The retained operations reacquired and locked the record inside a database transaction. That matters because a reservation can receive competing capture, release, or expiry attempts. The meaningful result is the state selected while holding that lock, after all related updates succeed.
A transition result should therefore come from the transactional decision, not from the caller’s earlier snapshot:
load and lock current state
decide requested transition
update reservation and related records
commit
return committed outcomeIf the commit fails, no successful result should escape. If a concurrent command won first, the result should describe the state found under the lock.
This is also why changed should not be guessed by comparing a caller’s old
object with a later response. The command boundary already knows whether it
performed the transition.
Idempotency belongs in the result
The reservation-creation command accepted an idempotency key. A repeated key for an open reservation returned the existing record and explicitly marked the response as idempotent. A key which referred to a closed reservation produced a conflict instead of silently creating another one.
That makes repetition observable:
{
"status": "pending",
"idempotent": true
}Capture and release did not expose the same distinction consistently. Their responses named the resulting state, but a successful repeated operation could look like an operation which had just changed it.
This is a useful design lesson precisely because the retained interface was not uniform. Named commands and status-bearing responses solved the largest ambiguity. A richer outcome would complete the contract:
{
"command": "release",
"changed": false,
"from": "released",
"to": "released",
"reason": "already_released"
}Do not add fields merely to make every response look elaborate. Add the distinction when a caller, operator, retry policy, or audit trail needs to know whether work happened.
Test the matrix, not the method count
One test for each command covers only the centre column of the design. The important regressions sit at the intersections:
capture pending
capture released
capture expired
release pending
release released
release expired
release captured
repeat reserve with an open key
repeat reserve with a closed keyFor each case, assert more than the returned status:
- whether the state changed;
- the reason for no change or rejection;
- the final reservation state;
- the final related-record state; and
- whether a repeated request created another record.
The public reproduction executes this smaller contract. It models named capture and release commands, explicit transition results, idempotent repetition, rejection, and a generic boolean update which loses the same distinctions. It does not execute the private application or reproduce its persistence and locking.
Use as much machinery as the lifecycle earns
A two-state record may need only a guard:
if ($invoice->paid_at !== null) {
return AlreadyPaid::result();
}A transition table can be a match expression. A larger workflow may justify state objects, a workflow component, or a persisted event model. None of those choices repairs a vague command or an opaque result by itself.
Start with the questions the caller and operator need answered:
What was requested?
What state was observed?
Did it change?
What state committed?
If it did not change, why?Then choose the smallest mechanism which answers them in one place.
A method called release() can still hide too much. A state-machine library can
still return an unhelpful boolean. The useful design is the contract between the
two: a named request and an outcome which tells the truth about what changed.