Opens in a new tab

Epok Frame – A modular, multi-tenant platform for running a company

Client
Status

Case study · Internal platform

EPOK FRAME

A modular, multi-tenant platform for running a company

EPOK Frame is a company management platform built from switchable modules. Each company on the platform turns on only what it needs — tasks and projects, HR, CRM, invoicing, a warehouse, production tracking — so the same installation becomes a full management suite for one business and a lean invoicing tool for the next.

Role
Sole developer — architecture, backend, frontend, UI design
Platform
Internal multi-tenant web application
Stack
Laravel · React · Inertia.js · MySQL
Year
2026

The problem

Most small and mid-sized companies run on a patchwork of tools: one app for tasks, a spreadsheet for holidays, a separate CRM, an invoicing service, and a stock sheet someone updates on Fridays. Each tool has its own login and its own copy of the client list, and none of them know what the others are doing. Finishing a production order doesn’t touch the stock count; approving a holiday doesn’t show up in the team calendar.

All-in-one suites solve this by being enormous. EPOK Frame takes the opposite approach: a small, solid core and a set of modules that plug into it. A company gets exactly the tools it uses, and those tools share one set of people, clients, files and history.

What it had to do

A handful of requirements shaped every technical decision:

  • Many companies, one installation. Each company’s data is invisible to every other company — guaranteed by the architecture, not by developers remembering to filter their queries.
  • Modules on demand. Switching a module on for a company is a setting, not a deployment, and switching it off must never break the modules that remain.
  • Access that fits real jobs. An accountant, a warehouse operator and a company admin can work in the same module and see different things.
  • Context on every record. Tasks, clients, invoices, employees and production orders all need comments, attachments, tags and a change history — without every module rebuilding them.
  • Open for integration. Everything the interface can do is also available through a versioned REST API.
  • Modest infrastructure. It runs on standard PHP and MySQL hosting, with a clear upgrade path when load grows.

What's inside

The core

Always on, for every company on the platform:

  • Dashboard

    A widget grid assembled from the modules a company has enabled, tailored to each role.

  • Companies, users & roles

    Tenant management, invitations, role assignment and per-company branding.

  • Notifications

    An in-app notification centre, with email for the events that matter.

  • Activity log

    An audit trail of every significant change, with before-and-after values.

  • Files

    Central storage — any file can be attached to any record.

  • Comments

    Threaded discussion on any record in any module.

  • Tags

    Colour-coded labels shared across every module.

  • Global search

    A ⌘K command palette that searches every enabled module at once.

Business modules

Eleven modules, each self-contained and switchable per company:

Tasks

tasks

List, board and calendar views; priorities from Low to Urgent; status workflows configurable per company; subtasks; a quick-create modal; overdue warnings; filters by status, assignee, tag, project and date. A task can be attached to any record — a client, an employee, a production order — so work always sits next to its context.

Projects

projects

Projects with members and roles, several Kanban boards per project with drag-and-drop, configurable columns and optional WIP limits, milestones, and progress calculated from task completion.

Calendar

calendar

Month, week and day views; recurring events; attendees with RSVP; personal and company-wide events; overlays for task deadlines and approved leave.

HR & Employees

hr

Employee directory and profiles, a nested department tree, positions and employment types, leave requests with an approval workflow, and a “who's out today” view. Employee records can be linked to user accounts.

CRM & Contacts

crm

People and company contacts, groups such as leads, clients and partners, a timeline of logged calls, emails and meetings, a “needs attention” list for contacts nobody has spoken to in 30 days, and CSV/Excel export.

Invoicing

invoicing

Invoices with line items, recipients drawn from the CRM with their billing details and tax IDs, sending, payment records and status tracking. Amounts are stored as integers in the currency's minor unit, so totals never pick up floating-point rounding errors.

File Manager

files

A per-company file browser with folders, built on the core file library — a document attached to a task and a contract on an employee profile end up in the same place.

Time Tracking

time

Start/stop timers and manual entries against tasks and projects, with reports per person and per project. The module owns all time data; Tasks itself stores none.

Warehouse

warehouse

Products, warehouses and storage locations, with receipts, issues and transfers. Stock is kept as a ledger: a quantity is never overwritten; every change is a recorded movement, and the stock level is the sum of those movements — so every number traces back to who moved what, and when.

Production

production

Production orders moving through defined stages, with a log of who did what at each stage and live progress tracking. Materials come from the warehouse, work steps become tasks and operators come from HR — all through events, never direct dependencies.

Reporting

reports

Cross-module dashboards and exports built on cached aggregations. It reads from every module and writes to none.

