enzoaccardo/ci4-adminkit-starter

Applicazione CodeIgniter 4 pronta all'uso: pannello admin AdminLTE, RBAC, MFA con passkey e API JWT, costruita sui pacchetti ci4-adminkit

Maintainers

Package info

github.com/enzoaccardo/ci4-adminkit-starter

Type:project

pkg:composer/enzoaccardo/ci4-adminkit-starter

Transparency log

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.0 2026-07-30 07:15 UTC

This package is auto-updated.

Last update: 2026-08-05 15:44:31 UTC


README

Un'applicazione CodeIgniter 4 completa e pronta all'uso: pannello di amministrazione, autenticazione con secondo fattore, controllo degli accessi, layer API JWT e un sistema di builder dichiarativi che riduce liste e form a poche righe di configurazione.

PHP 8.2+ · CodeIgniter 4.7 · AdminLTE 4 · Smarty 5 · MIT

Perché esiste

Questo progetto non è nato come framework. È nato per sedimentazione.

Ogni volta che mettevo in piedi un'applicazione gestionale mi ritrovavo a riscrivere le stesse cose: il login, il reset della password, la tabella con i filtri e l'ordinamento, la paginazione, il form con gli errori sotto ogni campo, i permessi per nascondere una voce di menu, l'endpoint API con il token da rinnovare. Cambiava il dominio, mai l'impalcatura. E ogni riscrittura si portava dietro le stesse piccole imprecisioni: la colonna di ordinamento presa dalla query string senza whitelist, il filtro che perdeva lo stato cambiando pagina, il campo obbligatorio lato interfaccia ma non lato server.

Ho cominciato quindi a portarmi dietro il codice da un progetto al successivo. Prima copiando file, poi con più metodo. Quando ho aggiunto il secondo fattore per un progetto che ne aveva bisogno, l'ho scritto in modo che potesse servire anche altrove. Quando mi sono servite tabelle filtrabili in dieci schermate diverse, ho estratto la logica in trait invece di duplicarla. Quando ho dovuto descrivere il ventesimo form ho smesso di scrivere HTML e ho iniziato a dichiarare i campi, lasciando che il rendering fosse un dettaglio di implementazione.

A un certo punto il pattern era chiaro e il copia-incolla non stava più in piedi: le versioni divergevano, e un difetto corretto in un progetto restava aperto in tutti gli altri. Ho unificato allora quello che avevo sviluppato in un pacchetto iniziale, l'ho scomposto in sottopacchetti (infrastruttura, tema, secondo fattore, controllo degli accessi) e questo repository è l'applicazione che li mette insieme e li mostra al lavoro.

Il vantaggio pratico è duplice. Un progetto nuovo parte più in fretta, e parte più affidabile, perché l'impalcatura è già stata usata, corretta e testata su casi reali. Quello che resta da scrivere è la logica di business, cioè l'unica parte che davvero cambia da un progetto all'altro.

Cosa ottieni

Dopo cinque comandi hai un'applicazione che gira, con dentro:

  • pannello di amministrazione AdminLTE 4, con layout, tabelle, form, notifiche e modali già cablati
  • autenticazione a sessione: login, logout, reset password via email, controllo dell'utente disattivato a ogni richiesta
  • secondo fattore: TOTP con app authenticator, codici di recupero, passkey WebAuthn, anche come conferma delle azioni sensibili
  • ruoli e permessi, con verifica nei controller e menu che si nasconde da sé
  • menu gestito da database, con albero, riordino drag&drop e visibilità per permesso
  • layer API JWT: token di accesso e refresh con rotazione per dispositivo, token ospite, rate limit, log delle richieste in ingresso
  • task pianificati modificabili dal pannello, con costruttore di espressioni cron
  • impostazioni, cache, log attività e upload, ciascuno con la propria interfaccia
  • suite PHPUnit e test end-to-end Playwright, passkey comprese

E un utente con cui entrare subito: admin@example.com / Admin1234!.

Se vieni da Laravel

Molte scelte di questo progetto assomigliano a cose che in Laravel esistono già, e non per caso: sono i pattern che negli anni si sono dimostrati comodi, riportati su CodeIgniter dove il framework lascia il posto vuoto. Se conosci Laravel ti muovi in fretta, perché i nomi e le forme sono spesso gli stessi.

