Light Services

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 Giveaways

This 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.

composer.json
PointsAddon.php
PathWhat the store doesWhen
lang/loadTranslationsFrom(..., identifier)Always, even when disabled
resources/views/loadViewsFrom(..., identifier)Enabled only
database/migrations/loadMigrationsFrom(...)Enabled only
routes/web.phpGrouped under the web middlewareEnabled only
config/config.phpmergeConfigFrom(..., identifier), so config("points.foo")Enabled only, at register
vendor/autoload.phpRequired before your PSR-4 prefixes are registeredAlways, 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

KeyRequiredPurpose
extra.lightstore.identifierYesSettings key and view/lang/config namespace. Must match /^[a-z0-9][a-z0-9_-]*$/
extra.lightstore.addonYesFully qualified name of your StoreAddon subclass
autoload.psr-4YesNamespace prefix to directory, relative to the addon root
extra.lightstore.nameNoDisplay name. Falls back to a headline-cased identifier
nameNoComposer package name. Falls back to the identifier
descriptionNoShown in the admin panel
versionNoShown in the admin panel. Defaults to 0.0.0
authors[0].nameNoShown in the admin panel
homepageNoLinks the addon's name in the admin panel
requireNoChecked at install, never resolved
extra.laravel.providersNoExtra 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-* and lib-* entries are ignored.
  • nortexdev/lightstore is 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 requires vendor/autoload.php and skips the dependency check entirely.

The main class

StoreAddon carries the addon's identity and lifecycle, nothing else:

MemberPurpose
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
addons/HelpdeskAddon/HelpdeskAddon.php
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.

ContractMethod(s)What it does
HasSettingssettings(): list<SettingField>Fields on the addon's settings page in the admin panel
ExtendsAdminPaneladminPanel(): PanelComponentsFilament resources, pages and widgets in the admin panel
ExtendsCreatorPanelcreatorPanel(): PanelComponentsFilament resources, pages and widgets in the creator panel
AddsNavigationLinksnavigationLinks(): list<NavigationLink>Links appended to the storefront navbar
AddsProfileTabsprofileTabs(): list<ProfileTab>Tabs listed under More in the profile sidebar
DefinesNotificationTypesnotificationTypes(): list<AddonNotificationType>Notification types users can opt in and out of
ProvidesMailTemplatesmailTemplates(): list<MailTemplateDefinition>Mail templates seeded on enable and editable in the admin panel
HoldsUserDataexportUserData(User): array, userDataSummary(User): list<UserDataSummaryEntry>Addon data in the user's data export and account deletion dialog
ExtendsProductSidebarproductSidebarViews(Product): list<AddonView>Views under the price on the product page
ExtendsProductActionsproductActionViews(Product): list<AddonView>Views after the primary button on the product page
ExtendsProductCardPriceproductCardPriceViews(Product): list<AddonView>Views inline after the price on product cards
ExtendsCartItemcartItemViews(CartItem): list<AddonView>Views in each cart item row, after the price line
ExtendsCartSidebarTopcartSidebarTopViews(Cart): list<AddonView>Views between the coupon field and the gateway picker
ExtendsCartTotalscartTotalViews(Cart): list<AddonView>Views in the totals block, above the total line
ExtendsCartSidebarcartSidebarViews(Cart): list<AddonView>Views at the bottom of the cart sidebar, after the checkout button
AdjustsCartTotalcartTotalAdjustment(Cart): float, suppressesCheckoutButton(Cart): bool, suppressesGateways(Cart): boolSettles 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.

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() and productActionViews() 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 a cartTotalViews() 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

MessageCause
Addon does not appear at all after uploadingNo composer.json, unreadable JSON, or a missing or malformed extra.lightstore.identifier
...does not name its main class in extra.lightstore.addonThe key is missing from the manifest
...declares no autoload.psr-4 namespaceautoload.psr-4 is missing or empty
...must pin nortexdev/lightstore to one major version as ^<major>.0The 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 shipShip a vendor/ directory with the addon
Class {FQCN} was not found in the logThe 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.

On this page