TENANT A TENANT B TENANT C Services agency Manufacturer Freelance studio 9 of 11 modules 6 of 11 modules 3 of 11 modules MODULE Tasks Projects Calendar HR & Employees CRM & Contacts Invoicing File Manager Time Tracking Warehouse Production Reporting Core platform Core — always on for every company users & roles · dashboard · notifications · activity log · files · comments · tags · search enabled off — hidden from the sidebar, its routes answer 403 toggled per company · company_modules
Fig. 1One installation, three example configurations. The core runs for every company; modules are switched on per company, and a module that's off doesn't exist for that company — it's missing from the sidebar, dashboard and search, and its routes refuse the request.

How it's built

A modular monolith

EPOK Frame is one Laravel application, deployed and migrated as a single unit — but internally it’s divided into modules with hard boundaries. Each module is one folder containing everything it needs: database migrations, models, controllers, validation, services, events and listeners, web and API routes, and its own React pages and components.

Every module also ships a manifest declaring its sidebar entries, dashboard widgets, permission keys, searchable models and dependencies on other modules. At boot, the platform discovers each module folder and registers it — routes, migrations, permissions and navigation included. Adding a module means adding a folder; nothing in the core changes.

I chose this over microservices on purpose. One codebase, one database and one deployment are far cheaper to run and reason about, and the module boundaries still keep each part independent.

Tenant isolation below the controllers

All companies share one database. Every tenant-owned table carries a company_id column, and that one column is all that separates one company’s data from another’s — so the filter can’t be left to individual queries.

Every tenant model uses a BelongsToTenant trait, which registers a global query scope. From then on, every query on that model gets WHERE company_id = ? appended at the query-builder level before any SQL is sent, and every new record has its company_id stamped automatically. A controller can’t forget the filter, because controllers never write it.

Before any of that runs, a request passes a layered set of checks: authentication, whether the module is enabled for the company, whether the user’s role grants the permission, and whether the record in the URL belongs to the user’s company.

GET /tasks/381 a user from Company 42 opens a task auth module:tasks permission:tasks.view EnsureTenantAccess Is the user signed in? Is Tasks enabled for Company 42? Does the user's role grant it? Does task 381 belong to Company 42? no no no no 302 · redirect to login 403 · MODULE_DISABLED 403 · FORBIDDEN 403 · access denied yes yes yes yes query CONTROLLER TaskController → Task::find(381) ELOQUENT + TENANTSCOPE → MYSQL select * from tasks where tasks.id = 381 and tasks.company_id = 42 appended automatically to every query — no controller writes it
Fig. 2Every request passes four gates before a controller runs, and the tenant filter is added to the query itself. A user from Company 42 can't reach a record belonging to another company, even through a carelessly written controller.

Platform staff sit outside the tenants. They see across companies in platform-level views and can enter a company to support it; entering sets a session context that scopes their reads and writes exactly as if they were a member, and creating a record without that context is refused outright.

A single shared database was a deliberate trade-off. It means one schema to migrate, platform-wide reporting as ordinary queries, and no need to provision databases on the fly — which shared hosting doesn’t allow. The cost is that isolation depends on the application, which is exactly why it’s enforced in one place, at the lowest layer, and covered by automated tests.

Modules that never talk directly

A module never imports another module’s models or services. Instead, modules announce what happened — TaskCompleted, LeaveRequestApproved, InteractionLogged — and any other module can listen.

Listeners are registered only for the modules the current company has enabled. If a company doesn’t use Time Tracking, its listener simply isn’t there, and nothing needs a guard clause or a feature flag. Completing a task updates project progress; approving leave notifies the employee and blocks out the calendar; starting a production order draws its materials from the warehouse. None of these modules know the others exist.

no direct import between modules Tasks module task marked done, fires one event TaskCompleted Event dispatcher delivers only to enabled modules Projects recalculates progress Notifications · core notifies the task creator Time Tracking off for this company: listener never registered
Fig. 3When a task is completed, Tasks fires one event and moves on. The dispatcher delivers it only to modules this company has switched on, so a disabled module needs no special handling.

Shared services that attach to anything

Comments, files, tags, activity history and notifications are built once in the core and attached to records through polymorphic relationships. A module opts in by adding a trait to its model and placing a shared React component on its page.

That’s why a CRM contact, an invoice, an employee profile and a production order all get threaded comments, attachments, tags and a full change history from a single implementation — one set of tables, one set of components, one place to fix a bug. The record types that can receive attachments are checked against an explicit allowlist, so a crafted request can’t attach anything to a record it shouldn’t reach.

Roles and permissions

Access follows a four-level hierarchy: platform owner, platform admin, company admin and company user. Underneath sit granular permission keys in module.action form — tasks.edit_own, hr.approve_leave_requests, crm.export — declared by each module in its manifest and registered automatically.

Permissions understand ownership: tasks.edit lets you edit any task, tasks.edit_own only the ones you created or were assigned. Company admins build their own roles — sales rep, accountant, warehouse operator — from a checklist grouped by module, and a custom role can never exceed the admin’s own permissions. The server enforces every check through route middleware and policies; the same permission list is shared with the interface, so buttons a user can’t use are never rendered. Platform admins can suspend a company but not delete it — that’s reserved for the platform owner.

