Case study
SmartStock
- Category
- Full-Stack
- Period
- Jul 2026 – Oct 2026
Inventory ledger and demand forecasting for warehouse operations
- Laravel 12
- Inertia.js
- Vue 3
- OLS Linear Regression
- PHPUnit
Context
Small-to-mid warehouse operations still track inventory in spreadsheets. That works until someone asks two questions: which item is going to run out before the next resupply arrives? and why did stock go up by forty when there is no purchase order?
Neither question has an answer in a spreadsheet. Reorder decisions fall back to memory, and the stock figure is whatever was last typed in.
Problem
Two separate gaps, usually treated as one:
- Recording. Stock movements are logged, but not in a form that can be audited. A partially received purchase order becomes a row that nobody can explain later, and the balance is a number someone maintains by hand.
- Forecasting. There is no demand signal at all. Reorder timing is reactive — stock runs out, then someone reacts.
Handling only one of them leaves the other as the weak point, because a forecast is worthless if the movement history feeding it is not trustworthy.
Solution
One Laravel application covering both. An inventory submission produces a single document with many lines, so a purchase order covering twelve SKUs is one record rather than twelve. Those documents feed a live, read-only ledger where every row explains where its number came from.
The same history drives the forecast: a nightly job condenses raw movements into daily aggregates, fits an ordinary least squares regression per product, and projects demand for the target month. Past predictions are then scored against what actually shipped, so the model reports its own error rather than asking to be trusted.
Architecture
inventory_transactions stores the document; inventory_movements stores its
lines. Movement type, recorder and supplier live on the header.
The central decision is that stock has exactly one door. Every mutation —
inbound, outbound, opname, including opening stock from the product form —
passes through InventoryMovementService inside a single transaction with the
product row locked for update. The invariant
SUM(inventory_movements.quantity) == products.current_stock
is therefore structural rather than something each call site has to remember. A code path that writes stock without that service is a bug by definition.
Authorization is enforced by value, not only by route. The product form is writable by both roles, so it is the request validator that refuses a non-zero opening stock for a manager, and the form does not render fields that would be rejected. A form that cannot be saved is a trap.
The interface is a single Inertia + Vue application — no separate API — with hand-rolled SVG charts instead of a charting dependency.
Challenges
Partial receipts. A document is whole: if line eight cannot be fulfilled, the entire document fails and nothing is written. A partially received purchase order is therefore recorded as a partial receipt, not as a failed document.
Forecasting the running month. Forecasts are computed from history up to the last day of the previous month and projected across the whole target month, so no future data leaks in and re-running the same day returns the same numbers. The current month is predicted as well — skipping it left a permanent gap in the dashboard.
Scoring only elapsed months. Scoring a partial month makes actual demand look artificially low, so every forecast reads as an under-prediction and the report ends up measuring the calendar instead of the algorithm.
Mojibake. Non-ASCII characters kept slipping into the source through
copy-paste and machine translation. A blacklist would not have caught the cases
that had not happened yet, so SourceEncodingTest maintains an allowlist of
every non-ASCII character in the tree instead.
Silent frontend breakage. Vite only transpiles, so an unbound identifier or a
missing import in a .vue file passes npm run build and fails in the browser
at runtime. Two tests parse every .vue file so the build catches it instead.
Results
The suite is 505 tests running against in-memory SQLite, so it never touches a development database, and it covers authorization boundaries, ledger invariants, date handling, the regression maths and the seeder output.
The ledger invariant cannot be violated without it being visible in a diff, the accuracy and reorder reports both export to CSV, and low-stock notifications are debounced so a single low item cannot flood the in-app bell.
Demo data seeds 120 days of movement history and runs the ETL jobs for real, so every screen has enough data to exercise on a fresh install.


