Light Services
Addon Migration Guides

v25 -> v26

The v26 addon contracts: every hook moves from an overridable StoreAddon method to a typed interface your addon implements.

Light Store v26 replaces the addon hook methods on StoreAddon with contracts. Everything an addon adds to the store, from a settings form to a row in the cart totals, is now declared by implementing an interface, and every hook returns typed value objects instead of array shapes.

This is a breaking change

StoreAddon no longer has any hook methods. A v25 addon still loads on v26, but none of its get*() overrides are ever called, so its settings, admin resources, views and cart logic all silently disappear. The installer refuses it anyway, since it pins ^25.0. Every addon needs the migration below.

Why it changed

In v25 StoreAddon carried 27 empty methods, and an addon hooked in by overriding whichever ones it needed. Nothing checked that it did so correctly: a misspelled method name was simply never called, a hook returning the wrong array shape failed somewhere deep in a Blade view, and the only way to find out what an addon did was to read its source.

In v26 an addon states what it does in its implements list. PHP enforces every method signature when the class loads, the store only calls methods a contract declares, and App\Enums\AddonCapability catalogues every contract with a description of where and when the store calls it. php artisan addon:list prints the capabilities of every installed addon.

v25 vs v26

Contracts live in App\Services\Platform\Addons\Contracts, and the value objects they return in App\Services\Platform\Addons.

v25 StoreAddon methodv26 contractv26 method(s)
getConfig(array $values = [])HasSettingssettings(): list<SettingField>
getAdminResources(), getAdminPages(), getAdminWidgets()ExtendsAdminPaneladminPanel(): PanelComponents
getCreatorResources(), getCreatorPages(), getCreatorWidgets()ExtendsCreatorPanelcreatorPanel(): PanelComponents
getNavigationLinks()AddsNavigationLinksnavigationLinks(): list<NavigationLink>
getProfileTabs()AddsProfileTabsprofileTabs(): list<ProfileTab>
getNotificationTypes()DefinesNotificationTypesnotificationTypes(): list<AddonNotificationType>
getMailTemplates()ProvidesMailTemplatesmailTemplates(): list<MailTemplateDefinition>
getDataExport(User), getAccountDeletionSummary(User)HoldsUserDataexportUserData(User): array, userDataSummary(User): list<UserDataSummaryEntry>
getProductSidebarHooks(Product)ExtendsProductSidebarproductSidebarViews(Product): list<AddonView>
getProductActionHooks(Product)ExtendsProductActionsproductActionViews(Product): list<AddonView>
getProductCardPriceHooks(Product)ExtendsProductCardPriceproductCardPriceViews(Product): list<AddonView>
getCartItemHooks(CartItem)ExtendsCartItemcartItemViews(CartItem): list<AddonView>
getCartSidebarTopHooks(Cart)ExtendsCartSidebarTopcartSidebarTopViews(Cart): list<AddonView>
getCartTotalHooks(Cart)ExtendsCartTotalscartTotalViews(Cart): list<AddonView>
getCartSidebarHooks(Cart)ExtendsCartSidebarcartSidebarViews(Cart): list<AddonView>
getCartTotalAdjustment(Cart), suppressesCartCheckoutButton(Cart), suppressesCartGateways(Cart)AdjustsCartTotalcartTotalAdjustment(Cart): float, suppressesCheckoutButton(Cart): bool, suppressesGateways(Cart): bool

boot(), enabled(), disabled() and the metadata getters (getIdentifier(), getName(), getVersion() and the rest) are unchanged. Two members are new: capabilities(), which lists the contracts an addon implements, and a protected setting($key, $default) that reads one of the addon's own settings.

Migrating an addon

Pin the addon to v26

composer.json
"require": {
	"php": ">=8.4.0",
	"nortexdev/lightstore": "^25.0"
	"nortexdev/lightstore": "^26.0"
}

Bump the addon's own version to a new major as well, since the release that works on v26 no longer works on v25.

Declare what the addon does

Add one contract to the implements list for every group of hooks the addon used to override, following the table above. Implementing a contract means implementing all of its methods: if you only used one of the three cart adjustment methods, return the neutral value from the other two.

addons/RobuxAddon/RobuxAddon.php
use App\Services\Platform\Addons\Contracts\ExtendsAdminPanel;
use App\Services\Platform\Addons\Contracts\ExtendsProductActions;
use App\Services\Platform\Addons\Contracts\ExtendsProductCardPrice;
use App\StoreAddon;

class RobuxAddon extends StoreAddon implements ExtendsAdminPanel, ExtendsProductActions, ExtendsProductCardPrice
{
	// ...
}

Undeclared methods are never called

A method whose contract you forget to implement is not an error. It is just never called. Run php artisan addon:list after migrating and check that the Capabilities column lists everything your addon is supposed to do.

Return value objects instead of arrays