In Laravel Qui
Risorse di Nova o Filament, dove dichiari i campi e il pannello si disegna Form builder e trait per le liste: dichiari sezioni, campi e colonne, il rendering è a carico del kit
$this->authorize('...') di AuthorizesRequests, Gate e Policy $this->authorize('slug') che interrompe con un 403, e $this->can('slug') che restituisce un booleano
spatie/laravel-permission per ruoli e permessi Il pacchetto ci4-adminkit-rbac, con la stessa idea di permessi come slug assegnati a ruoli
Layout e componenti Blade Layout e partial Smarty, con autoescape attivo per default
php artisan, con make:migration e i seeder php spark, con gli stessi comandi e la stessa forma
SoftDeletes e timestamp di Eloquent Gli stessi, più i campi di audit created_by, updated_by, deleted_by popolati dalla sessione
Fortify per il secondo fattore Il pacchetto ci4-adminkit-mfa, con TOTP, codici di recupero e passkey
Sanctum o Passport per le API a token JwtService e filtro jwt, con refresh a rotazione per dispositivo
Lo scheduler con una sola riga nel cron di sistema php spark tasks:run con una sola riga nel cron, ma i task sono righe di database modificabili dal pannello
Vite, integrato di serie Vite, configurato allo stesso modo
Package discovery dei service provider L'auto-discovery di CodeIgniter, su cui i quattro pacchetti si registrano da soli

La differenza di fondo resta quella tra i due framework. Laravel arriva con tutto e tu togli quello che non ti serve; CodeIgniter arriva snello e ti chiede di aggiungere. Questo starter è il mio "aggiungi", messo in forma riutilizzabile.

Dichiarare invece di scrivere

È la parte che cambia il modo di lavorare. Una schermata CRUD è di solito tre quarti di impalcatura ripetitiva e un quarto di logica applicativa. I builder invertono la proporzione: l'impalcatura la dichiari, e ti resta da scrivere soltanto ciò che è specifico del tuo dominio.

Le liste

Filtri, ordinamento e paginazione si ottengono dichiarando le colonne e i campi filtrabili. Da Admin\Users:

$filterFields = [
    'name'  => ['type' => 'text', 'label' => 'Nome',  'column' => 'u.name'],
    'email' => ['type' => 'text', 'label' => 'Email', 'column' => 'u.email'],
];

$columns = [
    'name'          => ['label' => 'Nome',           'sortable' => true, 'column' => 'u.name'],
    'email'         => ['label' => 'Email',          'sortable' => true, 'column' => 'u.email'],
    'last_login_at' => ['label' => 'Ultimo accesso', 'sortable' => true, 'column' => 'u.last_login_at'],
    'is_active'     => ['label' => 'Stato'],
    '_actions'      => ['label' => '', 'class' => 'text-end'],
];

$filters = $this->getFilters($filterFields);
$sort    = $this->getSort($columns, defaultKey: 'name', defaultDir: 'asc');
$page    = $this->getPage();
$perPage = $this->getPerPage();

$builder = $this->userModel->queryForIndex(withDeleted: true);
$this->applyFilters($builder, $filterFields, $filters);
$this->applySort($builder, $columns, $sort);

Il partial thead disegna le intestazioni ordinabili e la riga di filtri inline, il partial pagination il resto. La parte importante è nascosta nella firma dei metodi: applySort() accetta solo chiavi presenti in $columns e applyFilters() solo quelle dichiarate in $filterFields. La whitelist non è una raccomandazione della documentazione ma il funzionamento stesso, quindi un ?sort= arbitrario non arriva al database.

I form

Il controller descrive sezioni e campi, un unico template rende l'intero form Bootstrap. Da Admin\Users:

