HikaShop – Best practice for saving custom shipping data (locker selection)

  • Posts: 1133
  • Thank you received: 12
  • Hikashop Business
1 week 2 days ago #370007

-- HikaShop version -- : 5.1.5
-- Joomla version -- : 5.2.3
-- PHP version -- : 8.3

We are working on a custom shipping plugin for HikaShop (Joomla 5) that integrates BoxNow lockers selection during checkout.

The flow is the following:

Customer selects the BoxNow shipping method

A popup widget opens and the customer selects a locker

Locker data is correctly returned on the frontend (locker ID, name, address, postal code, city)

The data is temporarily stored (via session / AJAX)

What we want to achieve (and where we would appreciate your confirmation on best practice):

➡️ Persist the selected locker data into the final order, so that:

It is visible in the order backend (admin)

It can be used in order emails and order views

It is properly linked to the shipping method

Currently, we are handling this in the shipping plugin using onShippingSave() and injecting the locker data into $cart->shipping->shipping_params, letting HikaShop handle the order persistence automatically.

Our questions:

Is onShippingSave() the recommended hook to attach custom shipping-related data (such as locker info) to the order?

Is storing this data inside shipping_params the correct and future-proof approach?

Would you recommend any alternative hook or method (for example, onAfterOrderCreate) for this use case?

Is there any official guideline or example for saving custom shipping metadata into an order?

We want to stay fully aligned with HikaShop’s internal architecture and avoid hacks or direct database writes.

Thank you in advance for your time and support — any guidance or confirmation would be greatly appreciated.

Please Log in or Create an account to join the conversation.

  • Posts: 84998
  • Thank you received: 13853
  • MODERATOR
1 week 2 days ago #370008

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.

Please Log in or Create an account to join the conversation.

  • Posts: 1133
  • Thank you received: 12
  • Hikashop Business
1 week 1 day ago #370017

Hi Nicolas,

thank you again for your very detailed explanation — we are following your recommended pattern step by step and treating it as the reference implementation.

We have refactored our BoxNow shipping plugin accordingly:

Current implementation

We inject our custom UI (button + locker selection) via onShippingRatesDisplay() using $method->custom_html / $method->shipping_custom_html.

We rely on the official naming convention:
checkout[shipping][custom][WAREHOUSE_ID][SHIPPING_ID][locker_id]

We implement onShippingCustomSave() and onBeforeOrderCreate() exactly as described in your message.

Full locker data is cached in $_SESSION and transferred to order_shipping_params.

The blocker we are facing

The checkout UI works visually (locker is selected, popup returns data), however the hidden input required by HikaShop is not reliably present in the DOM at submit time, especially when:

the checkout is refreshed via AJAX

the customer navigates back and forth between checkout steps

modern templates (YOOtheme) re-render the shipping block

As a result:

onShippingCustomSave() is called

but $custom_data is empty because the hidden input is missing

and the checkout is blocked (correctly) with “Please select a locker”

We can force-create the hidden input via JavaScript by parsing the shipping radio ID
(e.g. shipping_radio_3_0__1__boxnow_2308) and appending:

<input type="hidden"
name="checkout[shipping][custom][1][2308][locker_id]"
value="XXXXX">


This does work, but before locking this in, we want to be 100% aligned with HikaShop’s intended lifecycle.

Our concrete questions

Is HikaShop expected to keep custom_html intact across AJAX checkout refreshes, or should shipping plugins assume that the DOM may be rebuilt and re-inject missing custom inputs?

Is it acceptable / recommended for a shipping plugin to re-create required hidden inputs via JavaScript if they are missing at submit time?

Is there any internal helper or hook we are missing that guarantees the presence of
checkout[shipping][custom][…] inputs until validation?

In modern AJAX checkouts, would you recommend:

relying exclusively on the HTML generated by onShippingRatesDisplay(), or

treating the DOM as volatile and enforcing the required inputs client-side?

Our goal is to implement this in a future-proof and HikaShop-native way, even if that means changing our approach.

Thanks again for your time — your guidance so far has been extremely helpful.

Best regards,
Christopher

Please Log in or Create an account to join the conversation.

  • Posts: 84998
  • Thank you received: 13853
  • MODERATOR
1 week 1 day ago #370018

Hi,

Let me clarify the expected behavior and the recommended approach.

When the checkout shipping block is refreshed via AJAX (address change, step navigation, etc.), the entire shipping block HTML is regenerated on the server and replaced in the DOM. This means:
- Your onShippingDisplay() method is called again
- Any JavaScript-generated hidden inputs are lost
- The DOM is completely replaced with the new server-rendered HTML

