Appearance
Architecture & extension points
For developers
Namespace Kommandhub\AfricaCommerceCore\ → src/. Plugin class Kommandhub\AfricaCommerceCore\KmhAfricaCommerceCoreSW. Package kommandhub/africa-commerce-core-sw; requires ext-intl and giggsey/libphonenumber-for-php ^8.13.
Two layers in one plugin
| Layer | Path | Rule |
|---|---|---|
| Domain core | src/Domain/ | Framework-agnostic: value objects, enums, interfaces, the precedence resolver, the reconciler, the dataset and its JSON files. Imports no Shopware class (only libphonenumber and ext-intl). Excluded from the autowire glob in services.yml; the Domain services adapters need are registered explicitly. |
| Shopware adapter | every other directory under src/ | Reads SystemConfigService, drives the DAL, decorates core routes, exposes controllers, the command and the storefront/admin assets. Depends on Domain, never the reverse. |
Country differences are data plus configuration — there is no if ($iso === 'NG') branch anywhere.
Modules
| Path | Responsibility |
|---|---|
Domain/Feature/Feature | Enum of the four switches; value = config key. |
Feature/FeatureGate | isEnabled(Feature, ?salesChannelId) → SystemConfigService::getBool('KmhAfricaCommerceCoreSW.config.<key>', $salesChannelId). |
Setting/Service/Config | Typed reader for KmhAfricaCommerceCoreSW.config.*. |
Domain/CountryConfiguration/ | RuleKey, ConfigScope, ScopeType, ConfigSourceInterface, ConfigResolverInterface, PrecedenceConfigResolver. |
CountryConfiguration/SystemConfigConfigSource | Maps the three scopes onto system-config keys. |
Domain/Geo/ | AfricanReferenceData (dataset 2026.1, data/subdivisions.json, data/divisions.json), records, Reconciler, ReconciliationReport. |
Geo/ReferenceImporter, Geo/ReferenceImportCommand | Applies the dataset to the DAL; africa:reference:import. |
Administration/Controller/ReferenceImportController | POST /api/_action/kmh-af/reference-import. |
Domain/AdministrativeHierarchy/ | DivisionRecord, DivisionNode, DivisionTreeBuilder, DivisionProviderInterface. |
AdministrativeHierarchy/ | Entity kmh_af_administrative_division + translation, DalDivisionProvider, Store-API and storefront controllers. |
Domain/Address/ | AddressExtension value object, AddressValidatorInterface, DefaultAddressValidator. |
Address/ | Entity kmh_af_customer_address_data, CustomerAddressExtension, AddressDataWriter, route decorators, subscribers. |
Domain/Phone/, Phone/Controller/ | PhoneNormalizerInterface, LibPhoneNumberNormalizer; Store-API and storefront endpoints. |
Domain/CurrencyFormatting/ | CurrencyFormatterInterface, IntlCurrencyFormatter — published utility, not used by the plugin itself. |
Installer/CustomFieldsInstaller | Custom-field set and its entity relations. |
Logging/ConfigurableLogger | PSR-3 wrapper over channel kommandhub_africacommercecore_channel. |
Feature switches
Feature case | Key | Read with sales channel? | Consumers |
|---|---|---|---|
ReferenceData | referenceDataEnabled | No (global) | ReferenceImportCommand, ReferenceImportController |
AdministrativeHierarchy | administrativeHierarchyEnabled | No in ReferenceImporter; yes in the controllers | importer (divisions), DivisionController, DivisionStorefrontController, Twig |
AddressExtensions | addressExtensionsEnabled | Yes | KmhUpsertAddressRoute, KmhRegisterRoute, AddressPageSubscriber, Twig |
PhoneNormalization | phoneNormalizationEnabled | Yes | PhoneController, PhoneStorefrontController, Twig |
Twig reads the switches with config('KmhAfricaCommerceCoreSW.config.<key>'), which resolves against the current sales channel.
Country-configuration resolver
ConfigResolverInterface::resolve(RuleKey $key, ?string $countryIso, ?string $salesChannelId) walks global → country → sales channel; the last layer returning non-null wins. bool() and int() coerce with a default.
| Scope | System-config key | Read at |
|---|---|---|
| Global | KmhAfricaCommerceCoreSW.config.<rule> | no sales channel |
| Country | KmhAfricaCommerceCoreSW.country.<ISO>.<rule> (ISO upper-cased) | no sales channel |
| Sales channel | KmhAfricaCommerceCoreSW.config.<rule> | the sales channel id — counted only when it differs from the global value |
Because Shopware already inherits the global value into a sales channel, a per-channel value equal to the global one is indistinguishable from "not set" (deliberate; the effective value is the same). Note the order: a sales-channel value beats a per-country value.
RuleKey has one case, DivisionDepth (divisionDepth). Add a case only together with code that reads it.
php
$depth = $resolver->int(RuleKey::DivisionDepth, 'NG', $salesChannelContext->getSalesChannelId());Reference-data import
text
ReferenceDataProviderInterface::load() → ReferenceDataSet (+ Provenance)
ReferenceImporter::import(Context)
├─ currencies key isoCode matches: itemRounding.decimals and totalRounding.decimals
│ Create → SkipMissing (never created)
│ Update writes itemRounding + totalRounding {decimals, interval 10^-d (1.0 for 0), roundForNet true}
├─ countries key iso matches: active + every non-null policy flag
│ Create writes {iso, name = iso, active, flags}; Update writes {active, flags}
├─ subdivisions key country_state.shortCode matches: name
│ skipped (uncounted) when the country is not installed
└─ divisions only if AdministrativeHierarchy is on (global)
key code matches: name, countryStateId, parentId, level, type
parents sorted before children; level = depth in the shipped set; active = truePer record Reconciler::decide(exists, overridden, matchesDesired) returns, in this order: Create (absent) → SkipOverridden → SkipUpToDate → Update. Each dataset is loaded with one EqualsAny query (with translations) and written with one upsert.
Override marker: customFields.kmh_af_merchant_override truthy on the entity or on any of its translations → SkipOverridden.
Context: the command uses Context::createCLIContext(); the Admin API action uses the request context, so names are compared and written in the admin user's content language.
The result is array{currencies, countries, subdivisions, divisions} of ReconciliationReport (created, updated, upToDate, overridden, missing; converged() = nothing created or updated).
Data model
| Table | Fields | Notes |
|---|---|---|
kmh_af_administrative_division | id, country_state_id (FK country_state, cascade), parent_id (self FK, cascade), code (unique, ≤ 64), level, type, active, custom_fields, timestamps | Adjacency list, arbitrary depth. Entity kmh_af_administrative_division; associations countryState, parent, children, translations. |
kmh_af_administrative_division_translation | name per language_id | Required translation. |
kmh_af_customer_address_data | id, customer_address_id (unique FK, cascade), landmark, area (≤ 255), directions (long text), digital_address_code (≤ 255), division_id (FK, set null), custom_fields, timestamps | 1:1 aggregate of customer_address; entity kmh_af_customer_address_data, associations customerAddress, division. |
Created by Migration1786500000CreateAdministrativeDivisionTables and Migration1786500100AddCustomerAddressFields (both additive, updateDestructive() empty). The tables are dropped in uninstall() only when user data is not kept.
CustomerAddressExtension adds the OneToOneAssociationField kmhAfData to customer_address. It is not auto-loaded:
php
$criteria->addAssociation('kmhAfData.division');
$data = $address->getExtension('kmhAfData'); // CustomerAddressAfricaDataEntity|nullAdmin API: POST /api/search/customer-address with "associations": {"kmhAfData": {}}.
Custom fields: set kommandhub_africacommercecore_fieldset (label African Commerce Foundation for Shopware 6) with bool kmh_af_merchant_override, related to country, currency, country_state, kmh_af_administrative_division. Created on install, re-checked on update (idempotent), deleted on uninstall without keeping data. Names live in Util/AfricaCommerceCoreConstants.
Address persistence
Core's address routes map a fixed field whitelist, so the extra fields are written after the core save by two decorators, both no-ops when AddressExtensions is off for the sales channel:
| Decorator | Decorates | Writes |
|---|---|---|
KmhUpsertAddressRoute | UpsertAddressRoute | the saved address, from the request databag |
KmhRegisterRoute | RegisterRoute | billingAddress → default billing address; shippingAddress → default shipping address, only if it differs |
Databag keys (inside the address bag):
| Key | Stored as | Validation |
|---|---|---|
kmhAfLandmark | landmark | string, ≤ 255 |
kmhAfArea | area | string, ≤ 255 |
kmhAfDirections | directions | string, ≤ 65,535 |
kmhAfDigitalAddressCode | digitalAddressCode | string, ≤ 255 |
kmhAfDivisionCode | divisionId | string, ≤ 64; resolved to an active division with that code and the bag's countryStateId, else null |
AddressDataWriter semantics: absent key → untouched; present but blank → null; nothing to write and no existing row → no row created. Values are trimmed.
AddressValidationSubscriber adds the constraints above to framework.validation.address.create and framework.validation.address.update, so an invalid value fails before core saves the address (or the customer, at registration). It is not gated by the feature switch.
AddressPageSubscriber (on AddressDetailPageLoadedEvent) loads the aggregate and attaches it as kmhAfData so the account edit form is pre-filled.
Address validator
AddressValidatorInterface::validate(AddressExtension $address, string $countryIso2): list<string> returns advisory warnings, never throws. DefaultAddressValidator returns Select your local administrative division for a more precise address. when DivisionDepth for that country is above 0 and divisionCode is null. It calls the resolver without a sales channel id.
Nothing in the plugin calls the validator in 0.1.0 — surfacing warnings is up to you:
php
$warnings = $this->addressValidator->validate(
new AddressExtension(landmark: $landmark, divisionCode: $divisionCode),
$countryIso,
);Storefront
Resources/views/storefront/component/address/address-form.html.twig extends component_address_form_address_fields and appends a wrapper [data-kmh-af-address] when address fields or phone check are on. Overridable blocks:
component_address_form_kmh_af_fields · component_address_form_kmh_af_landmark · component_address_form_kmh_af_area · component_address_form_kmh_af_division (only with the hierarchy on) · component_address_form_kmh_af_digital_code · component_address_form_kmh_af_directions
JS plugin KmhAddressForm (kmh-address-form.plugin.js), scoped by the form prefix (billingAddress, shippingAddress, address):
- on state change (and polling up to ~3 s for core's late state fill),
GET /kmh-af/divisions/state/{countryStateId}fills the division select; no divisions → hidden but enabled (submits"", clears); request failure → hidden and disabled (not submitted, keeps the stored value); - on phone blur,
POST /kmh-af/phone/normalize(number,countryId) and Bootstrapis-valid/is-invalidfeedback.
Snippets: kmhAf.address.{landmark,area,digitalAddressCode,directions,division,divisionPlaceholder}, kmhAf.phone.invalid (en-GB, de-DE, fr-FR).
HTTP endpoints
| Method & path | Scope | Gate | Response |
|---|---|---|---|
GET /store-api/kmh-af/divisions/{countryIso} | store-api | AdministrativeHierarchy (sales channel) | {countryIso, maxDepth, divisions: [{code, name, parentCode, children}]}; feature off → same shape, maxDepth: 0, empty list |
POST /store-api/kmh-af/phone/normalize | store-api | PhoneNormalization (sales channel) | {e164, national, international, valid, warning}; 200 even when invalid; 400 without number or countryIso; 404 {enabled: false} when off |
GET /kmh-af/divisions/state/{countryStateId} | storefront, XHR | AdministrativeHierarchy | {divisions: [{code, name, depth}]}, names prefixed — per depth; off → empty |
POST /kmh-af/phone/normalize | storefront, XHR | PhoneNormalization | as Store API, but takes countryId, translated warning; off → 200 {enabled: false} |
POST /api/_action/kmh-af/reference-import | api, ACL system.plugin_maintain | ReferenceData (global) | {sections: {currencies, countries, subdivisions, divisions}} of counts; off → 409 KMH_AF__REFERENCE_DATA_DISABLED |
Divisions are read as system context in the requested language with the system language as fallback, active only, sorted by name.
bash
curl -s https://shop.example/store-api/kmh-af/phone/normalize \
-H 'sw-access-key: SWSC…' -H 'Content-Type: application/json' \
-d '{"number":"08030000000","countryIso":"NG"}'
# {"e164":"+2348030000000","national":"0803 000 0000","international":"+234 803 000 0000","valid":true,"warning":null}An unparseable number returns e164: "", the raw input as national/international, valid: false and libphonenumber's message as warning.
Extension points
All registered under their interface; re-alias the interface (or decorate the implementation) in your plugin's services.yml.
| Interface | Default | Use it to |
|---|---|---|
Domain\Geo\ReferenceDataProviderInterface | AfricanReferenceData | Ship an extended or different dataset (more countries, states, divisions). ReferenceImporter itself is final and has no interface. |
Domain\AdministrativeHierarchy\DivisionProviderInterface | DalDivisionProvider | Source the division tree elsewhere; both controllers read through it. |
Domain\CountryConfiguration\ConfigResolverInterface | PrecedenceConfigResolver | Read country rules. |
Domain\CountryConfiguration\ConfigSourceInterface | SystemConfigConfigSource | Store rule values somewhere else. |
Domain\Address\AddressValidatorInterface | DefaultAddressValidator | Add or relax address warnings. |
Domain\Phone\PhoneNormalizerInterface | LibPhoneNumberNormalizer | Change phone parsing. |
Domain\CurrencyFormatting\CurrencyFormatterInterface | IntlCurrencyFormatter | format(int $minorAmount, int $fractionDigits, string $currencyIso, string $locale) — ICU formatting with the currency's own precision. |
Example — adding divisions by extending the dataset:
php
final class MyReferenceData implements ReferenceDataProviderInterface
{
public function __construct(private readonly AfricanReferenceData $inner) {}
public function load(): ReferenceDataSet
{
$base = $this->inner->load();
return new ReferenceDataSet(
$base->provenance,
$base->currencies,
$base->countries,
$base->subdivisions,
[...$base->divisions, new DivisionRecord('NG', 'NG-FC', 'NG-FC-AMAC', 'Abuja Municipal', null, 'LGA')],
);
}
}yaml
Kommandhub\AfricaCommerceCore\Domain\Geo\ReferenceDataProviderInterface:
alias: App\MyReferenceDataDivision code must be unique across the installation; the owning state (NG-FC) must exist as a country_state.shortCode.
Events: the plugin dispatches no events of its own. It subscribes to AddressDetailPageLoadedEvent, framework.validation.address.create and framework.validation.address.update.
CLI
| Command | Effect |
|---|---|
bin/console africa:reference:import | Runs the import with a CLI context; prints a table and a note when currencies are missing. Writes to the database — no dry-run option. Always exits 0, also when the feature is off. |
bin/console system:config:set KmhAfricaCommerceCoreSW.country.<ISO>.divisionDepth <n> | Per-country rule value (core command). |
Logging
ConfigurableLogger is bound to every Psr\Log\LoggerInterface $logger argument of this plugin's services (a bind, not a global alias) and writes to var/log/kommandhub_africacommercecore_<env>.log. emergency–error always pass; other levels need enableDebugging and, if set, logLevels. Pass the sales channel for scoped settings:
php
$this->logger->info('…', [ConfigurableLogger::CONTEXT_SALES_CHANNEL_ID => $salesChannelId]);No service in 0.1.0 logs anything yet.
Testing
bash
make test # PHPUnit, no kernel (what CI runs)
make test FILTER=ReconcilerTest
make analyse # PHPStan
make cs # php-cs-fixer dry runtests/Unit/ needs no database. tests/Integration/ (group kernel) needs a booted Shopware with the plugin active and covers import convergence, missing currencies, an override set in another language, and the division hierarchy.