dvdoug/boxpacker
A PHP library for 3D bin packing: given a set of items and boxes, it works out which boxes to use and how to arrange the items inside them, minimising waste. Suited to e-commerce shipping, with support for multiple box sizes, weight and dimension limits, and item rotation.
Install and confirm the version
composer require dvdoug/boxpacker:^4.3
4.3.0 is a patch-level release on the 4.x line: no signature changes, no deprecations, no config changes. Upgrading from 4.2.x is a composer update with no code edits. If you are still on 3.x, treat it as a migration (the Packer API moved from setter-based setItemList()/setBoxList() to pack(ItemList, BoxList)), not as a patch bump.
The five objects you actually touch
Item — one thing being shipped: three dimensions plus weight.Box — one carton: outer dimensions for display, inner dimensions for packing, plus max weight and empty weight.ItemList / BoxList — typed collections; build these, don't pass arrays.Packer — the entry point; returns a PackedBoxList.PackedBox — one carton with getItems(), getWeight(), getUsedVolumePercentage(), getRemainingSpace().First use case: "how many cartons for this order?"
use DVDoug\BoxPacker\Box;
use DVDoug\BoxPacker\BoxList;
use DVDoug\BoxPacker\Item;
use DVDoug\BoxPacker\ItemList;
use DVDoug\BoxPacker\Packer;
$boxes = new BoxList();
$boxes->insert(Box::create(
reference: 'SMALL',
outerWidth: 200, outerLength: 150, outerDepth: 100,
emptyWeight: 120,
innerWidth: 190, innerLength: 140, innerDepth: 90,
maxWeight: 5000,
));
$items = new ItemList();
foreach ($order->lines as $line) {
$items->insert(Item::create(
description: $line->sku,
width: (int) $line->width_mm,
length: (int) $line->length_mm,
depth: (int) $line->depth_mm,
weight: (int) $line->weight_g,
));
}
$packed = (new Packer())->pack($items, $boxes);
$packed->count(); // number of cartons
$packed->getWeight(); // total shipped weight
Where to look first
PackedBox::getRemainingSpace() — returns an ItemList of void-fill blocks. This is the fastest win for dunnage calculation and for showing "87% full" in an admin UI.NoBoxesAvailableException — the exception you must catch on day one. It is thrown when no box in your list fits, which is a business problem, not a bug.Item/Box constructor argument order. Get this wrong and everything "works" while producing nonsense.Units: pick millimetres and grams and never mix them. The packer has no unit awareness; a single order in centimetres silently produces a box 10x too small.
Wrap it in one service, not in controllers
The packer is stateless and cheap to construct but expensive to run. Bind a single PackagingService in the container that owns box-list construction and exception handling, and inject it. Keep Packer itself out of your application layer so you can swap in a caching decorator later.
final class PackagingService
{
public function __construct(private BoxRepository $boxes) {}
public function pack(Order $order): PackedBoxList
{
try {
return (new Packer())->pack(
$this->itemList($order),
$this->boxList(),
);
} catch (NoBoxesAvailableException $e) {
throw new CannotPackageOrder($order, previous: $e);
}
}
}
Build the box list from the database, cache it
Boxes change rarely. Read them from a boxes table (so ops can edit dimensions without a deploy), map rows to Box objects, and cache the resulting BoxList under a key you bust on save. BoxList is serialisable, so Cache::remember('box-list', ..., fn () => $this->buildBoxList()) works.
Insert linked items consecutively
4.3.0 sorts linked items so they have the best chance of landing in the same carton. The practical implication for your code is that the packer now reorders your input around linked groups. If you group items that must ship together (a chassis and its PSU, a gift set), insert them adjacently in one pass and mark them as a linked group; do not interleave them with unrelated lines. If you were previously working around "linked items split across boxes" by manually chunking the ItemList into sub-orders and packing each separately, re-test that workaround — the sorter may make it unnecessary, and it was always costing you cartons.
Choose the strategy per order type, not globally
Three cheap knobs, worth exposing as config:
count() === 1, done. Only for the fast path.WeightRedistributor now sorts and no longer drops items, but it is still extra work — enable it only when count() > 1.BoxList per restriction rather than branching inside the pack call.Persist the result, don't recompute
Packing is NP-hard and gets slow fast as count($items) grows. Store the chosen box references, per-box item assignments, weights, and getUsedVolumePercentage() on the shipment record at checkout/pick-time. Downstream (labels, manifests, rate quoting) should read the stored decision. Recomputing on every page load is the single most common performance mistake with this library.
Queue anything catalog-scale
For a real order (≤ ~50 lines), packing is milliseconds. For "what box fits this 500-SKU pallet" or "simulate 10,000 orders after a box-table change", push it to a job. If you must do it inline, cap the ItemList and fall back to a conservative large box above the cap rather than timing out a request.
Fallback ladder for NoBoxesAvailableException
Handle it as a decision tree, not a 500: (1) retry with the next box size up; (2) split the order into two shipments and pack each half; (3) mark the order for manual packing. Log the offending item dimensions — nine times out of ten it's one line item with a data-entry error (a 3000mm "depth").
Verify item counts after upgrading to 4.3.0. The release fixes a WeightRedistributor bug that could silently drop items — the pack call succeeded, returned a PackedBoxList, and some of your items simply were not in any box. There was no exception. Two consequences:
Assert completeness defensively
$inputCount = iterator_count(clone $items);
$packedCount = $packed->getItemsPacked(); // or sum count($box->getItems())
abort_if($inputCount !== $packedCount, 500, 'Packing lost items');
Cheap insurance; catches both regressions and duplicate-item bugs in your own ItemList construction.
Do not depend on output ordering. 4.3.0 added sorting of linked items. Any code that assumes box N contains item N — or that the first returned PackedBox holds your heaviest item — was already fragile and now may break. Iterate and match on your own identifier (Item::getDescription()), never on position.
Outer vs inner dimensions is not a detail. The packer packs to inner dimensions but reports weight and volume using outer. If you only populate outer and leave inner at zero, you get NoBoxesAvailableException for everything. If you populate inner identically to outer, you'll systematically overfill cartons and get real-world damage. Model the wall thickness once in your Box factory.
Empty weight counts. Box::emptyWeight is added to the shipment weight for every carton used. Forget it and your carrier rates are off by ~120g per box — negligible on one parcel, a real reconciliation problem across a thousand.
PackedBoxList is not a list of PackedBox in insertion order you can trust, and getWeight() is aggregate. For per-box display, iterate foreach ($packed as $packedBox) and read getBox()->getReference().
Performance levers, in order of payoff: (1) cache the BoxList; (2) shrink the box list to plausible candidates before packing (filter by max weight and by a bounding-box check against the largest item) — this is nearly free and often a 10x win; (3) restrict WeightRedistributor to multi-box results; (4) persist results. Avoid tuning packer internals.
Testing: packing is deterministic given identical inputs and ordering, so snapshot tests are viable — but they will churn on minor-version releases like this one (sorting changed in 4.3.0). Assert properties instead: every item is packed, no box exceeds maxWeight, no box exceeds inner volume, carton count is within an expected band. Those tests survive upgrades; exact-assignment snapshots do not.
Extension points: if your rules outgrow the model (e.g. "box A only for hazmat SKUs"), don't fork the packer — implement Box/Item interfaces with your own classes carrying the extra metadata, and pre-filter the BoxList per order. Subclassing BoxList to insert()-guard against misconfigured cartons is also a clean place to fail loudly in dev.
Debugging a surprising result: print the box's inner volume and total item volume first. Most "the packer is broken" reports are a single item whose dimensions were entered in cm.
How can I help you explore Laravel packages today?