How it is built

Architecture

Portal authenticates, Console runs the HR business, and tenants are isolated by PostgreSQL schema.

Three applications, three jobs

FincoHR is not one monolith. Authentication, business logic and the operator back office are three separate Laravel applications, and the lines between them are drawn deliberately.

Portal — the identity provider

The only place in the system that ever checks a password. Users sign in here, choose which tenant to enter, and are handed off to Console. The marketing site you are reading also lives on Portal.

Console — the HR application

Every HR feature lives here: people, attendance, leave, payroll, performance, compliance. It has no login page and no registration page — identity can only arrive by handoff from Portal.

Admin — the operator console

The service provider's own control plane: tenant provisioning, plans and cross-tenant operations. Customers never touch this application.

Credential handoff

From sign-in to inside a tenant

No shared-cookie SSO, no second login page. There is exactly one path.

01
Sign in at Portal

The user enters their credentials at Portal. This is the only place a password is verified.

02
Get a one-time handoff

Portal writes a credential payload and issues a session_id that can be redeemed exactly once.

03
Console resolves the tenant

Console redeems the handoff for the payload and reads the user's tenant schema name from it.

04
Session created

Console sets the search_path for the request and creates the session. The user is inside their own company's data.

The schema name may only come from the credential payload — never from a URL, query string, cookie or request body. That single rule is the foundation of tenant isolation; relaxing it dismantles the isolation.
Multi-tenancy

One company, one schema

Tenant isolation is not a company_id column on every table plus the hope that every query remembers it. Each company gets its own PostgreSQL schema, and Console sets the search_path at the start of every request.

  • Queries carry no tenant predicate, because the connection can only see one schema
  • A forgotten WHERE cannot leak across tenants, because there is no WHERE to forget
  • The schema name comes only from the credential payload, never from user input
-- at the start of every request SET search_path TO fhr_acme, public; -- every query afterwards runs inside that tenant's schema SELECT * FROM employees WHERE status = 'active'; -- no AND company_id = ? needed: -- this connection cannot see another company's employees table
Request layering

Business rules do not live in controllers

A controller does three things: validate input, resolve context, delegate. The actual rules — how annual leave accrues, whether a period may be finalized, whose rows a person may read — live in the service layer.

Controller

Thin: validate, resolve tenant context, call a service, return a consistent JSON envelope or a view.

Validator

Write rules gathered in one place instead of scattered through controllers.

Service

Where the business logic lives: payroll calculation, leave accrual, approval workflow, compliance exports.

Model

One class serves every tenant. Isolation comes from the per-request search_path, not from a different connection.

Stack
Laravel 12 PHP 8.4 PostgreSQL 16 Vite Pest Bootstrap 5
Next step

Wondering whether it fits how you already work?

Tell us how you run payroll today, which time clock you use and how many departments you have. We will tell you straight whether it fits.