Il modello mentale che il resto di questi docs presume.

Un plugin in questa codebase è un piccolo package Laravel — ma il modo in cui l'applicazione host lo carica non è il modo in cui Composer carica normalmente i package. Non c'è uno step di install in vendor/, niente entry in composer.lock, nessuna rigenerazione dell'autoload. Ogni plugin è registrato a runtime, da un singolo master file JSON, da un nuovo Composer\Autoload\ClassLoader che l'host istanzia dentro AppServiceProvider::boot(). Una volta che hai questa immagine, il resto dei docs per sviluppatori — hook, sending driver, payment gateway, UI injection, lifecycle — si incastra attorno in modo pulito.

Cos'è un plugin qui

Un plugin è un package Laravel autonomo che vive in storage/app/plugins/{vendor}/{name}/ dentro l'install AcelleMail host. Porta il proprio composer.json, il proprio namespace PSR-4, il proprio service provider, le proprie route, view, migration e traduzioni. È strutturato esattamente come una piccola applicazione Laravel — tranne per una distinzione decisiva.

L'applicazione host non installa il plugin tramite l'autoloader Composer root. Non c'è alcuno step composer require, nessuna directory vendor/{vendor}/{name}/, nessuna entry in composer.lock. Invece, ogni volta che l'applicazione fa il boot, fa da sola le seguenti cose:

  1. Legge il composer.json di ciascun plugin.
  2. Registra il namespace PSR-4 dichiarato lì con una nuova istanza di Composer\Autoload\ClassLoader.
  3. Chiama App::register(...) sui service provider elencati sotto extra.laravel.providers.

La scelta è stata deliberata. Trattare i plugin come package installati via Composer avrebbe reso il composer.json dell'applicazione host un target mobile — ogni install, deattivazione o upgrade avrebbe mutato il lockfile. Il loader a runtime mantiene stabile il grafo delle dipendenze dell'host: i plugin portano i propri metadata e l'host può scansionarli, ignorarli o riordinarli senza toccare vendor/.

Cinque file che governano l'intero sistema

Quasi ogni comportamento del lifecycle dei plugin è implementato in cinque file nell'applicazione host. Leggere il sorgente di questi è il modo più rapido per confermare qualsiasi cosa in questa documentazione:

FileResponsabilità
app/Console/Commands/InitPlugin.phpL'entry point CLI per php artisan plugin:init. Thin wrapper attorno a Plugin::init($name).
app/Model/Plugin.phpL'intero lifecycle: scaffold, register, load, activate, disable, delete, più la macchineria del master file.
app/Library/HookManager.phpLe primitive di injection che i plugin usano per estendere il core — REGISTRY, EVENT, BEHAVIOR, FILTER. Circa 160 righe, zero dipendenze.
app/Providers/AppServiceProvider.phpAutoload dei plugin al boot + registrazione delle traduzioni. L'unico call site che collega i plugin all'applicazione in esecuzione.
app/Model/Language.phpMaterializza i file di traduzione dei plugin in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/. L'indirezione che permette agli admin di modificare le traduzioni tramite la Languages UI senza toccare i file sorgente dei plugin.

Insieme totalizzano ben meno di tremila righe di codice host-side. Il sistema di plugin è piccolo di proposito — ogni vincolo che un plugin ha viene da uno di questi cinque file, e non c'è altro posto dove guardare.

Il flow di boot-and-load

Ogni richiesta, queue worker, tick dello scheduler e comando Artisan passa per la stessa sequenza di boot. La fetta rilevante per i plugin assomiglia a questa:

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.

Due dettagli implementativi in questa sequenza hanno conseguenze sproporzionate per gli autori di plugin:

1. La discovery al boot non interroga mai il database

L'elenco dei plugin da caricare viene da storage/app/plugins/index.json, non dalla tabella database plugins. I service provider non possono interrogare il database in sicurezza — nel momento in cui AppServiceProvider::boot() gira, la connessione potrebbe non esistere ancora (comandi CLI come artisan db:create) o lo schema potrebbe non essere migrato (setup di CI test). Conservare il registry di boot in un file JSON aggira l'intero problema.

La tabella DB esiste comunque. Conserva lo stesso status del file JSON, più i metadata user-facing come title, description e version. La pagina admin Plugins legge dal DB; il boot loader legge dal JSON. Entrambi sono tenuti in sync da Plugin::register(), activate() e disable() — ogni cambio di status scrive su entrambi gli store.

