Compare commits

...

36 Commits

Author SHA1 Message Date
f5c059cd08 fix: Add gateway network to nginx for NPM connectivity on restart 2026-03-15 12:56:00 +02:00
21620906dc fix: Add public directory to permission fix for storage:link 2026-03-15 12:16:34 +02:00
14084e172a fix: Add permission fix for .env file in setup.sh 2026-03-15 11:56:17 +02:00
6fe63d70af fix: Add permission fix step before artisan commands in setup.sh 2026-03-15 11:53:46 +02:00
fdbd8f5148 fix: Write port config to project root .env for docker compose 2026-03-15 11:49:40 +02:00
cd6043ed64 fix: Properly set port variables in .env for docker compose port mapping 2026-03-15 11:43:59 +02:00
eb05ec9b31 fix: Remove hardcoded container_name and obsolete version to allow multiple deployments 2026-03-15 11:41:54 +02:00
db0cf0c2c4 Replace docker-compose with docker compose in setup script 2026-03-15 11:37:19 +02:00
a6b1cfe498 Add module generator UI and discovery service documentation 2026-03-15 08:59:53 +02:00
dff2cd752c Add ViteHelper fallback for missing frontend assets 2026-03-15 07:47:59 +02:00
94f4e53860 push 2026-03-15 07:37:23 +02:00
779702ca3f Merge branch 'main' of https://git.radapps.co.za/theradcoza/Laravel-Docker-Dev-Template
Merge new feature. menu management
2026-03-14 09:23:45 +02:00
ed7055edaa feat: Add frontend menu management system with permission filtering 2026-03-13 14:21:43 +02:00
6d2d4ad5ca Update REPO_MANAGEMENT.md 2026-03-13 07:25:47 +00:00
551024cc32 Add REPO_MANAGEMENT.md 2026-03-13 07:24:17 +00:00
1995f58056 Add .dockerignore to exclude node_modules from builds 2026-03-12 10:40:05 +02:00
bcb7997ba0 Fix Filament 403 - add FilamentUser interface and canAccessPanel method 2026-03-12 10:22:49 +02:00
a0a722971d Add seeders to production instructions 2026-03-12 10:05:09 +02:00
754153ed1c pass 2026-03-12 10:00:50 +02:00
a1a0d6a6c3 add user make 2026-03-12 09:53:03 +02:00
1d83a3c830 Add make:admin artisan command for creating admin users 2026-03-12 09:51:00 +02:00
fa4c787beb Fix npm ci to npm install in deployment docs 2026-03-12 09:48:17 +02:00
c39a04315c Fix docblock parse error in RolePermissionSeeder 2026-03-11 09:19:59 +02:00
e380721938 Add central Audit Trail viewer in Filament admin
Features:
- AuditResource with table listing all audit logs
- ViewAudit page showing detailed changes with diff table
- Filters: date range, user, model type, event type
- Search by user name
- Auto-refresh every 60 seconds
- Dark mode support for changes view
- Fixed audits migration with proper schema
- Added audit.view permission (admin only by default)

