API / Route Reference
This is a **Laravel** recruitment management system with **4 major route groups
မြန်မာဘာသာဖြင့် ဖတ်ရန်API / Route Reference — Architecture Explanation & Implementation Guide
Overview
This is a Laravel recruitment management system with 4 major route groups:
| Group | Purpose | Middleware |
|---|---|---|
| Auth Routes | Login, logout, password reset | guest (unauthenticated users only) |
| Admin Routes | Dashboard, accounts, mail templates, applications | auth + EnsureAdmin |
| Webhook Routes | Google Form integration (API) | VerifyGoogleFormWebhook + throttle |
| Flash Messages | UI feedback after actions | N/A (reference table) |
The system manages a candidate recruitment pipeline: Apply → Shortlist → IQ Test → Interview 1 → Interview 2 → Hired/Rejected.
Architecture Diagram
graph TB
subgraph "External"
GF["Google Forms"]
end
subgraph "Middleware Layer"
Guest["guest middleware"]
Auth["auth middleware"]
EA["EnsureAdmin"]
EAR["EnsureAdminRole"]
VGF["VerifyGoogleFormWebhook"]
end
subgraph "Auth Routes"
Login["LoginController"]
Forgot["ForgotPasswordController"]
Reset["ResetPasswordController"]
end
subgraph "Admin Routes"
Dashboard["DashboardController"]
Accounts["AccountController"]
MailTpl["MailTemplateController"]
Apps["ApplicationController"]
end
subgraph "Webhook Routes"
WH1["GoogleFormWebhookController"]
WH2["GoogleFormBasicInfoWebhookController"]
end
subgraph "Models"
Admin["Admin model"]
App["Application model"]
MT["MailTemplate model"]
Note["Note model"]
Log["ApplicationLog model"]
end
Guest --> Login
Guest --> Forgot
Guest --> Reset
Auth --> EA --> Dashboard
Auth --> EA --> MailTpl
Auth --> EA --> EAR --> Accounts
Auth --> EA --> Apps
GF --> VGF --> WH1
GF --> VGF --> WH2
1. Auth Routes — Explanation & Implementation
What it does
Standard Laravel authentication: login page, password reset flow, logout.
Key Points
- All routes except
logoutuseguestmiddleware (redirects already-logged-in users away) logoutusesauthmiddleware (only logged-in users can log out)- Uses the
adminstable (not the defaultuserstable)
Implementation
Route Definition (routes/web.php)
<?php
use App\Http\Controllers\Auth\LoginController;
use App\Http\Controllers\Auth\ForgotPasswordController;
use App\Http\Controllers\Auth\ResetPasswordController;
// Root redirect
Route::get('/', fn() => redirect()->route('login'));
// Guest-only routes
Route::middleware('guest')->group(function () {
Route::get('/login', [LoginController::class, 'show'])->name('login');
Route::post('/login', [LoginController::class, 'login']);
Route::get('/forgot-password', [ForgotPasswordController::class, 'show'])
->name('password.request');
Route::post('/forgot-password', [ForgotPasswordController::class, 'sendResetLink'])
->name('password.email');
Route::get('/reset-password/{token}', [ResetPasswordController::class, 'show'])
->name('password.reset');
Route::post('/reset-password', [ResetPasswordController::class, 'reset'])
->name('password.update');
});
// Auth-only
Route::post('/logout', [LoginController::class, 'logout'])
->middleware('auth')
->name('logout');
LoginController (app/Http/Controllers/Auth/LoginController.php)
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class LoginController extends Controller
{
public function show()
{
return view('auth.login');
}
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (Auth::attempt($credentials, $request->boolean('remember'))) {
$request->session()->regenerate();
return redirect()->intended(route('admin.dashboard'));
}
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
])->onlyInput('email');
}
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login');
}
}
2. Admin Routes — The Core System
2.1 Middleware Architecture
graph LR
Request --> Auth["auth middleware<br/>(is user logged in?)"]
Auth --> EnsureAdmin["EnsureAdmin<br/>(is user an Admin model?)"]
EnsureAdmin --> EnsureAdminRole["EnsureAdminRole<br/>(is role = 'admin'?)"]
style Auth fill:#4a9eff,color:#fff
style EnsureAdmin fill:#ff9f43,color:#fff
style EnsureAdminRole fill:#ee5a24,color:#fff
There are two levels of admin access:
| Role | Can Access |
|---|---|
| member | Dashboard, view accounts list, mail templates, applications |
| admin | Everything above + create/edit/delete accounts |
Middleware Implementation
// app/Http/Middleware/EnsureAdmin.php
<?php
namespace App\Http\Middleware;
use Closure;
class EnsureAdmin
{
public function handle($request, Closure $next)
{
if (!auth()->check() || !auth()->user() instanceof \App\Models\Admin) {
abort(403, 'Unauthorized');
}
return $next($request);
}
}
// app/Http/Middleware/EnsureAdminRole.php
<?php
namespace App\Http\Middleware;
use Closure;
use App\Enums\AdminRole;
class EnsureAdminRole
{
public function handle($request, Closure $next)
{
if (auth()->user()->role !== AdminRole::ADMIN) {
abort(403, 'Admin role required');
}
return $next($request);
}
}
2.2 Admin Routes Definition
// routes/web.php (continued)
use App\Http\Controllers\Admin\DashboardController;
use App\Http\Controllers\Admin\AccountController;
use App\Http\Controllers\Admin\MailTemplateController;
use App\Http\Controllers\Admin\ApplicationController;
Route::prefix('admin')
->name('admin.')
->middleware(['auth', EnsureAdmin::class])
->group(function () {
// Dashboard
Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard');
// Accounts — index is accessible to members too
Route::get('/accounts', [AccountController::class, 'index'])
->name('accounts.index');
// Accounts CRUD — admin role only
Route::middleware(EnsureAdminRole::class)->group(function () {
Route::get('/accounts/create', [AccountController::class, 'create'])
->name('accounts.create');
Route::post('/accounts', [AccountController::class, 'store'])
->name('accounts.store');
Route::get('/accounts/{admin}/edit', [AccountController::class, 'edit'])
->name('accounts.edit');
Route::put('/accounts/{admin}', [AccountController::class, 'update'])
->name('accounts.update');
Route::delete('/accounts/{admin}', [AccountController::class, 'destroy'])
->name('accounts.destroy');
});
// Mail Templates (full resource)
Route::resource('mail-templates', MailTemplateController::class);
Route::get('/mail-templates/{mailTemplate}/get', [MailTemplateController::class, 'get'])
->name('mail-templates.get');
// Applications
Route::get('/applications', [ApplicationController::class, 'index'])
->name('applications.index');
Route::get('/applications/{application}', [ApplicationController::class, 'show'])
->name('applications.show');
Route::get('/applications/{application}/export', [ApplicationController::class, 'export'])
->name('applications.export');
// Application Actions (status transitions)
Route::post('/applications/{application}/shortlist', [ApplicationController::class, 'shortlist'])
->name('applications.shortlist');
Route::post('/applications/{application}/send-iq-invite', [ApplicationController::class, 'sendIqInvite'])
->name('applications.send-iq-invite');
Route::post('/applications/{application}/reject', [ApplicationController::class, 'reject'])
->name('applications.reject');
Route::post('/applications/{application}/iq-result', [ApplicationController::class, 'iqResult'])
->name('applications.iq-result');
Route::post('/applications/{application}/interview-1-result', [ApplicationController::class, 'interview1Result'])
->name('applications.interview-1-result');
Route::post('/applications/{application}/interview-2-result', [ApplicationController::class, 'interview2Result'])
->name('applications.interview-2-result');
Route::post('/applications/{application}/hired', [ApplicationController::class, 'hired'])
->name('applications.hired');
// Notes
Route::post('/applications/{application}/notes', [ApplicationController::class, 'storeNote'])
->name('applications.notes');
Route::delete('/applications/{application}/notes/{note}', [ApplicationController::class, 'destroyNote'])
->name('applications.notes.destroy');
// Profile / Info Updates
Route::put('/applications/{application}/basic-info', [ApplicationController::class, 'updateBasicInfo'])
->name('applications.basic-info.update');
Route::put('/applications/{application}/update-details', [ApplicationController::class, 'updateDetails'])
->name('applications.update-details');
Route::put('/applications/{application}/basic-profile', [ApplicationController::class, 'updateBasicProfile'])
->name('applications.update-basic-profile');
});
[!IMPORTANT] The Accounts resource uses
{admin}as the route parameter (not{account}). This means the route model binding will look for theAdminmodel.
2.3 Database Schema (Implied)
Based on the routes, validations, and fields, here's the implied schema:
erDiagram
ADMINS {
bigint id PK
string name
string email UK
string password
enum role "admin | member"
timestamp created_at
timestamp updated_at
}
APPLICATIONS {
bigint id PK
string google_response_id
string full_name
string email
string phone
string nrc
date date_of_birth
string address
string degree
string position
string expected_salary
string cv_original_name
string cv_link
string cv_folder_path
string cv_mime
string cv_folder_link
text basic_info
integer iq_test_score
date iq_test_date
date first_interview_date
date second_interview_date
string status
string reject_reason
json other_files
timestamp applied_at
timestamp basic_info_submitted_at
timestamp created_at
timestamp updated_at
}
MAIL_TEMPLATES {
bigint id PK
string template_name
enum mail_type
string title
text body
boolean is_active
timestamp created_at
timestamp updated_at
}
NOTES {
bigint id PK
bigint application_id FK
bigint admin_id FK
text body
timestamp created_at
timestamp updated_at
}
APPLICATION_LOGS {
bigint id PK
bigint application_id FK
bigint admin_id FK
string action
text note
timestamp created_at
}
APPLICATIONS ||--o{ NOTES : "has many"
APPLICATIONS ||--o{ APPLICATION_LOGS : "has many"
ADMINS ||--o{ NOTES : "created by"
ADMINS ||--o{ APPLICATION_LOGS : "performed by"
2.4 Enums
// app/Enums/AdminRole.php
<?php
namespace App\Enums;
enum AdminRole: string
{
case ADMIN = 'admin';
case MEMBER = 'member';
}
// app/Enums/MailType.php
<?php
namespace App\Enums;
enum MailType: string
{
case IQ_TEST = 'iq_test';
case SHORTLIST = 'shortlist';
case INTERVIEW_1 = 'interview_1';
case INTERVIEW_2 = 'interview_2';
case HIRED = 'hired';
case REJECTED = 'rejected';
// Add more as needed
}
// app/Enums/ApplicationStatus.php
<?php
namespace App\Enums;
enum ApplicationStatus: string
{
case APPLIED = 'applied';
case SHORTLISTED = 'shortlisted';
case IQ_INVITED = 'iq_invited';
case IQ_PASS = 'iq_pass';
case IQ_FAIL = 'iq_fail';
case INTERVIEW_1_PASS = 'interview_1_pass';
case INTERVIEW_1_FAIL = 'interview_1_fail';
case INTERVIEW_2_PASS = 'interview_2_pass';
case INTERVIEW_2_FAIL = 'interview_2_fail';
case HIRED = 'hired';
case REJECTED = 'rejected';
}
2.5 Key Request Validation Examples
// app/Http/Requests/Admin/StoreAccountRequest.php
<?php
namespace App\Http\Requests\Admin;
use App\Enums\AdminRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
class StoreAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true; // Middleware handles authorization
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:admins,email'],
'role' => ['required', new Enum(AdminRole::class)],
'password' => ['required', 'confirmed', Password::defaults()],
];
}
}
// app/Http/Requests/Admin/UpdateAccountRequest.php
<?php
namespace App\Http\Requests\Admin;
use App\Enums\AdminRole;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rules\Enum;
use Illuminate\Validation\Rules\Password;
class UpdateAccountRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255',
'unique:admins,email,' . $this->route('admin')->id],
'role' => ['required', new Enum(AdminRole::class)],
'password' => ['nullable', 'confirmed', Password::defaults()],
];
}
}
2.6 The "Send Only" Pattern — Critical Design Pattern
This is the most complex pattern in the system. When send_only=true is passed in any status-transition request (shortlist, iq-result, interview results, hired, reject), the system does NOT change the application status — it only sends an email.
flowchart TD
Request["POST .../shortlist"]
Check{"send_only=true?"}
Request --> Check
Check -->|No| Normal["1. Validate normally<br/>2. Change status<br/>3. Optionally send mail<br/>4. Log transition"]
Check -->|Yes| SendOnly["1. Validate mail fields<br/>2. Send email only<br/>3. Don't change status<br/>4. Flash success message"]
style SendOnly fill:#ff9f43,color:#fff
style Normal fill:#4a9eff,color:#fff
Implementation Pattern
// In ApplicationController
public function shortlist(ShortlistRequest $request, Application $application)
{
// Check if this is a "send only" request
if ($request->boolean('send_only')) {
return $this->handleSendOnly($request, $application, MailType::SHORTLIST);
}
// Normal flow: change status + optionally send mail
$application->transitionTo(ApplicationStatus::SHORTLISTED);
if ($request->filled('mail_template_id')) {
$this->sendMail($application, $request->mail_template_id);
}
$this->logAction($application, 'shortlisted');
return back()->with('success', 'Candidate shortlisted.');
}
protected function handleSendOnly(Request $request, Application $application, MailType $type)
{
try {
$template = MailTemplate::where('id', $request->mail_template_id)
->where('mail_type', $type)
->where('is_active', true)
->firstOrFail();
// Send the email with optional subject/body overrides and attachments
$this->sendMailWithTemplate($application, $template, $request);
return back()->with('success', $this->getMailSuccessMessage($type));
} catch (\Exception $e) {
return back()->with('error', "Failed to send email: {$e->getMessage()}");
}
}
2.7 Application Status State Machine
stateDiagram-v2
[*] --> applied : Google Form Webhook
applied --> shortlisted : shortlist
applied --> rejected : reject
shortlisted --> iq_invited : send-iq-invite
shortlisted --> rejected : reject
iq_invited --> iq_pass : iq-result (pass)
iq_invited --> iq_fail : iq-result (fail)
iq_invited --> rejected : reject
iq_pass --> interview_1_pass : interview-1-result (pass)
iq_pass --> interview_1_fail : interview-1-result (fail)
iq_pass --> hired : hired (after 1st interview)
iq_pass --> rejected : reject
interview_1_pass --> interview_2_pass : interview-2-result (pass)
interview_1_pass --> interview_2_fail : interview-2-result (fail)
interview_1_pass --> hired : hired (after 1st interview)
interview_1_pass --> rejected : reject
interview_2_pass --> hired : hired (after 2nd interview)
interview_2_pass --> rejected : reject
iq_fail --> rejected : reject
interview_1_fail --> rejected : reject
interview_2_fail --> rejected : reject
hired --> [*]
rejected --> [*]
[!WARNING] Invalid transitions should throw an exception (e.g., "Cannot transition from X to Y"). This should be implemented in the
Applicationmodel'stransitionTo()method.
2.8 Dashboard Controller
// app/Http/Controllers/Admin/DashboardController.php
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\DashboardRequest;
use App\Models\Application;
class DashboardController extends Controller
{
public function index(DashboardRequest $request)
{
$year = $request->input('year', now()->year);
$month = $request->input('month', now()->month);
$position = $request->input('position');
// Query applications with filters
$query = Application::query()
->whereYear('applied_at', $year)
->whereMonth('applied_at', $month);
if ($position) {
$query->where('position', $position);
}
// Aggregate stats by status for the dashboard
$stats = $query->get()->groupBy('status')->map->count();
return view('admin.dashboard', compact('stats', 'year', 'month', 'position'));
}
}
2.9 Account Controller (with Delete Guards)
// app/Http/Controllers/Admin/AccountController.php (partial — destroy method)
public function destroy(Admin $admin)
{
// Guard 1: Cannot delete yourself
if ($admin->id === auth()->id()) {
return back()->with('error', 'You cannot delete your own account.');
}
// Guard 2: Must keep at least one admin
if ($admin->role === AdminRole::ADMIN) {
$adminCount = Admin::where('role', AdminRole::ADMIN)->count();
if ($adminCount <= 1) {
return back()->with('error', 'At least one admin role account must remain.');
}
}
$admin->delete();
return back()->with('success', 'Account deleted.');
}
3. Webhook Routes — Google Form Integration
What it does
Receives data from Google Forms (via Apps Script) to automatically create candidate applications.
Route Definition (routes/api.php)
<?php
use App\Http\Controllers\Api\GoogleFormWebhookController;
use App\Http\Controllers\Api\GoogleFormBasicInfoWebhookController;
Route::prefix('webhooks')
->middleware(['App\Http\Middleware\VerifyGoogleFormWebhook', 'throttle:60,1'])
->group(function () {
Route::post('/google-form', GoogleFormWebhookController::class);
Route::post('/google-form/basic-info', GoogleFormBasicInfoWebhookController::class);
});
Webhook Authentication Middleware
// app/Http/Middleware/VerifyGoogleFormWebhook.php
<?php
namespace App\Http\Middleware;
use Closure;
class VerifyGoogleFormWebhook
{
public function handle($request, Closure $next)
{
$secret = $request->header('X-Webhook-Secret');
$expected = config('mtm.google_form_webhook_secret');
if (!$secret || $secret !== $expected) {
return response()->json(['message' => 'Unauthorized'], 401);
}
return $next($request);
}
}
Main Webhook Controller
// app/Http/Controllers/Api/GoogleFormWebhookController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\GoogleFormWebhookRequest;
use App\Models\Application;
class GoogleFormWebhookController extends Controller
{
public function __invoke(GoogleFormWebhookRequest $request)
{
$application = Application::create([
'google_response_id' => $request->google_response_id,
'full_name' => $request->full_name,
'email' => $request->email,
'phone' => $request->phone,
'nrc' => $request->nrc,
'date_of_birth' => $request->date_of_birth,
'address' => $request->address,
'degree' => $request->degree,
'position' => $request->position,
'expected_salary' => $request->expected_salary,
'cv_original_name' => $request->cv_original_name,
'cv_link' => $request->cv_link,
'cv_folder_path' => $request->cv_folder_path,
'cv_mime' => $request->cv_mime,
'cv_folder_link' => $request->cv_folder_link,
'other_files' => $request->other_files,
'applied_at' => $request->submitted_at ?? now(),
'status' => 'applied',
]);
return response()->json([
'message' => 'Application received',
'application_id' => $application->id,
'status' => 'applied',
], 201);
}
}
Basic Info Webhook — Lookup Logic
// app/Http/Controllers/Api/GoogleFormBasicInfoWebhookController.php
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\GoogleFormBasicInfoRequest;
use App\Models\Application;
class GoogleFormBasicInfoWebhookController extends Controller
{
public function __invoke(GoogleFormBasicInfoRequest $request)
{
// Find application by ID or email
$application = null;
if ($request->filled('application_id')) {
$application = Application::find($request->application_id);
} elseif ($request->filled('email')) {
$application = Application::where('email', $request->email)
->latest('applied_at')
->first();
}
if (!$application) {
return response()->json(['message' => 'Application not found'], 422);
}
$application->update([
'basic_info' => json_encode($request->basic_info_text),
'basic_info_submitted_at' => $request->submitted_at ?? now(),
]);
return response()->json([
'message' => 'Basic info saved',
'application_id' => $application->id,
'basic_info_submitted_at' => $application->basic_info_submitted_at,
], 200);
}
}
4. Project File Structure
Here's the recommended file organization:
app/
├── Enums/
│ ├── AdminRole.php
│ ├── ApplicationStatus.php
│ └── MailType.php
├── Http/
│ ├── Controllers/
│ │ ├── Auth/
│ │ │ ├── LoginController.php
│ │ │ ├── ForgotPasswordController.php
│ │ │ └── ResetPasswordController.php
│ │ ├── Admin/
│ │ │ ├── DashboardController.php
│ │ │ ├── AccountController.php
│ │ │ ├── MailTemplateController.php
│ │ │ └── ApplicationController.php
│ │ └── Api/
│ │ ├── GoogleFormWebhookController.php
│ │ └── GoogleFormBasicInfoWebhookController.php
│ ├── Middleware/
│ │ ├── EnsureAdmin.php
│ │ ├── EnsureAdminRole.php
│ │ └── VerifyGoogleFormWebhook.php
│ └── Requests/
│ ├── Admin/
│ │ ├── DashboardRequest.php
│ │ ├── StoreAccountRequest.php
│ │ ├── UpdateAccountRequest.php
│ │ ├── StoreMailTemplateRequest.php
│ │ ├── UpdateMailTemplateRequest.php
│ │ ├── ShortlistRequest.php
│ │ ├── IqResultRequest.php
│ │ ├── InterviewResultRequest.php
│ │ ├── HiredRequest.php
│ │ ├── RejectRequest.php
│ │ ├── UpdateDetailsRequest.php
│ │ ├── UpdateBasicInfoRequest.php
│ │ ├── StoreNoteRequest.php
│ │ └── SendMailRequest.php
│ └── Api/
│ ├── GoogleFormWebhookRequest.php
│ └── GoogleFormBasicInfoRequest.php
├── Models/
│ ├── Admin.php
│ ├── Application.php
│ ├── MailTemplate.php
│ ├── Note.php
│ └── ApplicationLog.php
├── Mail/
│ └── CandidateMail.php
└── Services/
├── ApplicationService.php (status transitions logic)
└── MailService.php (email sending with template rendering)
config/
└── mtm.php (google_form_webhook_secret, etc.)
database/
└── migrations/
├── create_admins_table.php
├── create_applications_table.php
├── create_mail_templates_table.php
├── create_notes_table.php
└── create_application_logs_table.php
resources/views/
├── auth/
│ ├── login.blade.php
│ ├── forgot-password.blade.php
│ └── reset-password.blade.php
├── admin/
│ ├── dashboard.blade.php
│ ├── accounts/
│ │ ├── index.blade.php
│ │ ├── create.blade.php
│ │ └── edit.blade.php
│ ├── mail-templates/
│ │ ├── index.blade.php
│ │ ├── create.blade.php
│ │ └── edit.blade.php
│ └── applications/
│ ├── index.blade.php
│ └── show.blade.php
└── layouts/
└── admin.blade.php
routes/
├── web.php (Auth + Admin routes)
└── api.php (Webhook routes)
5. Implementation Order (Recommended)
[!TIP] Build in this order to avoid dependency issues:
| Step | What to Build | Why First |
|---|---|---|
| 1 | Migrations + Models + Enums | Everything depends on the database schema |
| 2 | Config (config/mtm.php) | Webhook secret needed early |
| 3 | Middleware (3 files) | Routes need middleware registered |
| 4 | Auth Controllers + Views | You need to log in to test anything |
| 5 | Admin Layout (layouts/admin.blade.php) | All admin views extend this |
| 6 | Dashboard | Simple starting point after login |
| 7 | Accounts CRUD | Manage who can access the system |
| 8 | Mail Templates CRUD | Needed before application actions |
| 9 | Application Index + Show | View candidates |
| 10 | Status Transition Actions | The core workflow |
| 11 | Send Only Pattern | Email-only actions |
| 12 | Notes CRUD | Add notes to applications |
| 13 | Webhook Controllers | Google Form integration |
| 14 | Export Feature | PDF/Excel export |
6. Key Design Patterns Used
Pattern 1: Form Request Validation
All validation is extracted into dedicated FormRequest classes, keeping controllers clean.
Pattern 2: State Machine for Status
Applications follow a strict state machine. Invalid transitions throw exceptions. This prevents data corruption (e.g., you can't mark someone "hired" if they haven't passed an IQ test).
Pattern 3: Send Only Pattern
A single mechanism that lets admins re-send emails without changing candidate status. Useful when an email bounced or needs to be re-sent with different content.
Pattern 4: Template-based Emails
Email content comes from database-stored templates with placeholders like {{candidate_name}}. This allows admins to edit email content without code changes.
Pattern 5: Action Logging
Every status change is logged with the admin who performed it, creating an audit trail.
Pattern 6: Soft Authorization Layers
Two middleware layers (EnsureAdmin + EnsureAdminRole) provide granular access control without complex permission systems.