return [
    'action'    => base_url('admin/users' . ($user ? "/{$user->id}" : '')),
    'cancelUrl' => base_url('admin/users'),
    'ajax'      => true,
    'sections'  => [[
        'fields' => [
            'name'     => ['type' => 'text',  'label' => 'Nome',  'required' => true, 'col' => 6],
            'email'    => ['type' => 'email', 'label' => 'Email', 'required' => true, 'col' => 6],
            'password' => [
                'type'     => 'password',
                'label'    => 'Password',
                'required' => $user === null,
                'hint'     => $user ? 'Lascia vuoto per non cambiare.' : 'Minimo 8 caratteri.',
                'widget'   => 'pwstrength',
            ],
            'role_id'  => [
                'type'    => 'select',
                'label'   => 'Ruolo',
                'options' => $this->roleModel->getActive(),
                'empty'   => '— nessun ruolo —',
                'widget'  => 'tomselect',
            ],
            'is_active' => ['type' => 'switch', 'label' => 'Utente attivo', 'default' => 1],
        ],
    ]],
];

Quello che il builder fa da sé, e che altrimenti riscriveresti a ogni form:

  • risolve il valore di ogni campo nell'ordine corretto, cioè dato ripopolato dopo un errore, poi entità in modifica, poi valore di default
  • posiziona gli errori di validazione sotto il campo che li ha generati
  • deduce required e maxlength leggendo le $validationRules del model, così la regola vive in un posto solo
  • carica gli asset di un widget una volta sola anche con dieci campi che lo usano, generando l'inizializzazione per singolo campo

I tipi disponibili sono text, email, password, number, textarea, select, multiselect, checkbox, switch, radio, date, datetime, cron, file, static, hidden e custom. Ogni famiglia ha il proprio partial, quindi aggiungere un tipo significa aggiungere un file e non allungare un switch.

I widget inclusi sono Tom Select (ricerca, tag e selezione multipla, senza jQuery), flatpickr per le date, FilePond per gli upload, un indicatore di robustezza della password e un costruttore di espressioni cron.

I campi che si parlano

Un campo può dichiarare l'effetto che ha sugli altri, senza scrivere JavaScript:

'tipo' => [
    'type'    => 'radio',
    'options' => ['privato' => 'Privato', 'azienda' => 'Azienda'],
    'affects' => [
        ['when' => 'azienda', 'show' => ['ragione_sociale', 'piva'], 'require' => ['ragione_sociale', 'piva']],
        ['when' => 'privato', 'show' => ['codice_fiscale'],          'require' => ['codice_fiscale']],
    ],
],

Le regole vengono normalizzate e serializzate sul tag form; uno script leggero le valuta al caricamento della pagina e a ogni cambiamento, e viene caricato solo se ci sono regole da valutare.

Una precisazione necessaria: show e require sono comportamenti dell'interfaccia. La validazione condizionale corrispondente va scritta anche lato server, e il controller di dimostrazione mostra come derivarla dalle stesse regole affects senza riscrivere la condizione.

Le tendine a cascata

Il caso classico della provincia che popola le città costa due dichiarazioni e un metodo. Il campo bersaglio dice da chi dipende e chi gli fornisce le opzioni:

'citta' => [
    'type'   => 'select',
    'widget' => 'tomselect',
    'remote' => ['dependsOn' => 'provincia', 'provider' => 'citta'],
],

nel controller esiste soltanto il metodo che restituisce i dati:

protected function optionsCitta(): array
{
    $prov = (string) $this->request->getGet('provincia');

    return $this->cittaPerProvincia($prov);   // [['value' => ..., 'label' => ...], ...]
}

La rotta admin/<slug>/options/citta viene registrata da sola, l'incapsulamento JSON è gestito dalla classe base e l'URL del campo è costruito dal builder a partire dallo slug del controller. In modifica il valore già salvato viene riselezionato dopo il ripopolamento. Non c'è nessuna rotta, nessun URL e nessun json_encode da scrivere a mano.

Creare un record collegato senza perdere il form

Capita sempre: stai compilando un ordine e il cliente non è ancora in anagrafica. Un campo select può dichiarare un pulsante che apre in modale lo stesso form di creazione dell'entità collegata:

'paziente_id' => [
    'type'      => 'select',
    'widget'    => 'tomselect',
    'options'   => $this->pazienti(),
    'createNew' => ['provider' => 'patient', 'label' => 'Crea nuovo', 'title' => 'Nuovo paziente'],
],

