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
Package info
github.com/enzoaccardo/ci4-adminkit-starter
Type:project
pkg:composer/enzoaccardo/ci4-adminkit-starter
Requires
- php: ^8.2
- codeigniter4/framework: ^4.7
- codeigniter4/tasks: ^1.0
- enzoaccardo/ci4-adminkit: ^0.1
- enzoaccardo/ci4-adminkit-adminlte4: ^0.1
- enzoaccardo/ci4-adminkit-mfa: ^0.1
- enzoaccardo/ci4-adminkit-rbac: ^0.1
- firebase/php-jwt: ^7.1
- predis/predis: ^3.4
Requires (Dev)
- fakerphp/faker: ^1.9
- mikey179/vfsstream: ^1.6
- phpunit/phpunit: ^10.5.16
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
requiredemaxlengthleggendo le$validationRulesdel 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.
- La migrazione.
BaseMigration::createTable()aggiunge da sé i sei campi di audit, quindi descrivi soltanto i tuoi:php spark make:migration CreateArticlesTable. - Il model, sui tre livelli:
Base\ArticleModelcon tabella, campi ammessi e$validationRules, poiAdmin\ArticleModelconqueryForIndex()e le sue join. - Il controller. Estende
AdminController, dichiara le sue rotte nel metodo staticoroutes()e usa i builder per lista e form, come negli esempi qui sopra. Nessun file di configurazione da toccare, la discovery lo trova. - I permessi. Aggiungi gli slug, per esempio
articles.viewearticles.create, e chiamali con$this->authorize('articles.view'). - La voce di menu. Una riga in
nav_itemsconpermission_slugvalorizzato, 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.