To build a Laravel HRMS, you need six core modules: employees and departments, roles and permissions, leave management, attendance, payroll records, and secure document storage. Laravel provides much of the foundation out of the box. Eloquent handles employee relationships, policies protect salary information, queues send approval emails, and the scheduler automates monthly leave accruals. Building a focused first version can take a few weeks by hand, or days to generate the foundation with AI.
Nadia runs a 40-person logistics company in Manchester. Leave requests lived in email threads. Attendance was tracked in a spreadsheet. Payslips were PDFs sent from a personal inbox. Off-the-shelf HR tools quoted per-seat pricing that would cost thousands a year, and none supported her split day and night shifts.
That’s when building your own HR management system in Laravel makes sense: when your workflow doesn’t fit a generic tool or when you’re an agency building HR software for clients. This guide walks you through the database schema, user roles, leave approval workflow, attendance tracking, payslips, and data security, with code examples for each.
Key Takeaways
A Laravel HRMS needs six modules: Employees, roles, leave, attendance, payroll records, and documents. Build these first and add performance reviews later.
Model the org chart with a self-referencing
manager_idcolumn on employees, allowing managers to approve requests for their direct reports.Use Spatie Laravel Permission for roles (admin, HR, manager, employee) and Laravel policies for record-level rules, such as ensuring employees can see only their own payslips.
Approve leave inside a database transaction with a row lock to prevent concurrent approvals from over-deducting the same leave balance.
Encrypt sensitive fields such as bank details and national ID numbers with Laravel’s
encryptedcast, and store documents on a private disk.
The people in this article’s examples are illustrative composites based on common Laravel scenarios.
What Your Laravel HRMS Needs in Version One
The fastest way to fail is to copy every feature of Workday. Instead, scope version one around the work HR teams handle every week.
Module | Version 1 | Later |
|---|---|---|
Employees and departments | Profiles, job titles, managers | Org chart visualization |
Roles and permissions | Admin, HR, manager, employee | Custom roles per client |
Leave management | Requests, approvals, balances | Holiday calendars by country |
Attendance | Clock in and out, daily records | Geofencing, shift rotas |
Payroll records | Salary history, payslip PDFs | Tax calculations, bank exports |
Documents | Contracts and IDs on private storage | E-signatures |
Self-service | Employees view their own data | Mobile app |
Notice what’s missing: performance reviews, recruiting, and tax calculations. Each can become a product of its own. Add them once the core system is in daily use.
If you’re building this for multiple client companies, decide on multi-tenancy now. Every table below would need a company_id column.
Build or Buy?
Building isn’t always the right choice. Use this quick comparison:
Situation | Better choice |
|---|---|
Standard office hours, one country, fewer than 20 staff | Buy an off-the-shelf HR tool |
Unusual shifts, approval chains, or leave rules | Build a Laravel HRMS |
HR features inside an existing Laravel product | Build |
Agency selling HR software to multiple clients | Build with multi-tenancy |
Complex multi-country payroll | Buy payroll software and build the rest around it |
The sweet spot for a custom Laravel HR management system is a company whose workflow is the problem, not simply the price. If people keep saying, “The tool doesn’t let us do that,” you have a case for building.
Step 1: Design the Laravel HRMS Database Schema
Five tables form the core: departments, employees, leave_types, leave_balances, and leave_requests. Attendance comes in Step 4.
// database/migrations/xxxx_create_hrms_tables.php
Schema::create('departments', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('employees', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->unique()->constrained()->cascadeOnDelete();
$table->foreignId('department_id')->nullable()->constrained()->nullOnDelete();
// Self-reference: who this employee reports to
$table->foreignId('manager_id')->nullable()->constrained('employees')->nullOnDelete();
$table->string('employee_code')->unique();
$table->string('job_title');
$table->date('hired_on');
$table->text('bank_account')->nullable(); // encrypted via model cast
$table->text('national_id')->nullable(); // encrypted via model cast
$table->timestamps();
});
Schema::create('leave_types', function (Blueprint $table) {
$table->id();
$table->string('name'); // Annual, Sick, Unpaid
$table->decimal('days_per_year', 5, 2)->default(0);
$table->boolean('requires_approval')->default(true);
$table->boolean('deducts_balance')->default(true); // false for unpaid leave
$table->timestamps();
});
Schema::create('leave_balances', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
$table->foreignId('leave_type_id')->constrained()->cascadeOnDelete();
$table->unsignedSmallInteger('year');
$table->decimal('days_available', 5, 2)->default(0);
$table->unique(['employee_id', 'leave_type_id', 'year']);
$table->timestamps();
});
Schema::create('leave_requests', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
$table->foreignId('leave_type_id')->constrained();
$table->date('starts_on');
$table->date('ends_on');
$table->decimal('days', 5, 2);
$table->string('status')->default('pending'); // pending, approved, rejected
$table->foreignId('decided_by')->nullable()->constrained('users')->nullOnDelete();
$table->text('reason')->nullable();
$table->timestamps();
$table->index(['employee_id', 'status']);
});The Employee model defines the relationships and encrypts sensitive fields:
// app/Models/Employee.php
class Employee extends Model
{
protected $fillable = [
'user_id',
'department_id',
'manager_id',
'employee_code',
'job_title',
'hired_on',
];
// Never expose these in JSON responses
protected $hidden = ['bank_account', 'national_id'];
protected function casts(): array
{
return [
'hired_on' => 'date',
'bank_account' => 'encrypted',
'national_id' => 'encrypted',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function manager(): BelongsTo
{
return $this->belongsTo(Employee::class, 'manager_id');
}
public function directReports(): HasMany
{
return $this->hasMany(Employee::class, 'manager_id');
}
public function leaveRequests(): HasMany
{
return $this->hasMany(LeaveRequest::class);
}
}The encrypted cast is documented in Laravel’s Eloquent mutators guide. It uses your APP_KEY, so back up that key securely. If you lose it, you lose access to the encrypted data.
Step 2: Set Up Roles and Permissions for Your HRMS
A Laravel HR management system needs two layers of access control:
Roles determine which screens someone can access. HR can open payroll screens; employees cannot.
Policies determine which records someone can access or modify. A manager can approve leave, but only for their own team.
For roles, use Spatie Laravel Permission.
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan optimize:clear
php artisan migrateAdd the HasRoles trait to your User model, then seed four roles:
// database/seeders/RoleSeeder.php
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
public function run(): void
{
$permissions = [
'manage employees',
'view payroll',
'manage payroll',
'approve leave',
];
foreach ($permissions as $name) {
Permission::findOrCreate($name);
}
Role::findOrCreate('admin')->syncPermissions($permissions);
Role::findOrCreate('hr')->syncPermissions([
'manage employees',
'view payroll',
'manage payroll',
'approve leave',
]);
Role::findOrCreate('manager')->syncPermissions(['approve leave']);
Role::findOrCreate('employee');
}Register Spatie’s middleware aliases in bootstrap/app.php, then protect your route groups:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
]);
})// routes/web.php
Route::middleware(['auth', 'permission:view payroll'])
->prefix('payroll')
->group(function () {
// Payroll screens: HR and admin only
});Our Laravel roles and permissions guide covers caching, teams, and super-admin setup in more detail.
Enforce Record-Level Rules With a Policy
Roles alone can’t express a rule such as “Managers can approve leave only for their direct reports.” That’s where policies come in.
// app/Policies/LeaveRequestPolicy.php
class LeaveRequestPolicy
{
public function view(User $user, LeaveRequest $request): bool
{
// Own requests, HR, or the employee's direct manager
return $request->employee->user_id === $user->id
|| $user->hasRole('hr')
|| $this->isManagerOf($user, $request);
}
public function approve(User $user, LeaveRequest $request): bool
{
// Nobody approves their own leave
if ($request->employee->user_id === $user->id) {
return false;
}
// HR can approve anyone; managers only their direct reports
return $user->hasRole('hr')
|| ($user->can('approve leave') && $this->isManagerOf($user, $request));
}
private function isManagerOf(User $user, LeaveRequest $request): bool
{
// Guard against null === null when the user has no employee record
return $user->employee !== null
&& $request->employee->manager_id === $user->employee->id;
}
}This assumes the User model has an employee() hasOne relationship. For more examples, see our guide to Laravel policies and gates.
Want to skip the boilerplate? StackMivo generates a Laravel HRMS foundation, including models, migrations, roles, policies, and an admin panel, from a plain-English description. You keep the code and customize the workflows that make your HR system different.
Step 3: Build the Leave Management Workflow
Leave is one of the features employees use most, and one of the easiest to get wrong. If two managers click “approve” at the same time, they could overdraw a leave balance unless you handle concurrency properly.
Count Working Days
Count only weekdays between the start and end dates, inclusive:
use Carbon\CarbonPeriod;
function workingDays(string $start, string $end): int
{
return collect(CarbonPeriod::create($start, $end))
->reject(fn ($day) => $day->isWeekend())
->count();
}In version two, you can also subtract public holidays using a holidays table.
Approve Leave Inside a Transaction
The approval action locks the balance row, checks the available days, and deducts the balance within a single transaction:
// app/Actions/ApproveLeaveRequest.php
class ApproveLeaveRequest
{
public function handle(LeaveRequest $request, User $approver): LeaveRequest
{
return DB::transaction(function () use ($request, $approver) {
// Re-read the request with a lock so a double click can't approve twice
$request = LeaveRequest::whereKey($request->id)
->lockForUpdate()
->firstOrFail();
if ($request->status !== 'pending') {
throw ValidationException::withMessages([
'status' => 'This request was already decided.',
]);
}
// Unpaid leave doesn't touch a balance
if ($request->leaveType->deducts_balance) {
$balance = LeaveBalance::where([
'employee_id' => $request->employee_id,
'leave_type_id' => $request->leave_type_id,
'year' => $request->starts_on->year,
])->lockForUpdate()->firstOrFail();
if ($balance->days_available < $request->days) {
throw ValidationException::withMessages([
'days' => 'Not enough leave balance.',
]);
}
$balance->decrement('days_available', $request->days);
}
$request->update([
'status' => 'approved',
'decided_by' => $approver->id,
]);
// Queued notification is sent only after the transaction commits
$request->employee->user
->notify((new LeaveRequestDecided($request))->afterCommit());
return $request;
});
}
}Call this from a controller after Gate::authorize('approve', $leaveRequest). Laravel 11+ controllers don’t include the authorize() helper by default. Make sure starts_on is cast to date and that the leaveType() relationship is defined on the LeaveRequest model.
Accrue Leave Every Month
Use the scheduler to update leave balances on the first of each month. In routes/console.php:
use Illuminate\Support\Facades\Schedule;
Schedule::command('leave:accrue')
->monthlyOn(1, '01:00')
->onOneServer();The leave:accrue command adds days_per_year / 12 to each employee’s balance for the current year. See the Laravel scheduling documentation for instructions on running the scheduler in production.
Kwame’s agency built a Laravel HRMS for a chain of 12 dental clinics. During the first week of testing, a practice manager and the regional HR lead approved the same request within seconds of each other. Without the row lock, the nurse’s balance would have been deducted twice. With it, the second click received a clear “already decided” message, and nobody had to correct balances manually.
Step 4: Add Attendance Tracking
Keep attendance simple: one record per employee per day.
Schema::create('attendances', function (Blueprint $table) {
$table->id();
$table->foreignId('employee_id')->constrained()->cascadeOnDelete();
$table->date('date');
$table->timestamp('clock_in')->nullable();
$table->timestamp('clock_out')->nullable();
$table->unique(['employee_id', 'date']); // one record per day
$table->timestamps();
});// app/Models/Attendance.php
class Attendance extends Model
{
protected $fillable = ['employee_id', 'date', 'clock_in', 'clock_out'];
protected function casts(): array
{
return [
'date' => 'date',
'clock_in' => 'datetime', // so ->format() works on existing rows too
'clock_out' => 'datetime',
];
}
}// app/Http/Controllers/AttendanceController.php
public function clockIn(Request $request)
{
$attendance = Attendance::firstOrCreate(
[
'employee_id' => $request->user()->employee->id,
'date' => today(),
],
['clock_in' => now()]
);
return back()->with(
'status',
'Clocked in at '.$attendance->clock_in->format('H:i')
);
}The unique index prevents duplicate rows, even if someone taps the button twice. Night shifts that cross midnight need more care: store the shift’s start date, not the calendar date of clock-out.
Step 5: Handle Payroll Records and Payslips
Here’s the honest advice: don’t build tax calculations in version one. Tax rules change every year and vary by country, state, and employment type. Mistakes can have legal consequences.
Instead, your Laravel HRMS should:
Store salary history, including amount, currency, and effective date, so you know what each employee earned and when.
Record payroll runs with each employee’s gross pay, deductions, and net pay, entered or imported from your payroll provider or accountant.
Generate payslip PDFs in a queued job and store them on a private disk.
Let employees download only their own payslips, enforced through a policy check.
Never store payslips or contracts in public/. Use a private disk and serve files through an authorized controller or a short-lived temporary URL. Our Laravel file upload guide explains private S3 storage and signed downloads.
Step 6: Build the Admin Panel and Employee Self-Service Portal
HR staff need fast tables, filters, and forms. Employees need a simple portal. Build the two experiences separately:
HR admin panel: Filament helps you create employee tables, leave approval queues, and payroll screens quickly. Our Filament admin panel guide explains how to scaffold one.
Employee portal: A small Blade, Livewire, or Inertia front end where employees can request leave, clock in, and download payslips.
Many of the same patterns appear in our Laravel inventory management build guide: role-based dashboards, audit-friendly records, and locked database updates.
Laravel HRMS Security Checklist
An HR management system holds some of a company’s most sensitive data. Before launch, check the following:
Sensitive fields are encrypted with the
encryptedcast and hidden from JSON responses.APP_KEYis backed up securely and never committed to Git.Policies protect every employee, leave, payroll, and document route.
Documents are stored on a private disk and served through authorized downloads.
An activity log records salary changes and leave decisions, including who made each change and when.
Two-factor authentication is enabled for admin and HR accounts.
Data retention rules have been agreed with the client’s legal advisor, including applicable requirements such as GDPR in the UK and EU.
Database backups are encrypted and have been tested through a restore.
Lena, a freelancer in Berlin, nearly shipped an HR portal where /payslips/{id} worked for any logged-in user. Changing the ID in the URL revealed a colleague’s salary. One authorize('view', $payslip) call closed the gap. She now writes a feature test for every rule that prevents users from viewing other people’s data before building the screen.
Ready to build your HR system faster? Describe your HRMS in plain English, including roles, leave rules, and approval flows, and StackMivo generates a working Laravel app with tests. Start free with StackMivo →
Frequently Asked Questions
Is Laravel Good for Building an HRMS?
Yes. Laravel’s Eloquent ORM, policies, queues, scheduler, and encryption support the core needs of an HR system. Packages such as Spatie Laravel Permission and Filament help with roles and admin screens.
How Long Does It Take to Build an HR Management System in Laravel?
A focused first version with employees, roles, leave, attendance, and payslip storage typically takes an experienced Laravel developer three to six weeks. Generating the foundation with AI can reduce that time substantially.
Should I Build Payroll Tax Calculations Myself?
Usually not. Store salary history and payroll results, and integrate with a payroll provider or accountant for tax calculations. Tax rules change frequently and vary by country.
How Do I Keep Employee Data Secure in Laravel?
Encrypt sensitive columns with the encrypted cast, authorize each record with policies, store documents on a private disk, and require two-factor authentication for HR staff.
Can I Sell a Laravel HRMS to Multiple Companies?
Yes. Plan for multi-tenancy early, using a company_id on every relevant table or a separate database for each tenant. Scope every query so one client can never access another client’s employee data.
Conclusion
A Laravel HRMS doesn’t need to match enterprise HR suites on day one. It needs to do six things well: maintain accurate employee records, control who sees what, handle leave without errors, track attendance, store payroll records safely, and protect documents.
Start with the schema and roles above. Put leave approvals inside a locked transaction, encrypt sensitive fields, and write a test for every privacy rule. Then add the features your users actually ask for, whether that’s shift rotas, holiday calendars, or performance reviews.
Build it right, and your HR management system becomes the tool your team opens every morning not another spreadsheet they dread.