Nel controller servono due metodi, uno che descrive il form (riusando quello della creazione normale) e uno che salva. La rotta admin/<slug>/create/patient è automatica. Al salvataggio il nuovo record viene iniettato e selezionato nella tendina del form padre, senza ricaricare la pagina e senza perdere quello che avevi già compilato.

Tutto questo è visitabile e leggibile: la sezione admin/demo/form è una dimostrazione completa e commentata, pensata per essere cancellata quando non serve più.

La discovery delle rotte

In CodeIgniter le rotte si dichiarano al centro, in app/Config/Routes.php. Funziona, ma su un pannello con trenta sezioni quel file diventa lungo e ogni controller nuovo obbliga a tornare a modificarlo, con il rischio di toccare le rotte di qualcun altro. L'alternativa offerta dal framework è l'auto-routing, che deriva l'URL dal nome del metodo: è disattivato per default e sconsigliato, perché pubblica come rotta ogni metodo pubblico del controller, compresi quelli che non volevi raggiungibili.

La convenzione adottata qui sta in mezzo. Ogni controller dichiara le proprie rotte, ma le dichiara in modo esplicito:

class Users extends AdminController
{
    public static function routes(RouteCollection $routes): void
    {
        $routes->get('users',                       'Admin\Users::index');
        $routes->get('users/create',                'Admin\Users::create');
        $routes->post('users',                      'Admin\Users::store');
        $routes->get('users/([0-9]{1,10})/edit',    'Admin\Users::edit/$1');
        $routes->post('users/([0-9]{1,10})',        'Admin\Users::update/$1');
        $routes->post('users/([0-9]{1,10})/delete', 'Admin\Users::delete/$1');
    }
}

AdminKit\Routing\Discovery scopre i controller di un namespace e invoca quel metodo statico. L'intero Config/Routes.php dell'applicazione si riduce a questo:

use AdminKit\Routing\Discovery;

$routes->get('/', 'Front\Home::index', ['filter' => 'auth']);

Discovery::discover($routes, 'App\Controllers\Front\Auth');           // login, reset password
Discovery::discover($routes, 'AdminKit\Mfa\Controllers');             // flusso del secondo fattore
Discovery::discover($routes, 'App\Controllers\Api', 'publicRoutes');  // API senza token

$routes->group('api/v1', ['filter' => 'jwt'], static function ($routes) {
    Discovery::discover($routes, 'App\Controllers\Api');              // API protette
});

Discovery::adminGroup($routes, 'App\Controllers\Admin');              // pannello

Sotto ci sono tre dettagli che fanno la differenza fra una comodità e una fonte di sorprese.

Due metodi, due livelli di esposizione. La convenzione prevede routes() e publicRoutes(). Un controller API mette in publicRoutes() login, registrazione, refresh e token ospite, che per definizione non possono pretendere un token, e in routes() tutto il resto. L'applicazione invoca publicRoutes() fuori dal gruppo protetto e routes() dentro il gruppo con il filtro jwt. La distinzione fra raggiungibile senza credenziali e protetto vive quindi nel controller, accanto alle rotte stesse, invece di dipendere dal punto in cui qualcuno le ha incollate in un file condiviso.

La risoluzione del namespace non è un glob fragile. La directory viene cercata prima nelle mappe PSR-4 registrate nell'autoloader, scegliendo il prefisso più lungo che corrisponde, così la discovery funziona anche sui namespace dei pacchetti e dei moduli e non solo su App\. Esiste poi un fallback che risolve App\ su APPPATH, perché in alcuni contesti, per esempio durante l'esecuzione dei test, l'autoloader può non avere ancora esposto le mappe. La ricerca è volutamente non ricorsiva: i controller di una sottocartella vanno scoperti con una chiamata dedicata, e questo evita che un file dimenticato in una directory annidata pubblichi rotte a tua insaputa.

adminGroup() fa una cosa in più. Crea il gruppo, con prefisso admin e filtro auth, e mentre scopre i controller registra anche le rotte di servizio del form builder, ma solo per i controller che espongono i metodi corrispondenti:

se il controller espone viene registrata
formOptions() GET admin/<slug>/options/(:segment), le opzioni delle tendine a cascata
formCreate() GET e POST su admin/<slug>/create/(:segment), il "crea nuovo" in modale

