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
routes/api.phpGrouped under the api middleware and the /api prefixEnabled 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.

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

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
ReactsToSettingChangessettingsSaved(array $previous, array $current): voidRuns after the settings page is saved, with old and new values
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 in the cart's payment step, above the gateway picker
ExtendsCartTotalscartTotalViews(Cart): list<AddonView>Views in the totals block, above the total line
ExtendsCartSidebarcartSidebarViews(Cart): list<AddonView>Views under the checkout button, on the cart's last step
AdjustsCartTotalcartTotalAdjustment(Cart): float, suppressesCheckoutButton(Cart): bool, suppressesGateways(Cart): boolSettles part of the cart by other means, such as points or credit
ProvidesDeliverablesdeliverableHandlers(): list<DeliverableHandler>Deliverable types offered in the Deliverables tab
ExtendsLicenseCardlicenseCardViews(License): list<AddonView>Views under each license on the profile licenses page
ExtendsLicenseResourcelicenseResourceData(License): arrayData 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.

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() 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() returns Available, Disabled (listed but not selectable) or Hidden for a given product or option.
  • configSchema() returns Filament fields editing the deliverable's config column, named config.*.
  • 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"),
		);
	}
}
  • icon is any Blade Icons component name the store ships, such as lucide-*.
  • Return null to 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_includes setting is off.
  • Implementing it needs Light Store 26.1, so pin nortexdev/lightstore to ^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

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