2. autoloadWithoutDbQuery() attualmente carica ogni plugin nell'index — anche quelli inattivi

L'implementazione attuale itera ogni entry in index.json e chiama loadPluginByName su di essa, indipendentemente dallo status. Il motivo è pragmatico: anche un plugin inattivo ha bisogno che le sue route siano registrate (così le pagine admin continuano a funzionare quando un admin clicca "deactivate" senza ricaricare subito), e ha bisogno che le sue traduzioni siano disponibili (così i cloni dumpati non diventano stantii).

La conseguenza è che "inactive" nel sistema di plugin AcelleMail non è lo stesso di "unloaded". La prossima sezione rende precisa la distinzione.

Il contratto composer.json

Il composer.json di un plugin non è solo metadata — è il contratto a runtime su cui il loader dipende. Le chiavi che contano sono:

ChiaveScopo
nameID canonico del plugin. Deve corrispondere esattamente alla directory sotto storage/app/plugins/. Plugin::register() lancia se divergono.
autoload.psr-4Mappa il prefisso di namespace del plugin su src/. Obbligatorio — senza, loadPluginByName() lancia e il plugin non può fare il boot.
extra.laravel.providersArray di nomi classe fully-qualified. Il loader chiama App::register() su ognuno. Obbligatorio se il plugin vuole registrare route, view, hook o qualsiasi altra cosa.
extra.setting-routeIl controller@method a cui la pagina admin Plugins linka come pulsante "Settings" del plugin. Opzionale — i plugin senza configurazione possono ometterlo.
title, description, versionEsposto nel listing admin Plugins. title è obbligatorio; gli altri ricadono su default.

La mappa di autoload è registrata a runtime, non all'install. Non hai bisogno di lanciare composer dump-autoload dopo aver modificato la mappa PSR-4 del plugin — l'host istanzia un nuovo ClassLoader ad ogni richiesta e rilegge il file. È anche per questo che cambiare il namespace di un plugin non richiede più di un search-and-replace più una richiesta all'host.

Il master file (storage/app/plugins/index.json)

Il master file è un oggetto JSON piatto chiavato per name di plugin. Ogni entry memorizza al minimo uno status, più una stringa error opzionale quando l'ultimo tentativo di boot è fallito. Un file tipico assomiglia a questo:

{
  "acelle/ai":      { "status": "active" },
  "acmecorp/loyalty": { "status": "inactive" },
  "broken/sample":   { "status": "active", "error": "Class \"Broken\\Sample\\ServiceProvider\" not found" }
}

Tre metodi host-side possiedono questo file. Ogni cambio di status passa per uno di loro:

  • Plugin::updatePluginMasterFile($name, $params) — merge-write della entry di un singolo plugin. Passa null come secondo argomento per rimuovere completamente la entry (percorso delete).
  • Plugin::resetPluginMasterFile() — ricostruisce il file da zero iterando Plugin::all(). Usato come recovery quando il JSON si corrompe o esce di sync con il DB.
  • Plugin::getErroredPluginNames() — legge ogni entry, restituisce i name con error non vuoto. Il listing admin Plugins lo usa per spingere in fondo i plugin rotti e mostrare la pillola rossa di errore.

La chiave error è impostata quando autoloadWithoutDbQuery() avvolge una chiamata a loadPluginByName() in un try/catch e la chiamata lancia. Il messaggio dell'eccezione viene registrato così la UI admin ha qualcosa da mostrare senza dover rieseguire il fallimento. Riattivare un plugin pulito ripulisce automaticamente il campo.

Il master file è l'unica source of truth al boot. Se devi mai recuperare da un plugin bloccato (la UI admin è giù, il database è offline), modifica direttamente storage/app/plugins/index.json. La prossima richiesta legge lo stato aggiornato e si comporta di conseguenza. La riga DB è il metadata a lungo termine; il file JSON è il registry a runtime.

Timing register() vs boot()

Laravel esegue prima il metodo register() di ogni service provider, in ordine di registrazione, prima di chiamare qualsiasi boot(). È un fatto Laravel ben noto — ma ha conseguenze dirette nel sistema di plugin.