This is by design. The AJAX refresh calls onShippingDisplay() again, giving your plugin the opportunity to restore any previously saved state.

When onShippingCustomSave() successfully saves the locker selection, HikaShop stores that data in cart_params->shipping. This data is then available in subsequent calls to onShippingDisplay() via the `$order` object.

Here's how you can handle this :

// In onShippingDisplay()
$value = ''; // Default: no locker selected

// Check if there's previously stored shipping data
$shipping_data = null;
if(!empty($order->cart_params->shipping) && is_object($order->cart_params->shipping) 
   && isset($order->cart_params->shipping->{$order->shipping_warehouse_id}))
    $shipping_data = $order->cart_params->shipping->{$order->shipping_warehouse_id};

if(!empty($order->cart_params->shipping) && is_array($order->cart_params->shipping) 
   && isset($order->cart_params->shipping[$order->shipping_warehouse_id]))
    $shipping_data = $order->cart_params->shipping[$order->shipping_warehouse_id];

if(!empty($shipping_data)) {
    $data = isset($shipping_data->{$rate->shipping_id}) ? $shipping_data->{$rate->shipping_id} : null;
    // Restore the previously selected value
    $value = !empty($data->locker_id) ? $data->locker_id : '';
}

// Now render the custom_html with the restored value
$rate->custom_html = '<input type="hidden" name="'.$map.'[locker_id]" value="'.htmlspecialchars($value).'" id="boxnow_locker_id_'.$rate->shipping_id.'"/>';
// ... rest of your widget HTML
You must pre-populate your hidden inputs in onShippingDisplay() by reading from cart_params->shipping. This way, even after AJAX refresh, HikaShop calls your onShippingDisplay(), then, you read the previously saved locker_id from $order->cart_params->shipping
and you render the hidden input with that value already set. That way, when the form is submitted later on, the value is still there.

For your BoxNow popup widget, your JavaScript popup should:
- When user selects a locker, update the hidden input AND trigger shipping submission
- This saves the data via onShippingCustomSave() to cart_params->shipping
Your onShippingDisplay() should:
- Read from cart_params->shipping to get any previously saved locker_id
- Render the hidden input pre-populated with that value
- Display appropriate UI showing the selected locker (if any)

Is HikaShop expected to keep custom_html intact across AJAX refreshes?
No. The DOM is replaced. Your plugin must restore state by reading from cart_params->shipping in onShippingDisplay()

Is it acceptable for a shipping plugin to re-create hidden inputs via JavaScript?
That's not the recommended approach. Instead, render them server-side in onShippingDisplay() with the correct value read from cart_params->shipping.

Is there a helper that guarantees presence of inputs?
No, because the DOM is replaced on refresh. The solution is to always read and restore state server-side.

For modern AJAX checkouts, should we treat the DOM as volatile?
Yes, treat it as volatile. But the recommended solution is server-side restoration of state in onShippingDisplay(), not client-side enforcement of inputs.

Here is a summary of the workflow:
1. User selects locker → JS updates hidden input
2. User clicks Submit → form submitted
3. onShippingCustomSave() → data saved to cart_params->shipping
4. AJAX refresh occurs → DOM replaced
5. onShippingDisplay() called → read cart_params->shipping → render hidden input with saved value
6. User continues checkout → value is present
This is how all existing pickup plugins work (Colissimo, Mondial Relay, MyParcel Pickup).

Please Log in or Create an account to join the conversation.

  • Posts: 1133
  • Thank you received: 12
  • Hikashop Business
1 week 1 day ago #370021

Hi Nicolas,

thanks in advance for your time — we’re a bit stuck and we want to be very precise so we don’t waste yours.

Our setup

Joomla 5

HikaShop (multi-step checkout: cart → address → shipping/payment → confirmation)


Custom shipping plugin for BoxNow lockers

Locker is selected via BoxNow widget (popup iframe)

What works
  • The BoxNow widget opens correctly

    Locker selection works in the UI

    We receive the locker data in JS

We successfully POST locker data via com_ajax and store it in PHP session

Hidden inputs are correctly filled (checkout[shipping][custom][…][locker_id])

The core problem

onShippingCustomSave() is NEVER called.

We added logging at the very beginning of onShippingCustomSave():
file_put_contents(JPATH_ROOT.'/tmp/boxnow_hooks.log', 'CALLED', FILE_APPEND);