I due metodi arrivano dalla classe base, quindi per i controller del pannello la condizione è di fatto sempre soddisfatta: è il meccanismo per cui gli option provider e le modali funzionano senza scrivere una riga di routing. Lo slug è derivato dal nome della classe ed è lo stesso che il form builder usa per costruire gli URL dei campi, quindi le due parti non possono divergere perché leggono la stessa fonte.

Il risultato pratico è che per aggiungere una sezione al pannello si crea un file. Non si modifica nessun file condiviso, non si tocca la configurazione delle rotte, e l'aggiunta non può rompere le rotte di un'altra sezione.

Cosa aggiunge rispetto a CodeIgniter 4

CodeIgniter 4 è un framework deliberatamente snello: dà router, layer HTTP, model, migrazioni, validazione, filtri, sessioni, astrazione della cache, CLI e toolbar di debug. Quasi tutto quello che serve a un pannello di amministrazione non è compreso, non perché manchi qualcosa al framework ma perché non è il suo lavoro.

Questa è la lista di ciò che qui trovi già fatto e che nel framework nudo dovresti costruire.

Ambito CodeIgniter 4 di serie Qui
Autenticazione Nessuna nel core; esiste il pacchetto ufficiale Shield, a parte Login a sessione, logout, reset password via email, verifica dell'utente disattivato a ogni richiesta
Secondo fattore Assente TOTP con QR code, codici di recupero monouso, passkey WebAuthn, conferma per le azioni sensibili
Autorizzazioni Assenti Ruoli, permessi, tabella di associazione, authorize() nei controller, menu filtrato, bypass del superamministratore
Rotte Dichiarazione centralizzata, oppure auto-routing sconsigliato Dichiarazione locale al controller con scoperta automatica del namespace, più le rotte di servizio del form builder registrate da sé
Template View PHP e un parser minimale Smarty 5 con autoescape attivo, layout e partial riutilizzabili, ponte verso gli helper del framework
Form Validazione sì, costruzione del markup no Form builder dichiarativo: valori, errori per campo, required e maxlength dedotti dal model, widget, interazioni fra campi, cascate AJAX, creazione in modale
Liste Un paginatore Filtri, ordinamento e paginazione con whitelist obbligatoria delle colonne, intestazioni e filtri inline già disegnati
Campi di audit created_at, updated_at, deleted_at Anche created_by, updated_by, deleted_by, popolati dalla sessione; le migrazioni iniettano i sei campi da sé
API a token Nessun layer JWT Servizio JWT in HS256, filtro dedicato, token di accesso, refresh e ospite, rotazione del refresh per dispositivo
Rate limit Una classe Throttler, da cablare Filtro applicabile per rotta, con soglia e finestra, per esempio rateLimit:5,60
Log delle richieste Assente Filtro che registra le richieste API in ingresso, più un registro separato dei tentativi di autenticazione falliti
Impostazioni Storage chiave-valore, dal pacchetto ufficiale Servizio con priorità JSON, poi variabili d'ambiente, poi default, e la sua interfaccia nel pannello
Cache Astrazione dei driver Namespace per chiave, statistiche, invalidazione per pattern, pagina di gestione; degrada con grazia se Redis non c'è
Audit trail Assente Registro delle attività con attore, azione, entità e descrizione
Task pianificati Esecutore dal pacchetto ufficiale, task definiti nel codice Task come righe di database, modificabili dal pannello con costruttore di espressioni cron
Upload Gestione del file caricato Servizio di storage per le immagini, con la sua configurazione
Asset Nessuna pipeline Vite, più comandi che pubblicano gli asset dei pacchetti
Menu Assente Albero su database, riordino drag&drop, visibilità per permesso

Sul modello a tre livelli vale una nota, perché è una convenzione e non un'imposizione. Base definisce tabella, campi ammessi e regole di validazione, Common la logica riutilizzabile, Admin le query del pannello con le loro join. Serve a evitare che le query dell'interfaccia si mescolino alle regole del dominio, e che una join aggiunta per una schermata cambi il comportamento di tutto il resto.

Architettura