Cosa va in register()

  • Costanti e binding — devono esistere prima che il boot() dell'host parta.
  • L'hook add_translation_file — e solo questo hook. L'AppServiceProvider::boot() dell'host chiama Hook::collect('add_translation_file') nella propria fase di boot. Quando il boot() di un plugin gira, quel loop è già terminato. Se un plugin registra la sua entry di traduzione in boot(), non viene mai raccolta — e trans('myname::messages.intro') restituisce la chiave letterale.

Cosa va in boot()

  • Route e view$this->loadRoutesFrom(...), $this->loadViewsFrom(...).
  • Pubblicazione di asset$this->publishes([...], 'plugin').
  • Listener per eventi di lifecycleHook::on('activate_plugin_{name}', ...), Hook::on('delete_plugin_{name}', ...).
  • L'icon URLHook::set('icon_url_{vendor}/{name}', ...).
  • Ogni altro hook — REGISTRY add, EVENT on, BEHAVIOR set, FILTER modify. Tutto ciò che dipende da binding nel container, config o altri plugin.

Non chiamare $this->loadTranslationsFrom(...) nel boot() del tuo plugin. L'host ha già wired il namespace tramite l'hook add_translation_file, puntandolo ai file di runtime dumpati sotto storage/app/data/plugins/.... Una seconda loadTranslationsFrom dal boot() del tuo plugin sovrascrive l'hint dell'host e ri-punta il namespace al master file sotto resources/lang/.... Il sintomo visibile è che le modifiche admin nella Languages UI smettono di avere effetto a runtime — i cloni dumpati diventano file zombie. Usa solo l'hook.

Perché i plugin inattivi influenzano comunque l'app

La chiamata autoloadWithoutDbQuery() al boot carica ogni plugin in index.json indipendentemente dallo status. Quindi un plugin "inactive" ha comunque ognuno di questi registrato con l'host:

  • Le sue route — dichiarate da $this->loadRoutesFrom(...) in boot().
  • Le sue view — dichiarate da $this->loadViewsFrom(...).
  • I suoi middleware alias — registrati tramite le API Laravel standard.
  • I suoi hook listener — ogni Hook::add, Hook::on, Hook::modify, Hook::set continua a partire.
  • I suoi frammenti UI — qualsiasi cosa contribuita via layout.head.assets, layout.body.before_close, admin.sidebar.groups o hook REGISTRY di page-slot continua a comparire.

Ciò che l'attivazione aggiunge in realtà è quello che l'autore del plugin ha collegato a activate_plugin_{vendor}/{name}. Il listener dello scheletro esegue la migration. Non c'è alcuno step implicito di "registra route quando attivo" o "rimuovi route quando inattivo" — le route sono state registrate nel momento in cui l'applicazione ha fatto il boot.

Se una feature deve davvero scomparire quando un admin disabilita il plugin, l'autore del plugin deve metterla esplicitamente dietro un gate. Il pattern convenzionale vive in storage/app/plugins/acelle/console: le route si caricano sempre, ma un middleware di route chiamato console.active fa abort con 404 quando Plugin::getByName('acelle/console')->isActive() ritorna false. Copia quel pattern quando "deattivato" deve significare "non raggiungibile".

Lo stesso vale per gli hook UI. Se una bubble chatbox iniettata tramite layout.body.before_close deve nascondersi quando il plugin è inattivo, il corpo della closure deve prima controllare Plugin::enabled('myvendor/myplugin') e restituire null se false. L'host filtra automaticamente i return falsy prima del render.

Lifecycle: register / activate / disable / delete

Quattro stati, quattro metodi host-side. Ognuno è preciso su ciò che cambia e su ciò che non cambia.

Register / install

Plugin::register($name) è l'entry point — viene chiamato automaticamente alla fine di plugin:init e su ogni upload riuscito tramite la UI admin. I cinque step sono:

  1. Legge composer.json, copia title / description / version nel model.
  2. Inserisce o aggiorna la riga in plugins con status = inactive.
  3. Scrive storage/app/plugins/index.json con { "name": { "status": "inactive" } }.
  4. Chiama Plugin::load($withServiceProvider = true) — registra il prefisso PSR-4 e fa il boot del service provider immediatamente, così ogni route / view / hook diventa attivo nel processo corrente.
  5. Chiama Language::dump() per materializzare i file di traduzione, poi esegue vendor:publish --tag=plugin --force per copiare gli asset in bundle in public/plugins/....

