Under the hood

Updated

Backend & interfaces

Business rules live in small action classes, every stock change lands in a journal, and the systems talk to each other only through logged HTTP messages. A scheduler, not a person, keeps the Warehouse moving.

Actions carry the rules

An action is a plain PHP class in app/Actions with one public method, usually handle(). It has no base class and no framework magic: Laravel’s container builds it and injects what its constructor asks for. ConfirmPick, ReceivePallet, CloseShippingUnit, and ReleaseOrder are typical names.

Whoever wants something done calls the action. The desktop controller, the Handheld controller, and the virtual crew all call ConfirmPick for a pick, so the rule about what a pick may do exists once. When a rule is broken, the action throws a validation exception, which a page shows as a form error and the interface answers as a failed message.

Controllers therefore contain almost no decisions, and the feature tests can check a rule through any door.

The data model

The pallet is the centre of the WMS. Stock is never a number on an article: it is the sum of the pallets that carry the article, each with its SSCC, quantity, batch, best-before date, and location. A rack location holds at most one pallet; gates and packing stations hold several.

Work is modelled as tasks that point at pallets: a putaway task, a replenishment task, a pick task. A pick task belongs to one order line and takes from one pallet, which is how a reservation is stored. Picked pieces become shipping unit items inside shipping units, and units leave the building with a pickup.

The ERP side mirrors this with its own tables for articles, customers, orders, purchase orders, despatch advices, and sent emails. The carrier keeps shipments and tracking events, the supplier keeps its deliveries.

InboundPurchase orderfrom the ERPDespatch advicefrom the supplierAdvised palletSSCC, quantityStockWarehousea buildingLocationrack place, gatePalletSSCC, articleStock movementthe journalOutboundCustomer orderfrom the ERPOrder linearticle, quantityPick taskone palletShipping unitparcel, SSCCPickupcarrier handover
The main WMS tables. An arrow reads “has many”.

Transactions, locks, and numbers

Several people and a virtual crew work on the same stock at the same time, so every action that changes data runs inside DB::transaction(). Before it decides anything, it reads the rows it is about to change again with lockForUpdate(). Two pickers who confirm the last pieces of one pallet at the same moment are therefore handled one after the other, and the second one sees what the first one left.

Numbers that must never repeat come from the table number_sequences: one row per sequence, locked, read, and incremented in the same transaction. The SSCC is built from it as extension digit, company prefix, serial number, and the GS1 check digit, 18 digits in all. ERP order numbers, purchase order numbers, and carrier tracking numbers use the same mechanism.

Every change to a pallet writes a stock movement through Pallet::journal(). The movement copies what it needs: SSCC, article, quantity afterwards, batch, best-before date, both location codes, and the user. The journal stays readable after the pallet is dissolved or the location renamed.

The stock report to the ERP is sent only after the transaction has committed, so the ERP never hears about a booking that was rolled back.

Interfaces between the systems

The systems exchange JSON over HTTP, even though they live in one application. The ERP calls the WMS at /api/articles, /api/orders, /api/purchase-orders, and /api/despatch-advices. The WMS calls the ERP at /api/erp/… to report stock, releases, shortened lines, shipments, and receipts, and the carrier at /api/carrier/… to book packages and pickups.

Every call carries a bearer token. There is one token per direction (wms, erp, carrier), compared in constant time; without its own setting, a token is derived from one shared secret with HMAC-SHA256, and the secret itself is never accepted. A body over 1 MiB is refused, and two rate limits apply: 1,200 calls a minute per IP address and 600 per token.

The receiving side stores the raw message before it looks at it. Only then does it validate and apply the content inside one transaction. A message ends as processed or as failed with the reason, and the answer (202 or 422) carries the message number. Both logs are visible under Integration in the WMS, so a rejected order can be read exactly as it arrived.

There is no automatic retry, and a failed report never undoes the work in the Warehouse. An administrator resends failed messages from the log. Repeats are harmless by design: an order is matched by its order number, a stock report older than the last one applied is ignored, and packages are matched by SSCC.

