Hi,
Great questions! Your approach is very close to the recommended HikaShop pattern. Let me answer each of your questions using our existing pickup shipping plugin as reference examples.
1. Is onShippingSave() the recommended hook to attach custom shipping-related data?
Actually, the recommended hook is onBeforeOrderCreate(&$order, &$do)
This is the method where you should transfer your custom shipping data (in your case, the selected locker information) from the cart's temporary storage to the order's `order_shipping_params`.
In our pickup plugins, we do exactly this:
- First, we collect the relay/pickup data during checkout and store it temporarily in the cart params
- Then in onBeforeOrderCreate, we extract this data and add it to `$order->order_shipping_params`
Here's the pattern:
public function onBeforeOrderCreate(&$order, &$do) {
if(empty($order->order_shipping_method))
return;
$app = JFactory::getApplication();
$cart_shipping_ids = $order->cart->cart_shipping_ids;
$process = array();
// Find which shipping methods belong to this plugin
foreach($cart_shipping_ids as $shipping_id) {
list($sip, $warehouse) = explode('@', $shipping_id, 2);
$current = null;
foreach($order->cart->shipping as $shipping) {
if($shipping->shipping_id != $sip)
continue;
$current = $shipping;
break;
}
if(empty($current) || $current->shipping_type != $this->name)
continue;
$process[$warehouse] = $sip;
}
if(empty($process))
return;
if(empty($order->cart->cart_params->shipping)) {
$app->enqueueMessage('BoxNow: Please select a locker', 'error');
$do = false;
return;
}
$order_shipping_params = $order->order_shipping_params;
foreach($process as $warehouse => $id) {
// Get the locker ID from cart_params->shipping
$shipping_data = null;
if(is_object($order->cart->cart_params->shipping) && isset($order->cart->cart_params->shipping->{$warehouse}))
$shipping_data = $order->cart->cart_params->shipping->{$warehouse};
if(is_array($order->cart->cart_params->shipping) && isset($order->cart->cart_params->shipping[$warehouse]))
$shipping_data = $order->cart->cart_params->shipping[$warehouse];
if(empty($shipping_data))
continue;
$locker_id = isset($shipping_data->{$id}->locker_id) ? $shipping_data->{$id}->locker_id : '';
if(empty($locker_id))
continue;
// Get the FULL locker data from session cache
$full_data = @$_SESSION['boxnow_locations'][$locker_id];
if(empty($full_data)) {
$app->enqueueMessage('BoxNow: Invalid locker selection', 'error');
$do = false;
return;
}
// Store full data in order_shipping_params
if(empty($order_shipping_params->boxnow_locker))
$order_shipping_params->boxnow_locker = array();
$order_shipping_params->boxnow_locker[$warehouse] = $full_data;
}
$order->order_shipping_params = $order_shipping_params;
}
2. Is storing data inside shipping_params the correct and future-proof approach?
Yes, but specifically you should use order_shipping_params, not the rate's shipping_params.
shipping_params on the rate object contains the plugin configuration (API keys, etc.)
order_shipping_params is where you store order-specific custom data (relay ID, locker info, etc.)
HikaShop automatically serializes and persists order_shipping_params to the database, so your locker data will be permanently associated with the order.
3. HTML Input Naming Convention for Custom Shipping Data (IMPORTANT)
When you add custom HTML inputs to your shipping method (like a locker selector dropdown), you MUST use a specific naming format for the checkout's validate method to process them correctly.
The required format is:
checkout[shipping][custom][WAREHOUSE_ID][SHIPPING_ID][your_field_name]
In PHP code, you build it like this:
// Build the input name prefix
$map = 'checkout[shipping][custom]';
if(isset($order->shipping_warehouse_id)) {
$map .= '['.$order->shipping_warehouse_id.']';
}
$map .= '['.$rate->shipping_id.']';
// Cache the full locker data from your widget/API in the session
$_SESSION['boxnow_locations'][$locker_id] = $full_locker_data_from_api;
// Your form inputs - only need to pass the locker ID
$rate->custom_html = '<select name="'.$map.'[locker_id]" onchange="...">';
foreach($locations as $id => $location) {
$rate->custom_html .= '<option value="'.$id.'">'.$location['name'].'</option>';
}
$rate->custom_html .= '</select>';The checkout shipping helper (hikashop/back/helpers/checkout/shipping.php) parses data from
$data['shipping']['custom'][$group][$id]
in the validate method, and then calls your plugin's onShippingCustomSave() method with that data. This is what allows HikaShop to store the custom data in cart_params->shipping during checkout.
4. Implement onShippingCustomSave() to handle the data
Your plugin should implement the onShippingCustomSave method to process and validate the custom data:
public function onShippingCustomSave(&$cart, &$shipping, $warehouse_id, $custom_data) {
if(empty($custom_data['locker_id'])) {
JFactory::getApplication()->enqueueMessage('Please select a locker', 'error');
return false;
}
$ret = new stdClass();
$ret->locker_id = $custom_data['locker_id'];
return $ret;
}
5. Would you recommend onAfterOrderCreate as an alternative?
No, onBeforeOrderCreate is the correct hook for this use case. This ensures the data is stored with the order at creation time. onAfterOrderCreate would require an additional database update, which is unnecessary.
6. How to display the custom data in backend, emails, and frontend order views?
Use the onHikashopBeforeDisplayView(&$viewObj) event to inject your locker information into order views. You can check the view name and layout to determine where you are:
public function onHikashopBeforeDisplayView(&$viewObj) {
$viewName = $viewObj->getName();
$layout = $viewObj->getLayout();
if($viewName == 'order' && $layout == 'show') {
if(empty($viewObj->order->order_shipping_params->boxnow_locker))
return;
$app = JFactory::getApplication();
if($app->isClient('administrator')) {
$content = '';
foreach($viewObj->order->order_shipping_params->boxnow_locker as $warehouse => $data) {
$content .= '<strong>'.$data['name'].'</strong><br/>';
$content .= $data['address'].'<br/>';
$content .= $data['zipcode'].' '.$data['city'];
}
if(empty($viewObj->extra_data))
$viewObj->extra_data = array();
if(empty($viewObj->extra_data['additional']))
$viewObj->extra_data['additional'] = array();
$viewObj->extra_data['additional']['boxnow'] = array(
'title' => 'BoxNow Locker',
'data' => $content
);
}
}
}For emails, implement onAfterOrderProductsListingDisplay(&$orderObject, $name) and check if $name == 'email_notification_html', then append your content to $orderObject->override_shipping_address:
public function onAfterOrderProductsListingDisplay(&$orderObject, $name) {
// Check if we have shipping params
if(empty($orderObject->order_shipping_params))
return;
// Handle both serialized string and object formats
$shipping_params = is_string($orderObject->order_shipping_params)
? hikashop_unserialize($orderObject->order_shipping_params)
: $orderObject->order_shipping_params;
// Check if our plugin's data exists
if(empty($shipping_params->boxnow_locker))
return;
// Build the content HTML
$content = '';
foreach($shipping_params->boxnow_locker as $warehouse => $data) {
$lockerName = isset($data['name']) ? $data['name'] : '';
$addr = isset($data['address']) ? $data['address'] : '';
$zip = isset($data['zipcode']) ? $data['zipcode'] : '';
$city = isset($data['city']) ? $data['city'] : '';
$content .= '<p><strong>'.$lockerName.'</strong><br/>';
$content .= $addr . '<br/>';
$content .= $zip.' '.$city.'</p>';
}
// Handle different display contexts
if($name == 'email_notification_html') {
// For order notification emails - append to shipping address override
if(empty($orderObject->override_shipping_address))
$orderObject->override_shipping_address = '';
$orderObject->override_shipping_address .= $content;
} elseif($name == 'custom_display') {
// For frontend order view - store in static variable for getShippingAddress()
self::$address_override = $content;
}
}
// Also implement getShippingAddress() for frontend order display
public function getShippingAddress($shipping_id = 0, $order = null) {
if(!empty(self::$address_override))
return '<strong>'.JText::_('BOXNOW_LOCKER_LOCATION').'</strong><br/>'.self::$address_override;
return false;
}Summary of the recommended pattern:
1. During checkout display (onShippingDisplay): Show your locker picker widget and store the selection in session
Show your locker picker widget using the correct input naming format
checkout[shipping][custom][warehouse][shipping_id][field]
2. Implement onShippingCustomSave: To validate and return the custom data for cart storage
3. Before order creation (onBeforeOrderCreate): Transfer cart_params data to $order->order_shipping_params
4. For display (onHikashopBeforeDisplayView): Read from order_shipping_params and inject into the view
You can look at the Colissimo, MyParcel Pickup or Mondial Relay plugins on our marketplace for complete working examples.
Now, you can adapt this, simplify it, etc.
But this is complex because it supports warehouses / vendors (products are grouped together per warehouse / vendor) and thus you can have several shipping methods in an order, one per warehouse / vendor, and thus potentially several lockers selected.