Dopo register il plugin è installato e caricato. L'unica cosa che manca è quello che il plugin ha scelto di collegare al suo evento di activate — tipicamente l'esecuzione di una migration.

Activate

$plugin->activate() viene chiamato dal pulsante "Activate" della UI admin (e da test / seeder che chiamano direttamente il model). Fa quattro cose, in ordine:

  1. Lancia Hook::fire('activate_plugin_'.$name). Il listener dello scheletro esegue artisan migrate su storage/app/plugins/{vendor}/{name}/database/migrations. Altri plugin possono registrare listener aggiuntivi — comportamento REGISTRY, ogni listener parte.
  2. Rivalida il composer.json del plugin contro la lista di chiavi obbligatorie dell'host (name, version, app_version).
  3. Imposta lo status DB a active.
  4. Aggiorna il master file: { "status": "active", "error": null } — pulisce qualsiasi errore di boot precedente.

Disable

$plugin->disable() si limita a:

  • Impostare lo status DB a inactive.
  • Aggiornare il master file con il nuovo status e ripulire ogni error registrato.

Non scarica route, view, service provider, hook listener o nient'altro che era stato registrato al boot. L'host non ha alcun concetto di "deregistra un service provider" — Laravel stesso non lo supporta. Disable è un flip di status, non un unload.

Delete

$plugin->deleteAndCleanup($keepData = false) percorre l'intero teardown:

  1. Lancia Hook::fire('delete_plugin_'.$name, [$keepData]). Il listener dello scheletro esegue migrate:rollback; $keepData = true può saltarlo per plugin che possiedono dati che l'admin vuole preservare.
  2. Cancella ricorsivamente la directory del plugin sotto storage/app/plugins/....
  3. Cancella la riga dalla tabella DB plugins.
  4. Rimuove la entry dal master file.

Fino a quando la prossima richiesta non fa il boot di un nuovo processo, il service provider del plugin resta caricato in memoria. La prossima richiesta legge il master file (ora ridotto), non carica il plugin, e lo stato in-process viene scartato con il request lifecycle.

Due layer di injection

Un plugin influenza l'applicazione host tramite due layer paralleli. Distinguerli è ciò che fa sì che il resto della documentazione mappi pulitamente sul codice.

Layer 1 — registrazione Laravel

Tramite il service provider, un plugin usa le API standard del container Laravel per estendere l'applicazione:

  • $this->loadRoutesFrom(__DIR__ . '/../routes.php') — aggiunge la superficie HTTP del plugin.
  • $this->loadViewsFrom(__DIR__ . '/../resources/views', 'myname') — espone le view Blade sotto il namespace myname::view.
  • $this->publishes([...], 'plugin') — copia gli asset in bundle nella public/plugins/{vendor}/{name}/ dell'host all'install.
  • Middleware alias, binding nel container, comandi console, scheduled task, queue listener — tutto ciò che Laravel stesso supporta.

Layer 2 — injection basata su hook

L'host chiama nelle primitive di App\Library\HookManager in punti di estensione scelti con cura. I plugin registrano listener su quei punti per partecipare. Ci sono esattamente quattro pattern: REGISTRY, EVENT, BEHAVIOR, FILTER. Il prossimo deep-dive — Il sistema di Hook — li copre tutti per esteso.

Due cose da sapere subito: (1) ogni hook che l'host lancia è un contratto stabile — una volta pubblicato, name e signature non cambiano tra le release. (2) BEHAVIOR è esclusivo — se due plugin provano a fare Hook::set sullo stesso name, la seconda chiamata lancia immediatamente. Niente override silenziosi; i conflitti emergono al boot, non in produzione.

La codebase fornisce tre hook REGISTRY a livello di layout che quasi ogni plugin che estende la UI usa:

Hook keyDove parteUsato per
layout.head.assetsresources/views/refactor/layouts/{app,admin}.blade.php, prima di @yield('head')CSS / JS che devono caricarsi prima del contenuto della pagina (stili chatbox, script popover sparkle)
layout.body.before_closeStessi layout, appena prima di </body>Widget floating — bubble chatbox, modal, popover sparkle
admin.sidebar.groupsresources/views/refactor/components/nav/admin-sidebar.blade.phpSezioni della sidebar admin contribuite dai plugin

