03Case study
Derived workflow state
An HR appraisal objective-setting workflow whose stages are computed from evidence instead of stored in a status column.
- The problem
- An HR objectives workflow where a stored status column would drift away from what actually happened.
- What I built
- Five stages derived from timestamps, a submission window that fails closed, and visibility scoped to each manager’s reporting line.
- The result
- Used by HR across the group. The stage on screen cannot disagree with the record, because it is computed from it.
Primary author. About 85% of the subsystem: 2,334 of 2,731 lines across 17 files, plus 2,725 of 5,698 lines of its front end.
Problem
HR runs an appraisal objective-setting workflow across the group. Employees write objectives and submit them, managers review them, and the whole thing moves through five stages.
The obvious implementation is a status column, and it is also the one that rots. A status column is a second copy of something the timestamps already prove, and every code path that forgets to update it leaves a record whose displayed stage disagrees with what actually happened. On appraisal data that is not a cosmetic bug, because the stage is what decides who is allowed to edit what.
Constraint
Three things shaped the design. The stage shown to a user has to be defensible from evidence, because people argue about appraisals and someone will eventually ask why a record says what it says. The submission window is a hard deadline, and once it closes nothing may change. And the data is salary-adjacent, so a manager must see their own reporting line and nothing else, on every query, including the one endpoint somebody adds in a hurry six months from now.
Approach
The five stages — draft, submitted, manager-reviewed, senior-reviewed, and read-only once the window closes — are derived from the submission and review timestamps rather than stored. Submission and review are already recorded as facts, each with a time on it. The stage is a function of those facts, computed on read. There is no status column to drift, no migration to backfill when the workflow changes, and no way for the displayed stage to disagree with the evidence, because it is the evidence.
Middleware closes the submission window. Once the deadline passes, everything becomes read-only. The decision worth naming is that it fails closed: if the check itself errors, it locks rather than letting the write through. Fail-open would mean a bug in a date comparison could quietly reopen an appraisal cycle, with nobody finding out until the numbers had already been used.
Visibility lives in a hierarchy-aware Eloquent global scope. Every manager sees their own reporting line, and no controller performs a per-query permission check — there is none to forget. Getting this wrong leaks salary-adjacent information about someone's colleagues, and that is the kind of rule that belongs in one place rather than repeated across controllers with slightly different conditions.
Trade-off
Deriving the stage costs a little more computation on every read than reading a column would. For this workload that is a fair trade, because the cost on the other side is a field that is allowed to be wrong.
The larger trade-off is that the derivation rules live in code rather than in data. Changing how the workflow behaves means a deploy rather than a configuration edit, and HR cannot reshape the stages without an engineer. I would make the same call again: the rules are stable, and correctness mattered more than configurability.
Outcome
It is used by HR across the group. There is no class of bug where the displayed stage disagrees with the record, because that state is not representable, and access to another manager's reporting line is decided in one scope instead of at every call site.
- Lines
- 2,334 / 2,731
- Workflow stages
- 5
- Status columns
- 0
Screens

Approved and locked — the stage shown here is derived, not stored. The AI quality check underneath is advisory only: it flags objectives that are not SMART and never blocks a submission. 
Submission progress across one manager’s reporting line, and a single reminder for everyone still outstanding. Staging data.
/**
* The stage is derived, never stored.
*
* There is no status column to drift, no backfill migration, and no way for
* the displayed stage to disagree with the timestamps that prove it.
*/
public function stage(): Stage
{
return match (true) {
$this->senior_reviewed_at !== null => Stage::SeniorReviewed,
$this->manager_reviewed_at !== null => Stage::ManagerReviewed,
$this->submitted_at !== null => Stage::Submitted,
default => Stage::Draft,
};
}Paraphrased from a private repository — names changed, logic preserved.
- Laravel
- PHP
- Eloquent Global Scopes
- Middleware
- Domain Modelling
- PHPUnit
Need something like this built?
Taking freelance work · ~20h/week.