The Problem
The company was migrating from first-party retail to a multi-seller marketplace. That shift split a single customer order into multiple shipments—each a distinct fulfillment unit with its own status, pricing, and actions.
Two fulfillment models drove the split:
- Seller-fulfilled — marketplace sellers ship their own items. Operations staff monitor these but do not run warehouse actions on them.
- Warehouse-fulfilled — in-house inventory (stock held and packed by the company) ships through internal fulfillment workflows.
A single checkout could now contain both types, plus separate warehouse groupings when items came from different internal sources.
The legacy operations order page was the daily working surface for fulfillment, customer care, and operations teams. Every part of it assumed one order meant one shipment: page layout, action buttons, invoices, merge rules, coupon application, comments, permissions, and financial fields.
When the data model changed underneath, the page didn’t just need new UI—it needed a new architecture. Wrong statuses, illegal merges, or drifting delivery prices weren’t cosmetic bugs; they meant mis-shipped orders and accounting inconsistencies.
Constraints
The business could not stop. Legacy single-shipment orders had to keep working on the existing page while new multi-shipment orders needed an entirely rebuilt experience.
Financial fields—delivery price, delivery cost, discounts—had to stay consistent through the transition. Different teams needed different views: warehouse fulfillment staff should see only shipments they physically handle, while customer care needed the full order picture. External carrier APIs and downstream consumers of order data were not changing; this was surgery on a running system, not a greenfield rewrite.
Solution Architecture
Router-Level Backwards Compatibility
Rather than a big-bang cutover, I introduced a compatibility router at the page level: orders with shipments route to the new page; everything else stays on the legacy page. Two generations coexisted in production while the migration shipped incrementally.
Derived Order Status
In a multi-shipment world, storing a single order-level status independently creates drift. I made overall order status derived from shipment statuses—Completed when all shipments are delivered, In Progress when any are pending, Cancelled when all are cancelled—with an explanatory tooltip. One source of truth, never stored separately.
Money Model Follows the Shipment Model
Financial integrity was treated as part of the architecture, not an afterthought:
- Delivery cost recalculated as the sum of shipment delivery prices.
- Merged shipments reset delivery price to zero when two warehouse-fulfilled shipments in “New” status were legally combined.
- Financial edits locked after dispatch and accounting treatment.
- Coupons, invoices, and payment splits applied at shipment scope.
Fulfillment-Mode Permissions
Seller-fulfilled and warehouse-fulfilled shipments follow different operational rules, and the UI had to encode that:
- Seller-fulfilled shipments carry no warehouse action buttons—the seller handles picking, packing, and dispatch.
- Warehouse fulfillment staff see a filtered view showing only shipments the internal team is responsible for.
- Seller-specific actions are gated behind dedicated permissions so teams cannot perform operations outside their role.
Immutable Item Snapshots
Every item modification or swap creates a fresh snapshot record—name, price, size, barcode, images at purchase time—so dispute resolution always has an audit-proof record of what the customer actually ordered.
Technologies
- Backend: Laravel, PHP
- Admin & UI: Livewire, Filament
- Database: MySQL
- Queues: Laravel queue workers for dispatch and async processing
- Testing: Pest (feature tests), PHPUnit (unit tests)
Engineering Decisions
Incremental migration over big-bang rewrite
~30 merged PRs over four months shipped the page piece by piece—containers, shipment-scoped comments, invoices, merge machinery, delivery-price integrity, cancellation refactors—while the business kept running.
Derived state over duplicated state
Order status computed from shipment statuses trades a cheap stored column for guaranteed consistency. In a system where money and fulfillment depend on shipment truth, that consistency matters more than convenience.
Prototype, then re-cut clean
Two large header redesigns were deliberately closed unmerged—throwaway iterations to explore layout before the accepted shape landed in smaller, focused PRs. Risky UI restructures benefited from disposable prototypes rather than polluting main history.
Financial invariants as permanent rules
Delivery cost follows shipment prices. Edits lock after dispatch. Snapshots recreate on every modification. These weren’t feature requests—they became permanent integrity rules encoded in the domain.
Difficult Problems
Queue serialization nulling dependencies
ShipmentTotalsCalculator held constructor-injected objects that became null when dispatch jobs serialized. The fix: convert to method injection across all 18 call sites in one atomic PR, plus eager loading where relations could be unloaded inside closures.
Partial-failure bulk cancellation
Bulk cancel aborted all-or-nothing when one shipment failed. Refactored to continue processing remaining shipments so operators weren’t blocked by a single edge case.
Legacy collation vs modern input
Customer name search broke on emoji input against a legacy database character encoding that could not store four-byte characters. The column could not be upgraded cheaply, so input is sanitized before querying—a pragmatic boundary between old schema and modern user behavior.
What I Learned
The biggest lesson was that structural data-model migrations on live, business-critical systems require compatibility strategy first, features second. The router-level backcompat decision de-risked everything that followed.
I also learned that financial and audit models must migrate with the data model, not after it. Delivery prices, coupons, invoices, and snapshots all had to follow shipment scope from the start—bolting them on later would have been far more expensive.
Perhaps the most durable takeaway was about queue serialization: constructor injection feels natural in Laravel, but serialized jobs have different rules. That fix became a permanent team lesson, and the migration itself became the daily operational cockpit for the marketplace era—shipped incrementally, with the business running throughout.