Light Services

Addon Manifest Migration

The v24 addon API changeset: what every addon maintainer has to change to keep their addon loading.

Light Store v24 replaces addon discovery-by-reflection with a manifest. Every addon now declares itself in its own composer.json, and the store wires the addon into Laravel by convention instead of making each addon ship a service provider that does it by hand.

This is a breaking change

An addon built for v23 or earlier will not be discovered at all on v24. Every addon needs the migration below.

Why it changed

Discovery used to scan every top-level .php file in an addon folder, reflect on each class, and look for a StoreAddon subclass. The identifier, name and version lived as protected properties on that class, so the store had to load and instantiate PHP before it could tell you what an addon even was. A broken addon took the panel down with it, a disabled addon could not name itself, and nothing could be validated before install.

The manifest is read as plain JSON, so the store now knows an addon's identity, its version requirements and its dependencies without executing any of its code.

v23 vs v24

v23v24
Identity in protected string $identifier etc.Identity in composer.json under extra.lightstore
Main class found by reflection over top-level filesMain class named by extra.lightstore.addon
Classes autoloaded from a hardcoded Addon\{Folder} guessClasses autoloaded from autoload.psr-4
Each addon ships a provider that loads routes/views/langLoaded by convention, provider optional
Routes registered inside the provider's boot()routes/web.php, grouped under web for you
Name and description translated with __()Plain strings in the manifest, no longer translatable
config('settings.general.site_name') in Blade$generalSettings->site_name
Install by unzipping, then php artisan migrate by handAdmin panel, Addons then Install addon

Migrating an addon

Add a composer.json

Create it at the root of your addon folder, next to the main class. This file is now the single source of truth for your addon's identity.

addons/PointsAddon/composer.json
{
	"name": "nortexdev/lightstore-points",
	"description": "Reward users with loyalty points on every purchase",
	"version": "2.0.0",
	"type": "library",
	"license": "MIT",
	"homepage": "https://lightstore.nortex.dev",
	"authors": [{ "name": "NorteX" }],
	"require": {
		"php": ">=8.4.0",
		"nortexdev/lightstore": ">=24.0"
	},
	"autoload": {
		"psr-4": {
			"Addon\\PointsAddon\\": ""
		}
	},
	"extra": {
		"laravel": {
			"providers": ["Addon\\PointsAddon\\Providers\\PointsServiceProvider"]
		},
		"lightstore": {
			"identifier": "points",
			"name": "Points",
			"addon": "Addon\\PointsAddon\\PointsAddon"
		}
	}
}

The identifier is a data key

extra.lightstore.identifier is the primary key in the addon_settings table, and the namespace for your views, translations and config. Use exactly the string your v23 addon returned from getIdentifier(). Changing it orphans every stored setting, so treat it as a data migration rather than a rename.

Strip the metadata off the main class

All six identity properties are gone from StoreAddon. Delete them, along with any getName() or getDescription() override. Those values now come from the manifest.

Before (v23)
class PointsAddon extends StoreAddon
{
	protected string $identifier = "points";
	protected string $version = "1.1.0";
	protected string $author = "NorteX";

	public function getName(): string
	{
		return __("points::addon.Points");
	}

	public function getDescription(): string
	{
		return __("points::addon.Reward users with loyalty points on every purchase");
	}

	public function getConfig(array $values = []): array { /* ... */ }
}
After (v24)
class PointsAddon extends StoreAddon
{
	public function getConfig(array $values = []): array { /* ... */ }
}

The properties that no longer exist are $identifier, $name, $version, $description, $author and $url.

The getters themselves stay and keep working. getIdentifier(), getName(), getVersion(), getDescription(), getAuthor() and getUrl() all read through to the manifest. Three members are new: getManifest(), getPath() for the absolute path to your addon folder, and setManifest(), which the store calls for you right after it constructs your class.

