DEV3 Technologies home

Applicant pipeline state machine

A backed enum with an allowed-transitions map and a locked mover that enforces rules, requires a reason for rejection and writes a history row.

LaravelDatabasestate-machineenumworkflowtransactions

Code

<?php

namespace App\Hiring;

enum Stage: string
{
    case Applied = 'applied';
    case Screening = 'screening';
    case Interview = 'interview';
    case Offer = 'offer';
    case Hired = 'hired';
    case Rejected = 'rejected';

    /** @return list<self> stages reachable from this one */
    public function next(): array
    {
        return match ($this) {
            self::Applied => [self::Screening, self::Rejected],
            self::Screening => [self::Interview, self::Rejected],
            self::Interview => [self::Offer, self::Screening, self::Rejected],
            self::Offer => [self::Hired, self::Interview, self::Rejected],
            self::Hired, self::Rejected => [],
        };
    }

    public function canMoveTo(self $to): bool
    {
        return in_array($to, $this->next(), true);
    }

    public function isFinal(): bool
    {
        return $this->next() === [];
    }
}

How it works

What it solves

Hiring pipelines look like a list of columns, but in practice some moves are not allowed: nobody should jump from "applied" straight to "hired", and a rejected candidate should not quietly reappear. Encoding the rules in one enum keeps controllers, API endpoints and bulk actions consistent.

How it works

Stage is a string-backed enum. next() returns a match expression listing the stages reachable from each case, and canMoveTo() checks membership. Terminal stages return an empty list, so isFinal() is derived instead of being stored twice.

StageMover::move() wraps the change in a transaction and reads the row with lockForUpdate(). Moving to the stage the applicant is already in returns early, which makes retries and double clicks harmless. Illegal transitions and a missing rejection reason raise a ValidationException, so the same code works for web forms and JSON APIs. After saving, a history row records who moved whom, from where, to where and why. That table is only ever inserted into, which gives you a reliable timeline and time-in-stage reports.

Adapting it

  • Add a column on the Offer stage guard, for example requiring an accepted offer before Hired.
  • Add a dispatch of an event or an email after the save, using DB::afterCommit.
  • Make the map configurable per pipeline by loading transitions from the database.

Pitfalls

  • Store the stage as a plain string column, not a database enum, so adding a case needs no migration.
  • Fire emails after the transaction commits, not inside it.

How to use it

  1. Add stage (string, default applied) and stage_entered_at to your applicants table, plus a moves relation backed by a history table with from_stage, to_stage, user_id, reason.
  2. Put both files in app/Hiring/.
  3. Move an applicant: app(StageMover::class)->move($id, Stage::Interview, auth()->id());
  4. Reject with a reason: ->move($id, Stage::Rejected, auth()->id(), 'Role filled');
  5. Catch ValidationException in your controller to show the message.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets