Skip to content

Point of sale and stock control

← Projects

2026Point of sale and stock controlBUILDING· 2026

Shopfront POS

A till and inventory application in Laravel — and an audit of my own code, which turned out to be selling stock it did not have and letting the browser decide what things cost.

The words "Shopfront POS" set on pale slate, beside four shelf columns standing in for stock levels, the third nearly empty and lit in indigo
Role
Solo developer
Architecture
Laravel 10 + Blade, JSON API
Scope
7 models, 11 migrations, 32 routes
Tests
27 feature tests on MySQL
Laravel 10MySQLBladeJWTDomPDFChart.jsPHPUnit

01Outcome

security holes found and closed
3
stock tracking before this pass
0
assertions, all passing
93

02The story

What it is

A point-of-sale application for a small shop: a till, a catalogue, customers, invoices, and stock. One account is one shop, and every query is scoped to its owner.

I had written most of it earlier as a way to learn Laravel properly. Coming back to it to put it in this portfolio, I read it as if someone else had sent it to me — and found enough wrong that the honest thing to write up is the audit rather than the feature list.

The thing it was named for and did not have

The repository is called Inventory Management System. There was no inventory.

A sale wrote an invoice row and left the catalogue completely untouched. Nothing in the schema could answer "how many are left", so nothing could stop you selling the twentieth of three, and there was no page anywhere that would tell a shopkeeper to reorder.

Stock now exists in two pieces, because one is not enough. products.stock is the balance. stock_movements is how the balance came to be — a signed row for every change, with the reason and the resulting total:

movement reasons
4
row per stock change
1
ways to edit the balance directly
0

The last of those is the decision I would defend hardest. Stock is deliberately not a field on the product form. It moves through restocking and through sales, both of which write a ledger row. Letting a form overwrite the balance would put the number and its history permanently out of step, and then the ledger is decoration.

Selling takes a row lock while it checks and decrements, so two tills reaching for the last unit at the same moment cannot both read "1 left" and both succeed. Deleting an invoice puts everything on it back and records that as a reversal rather than quietly editing the earlier rows.

Three holes, all the same mistake

The server believed the browser. invoiceCreate read total, vat and payable straight out of the request body and wrote them to the invoice. The browser was not merely displaying the price, it was setting it — a hand-written POST could buy a desk lamp for a dollar.

The request now carries only what the browser is entitled to decide: which products, how many, which customer, what discount was agreed. Every figure is computed server-side from the catalogue. I found this one properly by accident, while testing: I fed the endpoint a line the page had priced at $1,450 and the server charged $765.45, because $1,450 was never a real price for that product.

Deleting an invoice needed only its number. The child rows were scoped to the owner; the parent was not. Invoice::where('id', $inv_id)->delete(). Any signed-in user could delete anybody's sale by guessing an integer. The ownership check had been written — it just was not applied where it mattered, which is the version of this bug that survives review.

The request chose which file to delete. Product delete and update read a file_path field out of the POST body and handed it to File::delete(). The path now comes from the product row, and the resolved path is confirmed to sit inside public/uploads before anything is removed. Uploaded images get a generated filename too — the original was pasting getClientOriginalName() straight into the path.

The bug that only MySQL could see

password was VARCHAR(50). A bcrypt hash is sixty characters.

It had never failed, because the project was built against SQLite, which does not enforce VARCHAR lengths. The column was wrong from the first migration and the development database politely agreed with it. Moving to MySQL, every single registration died on Data too long for column 'password'.

That is why the test suite runs on MySQL and not on an in-memory SQLite database. A suite on SQLite would have agreed with the bug rather than caught it, and the queries here are MySQL's anyway — DATE(), CAST(x AS UNSIGNED), SELECT … FOR UPDATE.

feature tests
27
assertions
93
failures
0

Smaller things, found by reading

Not everything was structural. These are the ones worth listing because each is a different way for code to look fine and not work:

  • The sales report passed FormDate as both ends of its range, so every PDF claimed to cover a single day whatever you asked for.
  • The reports page had its <script> after @endsection. Blade discards anything a child template puts outside a section, so the Download button called a function that never reached the page at all.
  • Registration validated the form, showed a message for the empty field, and then fell straight through and posted anyway — no return after the warning.
  • The OTP and password-reset forms caught every error into an empty block. A wrong code left the loading bar running and said nothing.
  • ResetPass() called event.preventDefault() without declaring event. That works in Chrome, which still exposes window.event, and does not in Firefox, where the form did a full page submit instead.

The interface

The application worked before this and looked like a template. I rebuilt the front end on a small token-based stylesheet — one accent, three stock colours, one radius, one shadow — over the Bootstrap that was already there.

The accent is indigo rather than the magenta it started with, chosen partly because it sits far enough from green, amber and red that a primary button and an "in stock" badge can never be confused. That matters more here than it sounds: colour is doing real work on this screen.

The dashboard was seven identical counters. It is now ordered by the question being asked — money taken today first, catalogue counts last — with a takings chart, a reorder list, best sellers and recent sales. The till became three columns in the order the sale actually happens. Every error message names the product and the number left, because "something went wrong" tells a cashier with a queue in front of them nothing they can act on.

What is honestly not done

It is not deployed, so it has no users and no outcome section. Payment is a total on a screen, not a card reader. There is one role — the shop owner — with no staff accounts or permissions, which is the next real piece of work and a much larger one than it looks. Sales tax is a single fixed rate.

What the project produced is the thing I wanted from it: reading my own Laravel code carefully enough to find three ways it could be abused, and being able to say exactly why each one was wrong.

03Detail

The till screen with three items on a sale for Sarah Mitchell, a five percent discount, and a payable total of 57 dollars 58
The till. The shelf shows what is left after the basket, not before it, and the totals on the left are a preview — the server recomputes every figure from the catalogue when Confirm is pressed.
The dashboard showing takings today, a fourteen-day sales chart, a needs-reordering panel with two products out of stock, best sellers and recent invoices
The dashboard answers the two questions a shop actually opens it for — what came in today, and what is about to run out. The amber and red tiles only colour when there is something wrong.
The products and stock table, each row showing a stock badge, retail value, and buttons to restock, view history, edit and delete
Stock is a badge with three states, not a number. Out of stock is its own state because a product with none left cannot be sold at all, while a low one still can.