Skip to content

How-to: Approval Workflow API ​

FT reference: FT68 (NENE2-FT/approvallog) — Approval Workflow API

Demonstrates a multi-step approval workflow where a request moves through defined states (Draft → Submitted → UnderReview → Approved/Rejected). Invalid transitions return 409 Conflict. The state machine is encoded directly in the ApprovalStatus backed enum using an allowedTransitions() method.


Workflow states ​

Draft ──submit──▶ Submitted ──review──▶ UnderReview
                                              │
                                    ┌─approve─┤─reject─┐
                                    ▼                   ▼
                                 Approved            Rejected
                                                        │
                                                    ─rework─▶ Draft
StateDescription
draftCreated but not yet submitted
submittedAwaiting review assignment
under_reviewReviewer assigned and reviewing
approvedFinal approval given
rejectedRejected with a mandatory reason

A rejected request can be reworked (returned to draft) for revision and resubmission. An approved request has no further transitions.


Transition rules encoded in the enum ​

State transition rules live inside the enum — not in the repository or controller:

php
enum ApprovalStatus: string
{
    case Draft       = 'draft';
    case Submitted   = 'submitted';
    case UnderReview = 'under_review';
    case Approved    = 'approved';
    case Rejected    = 'rejected';

    /** @return list<self> */
    public function allowedTransitions(): array
    {
        return match ($this) {
            self::Draft       => [self::Submitted],
            self::Submitted   => [self::UnderReview],
            self::UnderReview => [self::Approved, self::Rejected],
            self::Approved    => [],
            self::Rejected    => [self::Draft],   // rework path
        };
    }

    public function canTransitionTo(self $target): bool
    {
        return in_array($target, $this->allowedTransitions(), true);
    }
}

canTransitionTo() is the single source of truth for whether a transition is valid. Adding a new allowed transition means updating only this one method.


Routes ​

MethodPathDescription
POST/requestsCreate a draft request
GET/requestsList all requests (?status= filter)
GET/requests/{id}Get a single request
POST/requests/{id}/submitDraft → Submitted
POST/requests/{id}/reviewSubmitted → UnderReview (assigns reviewer)
POST/requests/{id}/approveUnderReview → Approved
POST/requests/{id}/rejectUnderReview → Rejected (reason required)
POST/requests/{id}/reworkRejected → Draft (clears reviewer/note)

Guarding transitions in the repository ​

The repository checks canTransitionTo() before running the UPDATE query:

php
public function submit(int $id, string $now): ?ApprovalRequest
{
    $req = $this->findById($id);

    if ($req === null || !$req->status->canTransitionTo(ApprovalStatus::Submitted)) {
        return null;   // caller maps null → 409 Conflict
    }

    $this->db->execute(
        "UPDATE requests SET status = 'submitted', submitted_at = ?, updated_at = ? WHERE id = ?",
        [$now, $now, $id],
    );

    return $this->findById($id);
}

Returning null for both "not found" and "invalid transition" is a deliberate simplification. In production, distinguish between 404 (not found) and 409 (found but invalid transition) by returning a typed result or throwing domain exceptions.

The controller maps null → 409 Conflict:

php
private function submit(ServerRequestInterface $request): ResponseInterface
{
    $id  = (int) ($params['id'] ?? 0);
    $req = $this->repo->submit($id, $now);

    if ($req === null) {
        return $this->problems->create(
            $request,
            'conflict',
            'Request not found or cannot be submitted from its current status.',
            409,
            '',
        );
    }

    return $this->json->create($req->toArray());
}

Rejection requires a reason ​

The reject transition requires both reviewer and note:

php
private function reject(ServerRequestInterface $request): ResponseInterface
{
    $reviewer = isset($body['reviewer']) && is_string($body['reviewer']) ? trim($body['reviewer']) : '';
    $note     = isset($body['note']) && is_string($body['note']) ? trim($body['note']) : '';

    if ($reviewer === '' || $note === '') {
        $errors = [];
        if ($reviewer === '') {
            $errors[] = ['field' => 'reviewer', 'code' => 'required', 'message' => 'reviewer is required.'];
        }
        if ($note === '') {
            $errors[] = ['field' => 'note', 'code' => 'required', 'message' => 'note (rejection reason) is required.'];
        }

        return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, compact('errors'));
    }
    // ...
}

Reject with no reason is rejected (422). Approve with no note is allowed — the note field is optional for approvals.


Rework: clearing the review state ​

When a rejected request is reworked, the reviewer and review note are cleared so the next reviewer starts fresh:

php
// Repository: rework (Rejected → Draft)
$this->db->execute(
    "UPDATE requests SET status = 'draft', reviewer = NULL, review_note = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?",
    [$now, $id],
);

The submitted_at timestamp is preserved — it records when the request was first submitted, not the current cycle.


Schema ​

sql
CREATE TABLE requests (
    id           INTEGER PRIMARY KEY AUTOINCREMENT,
    title        TEXT    NOT NULL,
    submitter    TEXT    NOT NULL,
    status       TEXT    NOT NULL DEFAULT 'draft',
    reviewer     TEXT,              -- NULL until review starts
    review_note  TEXT,             -- NULL until reviewed
    submitted_at TEXT,             -- NULL until submitted
    reviewed_at  TEXT,             -- NULL until approved/rejected
    created_at   TEXT    NOT NULL,
    updated_at   TEXT    NOT NULL
);

Nullable columns (reviewer, review_note, submitted_at, reviewed_at) are cleared to NULL on rework, keeping the schema clean without adding a rework_count column.

Enhancement: add a CHECK(status IN ('draft','submitted','under_review','approved','rejected')) as a DB-level backstop to match the enum values.


Status filter on list endpoint ​

php
private function list(ServerRequestInterface $request): ResponseInterface
{
    $params    = $request->getQueryParams();
    $statusRaw = isset($params['status']) && is_string($params['status']) ? $params['status'] : null;
    $status    = $statusRaw !== null ? ApprovalStatus::tryFrom($statusRaw) : null;

    if ($statusRaw !== null && $status === null) {
        return $this->problems->create($request, 'validation-failed', 'Validation Failed', 422, null, [
            'errors' => [['field' => 'status', 'code' => 'invalid_value', 'message' => 'Invalid status value.']],
        ]);
    }

    $requests = $this->repo->listByStatus($status);
    // ...
}

ApprovalStatus::tryFrom() returns null for unknown status strings → 422. When $statusRaw === null (no filter), all requests are returned.


Adding a new transition ​

To add a cancelled state that can be reached from any non-terminal state:

  1. Add case Cancelled = 'cancelled'; to ApprovalStatus.
  2. Update allowedTransitions() for Draft, Submitted, and UnderReview to include self::Cancelled.
  3. Add POST /requests/{id}/cancel route and handler.
  4. Write the DB UPDATE in the repository.
  5. Update the schema CHECK constraint (if added).

The enum is the single source of truth — no other files need to change to add the transition guard.


Released under the MIT License.