Prerequisiti
Un plugin in questa codebase è un piccolo package Laravel. Prima di generarne lo scaffold, assicurati che l'install AcelleMail host che stai estendendo sia già in esecuzione e di avere una toolchain PHP funzionante sulla tua macchina locale. I comandi CLI qui sotto presumono che tu sia nella root dell'applicazione (la directory che contiene il file artisan).
Applicazione host
- AcelleMail v4.x installato e in grado di servire richieste. Il plugin loader fa parte di
App\Providers\AppServiceProvider — le build 3.x precedenti non hanno Plugin::autoloadWithoutDbQuery().
- Un queue worker, uno scheduler o una web request che possa colpire la root dell'applicazione — il loader gira all'avvio, non on demand.
- Accesso in scrittura a
storage/app/plugins/. Il comando Artisan scrive lo scaffold qui, non in vendor/.
Conoscenze PHP da rinfrescare
Il sistema di plugin si appoggia pesantemente a una manciata di fondamentali PHP e Laravel. Se qualcuno di questi ti sembra arrugginito, fermati un attimo e ripassa la documentazione rilevante prima di generare lo scaffold — fare debug su un plugin che ha il namespace sbagliato nel suo composer.json è molto più difficile che farlo bene fin da subito.
- Autoload PSR-4. Il
composer.json del plugin mappa un prefisso di namespace sulla directory src/. AcelleMail registra quella mappa con un nuovo Composer\Autoload\ClassLoader all'avvio — quindi la dichiarazione namespace in ogni file PHP deve corrispondere esattamente alla mappa in composer.json, maiuscole comprese.
- Closure e la parola chiave
use. Quasi ogni hook listener è una closure. Quando la closure ha bisogno di una variabile esterna, devi catturarla esplicitamente. Dimenticarsene è la causa più comune di errori undefined variable nel codice dei plugin.
register() vs boot() su un service provider. Laravel esegue prima il register() di ogni provider, poi il boot() di ogni provider. Gli hook elencati in register() possono girare prima che le loro dipendenze siano pronte; gli hook elencati in boot() girano troppo tardi per il translation collector. Entrambi sono footgun veri — vedi Sette errori del primo giorno.
- Eloquent, Blade, Routes, Facades. Le migration dei plugin usano lo
Schema builder standard, le view dei plugin sono normali file Blade, le route dei plugin usano Route::group(...). Niente in un plugin è su misura — i file generati sono Laravel puro.
Non hai bisogno di pubblicare il plugin su Packagist, di lanciare composer install nella cartella del plugin, né di registrare nulla nel composer.json root dell'host. Il loader a runtime gestisce ogni step.
Regole di naming — leggile una volta, risparmiati un'ora
Ogni plugin ha un'identità nella forma {vendor}/{name} — per esempio Aurius, aix/sample, athena/evs. Questa identità è la chiave canonica nella tabella database plugins, nella directory storage/app/plugins/, nel master file storage/app/plugins/index.json e nei nomi degli hook di lifecycle (activate_plugin_{vendor}/{name}, delete_plugin_{vendor}/{name}, icon_url_{vendor}/{name}).
Il validator in App\Model\Plugin::init() applica un piccolo set di regole conservative (regex canonica: ^[a-z0-9]+\/[a-z0-9]+$ con min:2 max:32 per lato):
- Solo lettere minuscole e cifre. Niente underscore, niente trattini, niente maiuscole. La precedente indicazione che permetteva gli underscore è stata superata — se vedi
my_plugin in un vecchio README, non è più valido.
- Da due a trentadue caratteri per lato.
a/sample fallisce (vendor troppo corto); team/x fallisce (name troppo corto).
- Esattamente uno slash. Vendor e name. Niente nesting.
La regola conservative-intersection viene da una pulizia del 2026-04 che ha allineato Plugin::init() con Plugin::getStoragePathByName(). Entrambi i validator concordano ora sulla stessa regex — non c'è più modo che un name superi lo scaffold ma poi fallisca il load.
Scegli con cura il segmento vendor. Il vendor è parte di ogni namespace, di ogni prefisso URL nel routes.php del tuo plugin e di ogni translation key che il plugin emette. Rinominarlo dopo significa fare search-and-replace su ogni file. acmecorp/loyalty è inequivocabile; x/loyalty non è valido (vendor troppo corto); acmecorp/loyaltypoints va bene.
Il comando di scaffold
Dalla root dell'applicazione, esegui:
php artisan plugin:init {vendor}/{name}
Per un esempio pratico useremo acmecorp/loyalty — il resto della pagina assume questo name. Sostituiscilo con il tuo quando esegui il comando.
$ 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
Il messaggio di successo viene stampato da App\Console\Commands\InitPlugin, che è un thin wrapper attorno al metodo a livello model App\Model\Plugin::init($name). Quel metodo fa tutto ciò che il resto della pagina descrive — validazione, copia dello scaffold, render Twig, rinomina file, poi una chiamata concatenata a Plugin::register($name) che inserisce la riga di database e fa il boot del service provider.
Quando il prompt ritorna, il plugin è già caricato nell'applicazione in esecuzione come package inattivo. Le route dichiarate nel suo routes.php sono raggiungibili, le view sono renderizzabili e ogni hook registrato dal service provider è attivo. L'unica cosa che l'attivazione aggiunge è ciò che l'autore del plugin ha collegato all'evento activate_plugin_{vendor}/{name} — tipicamente l'esecuzione di una migration.
Cosa è stato generato
Il comando Artisan scrive un piccolo set di file starter dentro storage/app/plugins/{vendor}/{name}/, renderizza i placeholder Twig al loro interno e rinomina la migration placeholder. L'elenco esatto dei file è hard-coded in Plugin::init() — otto file content-rendered più un paio di asset statici. Nessuno di questi file è speciale; sono Laravel puro che sei libero di cancellare, rinominare o estendere.
L'albero delle directory su disco dopo che il comando ha finito:
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
Gli otto file a colpo d'occhio
| File | A cosa serve |
composer.json | Contratto a runtime: name, autoload.psr-4 ed extra.laravel.providers sono obbligatori. Senza di essi il loader non può registrare il namespace né fare il boot del provider. |
src/ServiceProvider.php | L'unico entry point che Laravel vede. Registra le traduzioni in register(), poi route, view, lifecycle hook e l'icon URL in boot(). |
src/Controllers/DashboardController.php | Un sample usa-e-getta. Restituisce la view index.blade.php in bundle. Sostituiscilo liberamente. |
src/Models/Setting.php | Un model Eloquent legato alla prima migration del plugin. Il nome tabella è namespaced come {vendor}_{name}_settings in modo che i plugin non possano collidere sullo stesso DB. |
routes.php | Caricato dal service provider. Dichiara sia la route che serve l'icona (usata dalla pagina admin Plugins) sia una route di dashboard di esempio plugins/{vendor}/{name}. |
resources/views/index.blade.php | La view Hello World renderizzata da DashboardController. Sostituiscila con la tua UI reale. |
resources/lang/en/messages.php | Il file di traduzione master. Language::dump() lo copia in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/ a runtime — i file dumpati sono quelli che l'applicazione legge davvero. |
database/migrations/2000_01_01_000000_create_{vendor}_{name}_settings_table.php | La prima migration. Gira solo all'attivazione del plugin, viene rollbackata alla cancellazione. Il nome del file è l'unico i cui placeholder non vengono renderizzati da Twig — Plugin::init() lo rinomina tramite un passaggio str_replace separato. |
Un plugin reale di produzione cresce oltre questa superficie minima. Il riferimento canonico nella codebase è storage/app/plugins/Aurius/ — otto model Eloquent, quattordici migration, diciotto locale, oltre sessanta view, un gruppo nella sidebar admin, una bubble UI di chatbox e i propri job legati alla queue. Lo scheletro Hello World è volutamente minimale così puoi sostituire i pezzi uno alla volta senza dover imparare ogni sottosistema in un colpo solo. I controller extra vanno sotto src/Controllers/, i model extra sotto src/Models/, i service extra sotto src/Services/, le migration aggiuntive sotto database/migrations/.
Cosa fa Plugin::register() dietro le quinte
La riga di output dice created & loaded, ed è esatta. Tra la copia dei file e la stampa del messaggio di successo, Plugin::init() chiama Plugin::register($name), che esegue cinque step distinti:
- Legge il
composer.json del plugin. Il campo name deve corrispondere esattamente alla directory (acmecorp/loyalty) — un mismatch lancia un'eccezione composer name in composer.json is expected to be ….
- Crea o aggiorna la riga nella tabella database
plugins. title, description e version vengono presi dai metadata di composer. Lo status è impostato a inactive.
- Scrive il master file.
storage/app/plugins/index.json è il registry al boot — AppServiceProvider::boot() legge questo file per decidere quali plugin autoloadare, su ogni richiesta, senza toccare il database. Attivazione e disable più avanti mutano lo stesso file.
- Carica immediatamente il service provider. Il
boot() del plugin gira nel processo corrente, quindi qualsiasi route / view / hook che registra è già attivo prima della prossima richiesta.
- Materializza i file di traduzione.
Language::dump() legge ogni entry dell'hook add_translation_file, copia i file master in storage/app/data/plugins/... e termina eseguendo vendor:publish --tag=plugin --force così tutti gli asset in bundle finiscono sotto public/plugins/....
Il modello mentale da ricordare: "installato" significa già "caricato". L'attivazione è puramente un flip di status più quello che l'autore del plugin ha collegato all'evento di attivazione. Non esiste uno step separato di registrazione delle route innescato dall'attivazione — le route sono registrate nel momento in cui plugin:init finisce.
I plugin inattivi sono comunque caricati. L'implementazione attuale di Plugin::autoloadWithoutDbQuery() carica ogni plugin elencato in index.json, indipendentemente dallo status. Se una feature deve davvero scomparire quando l'admin disabilita il plugin, l'autore del plugin deve metterla esplicitamente dietro un gate — un middleware di route che controlla Plugin::getByName($name)->isActive() e fa abort con 404 è il pattern convenzionale. Il plugin admin-console della piattaforma core ne è l'esempio canonico.
Attivare il plugin
Con il plugin scaffoldato e inattivo, lo step successivo è marcarlo attivo così che il suo listener activate_plugin_{vendor}/{name} esegua la migration. Due strade:
Dalla UI admin
Accedi come admin, apri /rui/admin/plugins, trova la entry Loyalty e clicca Activate. La pagina renderizza l'icona servita dal tuo routes.php (il placeholder fornisce un icon.svg nella root del plugin — sostituiscilo con il tuo per personalizzare la entry).
Programmaticamente (test o seeding)
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate();
=> ✓ status: active, migration ran, master file updated
Entrambe le strade lanciano Hook::fire('activate_plugin_acmecorp/loyalty'). Il service provider dello scheletro ha registrato un listener Hook::on(...) per quell'evento in boot() — il listener chiama Artisan::call('migrate', ['--path' => ..., '--force' => true]), che crea la tabella acmecorp_loyalty_settings.
Visita /plugins/acmecorp/loyalty in un browser e la pagina Hello World in bundle viene renderizzata. Il blockquote @{{ trans('loyalty::messages.intro') }} pesca dal file di traduzione dumpato sotto storage/app/data/plugins/acmecorp/loyalty/lang/en/messages.php.
Le tue prime modifiche
Lo scheletro è volutamente minimale così puoi sostituire i pezzi uno alla volta senza imparare ogni sottosistema in una volta sola. Un ordine ragionevole:
- Aggiorna
composer.json. Imposta title, description e version reali. La pagina admin Plugins li renderizza.
- Aggiungi una migration reale. Metti un nuovo file sotto
database/migrations/ con un timestamp maggiore di quello esistente. Girerà al prossimo activate (o dopo un ciclo deactivate-then-reactivate).
- Aggiungi un model reale. Lo scheletro fornisce
Setting come placeholder. Aggiungi il tuo sotto src/Models/; usa il namespace {Vendor_class}\{Name_class}\Models\YourModel. I nomi delle classi sono derivati automaticamente dal vendor/name in lowercase — acmecorp diventa Acmecorp e loyalty diventa Loyalty.
- Sostituisci
DashboardController. Aggiungi i controller di cui la tua feature ha effettivamente bisogno. Tienili snelli — sposta la business logic in classi src/Services/.
- Sostituisci le view. L'
index.blade.php in bundle usa Bootstrap 5 da CDN. La maggior parte degli autori di plugin lo rimuove ed estende invece il layout dell'applicazione host.
- Aggiungi hook in
ServiceProvider::boot(). Vedi il deep-dive sul sistema di Hook per i quattro pattern. Lo scheletro dimostra già EVENT (Hook::on) e BEHAVIOR (Hook::set) — REGISTRY e FILTER sono i prossimi due da imparare.
Sette errori del primo giorno e come risolverli
Quasi ogni segnalazione dei nuovi autori di plugin ricade in una di queste sette categorie. Ognuna è ancorata a codice che sta in App\Model\Plugin o App\Providers\AppServiceProvider, quindi i sintomi sono prevedibili.
1. Il naming viola il validator
plugin:init lancia con Plugin name must be in the "author/name" format o Author name "..." is invalid. Only lowercase letters and digits are allowed. Causa: la regex ^[a-z0-9]+\/[a-z0-9]+$ con min:2 max:32 per lato rifiuta underscore, trattini, lettere maiuscole o lati più corti di due caratteri.
Fix: usa solo lettere minuscole e cifre — per esempio acmecorp/loyalty, non acme_corp/loyalty-points.
2. Il name in composer.json non corrisponde alla cartella
Dopo lo scaffold, Plugin::register() valida che il name nel composer.json renderizzato corrisponda alla cartella sotto storage/app/plugins/. Modificare il JSON con un vendor o name diverso senza rinominare la directory lancia Plugin name in composer.json is expected to be '{folder}', found '{json}'.
Fix: rinomina la directory e il JSON in modo coordinato, oppure rilancia plugin:init con il nuovo name.
3. autoload.psr-4 mancante o malformato
loadPluginByName() lancia Cannot boot plugin '{name}'. No 'autoload' found in composer.json (o la variante corrispondente 'autoload.psr4') quando il blocco autoload è rimosso o scritto male. Il runtime ha bisogno di quella mappa per registrare il namespace; senza, nulla in src/ può essere istanziato.
Fix: mantieni la entry autoload.psr-4 generata dallo scaffold. Il prefisso di namespace che dichiara (Acmecorp\Loyalty\\) deve corrispondere alla dichiarazione namespace in testa a ogni file PHP sotto src/.
4. La dichiarazione namespace non corrisponde a composer.json
L'autoloader di PHP risolve Acmecorp\Loyalty\Controllers\DashboardController in src/Controllers/DashboardController.php rimuovendo il prefisso Acmecorp\Loyalty\\ dichiarato in composer.json. Se il file dichiara namespace AcmeCorp\Loyalty\Controllers (C maiuscola in AcmeCorp), l'autoloader non lo trova. Sintomi: Class "Acmecorp\Loyalty\Controllers\DashboardController" not found alla prima richiesta.
Fix: la dichiarazione namespace in ogni file PHP sotto src/ deve usare la capitalizzazione esatta derivata dal vendor/name in lowercase. Per acmecorp/loyalty, è Acmecorp\Loyalty. Plugin::makeClassNameFromString() applica solo ucfirst — non c'è alcun casing intelligente.
5. Hook di traduzione registrato in boot() invece che in register()
AppServiceProvider::boot() chiama Hook::collect('add_translation_file') nella propria fase di boot. Quando il boot() di un plugin gira, quel loop è già terminato — aggiungere lì la entry di traduzione significa che non viene mai raccolta, e trans('loyalty::messages.intro') restituisce la chiave letterale.
Fix: registra le traduzioni in register(), esattamente come fa lo scheletro. Gli hook di lifecycle per activate_plugin_* e delete_plugin_* restano comunque in boot().
6. Chiamare $this->loadTranslationsFrom(...) in boot()
Un istinto comune è chiamare direttamente il loadTranslationsFrom() di Laravel in aggiunta all'hook. Poiché il boot() del plugin gira dopo AppServiceProvider::boot, la seconda chiamata sovrascrive il namespace hint che puntava ai file di runtime dumpati (storage/app/data/plugins/...) e lo ri-punta al master file (storage/app/plugins/.../resources/lang/...). Il sintomo visibile è che le modifiche admin nella Languages UI smettono di avere effetto a runtime — i cloni dumpati diventano file zombie.
Fix: usa solo l'hook add_translation_file. Non chiamare anche loadTranslationsFrom().
7. Hook registrati in register() che dipendono da altri plugin o dal kernel
register() gira prima che il register() di tutti gli altri provider sia completato e ben prima di qualunque boot(). Codice che ha bisogno del database, dei service di un altro plugin o di un singleton wired dal register() di un altro provider può fallire con Class not found o Target class does not exist. L'unico hook che va in register() è add_translation_file (perché deve girare prima del collect loop di AppServiceProvider::boot).
Fix: metti ogni altro hook in boot(). Se hai assoluto bisogno che qualcosa parta in anticipo, mettilo dietro un gate su app()->runningInConsole() o isInitiated().
Checklist passo-passo
La sequenza completa per rilasciare un plugin funzionante, end-to-end:
php artisan plugin:init {vendor}/{name} — scaffold.
- Modifica
composer.json — imposta title, description, version reali.
- Scrivi le tue migration sotto
database/migrations/.
- Aggiungi i model sotto
src/Models/.
- Aggiungi i controller sotto
src/Controllers/.
- Aggiungi le view sotto
resources/views/.
- Dichiara le route in
routes.php.
- Collega tutto in
ServiceProvider::boot() — view, route, hook, asset publish.
- Accedi come admin → Plugins → Activate. La migration parte automaticamente.
Quando qualcosa va storto, due entry point di debug coprono quasi ogni caso. storage/logs/laravel.log cattura ogni eccezione lanciata durante il boot, incluse quelle sollevate dentro loadPluginByName() mentre registra l'autoload. Il campo error su ogni riga di storage/app/plugins/index.json mostra l'ultimo fallimento di boot per quel plugin ed è ciò che la pagina admin Plugins usa per mostrare la pillola rossa di errore — pulire il file riattivando il plugin (o cancellando e reinstallando) resetta lo stato di errore.
Dove andare ora
Hai lo scaffold, il lifecycle e i sette errori che bloccano la maggior parte del debug del primo giorno. Le prossime due pagine ti danno il modello mentale che il resto della documentazione presume:
- Plugin architecture — il flow di caricamento al boot, perché i plugin inattivi vengono comunque autoloadati, il meccanismo del master file e la differenza tra
register() e boot() a livello di runtime.
- Il sistema di Hook — i quattro pattern (REGISTRY, EVENT, BEHAVIOR, FILTER), quando usare quale e la semantica dei conflitti che fa lanciare BEHAVIOR in caso di collisione invece di sovrascrivere in silenzio.
Quando sei pronto a rilasciare un plugin di feature reale, gli esempi pratici sono Sending driver (Postal MTA end-to-end) e Payment gateway (Paddle come gateway regionale). Per il lavoro UI, UI injection copre gli hook di layout/sidebar/page-slot che permettono a un plugin di montare una bubble chatbox o un pannello di settings senza forkare un singolo Blade.