What a plugin is here
A plugin is a self-contained Laravel package that lives at storage/app/plugins/{vendor}/{name}/ inside the host AcelleMail install. It carries its own composer.json, its own PSR-4 namespace, its own service provider, its own routes, views, migrations, and translations. It is structured exactly like a tiny Laravel application — except for one decisive distinction.
The host application does not install the plugin through the root Composer autoloader. There is no composer require step, no vendor/{vendor}/{name}/ directory, no entry in composer.lock. Instead, every time the application boots, it does the following on its own:
- Reads each plugin's own
composer.json.
- Registers the PSR-4 namespace declared there with a fresh
Composer\Autoload\ClassLoader instance.
- Calls
App::register(...) on the service providers listed under extra.laravel.providers.
The decision was deliberate. Treating plugins as Composer-installed packages would have made the host application's composer.json a moving target — every install, deactivation, or upgrade would mutate the lockfile. The runtime loader keeps the host's dependency graph stable: plugins ship with their own metadata, and the host can scan, ignore, or reorder them without touching vendor/.
Five files that govern the entire system
Almost every behaviour in the plugin lifecycle is implemented in five files in the host application. Reading the source of these is the fastest way to confirm anything in this documentation:
| File | Responsibility |
app/Console/Commands/InitPlugin.php | The CLI entry point for php artisan plugin:init. Thin wrapper around Plugin::init($name). |
app/Model/Plugin.php | The whole lifecycle: scaffold, register, load, activate, disable, delete, plus the master-file machinery. |
app/Library/HookManager.php | The injection primitives plugins use to extend core behaviour — REGISTRY, EVENT, BEHAVIOR, FILTER. About 160 lines, no dependencies. |
app/Providers/AppServiceProvider.php | Boot-time plugin autoload + translation registration. The single call site that wires plugins into the running application. |
app/Model/Language.php | Materialises plugin translation files into storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. The indirection that lets admins edit translations through the Languages UI without touching plugin source files. |
Together these total well under three thousand lines of host-side code. The plugin system is small on purpose — every constraint a plugin has comes from one of those five files, and there is nowhere else to look.
The boot-and-load flow
Every request, queue worker, scheduler tick, and Artisan command goes through the same boot sequence. The plugin-relevant slice of it looks like this:
application boots
└─ AppServiceProvider::boot()
└─ Plugin::autoloadWithoutDbQuery()
└─ reads storage/app/plugins/index.json
└─ for each entry:
└─ Plugin::loadPluginByName($name)
├─ reads plugin's composer.json
├─ registers PSR-4 prefixes via Composer\Autoload\ClassLoader
└─ App::register()
├─ ServiceProvider::register() (early — translations registered here)
└─ ServiceProvider::boot() (late — routes, hooks, views, publishes)
└─ AppServiceProvider then collects every add_translation_file hook
and calls $this->loadTranslationsFrom() once per plugin.
Two implementation details in this sequence have outsized consequences for plugin authors:
1. Boot-time discovery never queries the database
The list of plugins to load comes from storage/app/plugins/index.json, not from the plugins database table. Service providers are not allowed to query the database safely — at the moment AppServiceProvider::boot() runs, the connection might not exist yet (CLI commands like artisan db:create) or the schema might not be migrated (CI test setup). Storing the boot-time registry in a JSON file sidesteps the entire problem.
The DB table still exists. It stores the same status the JSON file does, plus user-facing metadata like title, description, and version. The admin Plugins page reads from the DB; the boot loader reads from the JSON. Both are kept in sync by Plugin::register(), activate(), and disable() — every status change writes to both stores.
2. autoloadWithoutDbQuery() currently loads every plugin in the index — including inactive ones
The current implementation iterates every entry in index.json and calls loadPluginByName on it, regardless of status. The reason is pragmatic: even an inactive plugin needs its routes registered (so admin pages keep working when an admin clicks "deactivate" without immediately reloading), and it needs its translations available (so the dump-clones don't go stale).
The consequence is that "inactive" in the AcelleMail plugin system is not the same as "unloaded". The next section makes the distinction precise.
The composer.json contract
A plugin's composer.json is not just metadata — it is the runtime contract the loader depends on. The keys that matter are:
| Key | Purpose |
name | Canonical plugin ID. Must match the directory under storage/app/plugins/ exactly. Plugin::register() throws if these diverge. |
autoload.psr-4 | Maps the plugin's namespace prefix to src/. Required — without it, loadPluginByName() throws and the plugin cannot boot. |
extra.laravel.providers | Array of fully-qualified class names. The loader calls App::register() on each one. Required if the plugin wants to register routes, views, hooks, or anything else. |
extra.setting-route | The controller@method the admin Plugins page links to as the plugin's "Settings" button. Optional — plugins without configuration can omit it. |
title, description, version | Surfaced in the admin Plugins listing. title is required; the others fall back to defaults. |
The autoload mapping is registered at runtime, not at install. You do not need to run composer dump-autoload after editing the plugin's PSR-4 map — the host instantiates a fresh ClassLoader on every request and re-reads the file. This is also why bumping a plugin's namespace requires nothing more than a search-and-replace plus a request to the host.
The master file (storage/app/plugins/index.json)
The master file is a flat JSON object keyed by plugin name. Each entry stores at minimum a status, plus an optional error string when the most recent boot attempt failed. A typical file looks like this:
{
"acelle/ai": { "status": "active" },
"acmecorp/loyalty": { "status": "inactive" },
"broken/sample": { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}
Three host-side methods own this file. Every status change goes through one of them:
Plugin::updatePluginMasterFile($name, $params) — merge-write a single plugin's entry. Pass null as the second argument to remove the entry entirely (delete path).
Plugin::resetPluginMasterFile() — rebuild the file from scratch by iterating Plugin::all(). Used as recovery when the JSON gets corrupted or out of sync with the DB.
Plugin::getErroredPluginNames() — read every entry, return the names with non-empty error. The admin Plugins listing uses this to push broken plugins to the bottom and surface the red error pill.
The error key is set when autoloadWithoutDbQuery() wraps a loadPluginByName() call in try/catch and the call throws. The exception message is recorded so the admin UI has something to show without re-running the failure. Reactivating a clean plugin clears the field automatically.
The master file is the single source of truth at boot time. If you ever need to recover from a stuck plugin (the admin UI is down, the database is offline), edit storage/app/plugins/index.json directly. The next request reads the updated state and behaves accordingly. The DB row is the long-term metadata; the JSON file is the runtime registry.
register() vs boot() timing
Laravel runs every service provider's register() method first, in registration order, before calling any boot(). This is well-known Laravel — but it has direct consequences in the plugin system.
What goes in register()
- Constants and bindings — these need to exist before the host's own
boot() runs.
- The
add_translation_file hook — and only this hook. The host's AppServiceProvider::boot() calls Hook::collect('add_translation_file') in its own boot phase. By the time a plugin's boot() runs, that loop has already finished. If a plugin registers its translation entry in boot(), it never gets picked up — and trans('myname::messages.intro') returns the literal key.
What goes in boot()
- Routes and views —
$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
- Asset publishes —
$this->publishes([...], 'plugin').
- Lifecycle event listeners —
Hook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
- The icon URL —
Hook::set('icon_url_{vendor}/{name}', ...).
- Every other hook — REGISTRY
add, EVENT on, BEHAVIOR set, FILTER modify. Anything that depends on container bindings, config, or other plugins.
Do not call $this->loadTranslationsFrom(...) in your plugin's boot(). The host has already wired the namespace through the add_translation_file hook, pointing it at the dumped runtime files under storage/app/data/plugins/.... A second loadTranslationsFrom from your plugin's boot() overrides the host's hint and re-points the namespace at the master file under resources/lang/.... The visible symptom is that admin edits in the Languages UI stop taking effect at runtime — the dumped clones become zombie files. Use the hook only.
Why inactive plugins still affect the app
The autoloadWithoutDbQuery() call at boot loads every plugin in index.json regardless of status. So an "inactive" plugin still has every one of these registered with the host:
- Its routes — declared by
$this->loadRoutesFrom(...) in boot().
- Its views — declared by
$this->loadViewsFrom(...).
- Its middleware aliases — registered by the standard Laravel APIs.
- Its hook listeners — every
Hook::add, Hook::on, Hook::modify, Hook::set still fires.
- Its UI fragments — anything contributed via
layout.head.assets, layout.body.before_close, admin.sidebar.groups, or page-slot REGISTRY hooks still appears.
What activation actually adds is whatever the plugin author wired up to activate_plugin_{vendor}/{name}. The skeleton's listener runs the migration. There is no implicit "register routes when active" or "remove routes when inactive" step — the routes were registered the moment the application booted.
If a feature must truly disappear when an admin disables the plugin, the plugin author has to guard it explicitly. The conventional pattern lives in storage/app/plugins/acelle/console: routes always load, but a route middleware named console.active aborts with 404 when Plugin::getByName('acelle/console')->isActive() returns false. Copy that pattern when "deactivated" should mean "not reachable".
The same applies to UI hooks. If a chatbox bubble injected through layout.body.before_close should hide when the plugin is inactive, the closure body must check Plugin::enabled('myvendor/myplugin') first and return null when false. The host filters falsy returns out automatically before rendering.
Lifecycle: register / activate / disable / delete
Four states, four host-side methods. Each is precise about what it does and does not change.
Register / install
Plugin::register($name) is the entry point — it is called automatically at the end of plugin:init and on every successful upload through the admin UI. The five steps are:
- Reads
composer.json, copies title / description / version into the model.
- Inserts or updates the row in
plugins with status = inactive.
- Writes
storage/app/plugins/index.json with { "name": { "status": "inactive" } }.
- Calls
Plugin::load($withServiceProvider = true) — registers the PSR-4 prefix and boots the service provider immediately, so any routes / views / hooks become live in the current process.
- Calls
Language::dump() to materialise translation files, then runs vendor:publish --tag=plugin --force to copy any bundled assets into public/plugins/....
After register the plugin is installed and loaded. The only thing missing is whatever the plugin chose to wire up to its activate event — typically a migration run.
Activate
$plugin->activate() is called from the admin UI's "Activate" button (and from tests / seeders calling the model directly). It does four things, in order:
- Fires
Hook::fire('activate_plugin_'.$name). The skeleton's listener runs artisan migrate against storage/app/plugins/{vendor}/{name}/database/migrations. Other plugins can register additional listeners — REGISTRY behavior, every listener fires.
- Re-validates the plugin's
composer.json against the host's required-keys list (name, version, app_version).
- Sets the DB
status to active.
- Updates the master file:
{ "status": "active", "error": null } — clearing any previous boot error.
Disable
$plugin->disable() only:
- Sets DB
status to inactive.
- Updates the master file with the new status and clears any recorded
error.
It does not unload routes, views, service providers, hook listeners, or anything else that was registered at boot. The host has no concept of "unregister a service provider" — Laravel itself does not support it. Disable is a status flip, not an unload.
Delete
$plugin->deleteAndCleanup($keepData = false) walks the full teardown:
- Fires
Hook::fire('delete_plugin_'.$name, [$keepData]). The skeleton's listener runs migrate:rollback; $keepData = true can skip that for plugins that own data the admin wants to preserve.
- Recursively deletes the plugin directory under
storage/app/plugins/....
- Deletes the row from the
plugins DB table.
- Removes the entry from the master file.
Until the next request boots a fresh process, the plugin's service provider is still loaded in memory. The next request reads the (now-shrunk) master file, does not load the plugin, and the in-process state is discarded with the request lifecycle.
Two injection layers
A plugin influences the host application through two parallel layers. Distinguishing between them is what makes the rest of the documentation map cleanly to code.
Layer 1 — Laravel registration
Through the service provider, a plugin uses the standard Laravel container APIs to extend the application:
$this->loadRoutesFrom(__DIR__ . '/../routes.php') — adds the plugin's HTTP surface.
$this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — exposes Blade views under the myname::view namespace.
$this->publishes([...], 'plugin') — copies bundled assets into the host's public/plugins/{vendor}/{name}/ on install.
- Middleware aliases, container bindings, console commands, scheduled tasks, queue listeners — everything Laravel itself supports.
Layer 2 — Hook-based injection
The host calls into the App\Library\HookManager primitives at carefully chosen extension points. Plugins register listeners against those points to participate. There are exactly four patterns: REGISTRY, EVENT, BEHAVIOR, FILTER. The next deep-dive — The Hook system — covers each in full.
Two things to know now: (1) every hook the host fires is a stable contract — once published, the name and signature do not change between releases. (2) BEHAVIOR is exclusive — if two plugins try to Hook::set the same name, the second call throws immediately. There is no silent override; conflicts surface at boot, not in production.
The codebase ships three layout-level REGISTRY hooks that almost every UI-extending plugin uses:
| Hook key | Where it fires | Used for |
layout.head.assets | resources/views/refactor/layouts/{app,admin}.blade.php, before | CSS / JS that must load before page content (chatbox styles, sparkle popover scripts) |
layout.body.before_close | Same layouts, just before </body> | Floating widgets — chatbox bubble, modals, sparkle popover |
admin.sidebar.groups | resources/views/refactor/components/nav/admin-sidebar.blade.php | Plugin-contributed admin sidebar sections |
All three follow the same idiom: each callback returns rendered HTML or null; the host iterates with array_filter and emits each fragment with {!! !!}. Returning null is the conventional way to gate a contribution by feature flag or plugin status without throwing.
Translation flow at runtime
Plugin translations are not served straight from the plugin's source resources/lang/ folder. The flow is indirect, and that indirection is what lets admins edit translations through the host's Languages UI without committing to the plugin's source files. The verified sequence:
- The plugin's
register() contributes a Hook::add('add_translation_file', ...) entry pointing at storage/app/data/plugins/{vendor}/{name}/lang/.
- The host's
AppServiceProvider::boot() collects all such entries and calls $this->loadTranslationsFrom() against each one.
- On every
Plugin::register(), the host calls Language::dump().
Language::dump() reads the plugin's master file at resources/lang/en/messages.php and copies it into storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php for every supported locale.
- The Languages admin UI edits the dumped runtime files. The plugin's source master file stays untouched.
Two paths matter to remember:
- Master file (you edit this in source):
storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
- Runtime files (auto-generated, what the app actually reads):
storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php
When you edit the master file, run php artisan translation:upgrade to re-sync the master into all locale runtime files (preserving any translations admins have edited via the Languages UI). The full mechanics — master vs runtime, upgrade semantics, per-locale fallback — get a dedicated deep-dive in Translations.
What this implies for plugin authors
Five rules fall out of the architecture above. Internalising them turns most of the surface complexity in the rest of the docs into a check against this list.
- Treat
boot() as the registration phase. Routes, views, hooks, lifecycle listeners — almost everything goes here. The only thing that goes in register() is the add_translation_file hook (because the host collects it before any plugin's boot() runs).
- Inactive does not mean unloaded. Anything you register at boot is live regardless of
active / inactive status. If a feature must truly disappear when disabled, gate it explicitly with a route middleware or a Plugin::enabled(...) check inside the hook closure.
- Edit translations through the master file, never through
loadTranslationsFrom() directly. The dumped clones under storage/app/data/plugins/... are what runtime reads. Pointing your namespace at the master directory yourself overrides the host's hint and breaks the Languages UI.
- Keep
composer.json thin and stable. The runtime loader reads it on every request. autoload.psr-4, extra.laravel.providers, name, title are the keys the host actually uses. Adding extra keys is fine but does nothing.
- The four hook patterns are the only contract. When you find yourself wanting to "import" a core class to extend it — pause. The plugin contract is one-way: the core declares hooks, plugins react. If the extension point you need does not exist as a hook yet, the right move is to file an issue against the host, not to
use Acelle\Model\Customer from your plugin's controller.
Where to go next
You have the architecture. Two pages turn this mental model into the daily-use APIs you will reach for:
- The Hook system — the four patterns at depth, with real call-sites grepped from core. The conflict semantics, when to use which pattern, and the anti-patterns that look right but break in production.
- UI injection — the layout-level hooks above, plus the
page.{controller}.{action}.{slot} contract that lets a plugin inject a card into an existing page without forking a single Blade.
When you are ready to ship a real feature plugin, the worked examples are Sending drivers (Postal MTA end-to-end) and Payment gateways (Paddle as a regional gateway). For a complete reading-comprehension exercise, the acelle/ai showcase walks through the canonical complex plugin: eight models, fourteen migrations, eighteen locales, and every hook surface used in production.