The file is never created, even when:

BoxNow shipping is selected

Locker is selected

User clicks “Next” to proceed to the next checkout step

This means:

HikaShop never saves the shipping custom data

cart_params->shipping stays empty

Any validation in onShippingCustomSave() or onBeforeOrderCreate() is bypassed

As a result:

Orders can be completed even without selecting a locker

The shipping plugin has no chance to block the checkout

What we already tried

Triggering window.checkout.shippingSelected(radio) manually after locker selection

Ensuring correct input naming (checkout[shipping][custom][warehouse][shipping_id][locker_id])

Server-side restore of locker value from cart_params->shipping

Validation in:

onShippingCustomSave()

onBeforeOrderCreate()

None of the above works because the shipping save hook is simply not triggered.

Our main questions

In a multi-step checkout, what is the guaranteed hook to validate shipping custom data before moving to the next step?

Is onShippingCustomSave() only called on specific AJAX actions?

Are we missing a required trigger or form submission step?

Is there a reference shipping plugin with pickup points (e.g. Colissimo, Mondial Relay, MyParcel Pickup) that:

uses onShippingCustomSave() correctly

blocks checkout if no pickup point is selected

If yes, even pointing us to the relevant method names / flow would help a lot.

We’re clearly missing one key piece of the HikaShop shipping lifecycle, and we want to align with the official pattern instead of hacking around it.

Thanks again for your help,
Christopher

Last edit: 1 week 1 day ago by verzevoul. Reason: styling

Please Log in or Create an account to join the conversation.

  • Posts: 84998
  • Thank you received: 13853
  • MODERATOR
1 week 1 day ago #370028

Hi,

On the checkout, when the user clicks on the "next" / "finish" button and the shipping block of the checkout workflow was on the checkout page where the click was made, the "validate" function of administrator/components/com_hikashop/helpers/checkout/shipping.php will be called.
There, you can see the call to onShippingCustomSave if there are "custom" data in the POST of the request.
And for the custom data to be in the POST of the request, the hidden input field(s) need to have the correct name, and be placed inside the shipping block, thanks to the $rate->custom_html attribute in onShippingDisplay.
In fact, you don't even need to implement onShippingCustomSave as the system will save $custom_data for you if your plugin properly inherit from the hikashopShippingPlugin class and your hidden input has the correct name. The goal of implementing onShippingCustomSave is precisely to cancel moving to the next step of the checkout when the custom data required by the plugin is missing.

If you just call com_ajax from your JS with the data of the locker in the POST of your request, HikaShop is not involved in this and in that case, it's up to you to handle the data. You can indeed store it in the user session, and then directly use it in onBeforeOrderCreate to fill in order_shipping_params.
And in that case, onShippingCustomSave is not useful to you.
While it's less clean, and doesn't support vendors / warehouses as I was explaining before, it is a lot simpler if you're struggling with the implementation.
The AI will be able to do it much easier this way, especially if it doesn't have access to other plugins like MyParcel Pickup to base itself on.

Please Log in or Create an account to join the conversation.

  • Posts: 1133
  • Thank you received: 12
  • Hikashop Business
5 days 10 hours ago #370041

Hi Nicolas,

We’re implementing the BoxNow lockers shipping method for HikaShop (Joomla 5 / HikaShop 6) following the “official pattern” you described (shipping custom data → cart_params → order_shipping_params).

We’re currently stuck on a key detail of the HikaShop 6 checkout flow: the way checkout[shipping][custom][<group>][<shipping_id>] is posted and validated (group key can be non-numeric like “warehouse1”), and we want to align 100% with the recommended approach you mentioned.

Could you please share one of the HikaShop shipping plugins you referred to as a good example (zip/file or repo link), or at least:

the relevant PHP hooks used (e.g., onShippingRatesDisplay, onShippingCustomSave, onBeforeOrderCreate), and

the exact input naming structure you recommend for HikaShop 6 (group key / warehouse format).

Even a minimal sample plugin that saves a single custom field (like “pickup_point_id”) would be enough for us to mirror the pattern cleanly.

Please Log in or Create an account to join the conversation.

  • Posts: 84998
  • Thank you received: 13853
  • MODERATOR
5 days 7 hours ago #370043

Hi,

We can. Please go through our contact form to request it for development purposes.
www.hikashop.com/support/contact-us.html

Please Log in or Create an account to join the conversation.

Time to create page: 0.068 seconds
Powered by Kunena Forum