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 method | v26 contract | v26 method(s) |
|---|---|---|
getConfig(array $values = []) | HasSettings | settings(): list<SettingField> |
getAdminResources(), getAdminPages(), getAdminWidgets() | ExtendsAdminPanel | adminPanel(): PanelComponents |
getCreatorResources(), getCreatorPages(), getCreatorWidgets() | ExtendsCreatorPanel | creatorPanel(): PanelComponents |
getNavigationLinks() | AddsNavigationLinks | navigationLinks(): list<NavigationLink> |
getProfileTabs() | AddsProfileTabs | profileTabs(): list<ProfileTab> |
getNotificationTypes() | DefinesNotificationTypes | notificationTypes(): list<AddonNotificationType> |
getMailTemplates() | ProvidesMailTemplates | mailTemplates(): list<MailTemplateDefinition> |
getDataExport(User), getAccountDeletionSummary(User) | HoldsUserData | exportUserData(User): array, userDataSummary(User): list<UserDataSummaryEntry> |
getProductSidebarHooks(Product) | ExtendsProductSidebar | productSidebarViews(Product): list<AddonView> |
getProductActionHooks(Product) | ExtendsProductActions | productActionViews(Product): list<AddonView> |
getProductCardPriceHooks(Product) | ExtendsProductCardPrice | productCardPriceViews(Product): list<AddonView> |
getCartItemHooks(CartItem) | ExtendsCartItem | cartItemViews(CartItem): list<AddonView> |
getCartSidebarTopHooks(Cart) | ExtendsCartSidebarTop | cartSidebarTopViews(Cart): list<AddonView> |
getCartTotalHooks(Cart) | ExtendsCartTotals | cartTotalViews(Cart): list<AddonView> |
getCartSidebarHooks(Cart) | ExtendsCartSidebar | cartSidebarViews(Cart): list<AddonView> |
getCartTotalAdjustment(Cart), suppressesCartCheckoutButton(Cart), suppressesCartGateways(Cart) | AdjustsCartTotal | cartTotalAdjustment(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
"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.
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.
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]]];
}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 array | v26 value object |
|---|---|
["view" => ..., "data" => [...]] | new AddonView($view, $data) |
| Class-string lists from the three panel getters | new 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.
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,
],
];
}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,
),
];
}Gate navigation links yourself
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:
@foreach (collect(app(AddonManager::class)->getEnabledAddons())->flatMap(fn($addon) => $addon->getCartItemHooks($cartItem)) as $hook)
@include($hook['view'], $hook['data'] ?? [])
@endforeach@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
@endforeachThe 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:listEvery 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.