Skip to content
Farhad Nazir
  • ERP
  • Laravel
  • Business software

ERP is workflow engineering, not CRUD

The tables are the easy part of an ERP. What I learned from the unwritten rules for attendance, performance scores, invoicing and one-transaction POS billing.

Farhad Nazir

8 min read

Most ERP projects are scoped as a list of screens: employees, attendance, projects, invoices, stock. Each one looks like a form and a table, so the estimate ends up looking like a CRUD app with a login page. In my experience that part goes quickly. Laravel migrations, Eloquent models and a few Livewire components will give you working screens for almost any business entity.

The work that decides whether the system actually gets used sits somewhere else: in rules nobody wrote down. Who is allowed to punch in from home. How late counts as late. What a single sale is supposed to change. Those rules live in the owner’s head, in a manager’s habits and in the paper register at the counter, and they usually surface only when the software gets them wrong.

This article walks through rules from three systems: SoftHouse Pro, an ERP I built as sole developer for a software house; Track Cue, a snooker club management SaaS, still in development, that I was assigned to audit and complete as a Laravel developer; and Asaan Optics, my own sales and records system for optical shops, along with the multi-branch Drivoli Optics system built from the same base.

The tables are the easy part

SoftHouse Pro has 20 Eloquent models over 28 migrations. Attendances, breaks, leave requests, projects, tasks, time logs, clients, invoices: none of those tables is difficult to design on its own.

The difficulty is that an ERP is a hub of shared data with modules attached to it, and one business action touches several of them. A punch-in feeds the attendance log, the late report, a notification and later a performance score. Moving a card on the Kanban board changes the task’s status and recalculates the project’s progress. A sale changes stock, a customer balance and the cash count. If each module is built as an isolated screen, those records drift apart, the owner stops trusting the numbers, and the team quietly goes back to the spreadsheet.

So I treat the schema as a first draft and the business rules as the real specification.

Attendance is not a button

In every spec, attendance starts as one button: punch in. Within a week of SoftHouse Pro’s first deployment, that button had to know quite a lot.

  • Where you are. Office staff can only punch in or out from IP addresses on an allowlist stored in the organization settings, which in practice means the office network.
  • How you work. Each employee has a work mode. Remote staff skip the IP check, so the rule does not penalize people who were hired to work from home.
  • Which browser you used. When someone punches in, that browser is marked for the day. A second employee trying to punch in from the same browser that day is refused, which stops one person punching in for a colleague who has not arrived yet.
  • What late means. Office start time and the grace period are settings, not constants. A punch-in after start time plus grace is saved as late, with the late minutes recorded.
  • Who needs to know. A scheduled command runs every minute. Just after the grace deadline passes, it emails each associate or manager who has not punched in yet. An employee who punches in late gets an email straight away.
  • What a break is. Breaks are logged in their own table and totaled per day, so a long lunch does not get lost inside one punch-in and punch-out pair.

Much of this was not in the first build. The IP allowlist, work mode, break tracking and late-arrival emails were added about a week after the first deployment. Each one was a decision the business had already made informally, and the software had to make it explicit. Here is the shape of the punch-in check, simplified:

// Simplified illustration of the rules, not production code
public function checkPunchIn(
    Employee $employee,
    Request $request,
    OrgSettings $settings,
): PunchResult {
    // Office staff must be on the office network; remote staff skip this
    if ($employee->work_mode === 'office'
        && ! in_array($request->ip(), $settings->allowedIps(), true)) {
        return PunchResult::deny('Please punch in from the office network.');
    }

    // One browser, one person, per day
    $usedBy = $request->cookie('attendance_' . today()->toDateString());
    if ($usedBy !== null && (int) $usedBy !== $employee->user_id) {
        return PunchResult::deny('This browser was already used today.');
    }

    $deadline = Carbon::parse($settings->office_start)
        ->addMinutes($settings->grace_minutes);

    return now()->greaterThan($deadline)
        ? PunchResult::late(minutesLate: (int) $deadline->diffInMinutes(now()))
        : PunchResult::present();
}

A performance score people can check

SoftHouse Pro gives each employee an overall performance score on their profile. With a feature like this, it is tempting to make the number look sophisticated. I made it explainable instead, because a number that affects how people are judged has to hold up when the person it describes questions it.

  • Task completion counts for 60% of the score and attendance reliability for 40%.
  • Attendance is not pass or fail. A late day counts as 0.8 of a present day and a half day as 0.5.
  • The formula handles the case where one of the two parts has no data in the selected period, rather than treating missing data as failure.
  • The score can be filtered to the last 7 days, the last 30 days, last month or the last 3 months.

