Prerequisites
A plugin in this codebase is a small Laravel package. Before scaffolding one, make sure the host AcelleMail install you are extending is already running, and that you have a working PHP toolchain on your local machine. The CLI commands below assume you are in the application root (the directory that contains the artisan file).
Host application
- AcelleMail v4.x installed and serving requests. The plugin loader is part of
App\Providers\AppServiceProvider — older 3.x builds do not have Plugin::autoloadWithoutDbQuery().
- A queue worker, scheduler, or web request able to hit the application root — the loader runs at boot, not on demand.
- Write access to
storage/app/plugins/. The Artisan command writes the scaffold here, not into vendor/.
PHP knowledge worth refreshing
The plugin system leans heavily on a handful of PHP and Laravel fundamentals. If any of these feel rusty, pause and skim the relevant docs before scaffolding — debugging a plugin that has the wrong namespace declared in its composer.json is much harder than getting it right first.
- PSR-4 autoloading. The plugin's
composer.json maps a namespace prefix to the src/ directory. AcelleMail registers that mapping with a fresh Composer\Autoload\ClassLoader at boot — so the namespace declaration in every PHP file must match the composer.json mapping exactly, capitalisation included.
- Closures and the
use keyword. Almost every hook listener is a closure. When the closure needs an outer variable, you must capture it explicitly. Forgetting this is the most common source of undefined variable errors in plugin code.
register() vs boot() on a service provider. Laravel runs every provider's register() first, then every provider's boot(). Hooks listed in register() may run before their dependencies are ready; hooks listed in boot() run too late for the translation collector. Both are real footguns — see Seven first-day errors.
- Eloquent, Blade, Routes, Facades. Plugin migrations use the standard
Schema builder, plugin views are ordinary Blade files, plugin routes use Route::group(...). Nothing about a plugin is bespoke — the generated files are vanilla Laravel.
You do not need to publish the plugin to Packagist, run composer install inside the plugin folder, or register anything in the host's root composer.json. The runtime loader handles every step.
Naming rules — read these once, save yourself an hour
Every plugin has an identity of the form {vendor}/{name} — for example acelle/ai, aix/sample, athena/evs. This identity is the canonical key in the database plugins table, the storage/app/plugins/ directory, the storage/app/plugins/index.json master file, and the lifecycle hook names (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).
The validator in App\Model\Plugin::init() enforces a small, conservative ruleset (canonical regex: ^[a-z0-9]+\/[a-z0-9]+$ with min:2 max:32 per side):
- Lowercase letters and digits only. No underscores, no dashes, no uppercase. The earlier guidance that allowed underscores has been superseded — if you see
my_plugin in an old README, that is no longer valid.
- Two to thirty-two characters per side.
a/sample fails (vendor too short); team/x fails (name too short).
- Exactly one slash. Vendor and name. No nesting.
The conservative-intersection rule comes from a 2026-04 cleanup that aligned Plugin::init() with Plugin::getStoragePathByName(). Both validators now agree on the same regex — there is no longer a way for a name to scaffold cleanly and then fail to load.
Pick the vendor segment carefully. The vendor is part of every namespace, every URL prefix in your plugin's routes.php, and every translation key the plugin emits. Renaming it later means a search-and-replace across every file. acmecorp/loyalty is unambiguous; x/loyalty is invalid (vendor too short); acmecorp/loyaltypoints is fine.
The scaffold command
From the application root, run:
php artisan plugin:init {vendor}/{name}
For a worked example we will use acmecorp/loyalty — the rest of this page assumes that name. Substitute your own when you run the command yourself.
$ php artisan plugin:init acmecorp/loyalty
Plugin acmecorp/loyalty created & loaded!
You can find its source files in the ./storage/app/plugins/acmecorp/loyalty folder
The success message is printed by App\Console\Commands\InitPlugin, which is a thin wrapper around the model-level method App\Model\Plugin::init($name). That method does everything the rest of this page describes — validation, scaffold copy, Twig render, file rename, then a chained call to Plugin::register($name) which inserts the database row and boots the service provider.
By the time the prompt returns, the plugin is already loaded into the running application as an inactive package. Routes declared in its routes.php are reachable, views are renderable, and any hooks the service provider registered are live. The only thing activation will add is whatever the plugin author wired up to the activate_plugin_{vendor}/{name} event — typically a migration run.
What was generated
The Artisan command writes a small set of starter files into storage/app/plugins/{vendor}/{name}/, renders Twig placeholders inside them, and renames the placeholder migration. The exact list of files is hard-coded in Plugin::init() — eight content-rendered files plus a couple of static assets. None of these files are special; they are vanilla Laravel that you are free to delete, rename, or extend.
The directory tree on disk after the command finishes:
storage/app/plugins/acmecorp/loyalty/
├── build.sh
├── composer.json
├── icon.svg
├── routes.php
├── database/
│ └── migrations/
│ └── 2000_01_01_000000_create_acmecorp_loyalty_settings_table.php
├── resources/
│ ├── lang/
│ │ └── en/
│ │ └── messages.php
│ └── views/
│ └── index.blade.php
└── src/
├── Controllers/
│ └── DashboardController.php
├── Models/
│ └── Setting.php
└── ServiceProvider.php
The eight files at a glance
| File | What it is for |
composer.json | Runtime contract: name, autoload.psr-4, and extra.laravel.providers are mandatory. Without them the loader cannot register the namespace or boot the provider. |
src/ServiceProvider.php | The single entry point Laravel sees. Registers translations in register(), then routes, views, lifecycle hooks, and the icon URL in boot(). |
src/Controllers/DashboardController.php | A throwaway sample. Returns the bundled index.blade.php view. Replace freely. |
src/Models/Setting.php | An Eloquent model bound to the plugin's first migration. The table name is namespaced as {vendor}_{name}_settings so plugins cannot collide on the same DB. |
routes.php | Loaded from the service provider. Declares both the icon-serving route (used by the admin Plugins page) and a sample plugins/{vendor}/{name} dashboard route. |
resources/views/index.blade.php | The Hello World view rendered by DashboardController. Replace with your real UI. |
resources/lang/en/messages.php | The master translation file. Language::dump() copies it into storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ at runtime — the dumped files are what the application actually reads. |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | The first migration. Runs only when the plugin is activated, rolls back when it is deleted. The filename is the only one whose placeholders Twig itself does not render — Plugin::init() renames it through a separate str_replace pass. |
A real shipping plugin grows beyond this minimum surface. The canonical reference inside the codebase is storage/app/plugins/acelle/ai/ — eight Eloquent models, fourteen migrations, eighteen locales, sixty-plus views, an admin sidebar group, a chatbox UI bubble, and its own queue-bound jobs. The Hello World skeleton is intentionally minimal so you can replace pieces one at a time without learning every subsystem at once. Extra controllers go under src/Controllers/, extra models under src/Models/, extra services under src/Services/, additional migrations under database/migrations/.
What Plugin::register() did behind the scenes
The output line says created & loaded, and that is precise. Between copying files and printing the success message, Plugin::init() calls Plugin::register($name), which performs five distinct steps:
- Reads the plugin's
composer.json. The name field must match the directory exactly (acmecorp/loyalty) — a mismatch throws a composer name in composer.json is expected to be … exception.
- Creates or updates the row in the
plugins database table. title, description, and version are pulled from the composer metadata. Status is set to inactive.
- Writes the master file.
storage/app/plugins/index.json is the boot-time registry — AppServiceProvider::boot() reads this file to decide which plugins to autoload, on every request, without touching the database. Activation and disable later mutate the same file.
- Loads the service provider immediately. The plugin's
boot() runs in the current process, so any routes / views / hooks it registers are live before the next request.
- Materialises translation files.
Language::dump() reads every add_translation_file hook entry, copies the master files into storage/app/data/plugins/..., and finishes by running vendor:publish --tag=plugin --force so any bundled assets land under public/plugins/....
The mental model worth remembering: "installed" already means "loaded". Activation is purely a status flip plus whatever the plugin author wired up to fire on the activation event. There is no separate register the routes step that activation triggers — the routes are registered the moment plugin:init finishes.
Inactive plugins are still loaded. The current implementation of Plugin::autoloadWithoutDbQuery() loads every plugin listed in index.json, regardless of status. If a feature must truly disappear when the admin disables the plugin, the plugin author has to guard it explicitly — a route middleware that checks Plugin::getByName($name)->isActive() and aborts with 404 is the conventional pattern. storage/app/plugins/acelle/console is the canonical example.
Activate the plugin
With the plugin scaffolded and inactive, the next step is to mark it active so its activate_plugin_{vendor}/{name} listener runs the migration. Two paths:
From the admin UI
Sign in as an admin, open /rui/admin/plugins, find the Loyalty entry, and click Activate. The page renders the icon served by your routes.php (the placeholder ships an icon.svg at the plugin root — replace it with your own to brand the entry).
Programmatically (testing or seeding)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
Either path fires Hook::fire('activate_plugin_acmecorp/loyalty'). The skeleton's service provider registered an Hook::on(...) listener for that event in boot() — the listener calls Artisan::call('migrate', ['--path' => ..., '--force' => true]), which creates the acmecorp_loyalty_settings table.
Visit /plugins/acmecorp/loyalty in a browser and the bundled Hello World page renders. The {{ trans('loyalty::messages.intro') }} blockquote pulls from the dumped translation file under storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.
Your first edits
The skeleton is intentionally minimal so you can replace pieces one at a time without learning every subsystem at once. A reasonable order:
- Update
composer.json. Set a real title, description, and version. The admin Plugins page renders these.
- Add a real migration. Drop a new file under
database/migrations/ with a timestamp greater than the existing one. It will run on the next activate (or after a deactivate-then-reactivate cycle).
- Add a real model. The skeleton ships
Setting as a placeholder. Add your own under src/Models/; namespace it as {Vendor_class}\{Name_class}\Models\YourModel. The class names are auto-derived from the lowercase vendor/name — acmecorp becomes Acmecorp and loyalty becomes Loyalty.
- Replace
DashboardController. Add the controllers your feature actually needs. Keep them thin — push business logic into src/Services/ classes.
- Replace the views. The bundled
index.blade.php uses Bootstrap 5 from a CDN. Most plugin authors strip that and extend the host application's layout instead.
- Add hooks in
ServiceProvider::boot(). See the Hook system deep-dive for the four patterns. The skeleton already demonstrates EVENT (Hook::on) and BEHAVIOR (Hook::set) — REGISTRY and FILTER are the next two to learn.
Seven first-day errors and how to fix them
Almost every report from new plugin authors falls into one of these seven categories. Each is grounded in code that ships in App\Model\Plugin or App\Providers\AppServiceProvider, so the symptoms are predictable.
1. Naming violates the validator
plugin:init throws with Plugin name must be in the "author/name" format or Author name "..." is invalid. Only lowercase letters and digits are allowed. Cause: the regex ^[a-z0-9]+\/[a-z0-9]+$ with min:2 max:32 per side rejects underscores, dashes, uppercase letters, or sides shorter than two characters.
Fix: use lowercase letters and digits only — for example acmecorp/loyalty, not acme_corp/loyalty-points.
2. composer.json name does not match the folder
After scaffolding, Plugin::register() validates that the name in the rendered composer.json matches the folder under storage/app/plugins/. Editing the JSON to a different vendor or name without renaming the directory throws Plugin name in composer.json is expected to be '{folder}', found '{json}'.
Fix: rename the directory and the JSON in lockstep, or run plugin:init again with the new name.
3. autoload.psr-4 missing or malformed
loadPluginByName() throws Cannot boot plugin '{name}'. No 'autoload' found in composer.json (or the matching 'autoload.psr4' variant) when the autoload block is removed or mis-spelled. The runtime needs that map to register the namespace; without it nothing in src/ can be instantiated.
Fix: keep the scaffolded autoload.psr-4 entry. The namespace prefix it declares (Acmecorp\Loyalty\\) must match the namespace declaration at the top of every PHP file under src/.
4. Namespace declaration does not match composer.json
PHP's autoloader resolves Acmecorp\Loyalty\Controllers\DashboardController to src/Controllers/DashboardController.php by stripping the Acmecorp\Loyalty\\ prefix declared in composer.json. If the file declares namespace AcmeCorp\Loyalty\Controllers (capital C in AcmeCorp), the autoloader does not find it. Symptoms: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found on the very first request.
Fix: the namespace declaration in every PHP file under src/ must use the exact capitalisation derived from the lowercase vendor/name. For acmecorp/loyalty, that is Acmecorp\Loyalty. Plugin::makeClassNameFromString() applies ucfirst only — there is no smart casing.
5. Translation hook registered in boot() instead of register()
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 — adding the translation entry there means it never gets picked up, and trans('loyalty::messages.intro') returns the literal key.
Fix: register translations in register(), exactly as the skeleton does. The lifecycle hooks for activate_plugin_* and delete_plugin_* still belong in boot().
6. Calling $this->loadTranslationsFrom(...) in boot()
A common instinct is to call Laravel's loadTranslationsFrom() directly in addition to the hook. Because plugin boot() runs after AppServiceProvider::boot, the second call overrides the namespace hint that pointed at the dumped runtime files (storage/app/data/plugins/...) and re-points it at the master file (storage/app/plugins/.../resources/lang/...). The visible symptom is that admin edits in the Languages UI stop taking effect at runtime — the dumped clones become zombie files.
Fix: use the add_translation_file hook only. Do not also call loadTranslationsFrom().
7. Hooks registered in register() that depend on other plugins or the kernel
register() runs before all other providers' register() have completed and well before any boot(). Code that needs the database, another plugin's services, or any singleton that is wired up in another provider's register() can fail with Class not found or Target class does not exist. The only hook that belongs in register() is add_translation_file (because it has to run before AppServiceProvider::boot's collect loop).
Fix: put every other hook in boot(). If you absolutely need something to run early, gate it on app()->runningInConsole() or isInitiated() first.
Step-by-step checklist
The full sequence to ship a working plugin, end-to-end:
php artisan plugin:init {vendor}/{name} — scaffold.
- Edit
composer.json — set real title, description, version.
- Write your migrations under
database/migrations/.
- Add models under
src/Models/.
- Add controllers under
src/Controllers/.
- Add views under
resources/views/.
- Declare routes in
routes.php.
- Wire everything up in
ServiceProvider::boot() — views, routes, hooks, asset publishes.
- Sign in to admin → Plugins → Activate. The migration runs automatically.
When something goes wrong, two debugging entry points cover almost every case. storage/logs/laravel.log captures any exception thrown during boot, including those raised inside loadPluginByName() while registering the autoload. The error field on each row of storage/app/plugins/index.json shows the most recent boot failure for that plugin and is what the admin Plugins page uses to surface the red error pill — clearing the file by reactivating the plugin (or deleting and re-installing) resets the error state.
Where to go next
You have the scaffold, the lifecycle, and the seven errors that gate most first-day debugging. The next two pages give you the mental model the rest of the documentation assumes:
- Plugin architecture — the boot-time load flow, why inactive plugins are still autoloaded, the master-file mechanism, and the difference between
register() and boot() at the runtime level.
- The Hook system — the four patterns (REGISTRY, EVENT, BEHAVIOR, FILTER), when to reach for each, and the conflict semantics that make BEHAVIOR throw on collision instead of silently overriding.
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 UI work, UI injection covers the layout/sidebar/page-slot hooks that let a plugin mount a chatbox bubble or a settings panel without forking a single Blade.