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 |
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.
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 |
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 between the coupon field and the gateway picker |
ExtendsCartTotals | cartTotalViews(Cart): list<AddonView> | Views in the totals block, above the total line |
ExtendsCartSidebar | cartSidebarViews(Cart): list<AddonView> | Views at the bottom of the cart sidebar, after the checkout button |
AdjustsCartTotal | cartTotalAdjustment(Cart): float, suppressesCheckoutButton(Cart): bool, suppressesGateways(Cart): bool | Settles part of the cart by other means, such as points or credit |
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 or Bool. A text field can also be hidden (rendered
as a password input) and revealable. 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.
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.
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() hides the gateway picker 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.
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.