Case Study • System Design & Backend Engineering
Auntie Jay's ERP
A batch-traceable inventory, contract, and ledger system for a cold-chain poultry distribution business, built to replace a notebook and a shared Google Doc.
- Client
- Auntie Jay's Farm, family-run poultry & cold-chain distribution
- Role
- Sole engineer, architecture, backend, admin UI
- Stack
- Laravel 12 • Filament 3 • MySQL
- Status
- Live in production
01 Problem
A notebook and a Google Doc were the entire system of record
Before this existed, Auntie Jay's Farm tracked stock and sales the way most small distribution businesses start out: a physical notebook for what came in and went out, and a shared Google Doc for anything that needed to be typed up. There was no structured link between the two.
In practice, that produced four concrete, recurring problems:
- Misplaced records. A notebook has one copy. If a page was misread, skipped, or the book itself misplaced, that transaction's history was simply gone.
- Disputes with customers over quantities. With no per-delivery paper trail tied back to a specific batch, a disagreement about how much stock a customer actually received had nothing authoritative to settle it.
- Slow, manual invoicing. Every invoice was typed by hand from whatever the notebook said, with no automatic link to price, contract terms, or what had already been delivered against an agreement.
- No visibility into contract fulfillment. A sales contract is a prepaid bulk agreement: a customer commits to a volume at a locked-in price. Knowing whether that volume had been fully delivered meant manually re-adding notebook entries; there was no running answer to "is this contract done yet?"
Month-end reconciliation meant reconstructing the story from two disconnected sources after the fact, rather than reading it off a system that had been keeping it in sync all along.
02 Approach
Six decisions that shaped the system
The build is a single Laravel/Filament admin panel: no separate API, no SPA. That constraint is itself a decision (more on it in the architecture section below). What follows is the reasoning behind each core module, not just what it does.
app/Models/InventoryBatch.php · app/Observers/DeliveryObserver.php
Batch-level inventory and mass balance
The unit of stock truth is the batch, not the product. Every arrival from a supplier (a specific weight, at a specific cost, on a specific date) becomes its own InventoryBatch row, and every delivery item traces to exactly one batch rather than drawing from a pooled product total. That granularity is what makes two things possible: accurate landed cost per batch (cost_price_per_kg + logistics_cost / quantity, computed on save so profit reporting never depends on a manual entry step), and a genuine mass-balance guarantee. The sum of everything dispatched against a batch (contract deliveries, spot sales, internal use) can never exceed what physically arrived.
That guarantee runs into a real physical constraint: frozen chicken picks up or loses a small amount of weight in transit from ice glaze and scale variance, so a strict equality check would reject legitimate deliveries constantly. Rather than loosen the rule, a fixed 100kg tolerance buffer was added specifically to that check. When the catalog later grew to include a product sold by count rather than weight (egg crates), that tolerance had to become conditional on the product's unit of measure, since a crate count has no equivalent physical variance to absorb.
Internal consumption (kitchen use, gifts, spoilage) is deliberately its own BatchAdjustment record rather than a silent decrement, so the batch trace report can show, per batch, what was invoiced out, what was used internally, and what's unaccounted for. That's a real yield and shrinkage answer instead of just trusting whatever's left in the cold room.
Screenshot: Batch Trace
app/Models/SalesContract.php • recalculateChain()
The rollover chain engine
A sales contract locks in a price and a volume, prepaid-wallet style. The complication: customers don't always take exactly their contracted volume in one pass. SalesContract::recalculateChain() walks a customer's contracts oldest-to-newest and treats any volume delivered past one contract's ceiling as a debt rolled forward onto the next contract. It recurses, so a customer's entire history stays internally consistent every time a delivery status changes.
The non-obvious rule sits at the start of that chain: the very first ("genesis") contract in a customer's history is deliberately exempted from having its rollover value overwritten by the recalculation. That number can represent a manually-entered opening balance, debt carried over from the notebook era before the system existed. Blindly recalculating it away would silently erase real history on go-live day.
Evidence: commit a46841e
fix(contracts): preserve historical genesis debt from automated recalculation
Payment status is kept in sync the same way, automatically: a CustomerLedger observer recomputes whether recorded payments cover what's actually billed (rollover debt plus everything delivered to date) every time a payment is recorded, edited, or removed, or a new delivery adds fresh debt on top. That's the mechanism behind real-time visibility into whether a contract is fulfilled and paid. Nobody has to cross-check a bank statement against a paper ledger to answer it.
Screenshot: Contract Statement
app/Models/Delivery.php
Deliveries: two lanes, one trigger
Deliveries split into two lanes, CONTRACT (against a locked-in agreement) and SPOT (walk-in, ad hoc pricing), sharing one table with a delivery_type discriminator. The physical act of dispatching stock is identical between them; only the pricing source differs.
Marking a delivery DELIVERED is the single event that deducts the batch, creates the customer's ledger debit at the correct rate for that lane, and re-runs the rollover chain for that customer, all through an Eloquent Observer rather than a queued job. That's a deliberate choice given the actual transaction volume: dozens of deliveries a day doesn't justify the operational overhead of a queue worker on this critical path. The one place a queue does earn its keep is the ledger export, below.
Screenshot: Itemized Invoice
database/migrations • create_master_ledgers_view.php
Two ledgers, one read-only view
CustomerLedger and SupplierLedger mirror the two sides of the business: money owed to the farm, and money the farm owes suppliers. Each carries the transaction types you'd expect (SUPPLY/PAYMENT, PURCHASE/DEPOSIT/REFUND). For the master reconciliation view, rather than duplicating that logic into a third Eloquent model kept in sync by hand, master_ledgers is a raw MySQL UNION ALL view over both tables, with synthetic C-/S- prefixed IDs so rows from either side stay unique in one stream. It's always current by construction (there's no copy to go stale), and Filament's queued exporter turns it into a CSV/XLSX download without blocking the admin UI while it runs.
app/Filament/Resources/*Resource.php • canAccess()
Access control, deliberately simple
There's no permissions package and no Policy classes. A plain role string (ADMIN / MANAGER / SALES) gates every Filament Resource inline through its own canAccess() method, and a separate is_active boolean means a brand-new account can't touch anything until an admin approves it. New signups fire a database notification to every active admin automatically.
The tradeoff of that simplicity is exactly what you'd expect from duplicating a string check across a dozen files instead of centralizing it, and a recent pass over the codebase surfaced a live instance of it:
Found in review: app/Filament/Resources/DeliveryResource.php
return ! in_array($user->role, ['superadmin', 'admin']);
The system only ever assigns uppercase roles (ADMIN, MANAGER, SALES). This lowercase comparison (it gates whether an admin can revert a delivery out of DELIVERED status) can never match, so that specific override is currently unreachable through the UI. Contained, non-destructive, and a clean argument for centralizing this into a Gate the next time this area is touched.
Products, DeliveryItem, invoice line rendering
Extending the model without a rewrite
The system launched kg-only, built around frozen chicken. Adding eggs, sold by the crate rather than by weight, without a rewrite meant giving Product a unit field and then finding every place "kg" had been hardcoded as a display string rather than derived from the product: Filament form suffixes, invoice line items, dashboard aggregates, print statements. Around two dozen files in total.
The more interesting bug was conceptual, not textual. A customer statement's "total volume this period" figure had, until then, gotten away with a flat sum('weight_kg') across every delivery in the period, because every product shared one unit. The moment a customer could buy chicken (kg) and eggs (crate) in the same statement period, that sum became meaningless. 5,000 of one thing plus 20 of a completely different thing is not 5,020 of anything. The fix was to group the aggregate by unit instead of collapsing it, which is the same category of mistake (blending two numbers that aren't actually the same quantity) that the rollover engine had already run into once, in a different part of the system:
Evidence: commit 3e018bc, six months earlier
fix(statements): decouple contract rollover and overflow logic from general customer ledger for mathematical accuracy
Same root cause, two separate places, months apart. That's a recurring shape of bug in a system that aggregates money and quantity across more than one dimension (per-contract vs. per-customer, per-product vs. per-unit), worth watching for deliberately rather than catching by accident each time.
Screenshot: Delivery Form
03 Architecture
How it's actually wired together
| Concern | Approach |
|---|---|
| Frontend | No API, no SPA. Filament (Livewire under the hood) is the entire authenticated UI. A handful of plain Blade routes render print-friendly invoices, waybills, and statements outside the admin shell, so they open cleanly in a new tab and print to PDF from the browser. |
| Auth | Laravel's session auth via Filament's panel login. Authorization is the role/is_active scheme described above: no OAuth, no API tokens, because this is a single-organization internal tool, not multi-tenant software. |
| Background work | Database-backed queue (QUEUE_CONNECTION=database), used specifically for Filament's ledger export job and its completion notifications. Everything else (stock deduction, ledger entries, payment-status sync) runs synchronously through Observers. |
| Deployment | cPanel Git Version Control on shared hosting, with public_html symlinked directly to the repository's public/ folder, so a git pull is effectively the deploy, with no separate build/copy step. Migrations still run manually over cPanel's Terminal, a real constraint of the hosting tier rather than a design choice. |
04 Result
What changed for the farm
These are the outcomes reported by the business after the system replaced the notebook and shared doc:
Customer disputes
Fewer disagreements over delivered quantities. Every delivery is now a traceable, printable record tied to a specific batch, instead of a notebook line nobody can cross-check.
Month-end close
Faster reconciliation. The ledger and statement views are the record, not a reconstruction exercise pieced back together from paper and Google Docs after the fact.
Invoicing
Faster turnaround. Proforma, summary, and itemized invoices generate directly from contract and delivery data instead of being typed up by hand.
Contract visibility
A real-time answer to whether a sales contract is fulfilled and paid, a status the business used to tally by hand and now gets maintained automatically as deliveries and payments happen.