ERPSenderWMSReceiverPOST /api/orders + tokenLog the raw message: receivedValidate, apply in one transaction202 processed, or 422 failedLater, at releaseLog the report: pendingPOST /api/erp/order-releases202: the report is marked sent
An order from the ERP, and the release report back.
ERP to WMSArticles, customer orders, purchase orders, despatch advices; create or update with POST, cancel with DELETE
WMS to ERPStock changes, order releases, shortened lines, shipped orders, purchase order receipts
WMS to carrierBook a package and get its tracking number; report a closed pickup
Inbound statusesReceived, processed, failed
Outbound statusesPending, sent, failed, with a count of attempts

The scheduler drives the Warehouse

Nothing in the Warehouse waits for a button. Laravel’s scheduler starts eight jobs, each protected against overlapping with itself. Six run every minute: releasing due orders, planning replenishment and putaway, letting the demo customers order, and moving the carrier’s and the supplier’s shipments along. Purchasing runs on weekdays at 10:00.

The crew runs every 15 seconds. Each crew member is a user with a role and a time until which they are busy. A run picks the members whose time has passed and lets each do one step through the same actions the Handheld uses. The step returns how many seconds it takes, which becomes the new busy time. A picker walking to the next location is therefore just a row that is not due yet.

There are no queued jobs behind this. Apart from emails, everything happens inside the scheduled command or the web request that caused it, which keeps the order of events easy to follow.

Every 15 secondsThe virtual crewcrew:workEvery minuteOrders and partnerswms:release-orderswms:plan-replenishmentwms:plan-putawaycustomers:ordercarrier:advancesupplier:advanceWeekdaysPurchasing10:00erp:reorder0 s15 s30 s45 s60 s
What the scheduler starts within one minute.

Chance, and replaying days

The demo needs chance: a short pallet, a late truck, a customer who orders now and not in an hour. All of it comes from three small classes, CrewLuck, DeliveryLuck, and SupplierLuck. The tests replace them with fixed versions, so a test can say that this pick comes up two pieces short.

Customer demand is a formula, not a script: about 100 orders a day, weighted by weekday and by hour, and multiplied by a mood between 0.9 and 1.1 that is derived from the date. Every minute, the command draws against the chance for that minute.

demo:reset rebuilds the whole world. It clears the business tables, sets the clock three weeks back, lets the suppliers deliver the opening stock, and then replays the last three days in steps of 15 seconds, running every scheduled job that would have been due. During the replay, interface calls are handed to the application directly instead of going over the network, so they see the same pretended clock.

Accounts and sign-in

Laravel Fortify provides registration, sign-in, password reset, email verification, two-factor authentication with recovery codes, and passkeys. Every page that changes something requires a verified email address.

Sign-in is limited to five attempts a minute per email address and IP address, the two-factor challenge to five, passkeys to ten. Pages that ask for the current password have their own limit. In production, a password needs at least 12 characters with mixed case, numbers, and symbols, and must not appear in a known data leak.

Changing the email address is confirmed by a link sent to the new address before it takes effect.

Rights and privacy

There are three levels. Guests can read almost every page. Signed-in users can order in the Shop and work on the Handheld. One gate, admin, protects master data, stock corrections, and resends; it is attached to the routes as can:admin.

Because real people can sign up, personal data is masked for everyone else. Records created by a real person carry an owner. When a page is rendered for somebody who is neither the owner nor an administrator, the name becomes Shop customer and address, email, and phone are removed, also inside logged message payloads. Real people on the Handheld appear as Warehouse helper in the same way.

Masking the display is not enough, so search and sorting are restricted too: a stranger cannot find a hidden name by typing it or learn it from the sort order. Deleting an account anonymises the customer, the stored emails, and the message payloads in one transaction.

Emails leave the application only for customers that belong to a user account. The made-up demo customers use reserved domains such as .example, and their mail is stored in the ERP’s Sent emails without ever reaching a mail server.