Addon Development
How to build a Light Store addon against the current addon API, from the manifest to every capability an addon can implement.
An addon is a folder in addons/ holding a composer.json manifest, a main class extending
App\StoreAddon, and whatever routes, views, migrations and translations it needs. The store loads
those by convention. Everything else an addon adds to the store, from a settings form to a row in
the cart totals, it declares by implementing a contract.
This page describes the current release. Upgrading an addon written for an older store is covered in the Addon Migration Guides, starting with v25 -> v26.
Scaffolding
php artisan addon:create GiveawaysThis creates addons/Giveaways with a manifest pinned to the store's current major, a bare main
class, and the conventional folders. Enable it on the Addons page, or with
php artisan addon:enable giveaways.
Layout
Lay your addon out like this and the store wires all of it up. Anything that is absent is simply skipped.
| Path | What the store does | When |
|---|---|---|
lang/ | loadTranslationsFrom(..., identifier) | Always, even when disabled |
resources/views/ | loadViewsFrom(..., identifier) | Enabled only |
database/migrations/ | loadMigrationsFrom(...) | Enabled only |
routes/web.php | Grouped under the web middleware | Enabled only |
routes/api.php | Grouped under the api middleware and the /api prefix | Enabled only |
config/config.php | mergeConfigFrom(..., identifier), so config("points.foo") | Enabled only, at register |
vendor/autoload.php | Required before your PSR-4 prefixes are registered | Always, if present |
Language files hold customer-facing text only. The admin panel is English only, so settings labels, Filament resources and anything else admins see are plain strings.
Store owners can override any line of an enabled addon's language files from Translations, where
each file is listed under Addons as identifier::file (knowledgebase::addon). Text that is only
there to be reworded, such as a page heading, needs no setting of its own.
Manifest reference
| Key | Required | Purpose |
|---|---|---|
extra.lightstore.identifier | Yes | Settings key and view/lang/config namespace. Must match /^[a-z0-9][a-z0-9_-]*$/ |
extra.lightstore.addon | Yes | Fully qualified name of your StoreAddon subclass |
autoload.psr-4 | Yes | Namespace prefix to directory, relative to the addon root |
extra.lightstore.name | No | Display name. Falls back to a headline-cased identifier |
name | No | Composer package name. Falls back to the identifier |
description | No | Shown in the admin panel |
version | No | Shown in the admin panel. Defaults to 0.0.0 |
authors[0].name | No | Shown in the admin panel |
homepage | No | Links the addon's name in the admin panel |
require | No | Checked at install, never resolved |
extra.laravel.providers | No | Extra service providers, registered only while the addon is enabled |
autoload.psr-4 is registered against the running Composer class loader at boot. That is what
lets an addon installed after the Docker image was built autoload at all, so the prefix has to be
correct even though nobody runs composer dump-autoload on the store.
Declaring dependencies
require is validated but never resolved. The store will not download anything on your behalf.
php,ext-*andlib-*entries are ignored.nortexdev/lightstoreis treated as a core version constraint. At install the store checks it against its own version with Semver and refuses the addon if it does not satisfy. It is mandatory and must pin exactly one store major as a caret constraint, such as^26.0.>=, wildcards and ranges spanning majors are refused, because the addon API changes between majors.- Every other package must either already ship with Light Store, or your addon must carry its own
vendor/directory. If it does, the store requiresvendor/autoload.phpand skips the dependency check entirely.
The main class
StoreAddon carries the addon's identity and lifecycle, nothing else:
| Member | Purpose |
|---|---|
getIdentifier(), getName(), getVersion(), getDescription(), getAuthor(), getUrl() | Read through to the manifest |
getPath(), getManifest() | The addon folder's absolute path, and the parsed manifest |
boot() | Called on every boot while the addon is enabled. Register schedules here |
enabled(), disabled() | Called when an admin enables or disables the addon |
capabilities() | The AddonCapability cases for every contract the addon implements |
setting($key, $default = null) | Protected. Reads one of the addon's own settings |
class HelpdeskAddon extends StoreAddon implements HasSettings, ExtendsAdminPanel, AddsNavigationLinks
{
public function boot(): void
{
if (!app()->runningInConsole()) {
return;
}
Schedule::call(fn(): int => app(ScheduledResolutions::class)->run())
->everyFiveMinutes()
->name("helpdesk:run-scheduled-resolutions")
->withoutOverlapping();
}
// settings(), adminPanel(), navigationLinks() ...
}Capabilities
Each contract is an interface in App\Services\Platform\Addons\Contracts, and the value objects
they return live in App\Services\Platform\Addons. The store only ever calls methods a contract
declares, so a method your addon defines without implementing its contract is never called. PHP
checks every signature when the class loads.
| Contract | Method(s) | What it does |
|---|---|---|
HasSettings | settings(): list<SettingField> | Fields on the addon's settings page in the admin panel |
ReactsToSettingChanges | settingsSaved(array $previous, array $current): void | Runs after the settings page is saved, with old and new values |
ExtendsAdminPanel | adminPanel(): PanelComponents | Filament resources, pages and widgets in the admin panel |
ExtendsCreatorPanel | creatorPanel(): PanelComponents | Filament resources, pages and widgets in the creator panel |
AddsNavigationLinks | navigationLinks(): list<NavigationLink> | Links appended to the storefront navbar |
AddsProfileTabs | profileTabs(): list<ProfileTab> | Tabs listed under More in the profile sidebar |
DefinesNotificationTypes | notificationTypes(): list<AddonNotificationType> | Notification types users can opt in and out of |
ProvidesMailTemplates | mailTemplates(): list<MailTemplateDefinition> | Mail templates seeded on enable and editable in the admin panel |
HoldsUserData | exportUserData(User): array, userDataSummary(User): list<UserDataSummaryEntry> | Addon data in the user's data export and account deletion dialog |
ExtendsProductSidebar | productSidebarViews(Product): list<AddonView> | Views under the price on the product page |
ExtendsProductActions | productActionViews(Product): list<AddonView> | Views after the primary button on the product page |
ExtendsProductCardPrice | productCardPriceViews(Product): list<AddonView> | Views inline after the price on product cards |
ExtendsCartItem | cartItemViews(CartItem): list<AddonView> | Views in each cart item row, after the price line |
ExtendsCartSidebarTop | cartSidebarTopViews(Cart): list<AddonView> | Views in the cart's payment step, above the gateway picker |
ExtendsCartTotals | cartTotalViews(Cart): list<AddonView> | Views in the totals block, above the total line |
ExtendsCartSidebar | cartSidebarViews(Cart): list<AddonView> | Views under the checkout button, on the cart's last step |
AdjustsCartTotal | cartTotalAdjustment(Cart): float, suppressesCheckoutButton(Cart): bool, suppressesGateways(Cart): bool | Settles part of the cart by other means, such as points or credit |
ProvidesDeliverables | deliverableHandlers(): list<DeliverableHandler> | Deliverable types offered in the Deliverables tab |
ExtendsLicenseCard | licenseCardViews(License): list<AddonView> | Views under each license on the profile licenses page |
ExtendsLicenseResource | licenseResourceData(License): array | Data added to each license returned by the REST API |
App\Enums\AddonCapability catalogues the same list at runtime: one case per contract, backed by
the interface's class name, with a label() and a description(). $addon->capabilities() returns
the cases an addon implements, and php artisan addon:list prints them for every installed addon.
Settings
public function settings(): array
{
return [
new SettingField(
name: "max_open_tickets",
type: AddonSettingType::Number,
label: "Open Ticket Limit",
description: "How many tickets a customer may have open at once.",
default: 5,
),
];
}type is AddonSettingType::Text, Number, Bool or Translatable. A text field can also be
hidden (rendered as a password input) and revealable. rules takes any Laravel validation rules,
strings or rule objects, and the form refuses to save until they pass:
new SettingField(
name: "url_prefix",
type: AddonSettingType::Text,
label: "URL",
default: "knowledgebase",
rules: ["required", "regex:/^[a-z0-9-]+$/", new AvailableUrlPrefix()],
),A Translatable field is one text input with a language picker, holding a value per locale the
store ships. It is saved as a locale => text array with empty languages left out, so fall back to
your own default for any locale missing from it. placeholders takes a locale => text array shown
in empty inputs, which is the place to show that default:
new SettingField(
name: "name",
type: AddonSettingType::Translatable,
label: "Name",
default: [],
placeholders: ["en" => "Knowledgebase", "pl" => "Baza wiedzy"],
),$names = $this->setting("name", []);
$name = $names[app()->getLocale()] ?? __("knowledgebase::addon.Knowledgebase");
``` Each non-null `default` is written the first time the addon
is enabled, and never overwrites a saved value. Read a value back with
`$this->setting("max_open_tickets", 5)` inside the addon class, or
`app(AddonManager::class)->getSetting("helpdesk", "max_open_tickets")` anywhere else.
<Callout type="info" title="Settings are English only">
The admin panel is not translated, so labels and descriptions are plain strings. Do not wrap them in `__()` or add
them to your language files.
</Callout>
### Reacting to setting changes
Implement `ReactsToSettingChanges` when saving a setting has to do more than store the value. The
store calls `settingsSaved()` after every save of the addon's settings page, with each field's value
from before and after, so compare the two and act only on what changed. The Knowledgebase uses it
to clear the route cache when its URL setting moves the articles to a new path:
```php
public function settingsSaved(array $previous, array $current): void
{
if (($previous["url_prefix"] ?? null) !== ($current["url_prefix"] ?? null)) {
app(AddonInstaller::class)->refreshRuntime();
}
}Admin and creator panels
public function adminPanel(): PanelComponents
{
return new PanelComponents(resources: [TicketResource::class], widgets: [OpenTicketsWidget::class]);
}resources, pages and widgets all default to empty. The components are registered only while
the addon is enabled.
Navigation links and profile tabs
public function navigationLinks(): array
{
if (!$this->setting("show_nav_link", true)) {
return [];
}
return [new NavigationLink(__("helpdesk::addon.Support"), route("helpdesk.index"))];
}
public function profileTabs(): array
{
return [
new ProfileTab(
route: "helpdesk.index",
label: __("helpdesk::addon.Support"),
icon: "life-buoy",
activeRoute: "helpdesk.*",
),
];
}These are shown to customers, so translate them. NavigationLink also takes exact (only
highlight on an exact URL match) and external (open in a new tab). A tab's icon is a Lucide icon
name, and activeRoute is a routeIs() pattern that defaults to route, so nested pages keep the
tab highlighted. A page behind a profile tab extends theme::layouts.profile.
Storefront views
Every Extends* view contract returns a list of AddonView objects, each a view name and its data.
The theme renders them with @include, so the view also sees the variables of the page around it.
public function productCardPriceViews(Product $product): array
{
$robuxPrice = RobuxPrice::where("product_id", $product->id)->first();
if (!$robuxPrice) {
return [];
}
return [new AddonView("robux::partials.product-card-price", ["robuxPrice" => $robuxPrice])];
}Return [] to render nothing for a given product or cart. A few slots come with conditions:
productSidebarViews()andproductActionViews()are only called for products the user does not already own.productCardPriceViews()runs once per product card on every listing, so keep it to a single query.- The theme renders the cart subtotal row as
#cart-subtotal-row, hidden while no discount applies, and the total as#cart-total-value, so acartTotalViews()view may reveal or recalculate them.
Cart total adjustment
AdjustsCartTotal is for addons that settle part of the cart by other means, the way Points does
with loyalty points.
public function cartTotalAdjustment(Cart $cart): float
{
return $this->creditFor($cart);
}
public function suppressesCheckoutButton(Cart $cart): bool
{
return $this->creditFor($cart) > 0;
}
public function suppressesGateways(Cart $cart): bool
{
return $this->creditFor($cart) >= $cart->calculateTotal(Currency::getUserCurrency());
}cartTotalAdjustment() is money in the user's currency taken off the payable total the theme
displays. suppressesCheckoutButton() hides the theme's checkout button, for an addon rendering its
own action in a cart view, and suppressesGateways() disables the gateway picker in place once the
addon covers the whole cart. Charging the adjusted amount is still the addon's job.
Notifications and mail templates
public function notificationTypes(): array
{
return [
new AddonNotificationType(
key: "helpdesk_reply",
label: __("helpdesk::addon.Support replies"),
description: __("helpdesk::addon.Get notified when support answers one of your tickets."),
mailTemplateKey: "helpdesk_reply",
),
];
}
public function mailTemplates(): array
{
return [
new MailTemplateDefinition(
key: "helpdesk_reply",
name: "Helpdesk Reply",
subject: "Support replied to your ticket",
body: "# Support answered your ticket ...",
),
];
}Notification types appear on the customer's notification preferences page and are accepted by
NotificationHelper::send(). The key is stored on every notification and preference row, so
changing it orphans the rows already written. AddonNotificationType lives in
App\Services\Notifications; mailTemplateKey defaults to the key.
Mail templates are seeded when the addon is enabled and editable in the admin panel from then on. A key that already exists is left untouched, so an upgrade never overwrites a template an admin reworded.
User data
An addon that stores personal data implements HoldsUserData, which covers both sides of it:
public function exportUserData(User $user): array
{
$tickets = Ticket::where("user_id", $user->id)->get();
return $tickets->isEmpty() ? [] : ["tickets" => $tickets->map->only(["subject", "status"])->all()];
}
public function userDataSummary(User $user): array
{
$count = Ticket::where("user_id", $user->id)->count();
return [new UserDataSummaryEntry("helpdesk", "life-buoy", __("helpdesk::addon.Support tickets"), $count)];
}The export is nested under the addon identifier in the user's data export file, and an empty array leaves the section out. Summary entries list what the account deletion dialog warns about; an entry with a zero count is left out.
Deliverables
A deliverable is something a product or option hands over with its license: a file, a Discord role, or
whatever an addon adds through ProvidesDeliverables. Each type is a DeliverableHandler from
App\Services\Deliverables\Contracts:
public function deliverableHandlers(): array
{
return [app(LicenseKeyDeliverableHandler::class)];
}class LicenseKeyDeliverableHandler implements DeliverableHandler
{
public function type(): string
{
return "license_key";
}
public function label(): string
{
return "License key";
}
public function description(): string
{
return "A unique key the buyer copies from their licenses page.";
}
public function availabilityFor(Product|ProductOption $owner): DeliverableAvailability
{
return DeliverableAvailability::Available;
}
public function configSchema(): array
{
return [TextInput::make("config.prefix")->label("Key prefix")];
}
public function grant(License $license, Deliverable $deliverable): void {}
public function revoke(License $license, Deliverable $deliverable): void {}
}type()is stored on every deliverable row, so changing it orphans the deliverables already saved. A core type always wins a clash.availabilityFor()returnsAvailable,Disabled(listed but not selectable) orHiddenfor a given product or option.configSchema()returns Filament fields editing the deliverable'sconfigcolumn, namedconfig.*.grant()runs when a license is issued, renewed or reactivated, and for every valid license of the product or option when the deliverable is added to it. Make it idempotent.revoke()runs when the license stops being valid or the deliverable is deleted.
Since 26.1, a handler can also implement DescribedToBuyers, which extends DeliverableHandler, to
appear in the "This product includes" list in the product page sidebar. label() and description()
stay admin-facing English; buyerSummary() returns the customer-facing, translated line:
class LicenseKeyDeliverableHandler implements DescribedToBuyers
{
public function buyerSummary(Deliverable $deliverable): ?DeliverableSummary
{
return new DeliverableSummary(
icon: "lucide-key-round",
label: __("license_keys::addon.License key"),
description: __("license_keys::addon.Shown on your licenses page"),
);
}
}iconis any Blade Icons component name the store ships, such aslucide-*.- Return
nullto leave a deliverable out, as the file type does before its first release. - Handlers that do not implement it are left out of the list.
- Only the product's own deliverables are listed, not its options'. The list is hidden when it holds a single line, or when the theme's
show_product_includessetting is off. - Implementing it needs Light Store 26.1, so pin
nortexdev/lightstoreto^26.1.
While the addon is disabled or uninstalled its deliverable rows stay, marked unavailable in the Deliverables
tab, and purchases skip them. The admin is warned how many deliverables and valid licenses depend on the
addon before disabling or uninstalling it. Catch up on what was missed in enabled(), for instance
by calling DeliverableManager::grantToHolders() for each of your deliverables.
Licenses
ExtendsLicenseCard renders views under each license on the profile licenses page, in a full width
strip below the license row. It runs once per license, valid or not, so load what the views need for
the whole page at once rather than one query per license.
ExtendsLicenseResource adds data to each license the REST API returns from
/api/users/{user}/licenses. It is nested under the addon identifier in the license's addons
object, and an empty array leaves the addon out.
public function licenseResourceData(License $license): array
{
$keys = LicenseKey::where("license_id", $license->id)->pluck("key")->all();
return $keys === [] ? [] : ["keys" => $keys];
}Packaging and installing
Zip the addon folder itself, meaning the folder with composer.json at its root. Users install it
from Addons then Install addon in the admin panel, which extracts it, validates the
manifest, copies it into addons/, runs your migrations by path, enables it and seeds your default
settings. Nothing needs rebuilding or restarting.
Installing over an existing copy upgrades it in place and keeps its settings and data. The previous
copy is set aside under a dotted folder name, which discovery skips. Your addon folder name has to
match /^[A-Za-z0-9][A-Za-z0-9_-]*$/.
Install errors
| Message | Cause |
|---|---|
| Addon does not appear at all after uploading | No composer.json, unreadable JSON, or a missing or malformed extra.lightstore.identifier |
...does not name its main class in extra.lightstore.addon | The key is missing from the manifest |
...declares no autoload.psr-4 namespace | autoload.psr-4 is missing or empty |
| ...must pin nortexdev/lightstore to one major version as ^<major>.0 | The constraint is missing, uses >=, a wildcard, or spans majors |
...needs Light Store {constraint} and this store is {version} | Your nortexdev/lightstore constraint excludes the target store |
...requires {packages}, which this store does not ship | Ship a vendor/ directory with the addon |
Class {FQCN} was not found in the log | The PSR-4 prefix does not match the main class's real namespace or path |
Styling
Do not ship a stylesheet with your addon. Tailwind expresses breakpoint precedence purely as source
order within one @layer utilities, so a second sheet loaded after the theme hoists every utility
it repeats above every responsive variant in the theme. That breaks the whole storefront, not just
your own pages.
The store's build cannot see your markup either, since addons are kept out of the image build
context. Utilities are covered by a generated safelist that is rebuilt when addons are packaged. In
practice, stay with utilities the shipped default theme already uses, and reach for a plain
style attribute for anything genuinely exotic.