Da un terminale vuoto a un plugin Hello World funzionante — in circa cinque minuti.

Un singolo comando Artisan genera un package Laravel autonomo sotto storage/app/plugins/{vendor}/{name}/. Quando finisci di leggere il messaggio di successo, la riga del database è già inserita, il master file è aggiornato e il service provider è già in boot all'interno dell'applicazione in esecuzione — anche se il plugin è ancora inattivo. Questa pagina ti accompagna nell'intera sequenza end-to-end e nei sette errori del primo giorno che spiegano quasi tutti i fallimenti dei nuovi autori.

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

FileA cosa serve
composer.jsonContratto 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.phpL'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.phpUn sample usa-e-getta. Restituisce la view index.blade.php in bundle. Sostituiscilo liberamente.
src/Models/Setting.phpUn 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.phpCaricato 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.phpLa view Hello World renderizzata da DashboardController. Sostituiscila con la tua UI reale.
resources/lang/en/messages.phpIl 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.phpLa 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:

  1. 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 ….
  2. Crea o aggiorna la riga nella tabella database plugins. title, description e version vengono presi dai metadata di composer. Lo status è impostato a inactive.
  3. 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.
  4. 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.
  5. 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:

  1. Aggiorna composer.json. Imposta title, description e version reali. La pagina admin Plugins li renderizza.
  2. 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).
  3. 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.
  4. Sostituisci DashboardController. Aggiungi i controller di cui la tua feature ha effettivamente bisogno. Tienili snelli — sposta la business logic in classi src/Services/.
  5. 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.
  6. 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:

  1. php artisan plugin:init {vendor}/{name} — scaffold.
  2. Modifica composer.json — imposta title, description, version reali.
  3. Scrivi le tue migration sotto database/migrations/.
  4. Aggiungi i model sotto src/Models/.
  5. Aggiungi i controller sotto src/Controllers/.
  6. Aggiungi le view sotto resources/views/.
  7. Dichiara le route in routes.php.
  8. Collega tutto in ServiceProvider::boot() — view, route, hook, asset publish.
  9. 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.