sartUP — Setup Iniziale (Blade + Auth Nativa + Spatie Ruoli “minimal”)

sartUP — Setup Iniziale (Blade + Auth Nativa + Spatie Ruoli “minimal”)

Obiettivo: partire con il piede giusto: Laravel 11 + Blade, autenticazione nativa, Spatie/laravel-permission per soli ruoli (permessi granulari attivabili in futuro), menu dinamico amministrabile e placeholder Industria 4.0 → Report → Macchine → Elenco macchine collegate.

> Questo documento è pensato per Cursor come contesto operativo: include environment, pacchetti, tasks, snippet e criteri di accettazione.

---

0) Architettura di riferimento (VPS cPanel)

  • Admin Container: topbar orizzontale (L1) + sidebar sinistra (L2/3/4), Blade.
  • Auth nativa: login, logout, forgot/reset password (+ opzionale email verification).
  • Ruoli con Spatie: super-admin, admin, operator, maintenance, viewer; ruolo attivo selezionabile se l'utente ne ha >1.
  • Menu dinamico su DB: menus, menu_items, filtrati per ruolo/permessi (inizialmente per ruoli).
  • Placeholder moduli: Dashboard, Industria 4.0 → Report → Macchine → Elenco macchine collegate.
  • Servizi VPS: MySQL, Redis (opzionale), file storage locale, cron jobs, SSL automatico.
  • ---

    1) Ambiente & servizi VPS

    1.1 Requisiti

  • VPS con cPanel (AlmaLinux)
  • PHP 8.2+, Composer 2.7+
  • Node 20 + npm/Vite
  • MySQL 8.0+
  • SSL/TLS automatico (AutoSSL cPanel)
  • Redis (opzionale, per cache/queue)
  • 1.2 Configurazione ambiente VPS

    > L'applicazione gira direttamente sul VPS senza containerizzazione.

    Servizi disponibili:

  • PHP-FPM (gestito da cPanel)
  • MySQL (database cPanel)
  • Redis (se disponibile, altrimenti file/database cache)
  • Cron jobs (Laravel Scheduler)
  • Storage locale o S3-compatible (opzionale)
  • .env chiavi principali: ``dotenv APP_ENV=production APP_URL=https://sartup.it APP_KEY=

    DB_HOST=localhost DB_DATABASE=cpanel_user_sartup DB_USERNAME=cpanel_user_xxxxx DB_PASSWORD=secure_password

    CACHE_DRIVER=file QUEUE_CONNECTION=database

    Se Redis disponibile:

    CACHE_DRIVER=redis

    QUEUE_CONNECTION=redis

    REDIS_HOST=localhost

    FILESYSTEM_DISK=local

    Se usi S3:

    FILESYSTEM_DISK=s3

    AWS_ACCESS_KEY_ID=

    AWS_SECRET_ACCESS_KEY=

    AWS_DEFAULT_REGION=us-east-1

    AWS_BUCKET=sartup

    `

    ---

    2) Pacchetti & configurazione

    2.1 Installazione Spatie (ruoli solo)

    `bash composer require spatie/laravel-permission php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" php artisan migrate `

    Config: config/permission.php (default va bene). Trait nel Model User: use Spatie\Permission\Traits\HasRoles;

    2.2 Auth nativa (senza Breeze)

    Rotte, controller e Blade scritti ad hoc (vedi §4). Opzionale: email verification.

    ---

    3) Database: menu dinamico + ruoli

    3.1 Migrazioni Menu (DB-first)

    > Task per Cursor: creare migrazioni Eloquent per gli schemi sotto.

    `sql CREATE TABLE menus ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) UNIQUE NOT NULL, -- 'admin_main' description VARCHAR(255) NULL, is_active TINYINT(1) DEFAULT 1, created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL );

    CREATE TABLE menu_items ( id BIGINT PRIMARY KEY AUTO_INCREMENT, menu_id BIGINT NOT NULL, parent_id BIGINT NULL, label VARCHAR(100) NOT NULL, route_name VARCHAR(120) NULL, -- priorità a route_name, fallback url url VARCHAR(255) NULL, icon VARCHAR(60) NULL, description VARCHAR(255) NULL, order_index INT DEFAULT 0, is_visible TINYINT(1) DEFAULT 1, required_roles JSON NULL, -- es. ["admin","maintenance"] required_permissions JSON NULL, -- es. ["machines.view"] created_at TIMESTAMP NULL, updated_at TIMESTAMP NULL, CONSTRAINT fk_menu FOREIGN KEY (menu_id) REFERENCES menus(id), CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES menu_items(id) ); `

    3.2 Seeder Ruoli & Super Admin

    `php // database/seeders/RolesSeeder.php use Spatie\Permission\Models\Role; class RolesSeeder extends Seeder { public function run(): void { foreach (['super-admin','admin','operator','maintenance','viewer'] as $r) { Role::firstOrCreate(['name' => $r]); } } }

    // database/seeders/SuperAdminSeeder.php use App\Models\User; use Spatie\Permission\Models\Role; class SuperAdminSeeder extends Seeder { public function run(): void { $u = User::firstOrCreate( ['email' => 'root@sartup.local'], ['name' => 'Root', 'password' => bcrypt('ChangeMe!')] ); $u->assignRole(Role::where('name','super-admin')->first()); } } `

    3.3 Seeder Menu (placeholder + Industria 4.0)

    `php // database/seeders/MenuSeeder.php use App\Models\Menu; use App\Models\MenuItem;

    class MenuSeeder extends Seeder { public function run(): void { $admin = Menu::firstOrCreate(['name'=>'admin_main'], ['description'=>'Menu principale admin']);

    $dashboard = MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Dashboard' ],['route_name'=>'admin.dashboard','icon'=>'lucide-home','order_index'=>1]);

    $ind40 = MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Industria 4.0' ],['icon'=>'lucide-cpu','order_index'=>2]);

    $report = MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>$ind40->id,'label'=>'Report' ],['order_index'=>1]);

    $macchine = MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>$report->id,'label'=>'Macchine' ],['order_index'=>1]);

    MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>$macchine->id,'label'=>'Elenco macchine collegate' ],[ 'route_name'=>'i40.machines.connected', 'order_index'=>1, 'required_roles'=>json_encode(['admin','maintenance','super-admin']) ]);

    MenuItem::firstOrCreate([ 'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Impostazioni' ],['icon'=>'lucide-settings','order_index'=>99,'required_roles'=>json_encode(['super-admin'])]); } } `

    ---

    4) Auth nativa + Ruolo attivo

    4.1 Rotte base

    `php // routes/web.php Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login')->middleware('guest'); Route::post('/login', [LoginController::class, 'login'])->name('login.post')->middleware('guest'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout')->middleware('auth');

    Route::get('/password/forgot', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request'); Route::post('/password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email'); Route::get('/password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset'); Route::post('/password/reset', [ResetPasswordController::class, 'reset'])->name('password.update');

    // Admin area Route::middleware(['auth','active.role'])->prefix('admin')->group(function () { Route::get('/', [DashboardController::class,'index'])->name('admin.dashboard'); Route::prefix('i40')->group(function() { Route::get('/', [I40\HomeController::class,'index'])->name('i40.home'); Route::get('/machines/connected', [I40\MachinesController::class,'connected'])->name('i40.machines.connected'); }); }); `

    4.2 Middleware “ActiveRole”

    `php // app/Http/Middleware/EnsureActiveRole.php class EnsureActiveRole { public function handle($request, Closure $next) { $user = $request->user(); if (!$user) return redirect()->route('login'); if ($user->roles()->count() <= 1) { if (!session()->has('active_role') && $user->roles()->exists()) { session(['active_role' => $user->roles()->first()->name]); } return $next($request); } if (!session()->has('active_role')) { return redirect()->route('auth.role.select'); } return $next($request); } } `

    4.3 Selettore ruolo (post-login)

    `php // routes/web.php (aggiunte) Route::middleware(['auth'])->group(function () { Route::get('/auth/select-role', [RoleSelectorController::class,'show'])->name('auth.role.select'); Route::post('/auth/set-role', [RoleSelectorController::class,'set'])->name('auth.role.set'); }); `

    `php // app/Http/Controllers/Auth/RoleSelectorController.php class RoleSelectorController extends Controller { public function show(Request $r) { $roles = $r->user()->roles()->pluck('name'); return view('auth.select-role', compact('roles')); } public function set(Request $r) { $r->validate(['role'=>'required|string']); abort_unless($r->user()->hasRole($r->role), 403); session(['active_role' => $r->role]); return redirect()->intended(route('admin.dashboard')); } } `

    `blade {{-- resources/views/auth/select-role.blade.php --}} @extends('layouts.app') @section('content') <h1>Seleziona ruolo</h1> <form method="POST" action="{{ route('auth.role.set') }}"> @csrf <select name="role" required> @foreach($roles as $role) <option value="{{ $role }}">{{ ucfirst($role) }}</option> @endforeach </select> <button type="submit">Continua</button> </form> @endsection `

    Gate super-admin `php // app/Providers/AuthServiceProvider.php Gate::before(function ($user, $ability) { return $user->hasRole('super-admin') ? true : null; }); `

    ---

    5) Menu dinamico: Model/Service & Rendering

    5.1 Model minimi

    `php // app/Models/Menu.php class Menu extends Model { protected $fillable = ['name','description','is_active']; public function items() { return $this->hasMany(MenuItem::class); } }

    // app/Models/MenuItem.php class MenuItem extends Model { protected $fillable = [ 'menu_id','parent_id','label','route_name','url','icon','description', 'order_index','is_visible','required_roles','required_permissions' ]; protected $casts = ['required_roles'=>'array','required_permissions'=>'array']; public function parent(){ return $this->belongsTo(MenuItem::class,'parent_id'); } public function children(){ return $this->hasMany(MenuItem::class,'parent_id'); } } `

    5.2 Service per albero filtrato

    `php // app/Services/MenuService.php class MenuService { public function forUserMenu(string $menuName, ?User $user): array { $menu = Menu::where('name',$menuName)->first(); if (!$menu) return []; $items = $menu->items()->orderBy('order_index')->get()->groupBy('parent_id'); $activeRole = session('active_role');

    $filter = function($item) use ($user,$activeRole) { if (!$item->is_visible) return false; if ($item->required_roles && $activeRole && !in_array($activeRole, $item->required_roles)) return false; if ($item->required_permissions) { foreach ($item->required_permissions as $perm) { if (!$user || !$user->can($perm)) return false; } } return true; };

    $build = function($parentId) use (&$build, $items, $filter) { return ($items[$parentId] ?? collect())->filter($filter)->map(function($i) use (&$build) { return [ 'id'=>$i->id,'label'=>$i->label,'route_name'=>$i->route_name,'url'=>$i->url,'icon'=>$i->icon, 'children'=>$build($i->id)->values()->all() ]; }); };

    return $build(null)->values()->all(); } } `

    5.3 Layout Blade (contenitore)

    `blade {{-- resources/views/layouts/admin.blade.php --}} @php($menu = app(\App\Services\MenuService::class)->forUserMenu('admin_main', auth()->user())) <!doctype html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>@yield('title','sartUP Admin')</title> @vite(['resources/css/app.css','resources/js/app.js']) </head> <body class="min-h-screen bg-gray-50"> <header class="h-14 shadow flex items-center px-4 bg-white"> <nav class="flex gap-4"> @foreach($menu as $item) <a href="{{ $item['route_name'] ? route($item['route_name']) : ($item['url'] ?? '#') }}" class="font-medium"> {{ $item['label'] }} </a> @endforeach </nav> <div class="ml-auto"> <form method="POST" action="{{ route('logout') }}">@csrf<button>Logout</button></form> </div> </header> <div class="flex"> <aside class="w-64 bg-white border-r min-h-[calc(100vh-3.5rem)] p-3"> {{-- sidebar: figli della voce L1 corrente → per demo, mostra tutti i figli del primo item --}} @if(count($menu)) @foreach(($menu[1]['children'] ?? $menu[0]['children'] ?? []) as $child) <div class="mb-3"> <div class="font-semibold">{{ $child['label'] }}</div> @if(count($child['children'])) <ul class="ml-3 list-disc"> @foreach($child['children'] as $sub) <li><a href="{{ $sub['route_name'] ? route($sub['route_name']) : ($sub['url'] ?? '#') }}">{{ $sub['label'] }}</a></li> @endforeach </ul> @endif </div> @endforeach @endif </aside> <main class="flex-1 p-6"> @yield('content') </main> </div> </body> </html> `

    ---

    6) Placeholder Industrie 4.0

    6.1 Rotte & Controller

    `php // app/Http/Controllers/Admin/I40/MachinesController.php namespace App\Http\Controllers\Admin\I40; class MachinesController extends Controller { public function connected() { return view('admin.i40.machines.connected'); } } `

    `blade {{-- resources/views/admin/i40/machines/connected.blade.php --}} @extends('layouts.admin') @section('title','Elenco macchine collegate') @section('content') <h1 class="text-xl font-semibold mb-4">Elenco macchine collegate</h1> <table class="min-w-full bg-white shadow border"> <thead><tr> <th class="p-2 text-left">Macchina</th> <th class="p-2 text-left">Protocollo</th> <th class="p-2 text-left">Stato</th> <th class="p-2 text-left">Last seen</th> </tr></thead> <tbody> <tr><td class="p-2">—</td><td class="p-2">—</td><td class="p-2">—</td><td class="p-2">—</td></tr> </tbody> </table> @endsection `

    ---

    7) Criteri di accettazione (Fase VPS)

  • Login/logout/reset funzionanti (Blade).
  • Seeder: creati ruoli + utente super-admin.
  • Role selector post-login per utenti con >1 ruolo; switch ruolo attivo in sessione.
  • Menu dinamico in DB; render topbar + sidebar; voce "Industria 4.0 → Report → Macchine → Elenco macchine collegate" visibile al ruolo indicato.
  • Cron job configurato per Laravel Scheduler.
  • Queue worker attivo (se necessario).
  • ---

    8) TODO per Cursor (operativo)

  • [ ] Setup database MySQL in cPanel
  • [ ] Configurazione .env per VPS
  • [ ] Migrazioni menus, menu_items
  • [ ] Install Spatie + publish + migrazioni
  • [ ] Seeder: RolesSeeder, SuperAdminSeeder, MenuSeeder
  • [ ] Middleware EnsureActiveRole + rotte auth.select-role/auth.set-role
  • [ ] Controller & Blade auth nativa (login, forgot/reset) + layout admin
  • [ ] Service MenuService + rendering topbar/sidebar
  • [ ] Rotte/controller/view i40.machines.connected (placeholder)
  • [ ] Cron job per php artisan schedule:run` (ogni minuto)
  • [ ] Queue worker via cron o supervisord (opzionale)

Analisi Codice

Blocco 1 dotenv
APP_ENV=production
APP_URL=https://sartup.it
APP_KEY=

DB_HOST=localhost
DB_DATABASE=cpanel_user_sartup
DB_USERNAME=cpanel_user_xxxxx
DB_PASSWORD=secure_password

CACHE_DRIVER=file
QUEUE_CONNECTION=database
# Se Redis disponibile:
# CACHE_DRIVER=redis
# QUEUE_CONNECTION=redis
# REDIS_HOST=localhost

FILESYSTEM_DISK=local
# Se usi S3:
# FILESYSTEM_DISK=s3
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# AWS_DEFAULT_REGION=us-east-1
# AWS_BUCKET=sartup
Blocco 2 bash
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Blocco 3 sql
CREATE TABLE menus (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(64) UNIQUE NOT NULL,      -- 'admin_main'
  description VARCHAR(255) NULL,
  is_active TINYINT(1) DEFAULT 1,
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL
);

CREATE TABLE menu_items (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  menu_id BIGINT NOT NULL,
  parent_id BIGINT NULL,
  label VARCHAR(100) NOT NULL,
  route_name VARCHAR(120) NULL,  -- priorità a route_name, fallback url
  url VARCHAR(255) NULL,
  icon VARCHAR(60) NULL,
  description VARCHAR(255) NULL,
  order_index INT DEFAULT 0,
  is_visible TINYINT(1) DEFAULT 1,
  required_roles JSON NULL,         -- es. ["admin","maintenance"]
  required_permissions JSON NULL,   -- es. ["machines.view"]
  created_at TIMESTAMP NULL,
  updated_at TIMESTAMP NULL,
  CONSTRAINT fk_menu FOREIGN KEY (menu_id) REFERENCES menus(id),
  CONSTRAINT fk_parent FOREIGN KEY (parent_id) REFERENCES menu_items(id)
);
Blocco 4 php
// database/seeders/RolesSeeder.php
use Spatie\Permission\Models\Role;
class RolesSeeder extends Seeder {
  public function run(): void {
    foreach (['super-admin','admin','operator','maintenance','viewer'] as $r) {
      Role::firstOrCreate(['name' => $r]);
    }
  }
}

// database/seeders/SuperAdminSeeder.php
use App\Models\User;
use Spatie\Permission\Models\Role;
class SuperAdminSeeder extends Seeder {
  public function run(): void {
    $u = User::firstOrCreate(
      ['email' => 'root@sartup.local'],
      ['name' => 'Root', 'password' => bcrypt('ChangeMe!')]
    );
    $u->assignRole(Role::where('name','super-admin')->first());
  }
}
Blocco 5 php
// database/seeders/MenuSeeder.php
use App\Models\Menu;
use App\Models\MenuItem;

class MenuSeeder extends Seeder {
  public function run(): void {
    $admin = Menu::firstOrCreate(['name'=>'admin_main'], ['description'=>'Menu principale admin']);

    $dashboard = MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Dashboard'
    ],['route_name'=>'admin.dashboard','icon'=>'lucide-home','order_index'=>1]);

    $ind40 = MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Industria 4.0'
    ],['icon'=>'lucide-cpu','order_index'=>2]);

    $report = MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>$ind40->id,'label'=>'Report'
    ],['order_index'=>1]);

    $macchine = MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>$report->id,'label'=>'Macchine'
    ],['order_index'=>1]);

    MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>$macchine->id,'label'=>'Elenco macchine collegate'
    ],[
      'route_name'=>'i40.machines.connected',
      'order_index'=>1,
      'required_roles'=>json_encode(['admin','maintenance','super-admin'])
    ]);

    MenuItem::firstOrCreate([
      'menu_id'=>$admin->id,'parent_id'=>null,'label'=>'Impostazioni'
    ],['icon'=>'lucide-settings','order_index'=>99,'required_roles'=>json_encode(['super-admin'])]);
  }
}
Blocco 6 php
// routes/web.php
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login')->middleware('guest');
Route::post('/login', [LoginController::class, 'login'])->name('login.post')->middleware('guest');
Route::post('/logout', [LoginController::class, 'logout'])->name('logout')->middleware('auth');

Route::get('/password/forgot', [ForgotPasswordController::class, 'showLinkRequestForm'])->name('password.request');
Route::post('/password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->name('password.email');
Route::get('/password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
Route::post('/password/reset', [ResetPasswordController::class, 'reset'])->name('password.update');

// Admin area
Route::middleware(['auth','active.role'])->prefix('admin')->group(function () {
  Route::get('/', [DashboardController::class,'index'])->name('admin.dashboard');
  Route::prefix('i40')->group(function() {
    Route::get('/', [I40\HomeController::class,'index'])->name('i40.home');
    Route::get('/machines/connected', [I40\MachinesController::class,'connected'])->name('i40.machines.connected');
  });
});
Blocco 7 php
// app/Http/Middleware/EnsureActiveRole.php
class EnsureActiveRole {
  public function handle($request, Closure $next) {
    $user = $request->user();
    if (!$user) return redirect()->route('login');
    if ($user->roles()->count() <= 1) {
      if (!session()->has('active_role') && $user->roles()->exists()) {
        session(['active_role' => $user->roles()->first()->name]);
      }
      return $next($request);
    }
    if (!session()->has('active_role')) {
      return redirect()->route('auth.role.select');
    }
    return $next($request);
  }
}
Blocco 8 php
// routes/web.php (aggiunte)
Route::middleware(['auth'])->group(function () {
  Route::get('/auth/select-role', [RoleSelectorController::class,'show'])->name('auth.role.select');
  Route::post('/auth/set-role', [RoleSelectorController::class,'set'])->name('auth.role.set');
});
Blocco 9 php
// app/Http/Controllers/Auth/RoleSelectorController.php
class RoleSelectorController extends Controller {
  public function show(Request $r) {
    $roles = $r->user()->roles()->pluck('name');
    return view('auth.select-role', compact('roles'));
  }
  public function set(Request $r) {
    $r->validate(['role'=>'required|string']);
    abort_unless($r->user()->hasRole($r->role), 403);
    session(['active_role' => $r->role]);
    return redirect()->intended(route('admin.dashboard'));
  }
}
Blocco 10 blade
{{-- resources/views/auth/select-role.blade.php --}}
@extends('layouts.app')
@section('content')
  <h1>Seleziona ruolo</h1>
  <form method="POST" action="{{ route('auth.role.set') }}">
    @csrf
    <select name="role" required>
      @foreach($roles as $role)
        <option value="{{ $role }}">{{ ucfirst($role) }}</option>
      @endforeach
    </select>
    <button type="submit">Continua</button>
  </form>
@endsection
Blocco 11 php
// app/Providers/AuthServiceProvider.php
Gate::before(function ($user, $ability) {
  return $user->hasRole('super-admin') ? true : null;
});
Blocco 12 php
// app/Models/Menu.php
class Menu extends Model {
  protected $fillable = ['name','description','is_active'];
  public function items() { return $this->hasMany(MenuItem::class); }
}

// app/Models/MenuItem.php
class MenuItem extends Model {
  protected $fillable = [
    'menu_id','parent_id','label','route_name','url','icon','description',
    'order_index','is_visible','required_roles','required_permissions'
  ];
  protected $casts = ['required_roles'=>'array','required_permissions'=>'array'];
  public function parent(){ return $this->belongsTo(MenuItem::class,'parent_id'); }
  public function children(){ return $this->hasMany(MenuItem::class,'parent_id'); }
}
Blocco 13 php
// app/Services/MenuService.php
class MenuService {
  public function forUserMenu(string $menuName, ?User $user): array {
    $menu = Menu::where('name',$menuName)->first();
    if (!$menu) return [];
    $items = $menu->items()->orderBy('order_index')->get()->groupBy('parent_id');
    $activeRole = session('active_role');

    $filter = function($item) use ($user,$activeRole) {
      if (!$item->is_visible) return false;
      if ($item->required_roles && $activeRole && !in_array($activeRole, $item->required_roles)) return false;
      if ($item->required_permissions) {
        foreach ($item->required_permissions as $perm) {
          if (!$user || !$user->can($perm)) return false;
        }
      }
      return true;
    };

    $build = function($parentId) use (&$build, $items, $filter) {
      return ($items[$parentId] ?? collect())->filter($filter)->map(function($i) use (&$build) {
        return [
          'id'=>$i->id,'label'=>$i->label,'route_name'=>$i->route_name,'url'=>$i->url,'icon'=>$i->icon,
          'children'=>$build($i->id)->values()->all()
        ];
      });
    };

    return $build(null)->values()->all();
  }
}
Blocco 14 blade
{{-- resources/views/layouts/admin.blade.php --}}
@php($menu = app(\App\Services\MenuService::class)->forUserMenu('admin_main', auth()->user()))
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>@yield('title','sartUP Admin')</title>
  @vite(['resources/css/app.css','resources/js/app.js'])
</head>
<body class="min-h-screen bg-gray-50">
  <header class="h-14 shadow flex items-center px-4 bg-white">
    <nav class="flex gap-4">
      @foreach($menu as $item)
        <a href="{{ $item['route_name'] ? route($item['route_name']) : ($item['url'] ?? '#') }}" class="font-medium">
          {{ $item['label'] }}
        </a>
      @endforeach
    </nav>
    <div class="ml-auto">
      <form method="POST" action="{{ route('logout') }}">@csrf<button>Logout</button></form>
    </div>
  </header>
  <div class="flex">
    <aside class="w-64 bg-white border-r min-h-[calc(100vh-3.5rem)] p-3">
      {{-- sidebar: figli della voce L1 corrente → per demo, mostra tutti i figli del primo item --}}
      @if(count($menu))
        @foreach(($menu[1]['children'] ?? $menu[0]['children'] ?? []) as $child)
          <div class="mb-3">
            <div class="font-semibold">{{ $child['label'] }}</div>
            @if(count($child['children']))
              <ul class="ml-3 list-disc">
                @foreach($child['children'] as $sub)
                  <li><a href="{{ $sub['route_name'] ? route($sub['route_name']) : ($sub['url'] ?? '#') }}">{{ $sub['label'] }}</a></li>
                @endforeach
              </ul>
            @endif
          </div>
        @endforeach
      @endif
    </aside>
    <main class="flex-1 p-6">
      @yield('content')
    </main>
  </div>
</body>
</html>
Blocco 15 php
// app/Http/Controllers/Admin/I40/MachinesController.php
namespace App\Http\Controllers\Admin\I40;
class MachinesController extends Controller {
  public function connected() {
    return view('admin.i40.machines.connected');
  }
}
Blocco 16 blade
{{-- resources/views/admin/i40/machines/connected.blade.php --}}
@extends('layouts.admin')
@section('title','Elenco macchine collegate')
@section('content')
  <h1 class="text-xl font-semibold mb-4">Elenco macchine collegate</h1>
  <table class="min-w-full bg-white shadow border">
    <thead><tr>
      <th class="p-2 text-left">Macchina</th>
      <th class="p-2 text-left">Protocollo</th>
      <th class="p-2 text-left">Stato</th>
      <th class="p-2 text-left">Last seen</th>
    </tr></thead>
    <tbody>
      <tr><td class="p-2">—</td><td class="p-2">—</td><td class="p-2">—</td><td class="p-2">—</td></tr>
    </tbody>
  </table>
@endsection