I quattro stati a colpo d'occhio
Ogni riga di plugin porta una colonna status con uno di due valori — active o inactive. Due ulteriori stati sono impliciti: non ancora registrato (nessuna riga DB, nessuna entry nel master file) e cancellato (directory file via, riga via, entry del master file via). Quattro transizioni muovono il plugin tra di essi:
| Transizione | Metodo | Status prima | Status dopo | Cosa cambia su disco / DB |
| Register | Plugin::register($name) | (nessuna riga) | inactive | Riga DB inserita; entry del master file scritta; service provider bootato nella request corrente |
| Activate | $plugin->activate() | inactive | active | Migration eseguite via activate hook; status DB flippato; error del master file ripulito |
| Disable | $plugin->disable() | active | inactive | Status DB flippato; error del master file ripulito. Niente altro. |
| Delete | $plugin->deleteAndCleanup($keepData = false) | qualsiasi | (nessuna riga) | Delete hook si attiva (tipicamente migrate:rollback); cartella del plugin rimossa; riga DB rimossa; entry del master file rimossa |
Il modello mentale che vale la pena tenere: register e delete cambiano il mondo (file su disco, schema DB). Activate e disable flippano solo un flag — le route, view, hook e lo stato del service provider da register restano in piedi. Le prossime sezioni coprono ogni transizione in ordine.
Stato 1 — Register / install
Plugin::register($name) in app/Model/Plugin.php:559 è l'entry point. Viene chiamato automaticamente alla fine di php artisan plugin:init e a ogni upload riuscito tramite la pagina admin Plugins. Il metodo fa cinque cose distinte in ordine:
- Legge
composer.json da storage/app/plugins/{vendor}/{name}/ e copia title, description, version nel model. Lancia se il campo composer name non corrisponde esattamente alla directory.
- Inserisce (o aggiorna) la riga nella tabella DB
plugins con status = inactive. firstOrNew(['name' => $name]) è il lookup, quindi ri-registrare un plugin esistente lo aggiorna anziché duplicarlo.
- Scrive il master file:
storage/app/plugins/index.json riceve una entry { "name": { "status": "inactive" } }. Questo è il registry di boot-time che l'host legge a ogni request senza andare al DB.
- Carica subito il service provider:
$plugin->load($withServiceProvider = true) registra il prefisso PSR-4 con un fresco Composer\Autoload\ClassLoader e chiama App::register() sulla classe service provider del plugin. Quando il metodo ritorna, le route, view e hook del plugin sono collegate al processo in esecuzione.
- Materializza le traduzioni e pubblica gli asset:
Language::dump() crea file runtime per-locale sotto storage/app/data/plugins/{vendor}/{name}/lang/, poi artisan vendor:publish --force --tag=plugin copia qualsiasi asset incluso in public/plugins/{vendor}/{name}/.
Dopo register, il plugin è installato e caricato. Non è ancora attivo — significa solo che qualsiasi cosa il plugin abbia scelto di collegare al suo evento activate non è ancora girata. Le route, view e listener hook del plugin sono già live.
Stato 2 — Activate
$plugin->activate() in Plugin.php:484 è ciò che chiama il bottone admin "Activate". Quattro passi ordinati:
- Attiva l'activate hook:
Hook::fire('activate_plugin_'.$this->name). Ogni listener registrato contro questo nome gira — tipicamente il listener Hook::on('activate_plugin_*', ...) del plugin stesso che chiama artisan migrate contro la migrations folder del plugin. Altri plugin possono registrare listener aggiuntivi sullo stesso evento.
- Ri-valida
composer.json: self::validateMetaData($config) verifica che le chiavi richieste del plugin (name, version, app_version) siano presenti e ben formate. Chiavi mancanti lanciano prima che lo status flip atterri.
- Imposta status DB a
active e salva la riga.
- Aggiorna il master file:
{ "status": "active", "error": null } — il reset di error ripulisce qualsiasi precedente fallimento di boot così che i futuri autoload sweep trattino il plugin come sano.
L'attivazione è in pratica idempotente. Ri-eseguire activate() su un plugin già attivo riattiva l'hook (così i listener che hanno eseguito migrate lo eseguono di nuovo — la migrations table di Laravel dedup-a i file già eseguiti, quindi la seconda invocazione è un no-op), ri-valida e scrive lo stesso status. Nessun branch speciale "già attivo".
Stato 3 — Disable
$plugin->disable() in Plugin.php:136 è il più semplice dei quattro metodi. Fa solo questo:
- Imposta status DB a
inactive.
- Aggiorna il master file con il nuovo status e ripulisce qualsiasi campo
error.
Questo è l'intero metodo. Non fa unload di nulla.
Le route registrate durante il boot() del plugin restano registrate. Le view restano mountable. I listener hook si attivano ancora quando l'host attiva il loro hook. Il service provider del plugin è ancora caricato nel container dell'applicazione e verrà caricato di nuovo alla prossima request perché autoloadWithoutDbQuery() legge ogni entry dal master file indipendentemente dallo status. Disable è un flip di status, non un unload — Laravel stesso non supporta la de-registrazione di un service provider dopo il suo boot.
Per questo il plugin acelle/console è il pattern canonico "le feature del plugin devono scomparire quando disabilitato": le route si caricano sempre, ma una route middleware chiamata console.active aborta con 404 quando Plugin::getByName('acelle/console')->isActive() restituisce false. Il check avviene a ogni request, contro lo status DB corrente, quindi disabilitando il plugin le sue route restituiscono 404 a partire dalla request successiva.
Il pattern visible-disable in tre passi. (1) Definisci una route middleware che controlla Plugin::enabled('myvendor/myplugin') e aborta 404 quando false. (2) Registralo come middleware alias nel boot() del tuo service provider. (3) Applicalo al tuo route group in routes.php. Ogni plugin che shippa feature visibili all'utente dovrebbe seguire questo pattern — senza, "disattivato" sembra identico ad "attivato" dalla prospettiva dell'utente.
Stato 4 — Delete
$plugin->deleteAndCleanup($keepData = false) in Plugin.php:670 è il teardown completo. Quattro passi ordinati:
- Attiva il delete hook:
Hook::fire('delete_plugin_'.$name, [$keepData]). Il listener dello skeleton chiama artisan migrate:rollback contro la migrations folder del plugin. Il flag $keepData viene inoltrato così il listener può fare opt-out dal rollback di tabelle che contengono dati customer — vedi la pagina database-models per il pattern pratico.
- Cancella la directory del plugin:
$this->deletePluginDirectory() rimuove ricorsivamente storage/app/plugins/{vendor}/{name}/. Dopo questo passo, il sorgente PHP del plugin è via dal disco.
- Cancella la riga DB. La tabella
plugins non referenzia più questo plugin.
- Rimuovi l'entry del master file:
updatePluginMasterFile($name, null) — il null è il segnale convenzionale per droppare l'entry anziché fare merge di nuovi campi.
Finché la prossima request non boota un processo fresco, le route, view e hook del plugin sono ancora caricati in memoria — il container Laravel in-process non ha concetto di "de-registrare il service provider di questo plugin". La prossima request legge il master file (ora ristretto), non carica il plugin, e lo stato in-memory viene scartato con il lifecycle della request precedente.
Il master file a ogni transizione
storage/app/plugins/index.json è l'unica source of truth al boot time. Ogni transizione qui sopra ci scrive. Un modo utile di vedere il lifecycle è guardare come appare l'entry di un plugin a ogni passo:
// Before register: no entry.
{}
// After register:
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After activate:
{
"acmecorp/loyalty": { "status": "active" }
}
// After a boot failure (sticky until cleared by activate):
{
"acmecorp/loyalty": { "status": "active", "error": "Class \"…\" not found" }
}
// After disable (error cleared, status flipped):
{
"acmecorp/loyalty": { "status": "inactive" }
}
// After delete: no entry.
{}
Tre metodi lato host detengono il file: updatePluginMasterFile($name, $params) per merge-writes (passa null come secondo arg per rimuovere l'entry), resetPluginMasterFile() per ricostruire il file da Plugin::all() quando va fuori sync con il DB, e getErroredPluginNames() per leggere ogni entry e restituire i nomi con error non vuoto.
Recovery da stato corrotto
Tre failure mode si presentano in produzione:
1. La riga del plugin nel master file è stale o sbagliata
Comune dopo edit manuali, deploy parziali, o ripristino di un database snapshot. Fix: esegui php artisan tinker e chiama Plugin::resetPluginMasterFile(). Il metodo itera Plugin::all() dal DB e riscrive il file JSON da zero, preservando lo status e ripulendo ogni campo error.
2. Il campo error di un plugin è impostato e la pagina admin Plugins mostra la pillola rossa
L'errore è persistente — impostato quando autoloadWithoutDbQuery() wrappa una chiamata loadPluginByName() in try/catch e la chiamata lancia. L'errore rimane fino a un activate() riuscito (che imposta error => null) o a un disable() (idem). Fix: risolvi il problema sottostante (autoload.psr-4 mancante, namespace mismatch, classe service provider mancante), poi clicca Activate; il prossimo boot avrà successo e l'errore si pulirà.
3. La cartella del plugin è mancante ma l'entry del master file resta
Succede dopo un rm -rf manuale. Il boot prova ancora a caricare il plugin via l'entry del master file, lancia, e registra l'errore. Fix: rimuovi l'entry del master file direttamente con Plugin::updatePluginMasterFile($name, null), oppure — se il plugin deve ancora esistere — ri-uploada l'archivio sorgente ed esegui Plugin::register($name) di nuovo per ripopolare tutto.
I comandi console plugin:*
Un comando artisan è incluso nell'host: plugin:init. Non ci sono comandi plugin:activate, plugin:disable, o plugin:delete — quelle sono azioni di admin-UI. L'accesso programmatico passa direttamente dai metodi del model:
php artisan tinker
>>> $p = \App\Model\Plugin::getByName('acmecorp/loyalty');
>>> $p->activate(); // → active, runs migration via activate hook
>>> $p->disable(); // → inactive, status flip only
>>> $p->deleteAndCleanup($keepData = true); // → preserve customer-facing tables
Questa è la stessa superficie che la pagina admin Plugins usa internamente. Script CI, seeder e integration test attingono tutti a questi metodi direttamente. Il deep-dive di Testing copre il pattern a livello di test-suite.
Transizioni di stato in un diagramma
┌─────────────────────┐
│ not registered │ (no row, no master-file entry)
└──────────┬──────────┘
│ Plugin::register($name)
│ ├─ writes DB row (status=inactive)
│ ├─ writes master file
│ ├─ loads service provider in-process
│ └─ Language::dump() + vendor:publish
▼
┌─────────────────────┐
┌────▶ │ inactive │ ◀───┐
│ └──────────┬──────────┘ │
│ │ │
│ activate()│ │ disable()
│ │ │ ├─ status=inactive
│ │ │ └─ master file updated
│ ▼ │
│ ┌─────────────────────┐ │
│ │ active │ ────┘
│ └──────────┬──────────┘
│ │
│ deleteAndCleanup($keepData)
│ │ ├─ fires delete hook (rollback unless $keepData)
│ │ ├─ removes plugin folder
│ │ ├─ deletes DB row
│ │ └─ removes master-file entry
│ ▼
│ ┌─────────────────────┐
└──────│ not registered │
└─────────────────────┘
(cycle: register again to re-install)
Cinque anti-pattern
1. Trattare disable come se facesse unload del plugin
Le route si registrano ancora, gli hook si attivano ancora, le view si montano ancora. Fix: guardia le feature visibili all'utente con un middleware o check inline Plugin::enabled(...), esattamente come acelle/console.
2. Editare manualmente il master file in produzione
Facile corrompere il JSON. Fix: chiama Plugin::updatePluginMasterFile() o Plugin::resetPluginMasterFile() tramite tinker — entrambi validano.
3. rm -rf storage/app/plugins/{vendor}/{name} senza rimuovere la master entry
Il boot continua a provare a caricare il plugin mancante e registra l'errore. Fix: accoppia sempre una rimozione di cartella con Plugin::updatePluginMasterFile($name, null), o usa deleteAndCleanup() che fa entrambi.
4. Chiamare activate() da dentro al boot() di un service provider
La fase di boot gira una volta per processo; chiamare activate() lì attiva l'activate hook a ogni request. La migration gira ogni volta (idempotente — ma costosa), e i listener con side-effect si attivano anche loro. Fix: l'attivazione è un'azione di admin-UI, mai un side effect di boot-time.
5. Dimenticare che register avviene prima di activate
Alcuni plugin provano a seedare dati di default via listener dell'activate hook e referenziano model Eloquent che dipendono dalle migration del plugin stesso — ma le migration non sono ancora girate al primo activate. Fix: il listener della migration gira durante activate, prima di qualsiasi altro listener Hook::on('activate_plugin_*') che possa referenziare le nuove tabelle. Ordina le tue registrazioni così la migration va prima (lo fa nello skeleton — tienila così).
Dove andare poi
Il lifecycle copre il quando; Testing copre il verifica. La prossima pagina percorre la registrazione del testsuite in phpunit.xml, il pattern base-class PluginTestCase, le assertion hooks-under-test, e il ciclo CI activate-test-delete.