Tutti e tre seguono lo stesso idioma: ogni callback restituisce HTML renderizzato o null; l'host itera con array_filter ed emette ogni frammento con {!! !!}. Restituire null è il modo convenzionale per mettere dietro un gate un contributo tramite feature flag o status del plugin senza lanciare eccezioni.

Flow delle traduzioni a runtime

Le traduzioni dei plugin non sono servite direttamente dalla cartella sorgente resources/lang/ del plugin. Il flow è indiretto, e quell'indirezione è ciò che permette agli admin di modificare le traduzioni tramite la Languages UI dell'host senza committare sui file sorgente del plugin. La sequenza verificata:

  1. Il register() del plugin contribuisce una entry Hook::add('add_translation_file', ...) che punta a storage/app/data/plugins/{vendor}/{name}/lang/.
  2. L'AppServiceProvider::boot() dell'host raccoglie tutte queste entry e chiama $this->loadTranslationsFrom() contro ognuna.
  3. Su ogni Plugin::register(), l'host chiama Language::dump().
  4. Language::dump() legge il master file del plugin in resources/lang/en/messages.php e lo copia in storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php per ogni locale supportato.
  5. La UI admin Languages modifica i file di runtime dumpati. Il master file sorgente del plugin resta intatto.

Due path da ricordare:

  • Master file (lo modifichi nel sorgente): storage/app/plugins/{vendor}/{name}/resources/lang/en/messages.php
  • File runtime (auto-generati, ciò che l'app legge davvero): storage/app/data/plugins/{vendor}/{name}/lang/{locale}/messages.php

Quando modifichi il master file, esegui php artisan translation:upgrade per risincronizzare il master in tutti i file runtime per locale (preservando le traduzioni che gli admin hanno modificato tramite la Languages UI). I meccanismi completi — master vs runtime, semantica di upgrade, fallback per locale — hanno un deep-dive dedicato in Translations.

Cosa implica per gli autori di plugin

Cinque regole discendono dall'architettura sopra. Interiorizzarle trasforma buona parte della complessità superficiale nel resto dei docs in un controllo contro questa lista.

  1. Tratta boot() come la fase di registrazione. Route, view, hook, listener di lifecycle — quasi tutto va qui. L'unica cosa che va in register() è l'hook add_translation_file (perché l'host lo raccoglie prima che il boot() di qualsiasi plugin parta).
  2. Inactive non significa unloaded. Tutto ciò che registri al boot è attivo indipendentemente dallo status active / inactive. Se una feature deve davvero scomparire quando disabilitata, mettila esplicitamente dietro un gate con un middleware di route o un check Plugin::enabled(...) dentro la closure dell'hook.
  3. Modifica le traduzioni tramite il master file, mai direttamente tramite loadTranslationsFrom(). I cloni dumpati sotto storage/app/data/plugins/... sono ciò che il runtime legge. Puntare il tuo namespace alla directory master da solo sovrascrive l'hint dell'host e rompe la Languages UI.
  4. Tieni composer.json snello e stabile. Il loader a runtime lo legge ad ogni richiesta. autoload.psr-4, extra.laravel.providers, name, title sono le chiavi che l'host usa davvero. Aggiungere altre chiavi va bene ma non produce effetti.
  5. I quattro pattern di hook sono l'unico contratto. Quando ti ritrovi a voler "importare" una classe core per estenderla — fermati. Il contratto del plugin è a senso unico: il core dichiara gli hook, i plugin reagiscono. Se il punto di estensione di cui hai bisogno non esiste ancora come hook, la mossa giusta è aprire una issue contro l'host, non fare use Acelle\Model\Customer dal controller del tuo plugin.

Dove andare ora

Hai l'architettura. Due pagine trasformano questo modello mentale nelle API daily-use che andrai a usare:

  • Il sistema di Hook — i quattro pattern in profondità, con call site reali grep-pati dal core. La semantica dei conflitti, quando usare quale pattern e gli anti-pattern che sembrano giusti ma si rompono in produzione.
  • UI injection — gli hook a livello di layout sopra, più il contratto page.{controller}.{action}.{slot} che permette a un plugin di iniettare una card in una pagina esistente senza forkare un singolo Blade.

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 un esercizio completo di lettura, lo showcase Aurius percorre il plugin complesso canonico: otto model, quattordici migration, diciotto locale e ogni superficie di hook usata in produzione.