Skip to content

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.php

Step 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, EmploymentType enums
  • [ ] Create Employee model with Money VOs for salary
  • [ ] Create Position model with Money VOs for salary range
  • [ ] Create HireEmployeeCommand and handler
  • [ ] Create AdjustSalaryCommand with 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

Documentation for SynthesQ CRM/ERP Platform