Extending Modules¶
This guide explains how to customize and extend the Innosend Magento 2 modules.
Overview¶
The Innosend modules are designed with extensibility in mind. You can:
- Replace core implementations via preferences
- Add functionality via plugins
- Customize templates and layouts
- Extend the JavaScript components
- Add custom event observers
Replacing Core Components¶
Custom API Client¶
Create a custom API client implementation:
<?php
// app/code/Your/Module/Model/Api/CustomClient.php
namespace Your\Module\Model\Api;
use Innosend\Integration\Api\ClientInterface;
use Innosend\Integration\Model\Config;
use Psr\Log\LoggerInterface;
class CustomClient implements ClientInterface
{
private Config $config;
private LoggerInterface $logger;
public function __construct(
Config $config,
LoggerInterface $logger
) {
$this->config = $config;
$this->logger = $logger;
}
public function get(string $endpoint, array $params = []): array
{
// Your custom implementation
$this->logger->debug('Custom API GET: ' . $endpoint);
// Add custom headers, authentication, caching, etc.
return $this->makeRequest('GET', $endpoint, $params);
}
public function post(string $endpoint, array $data = []): array
{
return $this->makeRequest('POST', $endpoint, $data);
}
public function put(string $endpoint, array $data = []): array
{
return $this->makeRequest('PUT', $endpoint, $data);
}
public function delete(string $endpoint): array
{
return $this->makeRequest('DELETE', $endpoint);
}
public function isEnabled(): bool
{
return $this->config->isEnabled();
}
private function makeRequest(string $method, string $endpoint, array $data = []): array
{
// Custom request logic
}
}
Register the preference:
<!-- app/code/Your/Module/etc/di.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="Innosend\Integration\Api\ClientInterface"
type="Your\Module\Model\Api\CustomClient"/>
</config>
Custom Order Mapper¶
Extend or replace the order mapping logic:
<?php
// app/code/Your/Module/Model/CustomOrderMapper.php
namespace Your\Module\Model;
use Innosend\OrderConnector\Model\OrderMapper;
use Magento\Sales\Api\Data\OrderInterface;
class CustomOrderMapper extends OrderMapper
{
/**
* Override to add custom fields
*/
public function map(OrderInterface $order): array
{
// Get default mapping
$data = parent::map($order);
// Add custom fields
$data['custom_field'] = $this->getCustomField($order);
$data['store_code'] = $order->getStore()->getCode();
// Modify existing fields
if (isset($data['items'])) {
$data['items'] = $this->enrichItems($data['items'], $order);
}
return $data;
}
private function getCustomField(OrderInterface $order): string
{
// Your custom logic
return 'custom_value';
}
private function enrichItems(array $items, OrderInterface $order): array
{
// Add additional item data
return $items;
}
}
Register the preference:
<!-- app/code/Your/Module/etc/di.xml -->
<preference for="Innosend\OrderConnector\Model\OrderMapper"
type="Your\Module\Model\CustomOrderMapper"/>
Using Plugins¶
Modify API Requests¶
Add data to API requests before they're sent:
<?php
// app/code/Your/Module/Plugin/Api/ClientPlugin.php
namespace Your\Module\Plugin\Api;
use Innosend\Integration\Model\Api\Client;
class ClientPlugin
{
/**
* Add custom headers to all requests
*/
public function beforePost(Client $subject, string $endpoint, array $data = []): array
{
// Modify data before sending
$data['_meta'] = [
'source' => 'magento',
'timestamp' => time()
];
return [$endpoint, $data];
}
/**
* Log all responses
*/
public function afterGet(Client $subject, array $result, string $endpoint): array
{
// Log or modify response
return $result;
}
}
Register the plugin:
<!-- app/code/Your/Module/etc/di.xml -->
<type name="Innosend\Integration\Model\Api\Client">
<plugin name="your_module_api_client_plugin"
type="Your\Module\Plugin\Api\ClientPlugin"/>
</type>
Extend Pickup Point Selection¶
Add validation or processing when a pickup point is selected:
<?php
// app/code/Your/Module/Plugin/PickupPoints/SavePlugin.php
namespace Your\Module\Plugin\PickupPoints;
use Innosend\PickupPoints\Model\PickupPointRepository;
use Innosend\PickupPoints\Api\Data\QuotePickupPointInterface;
use Magento\Framework\Exception\LocalizedException;
class SavePlugin
{
/**
* Validate pickup point before saving
*/
public function beforeSave(
PickupPointRepository $subject,
QuotePickupPointInterface $pickupPoint
): array {
// Add validation
if ($pickupPoint->getCarrierCode() === 'restricted_carrier') {
throw new LocalizedException(__('This carrier is not available.'));
}
return [$pickupPoint];
}
/**
* Additional processing after save
*/
public function afterSave(
PickupPointRepository $subject,
QuotePickupPointInterface $result
): QuotePickupPointInterface {
// Trigger custom event, logging, etc.
return $result;
}
}
Event Observers¶
Listen to Order Sync¶
<?php
// app/code/Your/Module/Observer/OrderSyncObserver.php
namespace Your\Module\Observer;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Psr\Log\LoggerInterface;
class OrderSyncObserver implements ObserverInterface
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
public function execute(Observer $observer): void
{
$order = $observer->getEvent()->getOrder();
// Check if order has pickup point
$pickupPoint = $order->getExtensionAttributes()->getPickupPoint();
if ($pickupPoint) {
$this->logger->info(
'Order with pickup point placed',
[
'order_id' => $order->getIncrementId(),
'pickup_point' => $pickupPoint->getName()
]
);
// Additional processing
$this->notifyWarehouse($order, $pickupPoint);
}
}
private function notifyWarehouse($order, $pickupPoint): void
{
// Your custom logic
}
}
Register the observer:
<!-- app/code/Your/Module/etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="sales_order_place_after">
<observer name="your_module_order_sync"
instance="Your\Module\Observer\OrderSyncObserver"/>
</event>
</config>
Template Customization¶
Override Templates in Your Theme¶
Copy templates to your theme:
app/design/frontend/Your/Theme/
└── Innosend_PickupPoints/
└── templates/
└── pickup-points/
├── checkout.phtml
├── modal.phtml
└── selected.phtml
Custom Pickup Point Display¶
<!-- app/design/frontend/Your/Theme/Innosend_PickupPoints/templates/pickup-points/selected.phtml -->
<?php
/** @var \Innosend\PickupPoints\Block\Checkout\PickupPoint $block */
?>
<div class="pickup-point-selected custom-style">
<div class="pickup-point-icon">
<img src="<?= $block->getCarrierLogo() ?>" alt="Carrier" />
</div>
<div class="pickup-point-info">
<strong><?= $escaper->escapeHtml($block->getPickupPointName()) ?></strong>
<address>
<?= $escaper->escapeHtml($block->getPickupPointAddress()) ?>
</address>
<!-- Add custom content -->
<div class="custom-badge">
<?= __('Ready for pickup in 24h') ?>
</div>
</div>
</div>
Override Knockout Templates¶
Create your own template in your theme:
<!-- app/design/frontend/Your/Theme/Innosend_PickupPoints/web/template/pickup-points/modal.html -->
<div class="modal-popup innosend-modal custom-modal" data-bind="visible: isModalOpen">
<div class="modal-header">
<h2 data-bind="i18n: 'Choose Pickup Point'"></h2>
<button class="action-close" data-bind="click: closeModal">
<span data-bind="i18n: 'Close'"></span>
</button>
</div>
<div class="modal-body">
<!-- Custom search bar -->
<div class="search-container">
<input type="text"
data-bind="value: searchQuery, valueUpdate: 'keyup'"
placeholder="Search by city or postal code" />
</div>
<!-- Custom carrier filter -->
<div class="carrier-filter">
<!-- ko foreach: availableCarriers -->
<button data-bind="click: $parent.filterByCarrier,
css: { active: $parent.selectedCarrier() === $data }">
<img data-bind="attr: { src: logoUrl, alt: name }" />
</button>
<!-- /ko -->
</div>
<!-- Pickup points list -->
<div class="pickup-points-list" data-bind="foreach: filteredPickupPoints">
<!-- Your custom item template -->
</div>
</div>
</div>
JavaScript Customization¶
Extend the Pickup Points Component¶
// app/design/frontend/Your/Theme/Innosend_PickupPoints/web/js/custom-pickup-points.js
define([
'Innosend_PickupPoints/js/pickup-points',
'ko',
'jquery'
], function (PickupPoints, ko, $) {
'use strict';
return PickupPoints.extend({
defaults: {
// Add custom defaults
customOption: true,
analyticsEnabled: true
},
/**
* Override initialize
*/
initialize: function () {
this._super();
// Add custom initialization
this.initAnalytics();
return this;
},
/**
* Override pickup point selection
*/
selectPickupPoint: function (pickupPoint) {
// Custom pre-selection logic
if (this.analyticsEnabled) {
this.trackSelection(pickupPoint);
}
// Call parent method
this._super(pickupPoint);
// Custom post-selection logic
this.showConfirmation(pickupPoint);
},
/**
* Custom method: Track selection
*/
trackSelection: function (pickupPoint) {
// Analytics tracking
if (typeof gtag !== 'undefined') {
gtag('event', 'pickup_point_selected', {
'carrier': pickupPoint.carrier,
'city': pickupPoint.city
});
}
},
/**
* Custom method: Show confirmation
*/
showConfirmation: function (pickupPoint) {
// Show toast notification
require(['Magento_Ui/js/model/messageList'], function (messageList) {
messageList.addSuccessMessage({
message: 'Pickup point selected: ' + pickupPoint.name
});
});
},
/**
* Custom method: Initialize analytics
*/
initAnalytics: function () {
// Setup analytics
}
});
});
Register the custom component:
// app/design/frontend/Your/Theme/Innosend_PickupPoints/requirejs-config.js
var config = {
map: {
'*': {
'Innosend_PickupPoints/js/pickup-points': 'Innosend_PickupPoints/js/custom-pickup-points'
}
}
};
Add Custom Mixins¶
// app/design/frontend/Your/Theme/Innosend_PickupPoints/web/js/pickup-points-mixin.js
define([
'jquery'
], function ($) {
'use strict';
return function (PickupPoints) {
return PickupPoints.extend({
/**
* Add method to filter by distance
*/
filterByDistance: function (maxDistance) {
var filtered = this.pickupPoints().filter(function (point) {
return point.distance <= maxDistance;
});
this.filteredPickupPoints(filtered);
},
/**
* Add method to sort by carrier
*/
sortByCarrier: function () {
var sorted = this.pickupPoints().sort(function (a, b) {
return a.carrier.localeCompare(b.carrier);
});
this.pickupPoints(sorted);
}
});
};
});
Register the mixin:
// app/design/frontend/Your/Theme/requirejs-config.js
var config = {
config: {
mixins: {
'Innosend_PickupPoints/js/pickup-points': {
'Innosend_PickupPoints/js/pickup-points-mixin': true
}
}
}
};
Layout XML Customization¶
Add Custom Block to Checkout¶
<!-- app/design/frontend/Your/Theme/Innosend_PickupPoints/layout/checkout_index_index.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="checkout.root">
<block class="Your\Module\Block\Checkout\CustomInfo"
name="innosend.pickup.custom.info"
template="Your_Module::checkout/pickup-info.phtml"
before="innosend.pickup.point"/>
</referenceContainer>
</body>
</page>
Modify Admin Order View¶
<!-- app/code/Your/Module/view/adminhtml/layout/sales_order_view.xml -->
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="order_additional_info">
<block class="Your\Module\Block\Adminhtml\Order\PickupPointActions"
name="pickup.point.actions"
template="Your_Module::order/pickup-point-actions.phtml"/>
</referenceContainer>
</body>
</page>
Best Practices¶
Do's¶
- ✅ Use dependency injection instead of ObjectManager
- ✅ Extend existing classes rather than replacing them entirely
- ✅ Use plugins for targeted modifications
- ✅ Keep customizations in a separate module
- ✅ Write unit tests for custom code
- ✅ Document your customizations
Don'ts¶
- ❌ Don't modify core Innosend module files directly
- ❌ Don't use class rewrites (preferences) unless necessary
- ❌ Don't break existing functionality
- ❌ Don't hardcode values—use configuration
- ❌ Don't ignore Magento coding standards
Module Structure Example¶
app/code/Your/InnosendCustomizations/
├── Block/
│ ├── Adminhtml/
│ │ └── Order/
│ │ └── PickupPointActions.php
│ └── Checkout/
│ └── CustomInfo.php
├── Model/
│ └── CustomOrderMapper.php
├── Observer/
│ └── OrderSyncObserver.php
├── Plugin/
│ └── Api/
│ └── ClientPlugin.php
├── etc/
│ ├── di.xml
│ ├── events.xml
│ ├── module.xml
│ └── frontend/
│ └── routes.xml
├── registration.php
└── view/
├── adminhtml/
│ └── layout/
│ └── sales_order_view.xml
└── frontend/
├── layout/
│ └── checkout_index_index.xml
├── templates/
│ └── checkout/
│ └── pickup-info.phtml
└── web/
└── js/
└── custom-pickup-points.js
Testing Your Extensions¶
Unit Tests¶
<?php
// Test/Unit/Model/CustomOrderMapperTest.php
namespace Your\Module\Test\Unit\Model;
use PHPUnit\Framework\TestCase;
use Your\Module\Model\CustomOrderMapper;
class CustomOrderMapperTest extends TestCase
{
private CustomOrderMapper $mapper;
protected function setUp(): void
{
// Setup mocks and instantiate
}
public function testMapIncludesCustomFields(): void
{
$order = $this->createMock(\Magento\Sales\Api\Data\OrderInterface::class);
$result = $this->mapper->map($order);
$this->assertArrayHasKey('custom_field', $result);
$this->assertArrayHasKey('store_code', $result);
}
}
Integration Tests¶
<?php
// Test/Integration/PickupPointCustomizationTest.php
namespace Your\Module\Test\Integration;
use Magento\TestFramework\TestCase\AbstractController;
class PickupPointCustomizationTest extends AbstractController
{
public function testCustomPickupPointValidation(): void
{
// Test your customizations in integration context
}
}