Files:
- src/app/Models/Audit.php
- src/app/Filament/Resources/AuditResource.php
- src/app/Filament/Resources/AuditResource/Pages/
- src/resources/views/filament/infolists/entries/audit-changes.blade.php
- Updated audits migration with full schema
2026-03-11 09:10:38 +02:00
f903914c2b Add auto-loading of module permissions in RolePermissionSeeder
RolePermissionSeeder now automatically scans app/Modules/*/Permissions.php
files and registers all permissions found. No manual registration required.

- Added loadModulePermissions() method to scan module directories
- Changed givePermissionTo() to syncPermissions() for idempotency
- Updated Modules README to document auto-loading
- Updated CLAUDE.md to reflect auto-loading behavior
2026-03-11 08:42:06 +02:00
2b153f1759 Upgrade make:module command with full ServiceProvider structure
Enhanced make:module command:
- Added --model flag for creating model + migration + Filament resource
- Added --api flag for including API routes
- Added --no-filament flag to skip Filament resource
- Creates full ServiceProvider structure with auto-registration
- Creates Config, Permissions, Routes, Views, Tests
- Module migrations stored in module folder
- Filament resources stored in module folder
- Auto-registers ServiceProvider in bootstrap/providers.php

Also added:
- ModuleAuditable trait for audit trail support
- Updated Modules README to document new command options
2026-03-11 08:12:54 +02:00
ac16dbc153 Add AI agent cross-references to docs/modules.md and docs/filament-admin.md 2026-03-11 08:01:27 +02:00
12027d9e98 Add CLAUDE.md - AI agent instructions for module development
Created comprehensive anti-hallucination documentation:
- EXACT file templates with full code examples
- Precise naming conventions (PascalCase, snake_case, kebab-case)
- Step-by-step module creation checklist
- Common mistakes to avoid section
- Filament form/table component reference
- Debugging commands and troubleshooting
- Updated AI_CONTEXT.md and README.md to reference CLAUDE.md
2026-03-11 07:59:39 +02:00
e4c7d09109 Add User Management features (Users, Roles, Permissions)
Added Filament Resources:
- UserResource: Enhanced with role assignment, password confirmation
- RoleResource: Manage roles with permission assignment
- PermissionResource: Manage individual permissions

Features:
- Users can be assigned multiple roles
- Roles can be assigned multiple permissions
- Color-coded badges for roles/permissions
- Filter users by role
- Prevent deletion of admin role
- User Management navigation group
2026-03-11 07:48:43 +02:00
cf6079d58c Redirect landing to login, add registration toggle
Changes:
- Landing page (/) now redirects to /login
- Added 'Enable User Registration' setting (off by default)
- Created CheckRegistrationEnabled middleware
- Registration routes blocked when setting is disabled
- Admin can toggle registration in Settings > System
2026-03-11 07:43:25 +02:00
4b2ff91ac4 Add global site settings view composer with database fallback
Implemented view composer in AppServiceProvider to share site settings across all views:
- Loads site name, logo, colors (primary/secondary/accent), and description from Setting model
- Falls back to config/defaults if database unavailable (prevents errors during migrations)
- Made siteSettings available to all Blade templates

Updated layouts and components to use dynamic settings:
- app.blade.php and guest.blade.php now use siteSettings for
2026-03-11 07:37:27 +02:00
e274d41f19 Fix seeder to actually create admin user
Two issues fixed:
1. Changed create() to firstOrCreate() - makes seeder idempotent
   (can run multiple times without 'already exists' errors)

2. Seeder now CREATES admin user instead of just assigning role
   to existing user. Previous code assumed user existed.

Admin credentials:
- Email: admin@example.com
- Password: password
2026-03-11 07:18:12 +02:00
2ff7a24736 Add frontend asset build to setup scripts
Added Step 11 to both setup.bat and setup.sh:
- Run npm install to install dependencies
- Run npm run build to compile Vite assets
- Creates public/build/manifest.json

This fixes the 'Vite manifest not found' error that occurred
when trying to login after fresh setup.

The node container is used for npm commands since the app
container doesn't have Node.js installed.
2026-03-11 06:25:03 +02:00
119eaf1873 Fix MySQL connection and DatabaseSeeder BOM issues
Two critical fixes:

1. DB_PORT was being appended to Laravel .env file
   - Setup script was writing DB_PORT=3307 (external port)
   - Laravel needs internal Docker port (3306)
   - Now ports are commented out in .env (reference only)
   - Docker-compose uses env vars, Laravel uses fixed internal ports

2. DatabaseSeeder.php had UTF-8 BOM (Byte Order Mark)
   - Caused 'Namespace declaration must be first statement' error
   - Removed invisible BOM bytes from file

These fixes ensure:
- MySQL connections work from within Docker network
- Database seeding completes successfully
- Fresh setup works end-to-end
2026-03-11 06:09:36 +02:00
306413ca56 update setup script 2026-03-10 20:33:13 +02:00
b1453ff249 Fix MySQL connection refused - add proper database readiness check
The previous 5-second fixed wait was insufficient for MySQL 8.0 to fully
initialize, causing migrations and seeders to fail with 'Connection refused'.

Changes:
- Replace 'sleep 5' with active connection polling
- Try to connect every second for up to 30 seconds
- Use PHP PDO to test actual database connectivity
- Only proceed when database accepts connections
- Show clear status when database is ready

This ensures migrations and seeders run only after database is fully ready,
preventing 'Connection refused' errors on fresh setup.

Fixes the issue where Laravel loads but shows MySQL connection error.
2026-03-10 20:31:51 +02:00
72 changed files with 8137 additions and 387 deletions

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
# Dependencies
src/node_modules
src/vendor
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build artifacts
src/public/build
src/public/hot
# Testing
src/coverage
# Logs
*.log
src/storage/logs/*
# Cache
src/bootstrap/cache/*
src/storage/framework/cache/*
src/storage/framework/sessions/*
src/storage/framework/views/*

View File

@@ -1,7 +1,25 @@
# AI Assistant Context
> **🚨 CRITICAL: For detailed module development instructions, see [CLAUDE.md](CLAUDE.md)**
> That document contains EXACT file templates, naming conventions, and step-by-step instructions.
This document provides context for AI coding assistants working on projects built with this template.
## Development Workflow Rules
### Git Branching Strategy
**Rule**: The AI agent must create a new Git branch for every new feature, module, or significant change. It should never commit or push directly to the `main` branch.
**Rationale**: This practice is crucial for maintaining a clean and stable main branch, facilitating code reviews, and making it easier for human developers to collaborate and manage the project's history.
**Example Workflow**:
1. `git checkout -b feature/new-company-module`
2. *...perform all work related to the new module...*
3. `git add .`
4. `git commit -m "feat: Create new company module"`
5. *...agent informs the user that the feature is complete on the new branch...*
## Template Overview
This is a **ready-to-use Laravel Docker Development Template** with everything pre-installed:
@@ -331,6 +349,7 @@ make restore file=... # Restore database
## Documentation Links
- **[CLAUDE.md](CLAUDE.md)** - 🚨 **AI AGENTS: START HERE** - Exact module templates
- [GETTING_STARTED.md](GETTING_STARTED.md) - Setup walkthrough
- [README.md](README.md) - Overview
- [docs/modules.md](docs/modules.md) - Module system

782
CLAUDE.md Normal file
View File

@@ -0,0 +1,782 @@
# AI Agent Instructions for Laravel Module Development
> **CRITICAL**: This document provides EXACT specifications for building modules in this Laravel template.
> Follow these instructions PRECISELY. Do NOT deviate, guess, or improvise.
> When in doubt, READ THE EXISTING CODE before making changes.
---
## 🚨 BEFORE YOU START - MANDATORY STEPS
### 1. Always Check Existing Patterns First
```bash
# List existing modules to see patterns
ls -la src/app/Modules/
# Read an existing module's structure
find src/app/Modules/[ExistingModule]/ -type f -name "*.php"
```
### 2. Always Check Logs After Errors
```bash
docker-compose exec app tail -n 100 storage/logs/laravel.log
```
### 3. Never Guess at Solutions
- If you encounter an error, READ the full stack trace
- Find the EXACT file and line number causing the issue
- Understand what the code is trying to do before fixing
---
## 📁 PROJECT STRUCTURE - EXACT PATHS
```
Laravel-Docker-Dev-Template/
├── src/ # ← ALL Laravel code goes here
│ ├── app/
│ │ ├── Filament/
│ │ │ ├── Pages/
│ │ │ │ └── Settings.php # Site settings page
│ │ │ └── Resources/ # Core Filament resources
│ │ │ ├── UserResource.php
│ │ │ ├── RoleResource.php
│ │ │ └── PermissionResource.php
│ │ ├── Http/
│ │ │ ├── Controllers/
│ │ │ └── Middleware/
│ │ ├── Models/
│ │ │ ├── User.php
│ │ │ └── Setting.php
│ │ ├── Modules/ # ← ALL modules go here
│ │ │ └── [ModuleName]/ # ← Each module is a folder
│ │ ├── Providers/
│ │ │ └── AppServiceProvider.php
│ │ └── Traits/
│ │ └── ModuleAuditable.php
│ ├── bootstrap/
│ │ └── app.php # Middleware registration
│ ├── config/
│ ├── database/
│ │ ├── migrations/
│ │ └── seeders/
│ │ ├── DatabaseSeeder.php
│ │ └── RolePermissionSeeder.php
│ ├── resources/
│ │ └── views/
│ │ ├── layouts/
│ │ │ ├── app.blade.php # Authenticated layout
│ │ │ └── guest.blade.php # Guest layout
│ │ └── components/
│ └── routes/
│ ├── web.php
│ ├── api.php
│ └── auth.php
├── docker-compose.yml
├── setup.bat # Windows setup
└── setup.sh # Linux/Mac setup
```
---
## 🔧 MODULE STRUCTURE - EXACT SPECIFICATION
When creating a module named `[ModuleName]` (e.g., `StockManagement`):
```
src/app/Modules/[ModuleName]/
├── Config/
│ └── [module_name].php # snake_case filename
├── Database/
│ ├── Migrations/
│ │ └── YYYY_MM_DD_HHMMSS_create_[table_name]_table.php
│ └── Seeders/
│ └── [ModuleName]Seeder.php
├── Filament/
│ └── Resources/
│ ├── [ModelName]Resource.php
│ └── [ModelName]Resource/
│ └── Pages/
│ ├── List[ModelName]s.php
│ ├── Create[ModelName].php
│ └── Edit[ModelName].php
├── Http/
│ ├── Controllers/
│ │ └── [ModuleName]Controller.php
│ ├── Middleware/
│ └── Requests/
│ └── [ModelName]Request.php
├── Models/
│ └── [ModelName].php
├── Policies/
│ └── [ModelName]Policy.php
├── Services/
│ └── [ModelName]Service.php
├── Routes/
│ ├── web.php
│ └── api.php
├── Resources/
│ └── views/
│ ├── index.blade.php
│ └── layouts/
│ └── module.blade.php
├── Permissions.php
└── [ModuleName]ServiceProvider.php
```
---
## 📝 EXACT FILE TEMPLATES
### 1. Service Provider (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/[ModuleName]ServiceProvider.php`
```php
<?php
namespace App\Modules\[ModuleName];
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class [ModuleName]ServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(
__DIR__ . '/Config/[module_name].php',
'[module_name]'
);
}
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
$this->loadViewsFrom(__DIR__ . '/Resources/views', '[module-slug]');
$this->registerRoutes();
}
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('[module-slug]')
->name('[module-slug].')
->group(__DIR__ . '/Routes/web.php');
if (file_exists(__DIR__ . '/Routes/api.php')) {
Route::middleware(['api', 'auth:sanctum'])
->prefix('api/[module-slug]')
->name('api.[module-slug].')
->group(__DIR__ . '/Routes/api.php');
}
}
}
```
**NAMING RULES**:
- `[ModuleName]` = PascalCase (e.g., `StockManagement`)
- `[module_name]` = snake_case (e.g., `stock_management`)
- `[module-slug]` = kebab-case (e.g., `stock-management`)
### 2. Config File (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/Config/[module_name].php`
```php
<?php
return [
'name' => '[Module Display Name]',
'slug' => '[module-slug]',
'version' => '1.0.0',
'audit' => [
'enabled' => true,
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
'include' => [],
'exclude' => [],
],
];
```
### 3. Model (REQUIRED for data modules)
**File**: `src/app/Modules/[ModuleName]/Models/[ModelName].php`
```php
<?php
namespace App\Modules\[ModuleName]\Models;
use App\Traits\ModuleAuditable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;
class [ModelName] extends Model implements Auditable
{
use HasFactory, ModuleAuditable;
protected $fillable = [
// List ALL fields that can be mass-assigned
];
protected $casts = [
// Type casts for fields
];
// Define relationships here
}
```
### 4. Migration (REQUIRED for data modules)
**File**: `src/app/Modules/[ModuleName]/Database/Migrations/YYYY_MM_DD_HHMMSS_create_[table_name]_table.php`
```php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('[table_name]', function (Blueprint $table) {
$table->id();
// Define ALL columns here
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('[table_name]');
}
};
```
### 5. Permissions (REQUIRED) - AUTO-LOADED
**File**: `src/app/Modules/[ModuleName]/Permissions.php`
```php
<?php
return [
'[module_name].view' => 'View [Module Name]',
'[module_name].create' => 'Create [Module Name] records',
'[module_name].edit' => 'Edit [Module Name] records',
'[module_name].delete' => 'Delete [Module Name] records',
];
```
> **✅ AUTO-LOADED**: `RolePermissionSeeder` automatically scans all `app/Modules/*/Permissions.php`
> files and registers them. Just run `php artisan db:seed --class=RolePermissionSeeder`.
### 6. Filament Resource (REQUIRED for admin)
**File**: `src/app/Modules/[ModuleName]/Filament/Resources/[ModelName]Resource.php`
```php
<?php
namespace App\Modules\[ModuleName]\Filament\Resources;
use App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource\Pages;
use App\Modules\[ModuleName]\Models\[ModelName];
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class [ModelName]Resource extends Resource
{
protected static ?string $model = [ModelName]::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationGroup = '[Module Display Name]';
protected static ?int $navigationSort = 1;
public static function canAccess(): bool
{
return auth()->user()?->can('[module_name].view') ?? false;
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('[Model Name] Details')
->schema([
// Add form fields here
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
// Add table columns here
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\List[ModelName]s::route('/'),
'create' => Pages\Create[ModelName]::route('/create'),
'edit' => Pages\Edit[ModelName]::route('/{record}/edit'),
];
}
}
```
### 7. Filament Resource Pages (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/Filament/Resources/[ModelName]Resource/Pages/List[ModelName]s.php`
```php
<?php
namespace App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource\Pages;
use App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class List[ModelName]s extends ListRecords
{
protected static string $resource = [ModelName]Resource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
```
**File**: `src/app/Modules/[ModuleName]/Filament/Resources/[ModelName]Resource/Pages/Create[ModelName].php`
```php
<?php
namespace App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource\Pages;
use App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource;
use Filament\Resources\Pages\CreateRecord;
class Create[ModelName] extends CreateRecord
{
protected static string $resource = [ModelName]Resource::class;
}
```
**File**: `src/app/Modules/[ModuleName]/Filament/Resources/[ModelName]Resource/Pages/Edit[ModelName].php`
```php
<?php
namespace App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource\Pages;
use App\Modules\[ModuleName]\Filament\Resources\[ModelName]Resource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class Edit[ModelName] extends EditRecord
{
protected static string $resource = [ModelName]Resource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
```
### 8. Routes (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/Routes/web.php`
```php
<?php
use App\Modules\[ModuleName]\Http\Controllers\[ModuleName]Controller;
use Illuminate\Support\Facades\Route;
Route::get('/', [[ModuleName]Controller::class, 'index'])->name('index');
```
**File**: `src/app/Modules/[ModuleName]/Routes/api.php` (if API needed)
```php
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
// API routes here
});
```
### 9. Controller (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/Http/Controllers/[ModuleName]Controller.php`
```php
<?php
namespace App\Modules\[ModuleName]\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class [ModuleName]Controller extends Controller
{
public function index()
{
$this->authorize('[module_name].view');
return view('[module-slug]::index');
}
}
```
### 10. Index View (REQUIRED)
**File**: `src/app/Modules/[ModuleName]/Resources/views/index.blade.php`
```blade
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('[Module Display Name]') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
{{ __('[Module Display Name] content goes here.') }}
</div>
</div>
</div>
</div>
</x-app-layout>
```
---
## ✅ MODULE REGISTRATION
After creating all files, register the module in `src/config/app.php` providers array:
```php
'providers' => ServiceProvider::defaultProviders()->merge([
// ...existing providers...
App\Modules\[ModuleName]\[ModuleName]ServiceProvider::class,
])->toArray(),
```
Or add to `src/bootstrap/providers.php`:
```php
return [
App\Providers\AppServiceProvider::class,
// ...existing providers...
App\Modules\[ModuleName]\[ModuleName]ServiceProvider::class,
];
```
---
## 🔄 AFTER MODULE CREATION - REQUIRED COMMANDS
```bash
# 1. Run migrations
docker-compose exec app php artisan migrate
# 2. Clear all caches
docker-compose exec app php artisan optimize:clear
# 3. Seed permissions (if using permissions)
docker-compose exec app php artisan db:seed --class=RolePermissionSeeder
# 4. Reset permission cache
docker-compose exec app php artisan permission:cache-reset
```
---
## ⚠️ COMMON MISTAKES TO AVOID
### 1. Wrong Namespace
`namespace App\Modules\StockManagement\Models;`
`namespace App\Modules\StockManagement\Models;`
The namespace MUST match the folder structure EXACTLY.
### 2. Wrong View Namespace
`return view('stockmanagement::index');`
`return view('StockManagement::index');`
`return view('stock-management::index');`
View namespace is ALWAYS kebab-case.
### 3. Wrong Permission Names
`'StockManagement.view'`
`'stock-management.view'`
`'stock_management.view'`
Permissions are ALWAYS snake_case.
### 4. Forgetting to Register Provider
The module WILL NOT LOAD if you forget to add its ServiceProvider.
### 5. Wrong Import Paths
`use App\Models\Product;`
`use App\Modules\StockManagement\Models\Product;`
Module models are in the MODULE namespace, not the core App namespace.
### 6. Missing Fillable Array
❌ Empty `$fillable` array causes mass-assignment errors
✅ List ALL fields that should be mass-assignable
### 7. Forgetting to Run Migrations
Always run `php artisan migrate` after creating migrations.
---
## 🎨 FILAMENT FORM FIELD REFERENCE
```php
// Text
Forms\Components\TextInput::make('name')
->required()
->maxLength(255);
// Email
Forms\Components\TextInput::make('email')
->email()
->required();
// Password
Forms\Components\TextInput::make('password')
->password()
->required();
// Textarea
Forms\Components\Textarea::make('description')
->rows(3);
// Select
Forms\Components\Select::make('status')
->options([
'active' => 'Active',
'inactive' => 'Inactive',
])
->required();
// Checkbox
Forms\Components\Toggle::make('is_active')
->default(true);
// Date
Forms\Components\DatePicker::make('date');
// DateTime
Forms\Components\DateTimePicker::make('published_at');
// Number
Forms\Components\TextInput::make('price')
->numeric()
->prefix('$');
// File Upload
Forms\Components\FileUpload::make('image')
->image()
->directory('uploads');
// Relationship Select
Forms\Components\Select::make('category_id')
->relationship('category', 'name')
->searchable()
->preload();
```
---
## 📊 FILAMENT TABLE COLUMN REFERENCE
```php
// Text
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable();
// Badge
Tables\Columns\TextColumn::make('status')
->badge()
->color(fn (string $state): string => match ($state) {
'active' => 'success',
'inactive' => 'danger',
default => 'gray',
});
// Boolean Icon
Tables\Columns\IconColumn::make('is_active')
->boolean();
// Image
Tables\Columns\ImageColumn::make('avatar')
->circular();
// Date
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable();
// Money
Tables\Columns\TextColumn::make('price')
->money('USD');
```
---
## 🔐 EXISTING CORE RESOURCES
### User Management (already exists in template)
- `src/app/Filament/Resources/UserResource.php` - User CRUD with role assignment
- `src/app/Filament/Resources/RoleResource.php` - Role CRUD with permissions
- `src/app/Filament/Resources/PermissionResource.php` - Permission CRUD
### Settings (already exists)
- `src/app/Filament/Pages/Settings.php` - Site settings (name, logo, colors, registration toggle)
- `src/app/Models/Setting.php` - Settings model with get/set helpers
### Authentication (pre-installed)
- Laravel Breeze with Blade templates
- Login: `/login`
- Register: `/register` (controlled by enable_registration setting)
- Admin: `/admin`
---
## 🧪 TESTING MODULES
Create tests in `src/tests/Feature/Modules/[ModuleName]/`:
```php
<?php
namespace Tests\Feature\Modules\[ModuleName];
use App\Models\User;
use App\Modules\[ModuleName]\Models\[ModelName];
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class [ModelName]Test extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_[model_name]s(): void
{
$user = User::factory()->create();
$user->givePermissionTo('[module_name].view');
$this->actingAs($user)
->get('/[module-slug]')
->assertStatus(200);
}
}
```
---
## 📋 CHECKLIST FOR NEW MODULES
Before considering a module complete:
- [ ] ServiceProvider created and registered
- [ ] Config file created
- [ ] Model created with fillable and casts
- [ ] Migration created and run
- [ ] Permissions.php created
- [ ] Filament Resource created with all pages
- [ ] Controller created
- [ ] Routes (web.php and optionally api.php) created
- [ ] Index view created
- [ ] `php artisan migrate` run
- [ ] `php artisan optimize:clear` run
- [ ] `php artisan db:seed --class=RolePermissionSeeder` run
- [ ] Module accessible at `/[module-slug]`
- [ ] Admin panel shows module in navigation
- [ ] CRUD operations work in admin
---
## 🆘 DEBUGGING
### Module Not Loading
```bash
# Check if provider is registered
docker-compose exec app php artisan about
# Clear everything
docker-compose exec app php artisan optimize:clear
```
### Filament Resource Not Showing
```bash
# Clear Filament cache
docker-compose exec app php artisan filament:cache-components
# Check canAccess() method returns true
# Check user has required permission
```
### Permission Denied
```bash
# Reset permission cache
docker-compose exec app php artisan permission:cache-reset
# Verify permissions exist
docker-compose exec app php artisan tinker
>>> \Spatie\Permission\Models\Permission::pluck('name');
```
### View Not Found
```bash
# Verify view namespace (must be kebab-case)
# Check file exists in Resources/views/
```
---
**Remember**: When in doubt, look at existing code in the codebase. The patterns are already established - follow them exactly.

View File

@@ -4,7 +4,8 @@ A comprehensive Laravel development environment with Docker for local developmen
> **New here?** Start with [GETTING_STARTED.md](GETTING_STARTED.md) for a step-by-step setup guide.
>
> **AI Assistant?** See [AI_CONTEXT.md](AI_CONTEXT.md) for project context and conventions.
> **AI Assistant?** See [CLAUDE.md](CLAUDE.md) for EXACT module templates and step-by-step instructions.
> Also see [AI_CONTEXT.md](AI_CONTEXT.md) for project context and conventions.
## Architecture Overview
@@ -74,6 +75,8 @@ A comprehensive Laravel development environment with Docker for local developmen
│ ├── laravel-setup.md # Laravel setup guide
│ ├── filament-admin.md # Admin panel docs
│ ├── modules.md # Modular architecture guide
│ ├── module-generator.md # Admin UI module generator
│ ├── menu-management.md # Frontend menu system
│ ├── audit-trail.md # Audit trail docs
│ ├── site-settings.md # Appearance settings
│ ├── testing.md # Pest testing guide
@@ -139,8 +142,8 @@ setup.bat # Windows
- Mailpit: http://localhost:8025
**Admin Login:**
- Email: admin@example.com
- Password: password
Interactive (prompts for name and password)
php artisan make:admin admin@example.com
### Manual Setup (Alternative)
@@ -207,6 +210,20 @@ If you prefer manual control:
| `make setup-laravel` | Configure auth, API, middleware |
| `make setup-all` | Run both setup scripts |
### Frontend Assets (Vite)
Build frontend CSS/JS assets:
```bash
# Build assets for production
docker-compose run --rm node npm run build
# Or run Vite dev server (hot reload)
docker-compose --profile frontend up -d
```
> **Note:** The template includes a resilient fallback - if assets aren't built, basic styling is provided and a development warning is shown. This prevents the `ViteManifestNotFoundException` error from breaking the app.
## Laravel Setup (Auth, API, Middleware)
After installing Laravel, run the interactive setup:
@@ -376,7 +393,9 @@ See [docs/laravel-setup.md](docs/laravel-setup.md) for detailed configuration.
# Edit .env with your settings
php artisan key:generate
php artisan migrate
npm ci && npm run build
npm install && npm run build
php artisan db:seed
php artisan make:admin user@domain.com
```
2. **Configure Nginx**

2
REPO_MANAGEMENT.md Normal file
View File

@@ -0,0 +1,2 @@
Commits must be againsts the module being currently worked on.
Commits must be to a branch that has the exact same name as the module being worked on.

View File

@@ -0,0 +1,16 @@
# Template Improvement Suggestions
This document contains suggestions for improving the template based on recurring development issues.
## 1. Mandatory Module Settings Page
**Rule**: When creating a new module, the AI agent must always create a corresponding settings page within the admin panel.
**Minimum Requirements**: This settings page must, at a minimum, allow an administrator to configure which user roles have Create, Read, Update, and Delete (CRUD) permissions for that module.
**Rationale**: This has been a recurring issue. Automating the creation of a permission management UI for each module makes the template more robust, secure, and user-friendly out-of-the-box. It prevents situations where new modules are added without any way for an admin to control access to them.
**Example Implementation**:
- When a `Blog` module is created, a `Blog Settings` page should also be created.
- This page should contain a form with checkboxes or a multi-select dropdown for each CRUD permission (`blog.view`, `blog.create`, `blog.edit`, `blog.delete`).
- An administrator can then select which roles (e.g., 'admin', 'editor', 'viewer') are granted each of these permissions.

View File

@@ -36,7 +36,7 @@ composer install --no-dev --optimize-autoloader --no-interaction
# Install and build frontend assets
echo "[4/9] Installing Node dependencies..."
npm ci --production=false
npm install
echo "[5/9] Building frontend assets..."
npm run build

View File

@@ -1,4 +1,3 @@
version: '3.8'
services:
# PHP-FPM Application Server
@@ -6,7 +5,6 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: laravel_app
restart: unless-stopped
working_dir: /var/www/html
volumes:
@@ -21,7 +19,6 @@ services:
# Nginx Web Server
nginx:
image: nginx:alpine
container_name: laravel_nginx
restart: unless-stopped
ports:
- "${APP_PORT:-8080}:80"
@@ -30,6 +27,7 @@ services:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
networks:
- laravel_network
- gateway
depends_on:
- app
@@ -43,7 +41,6 @@ services:
# MySQL Database
mysql:
image: mysql:8.0
container_name: laravel_mysql
restart: unless-stopped
ports:
- "${DB_PORT:-3306}:3306"
@@ -63,7 +60,6 @@ services:
# PostgreSQL Database
pgsql:
image: postgres:16-alpine
container_name: laravel_pgsql
restart: unless-stopped
ports:
- "${DB_PORT:-5432}:5432"
@@ -83,7 +79,6 @@ services:
# This is a dummy service to enable the sqlite profile
sqlite:
image: alpine:latest
container_name: laravel_sqlite_init
volumes:
- ./src/database:/data
command: sh -c "touch /data/database.sqlite && chmod 666 /data/database.sqlite"
@@ -93,7 +88,6 @@ services:
# Redis Cache
redis:
image: redis:alpine
container_name: laravel_redis
restart: unless-stopped
ports:
- "${REDIS_PORT:-6379}:6379"
@@ -105,7 +99,6 @@ services:
# Node.js for frontend assets (Vite/Mix)
node:
image: node:20-alpine
container_name: laravel_node
working_dir: /var/www/html
volumes:
- ./src:/var/www/html
@@ -120,7 +113,6 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: laravel_queue
restart: unless-stopped
working_dir: /var/www/html
volumes:
@@ -140,7 +132,6 @@ services:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: laravel_scheduler
restart: unless-stopped
working_dir: /var/www/html
volumes:
@@ -156,7 +147,6 @@ services:
# Mailpit for local email testing
mailpit:
image: axllent/mailpit
container_name: laravel_mailpit
restart: unless-stopped
ports:
- "${MAIL_PORT:-1025}:1025"
@@ -167,6 +157,8 @@ services:
networks:
laravel_network:
driver: bridge
gateway:
external: true
volumes:
mysql_data:

6
docker/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
src/node_modules
src/vendor
.git
.env
*.log

View File

@@ -38,6 +38,11 @@ RUN pecl install redis && docker-php-ext-enable redis
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Install Node.js for asset building
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Create system user to run Composer and Artisan commands
RUN useradd -G www-data,root -u 1000 -d /home/devuser devuser
RUN mkdir -p /home/devuser/.composer && \
@@ -61,6 +66,13 @@ RUN chown -R devuser:www-data /var/www/html \
&& chmod -R 775 /var/www/html/storage 2>/dev/null || true \
&& chmod -R 775 /var/www/html/bootstrap/cache 2>/dev/null || true
# Build frontend assets (if package.json exists)
RUN if [ -f "package.json" ]; then \
npm ci --ignore-scripts && \
npm run build && \
rm -rf node_modules; \
fi
# Run post-install scripts
USER devuser
RUN composer run-script post-autoload-dump 2>/dev/null || true

View File

@@ -191,7 +191,7 @@ composer install --no-dev --optimize-autoloader
php artisan migrate --force
# Build assets
npm ci
npm install
npm run build
# Clear caches

View File

@@ -1,5 +1,7 @@
# Filament Admin Panel
> **🤖 AI Agents**: For Filament form/table component syntax, see [CLAUDE.md](../CLAUDE.md#-filament-form-field-reference).
This template includes optional [Filament](https://filamentphp.com/) admin panel setup for user management and administration.
## What is Filament?

130
docs/menu-management.md Normal file
View File

@@ -0,0 +1,130 @@
# Frontend Menu Management
This template includes a dynamic menu management system that allows administrators to create and manage frontend navigation menus through the admin panel.
## Features
- **Dynamic Menus**: Create multiple menus (header, footer, sidebar, etc.)
- **Nested Items**: Support for parent-child menu items (dropdowns)
- **Multiple Link Types**:
- External links (URLs)
- Named routes
- Module links (auto-generates route from module slug)
- **Permission-Based Filtering**: Menu items are automatically filtered based on user permissions
- **Drag & Drop Reordering**: Reorder menu items in the admin panel
## How It Works
### Permission Filtering
Menu items are automatically filtered based on the current user's permissions:
1. **Module Items**: If a menu item links to a module (e.g., `companies`), the user must have `{module}.view` permission (e.g., `companies.view`) to see that item.
2. **Permission Items**: If a menu item has a specific `permission` set, the user must have that exact permission.
3. **Admin Users**: Users with the `admin` role can see all menu items.
4. **Guest Users**: Only see items with no permission or module restrictions.
## Usage
### In Blade Templates
Use the `<x-frontend-menu>` component to render a menu:
```blade
{{-- Desktop navigation --}}
<x-frontend-menu menu="header" />
{{-- Mobile/responsive navigation --}}
<x-frontend-menu-responsive menu="header" />
{{-- By location --}}
<x-frontend-menu menu="footer" />
```
### Programmatic Access
```php
use App\Services\MenuService;
$menuService = app(MenuService::class);
// Get menu items (already filtered for current user)
$items = $menuService->getMenu('header');
// Get available modules for menu items
$modules = $menuService->getAvailableModules();
// Get available routes for menu items
$routes = $menuService->getAvailableRoutes();
```
## Admin Panel
Navigate to **Settings > Menus** in the admin panel to:
1. Create new menus
2. Add/edit/delete menu items
3. Set item types (link, route, module)
4. Configure permissions for each item
5. Reorder items via drag & drop
## Database Structure
### `menus` table
- `id` - Primary key
- `name` - Display name
- `slug` - Unique identifier (used in templates)
- `location` - Optional location hint (header, footer, sidebar)
- `is_active` - Toggle menu visibility
### `menu_items` table
- `id` - Primary key
- `menu_id` - Foreign key to menus
- `parent_id` - For nested items
- `title` - Display text
- `type` - link, route, or module
- `url` - For external links
- `route` - For named routes
- `route_params` - JSON parameters for routes
- `module` - Module slug for permission checking
- `permission` - Specific permission required
- `icon` - Heroicon name
- `target` - _self or _blank
- `order` - Sort order
- `is_active` - Toggle visibility
## Adding Menu Items for New Modules
When creating a new module, add a menu item to link to it:
1. Go to **Settings > Menus** in admin
2. Edit the "Header Navigation" menu
3. Click "New" in the Items section
4. Set:
- **Title**: Your module name
- **Type**: Module
- **Module**: Select your module from dropdown
5. Save
The menu item will automatically:
- Generate the correct URL (`/{module-slug}`)
- Check for `{module}.view` permission
- Hide from users without permission
## Customization
### Custom Menu Component
Copy and modify the default component:
```bash
cp resources/views/components/frontend-menu.blade.php \
resources/views/components/my-custom-menu.blade.php
```
### Styling
The default components use Tailwind CSS classes. Modify the component templates to match your design.

173
docs/module-generator.md Normal file
View File

@@ -0,0 +1,173 @@
# Module Generator (Admin UI)
A visual tool for creating module skeletons through the Filament admin panel.
> **Development Only**: This tool is only available when `APP_ENV=local`.
## Access
Navigate to: **Admin Panel → Development → Module Generator**
URL: `/admin/module-generator`
## Features
- **Visual Form**: Create modules without command line
- **Auto Git Branch**: Optionally creates `module/{name}` branch and commits files
- **Skeleton Only**: Maximum flexibility - creates structure, you add the models
- **Instant Feedback**: Shows generation logs and next steps
## What Gets Created
```
app/Modules/{ModuleName}/
├── Config/{module_name}.php # Module configuration
├── Database/
│ ├── Migrations/ # Empty, add your migrations
│ └── Seeders/ # Empty, add your seeders
├── Filament/Resources/ # Empty, add Filament resources
├── Http/
│ ├── Controllers/{ModuleName}Controller.php
│ ├── Middleware/ # Empty
│ └── Requests/ # Empty
├── Models/ # Empty, add your models
├── Policies/ # Empty, add policies
├── Services/ # Empty, add services
├── Routes/
│ ├── web.php # Basic index route
│ └── api.php # If API option selected
├── Resources/views/
│ └── index.blade.php # Starter view
├── Permissions.php # CRUD permissions
├── {ModuleName}ServiceProvider.php # Auto-registered
└── README.md # Module documentation
```
## Form Options
| Field | Description |
|-------|-------------|
| **Module Name** | PascalCase name (e.g., `Accounting`, `Inventory`) |
| **Description** | Brief description for README and config |
| **Create Git Branch** | Auto-create `module/{name}` branch and commit |
| **Include API Routes** | Add `Routes/api.php` with Sanctum auth |
## Git Integration
When "Create Git Branch" is enabled:
1. **Checks** for uncommitted changes (fails if dirty)
2. **Creates** branch `module/{module-name}`
3. **Generates** all module files
4. **Commits** with message `feat: Add {ModuleName} module skeleton`
5. **Shows** push command: `git push -u origin module/{name}`
### Requirements
- Working directory must be clean (no uncommitted changes)
- Git must be installed in the container
- Repository must be initialized
## After Generation
1. **Run migrations** (if you add any):
```bash
php artisan migrate
```
2. **Seed permissions**:
```bash
php artisan db:seed --class=RolePermissionSeeder
```
3. **Clear caches**:
```bash
php artisan optimize:clear
```
4. **Push branch** (if Git was used):
```bash
git push -u origin module/{name}
```
## Adding Models
After generating the skeleton, add models manually:
```php
// app/Modules/Accounting/Models/Invoice.php
<?php
namespace App\Modules\Accounting\Models;
use App\Traits\ModuleAuditable;
use Illuminate\Database\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;
class Invoice extends Model implements Auditable
{
use ModuleAuditable;
protected $fillable = [
'number',
'customer_id',
'total',
'status',
];
}
```
## Adding Filament Resources
Create admin CRUD in `Filament/Resources/`:
```php
// app/Modules/Accounting/Filament/Resources/InvoiceResource.php
<?php
namespace App\Modules\Accounting\Filament\Resources;
use App\Modules\Accounting\Models\Invoice;
use Filament\Resources\Resource;
// ... standard Filament resource implementation
```
## Comparison: UI vs CLI
| Feature | UI Generator | `make:module` CLI |
|---------|--------------|-------------------|
| Skeleton only | ✅ | ❌ (includes model) |
| Git integration | ✅ Auto-branch | ❌ Manual |
| Model generation | ❌ | ✅ With `--model=` |
| Filament resource | ❌ | ✅ With `--model=` |
| Visual feedback | ✅ | Terminal output |
| Environment | Local only | Any |
**Use UI Generator when**: You need a blank canvas for complex modules with multiple models.
**Use CLI when**: You want a quick single-model module with all files generated.
## Troubleshooting
### "Git working directory has uncommitted changes"
Commit or stash your changes first:
```bash
git add . && git commit -m "WIP"
# or
git stash
```
### Module not showing in admin
Clear caches:
```bash
php artisan optimize:clear
```
### Permissions not working
Re-seed permissions:
```bash
php artisan db:seed --class=RolePermissionSeeder
```

View File

@@ -1,5 +1,8 @@
# Modular Architecture
> **🤖 AI Agents**: For EXACT file templates and copy-paste code, see [CLAUDE.md](../CLAUDE.md).
> This document explains concepts; CLAUDE.md provides strict implementation patterns.
This template uses a modular architecture to organize features into self-contained modules. Each module has its own admin panel, routes, views, and permissions.
## Quick Start

View File

@@ -114,12 +114,15 @@ echo Configuring environment...
if exist "src\.env.%DB%" (
copy /y "src\.env.%DB%" "src\.env" >nul
REM Append port configurations to .env
echo APP_PORT=%APP_PORT% >> src\.env
echo MAIL_DASHBOARD_PORT=%MAIL_DASHBOARD_PORT% >> src\.env
echo MAIL_PORT=%MAIL_PORT% >> src\.env
echo REDIS_PORT=%REDIS_PORT% >> src\.env
if defined DB_PORT echo DB_PORT=%DB_PORT% >> src\.env
REM Append port configurations to .env (for reference only, not used by Laravel)
REM Note: DB_PORT is NOT appended - Laravel uses internal Docker port (3306/5432)
REM These are for docker-compose external port mapping only
echo. >> src\.env
echo # Port Configuration ^(auto-assigned by setup^) >> src\.env
echo # APP_PORT=%APP_PORT% >> src\.env
echo # MAIL_DASHBOARD_PORT=%MAIL_DASHBOARD_PORT% >> src\.env
echo # MAIL_PORT=%MAIL_PORT% >> src\.env
echo # REDIS_PORT=%REDIS_PORT% >> src\.env
echo Environment configured for %DB%
) else (
@@ -147,15 +150,41 @@ echo.
REM Step 6: Wait for database
if "%DB%"=="mysql" (
echo Waiting for database...
timeout /t 5 /nobreak >nul
echo Database ready
echo Waiting for MySQL to be ready...
set DB_READY=0
for /L %%i in (1,1,30) do (
docker-compose exec -T app php -r "new PDO('mysql:host=mysql;dbname=laravel', 'laravel', 'secret');" >nul 2>&1
if !errorlevel! equ 0 (
set DB_READY=1
goto :mysql_ready
)
timeout /t 1 /nobreak >nul
)
:mysql_ready
if !DB_READY! equ 1 (
echo MySQL ready
) else (
echo WARNING: MySQL may not be fully ready yet
)
echo.
)
if "%DB%"=="pgsql" (
echo Waiting for database...
timeout /t 5 /nobreak >nul
echo Database ready
echo Waiting for PostgreSQL to be ready...
set DB_READY=0
for /L %%i in (1,1,30) do (
docker-compose exec -T app php -r "new PDO('pgsql:host=pgsql;dbname=laravel', 'laravel', 'secret');" >nul 2>&1
if !errorlevel! equ 0 (
set DB_READY=1
goto :pgsql_ready
)
timeout /t 1 /nobreak >nul
)
:pgsql_ready
if !DB_READY! equ 1 (
echo PostgreSQL ready
) else (
echo WARNING: PostgreSQL may not be fully ready yet
)
echo.
)
@@ -183,6 +212,13 @@ docker-compose exec -T app php artisan storage:link
echo Storage linked
echo.
REM Step 11: Build frontend assets
echo Building frontend assets...
docker-compose run --rm node npm install >nul 2>&1
docker-compose run --rm node npm run build >nul 2>&1
echo Frontend assets built
echo.
REM Done
echo.
echo ========================================================

View File

@@ -106,12 +106,15 @@ echo -e "${YELLOW}→ Configuring environment...${NC}"
if [ -f "src/.env.${DB}" ]; then
cp "src/.env.${DB}" "src/.env"
# Append port configurations to .env
echo "APP_PORT=$APP_PORT" >> src/.env
echo "MAIL_DASHBOARD_PORT=$MAIL_DASHBOARD_PORT" >> src/.env
echo "MAIL_PORT=$MAIL_PORT" >> src/.env
echo "REDIS_PORT=$REDIS_PORT" >> src/.env
[ -n "$DB_PORT" ] && echo "DB_PORT=$DB_PORT" >> src/.env
# Create .env in project root for docker compose port mapping
echo "# Docker Compose Port Configuration (auto-assigned by setup)" > .env
echo "APP_PORT=$APP_PORT" >> .env
echo "MAIL_DASHBOARD_PORT=$MAIL_DASHBOARD_PORT" >> .env
echo "MAIL_PORT=$MAIL_PORT" >> .env
echo "REDIS_PORT=$REDIS_PORT" >> .env
if [ -n "$DB_PORT" ]; then
echo "DB_PORT=$DB_PORT" >> .env
fi
echo -e "${GREEN}✓ Environment configured for ${DB}${NC}"
else
@@ -121,13 +124,13 @@ fi
# Step 3: Build containers
echo -e "${YELLOW}→ Building Docker containers...${NC}"
docker-compose build
docker compose build
echo -e "${GREEN}✓ Containers built${NC}"
echo ""
# Step 4: Start containers
echo -e "${YELLOW}→ Starting Docker containers...${NC}"
docker-compose --profile ${DB} up -d
docker compose --profile ${DB} up -d
echo -e "${GREEN}✓ Containers started${NC}"
echo ""
@@ -137,38 +140,69 @@ sleep 3
echo -e "${GREEN}✓ App container ready${NC}"
echo ""
# Step 5.5: Fix permissions on storage, bootstrap/cache, public, and .env
echo -e "${YELLOW}→ Setting directory permissions...${NC}"
chmod -R 777 src/storage src/bootstrap/cache src/public 2>/dev/null || true
chmod 666 src/.env 2>/dev/null || true
docker compose exec -T app chmod -R 777 storage bootstrap/cache public 2>/dev/null || true
docker compose exec -T app chmod 666 .env 2>/dev/null || true
echo -e "${GREEN}✓ Permissions set${NC}"
echo ""
# Step 6: Wait for database
if [ "$DB" = "mysql" ] || [ "$DB" = "pgsql" ]; then
echo -e "${YELLOW}→ Waiting for database...${NC}"
sleep 5
echo -e "${GREEN}✓ Database ready${NC}"
if [ "$DB" = "mysql" ]; then
echo -e "${YELLOW}→ Waiting for MySQL to be ready...${NC}"
for i in {1..30}; do
if docker compose exec -T app php -r "new PDO('mysql:host=mysql;dbname=laravel', 'laravel', 'secret');" >/dev/null 2>&1; then
echo -e "${GREEN}✓ MySQL ready${NC}"
break
fi
sleep 1
done
echo ""
elif [ "$DB" = "pgsql" ]; then
echo -e "${YELLOW}→ Waiting for PostgreSQL to be ready...${NC}"
for i in {1..30}; do
if docker compose exec -T app php -r "new PDO('pgsql:host=pgsql;dbname=laravel', 'laravel', 'secret');" >/dev/null 2>&1; then
echo -e "${GREEN}✓ PostgreSQL ready${NC}"
break
fi
sleep 1
done
echo ""
fi
# Step 7: Generate app key
echo -e "${YELLOW}→ Generating application key...${NC}"
docker-compose exec app php artisan key:generate --force
docker compose exec app php artisan key:generate --force
echo -e "${GREEN}✓ App key generated${NC}"
echo ""
# Step 8: Run migrations
echo -e "${YELLOW}→ Running database migrations...${NC}"
docker-compose exec app php artisan migrate --force
docker compose exec app php artisan migrate --force
echo -e "${GREEN}✓ Migrations completed${NC}"
echo ""
# Step 9: Seed database (roles, permissions, admin user)
echo -e "${YELLOW}→ Seeding database (roles, permissions, admin user)...${NC}"
docker-compose exec app php artisan db:seed --force
docker compose exec app php artisan db:seed --force
echo -e "${GREEN}✓ Database seeded${NC}"
echo ""
# Step 10: Create storage link
echo -e "${YELLOW}→ Creating storage symlink...${NC}"
docker-compose exec app php artisan storage:link
docker compose exec app php artisan storage:link
echo -e "${GREEN}✓ Storage linked${NC}"
echo ""
# Step 11: Build frontend assets
echo -e "${YELLOW}→ Building frontend assets...${NC}"
docker compose run --rm node npm install >/dev/null 2>&1
docker compose run --rm node npm run build >/dev/null 2>&1
echo -e "${GREEN}✓ Frontend assets built${NC}"
echo ""
# Done!
echo ""
echo -e "${GREEN}╔════════════════════════════════════════════════════════╗${NC}"
@@ -193,11 +227,11 @@ echo -e " ✓ Laravel Pint code style"
echo -e " ✓ Queue workers & scheduler (optional profiles)"
echo ""
echo -e "${YELLOW}Common Commands:${NC}"
echo -e " docker-compose exec app php artisan <command>"
echo -e " docker-compose exec app composer <command>"
echo -e " docker-compose exec app ./vendor/bin/pest"
echo -e " docker-compose logs -f app"
echo -e " docker compose exec app php artisan <command>"
echo -e " docker compose exec app composer <command>"
echo -e " docker compose exec app ./vendor/bin/pest"
echo -e " docker compose logs -f app"
echo ""
echo -e "${YELLOW}Stop containers:${NC}"
echo -e " docker-compose down"
echo -e " docker compose down"
echo ""

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
class MakeAdminCommand extends Command
{
protected $signature = 'make:admin
{email : The email address for the admin user}
{--name= : The name for the admin user}
{--password= : The password (will prompt if not provided)}';
protected $description = 'Create a new admin user with full permissions';
public function handle(): int
{
$email = $this->argument('email');
$name = $this->option('name') ?? $this->ask('Enter admin name', 'Admin');
$password = $this->option('password') ?? $this->secret('Enter password');
// Validate email
$validator = Validator::make(
['email' => $email],
['email' => 'required|email|unique:users,email']
);
if ($validator->fails()) {
$this->error('Validation failed:');
foreach ($validator->errors()->all() as $error) {
$this->error(" - {$error}");
}
return Command::FAILURE;
}
// Validate password
if (strlen($password) < 8) {
$this->error('Password must be at least 8 characters.');
return Command::FAILURE;
}
// Create user
$user = User::create([
'name' => $name,
'email' => $email,
'password' => Hash::make($password),
'email_verified_at' => now(),
]);
// Assign admin role
$user->assignRole('admin');
$this->info("✅ Admin user created successfully!");
$this->table(
['Field', 'Value'],
[
['Name', $name],
['Email', $email],
['Role', 'admin'],
]
);
$this->newLine();
$this->info("Login at: /admin");
return Command::SUCCESS;
}
}

View File

@@ -8,82 +8,322 @@
class MakeModuleCommand extends Command
{
protected $signature = 'make:module {name : The name of the module}';
protected $signature = 'make:module
{name : The name of the module (PascalCase)}
{--model= : Create a model with the given name}
{--api : Include API routes}
{--no-filament : Skip Filament resource generation}';
protected $description = 'Create a new module with standard structure';
protected $description = 'Create a new module with full structure (ServiceProvider, Config, Routes, Views, Permissions)';
protected string $studlyName;
protected string $kebabName;
protected string $snakeName;
protected string $modulePath;
protected ?string $modelName;
public function handle(): int
{
$name = $this->argument('name');
$studlyName = Str::studly($name);
$kebabName = Str::kebab($name);
$this->studlyName = Str::studly($name);
$this->kebabName = Str::kebab($name);
$this->snakeName = Str::snake($name);
$this->modelName = $this->option('model') ? Str::studly($this->option('model')) : null;
$modulePath = app_path("Modules/{$studlyName}");
$this->modulePath = app_path("Modules/{$this->studlyName}");
if (File::exists($modulePath)) {
$this->error("Module {$studlyName} already exists!");
if (File::exists($this->modulePath)) {
$this->error("Module {$this->studlyName} already exists!");
return self::FAILURE;
}
$this->info("Creating module: {$studlyName}");
$this->info("Creating module: {$this->studlyName}");
$this->newLine();
$this->createDirectories($modulePath);
$this->createModel($modulePath, $studlyName);
$this->createMigration($studlyName);
$this->createController($modulePath, $studlyName);
$this->createRoutes($modulePath, $studlyName, $kebabName);
$this->createViews($modulePath, $studlyName, $kebabName);
$this->createFilamentResource($studlyName);
$this->createTests($studlyName);
$this->createDirectoryStructure();
$this->createServiceProvider();
$this->createConfig();
$this->createPermissions();
$this->createController();
$this->createRoutes();
$this->createViews();
$this->info("\n✅ Module {$studlyName} created successfully!");
$this->info("\nNext steps:");
$this->info("1. Run migrations: php artisan migrate");
$this->info("2. Register routes in routes/web.php:");
$this->info(" require __DIR__.'/../app/Modules/{$studlyName}/routes.php';");
$this->info("3. Access at: /{$kebabName}");
if ($this->modelName) {
$this->createModel();
$this->createMigration();
if (!$this->option('no-filament')) {
$this->createFilamentResource();
}
}
$this->createTests();
$this->registerServiceProvider();
$this->newLine();
$this->info("✅ Module {$this->studlyName} created successfully!");
$this->newLine();
$this->warn("Next steps:");
$this->line(" 1. Run migrations: <info>php artisan migrate</info>");
$this->line(" 2. Seed permissions: <info>php artisan db:seed --class=RolePermissionSeeder</info>");
$this->line(" 3. Clear caches: <info>php artisan optimize:clear</info>");
$this->newLine();
$this->line(" Access at:");
$this->line(" - Frontend: <info>/{$this->kebabName}</info>");
if ($this->modelName && !$this->option('no-filament')) {
$this->line(" - Admin: <info>/admin → {$this->studlyName}</info>");
}
if ($this->option('api')) {
$this->line(" - API: <info>/api/{$this->kebabName}</info>");
}
return self::SUCCESS;
}
protected function createDirectories(string $path): void
protected function createDirectoryStructure(): void
{
File::makeDirectory($path, 0755, true);
File::makeDirectory("{$path}/Models", 0755, true);
File::makeDirectory("{$path}/Controllers", 0755, true);
File::makeDirectory("{$path}/Views", 0755, true);
$directories = [
'',
'/Config',
'/Database/Migrations',
'/Database/Seeders',
'/Filament/Resources',
'/Http/Controllers',
'/Http/Middleware',
'/Http/Requests',
'/Models',
'/Policies',
'/Services',
'/Routes',
'/Resources/views',
];
foreach ($directories as $dir) {
File::makeDirectory("{$this->modulePath}{$dir}", 0755, true);
}
protected function createModel(string $path, string $name): void
$this->info("✓ Created directory structure");
}
protected function createServiceProvider(): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$name}\Models;
namespace App\Modules\\{$this->studlyName};
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class {$name} extends Model
class {$this->studlyName}ServiceProvider extends ServiceProvider
{
use HasFactory;
public function register(): void
{
\$this->mergeConfigFrom(
__DIR__ . '/Config/{$this->snakeName}.php',
'{$this->snakeName}'
);
}
public function boot(): void
{
\$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
\$this->loadViewsFrom(__DIR__ . '/Resources/views', '{$this->kebabName}');
\$this->registerRoutes();
\$this->registerPermissions();
}
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('{$this->kebabName}')
->name('{$this->kebabName}.')
->group(__DIR__ . '/Routes/web.php');
if (file_exists(__DIR__ . '/Routes/api.php')) {
Route::middleware(['api', 'auth:sanctum'])
->prefix('api/{$this->kebabName}')
->name('api.{$this->kebabName}.')
->group(__DIR__ . '/Routes/api.php');
}
}
protected function registerPermissions(): void
{
// Permissions are registered via RolePermissionSeeder
// See: Permissions.php in this module
}
}
PHP;
File::put("{$this->modulePath}/{$this->studlyName}ServiceProvider.php", $stub);
$this->info("✓ Created ServiceProvider");
}
protected function createConfig(): void
{
$stub = <<<PHP
<?php
return [
'name' => '{$this->studlyName}',
'slug' => '{$this->kebabName}',
'version' => '1.0.0',
'audit' => [
'enabled' => true,
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
'include' => [],
'exclude' => [],
],
];
PHP;
File::put("{$this->modulePath}/Config/{$this->snakeName}.php", $stub);
$this->info("✓ Created Config");
}
protected function createPermissions(): void
{
$stub = <<<PHP
<?php
return [
'{$this->snakeName}.view' => 'View {$this->studlyName}',
'{$this->snakeName}.create' => 'Create {$this->studlyName} records',
'{$this->snakeName}.edit' => 'Edit {$this->studlyName} records',
'{$this->snakeName}.delete' => 'Delete {$this->studlyName} records',
];
PHP;
File::put("{$this->modulePath}/Permissions.php", $stub);
$this->info("✓ Created Permissions");
}
protected function createController(): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class {$this->studlyName}Controller extends Controller
{
public function index()
{
\$this->authorize('{$this->snakeName}.view');
return view('{$this->kebabName}::index');
}
}
PHP;
File::put("{$this->modulePath}/Http/Controllers/{$this->studlyName}Controller.php", $stub);
$this->info("✓ Created Controller");
}
protected function createRoutes(): void
{
// Web routes
$webStub = <<<PHP
<?php
use App\Modules\\{$this->studlyName}\Http\Controllers\\{$this->studlyName}Controller;
use Illuminate\Support\Facades\Route;
Route::get('/', [{$this->studlyName}Controller::class, 'index'])->name('index');
PHP;
File::put("{$this->modulePath}/Routes/web.php", $webStub);
// API routes (if requested)
if ($this->option('api')) {
$apiStub = <<<PHP
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
// API routes here
});
PHP;
File::put("{$this->modulePath}/Routes/api.php", $apiStub);
}
$this->info("✓ Created Routes" . ($this->option('api') ? ' (web + api)' : ''));
}
protected function createViews(): void
{
$stub = <<<BLADE
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('{$this->studlyName}') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
{{ __('{$this->studlyName} module is working!') }}
</div>
</div>
</div>
</div>
</x-app-layout>
BLADE;
File::put("{$this->modulePath}/Resources/views/index.blade.php", $stub);
$this->info("✓ Created Views");
}
protected function createModel(): void
{
$tableName = Str::snake(Str::plural($this->modelName));
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Models;
use App\Traits\ModuleAuditable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use OwenIt\Auditing\Contracts\Auditable;
class {$this->modelName} extends Model implements Auditable
{
use HasFactory, ModuleAuditable;
protected \$table = '{$tableName}';
protected \$fillable = [
'name',
'description',
];
protected \$casts = [
//
];
}
PHP;
File::put("{$path}/Models/{$name}.php", $stub);
$this->info("✓ Created Model: {$name}");
File::put("{$this->modulePath}/Models/{$this->modelName}.php", $stub);
$this->info("✓ Created Model: {$this->modelName}");
}
protected function createMigration(string $name): void
protected function createMigration(): void
{
$table = Str::snake(Str::plural($name));
$tableName = Str::snake(Str::plural($this->modelName));
$timestamp = date('Y_m_d_His');
$migrationName = "create_{$table}_table";
$migrationName = "create_{$tableName}_table";
$stub = <<<PHP
<?php
@@ -96,7 +336,7 @@ protected function createMigration(string $name): void
{
public function up(): void
{
Schema::create('{$table}', function (Blueprint \$table) {
Schema::create('{$tableName}', function (Blueprint \$table) {
\$table->id();
\$table->string('name');
\$table->text('description')->nullable();
@@ -106,231 +346,252 @@ public function up(): void
public function down(): void
{
Schema::dropIfExists('{$table}');
Schema::dropIfExists('{$tableName}');
}
};
PHP;
$migrationPath = database_path("migrations/{$timestamp}_{$migrationName}.php");
$migrationPath = "{$this->modulePath}/Database/Migrations/{$timestamp}_{$migrationName}.php";
File::put($migrationPath, $stub);
$this->info("✓ Created Migration: {$migrationName}");
}
protected function createController(string $path, string $name): void
protected function createFilamentResource(): void
{
$plural = Str::plural($name);
$variable = Str::camel($name);
$pluralVariable = Str::camel($plural);
$modelClass = "App\\Modules\\{$this->studlyName}\\Models\\{$this->modelName}";
$tableName = Str::snake(Str::plural($this->modelName));
$stub = <<<PHP
// Create the resource
$resourceStub = <<<PHP
<?php
namespace App\Modules\\{$name}\Controllers;
namespace App\Modules\\{$this->studlyName}\Filament\Resources;
use App\Http\Controllers\Controller;
use App\Modules\\{$name}\Models\\{$name};
use Illuminate\Http\Request;
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
use App\Modules\\{$this->studlyName}\Models\\{$this->modelName};
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class {$name}Controller extends Controller
class {$this->modelName}Resource extends Resource
{
public function index()
protected static ?string \$model = {$this->modelName}::class;
protected static ?string \$navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string \$navigationGroup = '{$this->studlyName}';
protected static ?int \$navigationSort = 1;
public static function canAccess(): bool
{
\${$pluralVariable} = {$name}::latest()->paginate(15);
return view('{$name}::index', compact('{$pluralVariable}'));
return auth()->user()?->can('{$this->snakeName}.view') ?? false;
}
public function create()
public static function form(Form \$form): Form
{
return view('{$name}::create');
}
public function store(Request \$request)
{
\$validated = \$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
return \$form
->schema([
Forms\Components\Section::make('{$this->modelName} Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
Forms\Components\Textarea::make('description')
->rows(3),
]),
]);
{$name}::create(\$validated);
return redirect()->route('{$variable}.index')
->with('success', '{$name} created successfully.');
}
public function show({$name} \${$variable})
public static function table(Table \$table): Table
{
return view('{$name}::show', compact('{$variable}'));
}
public function edit({$name} \${$variable})
{
return view('{$name}::edit', compact('{$variable}'));
}
public function update(Request \$request, {$name} \${$variable})
{
\$validated = \$request->validate([
'name' => 'required|string|max:255',
'description' => 'nullable|string',
return \$table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->limit(50),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
\${$variable}->update(\$validated);
return redirect()->route('{$variable}.index')
->with('success', '{$name} updated successfully.');
}
public function destroy({$name} \${$variable})
public static function getRelations(): array
{
\${$variable}->delete();
return [
//
];
}
return redirect()->route('{$variable}.index')
->with('success', '{$name} deleted successfully.');
public static function getPages(): array
{
return [
'index' => Pages\List{$this->modelName}s::route('/'),
'create' => Pages\Create{$this->modelName}::route('/create'),
'edit' => Pages\Edit{$this->modelName}::route('/{record}/edit'),
];
}
}
PHP;
File::put("{$path}/Controllers/{$name}Controller.php", $stub);
$this->info("✓ Created Controller: {$name}Controller");
}
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource.php", $resourceStub);
protected function createRoutes(string $path, string $name, string $kebabName): void
{
$variable = Str::camel($name);
// Create Pages directory
File::makeDirectory("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages", 0755, true);
$stub = <<<PHP
// Create List page
$listStub = <<<PHP
<?php
use Illuminate\Support\Facades\Route;
use App\Modules\\{$name}\Controllers\\{$name}Controller;
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
Route::middleware(['web', 'auth'])->group(function () {
Route::resource('{$kebabName}', {$name}Controller::class)->names('{$variable}');
});
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class List{$this->modelName}s extends ListRecords
{
protected static string \$resource = {$this->modelName}Resource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}
PHP;
File::put("{$path}/routes.php", $stub);
$this->info("✓ Created Routes");
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/List{$this->modelName}s.php", $listStub);
// Create Create page
$createStub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
use Filament\Resources\Pages\CreateRecord;
class Create{$this->modelName} extends CreateRecord
{
protected static string \$resource = {$this->modelName}Resource::class;
}
PHP;
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/Create{$this->modelName}.php", $createStub);
// Create Edit page
$editStub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource\Pages;
use App\Modules\\{$this->studlyName}\Filament\Resources\\{$this->modelName}Resource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class Edit{$this->modelName} extends EditRecord
{
protected static string \$resource = {$this->modelName}Resource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}
PHP;
File::put("{$this->modulePath}/Filament/Resources/{$this->modelName}Resource/Pages/Edit{$this->modelName}.php", $editStub);
$this->info("✓ Created Filament Resource: {$this->modelName}Resource");
}
protected function createViews(string $path, string $name, string $kebabName): void
protected function createTests(): void
{
$plural = Str::plural($name);
$variable = Str::camel($name);
$pluralVariable = Str::camel($plural);
$indexView = <<<BLADE
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('{$plural}') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900">
<div class="flex justify-between mb-4">
<h3 class="text-lg font-semibold">{$plural} List</h3>
<a href="{{ route('{$variable}.create') }}" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Create New
</a>
</div>
@if(session('success'))
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
{{ session('success') }}
</div>
@endif
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@forelse(\${$pluralVariable} as \${$variable})
<tr>
<td class="px-6 py-4">{{ \${$variable}->name }}</td>
<td class="px-6 py-4">{{ \${$variable}->description }}</td>
<td class="px-6 py-4">
<a href="{{ route('{$variable}.edit', \${$variable}) }}" class="text-blue-600 hover:text-blue-900">Edit</a>
<form action="{{ route('{$variable}.destroy', \${$variable}) }}" method="POST" class="inline">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:text-red-900 ml-2">Delete</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="px-6 py-4 text-center">No {$pluralVariable} found.</td>
</tr>
@endforelse
</tbody>
</table>
<div class="mt-4">
{{ \${$pluralVariable}->links() }}
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
BLADE;
File::put("{$path}/Views/index.blade.php", $indexView);
$this->info("✓ Created Views");
}
protected function createFilamentResource(string $name): void
{
$this->call('make:filament-resource', [
'name' => $name,
'--generate' => true,
]);
}
protected function createTests(string $name): void
{
$variable = Str::camel($name);
$testPath = base_path("tests/Feature/Modules/{$this->studlyName}");
File::makeDirectory($testPath, 0755, true);
$stub = <<<PHP
<?php
use App\Modules\\{$name}\Models\\{$name};
namespace Tests\Feature\Modules\\{$this->studlyName};
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
it('can list {$variable}s', function () {
class {$this->studlyName}Test extends TestCase
{
use RefreshDatabase;
public function test_user_can_view_{$this->snakeName}_index(): void
{
\$user = User::factory()->create();
{$name}::factory()->count(3)->create();
\$user->givePermissionTo('{$this->snakeName}.view');
\$response = \$this->actingAs(\$user)->get(route('{$variable}.index'));
\$response = \$this->actingAs(\$user)
->get('/{$this->kebabName}');
\$response->assertStatus(200);
});
}
it('can create {$variable}', function () {
public function test_unauthorized_user_cannot_view_{$this->snakeName}(): void
{
\$user = User::factory()->create();
\$response = \$this->actingAs(\$user)->post(route('{$variable}.store'), [
'name' => 'Test {$name}',
'description' => 'Test description',
]);
\$response = \$this->actingAs(\$user)
->get('/{$this->kebabName}');
\$response->assertRedirect(route('{$variable}.index'));
\$this->assertDatabaseHas('{$variable}s', ['name' => 'Test {$name}']);
});
PHP;
$testPath = base_path("tests/Modules/{$name}");
File::makeDirectory($testPath, 0755, true);
File::put("{$testPath}/{$name}Test.php", $stub);
$this->info("✓ Created Tests");
\$response->assertStatus(403);
}
}
PHP;
File::put("{$testPath}/{$this->studlyName}Test.php", $stub);
$this->info("✓ Created Tests");
}
protected function registerServiceProvider(): void
{
$providersPath = base_path('bootstrap/providers.php');
if (File::exists($providersPath)) {
$content = File::get($providersPath);
$providerClass = "App\\Modules\\{$this->studlyName}\\{$this->studlyName}ServiceProvider::class";
if (!str_contains($content, $providerClass)) {
// Add the provider before the closing bracket
$content = preg_replace(
'/(\];)/',
" {$providerClass},\n$1",
$content
);
File::put($providersPath, $content);
$this->info("✓ Registered ServiceProvider in bootstrap/providers.php");
}
} else {
$this->warn("⚠ Could not auto-register ServiceProvider. Add manually to config/app.php:");
$this->line(" App\\Modules\\{$this->studlyName}\\{$this->studlyName}ServiceProvider::class");
}
}
}

View File

@@ -0,0 +1,186 @@
<?php
namespace App\Filament\Pages;
use App\Services\ModuleDiscoveryService;
use App\Services\ModuleGeneratorService;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Pages\Page;
use Illuminate\Support\Str;
class ModuleGenerator extends Page implements HasForms
{
use InteractsWithForms;
protected static ?string $navigationIcon = 'heroicon-o-cube';
protected static ?string $navigationLabel = 'Module Generator';
protected static ?string $navigationGroup = 'Development';
protected static ?int $navigationSort = 100;
protected static string $view = 'filament.pages.module-generator';
public ?array $data = [];
public ?array $result = null;
public array $modules = [];
public array $summary = [];
public ?string $expandedModule = null;
public static function canAccess(): bool
{
// Only available in local environment
return app()->environment('local');
}
public static function shouldRegisterNavigation(): bool
{
return app()->environment('local');
}
public function mount(): void
{
$this->form->fill([
'create_git_branch' => true,
'include_api' => false,
]);
$this->loadModules();
}
public function loadModules(): void
{
$discovery = new ModuleDiscoveryService();
$this->modules = $discovery->discoverModules();
$this->summary = $discovery->getModuleSummary();
}
public function toggleModule(string $moduleName): void
{
$this->expandedModule = $this->expandedModule === $moduleName ? null : $moduleName;
}
public function form(Form $form): Form
{
return $form
->schema([
Section::make('Module Details')
->description('Create a new module skeleton with all the necessary folders and files.')
->schema([
TextInput::make('name')
->label('Module Name')
->placeholder('e.g., Accounting, Inventory, HumanResources')
->helperText('Use PascalCase. This will create app/Modules/YourModuleName/')
->required()
->maxLength(50)
->rules(['regex:/^[A-Z][a-zA-Z0-9]*$/'])
->validationMessages([
'regex' => 'Module name must be PascalCase (start with uppercase, letters and numbers only).',
])
->live(onBlur: true)
->afterStateUpdated(fn ($state, callable $set) =>
$set('name', Str::studly($state))
),
Textarea::make('description')
->label('Description')
->placeholder('Brief description of what this module does...')
->rows(2)
->maxLength(255),
]),
Section::make('Options')
->schema([
Toggle::make('create_git_branch')
->label('Create Git Branch')
->helperText('Automatically create and checkout a new branch: module/{module-name}')
->default(true),
Toggle::make('include_api')
->label('Include API Routes')
->helperText('Add Routes/api.php with Sanctum authentication')
->default(false),
])
->columns(2),
Section::make('What Will Be Created')
->schema([
\Filament\Forms\Components\Placeholder::make('structure')
->label('')
->content(fn () => new \Illuminate\Support\HtmlString('
<div class="text-sm text-gray-600 dark:text-gray-400 font-mono bg-gray-50 dark:bg-gray-900 p-4 rounded-lg">
<div class="font-bold mb-2">app/Modules/{ModuleName}/</div>
<div class="ml-4">
├── Config/{module_name}.php<br>
├── Database/Migrations/<br>
├── Database/Seeders/<br>
├── Filament/Resources/<br>
├── Http/Controllers/{ModuleName}Controller.php<br>
├── Http/Middleware/<br>
├── Http/Requests/<br>
├── Models/<br>
├── Policies/<br>
├── Services/<br>
├── Routes/web.php<br>
├── Resources/views/index.blade.php<br>
├── Permissions.php<br>
├── {ModuleName}ServiceProvider.php<br>
└── README.md
</div>
</div>
')),
])
->collapsible()
->collapsed(),
])
->statePath('data');
}
public function generate(): void
{
$data = $this->form->getState();
$service = new ModuleGeneratorService();
$this->result = $service->generate($data['name'], [
'description' => $data['description'] ?? '',
'create_git_branch' => $data['create_git_branch'] ?? true,
'include_api' => $data['include_api'] ?? false,
]);
if ($this->result['success']) {
Notification::make()
->title('Module Created Successfully!')
->body("Module {$this->result['module_name']} has been created.")
->success()
->persistent()
->send();
// Reset form for next module
$this->form->fill([
'name' => '',
'description' => '',
'create_git_branch' => true,
'include_api' => false,
]);
// Reload modules list
$this->loadModules();
} else {
Notification::make()
->title('Module Creation Failed')
->body($this->result['message'])
->danger()
->persistent()
->send();
}
}
public function clearResult(): void
{
$this->result = null;
}
}

View File

@@ -31,6 +31,7 @@ public function mount(): void
$siteDescription = Setting::get('site_description');
$contactEmail = Setting::get('contact_email');
$maintenanceMode = Setting::get('maintenance_mode', false);
$enableRegistration = Setting::get('enable_registration', false);
$this->form->fill([
'site_name' => is_string($siteName) ? $siteName : config('app.name'),
@@ -41,6 +42,7 @@ public function mount(): void
'site_description' => is_string($siteDescription) || is_null($siteDescription) ? $siteDescription : '',
'contact_email' => is_string($contactEmail) || is_null($contactEmail) ? $contactEmail : '',
'maintenance_mode' => is_bool($maintenanceMode) ? $maintenanceMode : false,
'enable_registration' => is_bool($enableRegistration) ? $enableRegistration : false,
]);
}
@@ -87,6 +89,10 @@ public function form(Form $form): Form
Forms\Components\Section::make('System')
->schema([
Forms\Components\Toggle::make('enable_registration')
->label('Enable User Registration')
->helperText('Allow new users to register. When disabled, only admins can create users.'),
Forms\Components\Toggle::make('maintenance_mode')
->label('Maintenance Mode')
->helperText('Enable to put the site in maintenance mode'),
@@ -116,6 +122,7 @@ public function save(): void
Setting::set('site_description', $data['site_description'] ?? '');
Setting::set('contact_email', $data['contact_email'] ?? '');
Setting::set('maintenance_mode', $data['maintenance_mode'] ?? false, 'boolean');
Setting::set('enable_registration', $data['enable_registration'] ?? false, 'boolean');
Notification::make()
->title('Settings saved successfully')

View File

@@ -0,0 +1,231 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Infolists;
use Filament\Infolists\Infolist;
use Illuminate\Database\Eloquent\Builder;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-clipboard-document-list';
protected static ?string $navigationGroup = 'System';
protected static ?string $navigationLabel = 'Audit Trail';
protected static ?int $navigationSort = 100;
protected static ?string $modelLabel = 'Audit Log';
protected static ?string $pluralModelLabel = 'Audit Trail';
public static function canAccess(): bool
{
return auth()->user()?->can('audit.view') ?? false;
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit($record): bool
{
return false;
}
public static function canDelete($record): bool
{
return false;
}
public static function form(Form $form): Form
{
return $form->schema([]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('created_at')
->label('Date/Time')
->dateTime('M j, Y H:i:s')
->sortable(),
Tables\Columns\TextColumn::make('user.name')
->label('User')
->default('System')
->searchable()
->sortable(),
Tables\Columns\BadgeColumn::make('event')
->label('Action')
->colors([
'success' => 'created',
'warning' => 'updated',
'danger' => 'deleted',
'info' => 'restored',
])
->formatStateUsing(fn (string $state): string => ucfirst($state)),
Tables\Columns\TextColumn::make('auditable_type')
->label('Model')
->formatStateUsing(fn (string $state): string => class_basename($state))
->sortable(),
Tables\Columns\TextColumn::make('auditable_id')
->label('Record ID')
->sortable(),
Tables\Columns\TextColumn::make('ip_address')
->label('IP Address')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('url')
->label('URL')
->limit(30)
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
Tables\Filters\SelectFilter::make('event')
->label('Action')
->options([
'created' => 'Created',
'updated' => 'Updated',
'deleted' => 'Deleted',
'restored' => 'Restored',
]),
Tables\Filters\SelectFilter::make('user_id')
->label('User')
->relationship('user', 'name')
->searchable()
->preload(),
Tables\Filters\SelectFilter::make('auditable_type')
->label('Model')
->options(fn () => Audit::query()
->distinct()
->pluck('auditable_type')
->mapWithKeys(fn ($type) => [$type => class_basename($type)])
->toArray()
),
Tables\Filters\Filter::make('created_at')
->form([
Forms\Components\DatePicker::make('from')
->label('From Date'),
Forms\Components\DatePicker::make('until')
->label('Until Date'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['from'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['until'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
})
->indicateUsing(function (array $data): array {
$indicators = [];
if ($data['from'] ?? null) {
$indicators['from'] = 'From: ' . $data['from'];
}
if ($data['until'] ?? null) {
$indicators['until'] = 'Until: ' . $data['until'];
}
return $indicators;
}),
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([])
->poll('60s');
}
public static function infolist(Infolist $infolist): Infolist
{
return $infolist
->schema([
Infolists\Components\Section::make('Audit Details')
->schema([
Infolists\Components\Grid::make(3)
->schema([
Infolists\Components\TextEntry::make('created_at')
->label('Date/Time')
->dateTime('F j, Y H:i:s'),
Infolists\Components\TextEntry::make('user.name')
->label('User')
->default('System'),
Infolists\Components\TextEntry::make('event')
->label('Action')
->badge()
->color(fn (string $state): string => match ($state) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
'restored' => 'info',
default => 'gray',
})
->formatStateUsing(fn (string $state): string => ucfirst($state)),
]),
Infolists\Components\Grid::make(3)
->schema([
Infolists\Components\TextEntry::make('auditable_type')
->label('Model')
->formatStateUsing(fn (string $state): string => class_basename($state)),
Infolists\Components\TextEntry::make('auditable_id')
->label('Record ID'),
Infolists\Components\TextEntry::make('ip_address')
->label('IP Address')
->default('N/A'),
]),
Infolists\Components\TextEntry::make('url')
->label('URL')
->columnSpanFull(),
]),
Infolists\Components\Section::make('Changes')
->schema([
Infolists\Components\ViewEntry::make('changes')
->view('filament.infolists.entries.audit-changes')
->columnSpanFull(),
]),
]);
}
public static function getRelations(): array
{
return [];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\MenuResource\Pages;
use App\Filament\Resources\MenuResource\RelationManagers;
use App\Models\Menu;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Str;
class MenuResource extends Resource
{
protected static ?string $model = Menu::class;
protected static ?string $navigationIcon = 'heroicon-o-bars-3';
protected static ?string $navigationGroup = 'Settings';
protected static ?int $navigationSort = 10;
public static function canAccess(): bool
{
$user = auth()->user();
return $user?->hasRole('admin') || $user?->can('menus.view');
}
public static function canCreate(): bool
{
$user = auth()->user();
return $user?->hasRole('admin') || $user?->can('menus.create');
}
public static function canEdit($record): bool
{
$user = auth()->user();
return $user?->hasRole('admin') || $user?->can('menus.edit');
}
public static function canDelete($record): bool
{
$user = auth()->user();
return $user?->hasRole('admin') || $user?->can('menus.delete');
}
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Menu Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255)
->live(onBlur: true)
->afterStateUpdated(fn ($state, Forms\Set $set) =>
$set('slug', Str::slug($state))
),
Forms\Components\TextInput::make('slug')
->required()
->maxLength(255)
->unique(ignoreRecord: true),
Forms\Components\Select::make('location')
->options([
'header' => 'Header Navigation',
'footer' => 'Footer Navigation',
'sidebar' => 'Sidebar Navigation',
])
->placeholder('Select a location'),
Forms\Components\Toggle::make('is_active')
->label('Active')
->default(true),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('slug')
->searchable()
->badge()
->color('gray'),
Tables\Columns\TextColumn::make('location')
->badge()
->color(fn (?string $state) => match ($state) {
'header' => 'success',
'footer' => 'warning',
'sidebar' => 'info',
default => 'gray',
}),
Tables\Columns\TextColumn::make('allItems_count')
->label('Items')
->counts('allItems')
->badge(),
Tables\Columns\IconColumn::make('is_active')
->label('Active')
->boolean(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\SelectFilter::make('location')
->options([
'header' => 'Header',
'footer' => 'Footer',
'sidebar' => 'Sidebar',
]),
Tables\Filters\TernaryFilter::make('is_active')
->label('Active'),
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
RelationManagers\ItemsRelationManager::class,
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListMenus::route('/'),
'create' => Pages\CreateMenu::route('/create'),
'edit' => Pages\EditMenu::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\MenuResource\Pages;
use App\Filament\Resources\MenuResource;
use Filament\Resources\Pages\CreateRecord;
class CreateMenu extends CreateRecord
{
protected static string $resource = MenuResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\MenuResource\Pages;
use App\Filament\Resources\MenuResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditMenu extends EditRecord
{
protected static string $resource = MenuResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\MenuResource\Pages;
use App\Filament\Resources\MenuResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListMenus extends ListRecords
{
protected static string $resource = MenuResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,153 @@
<?php
namespace App\Filament\Resources\MenuResource\RelationManagers;
use App\Models\MenuItem;
use App\Services\MenuService;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Permission;
class ItemsRelationManager extends RelationManager
{
protected static string $relationship = 'allItems';
protected static ?string $title = 'Menu Items';
public function form(Form $form): Form
{
$menuService = app(MenuService::class);
return $form
->schema([
Forms\Components\Section::make('Item Details')
->schema([
Forms\Components\TextInput::make('title')
->required()
->maxLength(255),
Forms\Components\Select::make('type')
->options([
'link' => 'External Link',
'route' => 'Named Route',
'module' => 'Module',
])
->default('link')
->required()
->live(),
Forms\Components\TextInput::make('url')
->label('URL')
->url()
->visible(fn (Forms\Get $get) => $get('type') === 'link')
->required(fn (Forms\Get $get) => $get('type') === 'link'),
Forms\Components\Select::make('route')
->label('Route Name')
->options(fn () => $menuService->getAvailableRoutes())
->searchable()
->visible(fn (Forms\Get $get) => $get('type') === 'route')
->required(fn (Forms\Get $get) => $get('type') === 'route'),
Forms\Components\Select::make('module')
->label('Module')
->options(fn () => $menuService->getAvailableModules())
->searchable()
->visible(fn (Forms\Get $get) => $get('type') === 'module')
->required(fn (Forms\Get $get) => $get('type') === 'module')
->helperText('Users need view permission for this module to see this item'),
])->columns(2),
Forms\Components\Section::make('Permissions & Display')
->schema([
Forms\Components\Select::make('parent_id')
->label('Parent Item')
->options(fn () => MenuItem::where('menu_id', $this->ownerRecord->id)
->whereNull('parent_id')
->pluck('title', 'id'))
->placeholder('None (Top Level)')
->searchable(),
Forms\Components\Select::make('permission')
->label('Required Permission')
->options(fn () => Permission::pluck('name', 'name'))
->searchable()
->placeholder('No specific permission required')
->helperText('If set, user must have this permission to see this item'),
Forms\Components\TextInput::make('icon')
->placeholder('heroicon-o-home')
->helperText('Heroicon name or custom icon class'),
Forms\Components\Select::make('target')
->options([
'_self' => 'Same Window',
'_blank' => 'New Tab',
])
->default('_self'),
Forms\Components\TextInput::make('order')
->numeric()
->default(0)
->helperText('Lower numbers appear first'),
Forms\Components\Toggle::make('is_active')
->label('Active')
->default(true),
])->columns(2),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('title')
->reorderable('order')
->defaultSort('order')
->columns([
Tables\Columns\TextColumn::make('title')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('parent.title')
->label('Parent')
->placeholder('—')
->badge()
->color('gray'),
Tables\Columns\TextColumn::make('type')
->badge()
->color(fn (string $state) => match ($state) {
'link' => 'info',
'route' => 'success',
'module' => 'warning',
default => 'gray',
}),
Tables\Columns\TextColumn::make('module')
->placeholder('—')
->toggleable(),
Tables\Columns\TextColumn::make('permission')
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('order')
->sortable(),
Tables\Columns\IconColumn::make('is_active')
->label('Active')
->boolean(),
])
->filters([
Tables\Filters\SelectFilter::make('type')
->options([
'link' => 'External Link',
'route' => 'Named Route',
'module' => 'Module',
]),
Tables\Filters\TernaryFilter::make('is_active')
->label('Active'),
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PermissionResource\Pages;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Permission;
class PermissionResource extends Resource
{
protected static ?string $model = Permission::class;
protected static ?string $navigationIcon = 'heroicon-o-key';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 3;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Permission Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->unique(ignoreRecord: true)
->maxLength(255)
->helperText('Use format: resource.action (e.g., users.create, posts.edit)'),
Forms\Components\Select::make('guard_name')
->options([
'web' => 'Web',
'api' => 'API',
])
->default('web')
->required(),
])->columns(2),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->badge()
->color('info')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('guard_name')
->badge()
->color('gray'),
Tables\Columns\TextColumn::make('roles_count')
->counts('roles')
->label('Roles')
->badge()
->color('success'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListPermissions::route('/'),
'create' => Pages\CreatePermission::route('/create'),
'edit' => Pages\EditPermission::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePermission extends CreateRecord
{
protected static string $resource = PermissionResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPermission extends EditRecord
{
protected static string $resource = PermissionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PermissionResource\Pages;
use App\Filament\Resources\PermissionResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPermissions extends ListRecords
{
protected static string $resource = PermissionResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\RoleResource\Pages;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Spatie\Permission\Models\Role;
class RoleResource extends Resource
{
protected static ?string $model = Role::class;
protected static ?string $navigationIcon = 'heroicon-o-shield-check';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 2;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('Role Details')
->schema([
Forms\Components\TextInput::make('name')
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\Components\Select::make('guard_name')
->options([
'web' => 'Web',
'api' => 'API',
])
->default('web')
->required(),
])->columns(2),
Forms\Components\Section::make('Permissions')
->schema([
Forms\Components\CheckboxList::make('permissions')
->relationship('permissions', 'name')
->columns(3)
->helperText('Select permissions for this role'),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->badge()
->color(fn (string $state): string => match ($state) {
'admin' => 'danger',
'editor' => 'warning',
default => 'success',
})
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('guard_name')
->badge()
->color('gray'),
Tables\Columns\TextColumn::make('permissions_count')
->counts('permissions')
->label('Permissions')
->badge()
->color('info'),
Tables\Columns\TextColumn::make('users_count')
->counts('users')
->label('Users')
->badge()
->color('success'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->before(function (Role $record) {
if ($record->name === 'admin') {
throw new \Exception('Cannot delete the admin role.');
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListRoles::route('/'),
'create' => Pages\CreateRole::route('/create'),
'edit' => Pages\EditRole::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Resources\Pages\CreateRecord;
class CreateRole extends CreateRecord
{
protected static string $resource = RoleResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditRole extends EditRecord
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\RoleResource\Pages;
use App\Filament\Resources\RoleResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListRoles extends ListRecords
{
protected static string $resource = RoleResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -3,25 +3,30 @@
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Hash;
use Spatie\Permission\Models\Role;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
protected static ?string $navigationIcon = 'heroicon-o-users';
protected static ?string $navigationGroup = 'User Management';
protected static ?int $navigationSort = 1;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\Section::make('User Information')
->schema([
Forms\Components\TextInput::make('name')
->required()
@@ -29,12 +34,34 @@ public static function form(Form $form): Form
Forms\Components\TextInput::make('email')
->email()
->required()
->unique(ignoreRecord: true)
->maxLength(255),
Forms\Components\DateTimePicker::make('email_verified_at'),
Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At'),
])->columns(2),
Forms\Components\Section::make('Password')
->schema([
Forms\Components\TextInput::make('password')
->password()
->required()
->maxLength(255),
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context): bool => $context === 'create')
->maxLength(255)
->confirmed(),
Forms\Components\TextInput::make('password_confirmation')
->password()
->maxLength(255)
->dehydrated(false),
])->columns(2),
Forms\Components\Section::make('Roles')
->schema([
Forms\Components\CheckboxList::make('roles')
->relationship('roles', 'name')
->columns(3)
->helperText('Assign roles to this user'),
]),
]);
}
@@ -43,26 +70,34 @@ public static function table(Table $table): Table
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable(),
Tables\Columns\TextColumn::make('email')
->searchable(),
Tables\Columns\TextColumn::make('email_verified_at')
->dateTime()
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('roles.name')
->badge()
->color('success')
->separator(','),
Tables\Columns\IconColumn::make('email_verified_at')
->label('Verified')
->boolean()
->trueIcon('heroicon-o-check-circle')
->falseIcon('heroicon-o-x-circle'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
Tables\Filters\SelectFilter::make('roles')
->relationship('roles', 'name')
->multiple()
->preload(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([

View File

@@ -0,0 +1,88 @@
<?php
namespace App\Helpers;
class ViteHelper
{
/**
* Check if the Vite manifest exists (assets have been built).
*/
public static function manifestExists(): bool
{
return file_exists(public_path('build/manifest.json'));
}
/**
* Get fallback CSS for when Vite assets are not built.
* This provides basic styling so the app remains usable.
*/
public static function fallbackStyles(): string
{
return <<<'CSS'
<style>
/* Fallback styles when Vite assets are not built */
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: ui-sans-serif, system-ui, sans-serif;
margin: 0;
background: #f3f4f6;
color: #111827;
}
.dark body { background: #111827; color: #f9fafb; }
.min-h-screen { min-height: 100vh; }
.bg-gray-100 { background: #f3f4f6; }
.bg-white { background: #fff; }
.shadow { box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.max-w-7xl { max-width: 80rem; margin: 0 auto; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-6 { padding-top: 1.5rem; padding-bottom: 1.5rem; }
.font-semibold { font-weight: 600; }
.text-xl { font-size: 1.25rem; }
.text-gray-800 { color: #1f2937; }
a { color: #3b82f6; text-decoration: none; }
a:hover { text-decoration: underline; }
.hidden { display: none; }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.space-x-4 > * + * { margin-left: 1rem; }
nav { background: #fff; border-bottom: 1px solid #e5e7eb; padding: 1rem; }
.container { max-width: 80rem; margin: 0 auto; padding: 0 1rem; }
button, .btn {
padding: 0.5rem 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
}
button:hover, .btn:hover { background: #2563eb; }
input, select, textarea {
border: 1px solid #d1d5db;
border-radius: 0.375rem;
padding: 0.5rem 0.75rem;
width: 100%;
}
.alert { padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
.alert-warning { background: #fef3c7; border: 1px solid #f59e0b; color: #92400e; }
</style>
CSS;
}
/**
* Get a warning banner HTML for development.
*/
public static function devWarningBanner(): string
{
if (app()->environment('production')) {
return '';
}
return <<<'HTML'
<div class="alert alert-warning" style="margin:1rem;padding:1rem;background:#fef3c7;border:1px solid #f59e0b;border-radius:0.5rem;color:#92400e;">
<strong>⚠️ Development Notice:</strong> Vite assets are not built.
Run <code style="background:#fde68a;padding:0.125rem 0.25rem;border-radius:0.25rem;">docker-compose run --rm node npm run build</code> to build assets.
</div>
HTML;
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use App\Models\Setting;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckRegistrationEnabled
{
/**
* Handle an incoming request.
* Block registration routes if registration is disabled in settings.
*/
public function handle(Request $request, Closure $next): Response
{
$registrationEnabled = Setting::get('enable_registration', false);
if (!$registrationEnabled) {
abort(403, 'User registration is currently disabled. Please contact an administrator.');
}
return $next($request);
}
}

111
src/app/Models/Audit.php Normal file
View File

@@ -0,0 +1,111 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Audit extends Model
{
protected $table = 'audits';
protected $guarded = [];
protected $casts = [
'old_values' => 'array',
'new_values' => 'array',
];
/**
* Get the user that performed the action.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the auditable model.
*/
public function auditable(): MorphTo
{
return $this->morphTo();
}
/**
* Get the event badge color.
*/
public function getEventColorAttribute(): string
{
return match ($this->event) {
'created' => 'success',
'updated' => 'warning',
'deleted' => 'danger',
'restored' => 'info',
default => 'gray',
};
}
/**
* Get a human-readable model name.
*/
public function getModelNameAttribute(): string
{
$class = class_basename($this->auditable_type);
return preg_replace('/(?<!^)[A-Z]/', ' $0', $class);
}
/**
* Get formatted changes for display.
*/
public function getFormattedChangesAttribute(): array
{
$changes = [];
$oldValues = $this->old_values ?? [];
$newValues = $this->new_values ?? [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
foreach ($allKeys as $key) {
$old = $oldValues[$key] ?? null;
$new = $newValues[$key] ?? null;
if ($old !== $new) {
$changes[] = [
'field' => $key,
'old' => $this->formatValue($old),
'new' => $this->formatValue($new),
];
}
}
return $changes;
}
/**
* Format a value for display.
*/
protected function formatValue($value): string
{
if (is_null($value)) {
return '(empty)';
}
if (is_bool($value)) {
return $value ? 'Yes' : 'No';
}
if (is_array($value)) {
return json_encode($value);
}
$stringValue = (string) $value;
if (strlen($stringValue) > 100) {
return substr($stringValue, 0, 100) . '...';
}
return $stringValue;
}
}

40
src/app/Models/Menu.php Normal file
View File

@@ -0,0 +1,40 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Menu extends Model
{
protected $fillable = [
'name',
'slug',
'location',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function items(): HasMany
{
return $this->hasMany(MenuItem::class)->whereNull('parent_id')->orderBy('order');
}
public function allItems(): HasMany
{
return $this->hasMany(MenuItem::class)->orderBy('order');
}
public static function findBySlug(string $slug): ?self
{
return static::where('slug', $slug)->where('is_active', true)->first();
}
public static function findByLocation(string $location): ?self
{
return static::where('location', $location)->where('is_active', true)->first();
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Auth;
class MenuItem extends Model
{
protected $fillable = [
'menu_id',
'parent_id',
'title',
'type',
'url',
'route',
'route_params',
'module',
'permission',
'icon',
'target',
'order',
'is_active',
];
protected $casts = [
'route_params' => 'array',
'is_active' => 'boolean',
'order' => 'integer',
];
public function menu(): BelongsTo
{
return $this->belongsTo(Menu::class);
}
public function parent(): BelongsTo
{
return $this->belongsTo(MenuItem::class, 'parent_id');
}
public function children(): HasMany
{
return $this->hasMany(MenuItem::class, 'parent_id')->orderBy('order');
}
public function getUrlAttribute(): ?string
{
return match ($this->type) {
'link' => $this->attributes['url'],
'route' => $this->route ? route($this->route, $this->route_params ?? []) : null,
'module' => $this->module ? route("{$this->module}.index") : null,
default => null,
};
}
public function isVisibleToUser(?User $user = null): bool
{
if (!$this->is_active) {
return false;
}
$user = $user ?? Auth::user();
if (!$user) {
return !$this->permission && !$this->module;
}
if ($user->hasRole('admin')) {
return true;
}
if ($this->permission) {
return $user->can($this->permission);
}
if ($this->module) {
return $user->can("{$this->module}.view");
}
return true;
}
public function getVisibleChildren(?User $user = null): \Illuminate\Support\Collection
{
return $this->children->filter(fn (MenuItem $item) => $item->isVisibleToUser($user));
}
}

View File

@@ -3,17 +3,24 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Filament\Models\Contracts\FilamentUser;
use Filament\Panel;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable
class User extends Authenticatable implements FilamentUser
{
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable, HasRoles, HasApiTokens;
public function canAccessPanel(Panel $panel): bool
{
return $this->hasRole('admin');
}
/**
* The attributes that are mass assignable.
*

View File

@@ -1,94 +1,131 @@
# Module System
> **🤖 AI Agents**: For EXACT file templates, see [CLAUDE.md](../../../CLAUDE.md)
This Laravel application uses a modular architecture to organize features into self-contained modules.
## Creating a New Module
Use the artisan command to scaffold a complete module:
```bash
# Basic module (no model)
php artisan make:module ProductCatalog
# With a model + Filament resource
php artisan make:module ProductCatalog --model=Product
# With API routes
php artisan make:module ProductCatalog --model=Product --api
# Without Filament admin
php artisan make:module ProductCatalog --model=Product --no-filament
```
This creates:
- **Model**: `app/Modules/ProductCatalog/Models/ProductCatalog.php`
- **Controller**: `app/Modules/ProductCatalog/Controllers/ProductCatalogController.php`
- **Routes**: `app/Modules/ProductCatalog/routes.php`
- **Views**: `app/Modules/ProductCatalog/Views/`
- **Migration**: `database/migrations/YYYY_MM_DD_HHMMSS_create_product_catalogs_table.php`
- **Filament Resource**: `app/Filament/Resources/ProductCatalogResource.php`
- **Tests**: `tests/Modules/ProductCatalog/ProductCatalogTest.php`
## Module Structure
## What Gets Created
### Basic module (`make:module Name`):
```
app/Modules/
── ProductCatalog/
├── Models/
│ └── ProductCatalog.php
├── Controllers/
│ └── ProductCatalogController.php
├── Views/
│ ├── index.blade.php
│ ├── create.blade.php
│ ├── edit.blade.php
│ └── show.blade.php
── routes.php
app/Modules/Name/
── Config/name.php # Module config
├── Database/Migrations/ # Module migrations
├── Database/Seeders/
├── Filament/Resources/ # Admin resources
├── Http/Controllers/NameController.php
├── Http/Middleware/
├── Http/Requests/
├── Models/
├── Policies/
├── Services/
── Routes/web.php # Frontend routes
├── Resources/views/index.blade.php
├── Permissions.php # Module permissions
└── NameServiceProvider.php # Auto-registered
```
## Registering Module Routes
### With `--model=Product`:
Adds:
- `Models/Product.php` (with auditing)
- `Database/Migrations/create_products_table.php`
- `Filament/Resources/ProductResource.php` (full CRUD)
After creating a module, register its routes in `routes/web.php`:
```php
require __DIR__.'/../app/Modules/ProductCatalog/routes.php';
```
### With `--api`:
Adds:
- `Routes/api.php` (Sanctum-protected)
## Module Features
Each module includes:
**CRUD Operations** - Full Create, Read, Update, Delete functionality
**Authentication** - Routes protected by auth middleware
**Filament Admin** - Auto-generated admin panel resource
**Blade Views** - Responsive Tailwind CSS templates
**Tests** - Pest test suite for module functionality
**Permissions** - Ready for role-based access control
**ServiceProvider** - Auto-registered, loads routes/views/migrations
**Config** - Module-specific settings with audit configuration
**Permissions** - Pre-defined CRUD permissions
**Filament Admin** - Full CRUD resource with navigation group
**Auditing** - Track all changes via ModuleAuditable trait
**Tests** - Feature tests with permission checks
## Example Usage
```bash
# Create an Inventory module
php artisan make:module Inventory
# Create a Stock Management module with Product model
php artisan make:module StockManagement --model=Product --api
# Run migrations
php artisan migrate
# Register routes in routes/web.php
require __DIR__.'/../app/Modules/Inventory/routes.php';
# Seed permissions
php artisan db:seed --class=RolePermissionSeeder
# Access at: http://localhost:8080/inventory
# Admin panel: http://localhost:8080/admin/inventories
# Clear caches
php artisan optimize:clear
```
## Adding Permissions
**Access:**
- Frontend: `http://localhost:8080/stock-management`
- Admin: `http://localhost:8080/admin` → Stock Management section
- API: `http://localhost:8080/api/stock-management`
To add module-specific permissions:
## Permissions
Each module creates these permissions in `Permissions.php`:
```php
// In database/seeders/RolePermissionSeeder.php
$permissions = [
'inventory.view',
'inventory.create',
'inventory.edit',
'inventory.delete',
return [
'stock_management.view' => 'View Stock Management',
'stock_management.create' => 'Create Stock Management records',
'stock_management.edit' => 'Edit Stock Management records',
'stock_management.delete' => 'Delete Stock Management records',
];
```
**Permissions are auto-loaded!** When you run `php artisan db:seed --class=RolePermissionSeeder`,
it scans all `app/Modules/*/Permissions.php` files and registers them automatically.
Use in Blade:
```blade
@can('stock_management.view')
<a href="{{ route('stock-management.index') }}">Stock</a>
@endcan
```
Use in Controller:
```php
$this->authorize('stock_management.view');
```
## Audit Configuration
Edit `Config/module_name.php`:
```php
'audit' => [
'enabled' => true,
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
'include' => [], // Fields to audit (if strategy='include')
'exclude' => ['password'], // Fields to exclude
],
```
## Best Practices
1. **Keep modules self-contained** - Each module should be independent
2. **Use namespaces** - Follow the `App\Modules\{ModuleName}` pattern
3. **Test your modules** - Run `php artisan test` after creating
4. **Document changes** - Update module README if you modify structure
5. **Use Filament** - Leverage the admin panel for quick CRUD interfaces
1. **Keep modules independent** - Avoid tight coupling between modules
2. **Use Services** - Put business logic in `Services/`, not controllers
3. **Define clear permissions** - One permission per action
4. **Test your modules** - Run `php artisan test` after creating
5. **Use Filament** - Leverage admin panel for quick CRUD

View File

@@ -2,6 +2,8 @@
namespace App\Providers;
use App\Models\Setting;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +21,28 @@ public function register(): void
*/
public function boot(): void
{
//
View::composer('*', function ($view) {
try {
$view->with('siteSettings', [
'name' => Setting::get('site_name', config('app.name', 'Laravel')),
'logo' => Setting::get('site_logo'),
'primary_color' => Setting::get('primary_color', '#3b82f6'),
'secondary_color' => Setting::get('secondary_color', '#8b5cf6'),
'accent_color' => Setting::get('accent_color', '#10b981'),
'description' => Setting::get('site_description'),
'enable_registration' => Setting::get('enable_registration', false),
]);
} catch (\Exception $e) {
$view->with('siteSettings', [
'name' => config('app.name', 'Laravel'),
'logo' => null,
'primary_color' => '#3b82f6',
'secondary_color' => '#8b5cf6',
'accent_color' => '#10b981',
'description' => null,
'enable_registration' => false,
]);
}
});
}
}

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\Menu;
use App\Models\MenuItem;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
class MenuService
{
public function getMenu(string $slugOrLocation, ?User $user = null): ?array
{
$menu = Menu::findBySlug($slugOrLocation) ?? Menu::findByLocation($slugOrLocation);
if (!$menu) {
return null;
}
return $this->buildMenuTree($menu, $user);
}
public function buildMenuTree(Menu $menu, ?User $user = null): array
{
$user = $user ?? Auth::user();
$items = $menu->items()
->with(['children' => fn ($q) => $q->orderBy('order')])
->where('is_active', true)
->get();
return $this->filterAndMapItems($items, $user);
}
protected function filterAndMapItems(Collection $items, ?User $user): array
{
return $items
->filter(fn (MenuItem $item) => $item->isVisibleToUser($user))
->map(fn (MenuItem $item) => $this->mapItem($item, $user))
->values()
->toArray();
}
protected function mapItem(MenuItem $item, ?User $user): array
{
$children = $item->children->count() > 0
? $this->filterAndMapItems($item->children, $user)
: [];
return [
'id' => $item->id,
'title' => $item->title,
'url' => $item->url,
'icon' => $item->icon,
'target' => $item->target,
'is_active' => $this->isActiveUrl($item->url),
'children' => $children,
'has_children' => count($children) > 0,
];
}
protected function isActiveUrl(?string $url): bool
{
if (!$url) {
return false;
}
$currentUrl = request()->url();
$currentPath = request()->path();
if ($url === $currentUrl) {
return true;
}
$urlPath = parse_url($url, PHP_URL_PATH) ?? '';
return $urlPath && str_starts_with('/' . ltrim($currentPath, '/'), $urlPath);
}
public function getAvailableModules(): array
{
$modules = [];
$modulesPath = app_path('Modules');
if (!is_dir($modulesPath)) {
return $modules;
}
foreach (scandir($modulesPath) as $module) {
if ($module === '.' || $module === '..' || !is_dir($modulesPath . '/' . $module)) {
continue;
}
$slug = strtolower(preg_replace('/(?<!^)[A-Z]/', '-$0', $module));
$modules[$slug] = $module;
}
return $modules;
}
public function getAvailableRoutes(): array
{
$routes = [];
foreach (app('router')->getRoutes() as $route) {
$name = $route->getName();
if ($name && !str_starts_with($name, 'filament.') && !str_starts_with($name, 'livewire.')) {
$routes[$name] = $name;
}
}
ksort($routes);
return $routes;
}
}