Every hook method is unchanged. getConfig(), boot(), enabled(), disabled(), the Filament getters, the cart and product view hooks, getProfileTabs(), getDataExport() and getAccountDeletionSummary() all keep their exact v23 signatures.

Delete the service provider boilerplate

The store now loads routes, views, translations, migrations and config for you. For most addons the whole service provider goes away. Drop it, and leave extra.laravel.providers out of the manifest entirely.

Delete all of this
class KnowledgebaseServiceProvider extends ServiceProvider
{
	public function boot(): void
	{
		$routesPath = __DIR__ . "/../routes/web.php";
		if (file_exists($routesPath)) {
			Route::middleware("web")->group(fn() => require $routesPath);
		}

		$viewsPath = __DIR__ . "/../resources/views";
		if (is_dir($viewsPath)) {
			$this->loadViewsFrom($viewsPath, "knowledgebase");
		}

		$langPath = __DIR__ . "/../lang";
		if (is_dir($langPath)) {
			$this->loadTranslationsFrom($langPath, "knowledgebase");
		}

		$migrationsPath = __DIR__ . "/../database/migrations";
		if (is_dir($migrationsPath)) {
			$this->loadMigrationsFrom($migrationsPath);
		}
	}
}

Keep a provider only for the things the conventions do not cover, such as event listeners, container bindings, macros and policies. Once the boilerplate is gone, it usually shrinks to just that:

addons/PointsAddon/Providers/PointsServiceProvider.php
class PointsServiceProvider extends ServiceProvider
{
	public function register(): void {}

	public function boot(): void
	{
		Event::listen(ItemPurchased::class, GrantPointsListener::class);
	}
}

Move routes into routes/web.php

Routes defined inside a provider's boot() need to move to a routes/web.php file in your addon folder. The store groups that file under the web middleware itself.

addons/PointsAddon/routes/web.php
<?php

declare(strict_types=1);

use Addon\PointsAddon\Http\Controllers\PointsController;
use Illuminate\Support\Facades\Route;

Route::middleware("auth")->group(function () {
	Route::get("/profile/points", [PointsController::class, "show"])->name("points.profile");
});

Do not re-apply `web`

The store already wraps the file in Route::middleware("web"). Listing web again in your own group runs the session, cookie and CSRF middleware twice.

Drop the name and description translation keys

Addon names and descriptions are no longer translatable. They are plain strings, read from extra.lightstore.name and the manifest's top-level description. Remove those two keys from every lang/{locale}/addon.php. Everything else in your language files is untouched.

addons/PointsAddon/lang/en/addon.php
return [
	"Points" => "Points",
	"Reward users with loyalty points on every purchase" => "...", 

	// Settings
	"Earn Multiplier" => "Earn Multiplier",
];

Keep a "Points" style key if your own views, navigation links or getConfig() labels still use it. Only the two entries the admin panel used to read are obsolete.

Check your Blade against the settings change

Separately from the manifest work, v24 removed the settings-into-config() injection. Any addon view still calling config('settings.*') now reads null.

@section('title', __('points::addon.Points') . ' - ' . config('settings.general.site_name')) {/* [!code --] */}
@section('title', __('points::addon.Points') . ' - ' . $generalSettings->site_name) {/* [!code ++] */}

Every settings group is shared with all views as a variable named after its class: $generalSettings, $captchaSettings, $footerSettings, $integrationsSettings, $oauth2Settings, $socialSettings and $statsSettings. Outside Blade, resolve the class directly with app(GeneralSettings::class).

The conventional 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

Translations load for disabled addons too. The admin panel still offers a Settings button for a disabled addon, and the labels and descriptions on that form come out of getConfig(), where they are usually __() calls against your language files. Without them loaded, that form would render raw language keys.

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, so ">=24.0" is the right marker for a migrated addon.
  • 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.

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_-]*$/.

Scaffolding a new addon

php artisan addon:create {name} generates the whole layout above with a correct manifest, including a nortexdev/lightstore constraint pinned to the store's current major version.

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