Questo repository è l'applicazione. Il codice riutilizzabile vive in quattro pacchetti separati, installati da Packagist.

Pacchetto Cosa contiene
enzoaccardo/ci4-adminkit L'infrastruttura: renderer Smarty, builder di liste e form, controller admin di base, model e migrazioni di base con i campi di audit, la discovery delle rotte, il contratto per il controllo degli accessi
enzoaccardo/ci4-adminkit-adminlte4 Il tema: layout del pannello, pagine di autenticazione, asset compilati
enzoaccardo/ci4-adminkit-mfa Il secondo fattore: TOTP, passkey, codici di recupero, il flusso completo e le sue migrazioni
enzoaccardo/ci4-adminkit-rbac Ruoli e permessi: servizio, model, migrazioni

La suddivisione risponde a una domanda pratica, cioè cosa voglio poter sostituire senza toccare il resto. Il tema è separato perché un progetto può averne uno proprio. Il secondo fattore è separato perché non tutti i progetti lo richiedono, ed è indipendente dal tema. Il controllo degli accessi usa una scoperta morbida: se il pacchetto non è installato l'applicazione continua a funzionare e le verifiche di permesso ricadono su un comportamento neutro.

Ogni pacchetto è autonomo, con le proprie migrazioni e i propri servizi, scoperti automaticamente da CodeIgniter. Aggiornarne uno non richiede di toccare l'applicazione.

Requisiti

  • PHP 8.2 o superiore, con intl, mbstring, json, mysqli
  • MySQL 8 o MariaDB 10.4+
  • Composer 2
  • Node.js 18 o superiore, solo per ricompilare gli asset o eseguire i test end-to-end
  • Redis facoltativo; senza, cache e rate limit funzionano su file

Installazione

Il pacchetto è di tipo project, cioè lo scheletro di un'applicazione e non una libreria da montare dentro un'altra.

composer create-project enzoaccardo/ci4-adminkit-starter mionuovoprogetto
cd mionuovoprogetto

create-project scarica l'ultima versione stabile, esegue composer install e rimuove la cartella .git, così parti con un albero pulito e la tua storia da fare. Subito dopo, uno script di post-installazione crea il .env a partire da .env.example e lancia i tre comandi publish, quindi gli asset sono già al loro posto.

Se preferisci il clone:

git clone https://github.com/enzoaccardo/ci4-adminkit-starter.git mionuovoprogetto
cd mionuovoprogetto
composer install
cp .env.example .env
php spark adminkit:publish      # asset dei builder e dei widget
php spark adminlte4:publish     # asset del tema
php spark mfa:publish           # script del flusso passkey

Il clone da solo non scarica le dipendenze: porta giù i file versionati e nient'altro. È composer install che legge composer.lock e installa in vendor/ i quattro pacchetti e tutto il resto. E lo script di post-installazione non scatta, perché è legato alla creazione del progetto, quindi .env e publish restano a carico tuo.

Da qui in avanti la strada è la stessa. Nel .env imposta il database, sotto database.default.*, e un JWT_SECRET, che deve essere una stringa casuale lunga: se resta vuoto i token non sono firmati in modo sicuro. Conviene valorizzare anche APP_SALT.

php spark migrate --all         # applicazione e pacchetti
php spark db:seed DatabaseSeeder

php spark serve                 # http://localhost:8080

Migrazioni e seeder non sono automatizzabili nello script di post-installazione, perché richiedono un database già raggiungibile e configurato.

I comandi *:publish copiano in public/ gli asset che i pacchetti si portano dietro, e vanno rieseguiti dopo ogni aggiornamento dei pacchetti. Per questo i file pubblicati non sono versionati: la loro sorgente è il pacchetto, e tenerne una copia in repository significherebbe soltanto lasciarla divergere in silenzio.

Entra con admin@example.com e Admin1234!, poi cambia subito la password.

Per i task pianificati serve una riga nel cron di sistema:

* * * * * cd /percorso/del/progetto && php spark tasks:run >> /dev/null 2>&1

Aggiungere una sezione al pannello