View File

@@ -0,0 +1,248 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
class ModuleDiscoveryService
{
public function discoverModules(): array
{
$modulesPath = app_path('Modules');
if (!File::exists($modulesPath)) {
return [];
}
$modules = [];
$directories = File::directories($modulesPath);
foreach ($directories as $directory) {
$moduleName = basename($directory);
// Skip non-module directories (like README files)
if (!File::exists("{$directory}/{$moduleName}ServiceProvider.php")) {
continue;
}
$modules[] = $this->getModuleInfo($moduleName, $directory);
}
return $modules;
}
protected function getModuleInfo(string $name, string $path): array
{
$kebabName = Str::kebab($name);
$snakeName = Str::snake($name);
return [
'name' => $name,
'slug' => $kebabName,
'path' => $path,
'config' => $this->getConfig($path, $snakeName),
'models' => $this->getModels($path),
'views' => $this->getViews($path),
'routes' => $this->getRoutes($path, $kebabName),
'migrations' => $this->getMigrations($path),
'filament_resources' => $this->getFilamentResources($path),
'permissions' => $this->getPermissions($path),
'has_api' => File::exists("{$path}/Routes/api.php"),
];
}
protected function getConfig(string $path, string $snakeName): array
{
$configPath = "{$path}/Config/{$snakeName}.php";
if (File::exists($configPath)) {
return require $configPath;
}
return [];
}
protected function getModels(string $path): array
{
$modelsPath = "{$path}/Models";
if (!File::exists($modelsPath)) {
return [];
}
$models = [];
$files = File::files($modelsPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php' && $file->getFilename() !== '.gitkeep') {
$modelName = $file->getFilenameWithoutExtension();
$models[] = [
'name' => $modelName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $models;
}
protected function getViews(string $path): array
{
$viewsPath = "{$path}/Resources/views";
if (!File::exists($viewsPath)) {
return [];
}
return $this->scanViewsRecursive($viewsPath, '');
}
protected function scanViewsRecursive(string $basePath, string $prefix): array
{
$views = [];
$items = File::files($basePath);
foreach ($items as $file) {
if ($file->getExtension() === 'php' && Str::endsWith($file->getFilename(), '.blade.php')) {
$viewName = Str::replaceLast('.blade.php', '', $file->getFilename());
$fullName = $prefix ? "{$prefix}.{$viewName}" : $viewName;
$views[] = [
'name' => $fullName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
// Scan subdirectories
$directories = File::directories($basePath);
foreach ($directories as $dir) {
$dirName = basename($dir);
$subPrefix = $prefix ? "{$prefix}.{$dirName}" : $dirName;
$views = array_merge($views, $this->scanViewsRecursive($dir, $subPrefix));
}
return $views;
}
protected function getRoutes(string $path, string $kebabName): array
{
$routes = [];
// Get web routes
$webRoutesPath = "{$path}/Routes/web.php";
if (File::exists($webRoutesPath)) {
$routes['web'] = $this->parseRouteFile($webRoutesPath, $kebabName);
}
// Get API routes
$apiRoutesPath = "{$path}/Routes/api.php";
if (File::exists($apiRoutesPath)) {
$routes['api'] = $this->parseRouteFile($apiRoutesPath, "api/{$kebabName}");
}
return $routes;
}
protected function parseRouteFile(string $path, string $prefix): array
{
$content = File::get($path);
$routes = [];
// Parse Route::get, Route::post, etc.
preg_match_all(
"/Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]*)['\"].*?->name\s*\(\s*['\"]([^'\"]*)['\"]|Route::(get|post|put|patch|delete|any)\s*\(\s*['\"]([^'\"]*)['\"].*?\[.*?::class,\s*['\"](\w+)['\"]\]/",
$content,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
$method = strtoupper($match[1] ?: $match[4]);
$uri = $match[2] ?: $match[5];
$fullUri = $uri === '/' ? "/{$prefix}" : "/{$prefix}{$uri}";
$routes[] = [
'method' => $method,
'uri' => $fullUri,
'name' => $match[3] ?? null,
];
}
return $routes;
}
protected function getMigrations(string $path): array
{
$migrationsPath = "{$path}/Database/Migrations";
if (!File::exists($migrationsPath)) {
return [];
}
$migrations = [];
$files = File::files($migrationsPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php') {
$migrations[] = [
'name' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $migrations;
}
protected function getFilamentResources(string $path): array
{
$resourcesPath = "{$path}/Filament/Resources";
if (!File::exists($resourcesPath)) {
return [];
}
$resources = [];
$files = File::files($resourcesPath);
foreach ($files as $file) {
if ($file->getExtension() === 'php' && Str::endsWith($file->getFilename(), 'Resource.php')) {
$resourceName = Str::replaceLast('Resource.php', '', $file->getFilename());
$resources[] = [
'name' => $resourceName,
'file' => $file->getFilename(),
'path' => $file->getPathname(),
];
}
}
return $resources;
}
protected function getPermissions(string $path): array
{
$permissionsPath = "{$path}/Permissions.php";
if (File::exists($permissionsPath)) {
return require $permissionsPath;
}
return [];
}
public function getModuleSummary(): array
{
$modules = $this->discoverModules();
return [
'total' => count($modules),
'with_models' => count(array_filter($modules, fn($m) => !empty($m['models']))),
'with_filament' => count(array_filter($modules, fn($m) => !empty($m['filament_resources']))),
'with_api' => count(array_filter($modules, fn($m) => $m['has_api'])),
];
}
}

View File

@@ -0,0 +1,492 @@
<?php
namespace App\Services;
use CzProject\GitPhp\Git;
use CzProject\GitPhp\GitRepository;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class ModuleGeneratorService
{
protected string $studlyName;
protected string $kebabName;
protected string $snakeName;
protected string $modulePath;
protected array $logs = [];
protected ?GitRepository $repo = null;
public function generate(string $name, array $options = []): array
{
$this->studlyName = Str::studly($name);
$this->kebabName = Str::kebab($name);
$this->snakeName = Str::snake($name);
$this->modulePath = app_path("Modules/{$this->studlyName}");
$options = array_merge([
'description' => '',
'create_git_branch' => true,
'include_api' => false,
], $options);
// Check if module already exists
if (File::exists($this->modulePath)) {
return [
'success' => false,
'message' => "Module {$this->studlyName} already exists!",
'logs' => $this->logs,
];
}
// Handle Git branching
$branchName = null;
if ($options['create_git_branch']) {
$gitResult = $this->createGitBranch();
if (!$gitResult['success']) {
return $gitResult;
}
$branchName = $gitResult['branch'];
}
// Generate module skeleton
$this->createDirectoryStructure();
$this->createServiceProvider($options['description']);
$this->createConfig($options['description']);
$this->createPermissions();
$this->createController();
$this->createRoutes($options['include_api']);
$this->createViews();
$this->createReadme($options['description']);
$this->registerServiceProvider();
// Git commit
if ($options['create_git_branch'] && $this->repo) {
$this->commitChanges();
}
return [
'success' => true,
'message' => "Module {$this->studlyName} created successfully!",
'module_name' => $this->studlyName,
'module_path' => $this->modulePath,
'branch' => $branchName,
'logs' => $this->logs,
'next_steps' => $this->getNextSteps($branchName),
];
}
protected function createGitBranch(): array
{
try {
$git = new Git();
$repoPath = base_path();
// Check if we're in a git repository
if (!File::exists($repoPath . '/.git')) {
$this->log('⚠ No Git repository found, skipping branch creation');
return ['success' => true, 'branch' => null];
}
$this->repo = $git->open($repoPath);
// Check for uncommitted changes
if ($this->repo->hasChanges()) {
return [
'success' => false,
'message' => 'Git working directory has uncommitted changes. Please commit or stash them first.',
'logs' => $this->logs,
];
}
$branchName = "module/{$this->kebabName}";
// Create and checkout new branch
$this->repo->createBranch($branchName, true);
$this->log("✓ Created and checked out branch: {$branchName}");
return ['success' => true, 'branch' => $branchName];
} catch (\Exception $e) {
return [
'success' => false,
'message' => 'Git error: ' . $e->getMessage(),
'logs' => $this->logs,
];
}
}
protected function commitChanges(): void
{
try {
$this->repo->addAllChanges();
$this->repo->commit("feat: Add {$this->studlyName} module skeleton");
$this->log("✓ Committed changes to Git");
} catch (\Exception $e) {
$this->log("⚠ Git commit failed: " . $e->getMessage());
}
}
protected function createDirectoryStructure(): void
{
$directories = [
'',
'/Config',
'/Database/Migrations',
'/Database/Seeders',
'/Filament/Resources',
'/Http/Controllers',
'/Http/Middleware',
'/Http/Requests',
'/Models',
'/Policies',
'/Services',
'/Routes',
'/Resources/views',
];
foreach ($directories as $dir) {
File::makeDirectory("{$this->modulePath}{$dir}", 0755, true);
}
// Create .gitkeep files in empty directories
$emptyDirs = [
'/Database/Migrations',
'/Database/Seeders',
'/Filament/Resources',
'/Http/Middleware',
'/Http/Requests',
'/Models',
'/Policies',
'/Services',
];
foreach ($emptyDirs as $dir) {
File::put("{$this->modulePath}{$dir}/.gitkeep", '');
}
$this->log("✓ Created directory structure");
}
protected function createServiceProvider(string $description): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName};
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
class {$this->studlyName}ServiceProvider extends ServiceProvider
{
public function register(): void
{
\$this->mergeConfigFrom(
__DIR__ . '/Config/{$this->snakeName}.php',
'{$this->snakeName}'
);
}
public function boot(): void
{
\$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
\$this->loadViewsFrom(__DIR__ . '/Resources/views', '{$this->kebabName}');
\$this->registerRoutes();
\$this->registerPermissions();
}
protected function registerRoutes(): void
{
Route::middleware(['web', 'auth'])
->prefix('{$this->kebabName}')
->name('{$this->kebabName}.')
->group(__DIR__ . '/Routes/web.php');
if (file_exists(__DIR__ . '/Routes/api.php')) {
Route::middleware(['api', 'auth:sanctum'])
->prefix('api/{$this->kebabName}')
->name('api.{$this->kebabName}.')
->group(__DIR__ . '/Routes/api.php');
}
}
protected function registerPermissions(): void
{
// Permissions are registered via RolePermissionSeeder
// See: Permissions.php in this module
}
}
PHP;
File::put("{$this->modulePath}/{$this->studlyName}ServiceProvider.php", $stub);
$this->log("✓ Created ServiceProvider");
}
protected function createConfig(string $description): void
{
$stub = <<<PHP
<?php
return [
'name' => '{$this->studlyName}',
'slug' => '{$this->kebabName}',
'description' => '{$description}',
'version' => '1.0.0',
'audit' => [
'enabled' => true,
'strategy' => 'all', // 'all', 'include', 'exclude', 'none'
'include' => [],
'exclude' => [],
],
];
PHP;
File::put("{$this->modulePath}/Config/{$this->snakeName}.php", $stub);
$this->log("✓ Created Config");
}
protected function createPermissions(): void
{
$stub = <<<PHP
<?php
return [
'{$this->snakeName}.view' => 'View {$this->studlyName}',
'{$this->snakeName}.create' => 'Create {$this->studlyName} records',
'{$this->snakeName}.edit' => 'Edit {$this->studlyName} records',
'{$this->snakeName}.delete' => 'Delete {$this->studlyName} records',
];
PHP;
File::put("{$this->modulePath}/Permissions.php", $stub);
$this->log("✓ Created Permissions");
}
protected function createController(): void
{
$stub = <<<PHP
<?php
namespace App\Modules\\{$this->studlyName}\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class {$this->studlyName}Controller extends Controller
{
public function index()
{
\$this->authorize('{$this->snakeName}.view');
return view('{$this->kebabName}::index');
}
}
PHP;
File::put("{$this->modulePath}/Http/Controllers/{$this->studlyName}Controller.php", $stub);
$this->log("✓ Created Controller");
}
protected function createRoutes(bool $includeApi): void
{
// Web routes
$webStub = <<<PHP
<?php
use App\Modules\\{$this->studlyName}\Http\Controllers\\{$this->studlyName}Controller;
use Illuminate\Support\Facades\Route;
Route::get('/', [{$this->studlyName}Controller::class, 'index'])->name('index');
// Add more routes here:
// Route::get('/create', [{$this->studlyName}Controller::class, 'create'])->name('create');
// Route::post('/', [{$this->studlyName}Controller::class, 'store'])->name('store');
// Route::get('/{id}', [{$this->studlyName}Controller::class, 'show'])->name('show');
// Route::get('/{id}/edit', [{$this->studlyName}Controller::class, 'edit'])->name('edit');
// Route::put('/{id}', [{$this->studlyName}Controller::class, 'update'])->name('update');
// Route::delete('/{id}', [{$this->studlyName}Controller::class, 'destroy'])->name('destroy');
PHP;
File::put("{$this->modulePath}/Routes/web.php", $webStub);
// API routes (if requested)
if ($includeApi) {
$apiStub = <<<PHP
<?php
use Illuminate\Support\Facades\Route;
Route::middleware('auth:sanctum')->group(function () {
// API routes here
// Route::get('/', fn() => response()->json(['message' => '{$this->studlyName} API']));
});
PHP;
File::put("{$this->modulePath}/Routes/api.php", $apiStub);
$this->log("✓ Created Routes (web + api)");
} else {
$this->log("✓ Created Routes (web)");
}
}
protected function createViews(): void
{
$stub = <<<BLADE
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 dark:text-gray-200 leading-tight">
{{ __('{$this->studlyName}') }}
</h2>
</x-slot>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white dark:bg-gray-800 overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 text-gray-900 dark:text-gray-100">
<h3 class="text-lg font-medium mb-4">{{ __('{$this->studlyName} Module') }}</h3>
<p class="text-gray-600 dark:text-gray-400">
This is your new module. Start building by adding models, controllers, and views.
</p>
<div class="mt-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<h4 class="font-medium mb-2">Quick Start:</h4>
<ul class="list-disc list-inside text-sm space-y-1">
<li>Add models in <code>app/Modules/{$this->studlyName}/Models/</code></li>
<li>Add migrations in <code>app/Modules/{$this->studlyName}/Database/Migrations/</code></li>
<li>Add Filament resources in <code>app/Modules/{$this->studlyName}/Filament/Resources/</code></li>
<li>Extend routes in <code>app/Modules/{$this->studlyName}/Routes/web.php</code></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</x-app-layout>
BLADE;
File::put("{$this->modulePath}/Resources/views/index.blade.php", $stub);
$this->log("✓ Created Views");
}
protected function createReadme(string $description): void
{
$stub = <<<MD
# {$this->studlyName} Module
{$description}
## Structure
```
{$this->studlyName}/
├── Config/{$this->snakeName}.php # Module configuration
├── Database/
├── Migrations/ # Database migrations
└── Seeders/ # Database seeders
├── Filament/Resources/ # Admin panel resources
├── Http/
├── Controllers/ # HTTP controllers
├── Middleware/ # Module middleware
└── Requests/ # Form requests
├── Models/ # Eloquent models
├── Policies/ # Authorization policies
├── Services/ # Business logic services
├── Routes/
├── web.php # Web routes
└── api.php # API routes (if enabled)
├── Resources/views/ # Blade templates
├── Permissions.php # Module permissions
├── {$this->studlyName}ServiceProvider.php
└── README.md
```
## Permissions
| Permission | Description |
|------------|-------------|
| `{$this->snakeName}.view` | View {$this->studlyName} |
| `{$this->snakeName}.create` | Create records |
| `{$this->snakeName}.edit` | Edit records |
| `{$this->snakeName}.delete` | Delete records |
## Routes
| Method | URI | Name | Description |
|--------|-----|------|-------------|
| GET | `/{$this->kebabName}` | `{$this->kebabName}.index` | Module index |
## Getting Started
1. **Add a Model:**
```bash
# Create model manually or use artisan
php artisan make:model Modules/{$this->studlyName}/Models/YourModel -m
```
2. **Run Migrations:**
```bash
php artisan migrate
```
3. **Seed Permissions:**
```bash
php artisan db:seed --class=RolePermissionSeeder
```
4. **Add Filament Resource:**
Create resources in `Filament/Resources/` following the Filament documentation.
## Configuration
Edit `Config/{$this->snakeName}.php` to customize module settings including audit behavior.
MD;
File::put("{$this->modulePath}/README.md", $stub);
$this->log("✓ Created README.md");
}
protected function registerServiceProvider(): void
{
$providersPath = base_path('bootstrap/providers.php');
if (File::exists($providersPath)) {
$content = File::get($providersPath);
$providerClass = "App\\Modules\\{$this->studlyName}\\{$this->studlyName}ServiceProvider::class";
if (!str_contains($content, $providerClass)) {
$content = preg_replace(
'/(\];)/',
" {$providerClass},\n$1",
$content
);
File::put($providersPath, $content);
$this->log("✓ Registered ServiceProvider");
}
} else {
$this->log("⚠ Could not auto-register ServiceProvider");
}
}
protected function getNextSteps(?string $branchName): array
{
$steps = [
"Run migrations: `php artisan migrate`",
"Seed permissions: `php artisan db:seed --class=RolePermissionSeeder`",
"Clear caches: `php artisan optimize:clear`",
"Access frontend: `/{$this->kebabName}`",
];
if ($branchName) {
$steps[] = "Push branch: `git push -u origin {$branchName}`";
$steps[] = "Create merge request when ready";
}
return $steps;
}
protected function log(string $message): void
{
$this->logs[] = $message;
}
}

View File

@@ -0,0 +1,77 @@
<?php
namespace App\Traits;
use OwenIt\Auditing\Auditable;
trait ModuleAuditable
{
use Auditable;
/**
* Get the audit configuration from module config.
*/
public function getAuditConfig(): array
{
$moduleName = $this->getModuleName();
return config("{$moduleName}.audit", [
'enabled' => true,
'strategy' => 'all',
'include' => [],
'exclude' => [],
]);
}
/**
* Determine if auditing is enabled for this model.
*/
public function isAuditingEnabled(): bool
{
$config = $this->getAuditConfig();
return $config['enabled'] ?? true;
}
/**
* Get fields to include in audit (if strategy is 'include').
*/
public function getAuditInclude(): array
{
$config = $this->getAuditConfig();
if (($config['strategy'] ?? 'all') === 'include') {
return $config['include'] ?? [];
}
return [];
}
/**
* Get fields to exclude from audit.
*/
public function getAuditExclude(): array
{
$config = $this->getAuditConfig();
return array_merge(
$this->auditExclude ?? [],
$config['exclude'] ?? []
);
}
/**
* Determine the module name from the model's namespace.
*/
protected function getModuleName(): string
{
$class = get_class($this);
// Extract module name from namespace: App\Modules\{ModuleName}\Models\...
if (preg_match('/App\\\\Modules\\\\([^\\\\]+)\\\\/', $class, $matches)) {
return \Illuminate\Support\Str::snake($matches[1]);
}
return 'app';
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\View\Components;
use App\Services\MenuService;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class FrontendMenu extends Component
{
public array $items = [];
public function __construct(
public string $menu = 'header',
public string $class = '',
) {
$menuService = app(MenuService::class);
$this->items = $menuService->getMenu($menu) ?? [];
}
public function render(): View
{
return view('components.frontend-menu');
}
public function hasItems(): bool
{
return count($this->items) > 0;
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\View\Components;
use App\Services\MenuService;
use Illuminate\Contracts\View\View;
use Illuminate\View\Component;
class FrontendMenuResponsive extends Component
{
public array $items = [];
public function __construct(
public string $menu = 'header',
public string $class = '',
) {
$menuService = app(MenuService::class);
$this->items = $menuService->getMenu($menu) ?? [];
}
public function render(): View
{
return view('components.frontend-menu-responsive');
}
public function hasItems(): bool
{
return count($this->items) > 0;
}
}

View File

@@ -12,7 +12,9 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
$middleware->alias([
'registration.enabled' => \App\Http\Middleware\CheckRegistrationEnabled::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//

View File

@@ -17,6 +17,7 @@
"spatie/laravel-permission": "^6.24"
},
"require-dev": {
"czproject/git-php": "^4.6",
"fakerphp/faker": "^1.23",
"laravel/breeze": "^2.3",
"laravel/pail": "^1.1",

66
src/composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1f84406b8f4e254677813bb043dd37c3",
"content-hash": "384bae7218ddf4c1e2b18a8c99946102",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -8305,6 +8305,70 @@
],
"time": "2026-01-08T08:02:38+00:00"
},
{
"name": "czproject/git-php",
"version": "v4.6.0",
"source": {
"type": "git",
"url": "https://github.com/czproject/git-php.git",
"reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/czproject/git-php/zipball/1f1ecc92aea9ee31120f4f5b759f5aa947420b0a",
"reference": "1f1ecc92aea9ee31120f4f5b759f5aa947420b0a",
"shasum": ""
},
"require": {
"php": "8.0 - 8.5"
},
"require-dev": {
"nette/tester": "^2.5"
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Jan Pecha",
"email": "janpecha@email.cz"
}
],
"description": "Library for work with Git repository in PHP.",
"keywords": [
"git"
],
"support": {
"issues": "https://github.com/czproject/git-php/issues",
"source": "https://github.com/czproject/git-php/tree/v4.6.0"
},
"funding": [
{
"url": "https://github.com/sponsors/janpecha",
"type": "github"
},
{
"url": "https://www.janpecha.cz/donate/git-php/",
"type": "other"
},
{
"url": "https://donate.stripe.com/7sIcO2a9maTSg2A9AA",
"type": "stripe"
},
{
"url": "https://thanks.dev/u/gh/czproject",
"type": "thanks.dev"
}
],
"time": "2025-11-10T07:24:07+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('menus', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('location')->nullable()->comment('e.g., header, footer, sidebar');
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('menus');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('menu_items', function (Blueprint $table) {
$table->id();
$table->foreignId('menu_id')->constrained()->cascadeOnDelete();
$table->foreignId('parent_id')->nullable()->constrained('menu_items')->cascadeOnDelete();
$table->string('title');
$table->string('type')->default('link')->comment('link, module, route');
$table->string('url')->nullable()->comment('For external links');
$table->string('route')->nullable()->comment('For named routes');
$table->json('route_params')->nullable()->comment('Route parameters as JSON');
$table->string('module')->nullable()->comment('Module slug for permission checking');
$table->string('permission')->nullable()->comment('Specific permission required');
$table->string('icon')->nullable()->comment('Icon class or name');
$table->string('target')->default('_self')->comment('_self, _blank');
$table->integer('order')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('menu_items');
}
};

View File

@@ -12,8 +12,20 @@
public function up(): void
{
Schema::create('audits', function (Blueprint $table) {
$table->id();
$table->bigIncrements('id');
$table->string('user_type')->nullable();
$table->unsignedBigInteger('user_id')->nullable();
$table->string('event');
$table->morphs('auditable');
$table->text('old_values')->nullable();
$table->text('new_values')->nullable();
$table->text('url')->nullable();
$table->ipAddress('ip_address')->nullable();
$table->string('user_agent', 1023)->nullable();
$table->string('tags')->nullable();
$table->timestamps();
$table->index(['user_id', 'user_type']);
});
}

View File

@@ -1,4 +1,4 @@
<?php
<?php
namespace Database\Seeders;
@@ -13,6 +13,7 @@ public function run(): void
{
$this->call([
RolePermissionSeeder::class,
MenuSeeder::class,
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Database\Seeders;
use App\Models\Menu;
use App\Models\MenuItem;
use Illuminate\Database\Seeder;
class MenuSeeder extends Seeder
{
public function run(): void
{
$headerMenu = Menu::firstOrCreate(
['slug' => 'header'],
[
'name' => 'Header Navigation',
'location' => 'header',
'is_active' => true,
]
);
MenuItem::firstOrCreate(
['menu_id' => $headerMenu->id, 'title' => 'Dashboard'],
[
'type' => 'route',
'route' => 'dashboard',
'icon' => 'heroicon-o-home',
'order' => 1,
'is_active' => true,
]
);
}
}

View File

@@ -3,6 +3,7 @@
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\File;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use App\Models\User;
@@ -13,30 +14,77 @@ public function run(): void
{
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
$permissions = [
// Core permissions
$corePermissions = [
'users.view',
'users.create',
'users.edit',
'users.delete',
'settings.manage',
'audit.view',
'menus.view',
'menus.create',
'menus.edit',
'menus.delete',
];
foreach ($permissions as $permission) {
Permission::create(['name' => $permission]);
foreach ($corePermissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
$adminRole = Role::create(['name' => 'admin']);
$adminRole->givePermissionTo(Permission::all());
// Auto-load module permissions from app/Modules/*/Permissions.php
$this->loadModulePermissions();
$editorRole = Role::create(['name' => 'editor']);
$editorRole->givePermissionTo(['users.view', 'users.edit']);
// Create roles
$adminRole = Role::firstOrCreate(['name' => 'admin']);
$adminRole->syncPermissions(Permission::all());
$viewerRole = Role::create(['name' => 'viewer']);
$viewerRole->givePermissionTo(['users.view']);
$editorRole = Role::firstOrCreate(['name' => 'editor']);
$editorRole->syncPermissions(['users.view', 'users.edit']);
$admin = User::where('email', 'admin@example.com')->first();
if ($admin) {
$viewerRole = Role::firstOrCreate(['name' => 'viewer']);
$viewerRole->syncPermissions(['users.view']);
// Create admin user if not exists
$admin = User::firstOrCreate(
['email' => 'admin@example.com'],
[
'name' => 'Admin',
'password' => bcrypt('password'),
'email_verified_at' => now(),
]
);
$admin->assignRole('admin');
}
/**
* Scan all module Permissions.php files and register permissions.
*/
protected function loadModulePermissions(): void
{
$modulesPath = app_path('Modules');
if (!File::isDirectory($modulesPath)) {
return;
}
$modules = File::directories($modulesPath);
foreach ($modules as $modulePath) {
$permissionsFile = $modulePath . '/Permissions.php';
if (File::exists($permissionsFile)) {
$permissions = require $permissionsFile;
if (is_array($permissions)) {
foreach ($permissions as $permissionName => $description) {
// Handle both formats:
// ['permission.name' => 'Description'] or ['permission.name']
$name = is_string($permissionName) ? $permissionName : $description;
Permission::firstOrCreate(['name' => $name]);
}
}
}
}
}
}

2852
src/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
@if(isset($siteSettings['logo']) && $siteSettings['logo'])
<img src="{{ asset('storage/' . $siteSettings['logo']) }}" alt="{{ $siteSettings['name'] ?? 'Logo' }}" {{ $attributes->merge(['class' => 'object-contain']) }}>
@else
<svg viewBox="0 0 316 316" xmlns="http://www.w3.org/2000/svg" {{ $attributes }}>
<path d="M305.8 81.125C305.77 80.995 305.69 80.885 305.65 80.755C305.56 80.525 305.49 80.285 305.37 80.075C305.29 79.935 305.17 79.815 305.07 79.685C304.94 79.515 304.83 79.325 304.68 79.175C304.55 79.045 304.39 78.955 304.25 78.845C304.09 78.715 303.95 78.575 303.77 78.475L251.32 48.275C249.97 47.495 248.31 47.495 246.96 48.275L194.51 78.475C194.33 78.575 194.19 78.725 194.03 78.845C193.89 78.955 193.73 79.045 193.6 79.175C193.45 79.325 193.34 79.515 193.21 79.685C193.11 79.815 192.99 79.935 192.91 80.075C192.79 80.285 192.71 80.525 192.63 80.755C192.58 80.875 192.51 80.995 192.48 81.125C192.38 81.495 192.33 81.875 192.33 82.265V139.625L148.62 164.795V52.575C148.62 52.185 148.57 51.805 148.47 51.435C148.44 51.305 148.36 51.195 148.32 51.065C148.23 50.835 148.16 50.595 148.04 50.385C147.96 50.245 147.84 50.125 147.74 49.995C147.61 49.825 147.5 49.635 147.35 49.485C147.22 49.355 147.06 49.265 146.92 49.155C146.76 49.025 146.62 48.885 146.44 48.785L93.99 18.585C92.64 17.805 90.98 17.805 89.63 18.585L37.18 48.785C37 48.885 36.86 49.035 36.7 49.155C36.56 49.265 36.4 49.355 36.27 49.485C36.12 49.635 36.01 49.825 35.88 49.995C35.78 50.125 35.66 50.245 35.58 50.385C35.46 50.595 35.38 50.835 35.3 51.065C35.25 51.185 35.18 51.305 35.15 51.435C35.05 51.805 35 52.185 35 52.575V232.235C35 233.795 35.84 235.245 37.19 236.025L142.1 296.425C142.33 296.555 142.58 296.635 142.82 296.725C142.93 296.765 143.04 296.835 143.16 296.865C143.53 296.965 143.9 297.015 144.28 297.015C144.66 297.015 145.03 296.965 145.4 296.865C145.5 296.835 145.59 296.775 145.69 296.745C145.95 296.655 146.21 296.565 146.45 296.435L251.36 236.035C252.72 235.255 253.55 233.815 253.55 232.245V174.885L303.81 145.945C305.17 145.165 306 143.725 306 142.155V82.265C305.95 81.875 305.89 81.495 305.8 81.125ZM144.2 227.205L100.57 202.515L146.39 176.135L196.66 147.195L240.33 172.335L208.29 190.625L144.2 227.205ZM244.75 114.995V164.795L226.39 154.225L201.03 139.625V89.825L219.39 100.395L244.75 114.995ZM249.12 57.105L292.81 82.265L249.12 107.425L205.43 82.265L249.12 57.105ZM114.49 184.425L96.13 194.995V85.305L121.49 70.705L139.85 60.135V169.815L114.49 184.425ZM91.76 27.425L135.45 52.585L91.76 77.745L48.07 52.585L91.76 27.425ZM43.67 60.135L62.03 70.705L87.39 85.305V202.545V202.555V202.565C87.39 202.735 87.44 202.895 87.46 203.055C87.49 203.265 87.49 203.485 87.55 203.695V203.705C87.6 203.875 87.69 204.035 87.76 204.195C87.84 204.375 87.89 204.575 87.99 204.745C87.99 204.745 87.99 204.755 88 204.755C88.09 204.905 88.22 205.035 88.33 205.175C88.45 205.335 88.55 205.495 88.69 205.635L88.7 205.645C88.82 205.765 88.98 205.855 89.12 205.965C89.28 206.085 89.42 206.225 89.59 206.325C89.6 206.325 89.6 206.325 89.61 206.335C89.62 206.335 89.62 206.345 89.63 206.345L139.87 234.775V285.065L43.67 229.705V60.135ZM244.75 229.705L148.58 285.075V234.775L219.8 194.115L244.75 179.875V229.705ZM297.2 139.625L253.49 164.795V114.995L278.85 100.395L297.21 89.825V139.625H297.2Z"/>
</svg>
</svg>
@endif

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -0,0 +1,47 @@
@if($hasItems())
<div {{ $attributes->merge(['class' => $class]) }}>
@foreach($items as $item)
@if($item['has_children'])
{{-- Collapsible menu item with children --}}
<div x-data="{ open: false }">
<button type="button"
@click="open = !open"
class="w-full flex items-center justify-between px-4 py-2 text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 {{ $item['is_active'] ? 'text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700' : '' }}">
<span class="flex items-center">
@if($item['icon'])
<x-dynamic-component :component="$item['icon']" class="w-5 h-5 mr-2" />
@endif
{{ $item['title'] }}
</span>
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<div x-show="open" x-collapse class="pl-4">
@foreach($item['children'] as $child)
<a href="{{ $child['url'] }}"
target="{{ $child['target'] }}"
class="block px-4 py-2 text-base font-medium text-gray-500 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 {{ $child['is_active'] ? 'text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700' : '' }}">
@if($child['icon'])
<x-dynamic-component :component="$child['icon']" class="inline w-4 h-4 mr-1" />
@endif
{{ $child['title'] }}
</a>
@endforeach
</div>
</div>
@else
{{-- Regular menu item --}}
<a href="{{ $item['url'] }}"
target="{{ $item['target'] }}"
class="block px-4 py-2 text-base font-medium text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 {{ $item['is_active'] ? 'text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 border-l-4 border-indigo-500' : '' }}">
@if($item['icon'])
<x-dynamic-component :component="$item['icon']" class="inline w-5 h-5 mr-2" />
@endif
{{ $item['title'] }}
</a>
@endif
@endforeach
</div>
@endif

View File

@@ -0,0 +1,60 @@
@if($hasItems())
<nav {{ $attributes->merge(['class' => $class]) }}>
<ul class="flex items-center space-x-4">
@foreach($items as $item)
<li class="relative group">
@if($item['has_children'])
{{-- Dropdown menu item --}}
<button type="button"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 focus:outline-none transition duration-150 ease-in-out {{ $item['is_active'] ? 'text-gray-900 dark:text-gray-100' : '' }}"
x-data="{ open: false }"
@click="open = !open"
@click.away="open = false">
@if($item['icon'])
<x-dynamic-component :component="$item['icon']" class="w-4 h-4 mr-1" />
@endif
{{ $item['title'] }}
<svg class="ml-1 w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
{{-- Dropdown content --}}
<div x-show="open"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="transform opacity-0 scale-95"
x-transition:enter-end="transform opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75"
x-transition:leave-start="transform opacity-100 scale-100"
x-transition:leave-end="transform opacity-0 scale-95"
class="absolute left-0 mt-2 w-48 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
style="display: none;">
<div class="py-1">
@foreach($item['children'] as $child)
<a href="{{ $child['url'] }}"
target="{{ $child['target'] }}"
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 {{ $child['is_active'] ? 'bg-gray-100 dark:bg-gray-700' : '' }}">
@if($child['icon'])
<x-dynamic-component :component="$child['icon']" class="inline w-4 h-4 mr-1" />
@endif
{{ $child['title'] }}
</a>
@endforeach
</div>
</div>
@else
{{-- Regular menu item --}}
<a href="{{ $item['url'] }}"
target="{{ $item['target'] }}"
class="inline-flex items-center px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 transition duration-150 ease-in-out {{ $item['is_active'] ? 'text-gray-900 dark:text-gray-100 border-b-2 border-indigo-500' : '' }}">
@if($item['icon'])
<x-dynamic-component :component="$item['icon']" class="w-4 h-4 mr-1" />
@endif
{{ $item['title'] }}
</a>
@endif
</li>
@endforeach
</ul>
</nav>
@endif

View File

@@ -0,0 +1,63 @@
<div class="space-y-4">
@php
$oldValues = $getRecord()->old_values ?? [];
$newValues = $getRecord()->new_values ?? [];
$allKeys = array_unique(array_merge(array_keys($oldValues), array_keys($newValues)));
@endphp
@if(count($allKeys) === 0)
<p class="text-sm text-gray-500 dark:text-gray-400 italic">No changes recorded.</p>
@else
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800">
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">Field</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">Old Value</th>
<th class="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300 border dark:border-gray-700">New Value</th>
</tr>
</thead>
<tbody>
@foreach($allKeys as $key)
@php
$oldValue = $oldValues[$key] ?? null;
$newValue = $newValues[$key] ?? null;
$hasChanged = $oldValue !== $newValue;
@endphp
<tr class="{{ $hasChanged ? 'bg-yellow-50 dark:bg-yellow-900/20' : '' }}">
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-200 border dark:border-gray-700">
{{ \Illuminate\Support\Str::headline($key) }}
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300 border dark:border-gray-700">
@if(is_null($oldValue))
<span class="text-gray-400 italic">(empty)</span>
@elseif(is_array($oldValue))
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">{{ json_encode($oldValue) }}</code>
@elseif(is_bool($oldValue))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ $oldValue ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' }}">
{{ $oldValue ? 'Yes' : 'No' }}
</span>
@else
{{ \Illuminate\Support\Str::limit((string) $oldValue, 100) }}
@endif
</td>
<td class="px-4 py-2 text-gray-600 dark:text-gray-300 border dark:border-gray-700">
@if(is_null($newValue))
<span class="text-gray-400 italic">(empty)</span>
@elseif(is_array($newValue))
<code class="text-xs bg-gray-100 dark:bg-gray-700 px-1 py-0.5 rounded">{{ json_encode($newValue) }}</code>
@elseif(is_bool($newValue))
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {{ $newValue ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100' : 'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100' }}">
{{ $newValue ? 'Yes' : 'No' }}
</span>
@else
{{ \Illuminate\Support\Str::limit((string) $newValue, 100) }}
@endif
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>

View File

@@ -0,0 +1,315 @@
<x-filament-panels::page>
<div class="space-y-6">
{{-- Warning Banner --}}
<div class="p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div class="flex items-start gap-3">
<x-heroicon-o-exclamation-triangle class="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div class="text-sm text-amber-800 dark:text-amber-200">
<strong>Development Only:</strong> This tool is only available in the local environment.
It creates module skeletons and optionally manages Git branches.
</div>
</div>
</div>
{{-- Registered Modules Section --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<x-heroicon-o-cube-transparent class="w-5 h-5" />
Registered Modules
</h3>
<div class="flex gap-4 text-sm">
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
{{ $summary['total'] ?? 0 }} Total
</span>
<span class="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded">
{{ $summary['with_models'] ?? 0 }} with Models
</span>
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
{{ $summary['with_filament'] ?? 0 }} with Filament
</span>
<span class="px-2 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded">
{{ $summary['with_api'] ?? 0 }} with API
</span>
</div>
</div>
</div>
@if(empty($modules))
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<x-heroicon-o-inbox class="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>No modules registered yet. Create your first module below!</p>
</div>
@else
<div class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($modules as $module)
<div class="group">
{{-- Module Header --}}
<button
type="button"
wire:click="toggleModule('{{ $module['name'] }}')"
class="w-full p-4 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
>
<div class="flex items-center gap-3">
<x-heroicon-o-cube class="w-5 h-5 text-gray-400" />
<div class="text-left">
<div class="font-medium text-gray-900 dark:text-white">{{ $module['name'] }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
/{{ $module['slug'] }}
@if($module['has_api'])
<span class="ml-2 text-xs px-1.5 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-600 dark:text-orange-400 rounded">API</span>
@endif
</div>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex gap-2 text-xs">
@if(count($module['models']) > 0)
<span class="px-2 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded">
{{ count($module['models']) }} {{ Str::plural('Model', count($module['models'])) }}
</span>
@endif
@if(count($module['views']) > 0)
<span class="px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded">
{{ count($module['views']) }} {{ Str::plural('View', count($module['views'])) }}
</span>
@endif
@if(count($module['filament_resources']) > 0)
<span class="px-2 py-1 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded">
{{ count($module['filament_resources']) }} {{ Str::plural('Resource', count($module['filament_resources'])) }}
</span>
@endif
</div>
<x-heroicon-o-chevron-down class="w-5 h-5 text-gray-400 transition-transform {{ $expandedModule === $module['name'] ? 'rotate-180' : '' }}" />
</div>
</button>
{{-- Module Details (Expandable) --}}
@if($expandedModule === $module['name'])
<div class="px-4 pb-4 bg-gray-50 dark:bg-gray-900/50">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 pt-4">
{{-- Models --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-circle-stack class="w-4 h-4" />
Models
</h4>
@if(count($module['models']) > 0)
<ul class="text-sm space-y-1">
@foreach($module['models'] as $model)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs">{{ $model['name'] }}</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No models</p>
@endif
</div>
{{-- Views --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-eye class="w-4 h-4" />
Views
</h4>
@if(count($module['views']) > 0)
<ul class="text-sm space-y-1">
@foreach($module['views'] as $view)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs">{{ $module['slug'] }}::{{ $view['name'] }}</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No views</p>
@endif
</div>
{{-- Filament Resources --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-rectangle-group class="w-4 h-4" />
Filament Resources
</h4>
@if(count($module['filament_resources']) > 0)
<ul class="text-sm space-y-1">
@foreach($module['filament_resources'] as $resource)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs">{{ $resource['name'] }}Resource</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No Filament resources</p>
@endif
</div>
{{-- Routes --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-arrow-path class="w-4 h-4" />
Routes
</h4>
@if(!empty($module['routes']['web']))
<ul class="text-sm space-y-1">
@foreach($module['routes']['web'] as $route)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs">
<span class="text-blue-600 dark:text-blue-400">{{ $route['method'] }}</span>
{{ $route['uri'] }}
</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No web routes parsed</p>
@endif
</div>
{{-- Migrations --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-table-cells class="w-4 h-4" />
Migrations
</h4>
@if(count($module['migrations']) > 0)
<ul class="text-sm space-y-1">
@foreach($module['migrations'] as $migration)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs truncate" title="{{ $migration['name'] }}">{{ $migration['name'] }}</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No migrations</p>
@endif
</div>
{{-- Permissions --}}
<div class="bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700">
<h4 class="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
<x-heroicon-o-key class="w-4 h-4" />
Permissions
</h4>
@if(count($module['permissions']) > 0)
<ul class="text-sm space-y-1">
@foreach($module['permissions'] as $key => $label)
<li class="text-gray-600 dark:text-gray-400 font-mono text-xs">{{ $key }}</li>
@endforeach
</ul>
@else
<p class="text-xs text-gray-400 italic">No permissions</p>
@endif
</div>
</div>
{{-- Module Path --}}
<div class="mt-4 p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs font-mono text-gray-500 dark:text-gray-400">
Path: {{ $module['path'] }}
</div>
</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
{{-- Create Module Form --}}
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
<x-heroicon-o-plus-circle class="w-5 h-5" />
Create New Module
</h3>
<form wire:submit="generate">
{{ $this->form }}
<div class="mt-6 flex gap-3">
<x-filament::button type="submit" size="lg">
<x-heroicon-o-sparkles class="w-5 h-5 mr-2" />
Generate Module
</x-filament::button>
</div>
</form>
</div>
{{-- Result Panel --}}
@if($result)
<div class="mt-8">
@if($result['success'])
<div class="p-6 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<div class="flex items-center gap-3 mb-4">
<x-heroicon-o-check-circle class="w-6 h-6 text-green-600 dark:text-green-400" />
<h3 class="text-lg font-semibold text-green-800 dark:text-green-200">
Module Created Successfully!
</h3>
</div>
<div class="space-y-4 text-sm">
<div>
<span class="font-medium text-green-700 dark:text-green-300">Module:</span>
<code class="ml-2 px-2 py-1 bg-green-100 dark:bg-green-800 rounded">{{ $result['module_name'] }}</code>
</div>
<div>
<span class="font-medium text-green-700 dark:text-green-300">Path:</span>
<code class="ml-2 px-2 py-1 bg-green-100 dark:bg-green-800 rounded text-xs">{{ $result['module_path'] }}</code>
</div>
@if($result['branch'])
<div>
<span class="font-medium text-green-700 dark:text-green-300">Git Branch:</span>
<code class="ml-2 px-2 py-1 bg-green-100 dark:bg-green-800 rounded">{{ $result['branch'] }}</code>
</div>
@endif
{{-- Logs --}}
<div class="mt-4">
<span class="font-medium text-green-700 dark:text-green-300">Generation Log:</span>
<div class="mt-2 p-3 bg-white dark:bg-gray-800 rounded border border-green-200 dark:border-green-700 font-mono text-xs">
@foreach($result['logs'] as $log)
<div class="py-0.5">{{ $log }}</div>
@endforeach
</div>
</div>
{{-- Next Steps --}}
<div class="mt-4">
<span class="font-medium text-green-700 dark:text-green-300">Next Steps:</span>
<ol class="mt-2 list-decimal list-inside space-y-1 text-green-800 dark:text-green-200">
@foreach($result['next_steps'] as $step)
<li>{!! preg_replace('/`([^`]+)`/', '<code class="px-1 py-0.5 bg-green-100 dark:bg-green-800 rounded text-xs">$1</code>', e($step)) !!}</li>
@endforeach
</ol>
</div>
</div>
<div class="mt-6">
<x-filament::button color="gray" wire:click="clearResult">
Create Another Module
</x-filament::button>
</div>
</div>
@else
<div class="p-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<div class="flex items-center gap-3 mb-4">
<x-heroicon-o-x-circle class="w-6 h-6 text-red-600 dark:text-red-400" />
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200">
Module Creation Failed
</h3>
</div>
<p class="text-red-700 dark:text-red-300">{{ $result['message'] }}</p>
@if(!empty($result['logs']))
<div class="mt-4 p-3 bg-white dark:bg-gray-800 rounded border border-red-200 dark:border-red-700 font-mono text-xs">
@foreach($result['logs'] as $log)
<div class="py-0.5">{{ $log }}</div>
@endforeach
</div>
@endif
<div class="mt-6">
<x-filament::button color="gray" wire:click="clearResult">
Try Again
</x-filament::button>
</div>
</div>
@endif
</div>
@endif
</div>
</x-filament-panels::page>

View File

@@ -5,16 +5,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>{{ $siteSettings['name'] ?? config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Dynamic Colors from Settings -->
<style>
:root {
--primary-color: {{ $siteSettings['primary_color'] ?? '#3b82f6' }};
--secondary-color: {{ $siteSettings['secondary_color'] ?? '#8b5cf6' }};
--accent-color: {{ $siteSettings['accent_color'] ?? '#10b981' }};
}
</style>
<!-- Scripts -->
@if(\App\Helpers\ViteHelper::manifestExists())
@vite(['resources/css/app.css', 'resources/js/app.js'])
@else
{!! \App\Helpers\ViteHelper::fallbackStyles() !!}
@endif
</head>
<body class="font-sans antialiased">
@if(!\App\Helpers\ViteHelper::manifestExists())
{!! \App\Helpers\ViteHelper::devWarningBanner() !!}
@endif
<div class="min-h-screen bg-gray-100 dark:bg-gray-900">
@include('layouts.navigation')

View File

@@ -5,16 +5,32 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<title>{{ $siteSettings['name'] ?? config('app.name', 'Laravel') }}</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600&display=swap" rel="stylesheet" />
<!-- Dynamic Colors from Settings -->
<style>
:root {
--primary-color: {{ $siteSettings['primary_color'] ?? '#3b82f6' }};
--secondary-color: {{ $siteSettings['secondary_color'] ?? '#8b5cf6' }};
--accent-color: {{ $siteSettings['accent_color'] ?? '#10b981' }};
}
</style>
<!-- Scripts -->
@if(\App\Helpers\ViteHelper::manifestExists())
@vite(['resources/css/app.css', 'resources/js/app.js'])
@else
{!! \App\Helpers\ViteHelper::fallbackStyles() !!}
@endif
</head>
<body class="font-sans text-gray-900 antialiased">
@if(!\App\Helpers\ViteHelper::manifestExists())
{!! \App\Helpers\ViteHelper::devWarningBanner() !!}
@endif
<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100 dark:bg-gray-900">
<div>
<a href="/">

View File

@@ -11,10 +11,8 @@
</div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<div class="hidden sm:-my-px sm:ms-10 sm:flex sm:items-center">
<x-frontend-menu menu="header" />
</div>
</div>
@@ -67,9 +65,7 @@
<!-- Responsive Navigation Menu -->
<div :class="{'block': open, 'hidden': ! open}" class="hidden sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<x-responsive-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-responsive-nav-link>
<x-frontend-menu-responsive menu="header" />
</div>
<!-- Responsive Settings Options -->

View File

@@ -12,10 +12,12 @@
use Illuminate\Support\Facades\Route;
Route::middleware('guest')->group(function () {
Route::middleware('registration.enabled')->group(function () {
Route::get('register', [RegisteredUserController::class, 'create'])
->name('register');
Route::post('register', [RegisteredUserController::class, 'store']);
});
Route::get('login', [AuthenticatedSessionController::class, 'create'])
->name('login');

View File

@@ -4,7 +4,7 @@
use Illuminate\Support\Facades\Route;
Route::get('/', function () {
return view('welcome');
return redirect()->route('login');
});
Route::get('/dashboard', function () {