A server-driven interface

The interface is React, delivered through Inertia.js: Laravel controllers return a page component and its data directly, with no separate client-side API layer, router or state store. Navigation feels like a single-page app while the server stays the single source of truth. A custom page resolver lets each module keep its React pages inside its own folder, and every page receives the user, their permissions and the company’s enabled modules — so the sidebar builds itself.

An API alongside the interface

Next to the interface runs a REST API under /api/v1, authenticated with Sanctum tokens and guarded by the same module and permission checks. Every response uses one envelope — data, meta, message — and errors carry machine-readable codes such as VALIDATION_ERROR, FORBIDDEN or MODULE_DISABLED. Resource classes define exactly which fields leave the server; raw database models are never returned.

Built for ordinary hosting

The whole platform needs only PHP and MySQL. Sessions and search run on the database, background jobs are processed by a cron-driven scheduler, and front-end assets are compiled before deployment, so the server never needs Node.js. Each of these is a single environment setting — Redis, Meilisearch or S3 can be switched in as load requires, without code changes.

Design

The interface is deliberately quiet: data first, no decorative gradients or ornament, with hierarchy built from spacing, weight and colour — in the spirit of the Linear, Vercel and Stripe dashboards.

The whole visual language lives in design tokens defined as CSS custom properties. Dark mode is the default — deep navy surfaces with the brand green as the only accent — and the light theme is tuned rather than inverted: the green darkens for text on white so it keeps its contrast. Switching themes changes a single attribute on the page.

Dark · default [data-theme="dark"] --bg-primary --bg-secondary --bg-tertiary --text-primary --text-secondary --accent #1A2332 #263547 #384E68 #F0F2F4 #94A3B8 #2ECC71 Light [data-theme="light"] --bg-primary --bg-secondary --border-primary --text-primary --text-secondary --accent-text #FFFFFF #F8F9FA #E2E4E7 #1A2332 #64748B #219653
Fig. 4A selection of the design tokens. Both themes share one set of token names, so no component ever references a colour directly.

Typography follows a strict rule: Orbitron is reserved for brand moments — the wordmark and the sign-in screen — while Inter carries the entire working interface and JetBrains Mono handles code and identifiers. The layout adapts from a full 260 px sidebar on desktop to a 68 px icon rail on tablets and a drawer on phones, and motion is used only to communicate a change of state.

A shared library of token-driven components — buttons, inputs, modals, tables, badges, dropdowns, tabs and toasts — gives every module the same look and behaviour without a line of module-specific styling.

Key decisions

The choices that shaped the platform, and what each one cost:

Decision Instead of Why Trade-off
Modular monolith Microservices One deploy, one database, simple hosting — with modules still independent. Boundaries are held by convention and review rather than by the network.
One database, scoped by company_id A database per company One schema to migrate; platform reporting is plain SQL; works on shared hosting. Isolation depends on the application — so it’s enforced in the model layer and tested.
Events between modules Direct calls and imports Modules can be switched on and off independently without breaking each other. A single flow is spread across several listeners, and the event cache stays off because listeners depend on the company.
Polymorphic shared services Separate comments and files tables per module One implementation serves every record type in every module. No database-level foreign keys on polymorphic columns, so attachable types are allow-listed.
Stock as a ledger of movements Editing a quantity field Every stock level is auditable and reproducible. Levels are computed from movements and cached, rather than read from one field.
Money as integer minor units Decimal or float amounts Exact totals with no rounding drift. Every amount has to be formatted for display.
Inertia + React A separate SPA and API Less code, one source of truth, no duplicated routing. The public API is maintained as its own layer.

The result

EPOK Frame turns “which software should we buy?” into a settings page. The same installation can run a services agency on projects, CRM, invoicing and time tracking, a manufacturer on warehouse and production, and a freelance studio on three modules — each company seeing only its own data and its own tools.

  • One codebase, many products. The platform becomes a management suite, an invoicing tool or a production tracker through configuration alone.
  • A new module is a new folder. It registers its own routes, permissions, navigation, widgets and search.
  • Isolation by construction. The tenant filter sits below every controller and is covered by automated tests, alongside the permission system and the module registry.
  • Runs wherever PHP runs. Ordinary shared hosting today; Redis, Meilisearch and S3 are a configuration change away.

Tech stack

Backend
Laravel 13, PHP 8.3 with strict types throughout, MySQL 8
Frontend
React 18, Inertia.js 2, Vite, Tailwind CSS, Lucide icons
Auth & API
Laravel Breeze, Sanctum tokens, versioned REST API
Search & files
Laravel Scout, Laravel Filesystem (S3-ready)
Testing
PHPUnit feature and unit tests