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() === [];
}
}
<?php
namespace App\Hiring;
use App\Models\Applicant;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class StageMover
{
public function move(int $applicantId, Stage $to, ?int $byUserId = null, ?string $reason = null): Applicant
{
return DB::transaction(function () use ($applicantId, $to, $byUserId, $reason) {
// Lock the row so two reviewers clicking at once cannot both win.
$applicant = Applicant::whereKey($applicantId)->lockForUpdate()->firstOrFail();
$from = Stage::from($applicant->stage);
if ($from === $to) {
return $applicant; // idempotent
}
if (! $from->canMoveTo($to)) {
throw ValidationException::withMessages([
'stage' => "Cannot move from {$from->value} to {$to->value}.",
]);
}
if ($to === Stage::Rejected && blank($reason)) {
throw ValidationException::withMessages(['reason' => 'A reason is required to reject.']);
}
$applicant->forceFill(['stage' => $to->value, 'stage_entered_at' => now()])->save();
// Immutable history: never updated, only inserted.
$applicant->moves()->create([
'from_stage' => $from->value,
'to_stage' => $to->value,
'user_id' => $byUserId,
'reason' => $reason,
]);
return $applicant;
});
}
}
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
Offerstage guard, for example requiring an accepted offer beforeHired. - 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
- Add
stage(string, defaultapplied) andstage_entered_atto yourapplicantstable, plus amovesrelation backed by a history table withfrom_stage,to_stage,user_id,reason. - Put both files in
app/Hiring/. - Move an applicant:
app(StageMover::class)->move($id, Stage::Interview, auth()->id()); - Reject with a reason:
->move($id, Stage::Rejected, auth()->id(), 'Role filled'); - Catch
ValidationExceptionin 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.