Rename each method and replace every array shape with the matching value object. The keys become constructor arguments, so named arguments keep the call sites readable.

Before (v25)
public function getAdminResources(): array
{
	return [RobuxPriceResource::class];
}

public function getProductActionHooks(Product $product): array
{
	$robuxPrice = RobuxPrice::where("product_id", $product->id)->first();

	if (!$robuxPrice) {
		return [];
	}

	return [["view" => "robux::partials.product-action", "data" => ["robuxPrice" => $robuxPrice]]];
}
After (v26)
public function adminPanel(): PanelComponents
{
	return new PanelComponents(resources: [RobuxPriceResource::class]);
}

public function productActionViews(Product $product): array
{
	$robuxPrice = RobuxPrice::where("product_id", $product->id)->first();

	if (!$robuxPrice) {
		return [];
	}

	return [new AddonView("robux::partials.product-action", ["robuxPrice" => $robuxPrice])];
}
v25 arrayv26 value object
["view" => ..., "data" => [...]]new AddonView($view, $data)
Class-string lists from the three panel gettersnew PanelComponents(resources: [...], pages: [...], widgets: [...])
["name", "href", "exact", "external"]new NavigationLink($name, $href, exact: false, external: false)
["route", "label", "icon", "active_route"]new ProfileTab($route, $label, $icon, activeRoute: null)
["key", "label", "description", "mail_template"]new AddonNotificationType(key:, label:, description:, mailTemplateKey:)
["key", "name", "subject", "body"]new MailTemplateDefinition($key, $name, $subject, $body)
["key", "icon", "label", "count"]new UserDataSummaryEntry($key, $icon, $label, $count)
["name", "type", "label", "description", "default", ...]new SettingField(name:, type:, label:, description:, default:, hidden:, revealable:)

AddonNotificationType is the existing class from App\Services\Notifications. Its fromDefinition() factory is gone; construct it directly.

Move settings to settings()

getConfig() becomes HasSettings::settings(). The unused $values parameter is gone, and the type is an App\Enums\AddonSettingType case instead of a string. The "boolean" alias for "bool" no longer exists.

The admin panel is English only, so labels and descriptions are plain strings rather than __() calls. Delete their keys from every lang/{locale}/addon.php; the store never shows a translated settings form.

Before (v25)
public function getConfig(array $values = []): array
{
	return [
		[
			"name" => "show_nav_link",
			"type" => "bool",
			"label" => __("knowledgebase::addon.Show in Navigation"),
			"description" => __("knowledgebase::addon.Display the Knowledgebase link in the main navigation bar"),
			"default" => true,
		],
	];
}
After (v26)
public function settings(): array
{
	return [
		new SettingField(
			name: "show_nav_link",
			type: AddonSettingType::Bool,
			label: "Show in Navigation",
			description: "Display the Knowledgebase link in the main navigation bar",
			default: true,
		),
	];
}

In v25 the store quietly skipped an addon's navigation links whenever that addon had a setting named show_nav_link switched off. That special case is gone: navigationLinks() is always called, and the addon decides.

public function navigationLinks(): array
{
	if (!$this->setting("show_nav_link", true)) {
		return [];
	}

	return [new NavigationLink("Knowledgebase", route("knowledgebase.index"))];
}

Link names are shown to customers, so keep translating them with __().

Implement both halves of HoldsUserData

Data export and the account deletion summary are one contract. An addon that stores personal data has to both hand it over and warn about it before deletion, so it implements exportUserData() and userDataSummary() together. Return [] from either when the user has nothing.

Update user themes

This step only applies to a theme in themes/ that was copied from default before v26. Themes no longer call hook methods on every enabled addon; they ask AddonManager for the addons implementing one contract. Replace each hook loop in the copied views, for example in partials/cart/cart-item.blade.php:

Before (v25)
@foreach (collect(app(AddonManager::class)->getEnabledAddons())->flatMap(fn($addon) => $addon->getCartItemHooks($cartItem)) as $hook)
    @include($hook['view'], $hook['data'] ?? [])
@endforeach
After (v26)
@use(App\Services\Platform\AddonManager)
@use(App\Services\Platform\Addons\Contracts\ExtendsCartItem)

@foreach (app(AddonManager::class)->implementing(ExtendsCartItem::class) as $addon)
    @foreach ($addon->cartItemViews($cartItem) as $addonView)
        @include($addonView->view, $addonView->data)
    @endforeach
@endforeach

The views that render addon hooks are components/product-card.blade.php, layouts/profile.blade.php, partials/cart/cart-item.blade.php, partials/cart/cart-sidebar.blade.php and products/show.blade.php. Diffing them against the shipped default theme is the quickest way to pick up every change.

Verifying

php artisan addon:list

Every installed addon is listed with its capabilities. An addon whose Capabilities column is empty, or is missing something it used to do, still has a hook that no contract declares.

For everything an addon can implement, see Addon Development.

On this page