It is a deterministic weighted formula, not machine learning, and I think that is the right choice for this job. A manager can explain any score in two sentences, and an employee who disagrees can see exactly which part to work on. The weights themselves are a business decision, and writing them down in code forced that decision to be made openly.

Invoices follow the client, not the form

Invoicing looks like the most standard CRUD screen there is: line items, subtotal, tax, total. In SoftHouse Pro the rules sit one level up, on the client record. Each client in the CRM has billing settings for currency, tax rate and payment terms. When an invoice is started for that client, the tax rate fills in and the due date is calculated from the terms, so Net 15 becomes an actual date. Line items are quantity times rate, and the finished invoice can be marked paid, exported to CSV or downloaded as a PDF.

Keeping these rules on the client means nobody has to remember which client pays in 15 days and which in 30, and a wrong tax rate is fixed in one place instead of on every new invoice. The client portal covers the other side: clients see and download only their own invoices, and the server checks ownership on every PDF download, not just in the menu.

One sale, many records

Point of sale makes the problem easy to see, because one action at the counter has to change several records at once, and the cash in the drawer has to match at the end of the day.

In Track Cue, a snooker club runs a live grid of tables. When staff stop a game, the end-of-game step works out the game charge, adds any canteen items served during play, reduces their stock, applies a discount if staff entered one (otherwise the calculated total stands), records the game, and then either takes it as cash or debits it to the member’s khata, the credit ledger many clubs used to keep on paper. All of it runs inside one database transaction. Much of the codebase existed before I was assigned to it, so this is a rule I worked with rather than one I designed.

If any step fails, none of it is saved. Without the transaction you can end up with canteen stock reduced for a bill that was never recorded, or a finished game with nothing owed. Those are the errors an owner discovers weeks later during a stock count. The general pattern looks like this:

// Illustration of the general pattern, not Track Cue's code
DB::transaction(function () use ($game, $items, $checkout) {
    foreach ($items as $item) {
        // Lock the row so two counters cannot sell the same last unit
        $product = Product::whereKey($item->productId)
            ->lockForUpdate()
            ->firstOrFail();
        if ($product->stock < $item->quantity) {
            throw new OutOfStock($product);
        }
        $product->decrement('stock', $item->quantity);
    }

    $total = $checkout->discountedTotal ?? $game->calculateTotal($items);
    $game->close(items: $items, total: $total);

    if (! $checkout->paidInCash) {
        $game->member->debit($total, reference: $game);
    }
});

Asaan Optics has the same shape across more records. A counter sale picks items from the shop’s frame, lens and product stock and records the payment, and POS money events go into that terminal’s cash-drawer ledger. Each money event and its terminal sequence number are written in one transaction, and amounts are stored in paisa as whole numbers so floating-point rounding cannot make the drawer drift from the receipts. Shifts open and close with X and Z reports, so the cash counted at closing can be compared with what the system recorded.

Drivoli Optics, built on the Asaan Optics base for an optical retail client, adds branches to the picture: each branch’s stock and sales stay separate, while the owner can switch between branches to see each one. Every extra dimension like this multiplies the number of records one sale is allowed to touch, and the number it must never touch.

How I get the rules out of a business

The rules are rarely in the brief. These are the habits that help me find them before the client finds them for me.

  • Walk through one real day, not the menu. Ask what happens at opening, at the first sale, when someone is late, when a customer pays half. The rules show up in the exceptions.
  • Look at the paper. Track Cue and Asaan Optics both replace paper registers, and a register’s columns are a good first draft of the data model.
  • For every action, list every record it changes. If that list has more than one item, it belongs in a transaction.
  • Separate each rule from its numbers. Office hours, grace period, the IP allowlist, tax rates and payment terms are settings the business can change without calling a developer.
  • Name the exceptions out loud: remote staff, half days, discounts, customers on credit. Each one is a branch in the code that somebody will reach sooner or later.
  • Prefer rules people can explain. If a manager cannot explain a score or a status to an employee, it will be disputed.
  • Plan for the second round. SoftHouse Pro was already deployed when most of its attendance controls were added. That is normal, and it belongs in the schedule.

The practical takeaway

For founders and business owners: when you brief an ERP, the list of screens is the least useful part. Bring the exceptions, the paper or spreadsheets you use today, and the numbers you need to trust at the end of each day.

For developers: estimate the rules, not the tables. Write down what each action changes, wrap it in a transaction, keep thresholds in settings, and make every calculated number explainable. CRUD gets you a working demo. The workflow rules are what keep a business using the system after the demo.

Have a software idea or a business process that needs a proper system?

Tell me what you are trying to fix. I will reply with honest questions, a practical plan and a realistic estimate.