HRM Module Implementation Guide
Version: 2.0 (Gold Standard Aligned) Last Updated: 2025-01-19 Reference: Operations Module Status: 📋 Implementation Guide
Overview
The HRM module manages employee lifecycle, compensation, leave, and performance. ALL monetary values (salary, bonuses, deductions) MUST use Money VO.
Gold Standard Reference: Follow Operations module patterns exactly.
Core Entities & Monetary Fields
Employee (HAS Money Fields) ✅
- base_salary - Employee base salary (Money VO)
- hourly_rate - For hourly employees (Money VO)
Payroll (HAS Money Fields) ✅
- gross_pay - Total earnings (Money VO)
- deductions - Total deductions (Money VO)
- net_pay - Take-home pay (Money VO)
Leave (NO Money Fields)
- Tracks time off, not monetary values
Module Structure
app/Modules/HRM/
├── Domain/
│ ├── Models/
│ │ ├── Employee.php ✅ Money VO for salary
│ │ ├── Department.php
│ │ ├── Position.php ✅ Money VO for salary range
│ │ ├── Leave.php
│ │ ├── TimeEntry.php
│ │ └── Performance.php
│ ├── Enums/
│ │ ├── EmploymentStatus.php
│ │ ├── EmploymentType.php
│ │ ├── LeaveStatus.php
│ │ └── PerformanceRating.php
│ ├── ValueObjects/
│ │ ├── EmployeeNumber.php
│ │ └── SalaryRange.php ✅ Uses Money VO
│ ├── Events/
│ │ ├── EmployeeHired.php
│ │ ├── EmployeeTerminated.php
│ │ ├── SalaryAdjusted.php ✅ Money VO in event
│ │ └── LeaveApproved.php
│ ├── Services/
│ │ ├── EmployeeOnboardingService.php
│ │ └── PayrollCalculationService.php ✅ Money VO calculations
│ └── Contracts/
│ ├── EmployeeRepositoryInterface.php
│ └── LeaveRepositoryInterface.php
│
├── Application/
│ ├── Commands/
│ │ ├── HireEmployeeCommand.php
│ │ ├── HireEmployeeCommandHandler.php
│ │ ├── AdjustSalaryCommand.php ✅ Money VO
│ │ ├── AdjustSalaryCommandHandler.php
│ │ ├── TerminateEmployeeCommand.php
│ │ └── ApproveLeaveCommand.php
│ ├── Queries/
│ │ ├── GetEmployeesQuery.php
│ │ ├── GetEmployeeByIdQuery.php
│ │ └── GetLeaveBalanceQuery.php
│ ├── DTOs/
│ │ ├── Employee/
│ │ │ ├── HireEmployeeDTO.php ✅ Money conversion
│ │ │ └── AdjustSalaryDTO.php ✅ Money conversion
│ │ └── Leave/
│ │ └── CreateLeaveDTO.php
│ └── Services/
│ ├── EmployeeService.php
│ └── LeaveService.php
│
└── Presentation/
├── Controllers/
│ ├── Employees/
│ │ ├── EmployeeController.php
│ │ └── EmployeeTerminateController.php
│ └── Leave/
│ └── LeaveController.php
└── Resources/
├── EmployeeResource.php ✅ Money to float
└── LeaveResource.phpStep 1: Domain Layer
1.1 Create Enums
EmploymentStatus.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Domain\Enums;
enum EmploymentStatus: string
{
case ACTIVE = 'active';
case ON_LEAVE = 'on_leave';
case SUSPENDED = 'suspended';
case TERMINATED = 'terminated';
case RESIGNED = 'resigned';
}EmploymentType.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Domain\Enums;
enum EmploymentType: string
{
case FULL_TIME = 'full_time';
case PART_TIME = 'part_time';
case CONTRACT = 'contract';
case INTERN = 'intern';
}1.2 Create Domain Models
Employee.php (with Money VO for salary)
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Domain\Models;
use App\Shared\Domain\ValueObjects\Money;
use App\Shared\Infrastructure\Eloquent\Casts\MoneyCast;
use App\Modules\HRM\Domain\Enums\EmploymentStatus;
use App\Modules\HRM\Domain\Enums\EmploymentType;
use App\Modules\HRM\Domain\Events\EmployeeHired;
use App\Modules\HRM\Domain\Events\SalaryAdjusted;
use App\Modules\HRM\Domain\Events\EmployeeTerminated;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
use Illuminate\Database\Eloquent\Model;
/**
* @property int $id
* @property string $employeeNumber
* @property string $firstName
* @property string $lastName
* @property EmploymentStatus $status
* @property EmploymentType $employmentType
* @property Money $baseSalary ✅ Money VO
* @property Money|null $hourlyRate ✅ Money VO
*/
final class Employee extends Model
{
use HasFactory, UsesTenantConnection, Auditable;
protected $table = 'hrm_employees';
protected $fillable = [
'employee_number',
'first_name',
'last_name',
'email',
'status',
'employment_type',
'base_salary',
'hourly_rate',
'hire_date',
'department_id',
'position_id',
'manager_id',
];
protected function casts(): array
{
return [
'status' => EmploymentStatus::class,
'employment_type' => EmploymentType::class,
'base_salary' => MoneyCast::class, // ✅ Money VO
'hourly_rate' => MoneyCast::class, // ✅ Money VO (nullable)
'hire_date' => 'date',
'termination_date' => 'date',
];
}
protected static function boot()
{
parent::boot();
static::creating(function ($employee) {
if (empty($employee->employee_number)) {
$employee->employee_number = static::generateEmployeeNumber();
}
});
static::created(function ($employee) {
EmployeeHired::dispatch(
$employee->id,
$employee->employee_number,
$employee->firstName,
$employee->lastName,
$employee->email,
now()
);
});
}
// ✅ Domain method to adjust salary
public function adjustSalary(Money $newSalary, string $reason): self
{
$previousSalary = $this->baseSalary;
$this->baseSalary = $newSalary;
$this->save();
SalaryAdjusted::dispatch(
$this->id,
$this->employee_number,
$previousSalary,
$newSalary,
$reason,
now()
);
return $this;
}
// ✅ Domain method to terminate
public function terminate(string $reason): self
{
if ($this->status === EmploymentStatus::TERMINATED) {
throw new EmployeeAlreadyTerminatedException();
}
$this->status = EmploymentStatus::TERMINATED;
$this->termination_date = now();
$this->termination_reason = $reason;
$this->save();
EmployeeTerminated::dispatch(
$this->id,
$this->employee_number,
$reason,
now()
);
return $this;
}
// ✅ Calculate annual salary using Money VO
public function getAnnualSalary(): Money
{
if ($this->employmentType === EmploymentType::FULL_TIME) {
return $this->baseSalary->multiply(12);
}
// For hourly, calculate based on standard hours
if ($this->hourlyRate) {
$hoursPerYear = 2080; // 40 hours/week * 52 weeks
$annualCents = $this->hourlyRate->amount() * $hoursPerYear;
return Money::fromAmount($annualCents, $this->hourlyRate->currency());
}
return $this->baseSalary;
}
private static function generateEmployeeNumber(): string
{
$lastEmployee = static::orderBy('employee_number', 'desc')->first();
$lastNumber = $lastEmployee ? (int) substr($lastEmployee->employee_number, 3) : 0;
return 'EMP' . str_pad((string) ($lastNumber + 1), 6, '0', STR_PAD_LEFT);
}
}Position.php (with salary range)
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Domain\Models;
use App\Shared\Domain\ValueObjects\Money;
use App\Shared\Infrastructure\Eloquent\Casts\MoneyCast;
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
/**
* @property Money $minSalary ✅ Money VO
* @property Money $maxSalary ✅ Money VO
*/
final class Position extends Model
{
use HasFactory, UsesTenantConnection;
protected $table = 'hrm_positions';
protected $fillable = [
'title',
'description',
'department_id',
'min_salary',
'max_salary',
];
protected function casts(): array
{
return [
'min_salary' => MoneyCast::class, // ✅ Money VO
'max_salary' => MoneyCast::class, // ✅ Money VO
];
}
// ✅ Check if salary is within range
public function isSalaryInRange(Money $salary): bool
{
return $salary->greaterThanOrEqual($this->minSalary)
&& $salary->lessThanOrEqual($this->maxSalary);
}
}Step 2: Application Layer - CQRS
2.1 Commands
HireEmployeeCommand.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Modules\HRM\Application\DTOs\Employee\HireEmployeeDTO;
final class HireEmployeeCommand implements Command
{
public function __construct(
public readonly HireEmployeeDTO $dto,
) {}
public function validate(): array
{
return [];
}
public static function fromArray(array $data): self
{
return new self(
dto: HireEmployeeDTO::fromArray($data),
);
}
}HireEmployeeCommandHandler.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\HRM\Domain\Contracts\EmployeeRepositoryInterface;
use App\Modules\HRM\Domain\Models\Employee;
final class HireEmployeeCommandHandler implements CommandHandler
{
public function __construct(
private readonly EmployeeRepositoryInterface $employeeRepository,
) {}
public function handle(Command $command): Employee
{
\assert($command instanceof HireEmployeeCommand);
$dto = $command->dto;
$employee = new Employee([
'first_name' => $dto->firstName,
'last_name' => $dto->lastName,
'email' => $dto->email,
'status' => EmploymentStatus::ACTIVE,
'employment_type' => $dto->employmentType,
'base_salary' => $dto->baseSalary, // ✅ Money VO
'hourly_rate' => $dto->hourlyRate, // ✅ Money VO
'hire_date' => $dto->hireDate,
'department_id' => $dto->departmentId,
'position_id' => $dto->positionId,
]);
$this->employeeRepository->save($employee);
// Event dispatched automatically in model boot
return $employee;
}
}AdjustSalaryCommand.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Shared\Domain\ValueObjects\Money;
final class AdjustSalaryCommand implements Command
{
public function __construct(
public readonly int $employeeId,
public readonly Money $newSalary, // ✅ Money VO
public readonly string $reason,
) {}
public function validate(): array
{
if ($this->newSalary->toFloat() <= 0) {
return ['Salary must be positive'];
}
return [];
}
}AdjustSalaryCommandHandler.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Application\Commands;
use App\Shared\Application\Commands\Command;
use App\Shared\Application\Commands\CommandHandler;
use App\Modules\HRM\Domain\Contracts\EmployeeRepositoryInterface;
use App\Modules\HRM\Domain\Models\Employee;
final class AdjustSalaryCommandHandler implements CommandHandler
{
public function __construct(
private readonly EmployeeRepositoryInterface $employeeRepository,
) {}
public function handle(Command $command): Employee
{
\assert($command instanceof AdjustSalaryCommand);
$employee = $this->employeeRepository->findOrFail($command->employeeId);
// ✅ Event dispatched inside adjustSalary() method
$employee->adjustSalary($command->newSalary, $command->reason);
return $employee;
}
}2.2 DTOs
HireEmployeeDTO.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Application\DTOs\Employee;
use App\Shared\Domain\ValueObjects\Money;
use App\Modules\HRM\Domain\Enums\EmploymentType;
final class HireEmployeeDTO
{
public function __construct(
public readonly string $firstName,
public readonly string $lastName,
public readonly string $email,
public readonly EmploymentType $employmentType,
public readonly Money $baseSalary, // ✅ Money VO
public readonly ?Money $hourlyRate, // ✅ Money VO (for hourly employees)
public readonly \DateTimeInterface $hireDate,
public readonly int $departmentId,
public readonly int $positionId,
public readonly string $currency = 'USD',
) {}
public static function fromArray(array $data): self
{
// ✅ Convert float to Money VO
$baseSalary = Money::fromFloat(
(float) $data['base_salary'],
$data['currency'] ?? 'USD'
);
$hourlyRate = isset($data['hourly_rate'])
? Money::fromFloat((float) $data['hourly_rate'], $data['currency'] ?? 'USD')
: null;
return new self(
firstName: $data['first_name'],
lastName: $data['last_name'],
email: $data['email'],
employmentType: EmploymentType::from($data['employment_type']),
baseSalary: $baseSalary,
hourlyRate: $hourlyRate,
hireDate: new \DateTimeImmutable($data['hire_date']),
departmentId: (int) $data['department_id'],
positionId: (int) $data['position_id'],
currency: $data['currency'] ?? 'USD',
);
}
}Step 3: Presentation Layer
3.1 API Resource
EmployeeResource.php
php
<?php
declare(strict_types=1);
namespace App\Modules\HRM\Presentation\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
final class EmployeeResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'employee_number' => $this->employee_number,
'first_name' => $this->first_name,
'last_name' => $this->last_name,
'email' => $this->email,
'status' => $this->status->value,
'employment_type' => $this->employment_type->value,
// ✅ Convert Money VO to float
'base_salary' => $this->moneyToFloat($this->baseSalary),
'hourly_rate' => $this->moneyToFloat($this->hourlyRate),
'annual_salary' => $this->moneyToFloat($this->getAnnualSalary()),
// ✅ Formatted amounts
'formatted_base_salary' => $this->baseSalary?->format(),
'formatted_annual_salary' => $this->getAnnualSalary()->format(),
'hire_date' => $this->hire_date?->toDateString(),
'department' => $this->whenLoaded('department'),
'position' => $this->whenLoaded('position'),
];
}
private function moneyToFloat(mixed $value): ?float
{
if ($value === null) {
return null;
}
if ($value instanceof \App\Shared\Domain\ValueObjects\Money) {
return $value->toFloat();
}
return \is_numeric($value) ? (float) $value : null;
}
}Step 4: Migrations
create_hrm_employees_table.php
php
Schema::create('hrm_employees', function (Blueprint $table) {
$table->id();
$table->string('employee_number')->unique();
$table->string('first_name');
$table->string('last_name');
$table->string('email')->unique();
$table->string('status');
$table->string('employment_type');
// ✅ JSON for Money VOs
$table->json('base_salary');
$table->json('hourly_rate')->nullable();
$table->string('currency', 3)->default('USD');
$table->date('hire_date');
$table->date('termination_date')->nullable();
$table->text('termination_reason')->nullable();
$table->foreignId('department_id')->constrained('hrm_departments');
$table->foreignId('position_id')->constrained('hrm_positions');
$table->foreignId('manager_id')->nullable()->constrained('hrm_employees');
$table->timestamps();
$table->softDeletes();
});create_hrm_positions_table.php
php
Schema::create('hrm_positions', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->foreignId('department_id')->constrained('hrm_departments');
// ✅ JSON for Money VOs (salary range)
$table->json('min_salary');
$table->json('max_salary');
$table->string('currency', 3)->default('USD');
$table->timestamps();
});Testing
php
public function test_adjusts_employee_salary_with_money_vo(): void
{
Event::fake();
$employee = Employee::factory()->create([
'base_salary' => ['amount' => 500000, 'currency' => 'USD'], // $5,000
]);
$newSalary = Money::fromFloat(6000.00, 'USD');
$employee->adjustSalary($newSalary, 'Annual raise');
$this->assertEquals(6000.00, $employee->baseSalary->toFloat());
Event::assertDispatched(SalaryAdjusted::class);
}
public function test_calculates_annual_salary_correctly(): void
{
$employee = Employee::factory()->create([
'employment_type' => EmploymentType::FULL_TIME,
'base_salary' => ['amount' => 500000, 'currency' => 'USD'], // $5,000/month
]);
$annualSalary = $employee->getAnnualSalary();
$this->assertInstanceOf(Money::class, $annualSalary);
$this->assertEquals(60000.00, $annualSalary->toFloat()); // $5k * 12
}Implementation Checklist
- [ ] Create
EmploymentStatus,EmploymentTypeenums - [ ] Create
Employeemodel with Money VOs for salary - [ ] Create
Positionmodel with Money VOs for salary range - [ ] Create
HireEmployeeCommandand handler - [ ] Create
AdjustSalaryCommandwith Money VO - [ ] Create DTOs with Money conversion
- [ ] Create migrations with JSON for Money fields
- [ ] Test salary calculations with Money VO
Reference: docs/implementation/operations-module-v2.md