BoxPacker Integration
J2Commerce provides a built-in 3D bin packing system that any shipping plugin can use to determine how many boxes are needed for an order, what dimensions those boxes are, and what the total weight per box is. This information drives carrier API rate requests (UPS, FedEx, AtoShip, DHL, and others) more accurately than naive approaches.
The system has two parts:
ShipperHelper— A static helper that wrapsdvdoug/boxpackerv4. Call one method; get optimally packed boxes back in store units, ready for any carrier API.BoxPackerField— A Joomla form field that renders a dynamic table in plugin admin settings so store owners can define custom box sizes without writing code.
Overview
The Problem It Solves
Without ShipperHelper, shipping plugins use naive packing strategies:
- Single parcel — Sum all item weights, use max dimensions. One package per order regardless of how many items. Inaccurate for mixed-size orders.
- Per-item — One package per item unit. 50 items of a small SKU = 50 API requests for 50 tiny boxes. Inflated rates.
Neither approach fits items into the fewest boxes while respecting physical limits. The result is either underquoted shipping (store absorbs the difference) or overquoted shipping (customers abandon carts).
ShipperHelper uses the dvdoug/boxpacker v4 algorithm — a production-grade 4D bin packing solver — to optimally pack items into boxes you define. When no boxes are defined, it falls back to per-item mode automatically.
Architecture
Dependency
The BoxPacker library ships with J2Commerce at libraries/j2commerce/vendor/dvdoug/boxpacker/. ShipperHelper loads its autoloader on demand — no Composer setup required in your plugin.
Package: dvdoug/boxpacker ^4.0
License: MIT
PHP: 8.2+
Location: libraries/j2commerce/vendor/dvdoug/boxpacker/
BoxPacker v4 requires all dimensions in integer millimetres and all weights in integer grams. ShipperHelper handles this conversion transparently. Plugin developers work entirely in store units (inches, centimetres, pounds, kilograms, or whatever the store is configured for).
Quick Start
The complete integration for a shipping plugin takes five steps.
Step 1: Add BoxPackerField to your plugin XML
<!-- plugins/j2commerce/shipping_example/shipping_example.xml -->
<config>
<fields name="params">
<fieldset name="basic">
<field name="dimension_unit"
type="list"
label="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_DIMENSION_UNIT"
default="1">
<!-- your dimension unit options -->
</field>
<field name="weight_unit"
type="list"
label="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_WEIGHT_UNIT"
default="1">
<!-- your weight unit options -->
</field>
<field name="box_list"
type="BoxPacker"
label="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_BOX_LIST"
description="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_BOX_LIST_DESC"
addfieldprefix="J2Commerce\Component\J2commerce\Administrator\Field" />
</fieldset>
</fields>
</config>
Step 2: Read custom boxes in your rate handler
// File: plugins/j2commerce/shipping_example/src/Extension/ShippingExample.php
use J2Commerce\Component\J2commerce\Administrator\Helper\ShipperHelper;
public function onGetShippingRates(Event $event): void
{
$args = $event->getArguments();
$order = $args[0] ?? null;
if ($order === null) {
return;
}
// Load custom boxes defined by the store owner
$customBoxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'box_list');
// Pack items — empty boxes array falls back to per-item automatically
$items = method_exists($order, 'getItems') ? $order->getItems() : [];
$result = ShipperHelper::packItems($customBoxes, $items, [
'weight_unit_id' => (int) $this->params->get('weight_unit', 1),
'length_unit_id' => (int) $this->params->get('dimension_unit', 1),
]);
// $result->boxes is an array of PackedBoxResult objects
foreach ($result->boxes as $packedBox) {
// Build your carrier API request using $packedBox properties
$this->addCarrierPackage(
length: $packedBox->outerLength,
width: $packedBox->outerWidth,
height: $packedBox->outerHeight,
weight: $packedBox->totalWeight,
);
}
// Publish results back to the event
$rates = $this->fetchCarrierRates();
$event->setArgument('result', array_merge($event->getArgument('result', []), $rates));
}
Step 3: Register the field prefix in services/provider.php
The BoxPackerField lives in the core component namespace. No extra DI registration is needed — the addfieldprefix attribute in your XML handles it.
Step 4: Test with the admin preview
When you save the plugin and open its settings, the BoxPackerField renders a packing preview section below the box table. Add sample items matching a real order (dimensions in store units) and click Preview Packing. The preview calls the core component AJAX endpoint and displays results immediately.
Step 5: Handle unpacked items
if ($result->hasUnpackedItems()) {
// Items exist that don't fit any defined box.
// Options: log a warning, fall back to per-item for those items,
// or return an error rate forcing the customer to call for a quote.
foreach ($result->unpacked as $item) {
// $item is an array: description, length, width, height, weight
}
}
BoxPackerField XML Reference
The field type is BoxPacker. It renders a dynamic table in plugin admin settings where store owners add, edit, and remove box definitions. The JSON array of box definitions is stored in the plugin's params column.
Complete XML Snippet
<field name="box_list"
type="BoxPacker"
label="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_BOX_LIST"
description="PLG_J2COMMERCE_SHIPPING_EXAMPLE_FIELD_BOX_LIST_DESC"
addfieldprefix="J2Commerce\Component\J2commerce\Administrator\Field" />
Attributes
| Attribute | Required | Value | Notes |
|---|---|---|---|
name | Yes | Any string | Used as the key in plugin params. Pass this name to getCustomBoxesFromParams() as the second argument if it is not the default box_list. |
type | Yes | BoxPacker | Selects the BoxPackerField class. |
label | Yes | Language key | Shown as the field label above the table. |
description | No | Language key | Shown as help text below the label. |
addfieldprefix | Yes | J2Commerce\Component\J2commerce\Administrator\Field | Required so Joomla can resolve the BoxPacker type to BoxPackerField. |
Stored JSON Format
Each row in the table is serialized as a JSON object. The complete stored value looks like:
[
{
"name": "Small Box",
"outer_length": 30,
"outer_width": 20,
"outer_height": 15,
"inner_length": 29,
"inner_width": 19,
"inner_height": 14,
"box_weight": 0.3,
"max_weight": 10
},
{
"name": "Large Box",
"outer_length": 60,
"outer_width": 40,
"outer_height": 30,
"inner_length": 59,
"inner_width": 39,
"inner_height": 29,
"box_weight": 0.8,
"max_weight": 25
}
]
All dimension and weight values are stored in the store's configured units (whatever the store owner set up in J2Commerce configuration). ShipperHelper converts them to mm/g internally before passing them to BoxPacker.
Column Descriptions
| Column | Description |
|---|---|
| Box Name | Identifier passed through to PackedBoxResult::$reference. Use the carrier box name (e.g., "UPS Small Express Box") for clarity. |
| Outer Length / Width / Height | External dimensions of the box including packaging material. Used for volumetric weight calculations. |
| Inner Length / Width / Height | Usable interior dimensions. Items are packed against these constraints. If left empty, the field defaults to the outer dimensions. |
| Box Weight | The empty box's own weight (packaging material). Added to item weights for total package weight. |
| Max Weight | Maximum total package weight the box can carry. BoxPacker uses this to determine when to open a new box. Set to 0 for unlimited. |
ShipperHelper API Reference
Namespace: J2Commerce\Component\J2commerce\Administrator\Helper File: administrator/components/com_j2commerce/src/Helper/ShipperHelper.php
All methods are static.
packItems()
The primary entry point. Packs cart items into the fewest boxes possible.
public static function packItems(
array $boxes,
array $items,
array $options = []
): PackingResult
Parameters
| Parameter | Type | Description |
|---|---|---|
$boxes | array | Box definitions. Each element is an array with the keys described in the createBox() reference below. Typically from getCustomBoxesFromParams() plus any carrier preset arrays you define. Pass an empty array to activate per-item fallback. |
$items | array | Cart items or order items. Each element can be an array or object. ShipperHelper normalizes several property naming conventions — see Item Normalization below. |
$options | array | Packing options (all optional). See Options table below. |
Options
| Key | Type | Default | Description |
|---|---|---|---|
weight_unit_id | int | 1 | The store's weight unit ID from #__j2commerce_weights. ShipperHelper uses WeightHelper to convert from this unit to grams. |
length_unit_id | int | 1 | The store's length unit ID from #__j2commerce_lengths. ShipperHelper uses LengthHelper to convert from this unit to millimetres. |
default_weight | float | 0.1 | Fallback weight in store units for items that have no weight set. |
default_length | float | 1.0 | Fallback length in store units for items with no length set. |
default_width | float | 1.0 | Fallback width in store units for items with no width set. |
default_height | float | 1.0 | Fallback height in store units for items with no height set. |
rotation | string | 'best_fit' | Rotation mode. One of 'best_fit', 'keep_flat', or 'never'. See Rotation Options below. |
max_boxes_to_balance_weight | int | 12 (library default) | Passed to Packer::setMaxBoxesToBalanceWeight(). Controls weight distribution across boxes. |
Return value
Returns a PackingResult object. See PackingResult Reference below.
Behaviour
- Items with
shipping = 0(orcartitem->shipping = 0) are silently excluded. - Each item's
qtyis expanded: qty=3 creates 3 separate packer items. - If
$boxesis empty, delegates togetPerItemPackages()and returnsmethod = 'per_item'. - If the BoxPacker library is unavailable, logs a warning and falls back to
getPerItemPackages(). - Items that do not fit in any box appear in
PackingResult::$unpacked.
Example
// File: plugins/j2commerce/shipping_example/src/Extension/ShippingExample.php
use J2Commerce\Component\J2commerce\Administrator\Helper\ShipperHelper;
$customBoxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'box_list');
$carrierBoxes = $this->getUPSPresetBoxes(); // your own method
$result = ShipperHelper::packItems(
boxes: array_merge($carrierBoxes, $customBoxes),
items: $order->getItems(),
options: [
'weight_unit_id' => (int) $this->params->get('weight_unit', 1),
'length_unit_id' => (int) $this->params->get('dimension_unit', 1),
'rotation' => 'keep_flat',
'default_weight' => 0.5,
]
);
foreach ($result->boxes as $box) {
// $box->outerLength, $box->outerWidth, $box->outerHeight — in store length units
// $box->totalWeight — in store weight units
// $box->items — array of ['description' => '...', 'qty' => N]
}
getCustomBoxesFromParams()
Parses the JSON stored by BoxPackerField from plugin params into a box definitions array.
public static function getCustomBoxesFromParams(
Registry $params,
string $fieldName = 'box_list'
): array
Parameters
| Parameter | Type | Description |
|---|---|---|
$params | Registry | The plugin's $this->params object. |
$fieldName | string | The field name used in your XML. Defaults to 'box_list'. |
Return value
Array of box definition arrays, each with these string keys: name, outer_length, outer_width, outer_height, inner_length, inner_width, inner_height, box_weight, max_weight. Box rows where all three outer dimensions are zero are silently skipped. If inner_* values are absent, they default to the corresponding outer_* values.
Example
// Read custom boxes from the BoxPacker field named 'carrier_boxes'
$boxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'carrier_boxes');
createBox()
Creates a single box definition array in the format expected by packItems(). Use this in your plugin to define carrier preset boxes in code.
public static function createBox(
string $name,
float $outerLength,
float $outerWidth,
float $outerHeight,
float $innerLength,
float $innerWidth,
float $innerHeight,
float $boxWeight = 0.0,
float $maxWeight = 0.0,
): array
All dimension and weight values must be in the store's configured units. ShipperHelper converts them during packItems().
Example: UPS carrier preset boxes
// File: plugins/j2commerce/shipping_ups/src/Extension/ShippingUps.php
private function getUPSPresetBoxes(): array
{
// Dimensions in inches, weights in pounds
return [
ShipperHelper::createBox(
name: 'UPS Small Express Box',
outerLength: 13.0,
outerWidth: 11.0,
outerHeight: 2.0,
innerLength: 12.5,
innerWidth: 10.5,
innerHeight: 1.8,
boxWeight: 0.1,
maxWeight: 0.0, // no limit
),
ShipperHelper::createBox(
name: 'UPS Medium Express Box',
outerLength: 15.0,
outerWidth: 11.0,
outerHeight: 3.0,
innerLength: 14.5,
innerWidth: 10.5,
innerHeight: 2.8,
boxWeight: 0.15,
maxWeight: 0.0,
),
ShipperHelper::createBox(
name: 'UPS Large Express Box',
outerLength: 18.0,
outerWidth: 13.0,
outerHeight: 3.0,
innerLength: 17.5,
innerWidth: 12.5,
innerHeight: 2.8,
boxWeight: 0.2,
maxWeight: 30.0,
),
];
}
getPerItemPackages()
Generates per-item packaging: one PackedBoxResult per item unit. Called automatically by packItems() when no boxes are defined. Call it directly when you need per-item behaviour regardless of box configuration.
public static function getPerItemPackages(
array $items,
array $options = []
): PackingResult
The returned PackingResult has method = 'per_item'. Each PackedBoxResult in $result->boxes represents a single item unit with volumeUtilisation = 100.0 and an empty $boxWeight.
previewPacking()
Runs packing on test input submitted from the admin UI preview panel. Returns a plain array instead of value objects — this array is JSON-encoded and returned directly to the browser.
public static function previewPacking(
array $boxes,
array $items,
array $options = []
): array
The $items parameter accepts a simpler structure than packItems() — each item needs only description, length, width, height, weight, qty, and optionally price. The method normalizes these before running the packer.
The returned array has this shape:
[
'success' => true,
'boxCount' => 2,
'itemCount' => 5,
'boxes' => [
[
'reference' => 'Small Box',
'outerLength' => 30.0,
'outerWidth' => 20.0,
'outerHeight' => 15.0,
'totalWeight' => 2.5,
'itemWeight' => 2.2,
'boxWeight' => 0.3,
'maxWeight' => 10.0,
'totalValue' => 45.00,
'volumeUtilisation'=> 72.4,
'items' => [
['description' => 'Widget A', 'qty' => 2],
['description' => 'Widget B', 'qty' => 1],
],
'visualisationUrl' => 'https://boxpacker.io/visualise?...',
],
],
'unpacked' => [],
'method' => 'box_packing',
]
This method is called by the core ShippingController's previewPacking task — you do not call it from your plugin. It is documented here because the AJAX endpoint is wired to core, not to your plugin.
PackingResult Reference
Class: J2Commerce\Component\J2commerce\Administrator\Helper\Shipping\PackingResult File: administrator/components/com_j2commerce/src/Helper/Shipping/PackingResult.php
class PackingResult
{
public readonly array $boxes; // PackedBoxResult[]
public readonly array $unpacked; // array of item arrays
public readonly string $method; // 'box_packing' or 'per_item'
}
Properties
| Property | Type | Description |
|---|---|---|
$boxes | PackedBoxResult[] | One entry per physical box needed. May be empty if all items are non-shippable. |
$unpacked | array | Items that could not fit in any defined box. Each element is an associative array with description, length, width, height, weight in store units. |
$method | string | 'box_packing' when BoxPacker ran successfully; 'per_item' when no boxes were defined or the library was unavailable. |
Methods
| Method | Return | Description |
|---|---|---|
getTotalWeight() | float | Sum of totalWeight across all packed boxes. |
getBoxCount() | int | Number of packed boxes. |
hasUnpackedItems() | bool | True if any items could not be packed. |
PackedBoxResult Reference
Class: J2Commerce\Component\J2commerce\Administrator\Helper\Shipping\PackedBoxResult File: administrator/components/com_j2commerce/src/Helper/Shipping/PackedBoxResult.php
class PackedBoxResult
{
public readonly string $reference;
public readonly float $outerLength;
public readonly float $outerWidth;
public readonly float $outerHeight;
public readonly float $totalWeight;
public readonly float $itemWeight;
public readonly float $boxWeight;
public readonly float $totalValue;
public readonly float $volumeUtilisation;
public readonly array $items;
public readonly string $visualisationUrl;
}
Properties
| Property | Type | Units | Description |
|---|---|---|---|
$reference | string | — | Box name as defined by the store owner or carrier preset. Pass this to carrier APIs as the packaging type identifier. |
$outerLength | float | Store length units | External length. Use for dimensional weight calculations. |
$outerWidth | float | Store length units | External width. |
$outerHeight | float | Store length units | External height (BoxPacker calls this "depth"). |
$totalWeight | float | Store weight units | Item weight plus empty box weight combined. This is what you declare to the carrier. |
$itemWeight | float | Store weight units | Weight of items only, excluding box packaging material. |
$boxWeight | float | Store weight units | Empty box weight only. |
$totalValue | float | Store currency | Declared value of items in this box. Useful for insurance calculations. |
$volumeUtilisation | float | Percentage (0–100) | How full the box is by volume. High values (above 90) shown in red in the admin preview. |
$items | array | — | List of items packed into this box. Each element: ['description' => 'Widget A', 'qty' => 2]. |
$visualisationUrl | string | — | Link to an interactive 3D packing visualisation on boxpacker.io. Empty string in per-item mode. |
Item Normalization
Cart items and order items in J2Commerce use different property names depending on context. ShipperHelper reads from multiple property names so your plugin does not need to normalize items before passing them in.
| Data point | Cart item property | Order item property | Fallback |
|---|---|---|---|
| Product name | product_name | orderitem_name | description, then 'Item' |
| Weight | weight | orderitem_weight | $options['default_weight'] |
| Length | length | length | $options['default_length'] |
| Width | width | width | $options['default_width'] |
| Height | height | height | $options['default_height'] |
| Quantity | product_qty | orderitem_quantity | qty, then 1 |
| Price | price | orderitem_price | 0.0 |
| Shippable flag | shipping | shipping | Included by default |
Items with shipping = 0 at the top level, or cartitem->shipping = 0 when the item carries a nested cartitem object, are automatically excluded from packing. You do not need to filter them yourself.
Rotation Options
| Value | DVDoug\BoxPacker\Rotation | Behaviour |
|---|---|---|
'best_fit' | Rotation::BestFit | Default. BoxPacker tries all orientations and chooses the arrangement that fits the most items. Suitable for most goods. |
'keep_flat' | Rotation::KeepFlat | Items can rotate on the horizontal plane but cannot be tipped upright. Use for liquids, fragile items, or items with a defined "this way up" orientation. |
'never' | Rotation::Never | Items are packed in the exact orientation provided. Only use when items have strict orientation requirements. |
Pass the rotation mode as a string in $options['rotation']. The same rotation applies to all items in that packItems() call. If you need per-item rotation control, implement a custom DVDoug\BoxPacker\Item and pass it directly to BoxPacker — this is an advanced use case outside ShipperHelper's scope.
Integration Example: Custom Boxes Only
This is the minimal pattern — no carrier preset boxes. The store owner defines all boxes through the admin UI.
<?php
// File: plugins/j2commerce/shipping_example/src/Extension/ShippingExample.php
declare(strict_types=1);
namespace J2Commerce\Plugin\J2Commerce\ShippingExample\Extension;
use J2Commerce\Component\J2commerce\Administrator\Helper\ShipperHelper;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class ShippingExample extends CMSPlugin implements SubscriberInterface
{
public $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onJ2CommerceGetShippingRates' => 'onGetShippingRates',
];
}
public function onGetShippingRates(Event $event): void
{
$args = $event->getArguments();
$order = $args[0] ?? null;
if ($order === null) {
return;
}
$items = method_exists($order, 'getItems') ? $order->getItems() : [];
if (empty($items)) {
return;
}
// Load boxes defined by the store owner in plugin settings
$boxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'box_list');
// Pack. Empty $boxes = per-item fallback, no error.
$result = ShipperHelper::packItems($boxes, $items, [
'weight_unit_id' => (int) $this->params->get('weight_unit', 1),
'length_unit_id' => (int) $this->params->get('dimension_unit', 1),
]);
$rates = [];
foreach ($result->boxes as $packedBox) {
// Call your carrier API per box (or batch all boxes, then divide rates)
$rate = $this->fetchRateFromCarrier(
length: $packedBox->outerLength,
width: $packedBox->outerWidth,
height: $packedBox->outerHeight,
weight: $packedBox->totalWeight,
);
if ($rate !== null) {
$rates[] = $rate;
}
}
// Merge with any existing rates from other plugins
$existing = $event->getArgument('result', []);
$event->setArgument('result', array_merge($existing, $rates));
}
private function fetchRateFromCarrier(
float $length,
float $width,
float $height,
float $weight,
): ?array {
// Your carrier API call here
return null;
}
}
Integration Example: Carrier Presets + Custom Boxes
This pattern combines boxes the carrier provides for free (UPS Express Boxes, USPS flat-rate boxes) with boxes the store owner configures. Carrier presets are defined in plugin code. Store-owner boxes are defined in the admin UI. packItems() considers all of them together.
<?php
// File: plugins/j2commerce/shipping_ups/src/Extension/ShippingUps.php
declare(strict_types=1);
namespace J2Commerce\Plugin\J2Commerce\ShippingUps\Extension;
use J2Commerce\Component\J2commerce\Administrator\Helper\ShipperHelper;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\Event;
use Joomla\Event\SubscriberInterface;
final class ShippingUps extends CMSPlugin implements SubscriberInterface
{
public $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [
'onJ2CommerceGetShippingRates' => 'onGetShippingRates',
];
}
public function onGetShippingRates(Event $event): void
{
$args = $event->getArguments();
$order = $args[0] ?? null;
if ($order === null) {
return;
}
$items = method_exists($order, 'getItems') ? $order->getItems() : [];
if (empty($items)) {
return;
}
$weightUnitId = (int) $this->params->get('weight_unit', 1);
$lengthUnitId = (int) $this->params->get('dimension_unit', 1);
// Carrier preset boxes (dimensions in store units)
$carrierBoxes = $this->getUPSPresetBoxes();
// Store-owner custom boxes (from BoxPackerField)
$customBoxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'box_list');
// Merge — carrier presets first, custom boxes second
$allBoxes = array_merge($carrierBoxes, $customBoxes);
$result = ShipperHelper::packItems($allBoxes, $items, [
'weight_unit_id' => $weightUnitId,
'length_unit_id' => $lengthUnitId,
'rotation' => (string) $this->params->get('rotation', 'best_fit'),
]);
if ($result->hasUnpackedItems()) {
// Log oversized items, then fall back to per-item for those
$this->handleOversizedItems($result->unpacked, $event);
}
$rates = $this->buildRatesFromPackedBoxes($result->boxes);
$existing = $event->getArgument('result', []);
$event->setArgument('result', array_merge($existing, $rates));
}
private function getUPSPresetBoxes(): array
{
// Only include preset box types enabled in plugin settings
$enabledTypes = (array) $this->params->get('preset_box_types', []);
$presets = [];
// UPS Express Box definitions (dimensions in inches)
$allPresets = [
'small_express' => ShipperHelper::createBox(
name: 'UPS Small Express Box',
outerLength: 13.0, outerWidth: 11.0, outerHeight: 2.0,
innerLength: 12.5, innerWidth: 10.5, innerHeight: 1.8,
boxWeight: 0.1, maxWeight: 0.0,
),
'medium_express' => ShipperHelper::createBox(
name: 'UPS Medium Express Box',
outerLength: 15.0, outerWidth: 11.0, outerHeight: 3.0,
innerLength: 14.5, innerWidth: 10.5, innerHeight: 2.8,
boxWeight: 0.15, maxWeight: 0.0,
),
'large_express' => ShipperHelper::createBox(
name: 'UPS Large Express Box',
outerLength: 18.0, outerWidth: 13.0, outerHeight: 3.0,
innerLength: 17.5, innerWidth: 12.5, innerHeight: 2.8,
boxWeight: 0.2, maxWeight: 30.0,
),
];
foreach ($allPresets as $key => $preset) {
if (empty($enabledTypes) || in_array($key, $enabledTypes, true)) {
$presets[] = $preset;
}
}
return $presets;
}
private function buildRatesFromPackedBoxes(array $boxes): array
{
// Build UPS API packages array from packed boxes, then call API
$packages = [];
foreach ($boxes as $packedBox) {
$packages[] = [
'length' => $packedBox->outerLength,
'width' => $packedBox->outerWidth,
'height' => $packedBox->outerHeight,
'weight' => $packedBox->totalWeight,
];
}
// Call UPS Rating API with $packages...
return [];
}
private function handleOversizedItems(array $unpacked, Event $event): void
{
// Log oversized items so the store owner knows to add a larger box
foreach ($unpacked as $item) {
// $item: ['description' => '...', 'length' => x, 'width' => x, ...]
}
}
}
Per-Item Fallback
When $boxes is an empty array, packItems() automatically calls getPerItemPackages() and returns method = 'per_item'. No error is thrown and no configuration is required. The fallback is intentional — a store owner who has not yet defined boxes still gets a working checkout.
In per-item mode, each unit of each item becomes its own PackedBoxResult with:
referenceset to the item descriptionouterLength / outerWidth / outerHeightset to the item's own dimensionstotalWeight = itemWeight(no box weight)boxWeight = 0.0volumeUtilisation = 100.0visualisationUrl = ''
When to rely on per-item fallback:
- Simple plugins where per-item rate calculation is acceptable
- During initial plugin development before box definitions are established
- For digital goods that are never physically packed (mark them with
shipping = 0instead)
When per-item is inappropriate:
- Orders with many small items — 50 widgets shipped as 50 separate packages is expensive and inaccurate
- APIs with package count limits or surcharges per package
- Any scenario where the true packing matters for accurate rate calculation
Oversized Item Handling
BoxPacker cannot pack an item into a box when the item is physically larger than the box's interior on any axis, or when the item alone exceeds the box's weight limit. These items appear in PackingResult::$unpacked.
Strategy 1: Log and skip (rate on shippable items only)
$result = ShipperHelper::packItems($boxes, $items, $options);
if ($result->hasUnpackedItems()) {
foreach ($result->unpacked as $item) {
// Log the oversized item but still generate rates for the items that did fit
}
}
// Rate normally on $result->boxes — the oversized items just won't be included
Use this when oversized items are rare and the store handles them manually.
Strategy 2: Fall back to per-item for oversized items
$result = ShipperHelper::packItems($boxes, $items, $options);
if ($result->hasUnpackedItems()) {
// Pack the unpacked items individually
$fallback = ShipperHelper::getPerItemPackages($result->unpacked, $options);
// Combine: optimally packed boxes + per-item fallback for oversized
$allBoxes = array_merge($result->boxes, $fallback->boxes);
}
This produces accurate rates for standard items and reasonable rates for oversized items.
Strategy 3: Return a "call for quote" rate
if ($result->hasUnpackedItems()) {
$event->setArgument('result', [[
'name' => 'Oversized item — call for shipping quote',
'code' => 'call_for_quote',
'price' => 0.0,
'tax_class' => '',
]]);
return;
}
Forces the customer to contact the store before completing checkout.
Strategy 4: Add a surcharge
$totalBoxes = $result->boxes;
if ($result->hasUnpackedItems()) {
$oversizedFallback = ShipperHelper::getPerItemPackages($result->unpacked, $options);
$totalBoxes = array_merge($totalBoxes, $oversizedFallback->boxes);
// Add a handling surcharge to the final rate
$surcharge = count($result->unpacked) * (float) $this->params->get('oversized_surcharge', 10.0);
}
Visual Packing Preview
The BoxPackerField renders a packing preview panel below the box definition table. Store owners use it to validate their box configuration with sample items before the plugin goes live — no test order required.
How it works
The preview panel is rendered by BoxPackerField::getInput(). It contains:
- A sample items table — store owners enter test items with description, dimensions, weight, and quantity.
- A Preview Packing button.
- A results area that shows packed boxes, volume utilisation bars, weight bars, and unpacked item warnings.
When the button is clicked, boxpacker-preview.js collects:
- The box definitions from the live box table above (reads from the DOM, not the saved params)
- The test items from the sample items table
- The current
weight_unitanddimension_unitfield values from the surrounding form
It then POSTs to the core component AJAX endpoint:
POST index.php
option=com_j2commerce
view=shipping
task=shipping.previewPacking
format=json
test_items=[...]
custom_boxes=[...]
weight_unit_id=1
length_unit_id=1
{token}=1
The core ShippingController::previewPacking() calls ShipperHelper::previewPacking() and returns JSON.
3D Visualisation
For each packed box, previewPacking() calls PackedBox::generateVisualisationURL() from the BoxPacker library. This generates a URL to an interactive 3D visualisation on boxpacker.io showing exactly how items are arranged inside the box. The visualisationUrl property is included in the response and rendered as a View 3D link in the preview panel.
The visualisation URL is only available when the BoxPacker library runs (not in per-item fallback mode). It requires internet access from the admin browser.
AJAX endpoint wiring
The preview AJAX call goes to the core component — your plugin does not need to handle it. The ShippingController::previewPacking() task reads test_items and custom_boxes from the request and delegates entirely to ShipperHelper::previewPacking().
If your plugin uses a different form field name for boxes (not box_list), the preview still works because the JS reads the live DOM table values directly, not the saved params. The preview always shows the unsaved box state.
Unit Conversion
ShipperHelper uses a two-tier conversion strategy.
Tier 1: Database-backed via WeightHelper / LengthHelper
When weight_unit_id and length_unit_id are valid IDs from the #__j2commerce_weights and #__j2commerce_lengths tables, ShipperHelper uses WeightHelper::convert() and LengthHelper::convert() to convert from store units to grams/mm. This is the production path and supports any unit configured in J2Commerce.
The conversion formula is:
result = value × (target_unit_value / source_unit_value)
Where unit_value is the weight_value or length_value column from the respective table. Units are stored relative to a base unit (e.g., kilogram = 1.0, gram = 1000.0 means 1 kg → 1000 g).
Tier 2: Hardcoded fallback factors
When the database lookup fails (unit ID not found, or WeightHelper/LengthHelper returns empty), ShipperHelper falls back to hardcoded conversion factors:
| Length unit | Factor to mm |
|---|---|
mm | 1.0 |
cm | 10.0 |
in | 25.4 |
m | 1000.0 |
ft | 304.8 |
yd | 914.4 |
| Weight unit | Factor to g |
|---|---|
g | 1.0 |
kg | 1000.0 |
oz | 28.3495 |
lb | 453.592 |
When the unit string is unrecognised, ShipperHelper falls back to inches (25.4 mm/unit) for length and pounds (453.592 g/unit) for weight.
Minimum values
BoxPacker requires non-zero integer values. ShipperHelper enforces a minimum of 1 mm for all dimensions and 1 g for all weights. An item with zero dimensions is treated as a 1 mm × 1 mm × 1 mm cube.
Troubleshooting
BoxPacker library not found
Symptom: ShipperHelper::packItems() returns per-item results even when boxes are defined, and a WARNING appears in administrator/logs/j2commerce.shipping.php.
Log message: BoxPacker library not available, falling back to per-item packaging
Cause: The dvdoug/boxpacker library is missing from libraries/j2commerce/vendor/dvdoug/boxpacker/.
Solution: Verify the J2Commerce library is installed at libraries/j2commerce/. If it is absent, reinstall the J2Commerce core package. Check libraries/j2commerce/vendor/dvdoug/boxpacker/autoload.php exists. This file is the entry point ShipperHelper looks for.
No boxes defined — all items packed per-item
Symptom: The shipping plugin returns rates but based on individual item dimensions rather than optimally packed boxes. The PackingResult::$method is 'per_item'.
Cause: $boxes passed to packItems() was an empty array. Either the store owner has not added any boxes to the BoxPackerField, or getCustomBoxesFromParams() is reading the wrong field name.
Solution:
- Check the
box_listfield in plugin settings has at least one row with non-zero outer dimensions. - Confirm the
$fieldNameargument togetCustomBoxesFromParams()matches thenameattribute in your XML. - If you rely on carrier preset boxes, verify
getCarrierPresetBoxes()returns a non-empty array.
Items appear in $result->unpacked
Symptom: PackingResult::hasUnpackedItems() returns true. Some items are never included in any packed box.
Cause: At least one item is physically larger than every defined box on one or more axes, or heavier than any box's max_weight.
Solution:
- Use the admin preview panel to identify which items don't fit. The preview highlights unpacked items in a yellow warning block.
- Add a larger box definition to the BoxPackerField.
- If the item genuinely cannot fit any standard box, implement one of the oversized item strategies above.
- Verify unit conversion is correct — an item weight in pounds being interpreted as grams would make every item appear too heavy.
Admin preview AJAX returns error
Symptom: Clicking Preview Packing shows an alert with "Packing preview failed" or the results area shows an error response.
Possible causes and solutions:
| Symptom detail | Likely cause | Solution |
|---|---|---|
| Browser console shows 403 | CSRF token missing or session expired | Reload the page and try again |
| Response is HTML, not JSON | PHP fatal error in preview handler | Check Joomla system logs for the error message |
| "Unknown error" in results | success: false in JSON response | Enable Joomla debug mode and recheck; error detail appears in result.error |
The preview JS reads the CSRF token from the data-token attribute on .j2commerce-boxpacker-field. If your plugin's Joomla session expires while you are editing the box table, the token becomes invalid. Reloading the page refreshes the token.
Inner dimensions validation error in admin
Symptom: An inner dimension input turns red with Bootstrap's is-invalid style after you type a value.
Cause: The boxpacker-field.js client-side validator checks that inner_* is never greater than outer_* on the same axis. If an inner dimension exceeds its corresponding outer dimension, the field is flagged.
Solution: Reduce the inner dimension or increase the outer dimension. Inner dimensions represent usable interior space and must always be less than or equal to outer dimensions.
Migration from J2Store
J2Store 4 shipping plugins used boxpacker/packer.php from the J2Store library path and worked with arbitrary units. The migration to ShipperHelper eliminates that dependency and handles all unit conversion automatically.
Before (J2Store 4 pattern)
// J2Store 4 — FOF-based, non-namespaced, manual unit conversion
require_once(JPATH_ADMINISTRATOR . '/components/com_j2store/library/boxpacker/packer.php');
$packer = new Packer();
foreach ($this->packaging as $code => $box) {
$packer->addBox(new Box(
$box['name'],
(int) ($box['length'] * 25.4), // manual inches to mm conversion
(int) ($box['width'] * 25.4),
(int) ($box['height'] * 25.4),
0,
(int) ($box['length'] * 25.4),
(int) ($box['width'] * 25.4),
(int) ($box['height'] * 25.4),
(int) ($box['weight'] * 453.592) // manual lb to g conversion
));
}
foreach ($order->getProducts() as $product) {
for ($i = 0; $i < $product->product_qty; $i++) {
$packer->addItem(new Item(
$product->product_name,
(int) ($product->length * 25.4),
(int) ($product->width * 25.4),
(int) ($product->height * 25.4),
(int) ($product->weight * 453.592),
true
));
}
}
$packedBoxes = $packer->pack();
After (J2Commerce 6 pattern)
// J2Commerce 6 — namespaced, unit conversion automatic
use J2Commerce\Component\J2commerce\Administrator\Helper\ShipperHelper;
// Define boxes using the store's configured units — no manual conversion
$presetBoxes = [
ShipperHelper::createBox('UPS Small Express Box', 13.0, 11.0, 2.0, 12.5, 10.5, 1.8, 0.1, 0.0),
ShipperHelper::createBox('UPS Medium Express Box', 15.0, 11.0, 3.0, 14.5, 10.5, 2.8, 0.15, 0.0),
];
$customBoxes = ShipperHelper::getCustomBoxesFromParams($this->params, 'box_list');
$result = ShipperHelper::packItems(
array_merge($presetBoxes, $customBoxes),
$order->getItems(),
[
'weight_unit_id' => (int) $this->params->get('weight_unit', 1),
'length_unit_id' => (int) $this->params->get('dimension_unit', 1),
]
);
Key differences
| J2Store 4 | J2Commerce 6 |
|---|---|
require_once library path | No require_once needed |
Manual * 25.4 / * 453.592 conversions | Automatic via WeightHelper / LengthHelper |
| Hardcoded box array in plugin code | Carrier presets in code + store-owner UI |
| No admin preview | Live packing preview with 3D visualisation |
Packer, Box, Item classes from J2Store library | ShipperHelper::packItems() wraps everything |
| No fallback — missing box causes exception | Empty boxes = automatic per-item fallback |
Results are PackedBox objects from old library | Results are PackedBoxResult value objects |