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
- Platform
- Stack
- Year
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
tasksList, 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
projectsProjects 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
calendarMonth, week and day views; recurring events; attendees with RSVP; personal and company-wide events; overlays for task deadlines and approved leave.
HR & Employees
hrEmployee 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
crmPeople 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
invoicingInvoices 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
filesA 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
timeStart/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
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
productionProduction 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
reportsCross-module dashboards and exports built on cached aggregations. It reads from every module and writes to none.
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.
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.
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.
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