Il giro completo, per farsi un'idea del ritmo di lavoro.

  1. La migrazione. BaseMigration::createTable() aggiunge da sé i sei campi di audit, quindi descrivi soltanto i tuoi: php spark make:migration CreateArticlesTable.
  2. Il model, sui tre livelli: Base\ArticleModel con tabella, campi ammessi e $validationRules, poi Admin\ArticleModel con queryForIndex() e le sue join.
  3. Il controller. Estende AdminController, dichiara le sue rotte nel metodo statico routes() e usa i builder per lista e form, come negli esempi qui sopra. Nessun file di configurazione da toccare, la discovery lo trova.
  4. I permessi. Aggiungi gli slug, per esempio articles.view e articles.create, e chiamali con $this->authorize('articles.view').
  5. La voce di menu. Una riga in nav_items con permission_slug valorizzato, così comparirà solo a chi ha quel permesso.

Il template della lista, spesso, è l'inclusione di due partial più il corpo della tabella.

Le altre parti, in breve

Due popolazioni di utenti, distinte. Gli amministratori del pannello stanno in users e si autenticano a sessione. Gli utenti delle API stanno in app_users e si autenticano con email e password ottenendo un JWT. Sono insiemi separati di proposito, perché chi consuma le API non deve poter entrare nel pannello.

API JWT. Le rotte pubbliche, cioè login, registrazione, refresh e token ospite, hanno ciascuna il proprio rate limit; tutto il resto passa dal filtro jwt. I refresh token ruotano per dispositivo, così revocare una sessione non tocca le altre. I claim sono user_id, email e type, dove il tipo vale access, refresh o guest. I token ospite consentono l'accesso alle risorse pubbliche e vengono respinti dove serve un utente reale.

Controllo degli accessi. $this->authorize('slug') interrompe con un 403, $this->can('slug') restituisce un booleano ed è quello che serve negli endpoint JSON e per decidere se disegnare un pulsante. La visibilità del menu usa gli stessi slug delle verifiche nei controller, quindi non esiste una voce visibile che porta a un 403. Il superamministratore bypassa i controlli.

Secondo fattore. Si attiva per singolo utente. TOTP con QR code e codici di recupero monouso, passkey come secondo fattore e come conferma davanti alle azioni sensibili. Un amministratore può azzerare il secondo fattore di un altro utente, che lo riconfigurerà al login successivo.

Impostazioni, cache, log. Le impostazioni sono lette con priorità JSON, variabili d'ambiente e default, e sono modificabili dal pannello. La cache usa chiavi con namespace e sa invalidare per pattern; senza Redis lavora su file. Il log attività registra chi ha fatto cosa e su quale entità.

Asset. I sorgenti stanno in resources/admin/ e si compilano con Vite. L'output finisce in una cartella dedicata, separata dagli script statici, così una ricompilazione non cancella file che non ha generato.

Test

vendor/bin/phpunit     # suite PHP, richiede il database configurato in database.tests.*
npm install && npx playwright install chromium
npm run e2e            # end-to-end, avvia il server da sé

I test end-to-end coprono ciò che i test PHP non raggiungono: i form inviati in AJAX, le interazioni fra campi, le cascate delle tendine e le passkey, provate con un authenticator virtuale via CDP, quindi senza alcun dispositivo fisico.

Struttura

app/
├── Controllers/
│   ├── Admin/     # pannello
│   ├── Api/       # endpoint JWT
│   └── Front/     # login, reset password
├── Filters/       # sessione, JWT, rate limit, log richieste
├── Libraries/     # JWT, cache, impostazioni, storage file, log API
├── Models/        # Base, Common, Admin
├── Database/      # migrazioni e seeder
└── Views/themes/{admin,front}/default/*.tpl
resources/admin/   # sorgenti Vite
public/themes/     # asset pubblicati e script statici
tests/             # PHPUnit e Playwright

Personalizzare

Il branding del pannello si configura in app/Config/AdminLTE.php, i parametri del secondo fattore in app/Config/Mfa.php. Per un tema proprio si sostituisce il pacchetto del tema mantenendo il resto. La sezione di dimostrazione admin/demo/form e il suo controller si possono cancellare senza conseguenze, perché servono soltanto a mostrare il form builder al lavoro.

Licenza

MIT, vedi LICENSE.