Intermediate · 45 MINUTES · VERSION 1
PHP enums and explicit state transitions
Distinguish recognized state values from permitted changes and keep authorization/concurrency separate.
Reviewed 23 Oct 2026Your setup may differ. Windows, Ubuntu, and optional tool bundles →
What you will learn
- Represent a closed set with a string-backed enum.
- Validate unknown input before creating a state.
- Separate valid values, allowed transitions and write permission.
Theory: values are not actions
An enum names a finite set of cases. A backed enum gives each case a scalar value for boundaries such as JSON or database fields. An arbitrary string is not automatically an enum case.
A valid destination value does not make every change valid. This exercise permits Todo→InProgress and InProgress→Done only. Skipping, moving backward and self-transitions are forbidden. These are explicit practice rules, not universal task-product rules.
An enum does not authorize a user, write a database or prevent concurrent updates. A real handler still needs permission checks and a deliberate transaction, stale-write and idempotency policy.
Worked example: an explicit transition table
Required: PHP 8.4+ CLI. Save as task_states.php in a personal folder. No framework/database/paid service. Laragon/XAMPP are optional.
<?php
declare(strict_types=1);
enum WorkState: string
{
case Todo = 'todo';
case InProgress = 'in_progress';
case Done = 'done';
public function permits(self $next): bool
{
return match ($this) {
self::Todo => $next === self::InProgress,
self::InProgress => $next === self::Done,
self::Done => false,
};
}
}
function readWorkState(mixed $input): WorkState
{
if (! is_string($input)) {
throw new InvalidArgumentException('State must be text.');
}
$state = WorkState::tryFrom($input);
if ($state === null) {
throw new InvalidArgumentException('Use todo, in_progress, or done.');
}
return $state;
}
function moveWorkState(WorkState $current, WorkState $next): WorkState
{
if (! $current->permits($next)) {
throw new DomainException('Transition is not allowed.');
}
return $next;
}
if (realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
$current = readWorkState('todo');
$next = moveWorkState($current, WorkState::InProgress);
echo 'Before: '.$current->value.PHP_EOL;
echo 'Next: '.$next->value.PHP_EOL;
try {
moveWorkState(WorkState::Todo, WorkState::Done);
} catch (DomainException $error) {
echo 'Rejected: '.$error->getMessage().PHP_EOL;
}
}
Run and inspect the state
Windows PowerShell:
php ./task_states.php
Ubuntu Bash:
php ./task_states.php
Before: todo
Next: in_progress
Rejected: Transition is not allowed.
PHP must be on PATH. Source is common to both shells. Windows PHP 8.4.26 was executed; Ubuntu was not.
Parsing checks the boundary type before tryFrom. Unknown text yields null and a useful rejection. The match handles each current case. A permitted move returns a case; the original variable remains Todo. The guard prevents demo output when tests require the file.
Guided lab
- Write the three states and all nine current/next pairs before running. Predict the two accepted pairs.
- Parse exact backing strings. Reject wrong case, whitespace, unknown strings, arrays, null, integers and booleans.
- Test every pair against your table. Verify rejected moves throw and the original case stays unchanged.
- Explain why a valid Done case cannot be reached directly from Todo.
- Consider a repeated request: this function rejects a self-transition. Request idempotency is separate; do not claim it was solved here.
Independent exercise
Add Cancelled: Todo and InProgress may cancel; Done and Cancelled are terminal. Update parsing and the full four-by-four transition matrix. Do not permit every move merely because both cases exist.
Add display labels separate from stable backing values. Hint: user-facing wording may change without changing persisted IDs. Implement this in a separate practice copy, keeping its chosen rules explicit.
Common mistakes and interview
Avoid silent type coercion, confusing enum membership with a permitted move, saving display labels as IDs, adding cases without updating match rules, and claiming enum validation prevents a race condition.
Interview: “Where do you enforce a state change?” Explain parsing, transition rules, authorization, persisted concurrency and repeated-request handling as different responsibilities.
English practice and glossary
Enum: a finite named case set. Backing value: a case’s stable scalar. Transition: a permitted state change. Terminal state: no outgoing moves under chosen rules. Boundary: where external data enters. Exhaustive match: handling each current case.
Say: “The value is recognized, but the action is rejected because __. I tested __. Permission and persistence are checked by __.” Save your test matrix in the journal.
Recap and next step
Use a closed value set and explicit rules for changes. Keep permissions and durable updates separate. Next learn generators and the difference between creating an iterator and completing its work.
Reveal exercise hints and self-check answers
Only Todo→InProgress and InProgress→Done succeed. Unknown or non-string input fails. A valid Done case does not permit a direct Todo→Done move. No database or user permission changes.
Exercise outline: add Cancelled with a stable backing string, make it terminal, explicitly allow cancellation from the two selected sources and test every pair. Keep labels and retry semantics separate.