diff --git a/AI/Config/config.php b/AI/Config/config.php
new file mode 100644
index 0000000..573f94d
--- /dev/null
+++ b/AI/Config/config.php
@@ -0,0 +1,9 @@
+ env('AI_DEFAULT_PROVIDER'),
+ 'timeout' => (int) env('AI_TIMEOUT', 60),
+ 'max_output_tokens' => (int) env('AI_MAX_OUTPUT_TOKENS', 2048),
+ 'temperature' => (float) env('AI_TEMPERATURE', 0.3),
+ 'retain_executions_days' => (int) env('AI_RETAIN_EXECUTIONS_DAYS', 90),
+];
diff --git a/AI/Console/PruneAIExecutions.php b/AI/Console/PruneAIExecutions.php
new file mode 100644
index 0000000..5993517
--- /dev/null
+++ b/AI/Console/PruneAIExecutions.php
@@ -0,0 +1,22 @@
+option('days') ?: config('ai.retain_executions_days', 90));
+ $count = AIExecution::where('created_at', '<', now()->subDays($days))->delete();
+ $this->info("Deleted {$count} AI executions.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/AI/Console/TestAIProvider.php b/AI/Console/TestAIProvider.php
new file mode 100644
index 0000000..7b192bd
--- /dev/null
+++ b/AI/Console/TestAIProvider.php
@@ -0,0 +1,21 @@
+testProvider((int) $this->argument('provider'));
+ $this->{$result['ok'] ? 'info' : 'error'}($result['message']);
+
+ return $result['ok'] ? self::SUCCESS : self::FAILURE;
+ }
+}
diff --git a/AI/Contracts/AIManager.php b/AI/Contracts/AIManager.php
new file mode 100644
index 0000000..d7cfe3a
--- /dev/null
+++ b/AI/Contracts/AIManager.php
@@ -0,0 +1,13 @@
+id();
+ $table->string('name');
+ $table->string('driver', 40)->default('openai_compatible');
+ $table->string('base_url')->nullable();
+ $table->text('api_key')->nullable();
+ $table->string('default_model')->nullable();
+ $table->json('models')->nullable();
+ $table->json('options')->nullable();
+ $table->boolean('is_active')->default(true)->index();
+ $table->boolean('is_default')->default(false)->index();
+ $table->timestamp('last_tested_at')->nullable();
+ $table->string('last_test_status', 20)->nullable();
+ $table->text('last_test_message')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('ai_prompts', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('purpose')->nullable()->index();
+ $table->text('system_prompt')->nullable();
+ $table->longText('user_template');
+ $table->json('variables')->nullable();
+ $table->unsignedInteger('version')->default(1);
+ $table->boolean('is_active')->default(true)->index();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ });
+ Schema::create('ai_executions', function (Blueprint $table) {
+ $table->id();
+ $table->uuid('execution_id')->unique();
+ $table->foreignId('ai_provider_id')->nullable()->constrained('ai_providers')->nullOnDelete();
+ $table->foreignId('ai_prompt_id')->nullable()->constrained('ai_prompts')->nullOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('model')->nullable();
+ $table->string('purpose')->nullable()->index();
+ $table->longText('input')->nullable();
+ $table->longText('output')->nullable();
+ $table->json('context')->nullable();
+ $table->json('usage')->nullable();
+ $table->string('status', 20)->default('pending')->index();
+ $table->text('error')->nullable();
+ $table->unsignedInteger('duration_ms')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('ai_knowledge_sources', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('source_kind', 40)->default('manual')->index();
+ $table->nullableMorphs('source');
+ $table->longText('content')->nullable();
+ $table->string('checksum', 64)->nullable()->index();
+ $table->json('metadata')->nullable();
+ $table->string('status', 20)->default('pending')->index();
+ $table->unsignedInteger('chunk_count')->default(0);
+ $table->timestamp('indexed_at')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('ai_knowledge_chunks', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('ai_knowledge_source_id')->constrained('ai_knowledge_sources')->cascadeOnDelete();
+ $table->unsignedInteger('position');
+ $table->longText('content');
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ $table->unique(['ai_knowledge_source_id', 'position']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('ai_knowledge_chunks');
+ Schema::dropIfExists('ai_knowledge_sources');
+ Schema::dropIfExists('ai_executions');
+ Schema::dropIfExists('ai_prompts');
+ Schema::dropIfExists('ai_providers');
+ }
+};
diff --git a/AI/Drivers/OpenAICompatibleDriver.php b/AI/Drivers/OpenAICompatibleDriver.php
new file mode 100644
index 0000000..74a7d5a
--- /dev/null
+++ b/AI/Drivers/OpenAICompatibleDriver.php
@@ -0,0 +1,36 @@
+timeout((int) config('ai.timeout', 60));
+
+ return filled($provider->api_key) ? $client->withToken($provider->api_key) : $client;
+ }
+
+ public function generate(AIProvider $provider, array $messages, array $options = []): array
+ {
+ $base = rtrim((string) ($provider->base_url ?: 'https://api.openai.com/v1'), '/');
+ $payload = ['model' => $options['model'] ?? $provider->default_model, 'messages' => $messages, 'temperature' => $options['temperature'] ?? config('ai.temperature', 0.3), 'max_tokens' => $options['max_tokens'] ?? config('ai.max_output_tokens', 2048)];
+ $response = $this->client($provider)->post($base.'/chat/completions', array_filter($payload, fn ($v) => $v !== null));
+ $response->throw();
+ $json = $response->json();
+
+ return ['text' => (string) data_get($json, 'choices.0.message.content', ''), 'usage' => (array) data_get($json, 'usage', []), 'raw' => $json];
+ }
+
+ public function test(AIProvider $provider): array
+ {
+ $result = $this->generate($provider, [['role' => 'user', 'content' => 'Reply with OK only.']], ['max_tokens' => 8, 'temperature' => 0]);
+
+ return ['ok' => true, 'message' => trim($result['text']) ?: 'Provider responded successfully.'];
+ }
+}
diff --git a/AI/Http/Controllers/AIApiController.php b/AI/Http/Controllers/AIApiController.php
new file mode 100644
index 0000000..77000a1
--- /dev/null
+++ b/AI/Http/Controllers/AIApiController.php
@@ -0,0 +1,20 @@
+validate(['variables' => 'nullable|array', 'provider_id' => 'nullable|integer', 'model' => 'nullable|string|max:150']);
+ $execution = $manager->execute($prompt, $data['variables'] ?? [], array_filter(['provider_id' => $data['provider_id'] ?? null, 'model' => $data['model'] ?? null]));
+
+ return response()->json(['data' => ['execution_id' => $execution->execution_id, 'status' => $execution->status, 'output' => $execution->output, 'usage' => $execution->usage, 'error' => $execution->error]], $execution->status === 'completed' ? 200 : 422);
+ }
+}
diff --git a/AI/Http/Controllers/AIController.php b/AI/Http/Controllers/AIController.php
new file mode 100644
index 0000000..96c1f5f
--- /dev/null
+++ b/AI/Http/Controllers/AIController.php
@@ -0,0 +1,69 @@
+ AIProvider::latest()->get(), 'prompts' => AIPrompt::latest()->paginate(15, '*', 'prompts'), 'executions' => AIExecution::latest()->limit(15)->get(), 'sources' => AIKnowledgeSource::latest()->limit(15)->get()]);
+ }
+
+ public function storeProvider(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => 'required|string|max:100', 'driver' => 'required|in:openai_compatible,openai,lm_studio,ollama', 'base_url' => 'nullable|url', 'api_key' => 'nullable|string', 'default_model' => 'required|string|max:150', 'is_default' => 'nullable|boolean']);
+ if ($request->boolean('is_default')) {
+ AIProvider::query()->update(['is_default' => false]);
+ }
+ $data['is_default'] = $request->boolean('is_default');
+ AIProvider::create($data);
+
+ return back()->with('status', 'AI provider created.');
+ }
+
+ public function testProvider(AIProvider $provider, AIManager $manager): RedirectResponse
+ {
+ $result = $manager->testProvider($provider->id);
+
+ return back()->with($result['ok'] ? 'status' : 'error', $result['message']);
+ }
+
+ public function storePrompt(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'nullable|alpha_dash|max:140', 'purpose' => 'nullable|string|max:100', 'system_prompt' => 'nullable|string', 'user_template' => 'required|string']);
+ $data['slug'] = $data['slug'] ?: Str::slug($data['name']);
+ $data['created_by'] = auth()->id();
+ AIPrompt::create($data);
+
+ return back()->with('status', 'Prompt created.');
+ }
+
+ public function execute(Request $request, AIPrompt $prompt, AIManager $manager): RedirectResponse
+ {
+ $data = $request->validate(['input' => 'required|string|max:50000', 'provider_id' => 'nullable|exists:ai_providers,id']);
+ $execution = $manager->execute($prompt, ['input' => $data['input']], ['provider_id' => $data['provider_id'] ?? null]);
+
+ return back()->with($execution->status === 'completed' ? 'status' : 'error', $execution->status === 'completed' ? 'AI execution completed.' : $execution->error);
+ }
+
+ public function storeSource(Request $request, KnowledgeIndexer $indexer): RedirectResponse
+ {
+ $data = $request->validate(['name' => 'required|string|max:150', 'content' => 'required|string|max:1000000']);
+ $source = AIKnowledgeSource::create(['name' => $data['name'], 'source_kind' => 'manual', 'content' => $data['content'], 'status' => 'pending']);
+ $indexer->index($source);
+
+ return back()->with('status','Knowledge source indexed.');
+ }
+}
diff --git a/AI/Models/AIExecution.php b/AI/Models/AIExecution.php
new file mode 100644
index 0000000..62ea778
--- /dev/null
+++ b/AI/Models/AIExecution.php
@@ -0,0 +1,12 @@
+ 'array', 'usage' => 'array', 'completed_at' => 'datetime'];
+}
diff --git a/AI/Models/AIKnowledgeChunk.php b/AI/Models/AIKnowledgeChunk.php
new file mode 100644
index 0000000..632ed3c
--- /dev/null
+++ b/AI/Models/AIKnowledgeChunk.php
@@ -0,0 +1,12 @@
+ 'array'];
+}
diff --git a/AI/Models/AIKnowledgeSource.php b/AI/Models/AIKnowledgeSource.php
new file mode 100644
index 0000000..f863a52
--- /dev/null
+++ b/AI/Models/AIKnowledgeSource.php
@@ -0,0 +1,24 @@
+ 'array', 'indexed_at' => 'datetime'];
+
+ public function source(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function chunks(): HasMany
+ {
+ return $this->hasMany(AIKnowledgeChunk::class);
+ }
+}
diff --git a/AI/Models/AIPrompt.php b/AI/Models/AIPrompt.php
new file mode 100644
index 0000000..5e1a23f
--- /dev/null
+++ b/AI/Models/AIPrompt.php
@@ -0,0 +1,17 @@
+ 'array', 'is_active' => 'boolean'];
+
+ public function render(array $variables): string
+ {
+ return preg_replace_callback('/\{\{\s*([\w.]+)\s*\}\}/', fn ($m) => (string) data_get($variables, $m[1], ''), $this->user_template) ?? $this->user_template;
+ }
+}
diff --git a/AI/Models/AIProvider.php b/AI/Models/AIProvider.php
new file mode 100644
index 0000000..f96e235
--- /dev/null
+++ b/AI/Models/AIProvider.php
@@ -0,0 +1,14 @@
+ 'encrypted', 'models' => 'array', 'options' => 'array', 'is_active' => 'boolean', 'is_default' => 'boolean', 'last_tested_at' => 'datetime'];
+
+ protected $hidden = ['api_key'];
+}
diff --git a/AI/Providers/AIServiceProvider.php b/AI/Providers/AIServiceProvider.php
new file mode 100644
index 0000000..e7fec1b
--- /dev/null
+++ b/AI/Providers/AIServiceProvider.php
@@ -0,0 +1,28 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('AI', 'Config/config.php'), 'ai');
+ $this->app->singleton(AIManagerContract::class, AIManager::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('AI', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('AI', 'Resources/views'), 'ai');
+ if ($this->app->runningInConsole()) {
+ $this->commands([TestAIProvider::class, PruneAIExecutions::class]);
+ }
+ }
+}
diff --git a/AI/Providers/RouteServiceProvider.php b/AI/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..4306ef6
--- /dev/null
+++ b/AI/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('AI', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api/v1')->group(module_path('AI', 'Routes/api.php'));
+ }
+}
diff --git a/AI/Resources/views/admin/index.blade.php b/AI/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..3948fa0
--- /dev/null
+++ b/AI/Resources/views/admin/index.blade.php
@@ -0,0 +1,21 @@
+
+
+
AI Platform Configure hosted or local models, reusable prompts, execution history and knowledge sources.
+@if(session('status'))
{{ session('status') }} @endif
+@if(session('error'))
{{ session('error') }} @endif
+
+Providers {{ $providers->count() }}
+Prompts {{ $prompts->total() }}
+Executions {{ \Modules\AI\Models\AIExecution::count() }}
+Knowledge sources {{ \Modules\AI\Models\AIKnowledgeSource::count() }}
+
+
+
Provider
+@foreach($providers as $provider)
{{ $provider->name }} {{ $provider->driver }} · {{ $provider->default_model }}
@endforeach
+
Reusable prompt
+
Knowledge source @foreach($sources as $source)
{{ $source->name }} {{ $source->status }} · {{ $source->chunk_count }} chunks
@endforeach
+
Recent executions @forelse($executions as $execution)
{{ $execution->purpose ?: 'Prompt execution' }} {{ $execution->status }}
{{ $execution->model }} · {{ $execution->duration_ms }} ms · {{ $execution->created_at?->diffForHumans() }}
@empty
No executions yet. @endforelse
+
diff --git a/AI/Routes/api.php b/AI/Routes/api.php
new file mode 100644
index 0000000..775f54c
--- /dev/null
+++ b/AI/Routes/api.php
@@ -0,0 +1,6 @@
+middleware(['auth.api', 'ability:ai.execute'])->name('api.ai.execute');
diff --git a/AI/Routes/web.php b/AI/Routes/web.php
new file mode 100644
index 0000000..c2acf21
--- /dev/null
+++ b/AI/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/ai')->name('administration.ai.')->group(function () {
+ Route::get('/', [AIController::class, 'index'])->name('index');
+ Route::post('/providers', [AIController::class, 'storeProvider'])->middleware('permission:ai.manage')->name('providers.store');
+ Route::post('/providers/{provider}/test', [AIController::class, 'testProvider'])->middleware('permission:ai.manage')->name('providers.test');
+ Route::post('/prompts', [AIController::class, 'storePrompt'])->middleware('permission:ai.manage')->name('prompts.store');
+ Route::post('/prompts/{prompt}/execute', [AIController::class, 'execute'])->middleware('permission:ai.execute')->name('prompts.execute');
+ Route::post('/knowledge', [AIController::class, 'storeSource'])->middleware('permission:ai.manage')->name('knowledge.store');
+});
diff --git a/AI/Services/AIManager.php b/AI/Services/AIManager.php
new file mode 100644
index 0000000..2f1c2c0
--- /dev/null
+++ b/AI/Services/AIManager.php
@@ -0,0 +1,65 @@
+where('is_active', true)->firstOrFail() : $prompt;
+ $provider = $this->provider($options['provider_id'] ?? null);
+ $input = $promptModel->render($variables);
+ $execution = AIExecution::create(['execution_id' => (string) Str::uuid(), 'ai_provider_id' => $provider->id, 'ai_prompt_id' => $promptModel->id, 'user_id' => auth()->id(), 'model' => $options['model'] ?? $provider->default_model, 'purpose' => $promptModel->purpose, 'input' => $input, 'context' => $variables, 'status' => 'running']);
+ $started = microtime(true);
+ try {
+ $messages = [];
+ if (filled($promptModel->system_prompt)) {
+ $messages[] = ['role' => 'system', 'content' => $promptModel->system_prompt];
+ }
+ $messages[] = ['role' => 'user', 'content' => $input];
+ $result = $this->driver($provider)->generate($provider, $messages, $options);
+ $execution->update(['output' => $result['text'], 'usage' => $result['usage'], 'status' => 'completed', 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]);
+ } catch (\Throwable $e) {
+ report($e);
+ $execution->update(['status' => 'failed', 'error' => $e->getMessage(), 'duration_ms' => (int) ((microtime(true) - $started) * 1000), 'completed_at' => now()]);
+ }
+
+ return $execution->refresh();
+ }
+
+ public function testProvider(int $providerId): array
+ {
+ $provider = AIProvider::findOrFail($providerId);
+ try {
+ $result = $this->driver($provider)->test($provider);
+ $provider->update(['last_tested_at' => now(), 'last_test_status' => 'passed', 'last_test_message' => $result['message']]);
+
+ return $result;
+ } catch (\Throwable $e) {
+ $provider->update(['last_tested_at' => now(), 'last_test_status' => 'failed', 'last_test_message' => $e->getMessage()]);
+
+ return ['ok' => false, 'message' => $e->getMessage()];
+ }
+ }
+
+ private function provider(?int $id): AIProvider
+ {
+ return AIProvider::query()->when($id, fn ($q) => $q->whereKey($id))->when(! $id, fn ($q) => $q->where('is_default', true))->where('is_active', true)->first()
+ ?? AIProvider::where('is_active', true)->firstOrFail();
+ }
+
+ private function driver(AIProvider $provider): AIProviderDriver
+ {
+ return match ($provider->driver) {
+ 'openai_compatible','openai','lm_studio','ollama' => app(OpenAICompatibleDriver::class), default => throw new \InvalidArgumentException("Unsupported AI driver [{$provider->driver}].")
+ };
+ }
+}
diff --git a/AI/Services/KnowledgeIndexer.php b/AI/Services/KnowledgeIndexer.php
new file mode 100644
index 0000000..9c21d83
--- /dev/null
+++ b/AI/Services/KnowledgeIndexer.php
@@ -0,0 +1,39 @@
+content);
+ $source->chunks()->delete();
+ $chunks = $content === '' ? [] : $this->split($content, max(500, $chunkSize));
+ foreach ($chunks as $position => $chunk) {
+ $source->chunks()->create(['position' => $position + 1, 'content' => $chunk]);
+ }
+ $source->update(['checksum' => hash('sha256', $content), 'status' => 'indexed', 'chunk_count' => count($chunks), 'indexed_at' => now()]);
+
+ return $source->refresh();
+ }
+
+ private function split(string $content, int $size): array
+ {
+ $paragraphs = preg_split('/\R{2,}/', $content) ?: [$content];
+ $chunks = [];
+ $buffer = '';
+ foreach ($paragraphs as $paragraph) {
+ if (strlen($buffer) + strlen($paragraph) + 2 > $size && $buffer !== '') {
+ $chunks[] = trim($buffer);
+ $buffer = '';
+ } $buffer .= $paragraph."\n\n";
+ }
+ if (trim($buffer) !== '') {
+ $chunks[] = trim($buffer);
+ }
+
+return $chunks;
+ }
+}
diff --git a/AI/Tests/Feature/ModuleBootTest.php b/AI/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..ebc1cac
--- /dev/null
+++ b/AI/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,16 @@
+assertTrue(class_exists(AIServiceProvider::class));
+ $this->assertTrue(app()->bound(AIManager::class));
+ }
+}
diff --git a/AI/module.json b/AI/module.json
new file mode 100644
index 0000000..dda2c66
--- /dev/null
+++ b/AI/module.json
@@ -0,0 +1 @@
+{"name":"AI","alias":"ai","version":"1.0.0","description":"Provider-neutral artificial intelligence services, reusable prompts, execution history and knowledge sources.","keywords":["ai","llm","prompts","knowledge","rag"],"priority":140,"providers":["Modules\\AI\\Providers\\AIServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Media","Documents","Automation"]}
diff --git a/Administration/Config/config.php b/Administration/Config/config.php
new file mode 100644
index 0000000..bb128b8
--- /dev/null
+++ b/Administration/Config/config.php
@@ -0,0 +1,6 @@
+ 'Administration',
+ 'enabled' => true,
+];
diff --git a/Administration/Http/Controllers/DashboardController.php b/Administration/Http/Controllers/DashboardController.php
new file mode 100644
index 0000000..fd399d5
--- /dev/null
+++ b/Administration/Http/Controllers/DashboardController.php
@@ -0,0 +1,82 @@
+backend()->entryView(), [
+ 'userCount' => User::count(),
+ 'roleCount' => Schema::hasTable('roles') ? Role::count() : 0,
+ 'mediaCount' => Schema::hasTable('media') ? Media::count() : 0,
+ 'auditCount' => Schema::hasTable('audit_logs') ? AuditLog::count() : 0,
+ 'recentAudit' => $this->recentAudit(),
+ 'modules' => $this->modules(),
+ ]);
+ }
+
+ private function recentAudit(): LengthAwarePaginator
+ {
+ if (! Schema::hasTable('audit_logs')) {
+ return new LengthAwarePaginator(
+ items: [],
+ total: 0,
+ perPage: 8,
+ currentPage: Paginator::resolveCurrentPage('audit_page'),
+ options: [
+ 'path' => request()->url(),
+ 'pageName' => 'audit_page',
+ ],
+ );
+ }
+
+ return AuditLog::query()
+ ->latest('created_at')
+ ->paginate(8, ['*'], 'audit_page')
+ ->withQueryString();
+ }
+
+ private function modules(): LengthAwarePaginator
+ {
+ $statuses = json_decode((string) file_get_contents(base_path('modules_statuses.json')), true) ?: [];
+ $modules = [];
+
+ foreach (glob(base_path('Modules/*/module.json')) ?: [] as $manifest) {
+ $data = json_decode((string) file_get_contents($manifest), true) ?: [];
+ $name = $data['name'] ?? basename(dirname($manifest));
+
+ $modules[] = [
+ 'name' => $name,
+ 'version' => $data['version'] ?? '1.0.0',
+ 'enabled' => (bool) ($statuses[$name] ?? false),
+ ];
+ }
+
+ usort($modules, fn (array $a, array $b): int => strcasecmp($a['name'], $b['name']));
+
+ $pageName = 'modules_page';
+ $perPage = 8;
+ $currentPage = Paginator::resolveCurrentPage($pageName);
+
+ return (new LengthAwarePaginator(
+ items: array_slice($modules, ($currentPage - 1) * $perPage, $perPage),
+ total: count($modules),
+ perPage: $perPage,
+ currentPage: $currentPage,
+ options: [
+ 'path' => request()->url(),
+ 'pageName' => $pageName,
+ ],
+ ))->withQueryString();
+ }
+}
diff --git a/Administration/Http/Controllers/ModuleController.php b/Administration/Http/Controllers/ModuleController.php
new file mode 100644
index 0000000..e277d1c
--- /dev/null
+++ b/Administration/Http/Controllers/ModuleController.php
@@ -0,0 +1,27 @@
+map(function (string $manifest) use ($statuses): array {
+ $data = json_decode((string) file_get_contents($manifest), true) ?: [];
+ $name = $data['name'] ?? basename(dirname($manifest));
+
+ return [
+ 'name' => $name,
+ 'description' => $data['description'] ?? 'Laravel application module.',
+ 'version' => $data['version'] ?? '1.0.0',
+ 'enabled' => (bool) ($statuses[$name] ?? false),
+ 'providers' => $data['providers'] ?? [],
+ ];
+ })->sortBy('name', SORT_NATURAL | SORT_FLAG_CASE)->values();
+
+ return view('administration::modules.index', compact('modules'));
+ }
+}
diff --git a/Administration/Providers/AdministrationServiceProvider.php b/Administration/Providers/AdministrationServiceProvider.php
new file mode 100644
index 0000000..9cf7292
--- /dev/null
+++ b/Administration/Providers/AdministrationServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Administration/Providers/RouteServiceProvider.php b/Administration/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..2f44254
--- /dev/null
+++ b/Administration/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Administration', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Administration/Resources/views/modules/index.blade.php b/Administration/Resources/views/modules/index.blade.php
new file mode 100644
index 0000000..69bbd42
--- /dev/null
+++ b/Administration/Resources/views/modules/index.blade.php
@@ -0,0 +1 @@
+Modules Installed platform modules and runtime status.
Module Version Status Providers @foreach($modules as $module){{ $module['name'] }}
{{ $module['description'] }}
{{ $module['version'] }} {{ $module['enabled'] ? 'Enabled' : 'Disabled' }} {{ implode(', ', $module['providers']) ?: '—' }} @endforeach
diff --git a/Administration/Routes/web.php b/Administration/Routes/web.php
new file mode 100644
index 0000000..ab08cb2
--- /dev/null
+++ b/Administration/Routes/web.php
@@ -0,0 +1,8 @@
+prefix('administration')->name('administration.')->group(function (): void {
+ Route::get('/modules', [ModuleController::class, 'index'])->name('modules.index');
+});
diff --git a/Administration/Support/ModuleInfo.php b/Administration/Support/ModuleInfo.php
new file mode 100644
index 0000000..51ef6a8
--- /dev/null
+++ b/Administration/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Administration');
+});
+
+it('uses the primary dashboard for administration overview content', function (): void {
+ $dashboard = Route::getRoutes()->getByName('dashboard');
+
+ expect($dashboard)->not->toBeNull()
+ ->and($dashboard->getActionName())->toBe(DashboardController::class)
+ ->and(view()->exists('stylist-theme-admin::dashboard'))->toBeTrue()
+ ->and(Route::has('administration.dashboard'))->toBeFalse();
+});
diff --git a/Administration/composer.json b/Administration/composer.json
new file mode 100644
index 0000000..3a6aff0
--- /dev/null
+++ b/Administration/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/administration-module",
+ "description": "Administration foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Administration\\": ""
+ }
+ }
+}
diff --git a/Administration/module.json b/Administration/module.json
new file mode 100644
index 0000000..74cfa82
--- /dev/null
+++ b/Administration/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Administration",
+ "alias": "administration",
+ "version": "1.0.0",
+ "description": "Administration foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "administration"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Administration\\Providers\\AdministrationServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Analytics/Config/config.php b/Analytics/Config/config.php
new file mode 100644
index 0000000..b2d0b76
--- /dev/null
+++ b/Analytics/Config/config.php
@@ -0,0 +1,10 @@
+ (bool) env('ANALYTICS_ENABLED', true),
+ 'respect_dnt' => (bool) env('ANALYTICS_RESPECT_DNT', true),
+ 'anonymize_ip' => (bool) env('ANALYTICS_ANONYMIZE_IP', true),
+ 'retention_days' => (int) env('ANALYTICS_RETENTION_DAYS', 365),
+ 'exclude_paths' => array_values(array_filter(array_map('trim', explode(',', (string) env('ANALYTICS_EXCLUDE_PATHS', 'administration,api'))))),
+ 'queue' => env('ANALYTICS_QUEUE', 'default'),
+];
diff --git a/Analytics/Console/AggregateAnalytics.php b/Analytics/Console/AggregateAnalytics.php
new file mode 100644
index 0000000..ae896e5
--- /dev/null
+++ b/Analytics/Console/AggregateAnalytics.php
@@ -0,0 +1,20 @@
+info('Metrics written: '.$aggregator->aggregate($this->option('date')));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Analytics/Console/PruneAnalytics.php b/Analytics/Console/PruneAnalytics.php
new file mode 100644
index 0000000..ead8b95
--- /dev/null
+++ b/Analytics/Console/PruneAnalytics.php
@@ -0,0 +1,21 @@
+subDays(config('analytics.retention_days', 365)))->delete();
+ $this->info("Deleted {$count} events.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Analytics/Console/RunScheduledReports.php b/Analytics/Console/RunScheduledReports.php
new file mode 100644
index 0000000..e169620
--- /dev/null
+++ b/Analytics/Console/RunScheduledReports.php
@@ -0,0 +1,21 @@
+whereNotNull('next_run_at')->where('next_run_at', '<=', now())->update(['last_run_at' => now(), 'next_run_at' => now()->addDay()]);
+ $this->info("Processed {$count} scheduled reports.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php b/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php
new file mode 100644
index 0000000..4cea18d
--- /dev/null
+++ b/Analytics/Database/Migrations/2026_07_24_150000_create_analytics_tables.php
@@ -0,0 +1,80 @@
+id();
+ $table->uuid('event_id')->unique();
+ $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $table->string('event_type', 40)->index();
+ $table->string('path', 2048)->nullable();
+ $table->string('title')->nullable();
+ $table->string('referrer', 2048)->nullable();
+ $table->string('visitor_hash', 64)->nullable()->index();
+ $table->string('session_hash', 64)->nullable()->index();
+ $table->string('ip_hash', 64)->nullable();
+ $table->string('country_code', 2)->nullable()->index();
+ $table->string('device_type', 20)->nullable()->index();
+ $table->string('browser', 40)->nullable();
+ $table->string('operating_system', 40)->nullable();
+ $table->nullableMorphs('subject');
+ $table->json('properties')->nullable();
+ $table->timestamp('occurred_at')->index();
+ $table->timestamps();
+ $table->index(['site_id', 'event_type', 'occurred_at']);
+ });
+ Schema::create('analytics_daily_metrics', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $table->date('metric_date')->index();
+ $table->string('metric', 60);
+ $table->string('dimension', 120)->default('all');
+ $table->unsignedBigInteger('value')->default(0);
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ $table->unique(['site_id', 'metric_date', 'metric', 'dimension'], 'analytics_daily_unique');
+ });
+ Schema::create('analytics_reports', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $table->string('name');
+ $table->string('report_type', 40)->default('overview');
+ $table->json('filters')->nullable();
+ $table->json('columns')->nullable();
+ $table->string('schedule')->nullable();
+ $table->string('format', 10)->default('csv');
+ $table->json('recipients')->nullable();
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamp('last_run_at')->nullable();
+ $table->timestamp('next_run_at')->nullable()->index();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ });
+ Schema::create('analytics_report_runs', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('analytics_report_id')->constrained('analytics_reports')->cascadeOnDelete();
+ $table->string('status', 20)->default('pending')->index();
+ $table->string('disk')->nullable();
+ $table->string('path')->nullable();
+ $table->unsignedBigInteger('row_count')->default(0);
+ $table->text('error')->nullable();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('analytics_report_runs');
+ Schema::dropIfExists('analytics_reports');
+ Schema::dropIfExists('analytics_daily_metrics');
+ Schema::dropIfExists('analytics_events');
+ }
+};
diff --git a/Analytics/Http/Controllers/AnalyticsController.php b/Analytics/Http/Controllers/AnalyticsController.php
new file mode 100644
index 0000000..4552d84
--- /dev/null
+++ b/Analytics/Http/Controllers/AnalyticsController.php
@@ -0,0 +1,43 @@
+integer('days', 30)));
+ $from = now()->subDays($days - 1)->startOfDay();
+ $events = AnalyticsEvent::query()->where('occurred_at', '>=', $from);
+
+ return view('analytics::index', [
+ 'days' => $days, 'pageViews' => $events->clone()->where('event_type', 'page_view')->count(),
+ 'visitors' => $events->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash'),
+ 'conversions' => $events->clone()->where('event_type', 'conversion')->count(),
+ 'searches' => $events->clone()->where('event_type', 'search')->count(),
+ 'topPages' => $events->clone()->where('event_type', 'page_view')->selectRaw('path, count(*) as total')->groupBy('path')->orderByDesc('total')->limit(10)->get(),
+ 'recentEvents' => $events->clone()->latest('occurred_at')->limit(20)->get(), 'reports' => AnalyticsReport::latest()->limit(20)->get(),
+ ]);
+ }
+
+ public function storeReport(Request $request): RedirectResponse
+ {
+ AnalyticsReport::create($request->validate(['name' => 'required|string|max:255', 'report_type' => 'required|in:overview,content,search,conversion,commerce', 'schedule' => 'nullable|string|max:50', 'format' => 'required|in:csv,json', 'is_active' => 'nullable|boolean']) + ['created_by' => $request->user()->id, 'is_active' => $request->boolean('is_active', true)]);
+
+ return back()->with('status', 'Report created.');
+ }
+
+ public function destroyReport(AnalyticsReport $report): RedirectResponse
+ {
+ $report->delete();
+
+ return back()->with('status','Report deleted.');
+ }
+}
diff --git a/Analytics/Http/Controllers/TrackingController.php b/Analytics/Http/Controllers/TrackingController.php
new file mode 100644
index 0000000..0523987
--- /dev/null
+++ b/Analytics/Http/Controllers/TrackingController.php
@@ -0,0 +1,25 @@
+validate([
+ 'event_type' => ['required', Rule::in(['page_view', 'search', 'conversion', 'content_view', 'form_submit', 'commerce'])],
+ 'path' => ['nullable', 'string', 'max:2048'], 'title' => ['nullable', 'string', 'max:255'], 'referrer' => ['nullable', 'string', 'max:2048'],
+ 'visitor_id' => ['nullable', 'string', 'max:255'], 'session_id' => ['nullable', 'string', 'max:255'],
+ 'subject_type' => ['nullable', 'string', 'max:255'], 'subject_id' => ['nullable', 'integer'], 'properties' => ['nullable', 'array'],
+ ]);
+ $event = $tracker->track($request, $data);
+
+ return response()->json(['recorded' => (bool) $event], $event ? 201 : 202);
+ }
+}
diff --git a/Analytics/Models/AnalyticsEvent.php b/Analytics/Models/AnalyticsEvent.php
new file mode 100644
index 0000000..389e863
--- /dev/null
+++ b/Analytics/Models/AnalyticsEvent.php
@@ -0,0 +1,12 @@
+ 'array', 'occurred_at' => 'datetime'];
+}
diff --git a/Analytics/Models/AnalyticsReport.php b/Analytics/Models/AnalyticsReport.php
new file mode 100644
index 0000000..3d8a81c
--- /dev/null
+++ b/Analytics/Models/AnalyticsReport.php
@@ -0,0 +1,12 @@
+ 'array', 'columns' => 'array', 'recipients' => 'array', 'is_active' => 'boolean', 'last_run_at' => 'datetime', 'next_run_at' => 'datetime'];
+}
diff --git a/Analytics/Models/AnalyticsReportRun.php b/Analytics/Models/AnalyticsReportRun.php
new file mode 100644
index 0000000..53930be
--- /dev/null
+++ b/Analytics/Models/AnalyticsReportRun.php
@@ -0,0 +1,12 @@
+ 'datetime', 'completed_at' => 'datetime'];
+}
diff --git a/Analytics/Models/DailyMetric.php b/Analytics/Models/DailyMetric.php
new file mode 100644
index 0000000..745d269
--- /dev/null
+++ b/Analytics/Models/DailyMetric.php
@@ -0,0 +1,14 @@
+ 'date', 'metadata' => 'array'];
+}
diff --git a/Analytics/Providers/AnalyticsServiceProvider.php b/Analytics/Providers/AnalyticsServiceProvider.php
new file mode 100644
index 0000000..ce1b3d5
--- /dev/null
+++ b/Analytics/Providers/AnalyticsServiceProvider.php
@@ -0,0 +1,32 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Analytics', 'Config/config.php'), 'analytics');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Analytics', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Analytics', 'Resources/views'), 'analytics');
+ if ($this->app->runningInConsole()) {
+ $this->commands([AggregateAnalytics::class, PruneAnalytics::class, RunScheduledReports::class]);
+ $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
+ $schedule->command('analytics:aggregate')->hourly()->withoutOverlapping();
+ $schedule->command('analytics:reports')->everyFifteenMinutes()->withoutOverlapping();
+ $schedule->command('analytics:prune')->dailyAt('02:30')->withoutOverlapping();
+ });
+ }
+ }
+}
diff --git a/Analytics/Providers/RouteServiceProvider.php b/Analytics/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..9d62eec
--- /dev/null
+++ b/Analytics/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Analytics', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api/v1')->group(module_path('Analytics', 'Routes/api.php'));
+ }
+}
diff --git a/Analytics/Resources/views/index.blade.php b/Analytics/Resources/views/index.blade.php
new file mode 100644
index 0000000..66e7cd8
--- /dev/null
+++ b/Analytics/Resources/views/index.blade.php
@@ -0,0 +1,14 @@
+
+
+
Analytics Visitor, content, search and conversion performance.
+ @if(session('status'))
{{ session('status') }} @endif
+
+ @foreach([['Page views',$pageViews],['Visitors',$visitors],['Conversions',$conversions],['Searches',$searches]] as [$label,$value])
{{ $label }}
{{ number_format($value) }}
@endforeach
+
+
+
Top pages @forelse($topPages as $page)
{{ $page->path ?: '/' }} {{ number_format($page->total) }}
@empty
No page views recorded.
@endforelse
+
Recent events @forelse($recentEvents as $event)
{{ str($event->event_type)->replace('_',' ')->title() }} {{ $event->occurred_at?->diffForHumans() }}
{{ $event->path ?: data_get($event->properties,'query','—') }}
@empty
No events recorded.
@endforelse
+
+ @can('analytics.manage')
Scheduled reports @forelse($reports as $report)
{{ $report->name }} {{ ucfirst($report->report_type) }} · {{ strtoupper($report->format) }}
@empty
No reports configured.
@endforelse
@endcan
+
+
diff --git a/Analytics/Routes/api.php b/Analytics/Routes/api.php
new file mode 100644
index 0000000..4ae8da7
--- /dev/null
+++ b/Analytics/Routes/api.php
@@ -0,0 +1,6 @@
+middleware('throttle:60,1')->name('api.analytics.events.store');
diff --git a/Analytics/Routes/web.php b/Analytics/Routes/web.php
new file mode 100644
index 0000000..5a5437e
--- /dev/null
+++ b/Analytics/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/analytics')->name('administration.analytics.')->group(function () {
+ Route::get('/', [AnalyticsController::class, 'index'])->name('index');
+ Route::post('/reports', [AnalyticsController::class, 'storeReport'])->middleware('permission:analytics.manage')->name('reports.store');
+ Route::delete('/reports/{report}', [AnalyticsController::class, 'destroyReport'])->middleware('permission:analytics.manage')->name('reports.destroy');
+});
diff --git a/Analytics/Services/AnalyticsAggregator.php b/Analytics/Services/AnalyticsAggregator.php
new file mode 100644
index 0000000..0f08c0d
--- /dev/null
+++ b/Analytics/Services/AnalyticsAggregator.php
@@ -0,0 +1,32 @@
+subHour()->toDateString();
+ $written = 0;
+ $base = AnalyticsEvent::query()->whereDate('occurred_at', $date);
+ foreach ($base->clone()->select('site_id')->distinct()->pluck('site_id') as $siteId) {
+ $query = $base->clone()->where('site_id', $siteId);
+ $metrics = [
+ ['page_views', 'all', $query->clone()->where('event_type', 'page_view')->count()],
+ ['visitors', 'all', $query->clone()->whereNotNull('visitor_hash')->distinct('visitor_hash')->count('visitor_hash')],
+ ['sessions', 'all', $query->clone()->whereNotNull('session_hash')->distinct('session_hash')->count('session_hash')],
+ ['conversions', 'all', $query->clone()->where('event_type', 'conversion')->count()],
+ ['searches', 'all', $query->clone()->where('event_type', 'search')->count()],
+ ];
+ foreach ($metrics as [$metric,$dimension,$value]) {
+ DailyMetric::updateOrCreate(['site_id' => $siteId, 'metric_date' => $date, 'metric' => $metric, 'dimension' => $dimension], ['value' => $value]);
+ $written++;
+ }
+ }
+
+ return $written;
+ }
+}
diff --git a/Analytics/Services/AnalyticsTracker.php b/Analytics/Services/AnalyticsTracker.php
new file mode 100644
index 0000000..4c37c77
--- /dev/null
+++ b/Analytics/Services/AnalyticsTracker.php
@@ -0,0 +1,61 @@
+header('DNT') === '1') {
+ return null;
+ }
+
+ $path = ltrim((string) ($payload['path'] ?? ''), '/');
+
+ foreach (config('analytics.exclude_paths', []) as $excluded) {
+ if ($excluded !== '' && str_starts_with($path, trim($excluded, '/'))) {
+ return null;
+ }
+ }
+
+ $ip = (string) $request->ip();
+ $visitorIdentifier = (string) ($payload['visitor_id'] ?? $request->cookie('analytics_visitor', 'guest'));
+ $sessionIdentifier = (string) ($payload['session_id'] ?? $this->sessionIdentifier($request, $visitorIdentifier));
+
+ return AnalyticsEvent::create([
+ 'event_id' => (string) Str::uuid(),
+ 'site_id' => app()->bound('currentSite') ? app('currentSite')?->id : null,
+ 'event_type' => $payload['event_type'] ?? 'page_view',
+ 'path' => '/'.$path,
+ 'title' => $payload['title'] ?? null,
+ 'referrer' => $payload['referrer'] ?? $request->headers->get('referer'),
+ 'visitor_hash' => hash('sha256', $visitorIdentifier),
+ 'session_hash' => hash('sha256', $sessionIdentifier),
+ 'ip_hash' => config('analytics.anonymize_ip') ? hash('sha256', $ip.config('app.key')) : $ip,
+ 'device_type' => $payload['device_type'] ?? null,
+ 'browser' => $payload['browser'] ?? null,
+ 'operating_system' => $payload['operating_system'] ?? null,
+ 'subject_type' => $payload['subject_type'] ?? null,
+ 'subject_id' => $payload['subject_id'] ?? null,
+ 'properties' => $payload['properties'] ?? [],
+ 'occurred_at' => now(),
+ ]);
+ }
+
+ private function sessionIdentifier(Request $request, string $visitorIdentifier): string
+ {
+ if ($request->hasSession()) {
+ return $request->session()->getId();
+ }
+
+ return (string) ($request->header('X-Analytics-Session') ?: $visitorIdentifier ?: 'stateless');
+ }
+}
diff --git a/Analytics/Tests/Feature/AnalyticsModuleTest.php b/Analytics/Tests/Feature/AnalyticsModuleTest.php
new file mode 100644
index 0000000..ce99fab
--- /dev/null
+++ b/Analytics/Tests/Feature/AnalyticsModuleTest.php
@@ -0,0 +1,17 @@
+postJson('/api/v1/analytics/events', ['event_type' => 'page_view', 'path' => '/about'])->assertCreated()->assertJson(['recorded' => true]);
+ $this->assertDatabaseHas('analytics_events', ['event_type' => 'page_view', 'path' => '/about']);
+ }
+}
diff --git a/Analytics/module.json b/Analytics/module.json
new file mode 100644
index 0000000..7afb408
--- /dev/null
+++ b/Analytics/module.json
@@ -0,0 +1 @@
+{"name":"Analytics","alias":"analytics","version":"1.0.0","description":"Privacy-conscious visitor, content, search, conversion and report analytics.","keywords":["analytics","reports","metrics","visitors","conversions"],"priority":150,"providers":["Modules\\Analytics\\Providers\\AnalyticsServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Sites","Search","Forms","Newsletter","Commerce"]}
diff --git a/Api/Config/config.php b/Api/Config/config.php
new file mode 100644
index 0000000..b11bfae
--- /dev/null
+++ b/Api/Config/config.php
@@ -0,0 +1,3 @@
+ 'api/v1', 'token_prefix' => 'cms_', 'pagination' => 20, 'webhook_timeout' => 10, 'openapi' => ['title' => env('APP_NAME', 'Content Manager').' API', 'version' => '1.0.0']];
diff --git a/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php b/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php
new file mode 100644
index 0000000..bb0d7b7
--- /dev/null
+++ b/Api/Database/Migrations/2026_07_24_090000_create_api_tokens_table.php
@@ -0,0 +1,27 @@
+id();
+ $t->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $t->string('name');
+ $t->string('token', 64)->unique();
+ $t->json('abilities')->nullable();
+ $t->timestamp('last_used_at')->nullable();
+ $t->timestamp('expires_at')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('api_tokens');
+ }
+};
diff --git a/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php b/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php
new file mode 100644
index 0000000..113840c
--- /dev/null
+++ b/Api/Database/Migrations/2026_07_24_090100_create_webhooks_tables.php
@@ -0,0 +1,43 @@
+id();
+ $t->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $t->string('name');
+ $t->string('url');
+ $t->string('secret');
+ $t->json('events');
+ $t->boolean('is_active')->default(true);
+ $t->unsignedSmallInteger('failure_count')->default(0);
+ $t->timestamp('last_triggered_at')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('webhook_deliveries', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('webhook_id')->constrained()->cascadeOnDelete();
+ $t->uuid('uuid')->unique();
+ $t->string('event');
+ $t->json('payload');
+ $t->unsignedSmallInteger('response_status')->nullable();
+ $t->text('response_body')->nullable();
+ $t->unsignedSmallInteger('attempts')->default(0);
+ $t->timestamp('delivered_at')->nullable();
+ $t->timestamp('failed_at')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_deliveries');
+ Schema::dropIfExists('webhooks');
+ }
+};
diff --git a/Api/Http/Controllers/ApiController.php b/Api/Http/Controllers/ApiController.php
new file mode 100644
index 0000000..fbe22bf
--- /dev/null
+++ b/Api/Http/Controllers/ApiController.php
@@ -0,0 +1,49 @@
+ ApiToken::with('user')->latest()->paginate(20, '*', 'tokens'), 'webhooks' => Webhook::latest()->paginate(20, '*', 'webhooks')]);
+ }
+
+ public function storeToken(Request $r): RedirectResponse
+ {
+ $d = $r->validate(['name' => 'required|string|max:255', 'abilities' => 'nullable|string', 'expires_at' => 'nullable|date|after:now']);
+ $abilities = array_values(array_filter(array_map('trim', explode(',', $d['abilities'] ?: '*'))));
+ [,$plain] = ApiToken::issue($r->user(), $d['name'], $abilities, isset($d['expires_at']) ? new \DateTimeImmutable($d['expires_at']) : null);
+
+ return back()->with('api_token', $plain)->with('status', 'API token created.');
+ }
+
+ public function destroyToken(ApiToken $token): RedirectResponse
+ {
+ $token->delete();
+
+ return back()->with('status', 'API token revoked.');
+ }
+
+ public function storeWebhook(Request $r): RedirectResponse
+ {
+ $d = $r->validate(['name' => 'required|string|max:255', 'url' => 'required|url|max:2048', 'events' => 'required|string', 'site_id' => 'nullable|integer|exists:sites,id']);
+ Webhook::create(['name' => $d['name'], 'url' => $d['url'], 'events' => array_values(array_filter(array_map('trim', explode(',', $d['events'])))), 'site_id' => $d['site_id'] ?? null, 'secret' => Str::random(48), 'is_active' => true]);
+
+ return back()->with('status', 'Webhook created.');
+ }
+
+ public function destroyWebhook(Webhook $webhook): RedirectResponse
+ {
+ $webhook->delete();
+
+ return back()->with('status','Webhook deleted.');
+ }
+}
diff --git a/Api/Http/Controllers/ContentController.php b/Api/Http/Controllers/ContentController.php
new file mode 100644
index 0000000..5be30a0
--- /dev/null
+++ b/Api/Http/Controllers/ContentController.php
@@ -0,0 +1,54 @@
+many(Page::query()->where('status', 'published'));
+ }
+
+ public function blog(): AnonymousResourceCollection
+ {
+ return $this->many(Blog::query()->published());
+ }
+
+ public function news(): AnonymousResourceCollection
+ {
+ return $this->many(News::query()->published());
+ }
+
+ public function page(Page $page): ContentResource
+ {
+ abort_unless($page->status === 'published', 404);
+
+ return new ContentResource($page);
+ }
+
+ public function blogPost(Blog $blog): ContentResource
+ {
+ abort_unless(Blog::query()->published()->whereKey($blog)->exists(), 404);
+
+ return new ContentResource($blog);
+ }
+
+ public function newsArticle(News $news): ContentResource
+ {
+ abort_unless(News::query()->published()->whereKey($news)->exists(), 404);
+
+ return new ContentResource($news);
+ }
+
+ private function many(Builder $q): AnonymousResourceCollection
+ {
+ return ContentResource::collection($q->latest('updated_at')->paginate((int) config('api.pagination', 20)));
+ }
+}
diff --git a/Api/Http/Controllers/OpenApiController.php b/Api/Http/Controllers/OpenApiController.php
new file mode 100644
index 0000000..f609f27
--- /dev/null
+++ b/Api/Http/Controllers/OpenApiController.php
@@ -0,0 +1,13 @@
+json(['openapi' => '3.1.0', 'info' => config('api.openapi'), 'servers' => [['url' => url('/'.config('api.prefix', 'api/v1'))]], 'components' => ['securitySchemes' => ['bearerAuth' => ['type' => 'http', 'scheme' => 'bearer']]], 'paths' => ['/pages' => ['get' => ['summary' => 'List published pages', 'responses' => ['200' => ['description' => 'OK']]]], '/blog' => ['get' => ['summary' => 'List published blog posts', 'responses' => ['200' => ['description' => 'OK']]]], '/news' => ['get' => ['summary' => 'List published news', 'responses' => ['200' => ['description' => 'OK']]]], '/user' => ['get' => ['summary' => 'Authenticated user', 'security' => [['bearerAuth' => []]], 'responses' => ['200' => ['description' => 'OK'], '401' => ['description' => 'Unauthenticated']]]]]]);
+ }
+}
diff --git a/Api/Http/Middleware/AuthenticateApiToken.php b/Api/Http/Middleware/AuthenticateApiToken.php
new file mode 100644
index 0000000..236b1b5
--- /dev/null
+++ b/Api/Http/Middleware/AuthenticateApiToken.php
@@ -0,0 +1,28 @@
+bearerToken();
+ if (! $plain) {
+ return response()->json(['message' => 'Unauthenticated.'], 401);
+ }$token = ApiToken::with('user')->where('token', hash('sha256', $plain))->first();
+ if (! $token || ($token->expires_at && $token->expires_at->isPast()) || ! $token->user) {
+ return response()->json(['message' => 'Invalid or expired API token.'], 401);
+ }if ($ability && ! $token->allows($ability)) {
+ return response()->json(['message' => 'Insufficient token ability.'], 403);
+ }$token->update(['last_used_at' => now()]);
+ auth()->setUser($token->user);
+ $r->attributes->set('apiToken', $token);
+
+ return $next($r);
+ }
+}
diff --git a/Api/Http/Resources/ContentResource.php b/Api/Http/Resources/ContentResource.php
new file mode 100644
index 0000000..1ac7b0a
--- /dev/null
+++ b/Api/Http/Resources/ContentResource.php
@@ -0,0 +1,14 @@
+ $this->id, 'type' => $this->resource->getTable(), 'title' => $this->title, 'slug' => $this->slug, 'excerpt' => $this->excerpt ?? null, 'content' => $this->content ?? null, 'status' => $this->status ?? null, 'featured_image' => $this->featured_image ?? null, 'published_at' => $this->published_at ?? null, 'updated_at' => $this->updated_at];
+ }
+}
diff --git a/Api/Models/ApiToken.php b/Api/Models/ApiToken.php
new file mode 100644
index 0000000..6de3874
--- /dev/null
+++ b/Api/Models/ApiToken.php
@@ -0,0 +1,40 @@
+ 'array', 'last_used_at' => 'datetime', 'expires_at' => 'datetime'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public static function issue(User $u, string $name, array $abilities = ['*'], ?\DateTimeInterface $expires = null): array
+ {
+ $plain = config('api.token_prefix', 'cms_').Str::random(64);
+ $record = self::query()->create(['user_id' => $u->id, 'name' => $name, 'token' => hash('sha256', $plain), 'abilities' => $abilities, 'expires_at' => $expires]);
+
+ return [$record, $plain];
+ }
+
+ public function allows(string $ability): bool
+ {
+ $a = $this->abilities ?: [];
+
+ return in_array('*', $a, true) || in_array($ability, $a, true);
+ }
+}
diff --git a/Api/Models/Webhook.php b/Api/Models/Webhook.php
new file mode 100644
index 0000000..28d08ef
--- /dev/null
+++ b/Api/Models/Webhook.php
@@ -0,0 +1,28 @@
+ 'array', 'is_active' => 'boolean', 'last_triggered_at' => 'datetime'];
+ }
+
+ public function deliveries(): HasMany
+ {
+ return $this->hasMany(WebhookDelivery::class);
+ }
+
+ public function subscribesTo(string $event): bool
+ {
+ return in_array('*', $this->events ?: [], true) || in_array($event, $this->events ?: [], true);
+ }
+}
diff --git a/Api/Models/WebhookDelivery.php b/Api/Models/WebhookDelivery.php
new file mode 100644
index 0000000..a07b1a4
--- /dev/null
+++ b/Api/Models/WebhookDelivery.php
@@ -0,0 +1,21 @@
+ 'array', 'delivered_at' => 'datetime', 'failed_at' => 'datetime'];
+ }
+
+ public function webhook(): BelongsTo
+ {
+ return $this->belongsTo(Webhook::class);
+ }
+}
diff --git a/Api/Providers/ApiServiceProvider.php b/Api/Providers/ApiServiceProvider.php
new file mode 100644
index 0000000..1f1122c
--- /dev/null
+++ b/Api/Providers/ApiServiceProvider.php
@@ -0,0 +1,22 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Api', 'Config/config.php'), 'api');
+ $this->app->singleton(WebhookDispatcher::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Api', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Api', 'Resources/views'), 'api');
+ }
+}
diff --git a/Api/Providers/RouteServiceProvider.php b/Api/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..f6699cb
--- /dev/null
+++ b/Api/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+prefix(config('api.prefix', 'api/v1'))->name('api.v1.')->group(module_path('Api', 'Routes/api.php'));
+ Route::middleware('web')->group(module_path('Api', 'Routes/web.php'));
+ }
+}
diff --git a/Api/Resources/views/admin/index.blade.php b/Api/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..b5d5117
--- /dev/null
+++ b/Api/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+{{ __('API & Webhooks') }} {{ __('Manage headless access, bearer tokens and webhook subscriptions.') }}
@if(session('status'))
{{ session('status') }} @endif @if(session('api_token'))
{{ __('Copy this token now') }} {{ session('api_token') }}
@endif
{{ __('Create token') }} {{ __('Create webhook') }}
@forelse($tokens as $token){{ $token->name }} {{ implode(', ', $token->abilities ?? []) }}
@empty{{ __('No tokens.') }}
@endforelse{{ $tokens->links() }} {{ __('Webhooks') }} @forelse($webhooks as $webhook){{ $webhook->name }} {{ $webhook->url }}
@empty{{ __('No webhooks.') }}
@endforelse{{ $webhooks->links() }}
diff --git a/Api/Routes/api.php b/Api/Routes/api.php
new file mode 100644
index 0000000..63c733c
--- /dev/null
+++ b/Api/Routes/api.php
@@ -0,0 +1,18 @@
+name('openapi');
+Route::controller(ContentController::class)->group(function (): void {
+ Route::get('/pages', 'pages');
+ Route::get('/pages/{page}', 'page');
+ Route::get('/blog', 'blog');
+ Route::get('/blog/{blog}', 'blogPost');
+ Route::get('/news', 'news');
+ Route::get('/news/{news}', 'newsArticle');
+});
+Route::middleware(AuthenticateApiToken::class)->get('/user', fn (Request $r) => $r->user());
diff --git a/Api/Routes/web.php b/Api/Routes/web.php
new file mode 100644
index 0000000..1b2d91a
--- /dev/null
+++ b/Api/Routes/web.php
@@ -0,0 +1,12 @@
+prefix('administration/api')->name('administration.api.')->controller(ApiController::class)->group(function (): void {
+ Route::get('/', 'index')->name('index');
+ Route::post('/tokens', 'storeToken')->name('tokens.store');
+ Route::delete('/tokens/{token}', 'destroyToken')->name('tokens.destroy');
+ Route::post('/webhooks', 'storeWebhook')->name('webhooks.store');
+ Route::delete('/webhooks/{webhook}', 'destroyWebhook')->name('webhooks.destroy');
+});
diff --git a/Api/Services/WebhookDispatcher.php b/Api/Services/WebhookDispatcher.php
new file mode 100644
index 0000000..05da44d
--- /dev/null
+++ b/Api/Services/WebhookDispatcher.php
@@ -0,0 +1,28 @@
+get()->filter(fn (Webhook $h) => ($h->site_id === null || $h->site_id === $siteId) && $h->subscribesTo($event))->each(function (Webhook $h) use ($event, $payload): void {
+ $uuid = (string) Str::uuid();
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES) ?: '{}';
+ $delivery = WebhookDelivery::create(['webhook_id' => $h->id, 'uuid' => $uuid, 'event' => $event, 'payload' => $payload, 'attempts' => 1]);
+ try {
+ $res = Http::timeout((int) config('api.webhook_timeout', 10))->withHeaders(['X-CMS-Event' => $event, 'X-CMS-Delivery' => $uuid, 'X-CMS-Signature' => 'sha256='.hash_hmac('sha256', $json, $h->getRawOriginal('secret'))])->withBody($json, 'application/json')->post($h->url);
+ $delivery->update(['response_status' => $res->status(), 'response_body' => Str::limit($res->body(), 10000), 'delivered_at' => $res->successful() ? now() : null, 'failed_at' => $res->successful() ? null : now()]);
+ $h->update(['last_triggered_at' => now(), 'failure_count' => $res->successful() ? 0 : $h->failure_count + 1]);
+ } catch (\Throwable $e) {
+ $delivery->update(['response_body' => $e->getMessage(), 'failed_at' => now()]);
+ $h->increment('failure_count');
+ }
+ });
+ }
+}
diff --git a/Api/Tests/Feature/ApiModuleBootTest.php b/Api/Tests/Feature/ApiModuleBootTest.php
new file mode 100644
index 0000000..f3b4f0a
--- /dev/null
+++ b/Api/Tests/Feature/ApiModuleBootTest.php
@@ -0,0 +1,13 @@
+ expect(app()->providerIsLoaded(ApiServiceProvider::class))->toBeTrue());
+it('hashes issued api tokens', function (): void {
+ $u = User::factory()->create();
+ [$t,$plain] = ApiToken::issue($u, 'Test', ['content.read']);
+ expect($t->getRawOriginal('token'))->toBe(hash('sha256', $plain))->not->toBe($plain);
+});
+it('publishes openapi documentation', fn () => $this->getJson('/api/v1/openapi.json')->assertOk()->assertJsonPath('openapi', '3.1.0'));
diff --git a/Api/module.json b/Api/module.json
new file mode 100644
index 0000000..e9574cd
--- /dev/null
+++ b/Api/module.json
@@ -0,0 +1 @@
+{"name":"Api","alias":"api","version":"1.0.0","description":"REST APIs, bearer tokens, webhooks and OpenAPI.","keywords":["api","headless","webhooks"],"priority":90,"providers":["Modules\\Api\\Providers\\ApiServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Sites"]}
diff --git a/Audit/Config/config.php b/Audit/Config/config.php
new file mode 100644
index 0000000..b43f43a
--- /dev/null
+++ b/Audit/Config/config.php
@@ -0,0 +1,6 @@
+ 'Audit',
+ 'enabled' => true,
+];
diff --git a/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php b/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php
new file mode 100644
index 0000000..81771b0
--- /dev/null
+++ b/Audit/Database/Migrations/2026_07_23_000200_create_audit_logs_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->nullableMorphs('actor');
+ $table->string('event')->index();
+ $table->nullableMorphs('auditable');
+ $table->json('old_values')->nullable();
+ $table->json('new_values')->nullable();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('audit_logs');
+ }
+};
diff --git a/Audit/Http/Controllers/AuditController.php b/Audit/Http/Controllers/AuditController.php
new file mode 100644
index 0000000..f28a59b
--- /dev/null
+++ b/Audit/Http/Controllers/AuditController.php
@@ -0,0 +1,14 @@
+ AuditLog::query()->latest('created_at')->paginate(50)]);
+ }
+}
diff --git a/Audit/Models/AuditLog.php b/Audit/Models/AuditLog.php
new file mode 100644
index 0000000..6dce2ae
--- /dev/null
+++ b/Audit/Models/AuditLog.php
@@ -0,0 +1,17 @@
+ 'array', 'new_values' => 'array', 'created_at' => 'datetime'];
+ }
+}
diff --git a/Audit/Providers/AuditServiceProvider.php b/Audit/Providers/AuditServiceProvider.php
new file mode 100644
index 0000000..939ae99
--- /dev/null
+++ b/Audit/Providers/AuditServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Audit/Providers/RouteServiceProvider.php b/Audit/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ce59b66
--- /dev/null
+++ b/Audit/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Audit', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Audit/Resources/views/index.blade.php b/Audit/Resources/views/index.blade.php
new file mode 100644
index 0000000..7bbc774
--- /dev/null
+++ b/Audit/Resources/views/index.blade.php
@@ -0,0 +1 @@
+Audit log Recorded platform activity.
Event Actor IP Date @forelse($logs as $log){{ $log->event }} {{ $log->actor_type ? class_basename($log->actor_type).' #'.$log->actor_id : 'System' }} {{ $log->ip_address ?: '—' }} {{ optional($log->created_at)->format('d M Y H:i') }} @emptyNo audit events recorded. @endforelse
{{ $logs->links() }}
diff --git a/Audit/Routes/web.php b/Audit/Routes/web.php
new file mode 100644
index 0000000..f4c314e
--- /dev/null
+++ b/Audit/Routes/web.php
@@ -0,0 +1,6 @@
+get('administration/audit', [AuditController::class, 'index'])->name('administration.audit.index');
diff --git a/Audit/Support/ModuleInfo.php b/Audit/Support/ModuleInfo.php
new file mode 100644
index 0000000..96f4251
--- /dev/null
+++ b/Audit/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Audit');
+});
diff --git a/Audit/composer.json b/Audit/composer.json
new file mode 100644
index 0000000..b14ddd2
--- /dev/null
+++ b/Audit/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/audit-module",
+ "description": "Audit foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Audit\\": ""
+ }
+ }
+}
diff --git a/Audit/module.json b/Audit/module.json
new file mode 100644
index 0000000..6a2042e
--- /dev/null
+++ b/Audit/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Audit",
+ "alias": "audit",
+ "version": "1.0.0",
+ "description": "Audit foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "audit"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Audit\\Providers\\AuditServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Authors/Config/config.php b/Authors/Config/config.php
new file mode 100644
index 0000000..9255ff8
--- /dev/null
+++ b/Authors/Config/config.php
@@ -0,0 +1,3 @@
+ 'Authors'];
diff --git a/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php b/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
new file mode 100644
index 0000000..fb9ddce
--- /dev/null
+++ b/Authors/Database/Migrations/2026_07_24_030300_create_authors_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('job_title')->nullable();
+ $table->text('biography')->nullable();
+ $table->string('avatar_path')->nullable();
+ $table->json('social_links')->nullable();
+ $table->string('meta_title')->nullable();
+ $table->text('meta_description')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('authors');
+ }
+};
diff --git a/Authors/Http/Controllers/AuthorController.php b/Authors/Http/Controllers/AuthorController.php
new file mode 100644
index 0000000..a25fa53
--- /dev/null
+++ b/Authors/Http/Controllers/AuthorController.php
@@ -0,0 +1,57 @@
+ Author::with('user')->latest()->paginate(25)]);
+ }
+
+ public function create(): View
+ {
+ return view('authors::admin.form', ['users' => User::orderBy('name')->get()]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Author::create($this->data($r));
+
+ return redirect()->route('administration.authors.index')->with('status', 'Author created.');
+ }
+
+ public function edit(Author $author): View
+ {
+ return view('authors::admin.form', ['record' => $author, 'users' => User::orderBy('name')->get()]);
+ }
+
+ public function update(Request $r, Author $author): RedirectResponse
+ {
+ $author->update($this->data($r));
+
+ return redirect()->route('administration.authors.index')->with('status', 'Author updated.');
+ }
+
+ public function destroy(Author $author): RedirectResponse
+ {
+ $author->delete();
+
+ return back()->with('status', 'Author deleted.');
+ }
+
+ private function data(Request $r): array
+ {
+ $d = $r->validate(['user_id' => ['nullable', 'exists:users,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'job_title' => ['nullable', 'string', 'max:255'], 'biography' => ['nullable', 'string'], 'avatar_path' => ['nullable', 'string', 'max:255'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string'], 'is_active' => ['sometimes', 'boolean']]);
+ $d['is_active'] = $r->boolean('is_active');
+
+ return $d;
+ }
+}
diff --git a/Authors/Models/Author.php b/Authors/Models/Author.php
new file mode 100644
index 0000000..7fa531e
--- /dev/null
+++ b/Authors/Models/Author.php
@@ -0,0 +1,22 @@
+ 'array', 'is_active' => 'boolean'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/Authors/Providers/AuthorsServiceProvider.php b/Authors/Providers/AuthorsServiceProvider.php
new file mode 100644
index 0000000..1df491a
--- /dev/null
+++ b/Authors/Providers/AuthorsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Authors', 'Config/config.php'), 'authors');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Authors', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Authors', 'Resources/views'), 'authors');
+ }
+}
diff --git a/Authors/Providers/RouteServiceProvider.php b/Authors/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..f3e7ff9
--- /dev/null
+++ b/Authors/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Authors', 'Routes/web.php'));
+ }
+}
diff --git a/Authors/Resources/views/admin/form.blade.php b/Authors/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..c224794
--- /dev/null
+++ b/Authors/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+
diff --git a/Authors/Resources/views/admin/index.blade.php b/Authors/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..5db3b07
--- /dev/null
+++ b/Authors/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Authors Manage publishing authors.
Create @if(session('status')){{ session('status') }}
@endifName / title Status Actions @forelse($records as $record){{ $record->title ?? $record->name }} {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} Edit @emptyNo records yet. @endforelse
{{ $records->links() }}
diff --git a/Authors/Routes/web.php b/Authors/Routes/web.php
new file mode 100644
index 0000000..9209fa8
--- /dev/null
+++ b/Authors/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/authors')->name('administration.authors.')->group(function (): void {
+ Route::get('/', [AuthorController::class, 'index'])->name('index');
+ Route::get('/create', [AuthorController::class, 'create'])->name('create');
+ Route::post('/', [AuthorController::class, 'store'])->name('store');
+ Route::get('/{author}/edit', [AuthorController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{author}', [AuthorController::class, 'update'])->name('update');
+ Route::delete('/{author}', [AuthorController::class, 'destroy'])->name('destroy');
+});
diff --git a/Authors/Tests/Feature/ModuleBootTest.php b/Authors/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..256a4e6
--- /dev/null
+++ b/Authors/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Authors', config('authors.name'));
+ }
+}
diff --git a/Authors/module.json b/Authors/module.json
new file mode 100644
index 0000000..c972181
--- /dev/null
+++ b/Authors/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Authors",
+ "alias": "authors",
+ "version": "1.0.0",
+ "description": "Authors publishing module.",
+ "keywords": ["publishing", "authors"],
+ "priority": 30,
+ "providers": ["Modules\\Authors\\Providers\\AuthorsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Automation/Config/config.php b/Automation/Config/config.php
new file mode 100644
index 0000000..0d56d36
--- /dev/null
+++ b/Automation/Config/config.php
@@ -0,0 +1,8 @@
+ env('AUTOMATION_QUEUE', 'default'),
+ 'max_steps' => (int) env('AUTOMATION_MAX_STEPS', 50),
+ 'webhook_timeout' => (int) env('AUTOMATION_WEBHOOK_TIMEOUT', 10),
+ 'retain_runs_days' => (int) env('AUTOMATION_RETAIN_RUNS_DAYS', 90),
+];
diff --git a/Automation/Console/DispatchScheduledAutomations.php b/Automation/Console/DispatchScheduledAutomations.php
new file mode 100644
index 0000000..b80d5fd
--- /dev/null
+++ b/Automation/Console/DispatchScheduledAutomations.php
@@ -0,0 +1,30 @@
+where('status', 'active')->where('trigger_type', 'schedule')->whereNotNull('next_run_at')->where('next_run_at', '<=', now())->chunkById(100, function ($items) use ($runner, &$count) {
+ foreach ($items as $automation) {
+ $runner->dispatch($automation, ['scheduled_at' => now()->toIso8601String()], 'schedule');
+ $interval = max(1, (int) ($automation->trigger_config['interval_minutes'] ?? 60));
+ $automation->update(['next_run_at' => now()->addMinutes($interval)]);
+ $count++;
+ }
+ });
+ $this->info("Dispatched {$count} scheduled automation(s).");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Automation/Contracts/ActionRegistry.php b/Automation/Contracts/ActionRegistry.php
new file mode 100644
index 0000000..49c599c
--- /dev/null
+++ b/Automation/Contracts/ActionRegistry.php
@@ -0,0 +1,12 @@
+id();
+ $table->string('name');
+ $table->text('description')->nullable();
+ $table->string('trigger_type')->index();
+ $table->json('trigger_config')->nullable();
+ $table->string('status', 20)->default('draft')->index();
+ $table->boolean('stop_on_failure')->default(true);
+ $table->unsignedInteger('run_count')->default(0);
+ $table->timestamp('last_ran_at')->nullable();
+ $table->timestamp('next_run_at')->nullable()->index();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ });
+ Schema::create('automation_steps', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete();
+ $table->unsignedSmallInteger('position')->default(1);
+ $table->string('action_type');
+ $table->string('name')->nullable();
+ $table->json('configuration')->nullable();
+ $table->boolean('is_enabled')->default(true);
+ $table->timestamps();
+ $table->unique(['automation_id', 'position']);
+ });
+ Schema::create('automation_runs', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('automation_id')->constrained('automations')->cascadeOnDelete();
+ $table->uuid('execution_id')->unique();
+ $table->string('trigger_type');
+ $table->json('payload')->nullable();
+ $table->string('status', 20)->default('queued')->index();
+ $table->unsignedSmallInteger('current_step')->default(0);
+ $table->text('error')->nullable();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('finished_at')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('automation_run_logs', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('automation_run_id')->constrained('automation_runs')->cascadeOnDelete();
+ $table->foreignId('automation_step_id')->nullable()->constrained('automation_steps')->nullOnDelete();
+ $table->string('level', 20)->default('info');
+ $table->string('message');
+ $table->json('context')->nullable();
+ $table->timestamp('created_at')->useCurrent();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('automation_run_logs');
+ Schema::dropIfExists('automation_runs');
+ Schema::dropIfExists('automation_steps');
+ Schema::dropIfExists('automations');
+ }
+};
diff --git a/Automation/Http/Controllers/AutomationApiController.php b/Automation/Http/Controllers/AutomationApiController.php
new file mode 100644
index 0000000..72701c4
--- /dev/null
+++ b/Automation/Http/Controllers/AutomationApiController.php
@@ -0,0 +1,24 @@
+trigger_type === 'webhook', 404);
+ $secret = (string) ($automation->trigger_config['secret'] ?? '');
+ if ($secret !== '') {
+ abort_unless(hash_equals($secret, (string) $request->header('X-Automation-Secret')), 401, 'Invalid automation secret.');
+ }
+ $run = $runner->dispatch($automation, $request->all(), 'webhook');
+
+ return response()->json(['data' => ['execution_id' => $run->execution_id, 'status' => $run->status]], 202);
+ }
+}
diff --git a/Automation/Http/Controllers/AutomationController.php b/Automation/Http/Controllers/AutomationController.php
new file mode 100644
index 0000000..38524c9
--- /dev/null
+++ b/Automation/Http/Controllers/AutomationController.php
@@ -0,0 +1,63 @@
+ Automation::withCount(['steps', 'runs'])->latest()->paginate(20), 'recentRuns' => AutomationRun::with('automation')->latest()->limit(15)->get(), 'triggers' => $triggers->all(), 'actions' => $actions->all()]);
+ }
+
+ public function store(Request $request, TriggerRegistry $triggers): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'trigger_type' => ['required', 'string'], 'status' => ['required', 'in:draft,active,paused'], 'next_run_at' => ['nullable', 'date'], 'trigger_config' => ['nullable', 'json']]);
+ abort_unless($triggers->has($data['trigger_type']), 422, 'Unknown trigger type.');
+ $data['trigger_config'] = filled($data['trigger_config'] ?? null) ? json_decode($data['trigger_config'], true, 512, JSON_THROW_ON_ERROR) : [];
+ $data['stop_on_failure'] = $request->boolean('stop_on_failure');
+ $data['created_by'] = $request->user()->id;
+ Automation::create($data);
+
+ return back()->with('status', 'Automation created.');
+ }
+
+ public function addStep(Request $request, Automation $automation, ActionRegistry $actions): RedirectResponse
+ {
+ $data = $request->validate(['action_type' => ['required', 'string'], 'name' => ['nullable', 'string', 'max:255'], 'configuration' => ['nullable', 'json']]);
+ abort_unless(array_key_exists($data['action_type'], $actions->all()), 422, 'Unknown action type.');
+ $automation->steps()->create(['position' => (int) $automation->steps()->max('position') + 1, 'action_type' => $data['action_type'], 'name' => $data['name'] ?? null, 'configuration' => filled($data['configuration'] ?? null) ? json_decode($data['configuration'], true, 512, JSON_THROW_ON_ERROR) : [], 'is_enabled' => true]);
+
+ return back()->with('status', 'Automation step added.');
+ }
+
+ public function run(Automation $automation, AutomationRunner $runner): RedirectResponse
+ {
+ $run = $runner->dispatch($automation, ['initiated_by' => request()->user()->id], 'manual');
+
+ return back()->with('status', "Automation queued as {$run->execution_id}.");
+ }
+
+ public function toggle(Automation $automation): RedirectResponse
+ {
+ $automation->update(['status' => $automation->status === 'active' ? 'paused' : 'active']);
+
+ return back()->with('status', 'Automation status updated.');
+ }
+
+ public function destroy(Automation $automation): RedirectResponse
+ {
+ $automation->delete();
+
+ return back()->with('status','Automation deleted.');
+ }
+}
diff --git a/Automation/Jobs/ExecuteAutomation.php b/Automation/Jobs/ExecuteAutomation.php
new file mode 100644
index 0000000..b84d34d
--- /dev/null
+++ b/Automation/Jobs/ExecuteAutomation.php
@@ -0,0 +1,46 @@
+findOrFail($this->runId);
+ $automation = $run->automation;
+ $run->update(['status' => 'running', 'started_at' => now()]);
+ try {
+ foreach ($automation->steps->where('is_enabled', true) as $step) {
+ $run->update(['current_step' => $step->position]);
+ try {
+ $result = $actions->execute($step, $run->payload ?? []);
+ $run->logs()->create(['automation_step_id' => $step->id, 'level' => 'info', 'message' => $step->name ?: $step->action_type.' completed', 'context' => $result, 'created_at' => now()]);
+ } catch (\Throwable $e) {
+ $run->logs()->create(['automation_step_id' => $step->id, 'level' => 'error', 'message' => $e->getMessage(), 'created_at' => now()]);
+ if ($automation->stop_on_failure) {
+ throw $e;
+ }
+ }
+ }
+ $run->update(['status' => 'completed', 'finished_at' => now()]);
+ } catch (\Throwable $e) {
+ $run->update(['status' => 'failed', 'error' => $e->getMessage(), 'finished_at' => now()]);
+ throw $e;
+ } finally {
+ $automation->increment('run_count');
+ $automation->update(['last_ran_at' => now()]);
+ }
+ }
+}
diff --git a/Automation/Models/Automation.php b/Automation/Models/Automation.php
new file mode 100644
index 0000000..0a060ed
--- /dev/null
+++ b/Automation/Models/Automation.php
@@ -0,0 +1,28 @@
+ 'array', 'stop_on_failure' => 'boolean', 'last_ran_at' => 'datetime', 'next_run_at' => 'datetime'];
+
+ public function steps(): HasMany
+ {
+ return $this->hasMany(AutomationStep::class)->orderBy('position');
+ }
+
+ public function runs(): HasMany
+ {
+ return $this->hasMany(AutomationRun::class);
+ }
+
+ public function isActive(): bool
+ {
+ return $this->status === 'active';
+ }
+}
diff --git a/Automation/Models/AutomationRun.php b/Automation/Models/AutomationRun.php
new file mode 100644
index 0000000..38b5fb5
--- /dev/null
+++ b/Automation/Models/AutomationRun.php
@@ -0,0 +1,24 @@
+ 'array', 'started_at' => 'datetime', 'finished_at' => 'datetime'];
+
+ public function automation(): BelongsTo
+ {
+ return $this->belongsTo(Automation::class);
+ }
+
+ public function logs(): HasMany
+ {
+ return $this->hasMany(AutomationRunLog::class);
+ }
+}
diff --git a/Automation/Models/AutomationRunLog.php b/Automation/Models/AutomationRunLog.php
new file mode 100644
index 0000000..3d93839
--- /dev/null
+++ b/Automation/Models/AutomationRunLog.php
@@ -0,0 +1,14 @@
+ 'array', 'created_at' => 'datetime'];
+}
diff --git a/Automation/Models/AutomationStep.php b/Automation/Models/AutomationStep.php
new file mode 100644
index 0000000..2627289
--- /dev/null
+++ b/Automation/Models/AutomationStep.php
@@ -0,0 +1,18 @@
+ 'array', 'is_enabled' => 'boolean'];
+
+ public function automation(): BelongsTo
+ {
+ return $this->belongsTo(Automation::class);
+ }
+}
diff --git a/Automation/Providers/AutomationServiceProvider.php b/Automation/Providers/AutomationServiceProvider.php
new file mode 100644
index 0000000..b4c3b7c
--- /dev/null
+++ b/Automation/Providers/AutomationServiceProvider.php
@@ -0,0 +1,30 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Automation', 'Config/config.php'), 'automation');
+ $this->app->singleton(ActionRegistry::class, AutomationActionRegistry::class);
+ $this->app->singleton(TriggerRegistry::class, AutomationTriggerRegistry::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Automation', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Automation', 'Resources/views'), 'automation');
+ if ($this->app->runningInConsole()) {
+ $this->commands([DispatchScheduledAutomations::class]);
+ }
+ }
+}
diff --git a/Automation/Providers/RouteServiceProvider.php b/Automation/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..f03573a
--- /dev/null
+++ b/Automation/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Automation', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api/v1')->group(module_path('Automation', 'Routes/api.php'));
+ }
+}
diff --git a/Automation/Resources/views/admin/index.blade.php b/Automation/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..1033167
--- /dev/null
+++ b/Automation/Resources/views/admin/index.blade.php
@@ -0,0 +1,26 @@
+
+
+
Automation Platform Create event-driven, scheduled and webhook automations.
+@if(session('status'))
{{ session('status') }} @endif
+
+Automations {{ $automations->total() }}
+Active {{ \Modules\Automation\Models\Automation::where('status','active')->count() }}
+Completed runs {{ \Modules\Automation\Models\AutomationRun::where('status','completed')->count() }}
+Failed runs {{ \Modules\Automation\Models\AutomationRun::where('status','failed')->count() }}
+
+@can('automation.manage')
+
Create automation
+@endcan
+
+@forelse($automations as $automation)
{{ $automation->name }} {{ $automation->trigger_type }} · {{ $automation->steps_count }} steps · {{ $automation->runs_count }} runs
{{ ucfirst($automation->status) }}
+@if($automation->description){{ $automation->description }} @endif
+@can('automation.execute')@endcan @can('automation.manage')@endcan
+@can('automation.manage')@endcan
+@empty
No automations have been created. @endforelse
+{{ $automations->links() }}
Recent runs @forelse($recentRuns as $run)
{{ $run->automation?->name }} {{ $run->status }}
{{ $run->execution_id }} @empty
No runs yet. @endforelse
+
diff --git a/Automation/Routes/api.php b/Automation/Routes/api.php
new file mode 100644
index 0000000..f90901d
--- /dev/null
+++ b/Automation/Routes/api.php
@@ -0,0 +1,6 @@
+middleware('throttle:60,1')->name('api.automations.trigger');
diff --git a/Automation/Routes/web.php b/Automation/Routes/web.php
new file mode 100644
index 0000000..342d7c0
--- /dev/null
+++ b/Automation/Routes/web.php
@@ -0,0 +1,13 @@
+name('administration.automation.')->middleware(['auth', 'permission:automation.view'])->group(function () {
+ Route::get('/', [AutomationController::class, 'index'])->name('index');
+ Route::post('/', [AutomationController::class, 'store'])->middleware('permission:automation.manage')->name('store');
+ Route::post('/{automation}/steps', [AutomationController::class, 'addStep'])->middleware('permission:automation.manage')->name('steps.store');
+ Route::post('/{automation}/run', [AutomationController::class, 'run'])->middleware('permission:automation.execute')->name('run');
+ Route::patch('/{automation}/toggle', [AutomationController::class, 'toggle'])->middleware('permission:automation.manage')->name('toggle');
+ Route::delete('/{automation}', [AutomationController::class, 'destroy'])->middleware('permission:automation.manage')->name('destroy');
+});
diff --git a/Automation/Services/AutomationActionRegistry.php b/Automation/Services/AutomationActionRegistry.php
new file mode 100644
index 0000000..b8e7f2c
--- /dev/null
+++ b/Automation/Services/AutomationActionRegistry.php
@@ -0,0 +1,79 @@
+ ['label' => 'Write log', 'fields' => ['message']],
+ 'webhook' => ['label' => 'Send webhook', 'fields' => ['url', 'method']],
+ 'email' => ['label' => 'Send email', 'fields' => ['to', 'subject', 'body']],
+ 'delay' => ['label' => 'Delay', 'fields' => ['seconds']],
+ ];
+ }
+
+ public function execute(AutomationStep $step, array $payload): array
+ {
+ $config = $step->configuration ?? [];
+
+ return match ($step->action_type) {
+ 'log' => $this->log($config, $payload),
+ 'webhook' => $this->webhook($config, $payload),
+ 'email' => $this->email($config, $payload),
+ 'delay' => $this->delay($config),
+ default => throw new \InvalidArgumentException("Unknown automation action [{$step->action_type}]."),
+ };
+ }
+
+ private function log(array $config, array $payload): array
+ {
+ $message = (string) ($config['message'] ?? 'Automation executed');
+ Log::info($message, $payload);
+
+ return ['message' => $message];
+ }
+
+ private function webhook(array $config, array $payload): array
+ {
+ $url = (string) ($config['url'] ?? '');
+ if ($url === '') {
+ throw new \InvalidArgumentException('Webhook URL is required.');
+ }
+ $method = strtolower((string) ($config['method'] ?? 'post'));
+ $response = Http::timeout(config('automation.webhook_timeout', 10))->send($method, $url, ['json' => $payload]);
+ $response->throw();
+
+ return ['status' => $response->status()];
+ }
+
+ private function email(array $config, array $payload): array
+ {
+ $to = (string) ($config['to'] ?? '');
+ if ($to === '') {
+ throw new \InvalidArgumentException('Email recipient is required.');
+ }
+ $subject = (string) ($config['subject'] ?? 'Automation notification');
+ $body = (string) ($config['body'] ?? json_encode($payload, JSON_PRETTY_PRINT));
+ Mail::raw($body, fn ($message) => $message->to($to)->subject($subject));
+
+ return ['to' => $to];
+ }
+
+ private function delay(array $config): array
+ {
+ $seconds = max(0, min(30, (int) ($config['seconds'] ?? 1)));
+ if ($seconds > 0) {
+ sleep($seconds);
+ }
+
+return ['seconds' => $seconds];
+ }
+}
diff --git a/Automation/Services/AutomationRunner.php b/Automation/Services/AutomationRunner.php
new file mode 100644
index 0000000..254ba8a
--- /dev/null
+++ b/Automation/Services/AutomationRunner.php
@@ -0,0 +1,46 @@
+isActive(), 422, 'Automation is not active.');
+ $run = $automation->runs()->create(['execution_id' => (string) Str::uuid(), 'trigger_type' => $triggerType ?? $automation->trigger_type, 'payload' => $payload, 'status' => 'queued']);
+ ExecuteAutomation::dispatch($run->id)->onQueue(config('automation.queue', 'default'));
+
+ return $run;
+ }
+
+ public function dispatchMatching(string $triggerType, array $payload = [], array $constraints = []): int
+ {
+ $query = Automation::query()->where('status', 'active')->where('trigger_type', $triggerType);
+ $count = 0;
+ foreach ($query->get() as $automation) {
+ if ($this->matches($automation, $constraints)) {
+ $this->dispatch($automation, $payload, $triggerType);
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ private function matches(Automation $automation, array $constraints): bool
+ {
+ $config = $automation->trigger_config ?? [];
+ foreach ($constraints as $key => $value) {
+ if (isset($config[$key]) && (string) $config[$key] !== (string) $value) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/Automation/Services/AutomationTriggerRegistry.php b/Automation/Services/AutomationTriggerRegistry.php
new file mode 100644
index 0000000..c0716f4
--- /dev/null
+++ b/Automation/Services/AutomationTriggerRegistry.php
@@ -0,0 +1,23 @@
+ ['label' => 'Manual', 'description' => 'Run from the administration area or API.'],
+ 'schedule' => ['label' => 'Schedule', 'description' => 'Run when next_run_at is due.'],
+ 'webhook' => ['label' => 'Incoming webhook', 'description' => 'Run from a signed API request.'],
+ 'domain_event' => ['label' => 'Domain event', 'description' => 'Run when an application event is dispatched.'],
+ ];
+ }
+
+ public function has(string $type): bool
+ {
+ return array_key_exists($type, $this->all());
+ }
+}
diff --git a/Automation/Tests/Feature/ModuleBootTest.php b/Automation/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..984262f
--- /dev/null
+++ b/Automation/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,16 @@
+assertTrue(class_exists(AutomationServiceProvider::class));
+ $this->assertTrue(app()->bound(ActionRegistry::class));
+ }
+}
diff --git a/Automation/module.json b/Automation/module.json
new file mode 100644
index 0000000..c39c7ae
--- /dev/null
+++ b/Automation/module.json
@@ -0,0 +1 @@
+{"name":"Automation","alias":"automation","version":"1.0.0","description":"Event-driven and scheduled automations with reusable triggers, actions, runs and execution logs.","keywords":["automation","events","triggers","actions","scheduler"],"priority":130,"providers":["Modules\\Automation\\Providers\\AutomationServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Api"]}
diff --git a/Banners/Config/config.php b/Banners/Config/config.php
new file mode 100644
index 0000000..b2a20d0
--- /dev/null
+++ b/Banners/Config/config.php
@@ -0,0 +1,3 @@
+ 'Banners'];
diff --git a/Banners/Http/Controllers/BannerController.php b/Banners/Http/Controllers/BannerController.php
new file mode 100644
index 0000000..a66e3f0
--- /dev/null
+++ b/Banners/Http/Controllers/BannerController.php
@@ -0,0 +1,60 @@
+ Banner::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('banners::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'content' => ['nullable', 'string'],
+ 'link_url' => ['nullable', 'string'],
+ 'position' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Banner::create($data);
+
+ return redirect()->route('administration.banners.index')->with('status', 'Banner created.');
+ }
+
+ public function edit(Banner $banner): View
+ {
+ return view('banners::admin.form', ['record' => $banner]);
+ }
+
+ public function update(Request $request, Banner $banner): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'content' => ['nullable', 'string'],
+ 'link_url' => ['nullable', 'string'],
+ 'position' => ['required', 'string', 'max:255'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $banner->update($data);
+
+ return redirect()->route('administration.banners.index')->with('status', 'Banner updated.');
+ }
+
+ public function destroy(Banner $banner): RedirectResponse
+ {
+ $banner->delete();
+
+ return back()->with('status', 'Banner deleted.');
+ }
+}
diff --git a/Banners/Models/Banner.php b/Banners/Models/Banner.php
new file mode 100644
index 0000000..7ba1279
--- /dev/null
+++ b/Banners/Models/Banner.php
@@ -0,0 +1,23 @@
+ 'boolean',
+ ];
+ }
+}
diff --git a/Banners/Providers/BannersServiceProvider.php b/Banners/Providers/BannersServiceProvider.php
new file mode 100644
index 0000000..eda98f7
--- /dev/null
+++ b/Banners/Providers/BannersServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Banners', 'Config/config.php'), 'banners');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Banners', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Banners', 'Resources/views'), 'banners');
+ }
+}
diff --git a/Banners/Providers/RouteServiceProvider.php b/Banners/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c948511
--- /dev/null
+++ b/Banners/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Banners', 'Routes/web.php')));
+ }
+}
diff --git a/Banners/Resources/views/admin/form.blade.php b/Banners/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..b78be52
--- /dev/null
+++ b/Banners/Resources/views/admin/form.blade.php
@@ -0,0 +1,9 @@
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Banner
+
diff --git a/Banners/Resources/views/admin/index.blade.php b/Banners/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..4ac0c2d
--- /dev/null
+++ b/Banners/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+Banners Manage banners content.
Add new
+@if(session('status'))
{{ session('status') }}
@endif
+
Title Content Link Url Actions @forelse($records as $record){{ is_array($record->title) ? json_encode($record->title) : Str::limit((string) $record->title, 70) }} {{ is_array($record->content) ? json_encode($record->content) : Str::limit((string) $record->content, 70) }} {{ is_array($record->link_url) ? json_encode($record->link_url) : Str::limit((string) $record->link_url, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Banners/Routes/web.php b/Banners/Routes/web.php
new file mode 100644
index 0000000..3651a6c
--- /dev/null
+++ b/Banners/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/banners')->name('administration.banners.')->group(function (): void {
+ Route::get('/', [BannerController::class, 'index'])->name('index');
+ Route::get('/create', [BannerController::class, 'create'])->name('create');
+ Route::post('/', [BannerController::class, 'store'])->name('store');
+ Route::get('/{banner}/edit', [BannerController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{banner}', [BannerController::class, 'update'])->name('update');
+ Route::delete('/{banner}', [BannerController::class, 'destroy'])->name('destroy');
+});
diff --git a/Banners/Support/ModuleInfo.php b/Banners/Support/ModuleInfo.php
new file mode 100644
index 0000000..09e1bda
--- /dev/null
+++ b/Banners/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Banners/module.json b/Banners/module.json
new file mode 100644
index 0000000..238aec6
--- /dev/null
+++ b/Banners/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Banners",
+ "alias": "banners",
+ "version": "1.0.0",
+ "description": "Banners CMS module.",
+ "keywords": [
+ "cms",
+ "banners"
+ ],
+ "priority": 26,
+ "providers": [
+ "Modules\\Banners\\Providers\\BannersServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Blog/Config/config.php b/Blog/Config/config.php
new file mode 100644
index 0000000..97da7a5
--- /dev/null
+++ b/Blog/Config/config.php
@@ -0,0 +1,3 @@
+ 'Blog'];
diff --git a/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php b/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
new file mode 100644
index 0000000..30684a4
--- /dev/null
+++ b/Blog/Database/Migrations/2026_07_24_03041_create_blog_posts_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete();
+ $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
+ $table->string('title');
+ $table->string('slug')->unique();
+ $table->text('excerpt')->nullable();
+ $table->longText('content')->nullable();
+ $table->string('featured_image')->nullable();
+ $table->string('status')->default('draft')->index();
+ $table->boolean('is_featured')->default(false);
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamp('expires_at')->nullable();
+ $table->string('meta_title')->nullable();
+ $table->text('meta_description')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('blog_post_tag', function (Blueprint $table): void {
+ $table->foreignId('blog_post_id')->constrained('blog_posts')->cascadeOnDelete();
+ $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
+ $table->primary(['blog_post_id', 'tag_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('blog_post_tag');
+ Schema::dropIfExists('blog_posts');
+ }
+};
diff --git a/Blog/Http/Controllers/BlogController.php b/Blog/Http/Controllers/BlogController.php
new file mode 100644
index 0000000..0e96878
--- /dev/null
+++ b/Blog/Http/Controllers/BlogController.php
@@ -0,0 +1,66 @@
+ Blog::with(['author', 'category'])->latest()->paginate(25)]);
+ }
+
+ public function create(): View
+ {
+ return view('blog::admin.form', $this->lookups());
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ $record = Blog::create($this->data($r));
+ $record->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.blog.index')->with('status', 'Blog created.');
+ }
+
+ public function edit(Blog $blog_post): View
+ {
+ return view('blog::admin.form', array_merge($this->lookups(), ['record' => $blog_post]));
+ }
+
+ public function update(Request $r, Blog $blog_post): RedirectResponse
+ {
+ $blog_post->update($this->data($r));
+ $blog_post->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.blog.index')->with('status', 'Blog updated.');
+ }
+
+ public function destroy(Blog $blog_post): RedirectResponse
+ {
+ $blog_post->delete();
+
+ return back()->with('status', 'Blog deleted.');
+ }
+
+ private function lookups(): array
+ {
+ return ['authors' => Author::where('is_active', true)->orderBy('name')->get(), 'categories' => Category::where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::orderBy('name')->get()];
+ }
+
+ private function data(Request $r): array
+ {
+ $d = $r->validate(['author_id' => ['nullable', 'exists:authors,id'], 'category_id' => ['nullable', 'exists:categories,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'excerpt' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'featured_image' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,review,published,archived'], 'is_featured' => ['sometimes', 'boolean'], 'published_at' => ['nullable', 'date'], 'expires_at' => ['nullable', 'date', 'after:published_at'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string']]);
+ $d['is_featured'] = $r->boolean('is_featured');
+
+ return $d;
+ }
+}
diff --git a/Blog/Http/Controllers/PublicBlogController.php b/Blog/Http/Controllers/PublicBlogController.php
new file mode 100644
index 0000000..6fb2096
--- /dev/null
+++ b/Blog/Http/Controllers/PublicBlogController.php
@@ -0,0 +1,20 @@
+ Blog::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]);
+ }
+
+ public function show(string $slug): View
+ {
+ return view('blog::public.show', ['record' => Blog::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]);
+ }
+}
diff --git a/Blog/Models/Blog.php b/Blog/Models/Blog.php
new file mode 100644
index 0000000..6f58ea0
--- /dev/null
+++ b/Blog/Models/Blog.php
@@ -0,0 +1,48 @@
+ 'boolean', 'published_at' => 'datetime', 'expires_at' => 'datetime'];
+ }
+
+ public function author(): BelongsTo
+ {
+ return $this->belongsTo(Author::class);
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(Category::class);
+ }
+
+ public function tags(): BelongsToMany
+ {
+ return $this->belongsToMany(Tag::class, 'blog_post_tag');
+ }
+
+ public function scopePublished(Builder $query): Builder
+ {
+ return $query->where('status', 'published')->where(fn (Builder $q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
+ }
+}
diff --git a/Blog/Providers/BlogServiceProvider.php b/Blog/Providers/BlogServiceProvider.php
new file mode 100644
index 0000000..74e8bcf
--- /dev/null
+++ b/Blog/Providers/BlogServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Blog', 'Config/config.php'), 'blog');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Blog', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Blog', 'Resources/views'), 'blog');
+ }
+}
diff --git a/Blog/Providers/RouteServiceProvider.php b/Blog/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..6c7b0c0
--- /dev/null
+++ b/Blog/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Blog', 'Routes/web.php'));
+ }
+}
diff --git a/Blog/Resources/views/admin/form.blade.php b/Blog/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..cf54f2b
--- /dev/null
+++ b/Blog/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+@csrf @isset($record) @method('PUT') @endisset None @foreach($authors as $author)author_id ?? null)==$author->id)>{{ $author->name }} @endforeachNone @foreach($categories as $category)category_id ?? null)==$category->id)>{{ $category->name }} @endforeach@foreach(['draft','review','published','archived'] as $status)status ?? 'draft')===$status)>{{ $status }} @endforeach
{{ old('excerpt',$record->excerpt ?? '') }} {{ old('content',$record->content ?? '') }} {{ old('meta_description',$record->meta_description ?? '') }} Save
diff --git a/Blog/Resources/views/admin/index.blade.php b/Blog/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..9ec9eda
--- /dev/null
+++ b/Blog/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Blog Manage publishing blog.
Create @if(session('status')){{ session('status') }}
@endifName / title Status Actions @forelse($records as $record){{ $record->title ?? $record->name }} {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
diff --git a/Blog/Resources/views/public/index.blade.php b/Blog/Resources/views/public/index.blade.php
new file mode 100644
index 0000000..b616fa9
--- /dev/null
+++ b/Blog/Resources/views/public/index.blade.php
@@ -0,0 +1,31 @@
+@extends('stylist-theme-saasland::layouts.app')
+
+@php
+ $title = 'Blog';
+ $description = 'Ideas, guides and updates from our team.';
+ $eyebrow = 'Insights';
+ $breadcrumbs = ['Blog' => null];
+@endphp
+
+@section('content')
+
+
+
+ @forelse($records as $record)
+
+
+
+
{{ optional($record->published_at)->format('j F Y') ?: 'Latest article' }}
+
+ @if($record->excerpt)
{{ $record->excerpt }}
@endif
+
Read article →
+
+
+ @empty
+
No blog posts have been published yet.
+ @endforelse
+
+
+
+
+@endsection
diff --git a/Blog/Resources/views/public/show.blade.php b/Blog/Resources/views/public/show.blade.php
new file mode 100644
index 0000000..c710f44
--- /dev/null
+++ b/Blog/Resources/views/public/show.blade.php
@@ -0,0 +1,23 @@
+@extends('stylist-theme-saasland::layouts.app')
+
+@php
+ $title = $record->title;
+ $description = $record->excerpt;
+ $eyebrow = 'Blog';
+ $breadcrumbs = ['Blog' => route('blog.public.index'), $record->title => null];
+@endphp
+
+@section('content')
+
+
+
+
+ @if($record->author){{ $record->author->name }} @endif
+ @if($record->published_at){{ $record->published_at->format('j F Y') }} @endif
+
+ @if($record->excerpt){{ $record->excerpt }}
@endif
+ {!! nl2br(e($record->content)) !!}
+
+
+
+@endsection
diff --git a/Blog/Routes/web.php b/Blog/Routes/web.php
new file mode 100644
index 0000000..01f9b3d
--- /dev/null
+++ b/Blog/Routes/web.php
@@ -0,0 +1,16 @@
+prefix('administration/blog')->name('administration.blog.')->group(function (): void {
+ Route::get('/', [BlogController::class, 'index'])->name('index');
+ Route::get('/create', [BlogController::class, 'create'])->name('create');
+ Route::post('/', [BlogController::class, 'store'])->name('store');
+ Route::get('/{blog_post}/edit', [BlogController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{blog_post}', [BlogController::class, 'update'])->name('update');
+ Route::delete('/{blog_post}', [BlogController::class, 'destroy'])->name('destroy');
+});
+Route::get('/blog', [PublicBlogController::class, 'index'])->name('blog.public.index');
+Route::get('/blog/{slug}', [PublicBlogController::class, 'show'])->name('blog.public.show');
diff --git a/Blog/module.json b/Blog/module.json
new file mode 100644
index 0000000..e72c145
--- /dev/null
+++ b/Blog/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Blog",
+ "alias": "blog",
+ "version": "1.0.0",
+ "description": "Blog publishing module.",
+ "keywords": ["publishing", "blog"],
+ "priority": 30,
+ "providers": ["Modules\\Blog\\Providers\\BlogServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Builder/Components/ContentComponent.php b/Builder/Components/ContentComponent.php
new file mode 100644
index 0000000..63dc588
--- /dev/null
+++ b/Builder/Components/ContentComponent.php
@@ -0,0 +1,38 @@
+ ['type' => 'textarea', 'required' => true]];
+ }
+
+ public function defaults(): array
+ {
+ return ['content' => '
Add your content here.
'];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ return (string) ($properties['content'] ?? '');
+ }
+}
diff --git a/Builder/Components/HeadingComponent.php b/Builder/Components/HeadingComponent.php
new file mode 100644
index 0000000..e619242
--- /dev/null
+++ b/Builder/Components/HeadingComponent.php
@@ -0,0 +1,40 @@
+ ['type' => 'text', 'required' => true], 'level' => ['type' => 'select', 'options' => [1, 2, 3, 4, 5, 6]]];
+ }
+
+ public function defaults(): array
+ {
+ return ['text' => 'Heading', 'level' => 2];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ $level = max(1, min(6, (int) ($properties['level'] ?? 2)));
+
+ return ''.e($properties['text'] ?? '').' ';
+ }
+}
diff --git a/Builder/Components/HeroComponent.php b/Builder/Components/HeroComponent.php
new file mode 100644
index 0000000..f017dea
--- /dev/null
+++ b/Builder/Components/HeroComponent.php
@@ -0,0 +1,38 @@
+ ['type' => 'text', 'required' => true], 'content' => ['type' => 'textarea'], 'button_label' => ['type' => 'text'], 'button_url' => ['type' => 'text']];
+ }
+
+ public function defaults(): array
+ {
+ return ['heading' => 'A compelling headline', 'content' => 'Add supporting content here.', 'button_label' => 'Learn more', 'button_url' => '#'];
+ }
+
+ public function render(array $properties, array $context = []): string
+ {
+ return view('builder::components.hero', ['properties' => $properties])->render();
+ }
+}
diff --git a/Builder/Config/config.php b/Builder/Config/config.php
new file mode 100644
index 0000000..b3a8362
--- /dev/null
+++ b/Builder/Config/config.php
@@ -0,0 +1,8 @@
+ (int) env('BUILDER_MAX_DOCUMENT_SIZE_KB', 512),
+ 'autosave_seconds' => (int) env('BUILDER_AUTOSAVE_SECONDS', 30),
+ 'preview_route' => env('BUILDER_PREVIEW_ROUTE', 'builder.preview'),
+ 'allowed_types' => ['page', 'form', 'dashboard', 'report', 'menu', 'email', 'query', 'workflow'],
+];
diff --git a/Builder/Contracts/BuilderComponent.php b/Builder/Contracts/BuilderComponent.php
new file mode 100644
index 0000000..e16fad7
--- /dev/null
+++ b/Builder/Contracts/BuilderComponent.php
@@ -0,0 +1,18 @@
+id();
+ $table->foreignId('site_id')->nullable()->constrained('sites')->nullOnDelete();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->nullable();
+ $table->string('status', 20)->default('draft')->index();
+ $table->unsignedInteger('version')->default(1);
+ $table->json('document')->nullable();
+ $table->json('settings')->nullable();
+ $table->nullableMorphs('buildable');
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('published_at')->nullable();
+ $table->timestamps();
+ $table->unique(['site_id', 'type', 'slug'], 'builder_document_site_type_slug_unique');
+ });
+ Schema::create('builder_document_versions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('builder_document_id')->constrained('builder_documents')->cascadeOnDelete();
+ $table->unsignedInteger('version');
+ $table->json('document');
+ $table->json('settings')->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->unique(['builder_document_id', 'version']);
+ });
+ Schema::create('builder_templates', function (Blueprint $table) {
+ $table->id();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->json('document');
+ $table->json('settings')->nullable();
+ $table->boolean('is_system')->default(false);
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('builder_templates');
+ Schema::dropIfExists('builder_document_versions');
+ Schema::dropIfExists('builder_documents');
+ }
+};
diff --git a/Builder/Http/Controllers/BuilderApiController.php b/Builder/Http/Controllers/BuilderApiController.php
new file mode 100644
index 0000000..57296aa
--- /dev/null
+++ b/Builder/Http/Controllers/BuilderApiController.php
@@ -0,0 +1,27 @@
+json(['data' => $registry->components(), 'widgets' => $registry->widgets(), 'fields' => $registry->fields()]);
+ }
+
+ public function show(BuilderDocument $document): JsonResponse
+ {
+ return response()->json(['data' => $document]);
+ }
+
+ public function preview(BuilderDocument $document, BuilderRenderer $renderer): JsonResponse
+ {
+ return response()->json(['html' => $renderer->render($document->document ?? [])]);
+ }
+}
diff --git a/Builder/Http/Controllers/BuilderController.php b/Builder/Http/Controllers/BuilderController.php
new file mode 100644
index 0000000..70443cc
--- /dev/null
+++ b/Builder/Http/Controllers/BuilderController.php
@@ -0,0 +1,61 @@
+string('type')->toString();
+ $documents = BuilderDocument::query()->when($type, fn ($q) => $q->where('type', $type))->latest()->paginate(20)->withQueryString();
+
+ return view('builder::index', compact('documents', 'type'));
+ }
+
+ public function create(BuilderRegistry $registry): View
+ {
+ return view('builder::editor', ['document' => new BuilderDocument(['type' => request('type', 'page'), 'document' => ['nodes' => []]]), 'components' => $registry->components()]);
+ }
+
+ public function edit(BuilderDocument $document, BuilderRegistry $registry): View
+ {
+ return view('builder::editor', ['document' => $document, 'components' => $registry->components()]);
+ }
+
+ public function store(Request $request, BuilderDocumentService $service): RedirectResponse
+ {
+ $document = $service->save(new BuilderDocument, $this->validated($request), $request->user()?->id);
+
+ return redirect()->route('administration.builder.edit', $document)->with('status', 'Builder document created.');
+ }
+
+ public function update(Request $request, BuilderDocument $document, BuilderDocumentService $service): RedirectResponse
+ {
+ $service->save($document, $this->validated($request), $request->user()?->id);
+
+ return back()->with('status', 'Builder document saved.');
+ }
+
+ public function destroy(BuilderDocument $document): RedirectResponse
+ {
+ $document->delete();
+
+ return redirect()->route('administration.builder.index')->with('status', 'Builder document deleted.');
+ }
+
+ private function validated(Request $request): array
+ {
+ $data = $request->validate(['type' => ['required', 'in:'.implode(',', config('builder.allowed_types'))], 'name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,published,archived'], 'document_json' => ['required', 'json'], 'settings_json' => ['nullable', 'json']]);
+
+ return ['type' => $data['type'], 'name' => $data['name'], 'slug' => $data['slug'] ?: Str::slug($data['name']), 'status' => $data['status'], 'document' => json_decode($data['document_json'], true, 512, JSON_THROW_ON_ERROR), 'settings' => json_decode($data['settings_json'] ?: '{}', true, 512, JSON_THROW_ON_ERROR), 'published_at' => $data['status'] === 'published' ? now() : null];
+ }
+}
diff --git a/Builder/Models/BuilderDocument.php b/Builder/Models/BuilderDocument.php
new file mode 100644
index 0000000..edb53ad
--- /dev/null
+++ b/Builder/Models/BuilderDocument.php
@@ -0,0 +1,18 @@
+ 'array', 'settings' => 'array', 'published_at' => 'datetime'];
+
+ public function versions(): HasMany
+ {
+ return $this->hasMany(BuilderDocumentVersion::class);
+ }
+}
diff --git a/Builder/Models/BuilderDocumentVersion.php b/Builder/Models/BuilderDocumentVersion.php
new file mode 100644
index 0000000..30d6a78
--- /dev/null
+++ b/Builder/Models/BuilderDocumentVersion.php
@@ -0,0 +1,12 @@
+ 'array', 'settings' => 'array'];
+}
diff --git a/Builder/Models/BuilderTemplate.php b/Builder/Models/BuilderTemplate.php
new file mode 100644
index 0000000..68520d6
--- /dev/null
+++ b/Builder/Models/BuilderTemplate.php
@@ -0,0 +1,12 @@
+ 'array', 'settings' => 'array', 'is_system' => 'boolean', 'is_active' => 'boolean'];
+}
diff --git a/Builder/Providers/BuilderServiceProvider.php b/Builder/Providers/BuilderServiceProvider.php
new file mode 100644
index 0000000..d1d9c21
--- /dev/null
+++ b/Builder/Providers/BuilderServiceProvider.php
@@ -0,0 +1,24 @@
+app->singleton(BuilderRegistry::class);
+ $this->app->alias(BuilderRegistry::class, 'builder.registry');
+ $this->app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Builder', 'Config/config.php'), 'builder');
+ }
+
+ public function boot(BuilderRegistry $registry): void
+ {
+ $this->loadMigrationsFrom(module_path('Builder', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Builder', 'Resources/views'), 'builder');
+ $registry->registerDefaults();
+ }
+}
diff --git a/Builder/Providers/RouteServiceProvider.php b/Builder/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..e63c673
--- /dev/null
+++ b/Builder/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Builder', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api/v1')->group(module_path('Builder', 'Routes/api.php'));
+ }
+}
diff --git a/Builder/Resources/views/components/hero.blade.php b/Builder/Resources/views/components/hero.blade.php
new file mode 100644
index 0000000..34083c8
--- /dev/null
+++ b/Builder/Resources/views/components/hero.blade.php
@@ -0,0 +1 @@
+
diff --git a/Builder/Resources/views/editor.blade.php b/Builder/Resources/views/editor.blade.php
new file mode 100644
index 0000000..82c8070
--- /dev/null
+++ b/Builder/Resources/views/editor.blade.php
@@ -0,0 +1,10 @@
+
+
+
{{ $document->exists ? 'Edit' : 'Create' }} Builder Document Use the component palette and JSON document canvas. Visual drag-and-drop clients can use the component API.
+
@csrf @if($document->exists) @method('PUT') @endif
+ @foreach(config('builder.allowed_types') as $type)type)===$type)>{{ str($type)->headline() }} @endforeach status)==='draft')>Draft status)==='published')>Published status)==='archived')>Archived
+
+ {{ old('document_json',json_encode($document->document ?: ['nodes'=>[]],JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }} {{ old('settings_json',json_encode($document->settings ?: new stdClass(),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)) }}
+ Save Cancel
+
+
diff --git a/Builder/Resources/views/index.blade.php b/Builder/Resources/views/index.blade.php
new file mode 100644
index 0000000..c146805
--- /dev/null
+++ b/Builder/Resources/views/index.blade.php
@@ -0,0 +1,7 @@
+
+
+
Low-Code Builder Pages, forms, dashboards, reports, menus, emails, queries and workflows.
@can('builder.manage')
Create @endcan
+
+
Name Type Status Version @forelse($documents as $document){{ $document->name }} {{ str($document->type)->headline() }} {{ ucfirst($document->status) }} {{ $document->version }} Edit @emptyNo builder documents yet. @endforelse
+ {{ $documents->links() }}
+
diff --git a/Builder/Routes/api.php b/Builder/Routes/api.php
new file mode 100644
index 0000000..41b80ee
--- /dev/null
+++ b/Builder/Routes/api.php
@@ -0,0 +1,11 @@
+prefix('builder')->name('api.builder.')->group(function () {
+ Route::get('/components', [BuilderApiController::class, 'components'])->name('components');
+ Route::get('/documents/{document}', [BuilderApiController::class, 'show'])->name('show');
+ Route::get('/documents/{document}/preview', [BuilderApiController::class, 'preview'])->name('preview');
+});
diff --git a/Builder/Routes/web.php b/Builder/Routes/web.php
new file mode 100644
index 0000000..ed43359
--- /dev/null
+++ b/Builder/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/builder')->name('administration.builder.')->group(function () {
+ Route::get('/', [BuilderController::class, 'index'])->name('index');
+ Route::get('/create', [BuilderController::class, 'create'])->middleware('permission:builder.manage')->name('create');
+ Route::post('/', [BuilderController::class, 'store'])->middleware('permission:builder.manage')->name('store');
+ Route::get('/{document}/edit', [BuilderController::class, 'edit'])->middleware('permission:builder.manage')->name('edit');
+ Route::put('/{document}', [BuilderController::class, 'update'])->middleware('permission:builder.manage')->name('update');
+ Route::delete('/{document}', [BuilderController::class, 'destroy'])->middleware('permission:builder.manage')->name('destroy');
+});
diff --git a/Builder/Services/BuilderDocumentService.php b/Builder/Services/BuilderDocumentService.php
new file mode 100644
index 0000000..29f2368
--- /dev/null
+++ b/Builder/Services/BuilderDocumentService.php
@@ -0,0 +1,28 @@
+exists && $document->isDirty()) {
+ $document->versions()->create(['version' => $document->version, 'document' => $document->getOriginal('document') ?: [], 'settings' => $document->getOriginal('settings') ?: [], 'created_by' => $userId]);
+ }
+ $document->fill($payload);
+ $document->updated_by = $userId;
+ if (! $document->exists) {
+ $document->created_by = $userId;
+ } else {
+ $document->version++;
+ }
+ $document->save();
+
+ return $document->refresh();
+ });
+ }
+}
diff --git a/Builder/Services/BuilderRegistry.php b/Builder/Services/BuilderRegistry.php
new file mode 100644
index 0000000..0e63193
--- /dev/null
+++ b/Builder/Services/BuilderRegistry.php
@@ -0,0 +1,56 @@
+registerComponent(new $component);
+ }
+ foreach (['metric', 'chart', 'table', 'activity', 'calendar'] as $widget) {
+ $this->widgets[$widget] = ['type' => $widget, 'label' => str($widget)->headline()->toString()];
+ }
+ foreach (['text', 'textarea', 'email', 'number', 'select', 'checkbox', 'date', 'file'] as $field) {
+ $this->fields[$field] = ['type' => $field, 'label' => str($field)->headline()->toString()];
+ }
+ }
+
+ public function registerComponent(BuilderComponent $component): void
+ {
+ $this->components[$component->type()] = $component;
+ }
+
+ public function component(string $type): BuilderComponent
+ {
+ return $this->components[$type] ?? throw new InvalidArgumentException("Unknown builder component [$type].");
+ }
+
+ public function components(): array
+ {
+ return array_map(fn (BuilderComponent $c) => ['type' => $c->type(), 'label' => $c->label(), 'category' => $c->category(), 'schema' => $c->schema(), 'defaults' => $c->defaults()], array_values($this->components));
+ }
+
+ public function widgets(): array
+ {
+ return array_values($this->widgets);
+ }
+
+ public function fields(): array
+ {
+ return array_values($this->fields);
+ }
+}
diff --git a/Builder/Services/BuilderRenderer.php b/Builder/Services/BuilderRenderer.php
new file mode 100644
index 0000000..2b8a647
--- /dev/null
+++ b/Builder/Services/BuilderRenderer.php
@@ -0,0 +1,19 @@
+map(function (array $node) use ($context) {
+ if (($node['kind'] ?? 'component') === 'container') {
+ return ''.$this->render($node, $context).'
';
+ }
+
+ return $this->registry->component((string) ($node['type'] ?? 'content'))->render($node['properties'] ?? [], $context);
+ })->implode("\n");
+ }
+}
diff --git a/Builder/Tests/Feature/BuilderModuleTest.php b/Builder/Tests/Feature/BuilderModuleTest.php
new file mode 100644
index 0000000..0844567
--- /dev/null
+++ b/Builder/Tests/Feature/BuilderModuleTest.php
@@ -0,0 +1,16 @@
+components();
+ $this->assertNotEmpty($components);
+ $this->assertContains('hero', array_column($components, 'type'));
+ }
+}
diff --git a/Builder/module.json b/Builder/module.json
new file mode 100644
index 0000000..a17c5b0
--- /dev/null
+++ b/Builder/module.json
@@ -0,0 +1 @@
+{"name":"Builder","alias":"builder","version":"1.0.0","description":"Low-code visual builders for pages, forms, dashboards, reports, menus, emails, queries and workflows.","keywords":["builder","low-code","visual","components","layouts"],"priority":160,"providers":["Modules\\Builder\\Providers\\BuilderServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Pages","Forms","Menus","Workflow","Analytics","Sites"]}
diff --git a/CRM/Config/config.php b/CRM/Config/config.php
new file mode 100644
index 0000000..c94cac3
--- /dev/null
+++ b/CRM/Config/config.php
@@ -0,0 +1,3 @@
+ 'CRM'];
diff --git a/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php b/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php
new file mode 100644
index 0000000..cf3bc64
--- /dev/null
+++ b/CRM/Database/Migrations/2026_07_24_04000_create_crm_tables.php
@@ -0,0 +1,79 @@
+id();
+ $t->string('name');
+ $t->string('website')->nullable();
+ $t->string('phone')->nullable();
+ $t->string('email')->nullable();
+ $t->string('industry')->nullable();
+ $t->text('address')->nullable();
+ $t->text('notes')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('crm_contacts', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete();
+ $t->string('first_name');
+ $t->string('last_name');
+ $t->string('email')->nullable()->index();
+ $t->string('phone')->nullable();
+ $t->string('job_title')->nullable();
+ $t->text('notes')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('crm_leads', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete();
+ $t->string('title');
+ $t->string('source')->nullable();
+ $t->string('status')->default('new')->index();
+ $t->decimal('estimated_value', 12, 2)->nullable();
+ $t->text('notes')->nullable();
+ $t->timestamp('next_action_at')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('crm_opportunities', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('company_id')->nullable()->constrained('crm_companies')->nullOnDelete();
+ $t->foreignId('contact_id')->nullable()->constrained('crm_contacts')->nullOnDelete();
+ $t->string('name');
+ $t->string('stage')->default('qualification')->index();
+ $t->decimal('value', 12, 2)->default(0);
+ $t->unsignedTinyInteger('probability')->default(0);
+ $t->date('expected_close_at')->nullable();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('crm_activities', function (Blueprint $t): void {
+ $t->id();
+ $t->nullableMorphs('subject');
+ $t->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $t->string('type');
+ $t->string('title');
+ $t->text('description')->nullable();
+ $t->timestamp('due_at')->nullable();
+ $t->timestamp('completed_at')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('crm_activities');
+ Schema::dropIfExists('crm_opportunities');
+ Schema::dropIfExists('crm_leads');
+ Schema::dropIfExists('crm_contacts');
+ Schema::dropIfExists('crm_companies');
+ }
+};
diff --git a/CRM/Http/Controllers/CRMController.php b/CRM/Http/Controllers/CRMController.php
new file mode 100644
index 0000000..0d85cd6
--- /dev/null
+++ b/CRM/Http/Controllers/CRMController.php
@@ -0,0 +1,55 @@
+ Company::withCount('contacts')->latest()->paginate(20), 'contacts' => Contact::with('company')->latest()->limit(10)->get(), 'leads' => Lead::with('contact')->latest()->limit(10)->get(), 'opportunities' => Opportunity::with('company')->latest()->limit(10)->get(), 'pipelineValue' => Opportunity::whereNotIn('stage', ['won', 'lost'])->sum('value')]);
+ }
+
+ public function storeCompany(Request $r): RedirectResponse
+ {
+ Company::create($r->validate(['name' => ['required', 'string', 'max:255'], 'website' => ['nullable', 'url', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'industry' => ['nullable', 'string', 'max:120'], 'address' => ['nullable', 'string'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Company created.');
+ }
+
+ public function storeContact(Request $r): RedirectResponse
+ {
+ Contact::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'first_name' => ['required', 'string', 'max:120'], 'last_name' => ['required', 'string', 'max:120'], 'email' => ['nullable', 'email', 'max:255'], 'phone' => ['nullable', 'string', 'max:80'], 'job_title' => ['nullable', 'string', 'max:120'], 'notes' => ['nullable', 'string']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Contact created.');
+ }
+
+ public function storeLead(Request $r): RedirectResponse
+ {
+ Lead::create($r->validate(['contact_id' => ['nullable', 'exists:crm_contacts,id'], 'title' => ['required', 'string', 'max:255'], 'source' => ['nullable', 'string', 'max:120'], 'status' => ['required', 'in:new,qualified,proposal,won,lost'], 'estimated_value' => ['nullable', 'numeric', 'min:0'], 'notes' => ['nullable', 'string'], 'next_action_at' => ['nullable', 'date']]));
+
+ return back()->with('status', 'Lead created.');
+ }
+
+ public function storeOpportunity(Request $r): RedirectResponse
+ {
+ Opportunity::create($r->validate(['company_id' => ['nullable', 'exists:crm_companies,id'], 'contact_id' => ['nullable', 'exists:crm_contacts,id'], 'name' => ['required', 'string', 'max:255'], 'stage' => ['required', 'in:qualification,proposal,negotiation,won,lost'], 'value' => ['required', 'numeric', 'min:0'], 'probability' => ['required', 'integer', 'between:0,100'], 'expected_close_at' => ['nullable', 'date'], 'notes' => ['nullable', 'string']]));
+
+ return back()->with('status', 'Opportunity created.');
+ }
+
+ public function destroyCompany(Company $company): RedirectResponse
+ {
+ $company->delete();
+
+ return back()->with('status', 'Company deleted.');
+ }
+}
diff --git a/CRM/Models/Activity.php b/CRM/Models/Activity.php
new file mode 100644
index 0000000..1a2e8b6
--- /dev/null
+++ b/CRM/Models/Activity.php
@@ -0,0 +1,23 @@
+ 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/CRM/Models/Company.php b/CRM/Models/Company.php
new file mode 100644
index 0000000..76bcf10
--- /dev/null
+++ b/CRM/Models/Company.php
@@ -0,0 +1,23 @@
+ 'boolean'];
+ }
+
+ public function contacts(): HasMany
+ {
+ return $this->hasMany(Contact::class);
+ }
+}
diff --git a/CRM/Models/Contact.php b/CRM/Models/Contact.php
new file mode 100644
index 0000000..e1f08cb
--- /dev/null
+++ b/CRM/Models/Contact.php
@@ -0,0 +1,28 @@
+ 'boolean'];
+ }
+
+ public function company(): BelongsTo
+ {
+ return $this->belongsTo(Company::class);
+ }
+
+ public function getNameAttribute(): string
+ {
+ return trim($this->first_name.' '.$this->last_name);
+ }
+}
diff --git a/CRM/Models/Lead.php b/CRM/Models/Lead.php
new file mode 100644
index 0000000..571b5dd
--- /dev/null
+++ b/CRM/Models/Lead.php
@@ -0,0 +1,23 @@
+ 'decimal:2', 'next_action_at' => 'datetime'];
+ }
+
+ public function contact(): BelongsTo
+ {
+ return $this->belongsTo(Contact::class);
+ }
+}
diff --git a/CRM/Models/Opportunity.php b/CRM/Models/Opportunity.php
new file mode 100644
index 0000000..6cd7884
--- /dev/null
+++ b/CRM/Models/Opportunity.php
@@ -0,0 +1,28 @@
+ 'decimal:2', 'expected_close_at' => 'date'];
+ }
+
+ public function company(): BelongsTo
+ {
+ return $this->belongsTo(Company::class);
+ }
+
+ public function contact(): BelongsTo
+ {
+ return $this->belongsTo(Contact::class);
+ }
+}
diff --git a/CRM/Providers/CRMServiceProvider.php b/CRM/Providers/CRMServiceProvider.php
new file mode 100644
index 0000000..ba46a79
--- /dev/null
+++ b/CRM/Providers/CRMServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('CRM', 'Config/config.php'), 'crm');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('CRM', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('CRM', 'Resources/views'), 'crm');
+ }
+}
diff --git a/CRM/Providers/RouteServiceProvider.php b/CRM/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..94e56b2
--- /dev/null
+++ b/CRM/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('CRM', 'Routes/web.php'));
+ }
+}
diff --git a/CRM/Resources/views/admin/index.blade.php b/CRM/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..4a4868f
--- /dev/null
+++ b/CRM/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+CRM Companies, contacts, leads and sales opportunities.
@if(session('status'))
{{ session('status') }}
@endif
Companies
{{ $companies->total() }}
Contacts
{{ \Modules\CRM\Models\Contact::count() }}
Open leads
{{ \Modules\CRM\Models\Lead::whereNotIn('status',['won','lost'])->count() }}
Pipeline
£{{ number_format($pipelineValue,2) }}
@csrfAdd company
Create company @csrfAdd contact None @foreach($companies as $company){{ $company->name }} @endforeach
Create contact Company Industry Contacts @forelse($companies as $company){{ $company->name }} {{ $company->industry ?: '—' }} {{ $company->contacts_count }} @csrf @method('DELETE')Delete @emptyNo companies yet. @endforelse
{{ $companies->links() }}
diff --git a/CRM/Routes/web.php b/CRM/Routes/web.php
new file mode 100644
index 0000000..5333122
--- /dev/null
+++ b/CRM/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/crm')->name('administration.crm.')->group(function (): void {
+ Route::get('/', [CRMController::class, 'index'])->name('index');
+ Route::post('/companies', [CRMController::class, 'storeCompany'])->name('companies.store');
+ Route::delete('/companies/{company}', [CRMController::class, 'destroyCompany'])->name('companies.destroy');
+ Route::post('/contacts', [CRMController::class, 'storeContact'])->name('contacts.store');
+ Route::post('/leads', [CRMController::class, 'storeLead'])->name('leads.store');
+ Route::post('/opportunities', [CRMController::class, 'storeOpportunity'])->name('opportunities.store');
+});
diff --git a/CRM/Tests/Feature/ModuleBootTest.php b/CRM/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..3bbcf6f
--- /dev/null
+++ b/CRM/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('CRM', config('crm.name'));
+ }
+}
diff --git a/CRM/module.json b/CRM/module.json
new file mode 100644
index 0000000..8d2e7b2
--- /dev/null
+++ b/CRM/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "CRM",
+ "alias": "crm",
+ "version": "1.0.0",
+ "description": "Customer relationship management for companies, contacts, leads, opportunities and activities.",
+ "keywords": ["business", "crm"],
+ "priority": 40,
+ "providers": ["Modules\\CRM\\Providers\\CRMServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Careers/Config/config.php b/Careers/Config/config.php
new file mode 100644
index 0000000..46a8b16
--- /dev/null
+++ b/Careers/Config/config.php
@@ -0,0 +1,3 @@
+ 'Careers'];
diff --git a/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php b/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php
new file mode 100644
index 0000000..c8627af
--- /dev/null
+++ b/Careers/Database/Migrations/2026_07_24_04300_create_careers_tables.php
@@ -0,0 +1,43 @@
+id();
+ $t->string('title');
+ $t->string('slug')->unique();
+ $t->string('department')->nullable();
+ $t->string('location')->nullable();
+ $t->string('employment_type')->default('full-time');
+ $t->longText('description')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->timestamp('published_at')->nullable();
+ $t->timestamp('closes_at')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('career_applications', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('job_id')->constrained('career_jobs')->cascadeOnDelete();
+ $t->string('name');
+ $t->string('email')->index();
+ $t->string('phone')->nullable();
+ $t->string('cv_path')->nullable();
+ $t->text('cover_letter')->nullable();
+ $t->string('status')->default('new')->index();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('career_applications');
+ Schema::dropIfExists('career_jobs');
+ }
+};
diff --git a/Careers/Http/Controllers/CareerController.php b/Careers/Http/Controllers/CareerController.php
new file mode 100644
index 0000000..d9c2bad
--- /dev/null
+++ b/Careers/Http/Controllers/CareerController.php
@@ -0,0 +1,32 @@
+ Job::withCount('applications')->latest()->paginate(20), 'applications' => Application::latest()->limit(10)->get()]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Job::create($r->validate(['title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:career_jobs,slug'], 'department' => ['nullable', 'string', 'max:120'], 'location' => ['nullable', 'string', 'max:120'], 'employment_type' => ['required', 'in:full-time,part-time,contract,temporary,internship'], 'description' => ['nullable', 'string'], 'status' => ['required', 'in:draft,published,closed'], 'published_at' => ['nullable', 'date'], 'closes_at' => ['nullable', 'date', 'after:published_at']]));
+
+ return back()->with('status', 'Job created.');
+ }
+
+ public function destroy(Job $job): RedirectResponse
+ {
+ $job->delete();
+
+ return back()->with('status', 'Job deleted.');
+ }
+}
diff --git a/Careers/Models/Application.php b/Careers/Models/Application.php
new file mode 100644
index 0000000..e85d73e
--- /dev/null
+++ b/Careers/Models/Application.php
@@ -0,0 +1,12 @@
+ 'datetime', 'closes_at' => 'datetime'];
+ }
+
+ public function applications(): HasMany
+ {
+ return $this->hasMany(Application::class);
+ }
+}
diff --git a/Careers/Providers/CareersServiceProvider.php b/Careers/Providers/CareersServiceProvider.php
new file mode 100644
index 0000000..5f4cfff
--- /dev/null
+++ b/Careers/Providers/CareersServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Careers', 'Config/config.php'), 'careers');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Careers', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Careers', 'Resources/views'), 'careers');
+ }
+}
diff --git a/Careers/Providers/RouteServiceProvider.php b/Careers/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..4e15858
--- /dev/null
+++ b/Careers/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Careers', 'Routes/web.php'));
+ }
+}
diff --git a/Careers/Resources/views/admin/index.blade.php b/Careers/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..bac71a2
--- /dev/null
+++ b/Careers/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Careers Manage vacancies and candidate applications.
@if(session('status'))
{{ session('status') }}
@endif
@csrfCreate vacancy Full time Part time Contract Temporary Internship Draft Published Closed
Create vacancy Vacancy Department Status Applications @forelse($jobs as $job){{ $job->title }} {{ $job->department ?: '—' }} {{ ucfirst($job->status) }} {{ $job->applications_count }} @csrf @method('DELETE')Delete @emptyNo vacancies yet. @endforelse
{{ $jobs->links() }}
diff --git a/Careers/Routes/web.php b/Careers/Routes/web.php
new file mode 100644
index 0000000..ed2f45f
--- /dev/null
+++ b/Careers/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/careers')->name('administration.careers.')->group(function (): void {
+ Route::get('/', [CareerController::class, 'index'])->name('index');
+ Route::post('/', [CareerController::class, 'store'])->name('store');
+ Route::delete('/{job}', [CareerController::class, 'destroy'])->name('destroy');
+});
diff --git a/Careers/Tests/Feature/ModuleBootTest.php b/Careers/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..28b7a14
--- /dev/null
+++ b/Careers/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Careers', config('careers.name'));
+ }
+}
diff --git a/Careers/module.json b/Careers/module.json
new file mode 100644
index 0000000..cda83cf
--- /dev/null
+++ b/Careers/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Careers",
+ "alias": "careers",
+ "version": "1.0.0",
+ "description": "Job vacancies and candidate application workflow.",
+ "keywords": ["business", "careers"],
+ "priority": 43,
+ "providers": ["Modules\\Careers\\Providers\\CareersServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Categories/Config/config.php b/Categories/Config/config.php
new file mode 100644
index 0000000..f310716
--- /dev/null
+++ b/Categories/Config/config.php
@@ -0,0 +1,3 @@
+ 'Categories'];
diff --git a/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php b/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
new file mode 100644
index 0000000..9796bc5
--- /dev/null
+++ b/Categories/Database/Migrations/2026_07_24_030100_create_categories_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->string('type')->default('content')->index();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('categories');
+ }
+};
diff --git a/Categories/Http/Controllers/CategoryController.php b/Categories/Http/Controllers/CategoryController.php
new file mode 100644
index 0000000..feca75e
--- /dev/null
+++ b/Categories/Http/Controllers/CategoryController.php
@@ -0,0 +1,56 @@
+ Category::with('parent')->orderBy('type')->orderBy('sort_order')->paginate(30)]);
+ }
+
+ public function create(): View
+ {
+ return view('categories::admin.form', ['parents' => Category::orderBy('name')->get()]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ Category::create($this->validated($request));
+
+ return redirect()->route('administration.categories.index')->with('status', 'Category created.');
+ }
+
+ public function edit(Category $category): View
+ {
+ return view('categories::admin.form', ['record' => $category, 'parents' => Category::whereKeyNot($category->getKey())->orderBy('name')->get()]);
+ }
+
+ public function update(Request $request, Category $category): RedirectResponse
+ {
+ $category->update($this->validated($request));
+
+ return redirect()->route('administration.categories.index')->with('status', 'Category updated.');
+ }
+
+ public function destroy(Category $category): RedirectResponse
+ {
+ $category->delete();
+
+ return back()->with('status', 'Category deleted.');
+ }
+
+ private function validated(Request $request): array
+ {
+ $data = $request->validate(['parent_id' => ['nullable', 'exists:categories,id'], 'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'type' => ['required', 'string', 'max:50'], 'sort_order' => ['nullable', 'integer', 'min:0'], 'is_active' => ['sometimes', 'boolean']]);
+ $data['is_active'] = $request->boolean('is_active');
+
+ return $data;
+ }
+}
diff --git a/Categories/Models/Category.php b/Categories/Models/Category.php
new file mode 100644
index 0000000..8835411
--- /dev/null
+++ b/Categories/Models/Category.php
@@ -0,0 +1,27 @@
+ 'boolean', 'sort_order' => 'integer'];
+ }
+
+ public function parent(): BelongsTo
+ {
+ return $this->belongsTo(self::class, 'parent_id');
+ }
+
+ public function children(): HasMany
+ {
+ return $this->hasMany(self::class, 'parent_id')->orderBy('sort_order');
+ }
+}
diff --git a/Categories/Providers/CategoriesServiceProvider.php b/Categories/Providers/CategoriesServiceProvider.php
new file mode 100644
index 0000000..8a20b37
--- /dev/null
+++ b/Categories/Providers/CategoriesServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Categories', 'Config/config.php'), 'categories');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Categories', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Categories', 'Resources/views'), 'categories');
+ }
+}
diff --git a/Categories/Providers/RouteServiceProvider.php b/Categories/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..1423c92
--- /dev/null
+++ b/Categories/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Categories', 'Routes/web.php'));
+ }
+}
diff --git a/Categories/Resources/views/admin/form.blade.php b/Categories/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..3ec530b
--- /dev/null
+++ b/Categories/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+@csrf @isset($record) @method('PUT') @endisset None @foreach($parents as $parent)parent_id ?? null)==$parent->id)>{{ $parent->name }} @endforeach{{ old('description',$record->description ?? '') }} Save
diff --git a/Categories/Resources/views/admin/index.blade.php b/Categories/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..8b15801
--- /dev/null
+++ b/Categories/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Categories Manage publishing categories.
Create @if(session('status')){{ session('status') }}
@endifName / title Status Actions @forelse($records as $record){{ $record->title ?? $record->name }} {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
diff --git a/Categories/Routes/web.php b/Categories/Routes/web.php
new file mode 100644
index 0000000..6ec5ed1
--- /dev/null
+++ b/Categories/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/categories')->name('administration.categories.')->group(function (): void {
+ Route::get('/', [CategoryController::class, 'index'])->name('index');
+ Route::get('/create', [CategoryController::class, 'create'])->name('create');
+ Route::post('/', [CategoryController::class, 'store'])->name('store');
+ Route::get('/{category}/edit', [CategoryController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{category}', [CategoryController::class, 'update'])->name('update');
+ Route::delete('/{category}', [CategoryController::class, 'destroy'])->name('destroy');
+});
diff --git a/Categories/Tests/Feature/ModuleBootTest.php b/Categories/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..479e0ae
--- /dev/null
+++ b/Categories/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Categories', config('categories.name'));
+ }
+}
diff --git a/Categories/module.json b/Categories/module.json
new file mode 100644
index 0000000..bde3d86
--- /dev/null
+++ b/Categories/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Categories",
+ "alias": "categories",
+ "version": "1.0.0",
+ "description": "Categories publishing module.",
+ "keywords": ["publishing", "categories"],
+ "priority": 30,
+ "providers": ["Modules\\Categories\\Providers\\CategoriesServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/CloudDevOps/Config/config.php b/CloudDevOps/Config/config.php
new file mode 100644
index 0000000..8278777
--- /dev/null
+++ b/CloudDevOps/Config/config.php
@@ -0,0 +1,9 @@
+ env('CLOUD_DEVOPS_ENABLED', true),
+ 'allow_execution' => env('CLOUD_DEVOPS_ALLOW_EXECUTION', false),
+ 'default_timeout' => (int) env('CLOUD_DEVOPS_TIMEOUT', 900),
+ 'retention_days' => (int) env('CLOUD_DEVOPS_RETENTION_DAYS', 90),
+ 'allowed_drivers' => ['local', 'ssh', 'docker', 'kubernetes'],
+];
diff --git a/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php b/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php
new file mode 100644
index 0000000..565e25b
--- /dev/null
+++ b/CloudDevOps/Console/Commands/CloudDevOpsHealthCommand.php
@@ -0,0 +1,22 @@
+each(fn ($check) => $runner->run($check));
+ $this->info('Health checks completed.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php b/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php
new file mode 100644
index 0000000..4309b43
--- /dev/null
+++ b/CloudDevOps/Console/Commands/CloudDevOpsPruneCommand.php
@@ -0,0 +1,22 @@
+option('days') ?: config('clouddevops.retention_days', 90));
+ $count = DeploymentRelease::where('created_at', '<', now()->subDays($days))->whereIn('status', ['succeeded', 'failed', 'cancelled'])->delete();
+ $this->info("Pruned {$count} releases.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php b/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php
new file mode 100644
index 0000000..9b9d98e
--- /dev/null
+++ b/CloudDevOps/Database/Migrations/2026_07_24_180000_create_cloud_devops_tables.php
@@ -0,0 +1,122 @@
+id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('type')->default('production');
+ $table->string('status')->default('active');
+ $table->boolean('is_protected')->default(false);
+ $table->json('variables')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_targets', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('driver')->default('ssh');
+ $table->string('host')->nullable();
+ $table->unsignedInteger('port')->nullable();
+ $table->string('path')->nullable();
+ $table->json('configuration')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_pipelines', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('trigger')->default('manual');
+ $table->json('steps');
+ $table->boolean('requires_approval')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_releases', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->foreignId('pipeline_id')->nullable()->constrained('deployment_pipelines')->nullOnDelete();
+ $table->string('version');
+ $table->string('commit_hash')->nullable();
+ $table->string('status')->default('pending');
+ $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->text('failure_message')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ $table->index(['environment_id', 'status']);
+ });
+
+ Schema::create('deployment_steps', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('release_id')->constrained('deployment_releases')->cascadeOnDelete();
+ $table->string('name');
+ $table->unsignedInteger('position')->default(0);
+ $table->string('status')->default('pending');
+ $table->text('output')->nullable();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_health_checks', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('type')->default('http');
+ $table->string('endpoint')->nullable();
+ $table->unsignedInteger('expected_status')->default(200);
+ $table->string('status')->default('unknown');
+ $table->unsignedInteger('response_time_ms')->nullable();
+ $table->timestamp('checked_at')->nullable();
+ $table->text('message')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_rollbacks', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->foreignId('from_release_id')->constrained('deployment_releases')->cascadeOnDelete();
+ $table->foreignId('to_release_id')->nullable()->constrained('deployment_releases')->nullOnDelete();
+ $table->string('status')->default('pending');
+ $table->foreignId('requested_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->text('reason')->nullable();
+ $table->timestamps();
+ });
+
+ Schema::create('deployment_secrets', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('environment_id')->constrained('deployment_environments')->cascadeOnDelete();
+ $table->string('key');
+ $table->string('provider')->default('database');
+ $table->string('reference')->nullable();
+ $table->timestamp('rotated_at')->nullable();
+ $table->timestamps();
+ $table->unique(['environment_id', 'key']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('deployment_secrets');
+ Schema::dropIfExists('deployment_rollbacks');
+ Schema::dropIfExists('deployment_health_checks');
+ Schema::dropIfExists('deployment_steps');
+ Schema::dropIfExists('deployment_releases');
+ Schema::dropIfExists('deployment_pipelines');
+ Schema::dropIfExists('deployment_targets');
+ Schema::dropIfExists('deployment_environments');
+ }
+};
diff --git a/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php b/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php
new file mode 100644
index 0000000..54897f1
--- /dev/null
+++ b/CloudDevOps/Http/Controllers/CloudDevOpsApiController.php
@@ -0,0 +1,21 @@
+json(['data' => DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->get()]);
+ }
+
+ public function releases(): JsonResponse
+ {
+ return response()->json(['data' => DeploymentRelease::with(['environment', 'steps'])->latest()->paginate()]);
+ }
+}
diff --git a/CloudDevOps/Http/Controllers/CloudDevOpsController.php b/CloudDevOps/Http/Controllers/CloudDevOpsController.php
new file mode 100644
index 0000000..dcc212b
--- /dev/null
+++ b/CloudDevOps/Http/Controllers/CloudDevOpsController.php
@@ -0,0 +1,50 @@
+ DeploymentEnvironment::withCount(['targets', 'pipelines', 'releases'])->latest()->get(),
+ 'releases' => DeploymentRelease::with('environment')->latest()->limit(10)->get(),
+ 'checks' => DeploymentHealthCheck::with('environment')->latest('checked_at')->limit(10)->get(),
+ ]);
+ }
+
+ public function storeEnvironment(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => 'required|string|max:120', 'slug' => 'required|alpha_dash|max:120|unique:deployment_environments,slug', 'type' => 'required|in:development,staging,production']);
+ DeploymentEnvironment::create($data);
+
+ return back()->with('status', 'Environment created.');
+ }
+
+ public function deploy(Request $request, DeploymentEnvironment $environment, DeploymentManager $manager): RedirectResponse
+ {
+ $data = $request->validate(['pipeline_id' => 'required|integer', 'version' => 'nullable|string|max:120', 'commit_hash' => 'nullable|string|max:120']);
+ $pipeline = DeploymentPipeline::where('environment_id', $environment->id)->findOrFail($data['pipeline_id']);
+ $manager->createRelease($environment, $pipeline, $data + ['requested_by' => $request->user()?->id]);
+
+ return back()->with('status', 'Release created.');
+ }
+
+ public function runCheck(DeploymentHealthCheck $check, HealthCheckRunner $runner): RedirectResponse
+ {
+ $runner->run($check);
+
+ return back()->with('status', 'Health check completed.');
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentEnvironment.php b/CloudDevOps/Models/DeploymentEnvironment.php
new file mode 100644
index 0000000..55bc24b
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentEnvironment.php
@@ -0,0 +1,36 @@
+ 'encrypted:array', 'is_protected' => 'boolean'];
+ }
+
+ public function targets(): HasMany
+ {
+ return $this->hasMany(DeploymentTarget::class, 'environment_id');
+ }
+
+ public function pipelines(): HasMany
+ {
+ return $this->hasMany(DeploymentPipeline::class, 'environment_id');
+ }
+
+ public function releases(): HasMany
+ {
+ return $this->hasMany(DeploymentRelease::class, 'environment_id');
+ }
+
+ public function healthChecks(): HasMany
+ {
+ return $this->hasMany(DeploymentHealthCheck::class, 'environment_id');
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentHealthCheck.php b/CloudDevOps/Models/DeploymentHealthCheck.php
new file mode 100644
index 0000000..2ff53fb
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentHealthCheck.php
@@ -0,0 +1,15 @@
+ 'datetime'];
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentPipeline.php b/CloudDevOps/Models/DeploymentPipeline.php
new file mode 100644
index 0000000..1aa3ad2
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentPipeline.php
@@ -0,0 +1,15 @@
+ 'array', 'requires_approval' => 'boolean', 'is_active' => 'boolean'];
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentRelease.php b/CloudDevOps/Models/DeploymentRelease.php
new file mode 100644
index 0000000..5eca011
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentRelease.php
@@ -0,0 +1,27 @@
+ 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function environment(): BelongsTo
+ {
+ return $this->belongsTo(DeploymentEnvironment::class, 'environment_id');
+ }
+
+ public function steps(): HasMany
+ {
+ return $this->hasMany(DeploymentStep::class, 'release_id')->orderBy('position');
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentRollback.php b/CloudDevOps/Models/DeploymentRollback.php
new file mode 100644
index 0000000..8c4cb66
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentRollback.php
@@ -0,0 +1,10 @@
+ 'datetime'];
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentStep.php b/CloudDevOps/Models/DeploymentStep.php
new file mode 100644
index 0000000..fc2e05b
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentStep.php
@@ -0,0 +1,15 @@
+ 'datetime', 'completed_at' => 'datetime'];
+ }
+}
diff --git a/CloudDevOps/Models/DeploymentTarget.php b/CloudDevOps/Models/DeploymentTarget.php
new file mode 100644
index 0000000..d5de432
--- /dev/null
+++ b/CloudDevOps/Models/DeploymentTarget.php
@@ -0,0 +1,15 @@
+ 'encrypted:array', 'is_active' => 'boolean'];
+ }
+}
diff --git a/CloudDevOps/Providers/CloudDevOpsServiceProvider.php b/CloudDevOps/Providers/CloudDevOpsServiceProvider.php
new file mode 100644
index 0000000..763eab7
--- /dev/null
+++ b/CloudDevOps/Providers/CloudDevOpsServiceProvider.php
@@ -0,0 +1,25 @@
+mergeConfigFrom(module_path('CloudDevOps', 'Config/config.php'), 'clouddevops');
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadViewsFrom(module_path('CloudDevOps', 'Resources/views'), 'clouddevops');
+ $this->loadMigrationsFrom(module_path('CloudDevOps', 'Database/Migrations'));
+ if ($this->app->runningInConsole()) {
+ $this->commands([CloudDevOpsHealthCommand::class, CloudDevOpsPruneCommand::class]);
+ }
+ }
+}
diff --git a/CloudDevOps/Providers/RouteServiceProvider.php b/CloudDevOps/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..1b62500
--- /dev/null
+++ b/CloudDevOps/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('CloudDevOps', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api')->group(module_path('CloudDevOps', 'Routes/api.php'));
+ }
+}
diff --git a/CloudDevOps/Resources/views/index.blade.php b/CloudDevOps/Resources/views/index.blade.php
new file mode 100644
index 0000000..cefb0ce
--- /dev/null
+++ b/CloudDevOps/Resources/views/index.blade.php
@@ -0,0 +1,44 @@
+
+
+
+ Cloud & DevOps
+ Manage environments, deployment releases, pipelines and service health.
+
+
+ @if (session('status'))
+
{{ session('status') }}
+ @endif
+
+
+
Environments
{{ $environments->count() }}
+
Recent releases
{{ $releases->count() }}
+
Healthy checks
{{ $checks->where('status', 'healthy')->count() }}
+
+
+ @can('cloud-devops.manage')
+
+ @csrf
+
+
+ Development Staging Production
+ Create environment
+
+ @endcan
+
+
+ Environments
+
+ @forelse ($environments as $environment)
+
+ {{ $environment->name }} {{ ucfirst($environment->type) }} · {{ ucfirst($environment->status) }}
@if ($environment->is_protected)
Protected @endif
+ Targets {{ $environment->targets_count }}
Pipelines {{ $environment->pipelines_count }}
Releases {{ $environment->releases_count }}
+
+ @empty
+
No deployment environments configured.
+ @endforelse
+
+
+
+
Recent releases Version Environment Status Created @forelse ($releases as $release){{ $release->version }} {{ $release->environment?->name }} {{ ucfirst(str_replace('_', ' ', $release->status)) }} {{ $release->created_at?->diffForHumans() }} @emptyNo releases yet. @endforelse
+
+
diff --git a/CloudDevOps/Routes/api.php b/CloudDevOps/Routes/api.php
new file mode 100644
index 0000000..89d17bf
--- /dev/null
+++ b/CloudDevOps/Routes/api.php
@@ -0,0 +1,9 @@
+prefix('v1/cloud-devops')->group(function (): void {
+ Route::get('/environments', [CloudDevOpsApiController::class, 'environments']);
+ Route::get('/releases', [CloudDevOpsApiController::class, 'releases']);
+});
diff --git a/CloudDevOps/Routes/web.php b/CloudDevOps/Routes/web.php
new file mode 100644
index 0000000..fb5ebf6
--- /dev/null
+++ b/CloudDevOps/Routes/web.php
@@ -0,0 +1,14 @@
+prefix('administration/cloud-devops')
+ ->name('administration.cloud-devops.')
+ ->group(function (): void {
+ Route::get('/', [CloudDevOpsController::class, 'index'])->name('index');
+ Route::post('/environments', [CloudDevOpsController::class, 'storeEnvironment'])->middleware('permission:cloud-devops.manage')->name('environments.store');
+ Route::post('/environments/{environment}/deploy', [CloudDevOpsController::class, 'deploy'])->middleware('permission:cloud-devops.deploy')->name('deploy');
+ Route::post('/health-checks/{check}/run', [CloudDevOpsController::class, 'runCheck'])->middleware('permission:cloud-devops.manage')->name('checks.run');
+ });
diff --git a/CloudDevOps/Services/DeploymentManager.php b/CloudDevOps/Services/DeploymentManager.php
new file mode 100644
index 0000000..1b9c665
--- /dev/null
+++ b/CloudDevOps/Services/DeploymentManager.php
@@ -0,0 +1,40 @@
+releases()->create([
+ 'pipeline_id' => $pipeline->getKey(),
+ 'version' => $attributes['version'] ?? now()->format('YmdHis').'-'.Str::lower(Str::random(6)),
+ 'commit_hash' => $attributes['commit_hash'] ?? null,
+ 'status' => $pipeline->requires_approval ? 'awaiting_approval' : 'pending',
+ 'requested_by' => $attributes['requested_by'] ?? null,
+ 'metadata' => $attributes['metadata'] ?? [],
+ ]);
+
+ foreach ($pipeline->steps as $position => $step) {
+ $release->steps()->create(['name' => $step['name'] ?? 'Step '.($position + 1), 'position' => $position]);
+ }
+
+ return $release->load('steps');
+ });
+ }
+
+ public function execute(DeploymentRelease $release): DeploymentRelease
+ {
+ abort_unless(config('clouddevops.allow_execution'), 403, 'Deployment execution is disabled.');
+ $release->update(['status' => 'running', 'started_at' => now()]);
+
+ return $release->refresh();
+ }
+}
diff --git a/CloudDevOps/Services/HealthCheckRunner.php b/CloudDevOps/Services/HealthCheckRunner.php
new file mode 100644
index 0000000..3c9f32a
--- /dev/null
+++ b/CloudDevOps/Services/HealthCheckRunner.php
@@ -0,0 +1,24 @@
+get((string) $check->endpoint);
+ $status = $response->status() === $check->expected_status ? 'healthy' : 'unhealthy';
+ $message = 'HTTP '.$response->status();
+ } catch (\Throwable $e) {
+ $status = 'unhealthy';
+ $message = $e->getMessage();
+ } $check->update(['status' => $status, 'response_time_ms' => (int) ((microtime(true) - $started) * 1000), 'checked_at' => now(), 'message' => $message]);
+
+ return $check->refresh();
+ }
+}
diff --git a/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php b/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php
new file mode 100644
index 0000000..5425d71
--- /dev/null
+++ b/CloudDevOps/Tests/Feature/CloudDevOpsModuleTest.php
@@ -0,0 +1,18 @@
+ 'Production', 'slug' => 'production', 'type' => 'production']);
+ $this->assertDatabaseHas('deployment_environments', ['id' => $environment->id, 'slug' => 'production']);
+ }
+}
diff --git a/CloudDevOps/module.json b/CloudDevOps/module.json
new file mode 100644
index 0000000..5a14f70
--- /dev/null
+++ b/CloudDevOps/module.json
@@ -0,0 +1,9 @@
+{
+ "name": "CloudDevOps",
+ "alias": "clouddevops",
+ "description": "Cloud deployment and DevOps operations for Content Manager.",
+ "keywords": ["cloud", "devops", "deployments", "pipelines"],
+ "priority": 18,
+ "providers": ["Modules\\CloudDevOps\\Providers\\CloudDevOpsServiceProvider"],
+ "files": []
+}
diff --git a/Comments/Config/config.php b/Comments/Config/config.php
new file mode 100644
index 0000000..e205f05
--- /dev/null
+++ b/Comments/Config/config.php
@@ -0,0 +1,3 @@
+ 'Comments'];
diff --git a/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php b/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
new file mode 100644
index 0000000..89de713
--- /dev/null
+++ b/Comments/Database/Migrations/2026_07_24_030600_create_comments_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->nullableMorphs('commentable');
+ $table->foreignId('parent_id')->nullable()->constrained('comments')->cascadeOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('author_name');
+ $table->string('author_email');
+ $table->text('body');
+ $table->string('status')->default('pending')->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('moderation_notes')->nullable();
+ $table->timestamp('approved_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('comments');
+ }
+};
diff --git a/Comments/Http/Controllers/CommentController.php b/Comments/Http/Controllers/CommentController.php
new file mode 100644
index 0000000..c8948af
--- /dev/null
+++ b/Comments/Http/Controllers/CommentController.php
@@ -0,0 +1,38 @@
+filled('status')) {
+ $q->where('status', $r->string('status'));
+ }
+
+return view('comments::admin.index', ['records' => $q->paginate(30)]);
+ }
+
+ public function update(Request $r, Comment $comment): RedirectResponse
+ {
+ $data = $r->validate(['status' => ['required', 'in:pending,approved,spam,rejected'], 'moderation_notes' => ['nullable', 'string']]);
+ $data['approved_at'] = $data['status'] === 'approved' ? now() : null;
+ $comment->update($data);
+
+ return back()->with('status', 'Comment moderated.');
+ }
+
+ public function destroy(Comment $comment): RedirectResponse
+ {
+ $comment->delete();
+
+ return back()->with('status', 'Comment deleted.');
+ }
+}
diff --git a/Comments/Models/Comment.php b/Comments/Models/Comment.php
new file mode 100644
index 0000000..0da0238
--- /dev/null
+++ b/Comments/Models/Comment.php
@@ -0,0 +1,21 @@
+ 'datetime'];
+ }
+
+ public function commentable(): MorphTo
+ {
+ return $this->morphTo();
+ }
+}
diff --git a/Comments/Providers/CommentsServiceProvider.php b/Comments/Providers/CommentsServiceProvider.php
new file mode 100644
index 0000000..d82e931
--- /dev/null
+++ b/Comments/Providers/CommentsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Comments', 'Config/config.php'), 'comments');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Comments', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Comments', 'Resources/views'), 'comments');
+ }
+}
diff --git a/Comments/Providers/RouteServiceProvider.php b/Comments/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..b83240b
--- /dev/null
+++ b/Comments/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Comments', 'Routes/web.php'));
+ }
+}
diff --git a/Comments/Resources/views/admin/index.blade.php b/Comments/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..a9e6652
--- /dev/null
+++ b/Comments/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Comment moderation @if(session('status')){{ session('status') }}
@endif@forelse($records as $comment)
@empty
No comments awaiting moderation.
@endforelse
{{ $records->links() }}
diff --git a/Comments/Routes/web.php b/Comments/Routes/web.php
new file mode 100644
index 0000000..d40cb18
--- /dev/null
+++ b/Comments/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/comments')->name('administration.comments.')->group(function (): void {
+ Route::get('/', [CommentController::class, 'index'])->name('index');
+ Route::put('/{comment}', [CommentController::class, 'update'])->name('update');
+ Route::delete('/{comment}', [CommentController::class, 'destroy'])->name('destroy');
+});
diff --git a/Comments/Tests/Feature/ModuleBootTest.php b/Comments/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..bf969d3
--- /dev/null
+++ b/Comments/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Comments', config('comments.name'));
+ }
+}
diff --git a/Comments/module.json b/Comments/module.json
new file mode 100644
index 0000000..ab9f85e
--- /dev/null
+++ b/Comments/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Comments",
+ "alias": "comments",
+ "version": "1.0.0",
+ "description": "Comments publishing module.",
+ "keywords": ["publishing", "comments"],
+ "priority": 30,
+ "providers": ["Modules\\Comments\\Providers\\CommentsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Commerce/Config/config.php b/Commerce/Config/config.php
new file mode 100644
index 0000000..7eb951b
--- /dev/null
+++ b/Commerce/Config/config.php
@@ -0,0 +1,8 @@
+ env('COMMERCE_CURRENCY', 'GBP'),
+ 'currency_symbol' => env('COMMERCE_CURRENCY_SYMBOL', '£'),
+ 'prices_include_tax' => (bool) env('COMMERCE_PRICES_INCLUDE_TAX', true),
+ 'low_stock_threshold' => (int) env('COMMERCE_LOW_STOCK_THRESHOLD', 5),
+];
diff --git a/Commerce/Contracts/InventoryProvider.php b/Commerce/Contracts/InventoryProvider.php
new file mode 100644
index 0000000..d2aaa5c
--- /dev/null
+++ b/Commerce/Contracts/InventoryProvider.php
@@ -0,0 +1,12 @@
+id();
+ $table->foreignId('parent_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->timestamps();
+ });
+ Schema::create('commerce_products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('category_id')->nullable()->constrained('commerce_categories')->nullOnDelete();
+ $table->string('type')->default('physical');
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('sku')->nullable()->unique();
+ $table->text('summary')->nullable();
+ $table->longText('description')->nullable();
+ $table->decimal('price', 12, 2)->default(0);
+ $table->decimal('compare_at_price', 12, 2)->nullable();
+ $table->decimal('cost_price', 12, 2)->nullable();
+ $table->string('currency', 3)->default('GBP');
+ $table->boolean('track_inventory')->default(true);
+ $table->integer('stock_quantity')->default(0);
+ $table->integer('low_stock_threshold')->default(5);
+ $table->boolean('is_featured')->default(false);
+ $table->string('status')->default('draft');
+ $table->timestamp('published_at')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index(['status', 'published_at']);
+ });
+ Schema::create('commerce_product_variants', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
+ $table->string('name');
+ $table->string('sku')->unique();
+ $table->decimal('price', 12, 2)->nullable();
+ $table->integer('stock_quantity')->default(0);
+ $table->json('options')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('commerce_customers', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('email')->index();
+ $table->string('first_name');
+ $table->string('last_name');
+ $table->string('company')->nullable();
+ $table->string('phone')->nullable();
+ $table->string('tax_number')->nullable();
+ $table->json('billing_address')->nullable();
+ $table->json('shipping_address')->nullable();
+ $table->string('status')->default('active');
+ $table->timestamps();
+ });
+ Schema::create('commerce_orders', function (Blueprint $table) {
+ $table->id();
+ $table->string('number')->unique();
+ $table->foreignId('customer_id')->nullable()->constrained('commerce_customers')->nullOnDelete();
+ $table->string('email');
+ $table->string('status')->default('pending');
+ $table->string('payment_status')->default('unpaid');
+ $table->string('fulfilment_status')->default('unfulfilled');
+ $table->string('currency', 3)->default('GBP');
+ $table->decimal('subtotal', 12, 2)->default(0);
+ $table->decimal('discount_total', 12, 2)->default(0);
+ $table->decimal('shipping_total', 12, 2)->default(0);
+ $table->decimal('tax_total', 12, 2)->default(0);
+ $table->decimal('grand_total', 12, 2)->default(0);
+ $table->json('billing_address')->nullable();
+ $table->json('shipping_address')->nullable();
+ $table->text('notes')->nullable();
+ $table->timestamp('placed_at')->nullable();
+ $table->timestamps();
+ $table->index(['status', 'payment_status']);
+ });
+ Schema::create('commerce_order_items', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
+ $table->foreignId('product_id')->nullable()->constrained('commerce_products')->nullOnDelete();
+ $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->nullOnDelete();
+ $table->string('name');
+ $table->string('sku')->nullable();
+ $table->unsignedInteger('quantity');
+ $table->decimal('unit_price', 12, 2);
+ $table->decimal('tax_total', 12, 2)->default(0);
+ $table->decimal('line_total', 12, 2);
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_inventory_movements', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('product_id')->constrained('commerce_products')->cascadeOnDelete();
+ $table->foreignId('variant_id')->nullable()->constrained('commerce_product_variants')->cascadeOnDelete();
+ $table->integer('quantity');
+ $table->string('reason');
+ $table->string('reference_type')->nullable();
+ $table->unsignedBigInteger('reference_id')->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->text('notes')->nullable();
+ $table->timestamps();
+ $table->index(['reference_type', 'reference_id']);
+ });
+ Schema::create('commerce_promotions', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('code')->nullable()->unique();
+ $table->string('type')->default('percentage');
+ $table->decimal('value', 12, 2);
+ $table->decimal('minimum_order', 12, 2)->nullable();
+ $table->unsignedInteger('usage_limit')->nullable();
+ $table->unsignedInteger('times_used')->default(0);
+ $table->timestamp('starts_at')->nullable();
+ $table->timestamp('ends_at')->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->json('conditions')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_shipping_methods', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('code')->unique();
+ $table->decimal('price', 12, 2)->default(0);
+ $table->decimal('free_over', 12, 2)->nullable();
+ $table->boolean('is_active')->default(true);
+ $table->json('settings')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('commerce_tax_rates', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('country_code', 2)->nullable();
+ $table->decimal('rate', 7, 4)->default(0);
+ $table->boolean('is_default')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('commerce_payment_transactions', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('order_id')->constrained('commerce_orders')->cascadeOnDelete();
+ $table->string('provider');
+ $table->string('provider_reference')->nullable()->index();
+ $table->string('type')->default('payment');
+ $table->string('status')->default('pending');
+ $table->decimal('amount', 12, 2);
+ $table->string('currency', 3);
+ $table->json('payload')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ foreach (['commerce_payment_transactions', 'commerce_tax_rates', 'commerce_shipping_methods', 'commerce_promotions', 'commerce_inventory_movements', 'commerce_order_items', 'commerce_orders', 'commerce_customers', 'commerce_product_variants', 'commerce_products', 'commerce_categories'] as $table) {
+ Schema::dropIfExists($table);
+ }
+ }
+};
diff --git a/Commerce/Http/Controllers/CommerceController.php b/Commerce/Http/Controllers/CommerceController.php
new file mode 100644
index 0000000..bf7eb83
--- /dev/null
+++ b/Commerce/Http/Controllers/CommerceController.php
@@ -0,0 +1,53 @@
+ Product::latest()->paginate(20), 'orders' => Order::latest()->limit(10)->get(), 'revenue' => Order::where('payment_status', 'paid')->sum('grand_total'), 'lowStock' => Product::where('track_inventory', true)->whereColumn('stock_quantity', '<=', 'low_stock_threshold')->count(), 'customers' => Customer::count(), 'promotions' => Promotion::where('is_active', true)->count()]);
+ }
+
+ public function storeProduct(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['nullable', 'string', 'max:255', 'unique:commerce_products,slug'], 'sku' => ['nullable', 'string', 'max:120', 'unique:commerce_products,sku'], 'type' => ['required', 'in:physical,digital,service,bundle'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]);
+ $data['slug'] = $data['slug'] ?: Str::slug($data['name']);
+ $data['currency'] = config('commerce.currency', 'GBP');
+ $data['published_at'] = $data['status'] === 'active' ? now() : null;
+ Product::create($data);
+
+ return back()->with('status', 'Product created.');
+ }
+
+ public function updateProduct(Request $request, Product $product): RedirectResponse
+ {
+ $product->update($request->validate(['name' => ['required', 'string', 'max:255'], 'price' => ['required', 'numeric', 'min:0'], 'stock_quantity' => ['required', 'integer'], 'status' => ['required', 'in:draft,active,archived']]));
+
+ return back()->with('status', 'Product updated.');
+ }
+
+ public function destroyProduct(Product $product): RedirectResponse
+ {
+ $product->delete();
+
+ return back()->with('status', 'Product deleted.');
+ }
+
+ public function updateOrder(Request $request, Order $order): RedirectResponse
+ {
+ $order->update($request->validate(['status' => ['required', 'in:pending,processing,completed,cancelled,refunded'], 'payment_status' => ['required', 'in:unpaid,authorised,paid,partially_refunded,refunded,failed'], 'fulfilment_status' => ['required', 'in:unfulfilled,partial,fulfilled,returned']]));
+
+ return back()->with('status', 'Order updated.');
+ }
+}
diff --git a/Commerce/Http/Controllers/ProductApiController.php b/Commerce/Http/Controllers/ProductApiController.php
new file mode 100644
index 0000000..07e871f
--- /dev/null
+++ b/Commerce/Http/Controllers/ProductApiController.php
@@ -0,0 +1,20 @@
+json(Product::query()->where('status', 'active')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->latest()->paginate(24));
+ }
+
+ public function show(string $product): JsonResponse
+ {
+ return response()->json(Product::where('status', 'active')->where(fn ($q) => $q->where('slug', $product)->orWhere('id', $product))->firstOrFail());
+ }
+}
diff --git a/Commerce/Models/Category.php b/Commerce/Models/Category.php
new file mode 100644
index 0000000..76dce14
--- /dev/null
+++ b/Commerce/Models/Category.php
@@ -0,0 +1,17 @@
+ 'boolean'];
+ }
+}
diff --git a/Commerce/Models/Customer.php b/Commerce/Models/Customer.php
new file mode 100644
index 0000000..2ed400c
--- /dev/null
+++ b/Commerce/Models/Customer.php
@@ -0,0 +1,17 @@
+ 'array', 'shipping_address' => 'array'];
+ }
+}
diff --git a/Commerce/Models/InventoryMovement.php b/Commerce/Models/InventoryMovement.php
new file mode 100644
index 0000000..68c3cb1
--- /dev/null
+++ b/Commerce/Models/InventoryMovement.php
@@ -0,0 +1,17 @@
+ 'decimal:2', 'discount_total' => 'decimal:2', 'shipping_total' => 'decimal:2', 'tax_total' => 'decimal:2', 'grand_total' => 'decimal:2', 'billing_address' => 'array', 'shipping_address' => 'array', 'placed_at' => 'datetime'];
+ }
+}
diff --git a/Commerce/Models/OrderItem.php b/Commerce/Models/OrderItem.php
new file mode 100644
index 0000000..06072b7
--- /dev/null
+++ b/Commerce/Models/OrderItem.php
@@ -0,0 +1,17 @@
+ 'decimal:2', 'tax_total' => 'decimal:2', 'line_total' => 'decimal:2', 'metadata' => 'array'];
+ }
+}
diff --git a/Commerce/Models/Product.php b/Commerce/Models/Product.php
new file mode 100644
index 0000000..a58482f
--- /dev/null
+++ b/Commerce/Models/Product.php
@@ -0,0 +1,20 @@
+ 'decimal:2', 'compare_at_price' => 'decimal:2', 'cost_price' => 'decimal:2', 'track_inventory' => 'boolean', 'is_featured' => 'boolean', 'published_at' => 'datetime', 'metadata' => 'array'];
+ }
+}
diff --git a/Commerce/Models/ProductVariant.php b/Commerce/Models/ProductVariant.php
new file mode 100644
index 0000000..72482ce
--- /dev/null
+++ b/Commerce/Models/ProductVariant.php
@@ -0,0 +1,17 @@
+ 'decimal:2', 'options' => 'array', 'is_active' => 'boolean'];
+ }
+}
diff --git a/Commerce/Models/Promotion.php b/Commerce/Models/Promotion.php
new file mode 100644
index 0000000..e16cb92
--- /dev/null
+++ b/Commerce/Models/Promotion.php
@@ -0,0 +1,17 @@
+ 'decimal:2', 'minimum_order' => 'decimal:2', 'starts_at' => 'datetime', 'ends_at' => 'datetime', 'is_active' => 'boolean', 'conditions' => 'array'];
+ }
+}
diff --git a/Commerce/Models/Relations.php b/Commerce/Models/Relations.php
new file mode 100644
index 0000000..7814843
--- /dev/null
+++ b/Commerce/Models/Relations.php
@@ -0,0 +1,3 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Commerce', 'Config/config.php'), 'commerce');
+ $this->app->bind(InventoryProvider::class, DatabaseInventoryProvider::class);
+ $this->app->bind(PaymentProvider::class, ManualPaymentProvider::class);
+ $this->app->bind(ShippingProvider::class, FlatRateShippingProvider::class);
+ $this->app->bind(TaxProvider::class, ConfiguredTaxProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Commerce', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Commerce', 'Resources/views'), 'commerce');
+ }
+}
diff --git a/Commerce/Providers/RouteServiceProvider.php b/Commerce/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..1dcfdb7
--- /dev/null
+++ b/Commerce/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Commerce', 'Routes/web.php'));
+ Route::prefix('api/v1')->middleware('api')->group(module_path('Commerce', 'Routes/api.php'));
+ }
+}
diff --git a/Commerce/Resources/views/admin/index.blade.php b/Commerce/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..4960512
--- /dev/null
+++ b/Commerce/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+Commerce Catalogue, stock, customers, orders and promotions.
@if(session('status'))
{{ session('status') }}
@endif
+
Products
{{ $products->total() }}
Orders
{{ \Modules\Commerce\Models\Order::count() }}
Revenue
{{ config('commerce.currency_symbol','£') }}{{ number_format($revenue,2) }}
Customers
{{ $customers }}
+
@csrfAdd product Physical Digital Service Bundle
Draft Active Archived Create product
+
Product SKU Price Stock Status @forelse($products as $product){{ $product->name }} {{ $product->sku ?: '—' }} {{ config('commerce.currency_symbol','£') }}{{ number_format($product->price,2) }} {{ $product->stock_quantity }} {{ ucfirst($product->status) }} @csrf @method('DELETE')Delete @emptyNo products yet. @endforelse
{{ $products->links() }}
+
Recent orders Order Customer Total Status Payment @forelse($orders as $order){{ $order->number }} {{ $order->email }} {{ $order->currency }} {{ number_format($order->grand_total,2) }} {{ ucfirst($order->status) }} {{ ucfirst($order->payment_status) }} @emptyNo orders yet. @endforelse
diff --git a/Commerce/Routes/api.php b/Commerce/Routes/api.php
new file mode 100644
index 0000000..16ddbdc
--- /dev/null
+++ b/Commerce/Routes/api.php
@@ -0,0 +1,7 @@
+name('administration.commerce.')->middleware(['auth', 'permission:commerce.view'])->group(function () {
+ Route::get('/', [CommerceController::class, 'index'])->name('index');
+ Route::post('/products', [CommerceController::class, 'storeProduct'])->middleware('permission:commerce.manage')->name('products.store');
+ Route::put('/products/{product}', [CommerceController::class, 'updateProduct'])->middleware('permission:commerce.manage')->name('products.update');
+ Route::delete('/products/{product}', [CommerceController::class, 'destroyProduct'])->middleware('permission:commerce.manage')->name('products.destroy');
+ Route::put('/orders/{order}', [CommerceController::class, 'updateOrder'])->middleware('permission:commerce.manage')->name('orders.update');
+});
diff --git a/Commerce/Services/ConfiguredTaxProvider.php b/Commerce/Services/ConfiguredTaxProvider.php
new file mode 100644
index 0000000..ee0a4ad
--- /dev/null
+++ b/Commerce/Services/ConfiguredTaxProvider.php
@@ -0,0 +1,13 @@
+increment('stock_quantity', $quantity);
+ InventoryMovement::create(['product_id' => $product->id, 'quantity' => $quantity, 'reason' => $reason, 'created_by' => $userId]);
+ });
+ }
+
+ public function available(Product $product): int
+ {
+ return max(0, (int) $product->stock_quantity);
+ }
+}
diff --git a/Commerce/Services/FlatRateShippingProvider.php b/Commerce/Services/FlatRateShippingProvider.php
new file mode 100644
index 0000000..30e08ed
--- /dev/null
+++ b/Commerce/Services/FlatRateShippingProvider.php
@@ -0,0 +1,13 @@
+= 50 ? 0.0 : 4.95;
+ }
+}
diff --git a/Commerce/Services/ManualPaymentProvider.php b/Commerce/Services/ManualPaymentProvider.php
new file mode 100644
index 0000000..5d7ebed
--- /dev/null
+++ b/Commerce/Services/ManualPaymentProvider.php
@@ -0,0 +1,19 @@
+ 'pending', 'provider' => 'manual', 'reference' => null];
+ }
+
+ public function refund(Order $order, float $amount): array
+ {
+ return ['status' => 'pending', 'provider' => 'manual', 'amount' => $amount];
+ }
+}
diff --git a/Commerce/Tests/Feature/ModuleBootTest.php b/Commerce/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..2932611
--- /dev/null
+++ b/Commerce/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,19 @@
+assertTrue(app('router')->has('administration.commerce.index'));
+ }
+
+ public function test_commerce_contracts_are_bound(): void
+ {
+ $this->assertInstanceOf(InventoryProvider::class, app(InventoryProvider::class));
+ }
+}
diff --git a/Commerce/module.json b/Commerce/module.json
new file mode 100644
index 0000000..f5f48cb
--- /dev/null
+++ b/Commerce/module.json
@@ -0,0 +1 @@
+{"name":"Commerce","alias":"commerce","version":"1.0.0","description":"Products, inventory, customers, orders, promotions, tax, shipping and payments.","keywords":["commerce","catalog","inventory","orders","customers"],"priority":110,"providers":["Modules\\Commerce\\Providers\\CommerceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Media","Sites"]}
diff --git a/Content/Config/config.php b/Content/Config/config.php
new file mode 100644
index 0000000..b0ba6d5
--- /dev/null
+++ b/Content/Config/config.php
@@ -0,0 +1,5 @@
+ 'home',
+ 'regions' => ['header', 'body', 'footer'],
+];
diff --git a/Content/Console/InstallSaaslandContent.php b/Content/Console/InstallSaaslandContent.php
new file mode 100644
index 0000000..ba501a6
--- /dev/null
+++ b/Content/Console/InstallSaaslandContent.php
@@ -0,0 +1,244 @@
+ ! Schema::hasTable($table)));
+
+ if ($missingTables !== []) {
+ $this->components->error('Content composition tables are missing: '.implode(', ', $missingTables).'.');
+ $this->components->info('Run php artisan migrate before installing the Saasland composition.');
+ return self::FAILURE;
+ }
+
+ DB::transaction(function (): void {
+ $this->installSharedSettings();
+ $this->installMenus();
+ if ($this->option('force')) {
+ PageContent::query()->where('page_key', 'home')->delete();
+ }
+
+ $order = [
+ 'site-header' => ['header', 10], 'hero' => ['body', 10], 'feature-grid' => ['body', 20],
+ 'product-feed' => ['body', 30], 'testimonial-feed' => ['body', 40], 'team-feed' => ['body', 50],
+ 'blog-feed' => ['body', 60], 'news-feed' => ['body', 70], 'faq-feed' => ['body', 80],
+ 'newsletter-form' => ['body', 90], 'call-to-action' => ['body', 100], 'site-footer' => ['footer', 10],
+ ];
+
+ foreach ($this->definitions() as $key => $definition) {
+ $type = ContentType::query()->updateOrCreate(['key' => $key], $definition['type']);
+ $document = ContentDocument::query()->firstOrCreate(
+ ['slug' => 'saasland-'.$key],
+ [
+ 'content_type_id' => $type->id,
+ 'title' => $definition['title'],
+ 'data' => $definition['data'],
+ 'status' => 'published',
+ 'published_at' => now(),
+ ]
+ );
+
+ $currentData = is_array($document->data) ? $document->data : [];
+ $mergedData = $this->mergeMissingDefaults($currentData, $definition['data']);
+
+ $document->forceFill([
+ 'content_type_id' => $type->id,
+ 'data' => $mergedData,
+ ])->save();
+ [$region, $sort] = $order[$key];
+ PageContent::query()->updateOrCreate(
+ ['page_key' => 'home', 'region' => $region, 'sort_order' => $sort],
+ ['content_document_id' => $document->id, 'is_enabled' => true]
+ );
+ }
+ });
+
+ $this->components->info('Saasland content composition installed with editable schemas.');
+ return self::SUCCESS;
+ }
+
+ private function installSharedSettings(): void
+ {
+ $settings = app(SettingsRepository::class);
+ $legacy = [];
+
+ if (Schema::hasTable('stylist_theme_settings')) {
+ $stored = DB::table('stylist_theme_settings')->where('theme_key', 'saasland')->value('settings');
+ $legacy = is_string($stored) ? (json_decode($stored, true) ?: []) : [];
+ }
+
+ $defaults = [
+ 'site_name' => $legacy['site_title'] ?? config('app.name'),
+ 'site_description' => $legacy['site_description'] ?? 'Modern software for modern companies.',
+ 'company_name' => $legacy['site_title'] ?? config('app.name'),
+ 'site_url' => url('/'),
+ 'logo_url' => $legacy['logo_url'] ?? '',
+ 'facebook_url' => $legacy['facebook_url'] ?? '',
+ 'x_url' => $legacy['x_url'] ?? '',
+ 'linkedin_url' => $legacy['linkedin_url'] ?? '',
+ 'dashboard_label' => 'Dashboard',
+ 'login_label' => 'Log in',
+ 'registration_label' => 'Get started',
+ ];
+
+ foreach ($defaults as $key => $value) {
+ if ($settings->get($key, null, 'site') === null) {
+ $settings->put($key, $value, 'site', 'string', true);
+ }
+ }
+ }
+
+ private function installMenus(): void
+ {
+ if (! Schema::hasTable('menus')) {
+ return;
+ }
+
+ $menus = [
+ 'primary' => ['Primary navigation', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]],
+ 'footer' => ['Footer — Explore', [['enabled' => true, 'label' => 'Home', 'url' => '/'], ['enabled' => true, 'label' => 'Blog', 'url' => '/blog'], ['enabled' => true, 'label' => 'News', 'url' => '/news']]],
+ 'footer_account' => ['Footer — Account', [['enabled' => true, 'label' => 'Dashboard', 'url' => '/dashboard'], ['enabled' => true, 'label' => 'Profile', 'url' => '/settings/profile']]],
+ 'footer_legal' => ['Footer — Legal', []],
+ ];
+
+ foreach ($menus as $location => [$name, $items]) {
+ Menu::query()->firstOrCreate(['location' => $location], ['name' => $name, 'items' => $items, 'enabled' => true]);
+ }
+ }
+
+ private function mergeMissingDefaults(array $current, array $defaults): array
+ {
+ foreach ($defaults as $key => $defaultValue) {
+ if (! array_key_exists($key, $current)) {
+ $current[$key] = $defaultValue;
+ continue;
+ }
+
+ if (
+ is_array($defaultValue)
+ && is_array($current[$key])
+ && ! array_is_list($defaultValue)
+ && ! array_is_list($current[$key])
+ ) {
+ $current[$key] = $this->mergeMissingDefaults(
+ $current[$key],
+ $defaultValue
+ );
+ }
+ }
+
+ return $current;
+ }
+
+ private function definitions(): array
+ {
+ $field = static fn (string $key, string $label, string $type = 'text', array $extra = []): array => array_merge(compact('key', 'label', 'type'), $extra);
+ $type = static fn (string $name, string $renderer, array $regions, array $schema): array => [
+ 'name' => $name, 'renderer' => $renderer, 'allowed_regions' => $regions,
+ 'is_system' => true, 'is_active' => true, 'schema' => $schema,
+ ];
+ $section = [$field('enabled', 'Show this section', 'boolean', ['default' => true])];
+ $feed = array_merge($section, [
+ $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
+ $field('limit', 'Maximum items', 'number', ['min' => 1, 'max' => 50]),
+ $field('include_ids', 'Only show record IDs', 'csv', ['help' => 'Optional comma-separated IDs. Leave blank to use the latest records.']),
+ $field('exclude_ids', 'Exclude record IDs', 'csv', ['help' => 'Optional comma-separated IDs to hide from this section.']),
+ ]);
+
+ return [
+ 'site-header' => [
+ 'title' => 'Main website header',
+ 'type' => $type('Site header', 'site-header', ['header'], [
+ $field('enabled', 'Show header', 'boolean', ['default' => true]),
+ $field('menu_location', 'Navigation menu location', 'text', ['help' => 'Usually primary. Edit links from Administration → Menus.']),
+ $field('show_navigation', 'Show navigation', 'boolean', ['default' => true]),
+ $field('show_dashboard', 'Show dashboard link', 'boolean', ['default' => true]),
+ $field('show_user_name', 'Show signed-in user', 'boolean', ['default' => true]),
+ $field('show_login', 'Show login', 'boolean', ['default' => true]),
+ $field('show_registration', 'Show registration', 'boolean', ['default' => true]),
+ $field('show_mobile_menu', 'Show mobile menu', 'boolean', ['default' => true]),
+ ]),
+ 'data' => ['enabled' => true, 'menu_location' => 'primary', 'show_navigation' => true, 'show_dashboard' => true, 'show_user_name' => true, 'show_login' => true, 'show_registration' => true, 'show_mobile_menu' => true],
+ ],
+ 'hero' => [
+ 'title' => 'Homepage hero',
+ 'type' => $type('Hero', 'hero', ['body'], [
+ ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
+ $field('primary_label', 'Primary button label'), $field('primary_url', 'Primary button URL', 'url'),
+ $field('secondary_label', 'Secondary button label'), $field('secondary_url', 'Secondary button URL', 'url'),
+ $field('show_metrics', 'Show metrics panel', 'boolean', ['default' => true]),
+ $field('metrics', 'Metrics', 'repeater', ['fields' => [
+ $field('enabled', 'Show item', 'boolean', ['default' => true]), $field('label', 'Label'), $field('value', 'Value'), $field('change', 'Change'), $field('height', 'Chart height (%)', 'number', ['min' => 0, 'max' => 100]),
+ ]]),
+ ]),
+ 'data' => ['enabled' => true, 'eyebrow' => 'A better way to run your business', 'heading' => 'Transform Your Business With Our SaaS Solution', 'description' => 'Streamline operations, boost productivity, and improve customer satisfaction with one connected platform built for modern teams.', 'primary_label' => 'Get started free', 'primary_url' => '/register', 'secondary_label' => 'Book demo', 'secondary_url' => '#contact', 'show_metrics' => true, 'metrics' => [
+ ['enabled' => true, 'label' => 'Monthly revenue', 'value' => '£48,240', 'change' => '+18.4%', 'height' => 62],
+ ['enabled' => true, 'label' => 'Active users', 'value' => '12,842', 'change' => '+9.2%', 'height' => 78],
+ ['enabled' => true, 'label' => 'Conversion', 'value' => '7.8%', 'change' => '+2.1%', 'height' => 92],
+ ]],
+ ],
+ 'feature-grid' => [
+ 'title' => 'Core features',
+ 'type' => $type('Feature grid', 'feature-grid', ['body'], [
+ ...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'),
+ $field('items', 'Features', 'repeater', ['fields' => [$field('enabled', 'Show item', 'boolean', ['default' => true]), $field('title', 'Title'), $field('description', 'Description', 'textarea')]]),
+ ]),
+ 'data' => ['enabled' => true, 'eyebrow' => 'Powerful features', 'heading' => 'Everything your team needs to grow', 'description' => 'Planning, collaboration, reporting, publishing, commerce and customer insight in one extensible platform.', 'items' => [
+ ['enabled' => true, 'title' => 'Smart automation', 'description' => 'Remove repetitive work and delays.'],
+ ['enabled' => true, 'title' => 'Real-time analytics', 'description' => 'Turn activity into useful business insight.'],
+ ['enabled' => true, 'title' => 'Content operations', 'description' => 'Create, review and publish confidently.'],
+ ['enabled' => true, 'title' => 'Secure by design', 'description' => 'Protect users and data with dependable controls.'],
+ ['enabled' => true, 'title' => 'Flexible integrations', 'description' => 'Connect the tools your organisation already uses.'],
+ ['enabled' => true, 'title' => 'Modular architecture', 'description' => 'Activate only the capabilities each site needs.'],
+ ]],
+ ],
+ 'product-feed' => ['title' => 'Pricing plans', 'type' => $type('Product feed', 'product-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Simple pricing', 'heading' => 'Choose a plan that fits', 'description' => 'Plans are managed from Products & Pricing in the CMS.', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
+ 'testimonial-feed' => ['title' => 'Customer stories', 'type' => $type('Testimonial feed', 'testimonial-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Customer stories', 'heading' => 'Trusted by ambitious teams', 'description' => '', 'limit' => 6, 'include_ids' => '', 'exclude_ids' => '']],
+ 'team-feed' => ['title' => 'Team', 'type' => $type('Team feed', 'team-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Our team', 'heading' => 'People behind the platform', 'description' => '', 'limit' => 8, 'include_ids' => '', 'exclude_ids' => '']],
+ 'blog-feed' => ['title' => 'Latest blog posts', 'type' => $type('Blog feed', 'blog-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Insights', 'heading' => 'Latest from the blog', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
+ 'news-feed' => ['title' => 'Latest news', 'type' => $type('News feed', 'news-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'Newsroom', 'heading' => 'Latest news', 'description' => '', 'limit' => 3, 'include_ids' => '', 'exclude_ids' => '']],
+ 'faq-feed' => ['title' => 'Frequently asked questions', 'type' => $type('FAQ feed', 'faq-feed', ['body'], $feed), 'data' => ['enabled' => true, 'eyebrow' => 'FAQ', 'heading' => 'Answers before you begin', 'description' => '', 'limit' => 12, 'include_ids' => '', 'exclude_ids' => '']],
+ 'newsletter-form' => [
+ 'title' => 'Newsletter signup',
+ 'type' => $type('Newsletter form', 'newsletter-form', ['body'], [...$section, $field('eyebrow', 'Eyebrow'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('placeholder', 'Email placeholder'), $field('button_label', 'Button label')]),
+ 'data' => ['enabled' => true, 'eyebrow' => 'Newsletter', 'heading' => 'Useful updates, no noise', 'description' => 'Receive product news, publishing guidance, and practical platform updates.', 'placeholder' => 'you@example.com', 'button_label' => 'Subscribe'],
+ ],
+ 'call-to-action' => [
+ 'title' => 'Homepage final call to action',
+ 'type' => $type('Call to action', 'call-to-action', ['body'], [...$section, $field('anchor', 'Section anchor'), $field('heading', 'Heading'), $field('description', 'Description', 'textarea'), $field('button_label', 'Button label'), $field('button_url', 'Button URL', 'url')]),
+ 'data' => ['enabled' => true, 'anchor' => 'contact', 'heading' => 'Ready to transform your business?', 'description' => 'Start building a faster, more connected operation today.', 'button_label' => 'Get started free', 'button_url' => '/register'],
+ ],
+ 'site-footer' => [
+ 'title' => 'Main website footer',
+ 'type' => $type('Site footer', 'site-footer', ['footer'], [
+ $field('enabled', 'Show footer', 'boolean', ['default' => true]),
+ $field('explore_heading', 'Explore column heading'),
+ $field('explore_menu_location', 'Explore menu location'),
+ $field('account_heading', 'Account column heading'),
+ $field('account_menu_location', 'Account menu location'),
+ $field('legal_heading', 'Legal column heading'),
+ $field('legal_menu_location', 'Legal menu location'),
+ $field('show_back_to_top', 'Show back-to-top link', 'boolean', ['default' => true]),
+ $field('back_to_top_label', 'Back-to-top label'),
+ ]),
+ 'data' => ['enabled' => true, 'explore_heading' => 'Explore', 'explore_menu_location' => 'footer', 'account_heading' => 'Account', 'account_menu_location' => 'footer_account', 'legal_heading' => 'Legal', 'legal_menu_location' => 'footer_legal', 'show_back_to_top' => true, 'back_to_top_label' => 'Back to top ↑'],
+ ],
+ ];
+ }
+}
diff --git a/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php b/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php
new file mode 100644
index 0000000..2fcd905
--- /dev/null
+++ b/Content/Database/Migrations/2026_07_27_000000_create_content_composition_tables.php
@@ -0,0 +1,62 @@
+id();
+ $table->string('name');
+ $table->string('key')->unique();
+ $table->text('description')->nullable();
+ $table->json('schema')->nullable();
+ $table->string('renderer');
+ $table->json('allowed_regions')->nullable();
+ $table->boolean('is_system')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+
+ Schema::create('content_documents', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('content_type_id')->constrained('content_types')->cascadeOnDelete();
+ $table->string('title');
+ $table->string('slug')->unique();
+ $table->json('data')->nullable();
+ $table->string('status')->default('draft')->index();
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamp('expires_at')->nullable();
+ $table->string('language', 12)->nullable();
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->softDeletes();
+ });
+
+ Schema::create('page_content', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('page_id')->nullable()->constrained('pages')->cascadeOnDelete();
+ $table->string('page_key')->nullable()->index();
+ $table->foreignId('content_document_id')->constrained('content_documents')->cascadeOnDelete();
+ $table->string('region')->index();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('is_enabled')->default(true);
+ $table->json('visibility_rules')->nullable();
+ $table->json('presentation')->nullable();
+ $table->timestamp('starts_at')->nullable();
+ $table->timestamp('ends_at')->nullable();
+ $table->timestamps();
+ $table->unique(['page_key', 'region', 'sort_order'], 'page_content_key_region_order_unique');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('page_content');
+ Schema::dropIfExists('content_documents');
+ Schema::dropIfExists('content_types');
+ }
+};
diff --git a/Content/Http/Controllers/ContentDocumentController.php b/Content/Http/Controllers/ContentDocumentController.php
new file mode 100644
index 0000000..af35ce9
--- /dev/null
+++ b/Content/Http/Controllers/ContentDocumentController.php
@@ -0,0 +1,146 @@
+with('type')
+ ->withCount('placements')
+ ->when($request->string('type')->isNotEmpty(), fn ($query) => $query->whereHas('type', fn ($type) => $type->where('key', $request->string('type')->toString())))
+ ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString()))
+ ->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where('title', 'like', '%'.$request->string('search')->toString().'%'))
+ ->orderBy('title')
+ ->paginate(30)
+ ->withQueryString();
+
+ return view('content::admin.index', [
+ 'documents' => $documents,
+ 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(),
+ ]);
+ }
+
+ public function create(Request $request): View
+ {
+ $types = ContentType::query()->where('is_active', true)->orderBy('name')->get();
+ $selectedType = $types->firstWhere('id', (int) $request->integer('type')) ?? $types->first();
+
+ return view('content::admin.form', [
+ 'document' => new ContentDocument(['status' => 'draft', 'data' => []]),
+ 'types' => $types,
+ 'selectedType' => $selectedType,
+ ]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $validated = $this->validateDocument($request);
+ $type = ContentType::query()->where('is_active', true)->findOrFail($validated['content_type_id']);
+
+ $document = ContentDocument::query()->create([
+ 'content_type_id' => $type->id,
+ 'title' => $validated['title'],
+ 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title']),
+ 'status' => $validated['status'],
+ 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? now()) : null,
+ 'expires_at' => $validated['expires_at'] ?? null,
+ 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR),
+ 'created_by' => $request->user()?->id,
+ 'updated_by' => $request->user()?->id,
+ ]);
+
+ return redirect()->route('content.documents.edit', $document)->with('status', 'Content document created.');
+ }
+
+ public function edit(ContentDocument $document): View
+ {
+ $document->load('type', 'placements');
+
+ return view('content::admin.form', [
+ 'document' => $document,
+ 'types' => ContentType::query()->where('is_active', true)->orderBy('name')->get(),
+ 'selectedType' => $document->type,
+ ]);
+ }
+
+ public function update(Request $request, ContentDocument $document): RedirectResponse
+ {
+ $validated = $this->validateDocument($request, $document);
+
+ $document->update([
+ 'content_type_id' => $validated['content_type_id'],
+ 'title' => $validated['title'],
+ 'slug' => $this->uniqueSlug($validated['slug'] ?: $validated['title'], $document),
+ 'status' => $validated['status'],
+ 'published_at' => $validated['status'] === 'published' ? ($validated['published_at'] ?? $document->published_at ?? now()) : null,
+ 'expires_at' => $validated['expires_at'] ?? null,
+ 'data' => json_decode($validated['data_json'], true, 512, JSON_THROW_ON_ERROR),
+ 'updated_by' => $request->user()?->id,
+ ]);
+
+ return back()->with('status', 'Content document updated.');
+ }
+
+ public function duplicate(Request $request, ContentDocument $document): RedirectResponse
+ {
+ $copy = $document->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']);
+ $copy->title = $document->title.' copy';
+ $copy->slug = $this->uniqueSlug($copy->title);
+ $copy->status = 'draft';
+ $copy->published_at = null;
+ $copy->created_by = $request->user()?->id;
+ $copy->updated_by = $request->user()?->id;
+ $copy->save();
+
+ return redirect()->route('content.documents.edit', $copy)->with('status', 'Independent draft copy created.');
+ }
+
+ public function destroy(ContentDocument $document): RedirectResponse
+ {
+ if ($document->placements()->exists()) {
+ return back()->withErrors(['document' => 'Remove this document from every page before deleting it.']);
+ }
+
+ $document->delete();
+
+ return redirect()->route('content.index')->with('status', 'Content document deleted.');
+ }
+
+ private function validateDocument(Request $request, ?ContentDocument $document = null): array
+ {
+ return $request->validate([
+ 'content_type_id' => ['required', 'integer', Rule::exists('content_types', 'id')->where('is_active', true)],
+ 'title' => ['required', 'string', 'max:255'],
+ 'slug' => ['nullable', 'string', 'max:255'],
+ 'status' => ['required', Rule::in(['draft', 'published'])],
+ 'published_at' => ['nullable', 'date'],
+ 'expires_at' => ['nullable', 'date', 'after:published_at'],
+ 'data_json' => ['required', 'json'],
+ ]);
+ }
+
+ private function uniqueSlug(string $value, ?ContentDocument $except = null): string
+ {
+ $base = Str::slug($value) ?: 'content-document';
+ $slug = $base;
+ $suffix = 2;
+
+ while (ContentDocument::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) {
+ $slug = $base.'-'.$suffix++;
+ }
+
+ return $slug;
+ }
+}
diff --git a/Content/Http/Controllers/PageCompositionController.php b/Content/Http/Controllers/PageCompositionController.php
new file mode 100644
index 0000000..d3c21ad
--- /dev/null
+++ b/Content/Http/Controllers/PageCompositionController.php
@@ -0,0 +1,189 @@
+with('document.type')
+ ->where('page_key', $pageKey)
+ ->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END")
+ ->orderBy('sort_order')
+ ->get()
+ ->groupBy('region');
+
+ $documents = ContentDocument::query()
+ ->with('type')
+ ->withCount('placements')
+ ->orderBy('title')
+ ->get();
+
+ return view('content::admin.composition', compact('pageKey', 'placements', 'documents'));
+ }
+
+
+ public function editPage(Page $page): View
+ {
+ $placements = PageContent::query()
+ ->with('document.type')
+ ->where(fn ($query) => $query->where('page_id', $page->id)->orWhere('page_key', $page->slug))
+ ->orderByRaw("CASE region WHEN 'header' THEN 1 WHEN 'body' THEN 2 ELSE 3 END")
+ ->orderBy('sort_order')
+ ->get();
+
+ PageContent::query()
+ ->whereNull('page_id')
+ ->where('page_key', $page->slug)
+ ->update(['page_id' => $page->id]);
+
+ $documents = ContentDocument::query()
+ ->with('type')
+ ->withCount('placements')
+ ->orderBy('title')
+ ->get();
+
+ return view('content::admin.composition', [
+ 'pageKey' => $page->slug,
+ 'page' => $page,
+ 'placements' => $placements->groupBy('region'),
+ 'documents' => $documents,
+ ]);
+ }
+
+ public function preview(string $pageKey): View
+ {
+ abort_unless($pageKey === 'home', 404, 'This phase currently provides the Saasland homepage preview.');
+
+ return view('stylist-theme-saasland::home', ['pageKey' => $pageKey]);
+ }
+
+ public function store(Request $request, string $pageKey): RedirectResponse
+ {
+ $validated = $request->validate([
+ 'content_document_id' => ['required', 'integer', Rule::exists('content_documents', 'id')->whereNull('deleted_at')],
+ 'region' => ['required', Rule::in(self::REGIONS)],
+ ]);
+
+ $document = ContentDocument::query()->with('type')->findOrFail($validated['content_document_id']);
+ abort_unless(in_array($validated['region'], $document->type?->allowed_regions ?? [], true), 422, 'This content type is not allowed in the selected region.');
+
+ $page = Page::query()->where('slug', $pageKey)->first();
+ $nextOrder = ((int) PageContent::query()->where(fn ($query) => $query->where('page_key', $pageKey)->when($page, fn ($q) => $q->orWhere('page_id', $page->id)))->where('region', $validated['region'])->max('sort_order')) + 10;
+ PageContent::query()->create([
+ 'page_id' => $page?->id,
+ 'page_key' => $pageKey,
+ 'content_document_id' => $document->id,
+ 'region' => $validated['region'],
+ 'sort_order' => $nextOrder,
+ 'is_enabled' => true,
+ ]);
+
+ return back()->with('status', 'Document added to the '.$validated['region'].' region.');
+ }
+
+ public function update(Request $request, PageContent $placement): RedirectResponse
+ {
+ $validated = $request->validate([
+ 'is_enabled' => ['nullable', 'boolean'],
+ 'starts_at' => ['nullable', 'date'],
+ 'ends_at' => ['nullable', 'date', 'after:starts_at'],
+ ]);
+
+ $placement->update([
+ 'is_enabled' => $request->boolean('is_enabled'),
+ 'starts_at' => $validated['starts_at'] ?? null,
+ 'ends_at' => $validated['ends_at'] ?? null,
+ ]);
+
+ return back()->with('status', 'Page placement updated.');
+ }
+
+ public function destroy(PageContent $placement): RedirectResponse
+ {
+ $placement->delete();
+ $this->normaliseRegion($placement->page_key, $placement->region);
+
+ return back()->with('status', 'Document removed from this page. The reusable document was not deleted.');
+ }
+
+ public function duplicate(Request $request, PageContent $placement): RedirectResponse
+ {
+ $source = $placement->document()->firstOrFail();
+ $copy = DB::transaction(function () use ($request, $placement, $source): ContentDocument {
+ $copy = $source->replicate(['slug', 'status', 'published_at', 'created_by', 'updated_by']);
+ $copy->title = $source->title.' copy';
+ $copy->slug = $source->slug.'-copy-'.strtolower(str()->random(6));
+ $copy->status = 'draft';
+ $copy->published_at = null;
+ $copy->created_by = $request->user()?->id;
+ $copy->updated_by = $request->user()?->id;
+ $copy->save();
+
+ PageContent::query()->create([
+ 'page_id' => $placement->page_id,
+ 'page_key' => $placement->page_key,
+ 'content_document_id' => $copy->id,
+ 'region' => $placement->region,
+ 'sort_order' => $placement->sort_order + 5,
+ 'is_enabled' => false,
+ 'visibility_rules' => $placement->visibility_rules,
+ 'presentation' => $placement->presentation,
+ ]);
+
+ return $copy;
+ });
+
+ $this->normaliseRegion($placement->page_key, $placement->region);
+
+ return redirect()->route('content.documents.edit', $copy)->with('status', 'Page-specific draft copy created and added after the original.');
+ }
+
+ public function reorder(Request $request, string $pageKey): JsonResponse|RedirectResponse
+ {
+ $validated = $request->validate([
+ 'region' => ['required', Rule::in(self::REGIONS)],
+ 'placements' => ['required', 'array'],
+ 'placements.*' => ['required', 'integer'],
+ ]);
+
+ $ids = array_values(array_unique(array_map('intval', $validated['placements'])));
+ $owned = PageContent::query()->where('page_key', $pageKey)->where('region', $validated['region'])->whereIn('id', $ids)->pluck('id')->all();
+ abort_unless(count($owned) === count($ids), 422, 'One or more placements do not belong to this page region.');
+
+ DB::transaction(function () use ($ids): void {
+ PageContent::query()->whereIn('id', $ids)->increment('sort_order', 100000);
+ foreach ($ids as $index => $id) {
+ PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]);
+ }
+ });
+
+ return $request->expectsJson()
+ ? response()->json(['message' => 'Order saved.'])
+ : back()->with('status', 'Page order updated.');
+ }
+
+ private function normaliseRegion(?string $pageKey, string $region): void
+ {
+ $ids = PageContent::query()->where('page_key', $pageKey)->where('region', $region)->orderBy('sort_order')->pluck('id');
+ DB::transaction(function () use ($ids): void {
+ foreach ($ids as $index => $id) {
+ PageContent::query()->whereKey($id)->update(['sort_order' => ($index + 1) * 10]);
+ }
+ });
+ }
+}
diff --git a/Content/Models/ContentDocument.php b/Content/Models/ContentDocument.php
new file mode 100644
index 0000000..edf93be
--- /dev/null
+++ b/Content/Models/ContentDocument.php
@@ -0,0 +1,17 @@
+'array','published_at'=>'datetime','expires_at'=>'datetime']; }
+ public function type(): BelongsTo { return $this->belongsTo(ContentType::class, 'content_type_id'); }
+ public function placements(): HasMany { return $this->hasMany(PageContent::class); }
+ public function scopePublished($query) { return $query->where('status','published')->where(fn($q) => $q->whereNull('published_at')->orWhere('published_at','<=',now()))->where(fn($q) => $q->whereNull('expires_at')->orWhere('expires_at','>',now())); }
+}
diff --git a/Content/Models/ContentType.php b/Content/Models/ContentType.php
new file mode 100644
index 0000000..6b0ffd7
--- /dev/null
+++ b/Content/Models/ContentType.php
@@ -0,0 +1,12 @@
+'array','allowed_regions'=>'array','is_system'=>'boolean','is_active'=>'boolean']; }
+ public function documents(): HasMany { return $this->hasMany(ContentDocument::class); }
+}
diff --git a/Content/Models/PageContent.php b/Content/Models/PageContent.php
new file mode 100644
index 0000000..cb204f3
--- /dev/null
+++ b/Content/Models/PageContent.php
@@ -0,0 +1,15 @@
+'boolean','visibility_rules'=>'array','presentation'=>'array','starts_at'=>'datetime','ends_at'=>'datetime']; }
+ public function document(): BelongsTo { return $this->belongsTo(ContentDocument::class, 'content_document_id'); }
+ public function page(): BelongsTo { return $this->belongsTo(Page::class); }
+}
diff --git a/Content/Providers/ContentServiceProvider.php b/Content/Providers/ContentServiceProvider.php
new file mode 100644
index 0000000..0ba0b27
--- /dev/null
+++ b/Content/Providers/ContentServiceProvider.php
@@ -0,0 +1,22 @@
+mergeConfigFrom(module_path('Content','Config/config.php'),'content');
+ $this->app->singleton(PageRenderer::class);
+ }
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Content','Database/Migrations'));
+ $this->loadViewsFrom(module_path('Content','Resources/views'),'content');
+ $this->loadRoutesFrom(module_path('Content','Routes/web.php'));
+ if ($this->app->runningInConsole()) $this->commands([InstallSaaslandContent::class]);
+ }
+}
diff --git a/Content/README.md b/Content/README.md
new file mode 100644
index 0000000..b1a800d
--- /dev/null
+++ b/Content/README.md
@@ -0,0 +1,37 @@
+# Content Composer
+
+The Content module provides reusable typed documents and ordered page regions.
+
+## Phase 2 features
+
+- Header, body and footer composition editor.
+- Drag-and-drop ordering with server-side ownership validation.
+- Add existing published or draft documents to a page.
+- Remove a placement without deleting the reusable document.
+- Create an independent page-specific draft copy.
+- Shared-use warnings and deletion protection.
+- Placement enable/disable and date scheduling.
+- Content document creation, duplication, filtering and publishing controls.
+- Authenticated Saasland homepage preview, including draft labels.
+- Content Composer entry in the administration sidebar.
+
+## Setup
+
+```bash
+php artisan migrate
+php artisan content:install-saasland
+```
+
+Reset the seeded homepage composition when required:
+
+```bash
+php artisan content:install-saasland --force
+```
+
+Administration:
+
+- `/admin/content`
+- `/admin/content/pages/home`
+- `/admin/content/pages/home/preview`
+
+The public theme remains backward-compatible: when no composition exists, the legacy Saasland homepage, header and footer are rendered.
diff --git a/Content/Resources/views/admin/composition.blade.php b/Content/Resources/views/admin/composition.blade.php
new file mode 100644
index 0000000..32a92ce
--- /dev/null
+++ b/Content/Resources/views/admin/composition.blade.php
@@ -0,0 +1,147 @@
+
+
+
+
+
+
{{ isset($page) ? $page->title : ucfirst($pageKey).' page' }}
+
Add sections, edit their content and drag them into the order visitors should see.
+
+
+ @isset($page)Page settings @endisset
+ Create section
+ @if(isset($page) && $page->status === 'published')Open live page @endif
+
+
+
+ @if(session('status'))
{{ session('status') }}
@endif
+ @if($errors->any())
@foreach($errors->all() as $error){{ $error }} @endforeach @endif
+
+
+
+ @foreach(['header', 'body', 'footer'] as $region)
+
+
+
{{ ucfirst($region) }} {{ $region === 'body' ? 'Ordered content sections' : 'Page-level '.$region.' document' }}
+
{{ $placements->get($region, collect())->count() }} document{{ $placements->get($region, collect())->count() === 1 ? '' : 's' }}
+
+
+ @forelse($placements->get($region, collect()) as $placement)
+
+
+
⋮⋮
+
+
+
{{ $placement->document?->title }}
+ {{ $placement->document?->type?->name }}
+ @if(($placement->document?->placements()->count() ?? 0) > 1)Shared @endif
+ @unless($placement->is_enabled)Disabled @endunless
+
+
Order {{ $placement->sort_order }} · {{ $placement->document?->status === 'published' ? 'Published' : 'Draft — hidden from public rendering' }}
+
+
Edit
+
+
+
+ Placement settings
+
+
+ @csrf @method('PUT')
+
+
+
+ is_enabled)> Enabled
+ Save
+
+
+
+
+
@csrfCreate page-specific copy
+ @csrf @method('DELETE')Remove from page
+
+
+
+ @empty
+
No documents assigned to this region.
+ @endforelse
+
+
+ @endforeach
+
+
+
+
+ Add content section Choose an existing section from the content library. Draft sections remain hidden from visitors.
+
+ @csrf
+
+ Header Body Footer
+
+ Content section
+
+ Select a content section
+ @foreach($documents as $document)
+ {{ $document->title }} — {{ $document->type?->name }}{{ $document->status === 'draft' ? ' (draft)' : '' }}
+ @endforeach
+
+
+ Add section
+
+
+
+
+ Editing shared content
+ Editing a shared document changes every placement. “Create page-specific copy” makes a disabled draft copy beside the original so it can be reviewed before switching.
+
+
+
+ Live order
+ Order is saved automatically after dropping a document. A confirmation appears here.
+
+
+
+
+
+
+@push('scripts')
+
+@endpush
+
diff --git a/Content/Resources/views/admin/form.blade.php b/Content/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..4a12768
--- /dev/null
+++ b/Content/Resources/views/admin/form.blade.php
@@ -0,0 +1,636 @@
+
+
+
+
+
+ Content documents
+
+
+ {{ $document->exists ? $document->title : 'Create content document' }}
+
+
+ Edit every frontend field below. JSON is retained only as an emergency advanced tool.
+
+
+
+ @if ($document->exists)
+
+
+ @csrf
+ Create independent copy
+
+
+
+ @csrf
+ @method('DELETE')
+ Delete
+
+
+ @endif
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($errors->any())
+
+
+ @foreach ($errors->all() as $error)
+ {{ $error }}
+ @endforeach
+
+
+ @endif
+
+ @if ($document->exists && $document->placements->count() > 1)
+
+ Shared document.
+ Changes affect all {{ $document->placements->count() }} placements.
+
+ @endif
+
+
+ @csrf
+ @if ($document->exists)
+ @method('PUT')
+ @endif
+
+
+
+ Document
+
+
+
+ @foreach ($types as $type)
+ id) === $type->id)
+ >
+ {{ $type->name }}
+
+ @endforeach
+
+
+
+
+
+
+
Frontend content
+
+ Switches take effect when this document is saved.
+
+
+
+
+
+
+
+
+ Advanced JSON (emergency use only)
+
+ Normal editing should be performed with the controls above.
+
+ {{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) }}
+
+
+
+
+
+
+ Publishing
+
+ status ?: 'draft') === 'draft')>Draft
+ status) === 'published')>Published
+
+
+
+ Save document
+
+
+ @if ($document->exists)
+
+ Usage
+
+
+ @endif
+
+
+
+
+
+
diff --git a/Content/Resources/views/admin/index.blade.php b/Content/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..c219cb4
--- /dev/null
+++ b/Content/Resources/views/admin/index.blade.php
@@ -0,0 +1,53 @@
+
+
+
+
+
Content documents
+
Create and reuse typed content across page regions.
+
+
+ Compose homepage
+ Create document
+
+
+
+ @if(session('status'))
+
{{ session('status') }}
+ @endif
+ @error('document')
{{ $message }}
@enderror
+
+
+
+
+ All types
+ @foreach($types as $type)key)>{{ $type->name }} @endforeach
+
+
+ All statuses
+ Draft
+ Published
+
+ Filter Reset
+
+
+
+
+ Document Type Status Uses Actions
+
+ @forelse($documents as $document)
+
+ {{ $document->title }}
{{ $document->slug }}
+ {{ $document->type?->name }}
+ {{ ucfirst($document->status) }}
+ {{ $document->placements_count }}
+ Edit
+
+ @empty
+ No content documents match the current filters.
+ @endforelse
+
+
+
+ {{ $documents->links() }}
+
+
diff --git a/Content/Resources/views/documents/unsupported.blade.php b/Content/Resources/views/documents/unsupported.blade.php
new file mode 100644
index 0000000..b80545d
--- /dev/null
+++ b/Content/Resources/views/documents/unsupported.blade.php
@@ -0,0 +1,3 @@
+@if(app()->hasDebugModeEnabled())
+
+@endif
diff --git a/Content/Routes/web.php b/Content/Routes/web.php
new file mode 100644
index 0000000..910c7f6
--- /dev/null
+++ b/Content/Routes/web.php
@@ -0,0 +1,24 @@
+prefix('admin/content')->name('content.')->group(function (): void {
+ Route::get('/', [ContentDocumentController::class, 'index'])->name('index');
+ Route::get('/documents/create', [ContentDocumentController::class, 'create'])->name('documents.create');
+ Route::post('/documents', [ContentDocumentController::class, 'store'])->name('documents.store');
+ Route::get('/documents/{document}/edit', [ContentDocumentController::class, 'edit'])->name('documents.edit');
+ Route::put('/documents/{document}', [ContentDocumentController::class, 'update'])->name('documents.update');
+ Route::post('/documents/{document}/duplicate', [ContentDocumentController::class, 'duplicate'])->name('documents.duplicate');
+ Route::delete('/documents/{document}', [ContentDocumentController::class, 'destroy'])->name('documents.destroy');
+
+ Route::get('/page/{page}/compose', [PageCompositionController::class, 'editPage'])->name('pages.page.edit');
+ Route::get('/pages/{pageKey}', [PageCompositionController::class, 'edit'])->name('pages.edit');
+ Route::get('/pages/{pageKey}/preview', [PageCompositionController::class, 'preview'])->name('pages.preview');
+ Route::post('/pages/{pageKey}/placements', [PageCompositionController::class, 'store'])->name('placements.store');
+ Route::put('/placements/{placement}', [PageCompositionController::class, 'update'])->name('placements.update');
+ Route::delete('/placements/{placement}', [PageCompositionController::class, 'destroy'])->name('placements.destroy');
+ Route::post('/placements/{placement}/duplicate', [PageCompositionController::class, 'duplicate'])->name('placements.duplicate');
+ Route::post('/pages/{pageKey}/reorder', [PageCompositionController::class, 'reorder'])->name('placements.reorder');
+});
diff --git a/Content/Services/PageRenderer.php b/Content/Services/PageRenderer.php
new file mode 100644
index 0000000..d02c898
--- /dev/null
+++ b/Content/Services/PageRenderer.php
@@ -0,0 +1,83 @@
+where('page_key', $pageKey)->where('is_enabled', true)->exists();
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ public function renderRegion(string $pageKey, string $region): string
+ {
+ if (! $this->hasComposition($pageKey)) {
+ return '';
+ }
+
+ $preview = Route::currentRouteNamed('content.pages.preview');
+ $placements = PageContent::query()
+ ->with('document.type')
+ ->where('page_key', $pageKey)
+ ->where('region', $region)
+ ->where('is_enabled', true)
+ ->where(fn ($query) => $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()))
+ ->where(fn ($query) => $query->whereNull('ends_at')->orWhere('ends_at', '>', now()))
+ ->orderBy('sort_order')
+ ->get();
+
+ return $placements->map(function (PageContent $placement) use ($preview): string {
+ $document = $placement->document;
+ if (! $document || ! $document->type || ! $this->isRenderable($document, $preview)) {
+ return '';
+ }
+
+ $view = 'stylist-theme-saasland::documents.'.$document->type->renderer;
+ if (! View::exists($view)) {
+ $view = 'content::documents.unsupported';
+ }
+
+ $rendered = View::make($view, [
+ 'document' => $document,
+ 'data' => $document->data ?? [],
+ 'placement' => $placement,
+ 'isCompositionPreview' => $preview,
+ ])->render();
+
+ if ($preview && $document->status !== 'published') {
+ return 'Draft preview
'.$rendered.'
';
+ }
+
+ return $rendered;
+ })->implode("\n");
+ }
+
+ private function isRenderable(ContentDocument $document, bool $preview): bool
+ {
+ if ($preview) {
+ return true;
+ }
+
+ if ($document->status !== 'published') {
+ return false;
+ }
+
+ if ($document->published_at?->isFuture()) {
+ return false;
+ }
+
+ return ! $document->expires_at?->isPast();
+ }
+}
diff --git a/Content/module.json b/Content/module.json
new file mode 100644
index 0000000..e34671c
--- /dev/null
+++ b/Content/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Content",
+ "alias": "content",
+ "version": "1.0.0",
+ "description": "Composable typed content documents and page regions.",
+ "keywords": ["cms", "content", "regions", "composition"],
+ "priority": 22,
+ "providers": ["Modules\\Content\\Providers\\ContentServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Pages", "Stylist"]
+}
diff --git a/Content/views/admin/form.blade.php b/Content/views/admin/form.blade.php
new file mode 100644
index 0000000..47ae90d
--- /dev/null
+++ b/Content/views/admin/form.blade.php
@@ -0,0 +1,399 @@
+
+
+
+
+
+ Content documents
+
+
+ {{ $document->exists ? $document->title : 'Create content document' }}
+
+
+ Edit every frontend field below. JSON is retained only as an emergency advanced tool.
+
+
+
+ @if ($document->exists)
+
+
+ @csrf
+ Create independent copy
+
+
+
+ @csrf
+ @method('DELETE')
+ Delete
+
+
+ @endif
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($errors->any())
+
+
+ @foreach ($errors->all() as $error)
+ {{ $error }}
+ @endforeach
+
+
+ @endif
+
+ @if ($document->exists && $document->placements->count() > 1)
+
+ Shared document.
+ Changes affect all {{ $document->placements->count() }} placements.
+
+ @endif
+
+
+ @csrf
+ @if ($document->exists)
+ @method('PUT')
+ @endif
+
+
+
+ Document
+
+
+
+ @foreach ($types as $type)
+ id) === $type->id)
+ >
+ {{ $type->name }}
+
+ @endforeach
+
+
+
+
+
+
+
Frontend content
+
+ Switches take effect when this document is saved.
+
+
+
+
+
+
+
+
+ Advanced JSON (emergency use only)
+
+ Normal editing should be performed with the controls above.
+
+ {{ old('data_json', json_encode($document->data ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)) }}
+
+
+
+
+
+
+ Publishing
+
+ status ?: 'draft') === 'draft')>Draft
+ status) === 'published')>Published
+
+
+
+ Save document
+
+
+ @if ($document->exists)
+
+ Usage
+
+
+ @endif
+
+
+
+
+
+
diff --git a/Core/Config/config.php b/Core/Config/config.php
new file mode 100644
index 0000000..c00cc12
--- /dev/null
+++ b/Core/Config/config.php
@@ -0,0 +1,6 @@
+ 'Core',
+ 'enabled' => true,
+];
diff --git a/Core/Contracts/ModuleRegistry.php b/Core/Contracts/ModuleRegistry.php
new file mode 100644
index 0000000..e05ca63
--- /dev/null
+++ b/Core/Contracts/ModuleRegistry.php
@@ -0,0 +1,11 @@
+ */
+ public function all(): array;
+
+ public function enabled(string $name): bool;
+}
diff --git a/Core/Providers/CoreServiceProvider.php b/Core/Providers/CoreServiceProvider.php
new file mode 100644
index 0000000..cee3091
--- /dev/null
+++ b/Core/Providers/CoreServiceProvider.php
@@ -0,0 +1,24 @@
+app->register(RouteServiceProvider::class);
+ $this->app->singleton(ModuleRegistry::class, LaravelModuleRegistry::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Core/Providers/RouteServiceProvider.php b/Core/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..d400e95
--- /dev/null
+++ b/Core/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Core', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Core/Routes/web.php b/Core/Routes/web.php
new file mode 100644
index 0000000..4c3fffc
--- /dev/null
+++ b/Core/Routes/web.php
@@ -0,0 +1,3 @@
+ $name,
+ 'alias' => (string) ($data['alias'] ?? strtolower($name)),
+ 'version' => (string) ($data['version'] ?? '0.0.0'),
+ 'enabled' => (bool) ($statuses[$name] ?? false),
+ ];
+ }
+ usort($modules, fn (array $a, array $b): int => $a['name'] <=> $b['name']);
+
+ return $modules;
+ }
+
+ public function enabled(string $name): bool
+ {
+ foreach ($this->all() as $module) {
+ if (strcasecmp($module['name'], $name) === 0) {
+ return $module['enabled'];
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Core/Support/ModuleInfo.php b/Core/Support/ModuleInfo.php
new file mode 100644
index 0000000..5710024
--- /dev/null
+++ b/Core/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Core');
+});
diff --git a/Core/composer.json b/Core/composer.json
new file mode 100644
index 0000000..ca9d3b5
--- /dev/null
+++ b/Core/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/core-module",
+ "description": "Core foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Core\\": ""
+ }
+ }
+}
diff --git a/Core/module.json b/Core/module.json
new file mode 100644
index 0000000..0affe62
--- /dev/null
+++ b/Core/module.json
@@ -0,0 +1,18 @@
+{
+ "name": "Core",
+ "alias": "core",
+ "version": "1.0.0",
+ "description": "Core foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "core"
+ ],
+ "priority": 100,
+ "providers": [
+ "Modules\\Core\\Providers\\CoreServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": []
+}
diff --git a/Developer/Config/config.php b/Developer/Config/config.php
new file mode 100644
index 0000000..f3bc1fc
--- /dev/null
+++ b/Developer/Config/config.php
@@ -0,0 +1,8 @@
+ 'Modules',
+ 'modules_path' => base_path('Modules'),
+ 'themes_path' => resource_path('themes'),
+ 'overwrite' => false,
+];
diff --git a/Developer/Console/Commands/InspectModulesCommand.php b/Developer/Console/Commands/InspectModulesCommand.php
new file mode 100644
index 0000000..11f3078
--- /dev/null
+++ b/Developer/Console/Commands/InspectModulesCommand.php
@@ -0,0 +1,44 @@
+argument('name');
+ $rows = [];
+ foreach ($files as $file) {
+ $manifest = json_decode((string) file_get_contents($file), true);
+ if (! is_array($manifest)) {
+ $this->error("Invalid JSON: {$file}");
+
+ return self::FAILURE;
+ }
+ if ($name && strcasecmp((string) ($manifest['name'] ?? ''), (string) $name) !== 0) {
+ continue;
+ }
+ $rows[] = [
+ 'name' => $manifest['name'] ?? basename(dirname($file)),
+ 'version' => $manifest['version'] ?? 'unknown',
+ 'requires' => implode(', ', $manifest['requires'] ?? []),
+ 'providers' => count($manifest['providers'] ?? []),
+ ];
+ }
+ if ($this->option('json')) {
+ $this->line(json_encode($rows, JSON_PRETTY_PRINT));
+
+ return self::SUCCESS;
+ }
+ $this->table(['Module', 'Version', 'Requires', 'Providers'], $rows);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Developer/Console/Commands/MakeCrudCommand.php b/Developer/Console/Commands/MakeCrudCommand.php
new file mode 100644
index 0000000..d615a48
--- /dev/null
+++ b/Developer/Console/Commands/MakeCrudCommand.php
@@ -0,0 +1,37 @@
+option('module') ?: $this->choice('Select the target module', $this->modules());
+ try {
+ $files = $generator->generate((string) $module, $this->argument('model'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
+ $this->warn('Register the generated controller routes in the target module Routes/web.php file.');
+
+ return self::SUCCESS;
+ }
+
+ private function modules(): array
+ {
+ return collect(glob(base_path('Modules/*/module.json')) ?: [])->map(fn (string $file) => basename(dirname($file)))->sort()->values()->all();
+ }
+}
diff --git a/Developer/Console/Commands/MakeModuleCommand.php b/Developer/Console/Commands/MakeModuleCommand.php
new file mode 100644
index 0000000..34ece37
--- /dev/null
+++ b/Developer/Console/Commands/MakeModuleCommand.php
@@ -0,0 +1,32 @@
+generate($this->argument('name'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
+ $this->newLine();
+ $this->info('Enable the module in modules_statuses.json, then run composer dump-autoload.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Developer/Console/Commands/MakeThemeCommand.php b/Developer/Console/Commands/MakeThemeCommand.php
new file mode 100644
index 0000000..2457559
--- /dev/null
+++ b/Developer/Console/Commands/MakeThemeCommand.php
@@ -0,0 +1,30 @@
+generate($this->argument('name'), (bool) $this->option('force'));
+ } catch (Throwable $e) {
+ $this->error($e->getMessage());
+
+ return self::FAILURE;
+ }
+ foreach ($files as $file) {
+ $this->line("created {$file}");
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Developer/Http/Controllers/DeveloperController.php b/Developer/Http/Controllers/DeveloperController.php
new file mode 100644
index 0000000..d921e99
--- /dev/null
+++ b/Developer/Http/Controllers/DeveloperController.php
@@ -0,0 +1,27 @@
+map(function (string $file): array {
+ $manifest = json_decode((string) file_get_contents($file), true) ?: [];
+
+ return [
+ 'name' => $manifest['name'] ?? basename(dirname($file)),
+ 'version' => $manifest['version'] ?? 'unknown',
+ 'description' => $manifest['description'] ?? '',
+ 'requires' => $manifest['requires'] ?? [],
+ ];
+ })
+ ->sortBy('name')
+ ->values();
+
+ return view('developer::admin.index', compact('modules'));
+ }
+}
diff --git a/Developer/Providers/DeveloperServiceProvider.php b/Developer/Providers/DeveloperServiceProvider.php
new file mode 100644
index 0000000..a5f09a9
--- /dev/null
+++ b/Developer/Providers/DeveloperServiceProvider.php
@@ -0,0 +1,32 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Developer', 'Config/config.php'), 'developer');
+ }
+
+ public function boot(): void
+ {
+ $this->loadViewsFrom(module_path('Developer', 'Resources/views'), 'developer');
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ MakeModuleCommand::class,
+ MakeCrudCommand::class,
+ MakeThemeCommand::class,
+ InspectModulesCommand::class,
+ ]);
+ }
+ }
+}
diff --git a/Developer/Providers/RouteServiceProvider.php b/Developer/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..50cbdde
--- /dev/null
+++ b/Developer/Providers/RouteServiceProvider.php
@@ -0,0 +1,14 @@
+group(module_path('Developer', 'Routes/web.php'));
+ }
+}
diff --git a/Developer/Resources/views/admin/index.blade.php b/Developer/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..ca9fe9f
--- /dev/null
+++ b/Developer/Resources/views/admin/index.blade.php
@@ -0,0 +1,26 @@
+
+
+
+
{{ __('Developer Tools') }}
+
{{ __('Generate modules, CRUD resources and themes using the platform conventions.') }}
+
+
+
+
{{ __('Module Generator') }} php artisan module:make Products
+
{{ __('CRUD Generator') }} php artisan crud:make Product --module=Products
+
{{ __('Theme Generator') }} php artisan theme:make Corporate
+
+
+
+
{{ __('Installed modules') }}
+
+ @foreach ($modules as $module)
+
+
{{ $module['name'] }}
{{ $module['description'] }}
+
v{{ $module['version'] }}
{{ implode(', ', $module['requires']) }}
+
+ @endforeach
+
+
+
+
diff --git a/Developer/Routes/web.php b/Developer/Routes/web.php
new file mode 100644
index 0000000..5a8712a
--- /dev/null
+++ b/Developer/Routes/web.php
@@ -0,0 +1,11 @@
+prefix('administration/developer')
+ ->name('administration.developer.')
+ ->group(function (): void {
+ Route::get('/', DeveloperController::class)->name('index');
+ });
diff --git a/Developer/Support/CrudGenerator.php b/Developer/Support/CrudGenerator.php
new file mode 100644
index 0000000..90db77c
--- /dev/null
+++ b/Developer/Support/CrudGenerator.php
@@ -0,0 +1,69 @@
+studly($module);
+ $model = $this->studly($model);
+ $table = str($model)->plural()->snake()->toString();
+ $route = str($model)->plural()->kebab()->toString();
+ $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$module;
+ $timestamp = now()->format('Y_m_d_His');
+
+ $files = [
+ "$root/Models/{$model}.php" => $this->model($module, $model),
+ "$root/Http/Controllers/{$model}Controller.php" => $this->controller($module, $model),
+ "$root/Http/Requests/Store{$model}Request.php" => $this->request($module, $model),
+ "$root/Database/Migrations/{$timestamp}_create_{$table}_table.php" => $this->migration($table),
+ "$root/Resources/views/admin/{$route}/index.blade.php" => $this->indexView($model, $route),
+ "$root/Resources/views/admin/{$route}/form.blade.php" => $this->formView($model),
+ "$root/Tests/Feature/{$model}CrudTest.php" => $this->crudTest($module, $model),
+ ];
+
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
+ return array_keys($files);
+ }
+
+ private function model(string $module, string $model): string
+ {
+ return " 'boolean'];\n }\n}\n";
+ }
+
+ private function controller(string $module, string $model): string
+ {
+ $plural = str($model)->plural()->kebab();
+
+ return "snake()."::admin.{$plural}.index', ['records' => {$model}::query()->latest()->paginate()]);\n }\n\n public function store(Store{$model}Request \$request): RedirectResponse\n {\n {$model}::query()->create(\$request->validated());\n return back()->with('status', '{$model} created.');\n }\n}\n";
+ }
+
+ private function request(string $module, string $model): string
+ {
+ return "user() !== null; }\n public function rules(): array { return ['name' => ['required', 'string', 'max:255'], 'is_active' => ['sometimes', 'boolean']]; }\n}\n";
+ }
+
+ private function migration(string $table): string
+ {
+ return "id(); \$table->string('name'); \$table->boolean('is_active')->default(true); \$table->timestamps(); }); }\n public function down(): void { Schema::dropIfExists('{$table}'); }\n};\n";
+ }
+
+ private function indexView(string $model, string $route): string
+ {
+ return "\n {$model} @foreach (\$records as \$record)
{{ \$record->name }}
@endforeach{{ \$records->links() }}
\n \n";
+ }
+
+ private function formView(string $model): string
+ {
+ return "@csrf{{ __('Save {$model}') }} \n";
+ }
+
+ private function crudTest(string $module, string $model): string
+ {
+ return "create(['name' => 'Example']))->toBeInstanceOf({$model}::class); });\n";
+ }
+}
diff --git a/Developer/Support/Generator.php b/Developer/Support/Generator.php
new file mode 100644
index 0000000..a8c8bf8
--- /dev/null
+++ b/Developer/Support/Generator.php
@@ -0,0 +1,43 @@
+files->isDirectory($path)) {
+ $this->files->makeDirectory($path, 0755, true);
+ }
+ }
+
+ protected function write(string $path, string $contents, bool $force = false): void
+ {
+ if ($this->files->exists($path) && ! $force) {
+ throw new RuntimeException("File already exists: {$path}");
+ }
+
+ $this->createDirectory(dirname($path));
+ $this->files->put($path, $contents);
+ }
+
+ protected function studly(string $value): string
+ {
+ return str($value)->studly()->toString();
+ }
+
+ protected function snake(string $value): string
+ {
+ return str($value)->snake()->toString();
+ }
+
+ protected function kebab(string $value): string
+ {
+ return str($value)->kebab()->toString();
+ }
+}
diff --git a/Developer/Support/ModuleGenerator.php b/Developer/Support/ModuleGenerator.php
new file mode 100644
index 0000000..a9668e3
--- /dev/null
+++ b/Developer/Support/ModuleGenerator.php
@@ -0,0 +1,55 @@
+studly($name);
+ $alias = $this->kebab($name);
+ $root = rtrim(config('developer.modules_path', base_path('Modules')), '/').'/'.$name;
+ $provider = "Modules\\{$name}\\Providers\\{$name}ServiceProvider";
+
+ $files = [
+ "$root/module.json" => json_encode([
+ 'name' => $name,
+ 'alias' => $alias,
+ 'version' => '1.0.0',
+ 'description' => "{$name} module.",
+ 'keywords' => [$alias],
+ 'priority' => 100,
+ 'providers' => [$provider],
+ 'aliases' => (object) [],
+ 'files' => [],
+ 'requires' => ['Core', 'Administration'],
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL,
+ "$root/Config/config.php" => " '{$name}'];\n",
+ "$root/Providers/{$name}ServiceProvider.php" => $this->provider($name),
+ "$root/Providers/RouteServiceProvider.php" => $this->routeProvider($name),
+ "$root/Routes/web.php" => "prefix('administration/{$alias}')->name('administration.{$alias}.')->group(function (): void {\n // Register module routes here.\n});\n",
+ "$root/Tests/Feature/{$name}ModuleBootTest.php" => $this->test($name),
+ ];
+
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
+ return array_keys($files);
+ }
+
+ private function provider(string $name): string
+ {
+ return "app->register(RouteServiceProvider::class);\n \$this->mergeConfigFrom(module_path('{$name}', 'Config/config.php'), '".str($name)->snake()."');\n }\n\n public function boot(): void\n {\n \$this->loadMigrationsFrom(module_path('{$name}', 'Database/Migrations'));\n \$this->loadViewsFrom(module_path('{$name}', 'Resources/views'), '".str($name)->snake()."');\n }\n}\n";
+ }
+
+ private function routeProvider(string $name): string
+ {
+ return "group(module_path('{$name}', 'Routes/web.php'));\n }\n}\n";
+ }
+
+ private function test(string $name): string
+ {
+ return "providerIsLoaded(Modules\\{$name}\\Providers\\{$name}ServiceProvider::class))->toBeTrue();\n});\n";
+ }
+}
diff --git a/Developer/Support/ThemeGenerator.php b/Developer/Support/ThemeGenerator.php
new file mode 100644
index 0000000..dd1de18
--- /dev/null
+++ b/Developer/Support/ThemeGenerator.php
@@ -0,0 +1,24 @@
+kebab($name);
+ $root = rtrim(config('developer.themes_path', resource_path('themes')), '/').'/'.$name;
+ $files = [
+ "$root/theme.json" => json_encode(['name' => str($name)->headline()->toString(), 'slug' => $name, 'version' => '1.0.0', 'parent' => 'common'], JSON_PRETTY_PRINT).PHP_EOL,
+ "$root/views/layouts/app.blade.php" => "getLocale()) }}\">{{ \$title ?? config('app.name') }} @vite(['resources/css/app.css', 'resources/js/app.js']){{ \$slot }}\n",
+ "$root/views/home.blade.php" => "{{ config('app.name') }} \n",
+ "$root/assets/css/theme.css" => ":root { --theme-radius: 0.5rem; }\n",
+ "$root/README.md" => '# '.str($name)->headline()." Theme\n\nGenerated by `php artisan theme:make {$name}`.\n",
+ ];
+ foreach ($files as $path => $contents) {
+ $this->write($path, $contents, $force);
+ }
+
+ return array_keys($files);
+ }
+}
diff --git a/Developer/Tests/Feature/DeveloperModuleBootTest.php b/Developer/Tests/Feature/DeveloperModuleBootTest.php
new file mode 100644
index 0000000..80f181c
--- /dev/null
+++ b/Developer/Tests/Feature/DeveloperModuleBootTest.php
@@ -0,0 +1,11 @@
+providerIsLoaded(DeveloperServiceProvider::class))->toBeTrue();
+});
+
+it('registers developer commands', function (): void {
+ $this->artisan('module:inspect')->assertSuccessful();
+});
diff --git a/Developer/module.json b/Developer/module.json
new file mode 100644
index 0000000..7df5745
--- /dev/null
+++ b/Developer/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Developer",
+ "alias": "developer",
+ "version": "1.0.0",
+ "description": "Developer generators, module inspection and scaffolding tools.",
+ "keywords": ["developer", "generator", "scaffold"],
+ "priority": 80,
+ "providers": ["Modules\\Developer\\Providers\\DeveloperServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Documents/Config/config.php b/Documents/Config/config.php
new file mode 100644
index 0000000..f780704
--- /dev/null
+++ b/Documents/Config/config.php
@@ -0,0 +1,3 @@
+ 'Documents'];
diff --git a/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php b/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php
new file mode 100644
index 0000000..87aaf16
--- /dev/null
+++ b/Documents/Database/Migrations/2026_07_24_000500_create_documents_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->string('title');
+ $table->text('description')->nullable();
+ $table->string('file_path')->nullable();
+ $table->boolean('is_public')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('documents');
+ }
+};
diff --git a/Documents/Http/Controllers/DocumentController.php b/Documents/Http/Controllers/DocumentController.php
new file mode 100644
index 0000000..ba1fc4e
--- /dev/null
+++ b/Documents/Http/Controllers/DocumentController.php
@@ -0,0 +1,58 @@
+ Document::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('documents::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'file_path' => ['nullable', 'string'],
+ 'is_public' => ['sometimes', 'boolean']]);
+ $data['is_public'] = $request->boolean('is_public');
+ Document::create($data);
+
+ return redirect()->route('administration.documents.index')->with('status', 'Document created.');
+ }
+
+ public function edit(Document $document): View
+ {
+ return view('documents::admin.form', ['record' => $document]);
+ }
+
+ public function update(Request $request, Document $document): RedirectResponse
+ {
+ $data = $request->validate(['title' => ['required', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'file_path' => ['nullable', 'string'],
+ 'is_public' => ['sometimes', 'boolean']]);
+ $data['is_public'] = $request->boolean('is_public');
+ $document->update($data);
+
+ return redirect()->route('administration.documents.index')->with('status', 'Document updated.');
+ }
+
+ public function destroy(Document $document): RedirectResponse
+ {
+ $document->delete();
+
+ return back()->with('status', 'Document deleted.');
+ }
+}
diff --git a/Documents/Models/Document.php b/Documents/Models/Document.php
new file mode 100644
index 0000000..2d9d2cf
--- /dev/null
+++ b/Documents/Models/Document.php
@@ -0,0 +1,23 @@
+ 'boolean',
+ ];
+ }
+}
diff --git a/Documents/Providers/DocumentsServiceProvider.php b/Documents/Providers/DocumentsServiceProvider.php
new file mode 100644
index 0000000..5e19ae9
--- /dev/null
+++ b/Documents/Providers/DocumentsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Documents', 'Config/config.php'), 'documents');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Documents', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Documents', 'Resources/views'), 'documents');
+ }
+}
diff --git a/Documents/Providers/RouteServiceProvider.php b/Documents/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..7c25a1d
--- /dev/null
+++ b/Documents/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Documents', 'Routes/web.php')));
+ }
+}
diff --git a/Documents/Resources/views/admin/form.blade.php b/Documents/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..d94c9d1
--- /dev/null
+++ b/Documents/Resources/views/admin/form.blade.php
@@ -0,0 +1,8 @@
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Document @csrf @isset($record) @method('PUT') @endisset
+Title @error('title')
{{ $message }}
@enderror
+Description {{ old('description', $record->description ?? '') }} @error('description')
{{ $message }}
@enderror
+File Path @error('file_path')
{{ $message }}
@enderror
+ is_public ?? true))>Is Public
+
+
diff --git a/Documents/Resources/views/admin/index.blade.php b/Documents/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..f79986e
--- /dev/null
+++ b/Documents/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+Documents Manage documents content.
Add new
+@if(session('status'))
{{ session('status') }}
@endif
+
Title Description File Path Actions @forelse($records as $record){{ is_array($record->title) ? json_encode($record->title) : Str::limit((string) $record->title, 70) }} {{ is_array($record->description) ? json_encode($record->description) : Str::limit((string) $record->description, 70) }} {{ is_array($record->file_path) ? json_encode($record->file_path) : Str::limit((string) $record->file_path, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Documents/Routes/web.php b/Documents/Routes/web.php
new file mode 100644
index 0000000..675d903
--- /dev/null
+++ b/Documents/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/documents')->name('administration.documents.')->group(function (): void {
+ Route::get('/', [DocumentController::class, 'index'])->name('index');
+ Route::get('/create', [DocumentController::class, 'create'])->name('create');
+ Route::post('/', [DocumentController::class, 'store'])->name('store');
+ Route::get('/{document}/edit', [DocumentController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{document}', [DocumentController::class, 'update'])->name('update');
+ Route::delete('/{document}', [DocumentController::class, 'destroy'])->name('destroy');
+});
diff --git a/Documents/Support/ModuleInfo.php b/Documents/Support/ModuleInfo.php
new file mode 100644
index 0000000..c815ec2
--- /dev/null
+++ b/Documents/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Documents/module.json b/Documents/module.json
new file mode 100644
index 0000000..318fef2
--- /dev/null
+++ b/Documents/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Documents",
+ "alias": "documents",
+ "version": "1.0.0",
+ "description": "Documents CMS module.",
+ "keywords": [
+ "cms",
+ "documents"
+ ],
+ "priority": 25,
+ "providers": [
+ "Modules\\Documents\\Providers\\DocumentsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Enterprise/Config/config.php b/Enterprise/Config/config.php
new file mode 100644
index 0000000..2dd4c3a
--- /dev/null
+++ b/Enterprise/Config/config.php
@@ -0,0 +1,11 @@
+ env('ENTERPRISE_BACKUP_DISK', 'local'),
+ 'backup_path' => env('ENTERPRISE_BACKUP_PATH', 'backups'),
+ 'retention_days' => (int) env('ENTERPRISE_BACKUP_RETENTION_DAYS', 30),
+ 'health' => [
+ 'minimum_free_disk_mb' => (int) env('ENTERPRISE_MIN_FREE_DISK_MB', 512),
+ 'maximum_failed_jobs' => (int) env('ENTERPRISE_MAX_FAILED_JOBS', 0),
+ ],
+];
diff --git a/Enterprise/Console/BackupCreateCommand.php b/Enterprise/Console/BackupCreateCommand.php
new file mode 100644
index 0000000..3492f0c
--- /dev/null
+++ b/Enterprise/Console/BackupCreateCommand.php
@@ -0,0 +1,21 @@
+create();
+ $this->info('Backup created: '.$backup->path);
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Enterprise/Console/BackupPruneCommand.php b/Enterprise/Console/BackupPruneCommand.php
new file mode 100644
index 0000000..bd7fbf4
--- /dev/null
+++ b/Enterprise/Console/BackupPruneCommand.php
@@ -0,0 +1,20 @@
+info($service->prune().' backup(s) pruned.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Enterprise/Console/HealthCheckCommand.php b/Enterprise/Console/HealthCheckCommand.php
new file mode 100644
index 0000000..40d1b07
--- /dev/null
+++ b/Enterprise/Console/HealthCheckCommand.php
@@ -0,0 +1,28 @@
+run();
+ HealthRun::create(['status' => $result['status'], 'results' => $result, 'ran_at' => now()]);
+ if ($this->option('json')) {
+ $this->line(json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
+ } else {
+ $this->table(['Check', 'Status', 'Message'], collect($result['checks'])->map(fn (array $check) => [$check['name'], $check['status'], $check['message']])->all());
+ $this->line('Overall: '.$result['status']);
+ }
+
+ return $result['status'] === 'failed' ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php b/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php
new file mode 100644
index 0000000..1413a3f
--- /dev/null
+++ b/Enterprise/Database/Migrations/2026_07_24_100000_create_enterprise_health_runs_table.php
@@ -0,0 +1,25 @@
+id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('status', 20);
+ $table->json('results');
+ $table->timestamp('ran_at');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('enterprise_health_runs');
+ }
+};
diff --git a/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php b/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php
new file mode 100644
index 0000000..4477ca8
--- /dev/null
+++ b/Enterprise/Database/Migrations/2026_07_24_100100_create_enterprise_backups_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('disk');
+ $table->string('path');
+ $table->unsignedBigInteger('size')->default(0);
+ $table->string('status', 20)->default('completed');
+ $table->text('message')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('enterprise_backups');
+ }
+};
diff --git a/Enterprise/Http/Controllers/EnterpriseController.php b/Enterprise/Http/Controllers/EnterpriseController.php
new file mode 100644
index 0000000..de0182f
--- /dev/null
+++ b/Enterprise/Http/Controllers/EnterpriseController.php
@@ -0,0 +1,82 @@
+ $health->run(),
+ 'latestRun' => HealthRun::latest('ran_at')->first(),
+ 'backups' => Backup::latest()->limit(10)->get(),
+ 'failedJobs' => DB::getSchemaBuilder()->hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0,
+ 'queueConnection' => config('queue.default'),
+ 'cacheStore' => config('cache.default'),
+ 'schedulerConfigured' => file_exists(base_path('routes/console.php')),
+ 'phpVersion' => PHP_VERSION,
+ 'laravelVersion' => app()->version(),
+ ]);
+ }
+
+ public function runHealth(SystemHealthService $service): RedirectResponse
+ {
+ $result = $service->run();
+ HealthRun::create(['user_id' => auth()->id(), 'status' => $result['status'], 'results' => $result, 'ran_at' => now()]);
+
+ return back()->with('status', 'Health checks completed.');
+ }
+
+ public function clearCache(): RedirectResponse
+ {
+ Artisan::call('optimize:clear');
+ Cache::flush();
+
+ return back()->with('status', 'Application and cache stores cleared.');
+ }
+
+ public function runScheduler(): RedirectResponse
+ {
+ Artisan::call('schedule:run');
+
+ return back()->with('status', trim(Artisan::output()) ?: 'Scheduler executed.');
+ }
+
+ public function retryFailedJobs(): RedirectResponse
+ {
+ Artisan::call('queue:retry', ['id' => 'all']);
+
+ return back()->with('status', trim(Artisan::output()) ?: 'Failed jobs queued for retry.');
+ }
+
+ public function createBackup(BackupService $service): RedirectResponse
+ {
+ try {
+ $service->create(auth()->id());
+
+ return back()->with('status', 'Backup created.');
+ } catch (Throwable $e) {
+ report($e);
+
+ return back()->withErrors(['backup' => $e->getMessage()]);
+ }
+ }
+
+ public function deleteBackup(Backup $backup, BackupService $service): RedirectResponse
+ {
+ $service->delete($backup);
+
+ return back()->with('status', 'Backup deleted.');
+ }
+}
diff --git a/Enterprise/Models/Backup.php b/Enterprise/Models/Backup.php
new file mode 100644
index 0000000..14ecef9
--- /dev/null
+++ b/Enterprise/Models/Backup.php
@@ -0,0 +1,24 @@
+ 'datetime'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/Enterprise/Models/HealthRun.php b/Enterprise/Models/HealthRun.php
new file mode 100644
index 0000000..34824c2
--- /dev/null
+++ b/Enterprise/Models/HealthRun.php
@@ -0,0 +1,24 @@
+ 'array', 'ran_at' => 'datetime'];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+}
diff --git a/Enterprise/Providers/EnterpriseServiceProvider.php b/Enterprise/Providers/EnterpriseServiceProvider.php
new file mode 100644
index 0000000..a532d0a
--- /dev/null
+++ b/Enterprise/Providers/EnterpriseServiceProvider.php
@@ -0,0 +1,30 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Enterprise', 'Config/config.php'), 'enterprise');
+ $this->app->singleton(SystemHealthService::class);
+ $this->app->singleton(BackupService::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Enterprise', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Enterprise', 'Resources/views'), 'enterprise');
+ if ($this->app->runningInConsole()) {
+ $this->commands([HealthCheckCommand::class, BackupCreateCommand::class, BackupPruneCommand::class]);
+ }
+ }
+}
diff --git a/Enterprise/Providers/RouteServiceProvider.php b/Enterprise/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c1150b7
--- /dev/null
+++ b/Enterprise/Providers/RouteServiceProvider.php
@@ -0,0 +1,14 @@
+group(module_path('Enterprise', 'Routes/web.php'));
+ }
+}
diff --git a/Enterprise/Resources/views/admin/index.blade.php b/Enterprise/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..d337ff9
--- /dev/null
+++ b/Enterprise/Resources/views/admin/index.blade.php
@@ -0,0 +1,24 @@
+
+
+
{{ __('Enterprise Services') }} {{ __('Health, queues, scheduler, cache, backups and production diagnostics.') }}
+ @if(session('status'))
{{ session('status') }} @endif
+ @if($errors->any())
{{ $errors->first() }} @endif
+
+
+
{{ __('Overall health') }}
{{ $health['status'] }}
+
{{ __('Queue') }}
{{ $queueConnection }}
{{ $failedJobs }} {{ __('failed') }}
+
{{ __('Cache') }}
{{ $cacheStore }}
+
{{ __('Runtime') }}
PHP {{ $phpVersion }}
Laravel {{ $laravelVersion }}
+
+
+
+
{{ __('Health checks') }} @csrf{{ __('Run checks') }}
+ @foreach($health['checks'] as $check)
{{ $check['name'] }} {{ $check['status'] }} {{ $check['message'] }}
@endforeach
+
+
+
+
{{ __('Operations') }}
@csrf{{ __('Clear cache') }} @csrf{{ __('Run scheduler') }} @csrf{{ __('Retry failed jobs') }}
+
{{ __('Backups') }} @csrf{{ __('Create backup') }} @forelse($backups as $backup){{ $backup->path }}
{{ number_format($backup->size / 1024 / 1024, 2) }} MB · {{ $backup->created_at->diffForHumans() }}
@csrf @method('DELETE'){{ __('Delete') }} @empty{{ __('No backups created.') }}
@endforelse
+
+
+
diff --git a/Enterprise/Routes/web.php b/Enterprise/Routes/web.php
new file mode 100644
index 0000000..69e772f
--- /dev/null
+++ b/Enterprise/Routes/web.php
@@ -0,0 +1,18 @@
+prefix('administration/enterprise')
+ ->name('administration.enterprise.')
+ ->controller(EnterpriseController::class)
+ ->group(function (): void {
+ Route::get('/', 'index')->name('index');
+ Route::post('/health', 'runHealth')->middleware('permission:enterprise.manage')->name('health.run');
+ Route::post('/cache/clear', 'clearCache')->middleware('permission:enterprise.manage')->name('cache.clear');
+ Route::post('/scheduler/run', 'runScheduler')->middleware('permission:enterprise.manage')->name('scheduler.run');
+ Route::post('/queue/retry', 'retryFailedJobs')->middleware('permission:enterprise.manage')->name('queue.retry');
+ Route::post('/backups', 'createBackup')->middleware('permission:enterprise.manage')->name('backups.store');
+ Route::delete('/backups/{backup}', 'deleteBackup')->middleware('permission:enterprise.manage')->name('backups.destroy');
+ });
diff --git a/Enterprise/Services/BackupService.php b/Enterprise/Services/BackupService.php
new file mode 100644
index 0000000..94463da
--- /dev/null
+++ b/Enterprise/Services/BackupService.php
@@ -0,0 +1,76 @@
+format('Ymd-His').'.zip';
+ $relativePath = $directory.'/'.$name;
+ $temporary = storage_path('app/'.$name);
+ $zip = new ZipArchive;
+ if ($zip->open($temporary, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException('Unable to create backup archive.');
+ }
+
+ $manifest = json_encode([
+ 'application' => config('app.name'),
+ 'environment' => app()->environment(),
+ 'created_at' => now()->toIso8601String(),
+ 'php' => PHP_VERSION,
+ 'laravel' => app()->version(),
+ ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
+ $zip->addFromString('manifest.json', $manifest ?: '{}');
+ $zip->addFile(base_path('composer.json'), 'composer.json');
+ if (File::exists(base_path('modules_statuses.json'))) {
+ $zip->addFile(base_path('modules_statuses.json'), 'modules_statuses.json');
+ }
+ if (config('database.default') === 'sqlite') {
+ $database = database_path('database.sqlite');
+ if (File::exists($database)) {
+ $zip->addFile($database, 'database/database.sqlite');
+ }
+ }
+ $zip->close();
+
+ Storage::disk($disk)->put($relativePath, File::get($temporary));
+ File::delete($temporary);
+
+ return Backup::create([
+ 'user_id' => $userId,
+ 'disk' => $disk,
+ 'path' => $relativePath,
+ 'size' => Storage::disk($disk)->size($relativePath),
+ 'status' => 'completed',
+ 'completed_at' => now(),
+ ]);
+ }
+
+ public function delete(Backup $backup): void
+ {
+ Storage::disk($backup->disk)->delete($backup->path);
+ $backup->delete();
+ }
+
+ public function prune(): int
+ {
+ $cutoff = now()->subDays((int) config('enterprise.retention_days', 30));
+ $backups = Backup::query()->where('created_at', '<', $cutoff)->get();
+ $backups->each(fn (Backup $backup) => $this->delete($backup));
+
+ return $backups->count();
+ }
+}
diff --git a/Enterprise/Services/SystemHealthService.php b/Enterprise/Services/SystemHealthService.php
new file mode 100644
index 0000000..b08b319
--- /dev/null
+++ b/Enterprise/Services/SystemHealthService.php
@@ -0,0 +1,94 @@
+database(),
+ $this->cache(),
+ $this->storage(),
+ $this->queue(),
+ $this->environment(),
+ ];
+
+ return [
+ 'status' => collect($checks)->contains(fn (array $check): bool => $check['status'] === 'failed') ? 'failed' : (collect($checks)->contains(fn (array $check): bool => $check['status'] === 'warning') ? 'warning' : 'healthy'),
+ 'checks' => $checks,
+ 'generated_at' => now()->toIso8601String(),
+ ];
+ }
+
+ private function database(): array
+ {
+ try {
+ DB::connection()->getPdo();
+
+ return $this->result('Database', 'healthy', DB::connection()->getDriverName());
+ } catch (Throwable $e) {
+ return $this->result('Database', 'failed', $e->getMessage());
+ }
+ }
+
+ private function cache(): array
+ {
+ try {
+ $key = 'enterprise-health-'.bin2hex(random_bytes(6));
+ Cache::put($key, 'ok', 10);
+ $healthy = Cache::get($key) === 'ok';
+ Cache::forget($key);
+
+ return $this->result('Cache', $healthy ? 'healthy' : 'failed', config('cache.default'));
+ } catch (Throwable $e) {
+ return $this->result('Cache', 'failed', $e->getMessage());
+ }
+ }
+
+ private function storage(): array
+ {
+ try {
+ Storage::disk('local')->put('enterprise-health.txt', now()->toIso8601String());
+ Storage::disk('local')->delete('enterprise-health.txt');
+ $freeMb = (int) floor(disk_free_space(storage_path()) / 1024 / 1024);
+ $minimum = (int) config('enterprise.health.minimum_free_disk_mb', 512);
+
+ return $this->result('Storage', $freeMb < $minimum ? 'warning' : 'healthy', $freeMb.' MB free');
+ } catch (Throwable $e) {
+ return $this->result('Storage', 'failed', $e->getMessage());
+ }
+ }
+
+ private function queue(): array
+ {
+ $failed = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : 0;
+ $maximum = (int) config('enterprise.health.maximum_failed_jobs', 0);
+
+ return $this->result('Queue', $failed > $maximum ? 'warning' : 'healthy', $failed.' failed jobs');
+ }
+
+ private function environment(): array
+ {
+ $warnings = [];
+ if (app()->environment('production') && config('app.debug')) {
+ $warnings[] = 'APP_DEBUG is enabled';
+ }
+ if (! app()->environment('production')) {
+ $warnings[] = 'Not running in production';
+ }
+
+ return $this->result('Environment', $warnings ? 'warning' : 'healthy', $warnings ? implode('; ', $warnings) : app()->environment());
+ }
+
+ private function result(string $name, string $status, string $message): array
+ {
+ return compact('name', 'status', 'message');
+ }
+}
diff --git a/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php b/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php
new file mode 100644
index 0000000..fe1d1d1
--- /dev/null
+++ b/Enterprise/Tests/Feature/EnterpriseModuleBootTest.php
@@ -0,0 +1,10 @@
+ expect(app()->providerIsLoaded(EnterpriseServiceProvider::class))->toBeTrue());
+it('runs platform health checks', function (): void {
+ $result = app(SystemHealthService::class)->run();
+ expect($result)->toHaveKeys(['status', 'checks', 'generated_at'])->and($result['checks'])->not->toBeEmpty();
+});
diff --git a/Enterprise/module.json b/Enterprise/module.json
new file mode 100644
index 0000000..fdce53e
--- /dev/null
+++ b/Enterprise/module.json
@@ -0,0 +1 @@
+{"name":"Enterprise","alias":"enterprise","version":"1.0.0","description":"Health, diagnostics, cache, queues, scheduler, backups and storage operations.","keywords":["health","diagnostics","queue","scheduler","backup"],"priority":100,"providers":["Modules\\Enterprise\\Providers\\EnterpriseServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration"]}
diff --git a/Events/Config/config.php b/Events/Config/config.php
new file mode 100644
index 0000000..6e87093
--- /dev/null
+++ b/Events/Config/config.php
@@ -0,0 +1,3 @@
+ 'Events'];
diff --git a/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php b/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php
new file mode 100644
index 0000000..a974f12
--- /dev/null
+++ b/Events/Database/Migrations/2026_07_24_04200_create_events_tables.php
@@ -0,0 +1,51 @@
+id();
+ $t->string('name');
+ $t->text('address')->nullable();
+ $t->unsignedInteger('capacity')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('events', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('venue_id')->nullable()->constrained('event_venues')->nullOnDelete();
+ $t->string('title');
+ $t->string('slug')->unique();
+ $t->text('summary')->nullable();
+ $t->longText('description')->nullable();
+ $t->timestamp('starts_at')->index();
+ $t->timestamp('ends_at')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->unsignedInteger('capacity')->nullable();
+ $t->decimal('price', 10, 2)->default(0);
+ $t->boolean('registration_open')->default(false);
+ $t->timestamps();
+ });
+ Schema::create('event_registrations', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('event_id')->constrained()->cascadeOnDelete();
+ $t->string('name');
+ $t->string('email')->index();
+ $t->unsignedInteger('quantity')->default(1);
+ $t->string('status')->default('confirmed')->index();
+ $t->text('notes')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('event_registrations');
+ Schema::dropIfExists('events');
+ Schema::dropIfExists('event_venues');
+ }
+};
diff --git a/Events/Http/Controllers/EventController.php b/Events/Http/Controllers/EventController.php
new file mode 100644
index 0000000..5668979
--- /dev/null
+++ b/Events/Http/Controllers/EventController.php
@@ -0,0 +1,32 @@
+ Event::with(['venue'])->withCount('registrations')->orderBy('starts_at')->paginate(20), 'venues' => Venue::orderBy('name')->get()]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Event::create($r->validate(['venue_id' => ['nullable', 'exists:event_venues,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:events,slug'], 'summary' => ['nullable', 'string'], 'description' => ['nullable', 'string'], 'starts_at' => ['required', 'date'], 'ends_at' => ['nullable', 'date', 'after:starts_at'], 'status' => ['required', 'in:draft,published,cancelled,completed'], 'capacity' => ['nullable', 'integer', 'min:1'], 'price' => ['required', 'numeric', 'min:0']]) + ['registration_open' => $r->boolean('registration_open')]);
+
+ return back()->with('status', 'Event created.');
+ }
+
+ public function destroy(Event $event): RedirectResponse
+ {
+ $event->delete();
+
+ return back()->with('status', 'Event deleted.');
+ }
+}
diff --git a/Events/Models/Event.php b/Events/Models/Event.php
new file mode 100644
index 0000000..cb1d6d4
--- /dev/null
+++ b/Events/Models/Event.php
@@ -0,0 +1,27 @@
+ 'datetime', 'ends_at' => 'datetime', 'price' => 'decimal:2', 'registration_open' => 'boolean'];
+ }
+
+ public function venue(): BelongsTo
+ {
+ return $this->belongsTo(Venue::class);
+ }
+
+ public function registrations(): HasMany
+ {
+ return $this->hasMany(Registration::class);
+ }
+}
diff --git a/Events/Models/Registration.php b/Events/Models/Registration.php
new file mode 100644
index 0000000..1ea8d7f
--- /dev/null
+++ b/Events/Models/Registration.php
@@ -0,0 +1,12 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Events', 'Config/config.php'), 'events');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Events', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Events', 'Resources/views'), 'events');
+ }
+}
diff --git a/Events/Providers/RouteServiceProvider.php b/Events/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ca21554
--- /dev/null
+++ b/Events/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Events', 'Routes/web.php'));
+ }
+}
diff --git a/Events/Resources/views/admin/index.blade.php b/Events/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..8cab5c2
--- /dev/null
+++ b/Events/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Events Manage events, venues and registrations.
@if(session('status'))
{{ session('status') }}
@endif
@csrfCreate event Draft Published Cancelled
Create event Event Starts Status Registrations @forelse($events as $event){{ $event->title }} {{ $event->starts_at->format('j M Y H:i') }} {{ ucfirst($event->status) }} {{ $event->registrations_count }} @csrf @method('DELETE')Delete @emptyNo events yet. @endforelse
{{ $events->links() }}
diff --git a/Events/Routes/web.php b/Events/Routes/web.php
new file mode 100644
index 0000000..7f27dfa
--- /dev/null
+++ b/Events/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/events')->name('administration.events.')->group(function (): void {
+ Route::get('/', [EventController::class, 'index'])->name('index');
+ Route::post('/', [EventController::class, 'store'])->name('store');
+ Route::delete('/{event}', [EventController::class, 'destroy'])->name('destroy');
+});
diff --git a/Events/Tests/Feature/ModuleBootTest.php b/Events/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..27cd6b4
--- /dev/null
+++ b/Events/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Events', config('events.name'));
+ }
+}
diff --git a/Events/module.json b/Events/module.json
new file mode 100644
index 0000000..7990472
--- /dev/null
+++ b/Events/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Events",
+ "alias": "events",
+ "version": "1.0.0",
+ "description": "Events, venues and registration management.",
+ "keywords": ["business", "events"],
+ "priority": 42,
+ "providers": ["Modules\\Events\\Providers\\EventsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Faq/Config/config.php b/Faq/Config/config.php
new file mode 100644
index 0000000..47a7171
--- /dev/null
+++ b/Faq/Config/config.php
@@ -0,0 +1,3 @@
+ 'Faq'];
diff --git a/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php b/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php
new file mode 100644
index 0000000..504d804
--- /dev/null
+++ b/Faq/Database/Migrations/2026_07_24_000700_create_faqs_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->string('question');
+ $table->longText('answer');
+ $table->string('category')->nullable();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('faqs');
+ }
+};
diff --git a/Faq/Http/Controllers/FaqController.php b/Faq/Http/Controllers/FaqController.php
new file mode 100644
index 0000000..b38040d
--- /dev/null
+++ b/Faq/Http/Controllers/FaqController.php
@@ -0,0 +1,60 @@
+ Faq::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('faq::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['question' => ['required', 'string', 'max:255'],
+ 'answer' => ['required', 'string'],
+ 'category' => ['nullable', 'string'],
+ 'sort_order' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Faq::create($data);
+
+ return redirect()->route('administration.faq.index')->with('status', 'Faq created.');
+ }
+
+ public function edit(Faq $faq): View
+ {
+ return view('faq::admin.form', ['record' => $faq]);
+ }
+
+ public function update(Request $request, Faq $faq): RedirectResponse
+ {
+ $data = $request->validate(['question' => ['required', 'string', 'max:255'],
+ 'answer' => ['required', 'string'],
+ 'category' => ['nullable', 'string'],
+ 'sort_order' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $faq->update($data);
+
+ return redirect()->route('administration.faq.index')->with('status', 'Faq updated.');
+ }
+
+ public function destroy(Faq $faq): RedirectResponse
+ {
+ $faq->delete();
+
+ return back()->with('status', 'Faq deleted.');
+ }
+}
diff --git a/Faq/Models/Faq.php b/Faq/Models/Faq.php
new file mode 100644
index 0000000..b2011f3
--- /dev/null
+++ b/Faq/Models/Faq.php
@@ -0,0 +1,24 @@
+ 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Faq/Providers/FaqServiceProvider.php b/Faq/Providers/FaqServiceProvider.php
new file mode 100644
index 0000000..43f9071
--- /dev/null
+++ b/Faq/Providers/FaqServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Faq', 'Config/config.php'), 'faq');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Faq', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Faq', 'Resources/views'), 'faq');
+ }
+}
diff --git a/Faq/Providers/RouteServiceProvider.php b/Faq/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..8c5175d
--- /dev/null
+++ b/Faq/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Faq', 'Routes/web.php')));
+ }
+}
diff --git a/Faq/Resources/views/admin/form.blade.php b/Faq/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..9998104
--- /dev/null
+++ b/Faq/Resources/views/admin/form.blade.php
@@ -0,0 +1,9 @@
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Faq @csrf @isset($record) @method('PUT') @endisset
+Question @error('question')
{{ $message }}
@enderror
+Answer {{ old('answer', $record->answer ?? '') }} @error('answer')
{{ $message }}
@enderror
+Category @error('category')
{{ $message }}
@enderror
+Sort Order @error('sort_order')
{{ $message }}
@enderror
+ enabled ?? true))>Enabled
+
+
diff --git a/Faq/Resources/views/admin/index.blade.php b/Faq/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..5bceb8c
--- /dev/null
+++ b/Faq/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+
+@if(session('status'))
{{ session('status') }}
@endif
+
Question Answer Category Actions @forelse($records as $record){{ is_array($record->question) ? json_encode($record->question) : Str::limit((string) $record->question, 70) }} {{ is_array($record->answer) ? json_encode($record->answer) : Str::limit((string) $record->answer, 70) }} {{ is_array($record->category) ? json_encode($record->category) : Str::limit((string) $record->category, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Faq/Routes/web.php b/Faq/Routes/web.php
new file mode 100644
index 0000000..031dc8b
--- /dev/null
+++ b/Faq/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/faq')->name('administration.faq.')->group(function (): void {
+ Route::get('/', [FaqController::class, 'index'])->name('index');
+ Route::get('/create', [FaqController::class, 'create'])->name('create');
+ Route::post('/', [FaqController::class, 'store'])->name('store');
+ Route::get('/{faq}/edit', [FaqController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{faq}', [FaqController::class, 'update'])->name('update');
+ Route::delete('/{faq}', [FaqController::class, 'destroy'])->name('destroy');
+});
diff --git a/Faq/Support/ModuleInfo.php b/Faq/Support/ModuleInfo.php
new file mode 100644
index 0000000..34b932c
--- /dev/null
+++ b/Faq/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Faq/module.json b/Faq/module.json
new file mode 100644
index 0000000..1996222
--- /dev/null
+++ b/Faq/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Faq",
+ "alias": "faq",
+ "version": "1.0.0",
+ "description": "Faq CMS module.",
+ "keywords": [
+ "cms",
+ "faq"
+ ],
+ "priority": 27,
+ "providers": [
+ "Modules\\Faq\\Providers\\FaqServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Feeds/Config/config.php b/Feeds/Config/config.php
new file mode 100644
index 0000000..166f718
--- /dev/null
+++ b/Feeds/Config/config.php
@@ -0,0 +1,3 @@
+ 'Feeds'];
diff --git a/Feeds/Http/Controllers/FeedController.php b/Feeds/Http/Controllers/FeedController.php
new file mode 100644
index 0000000..8f2fab8
--- /dev/null
+++ b/Feeds/Http/Controllers/FeedController.php
@@ -0,0 +1,21 @@
+view('feeds::rss', ['title' => config('app.name').' Blog', 'records' => Blog::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'blog.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8');
+ }
+
+ public function news(): Response
+ {
+ return response()->view('feeds::rss', ['title' => config('app.name').' News', 'records' => News::published()->latest('published_at')->limit(50)->get(), 'routeName' => 'news.public.show'])->header('Content-Type', 'application/rss+xml; charset=UTF-8');
+ }
+}
diff --git a/Feeds/Providers/FeedsServiceProvider.php b/Feeds/Providers/FeedsServiceProvider.php
new file mode 100644
index 0000000..c1200c7
--- /dev/null
+++ b/Feeds/Providers/FeedsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Feeds', 'Config/config.php'), 'feeds');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Feeds', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Feeds', 'Resources/views'), 'feeds');
+ }
+}
diff --git a/Feeds/Providers/RouteServiceProvider.php b/Feeds/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..89c193a
--- /dev/null
+++ b/Feeds/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Feeds', 'Routes/web.php'));
+ }
+}
diff --git a/Feeds/Resources/views/rss.blade.php b/Feeds/Resources/views/rss.blade.php
new file mode 100644
index 0000000..e516351
--- /dev/null
+++ b/Feeds/Resources/views/rss.blade.php
@@ -0,0 +1 @@
+{!! '' !!}{{ $title }} {{ url('/') }}{{ $title }} @foreach($records as $record){{ $record->title }} {{ route($routeName, $record->slug) }}{{ route($routeName, $record->slug) }} excerpt }}]]> {{ optional($record->published_at)->toRssString() }} @endforeach
diff --git a/Feeds/Routes/web.php b/Feeds/Routes/web.php
new file mode 100644
index 0000000..b9ba81f
--- /dev/null
+++ b/Feeds/Routes/web.php
@@ -0,0 +1,7 @@
+name('feeds.blog');
+Route::get('/feeds/news.xml', [FeedController::class, 'news'])->name('feeds.news');
diff --git a/Feeds/Tests/Feature/ModuleBootTest.php b/Feeds/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..7b90716
--- /dev/null
+++ b/Feeds/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Feeds', config('feeds.name'));
+ }
+}
diff --git a/Feeds/module.json b/Feeds/module.json
new file mode 100644
index 0000000..9569047
--- /dev/null
+++ b/Feeds/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Feeds",
+ "alias": "feeds",
+ "version": "1.0.0",
+ "description": "Feeds publishing module.",
+ "keywords": ["publishing", "feeds"],
+ "priority": 30,
+ "providers": ["Modules\\Feeds\\Providers\\FeedsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Forms/Config/config.php b/Forms/Config/config.php
new file mode 100644
index 0000000..ba58e72
--- /dev/null
+++ b/Forms/Config/config.php
@@ -0,0 +1,3 @@
+ 'Forms'];
diff --git a/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php b/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php
new file mode 100644
index 0000000..aee029b
--- /dev/null
+++ b/Forms/Database/Migrations/2026_07_24_000400_create_forms_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->string('name');
+ $table->string('slug');
+ $table->text('description')->nullable();
+ $table->json('schema')->nullable();
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('forms');
+ }
+};
diff --git a/Forms/Http/Controllers/FormController.php b/Forms/Http/Controllers/FormController.php
new file mode 100644
index 0000000..eacb1b8
--- /dev/null
+++ b/Forms/Http/Controllers/FormController.php
@@ -0,0 +1,66 @@
+ Form::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('forms::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'slug' => ['required', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'schema' => ['nullable', 'array'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('schema_json')) {
+ $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ Form::create($data);
+
+ return redirect()->route('administration.forms.index')->with('status', 'Form created.');
+ }
+
+ public function edit(Form $form): View
+ {
+ return view('forms::admin.form', ['record' => $form]);
+ }
+
+ public function update(Request $request, Form $form): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'slug' => ['required', 'string', 'max:255'],
+ 'description' => ['nullable', 'string'],
+ 'schema' => ['nullable', 'array'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ if ($request->filled('schema_json')) {
+ $data['schema'] = json_decode($request->string('schema_json')->toString(), true, 512, JSON_THROW_ON_ERROR);
+ }
+ $data['enabled'] = $request->boolean('enabled');
+ $form->update($data);
+
+ return redirect()->route('administration.forms.index')->with('status', 'Form updated.');
+ }
+
+ public function destroy(Form $form): RedirectResponse
+ {
+ $form->delete();
+
+ return back()->with('status', 'Form deleted.');
+ }
+}
diff --git a/Forms/Models/Form.php b/Forms/Models/Form.php
new file mode 100644
index 0000000..d72aec6
--- /dev/null
+++ b/Forms/Models/Form.php
@@ -0,0 +1,24 @@
+ 'array',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Forms/Providers/FormsServiceProvider.php b/Forms/Providers/FormsServiceProvider.php
new file mode 100644
index 0000000..53ce6d1
--- /dev/null
+++ b/Forms/Providers/FormsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Forms', 'Config/config.php'), 'forms');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Forms', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Forms', 'Resources/views'), 'forms');
+ }
+}
diff --git a/Forms/Providers/RouteServiceProvider.php b/Forms/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..1d81fce
--- /dev/null
+++ b/Forms/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Forms', 'Routes/web.php')));
+ }
+}
diff --git a/Forms/Resources/views/admin/form.blade.php b/Forms/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..12e5a85
--- /dev/null
+++ b/Forms/Resources/views/admin/form.blade.php
@@ -0,0 +1,32 @@
+
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Form
+
@csrf @isset($record)
+ @method('PUT')
+ @endisset
+ Name @error('name')
+
{{ $message }}
@enderror
+ Slug @error('slug')
+
{{ $message }}
@enderror
+ Description {{ old('description', $record->description ?? '') }} @error('description')
+
{{ $message }}
@enderror
+ Schema (JSON) {{ old('schema_json', isset($record) ? json_encode($record->schema, JSON_PRETTY_PRINT) : '') }}
+
+ enabled ?? true))>Enabled
+
+
+
+
diff --git a/Forms/Resources/views/admin/index.blade.php b/Forms/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..a8dd83d
--- /dev/null
+++ b/Forms/Resources/views/admin/index.blade.php
@@ -0,0 +1,43 @@
+
+
+
+
Forms
+
Manage forms content.
+
Add new
+
+ @if(session('status'))
+
{{ session('status') }}
+ @endif
+
+
+
+
+ Name
+ Slug
+ Description
+ Actions
+
+
+ @forelse($records as $record)
+
+ {{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }}
+ {{ is_array($record->slug) ? json_encode($record->slug) : Str::limit((string) $record->slug, 70) }}
+ {{ is_array($record->description) ? json_encode($record->description) : Str::limit((string) $record->description, 70) }}
+ Edit
+ @csrf @method('DELETE')
+ Delete
+
+
+
+
+ @empty
+
+ No records yet.
+
+ @endforelse
+
+
{{ $records->links() }}
+
diff --git a/Forms/Routes/web.php b/Forms/Routes/web.php
new file mode 100644
index 0000000..4da162d
--- /dev/null
+++ b/Forms/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/forms')->name('administration.forms.')->group(function (): void {
+ Route::get('/', [FormController::class, 'index'])->name('index');
+ Route::get('/create', [FormController::class, 'create'])->name('create');
+ Route::post('/', [FormController::class, 'store'])->name('store');
+ Route::get('/{form}/edit', [FormController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{form}', [FormController::class, 'update'])->name('update');
+ Route::delete('/{form}', [FormController::class, 'destroy'])->name('destroy');
+});
diff --git a/Forms/Support/ModuleInfo.php b/Forms/Support/ModuleInfo.php
new file mode 100644
index 0000000..916091c
--- /dev/null
+++ b/Forms/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Forms/module.json b/Forms/module.json
new file mode 100644
index 0000000..b4450a2
--- /dev/null
+++ b/Forms/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Forms",
+ "alias": "forms",
+ "version": "1.0.0",
+ "description": "Forms CMS module.",
+ "keywords": [
+ "cms",
+ "forms"
+ ],
+ "priority": 24,
+ "providers": [
+ "Modules\\Forms\\Providers\\FormsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Licensing/Config/.gitkeep b/Licensing/Config/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Config/config.php b/Licensing/Config/config.php
new file mode 100644
index 0000000..3b94eda
--- /dev/null
+++ b/Licensing/Config/config.php
@@ -0,0 +1,8 @@
+ 'Licensing',
+ 'features' =>
+ [
+ ],
+];
diff --git a/Licensing/Console/.gitkeep b/Licensing/Console/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Console/SonarDeskLicenseDeliver.php b/Licensing/Console/SonarDeskLicenseDeliver.php
new file mode 100644
index 0000000..67e5050
--- /dev/null
+++ b/Licensing/Console/SonarDeskLicenseDeliver.php
@@ -0,0 +1,20 @@
+option('limit'), 100));
+ $count = $delivery->deliverPending($limit);
+ $this->info("Delivered {$count} licence email(s).");
+ return self::SUCCESS;
+ }
+}
diff --git a/Licensing/Console/SonarDeskLicenseIssue.php b/Licensing/Console/SonarDeskLicenseIssue.php
new file mode 100755
index 0000000..12469cb
--- /dev/null
+++ b/Licensing/Console/SonarDeskLicenseIssue.php
@@ -0,0 +1,29 @@
+argument('product'))->firstOrFail();
+ [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
+ if (! $key) {
+ $this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
+
+ return self::FAILURE;
+ } $this->line('Licence ID: '.$l->id);
+ $this->line('Licence key: '.$key);
+ $this->warn('This is the only time the plaintext key is shown.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Licensing/Console/SonarDeskLicenseRevoke.php b/Licensing/Console/SonarDeskLicenseRevoke.php
new file mode 100755
index 0000000..ad34c16
--- /dev/null
+++ b/Licensing/Console/SonarDeskLicenseRevoke.php
@@ -0,0 +1,22 @@
+argument('id'));
+ $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
+ $this->info('Licence revoked.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Licensing/Console/SonarDeskProductCreate.php b/Licensing/Console/SonarDeskProductCreate.php
new file mode 100755
index 0000000..8622a85
--- /dev/null
+++ b/Licensing/Console/SonarDeskProductCreate.php
@@ -0,0 +1,21 @@
+ $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
+ $this->info("Product {$p->code} ready.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Licensing/Database/Migrations/.gitkeep b/Licensing/Database/Migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php
new file mode 100755
index 0000000..babc7a3
--- /dev/null
+++ b/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php
@@ -0,0 +1,92 @@
+id();
+ $table->string('code')->unique();
+ $table->string('name');
+ $table->string('edition')->default('Professional');
+ $table->unsignedInteger('activation_limit')->default(1);
+ $table->unsignedInteger('max_major_version')->nullable();
+ $table->string('payment_provider')->nullable();
+ $table->string('external_product_id')->nullable();
+ $table->string('external_price_id')->nullable();
+ $table->boolean('active')->default(true);
+ $table->timestamps();
+ $table->unique(['payment_provider', 'external_price_id']);
+ });
+
+ Schema::create('licenses', function (Blueprint $table) {
+ $table->uuid('id')->primary();
+ $table->foreignId('license_product_id')->constrained('license_products');
+ $table->string('license_key_hash', 64)->unique();
+ $table->string('license_key_last4', 4);
+ $table->string('customer_name')->nullable();
+ $table->string('customer_email')->nullable()->index();
+ $table->string('edition');
+ $table->unsignedInteger('activation_limit');
+ $table->unsignedInteger('max_major_version')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('revoked_at')->nullable();
+ $table->string('revoke_reason')->nullable();
+ $table->string('payment_provider')->nullable();
+ $table->string('external_order_id')->nullable();
+ $table->string('external_customer_id')->nullable();
+ $table->timestamps();
+ $table->unique(['payment_provider', 'external_order_id']);
+ });
+
+ Schema::create('license_delivery_outbox', function (Blueprint $table) {
+ $table->id();
+ $table->uuid('license_id')->unique();
+ $table->text('encrypted_license_key');
+ $table->string('recipient_email')->nullable();
+ $table->timestamp('delivered_at')->nullable();
+ $table->timestamps();
+ $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
+ });
+
+ Schema::create('license_activations', function (Blueprint $table) {
+ $table->id();
+ $table->uuid('license_id');
+ $table->uuid('installation_id');
+ $table->string('platform', 32)->nullable();
+ $table->string('app_version', 32)->nullable();
+ $table->timestamp('activated_at');
+ $table->timestamp('last_seen_at')->nullable();
+ $table->timestamp('deactivated_at')->nullable();
+ $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
+ $table->unique(['license_id', 'installation_id']);
+ $table->index(['license_id', 'deactivated_at']);
+ });
+
+ Schema::create('license_webhook_events', function (Blueprint $table) {
+ $table->id();
+ $table->string('provider');
+ $table->string('external_event_id');
+ $table->string('event_type');
+ $table->string('payload_sha256', 64);
+ $table->json('payload');
+ $table->timestamp('processed_at')->nullable();
+ $table->text('processing_error')->nullable();
+ $table->timestamps();
+ $table->unique(['provider', 'external_event_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('license_webhook_events');
+ Schema::dropIfExists('license_activations');
+ Schema::dropIfExists('license_delivery_outbox');
+ Schema::dropIfExists('licenses');
+ Schema::dropIfExists('license_products');
+ }
+};
diff --git a/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php
new file mode 100755
index 0000000..b302e12
--- /dev/null
+++ b/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php
@@ -0,0 +1,30 @@
+ $table->boolean('is_admin')->default(false)->index());
+ }
+
+ if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
+ Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
+ Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
+ }
+
+ if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
+ Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
+ }
+ }
+};
diff --git a/Licensing/Database/Seeders/.gitkeep b/Licensing/Database/Seeders/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/Licensing/Database/Seeders/LicensingDatabaseSeeder.php
new file mode 100644
index 0000000..fbb0b58
--- /dev/null
+++ b/Licensing/Database/Seeders/LicensingDatabaseSeeder.php
@@ -0,0 +1,21 @@
+call("OthersTableSeeder");
+ }
+}
diff --git a/Licensing/Database/factories/.gitkeep b/Licensing/Database/factories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Helpers/.gitkeep b/Licensing/Helpers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Helpers/Helper.php b/Licensing/Helpers/Helper.php
new file mode 100644
index 0000000..7607c60
--- /dev/null
+++ b/Licensing/Helpers/Helper.php
@@ -0,0 +1,12 @@
+ License::query()->count(),
+ 'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void {
+ $q->whereNull('expires_at')->orWhere('expires_at', '>', now());
+ })->count(),
+ 'productCount' => LicenseProduct::query()->count(),
+ 'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(),
+ ]);
+ }
+
+ public function licenses(): View
+ {
+ return view('licensing::admin.licenses', [
+ 'licenses' => License::query()->with('product')->latest()->paginate(25),
+ ]);
+ }
+
+ public function show(License $license): View
+ {
+ return view('licensing::admin.show', [
+ 'license' => $license->load(['product', 'activations']),
+ ]);
+ }
+}
diff --git a/Licensing/Http/Controllers/Api/LicenseActivationController.php b/Licensing/Http/Controllers/Api/LicenseActivationController.php
new file mode 100755
index 0000000..7580871
--- /dev/null
+++ b/Licensing/Http/Controllers/Api/LicenseActivationController.php
@@ -0,0 +1,164 @@
+validate([
+ 'licenseKey' => ['required', 'string', 'max:128'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
+ ]);
+
+ return DB::transaction(function () use ($data, $signer) {
+ $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
+ ->lockForUpdate()
+ ->first();
+
+ if (! $license) {
+ throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
+ }
+
+ if (! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $installationID = strtolower($data['installationID']);
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', $installationID)
+ ->first();
+
+ if ($activation && $activation->deactivated_at === null) {
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ $activeCount = LicenseActivation::where('license_id', $license->id)
+ ->whereNull('deactivated_at')
+ ->count();
+
+ if ($activeCount >= $license->activation_limit) {
+ return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
+ }
+
+ if ($activation) {
+ $activation->update([
+ 'deactivated_at' => null,
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ LicenseActivation::create([
+ 'license_id' => $license->id,
+ 'installation_id' => $installationID,
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ ]);
+ }
+ }
+
+ return response()->json([
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
+ });
+ }
+
+ public function validateInstallation(
+ Request $request,
+ LicenseTokenVerifier $verifier,
+ LicenseSigner $signer,
+ ): JsonResponse {
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ return DB::transaction(function () use ($data, $payload, $signer) {
+ $license = License::whereKey((string) ($payload['licenseID'] ?? ''))
+ ->lockForUpdate()
+ ->first();
+
+ if (! $license || ! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', strtolower($data['installationID']))
+ ->first();
+
+ if (! $activation || $activation->deactivated_at !== null) {
+ return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
+ }
+
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+
+ return response()->json([
+ 'active' => true,
+ // Refreshing the signed token also applies subscription edition/expiry changes
+ // without requiring the user to reactivate manually.
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
+ });
+ }
+
+ public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
+ {
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
+ ->where('installation_id', strtolower($data['installationID']))
+ ->whereNull('deactivated_at')
+ ->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
+
+ return response()->json((object) []);
+ }
+}
diff --git a/Licensing/Http/Controllers/Api/PaymentWebhookController.php b/Licensing/Http/Controllers/Api/PaymentWebhookController.php
new file mode 100755
index 0000000..61d9512
--- /dev/null
+++ b/Licensing/Http/Controllers/Api/PaymentWebhookController.php
@@ -0,0 +1,191 @@
+getContent();
+
+ $verifier = match ($provider) {
+ 'paddle' => app(PaddleWebhookVerifier::class),
+ 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
+ default => null,
+ };
+
+ if (! $verifier) {
+ return response()->json([
+ 'message' => 'Unknown webhook provider.',
+ ], 404);
+ }
+
+ try {
+ $verifier->verify($request, $raw);
+ } catch (Throwable $e) {
+ report($e);
+
+ return response()->json([
+ 'message' => 'Invalid webhook signature.',
+ ], 400);
+ }
+
+ try {
+ $payload = json_decode(
+ $raw,
+ true,
+ flags: JSON_THROW_ON_ERROR
+ );
+ } catch (Throwable) {
+ return response()->json([
+ 'message' => 'Invalid JSON.',
+ ], 400);
+ }
+
+ $externalID = (string) (
+ $provider === 'paddle'
+ ? ($payload['event_id'] ?? '')
+ : ($payload['meta']['event_name'] ?? '')
+ .':'
+ .($payload['data']['id'] ?? '')
+ );
+
+ $eventType = (string) (
+ $provider === 'paddle'
+ ? ($payload['event_type'] ?? 'unknown')
+ : ($payload['meta']['event_name'] ?? 'unknown')
+ );
+
+ if ($externalID === '') {
+ return response()->json([
+ 'message' => 'Webhook event has no stable identifier.',
+ ], 400);
+ }
+
+ $payloadHash = hash('sha256', $raw);
+
+ try {
+ /*
+ * Paddle can deliver several related events concurrently.
+ *
+ * Laravel retries this complete transaction when MySQL reports
+ * a deadlock. Five attempts gives short-lived lock contention
+ * enough opportunity to resolve without acknowledging an event
+ * that was not actually processed.
+ */
+ DB::transaction(
+ function () use (
+ $provider,
+ $externalID,
+ $eventType,
+ $payload,
+ $payloadHash,
+ $provisioner
+ ): void {
+ /*
+ * Insert the event if this is its first delivery.
+ *
+ * Once present, lock the row so duplicate deliveries of
+ * the same event cannot provision concurrently.
+ */
+ LicenseWebhookEvent::firstOrCreate(
+ [
+ 'provider' => $provider,
+ 'external_event_id' => $externalID,
+ ],
+ [
+ 'event_type' => $eventType,
+ 'payload_sha256' => $payloadHash,
+ 'payload' => $payload,
+ ]
+ );
+
+ $event = LicenseWebhookEvent::query()
+ ->where('provider', $provider)
+ ->where('external_event_id', $externalID)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ if ($event->processed_at) {
+ return;
+ }
+
+ if (! hash_equals(
+ (string) $event->payload_sha256,
+ $payloadHash
+ )) {
+ throw new RuntimeException(
+ 'Webhook ID was reused with a different payload.'
+ );
+ }
+
+ try {
+ $provisioner->provision(
+ $provider,
+ $payload
+ );
+
+ $event->processed_at = now();
+ $event->processing_error = null;
+ $event->save();
+ } catch (Throwable $e) {
+ /*
+ * This assignment is useful when the surrounding
+ * transaction eventually succeeds. If processing
+ * throws, the transaction itself is rolled back and
+ * Paddle will retry the event.
+ */
+ $event->processing_error = mb_substr(
+ $e->getMessage(),
+ 0,
+ 4000
+ );
+
+ $event->save();
+
+ throw $e;
+ }
+ },
+ 5
+ );
+ } catch (Throwable $e) {
+ report($e);
+ return response()->json([
+ 'message' => 'Webhook could not be processed.',
+ ], 500);
+ }
+
+ /*
+ * Delivery deliberately happens outside the webhook transaction.
+ *
+ * A temporary SMTP failure must not undo a successfully processed
+ * payment event. The outbox retains anything that still needs
+ * delivery.
+ */
+ try {
+ $delivery->deliverPending(10);
+ } catch (Throwable $e) {
+ report($e);
+ }
+
+ return response()->json([
+ 'ok' => true,
+ ]);
+ }
+}
diff --git a/Licensing/Http/Controllers/LicensingController.php b/Licensing/Http/Controllers/LicensingController.php
new file mode 100644
index 0000000..8ece907
--- /dev/null
+++ b/Licensing/Http/Controllers/LicensingController.php
@@ -0,0 +1,79 @@
+whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
+ }
+
+ public function index(Request $request)
+ {
+ $licenses = $this->owned($request)->latest()->get();
+ $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
+
+ return view('portal.index', compact('licenses', 'hasProfessional'));
+ }
+
+ public function show(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $license->load(['product', 'activations']);
+
+ return view('portal.show', compact('license'));
+ }
+
+ public function upgrade(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
+
+ $targetPriceID = $this->professionalPriceFor($license);
+ $targetProduct = LicenseProduct::query()
+ ->where('payment_provider', 'paddle')
+ ->where('external_price_id', $targetPriceID)
+ ->where('active', true)
+ ->firstOrFail();
+
+ try {
+ $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ // Apply the entitlement immediately. Paddle webhooks remain authoritative and
+ // will reconcile subscription status/current-period details afterwards.
+ $license->update([
+ 'license_product_id' => $targetProduct->id,
+ 'edition' => $targetProduct->edition,
+ 'activation_limit' => $targetProduct->activation_limit,
+ 'max_major_version' => $targetProduct->max_major_version,
+ 'external_price_id' => $targetPriceID,
+ 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
+ 'revoked_at' => null,
+ 'revoke_reason' => null,
+ ]);
+
+ return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
+ }
+
+ public function cancel(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
+
+ try {
+ $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ $license->update([
+ 'subscription_status' => 'canceled',
+ 'expires_at' => now(),
+ 'revoked_at' => now(),
+ 'revoke_reason' => 'Subscription canceled from licence portal',
+ 'cancel_scheduled_at' => null,
+ ]);
+
+ return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
+ }
+
+ public function deactivate(Request $request, License $license, int $activation)
+ {
+ $this->authorise($request, $license);
+ $item = $license->activations()->whereKey($activation)->firstOrFail();
+ if (! $item->deactivated_at) {
+ $item->update(['deactivated_at' => now()]);
+ }
+
+ return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.');
+ }
+
+ public function resend(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
+ if (! $out) {
+ return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
+ }
+
+ $out->update([
+ 'recipient_email' => strtolower($request->user()->email),
+ 'delivered_at' => null,
+ ]);
+ $this->delivery->deliverPending(1);
+
+ return back()->with('status', 'Licence key sent to your verified email address.');
+ }
+
+ private function authorise(Request $request, License $license): void
+ {
+ abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
+ }
+
+ private function professionalPriceFor(License $license): string
+ {
+ $paddle = config('paddle');
+ $current = (string) $license->external_price_id;
+
+ if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
+ return (string) ($paddle['professional_annual_price_id'] ?? '');
+ }
+
+ return (string) ($paddle['professional_monthly_price_id'] ?? '');
+ }
+}
diff --git a/Licensing/Http/Middleware/.gitkeep b/Licensing/Http/Middleware/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Http/Requests/.gitkeep b/Licensing/Http/Requests/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Models/.gitkeep b/Licensing/Models/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Models/License.php b/Licensing/Models/License.php
new file mode 100755
index 0000000..86e5c6a
--- /dev/null
+++ b/Licensing/Models/License.php
@@ -0,0 +1,36 @@
+ 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(LicenseProduct::class, 'license_product_id');
+ }
+
+ public function activations(): HasMany
+ {
+ return $this->hasMany(LicenseActivation::class);
+ }
+
+ public function getIsUsableAttribute(): bool
+ {
+ return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
+ }
+}
diff --git a/Licensing/Models/LicenseActivation.php b/Licensing/Models/LicenseActivation.php
new file mode 100755
index 0000000..fb454a0
--- /dev/null
+++ b/Licensing/Models/LicenseActivation.php
@@ -0,0 +1,14 @@
+ 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
+}
diff --git a/Licensing/Models/LicenseDeliveryOutbox.php b/Licensing/Models/LicenseDeliveryOutbox.php
new file mode 100755
index 0000000..89d1d5f
--- /dev/null
+++ b/Licensing/Models/LicenseDeliveryOutbox.php
@@ -0,0 +1,20 @@
+ 'datetime'];
+
+ public function license(): BelongsTo
+ {
+ return $this->belongsTo(License::class);
+ }
+}
diff --git a/Licensing/Models/LicenseProduct.php b/Licensing/Models/LicenseProduct.php
new file mode 100755
index 0000000..eb8a4cc
--- /dev/null
+++ b/Licensing/Models/LicenseProduct.php
@@ -0,0 +1,12 @@
+ 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
+}
diff --git a/Licensing/Models/LicenseWebhookEvent.php b/Licensing/Models/LicenseWebhookEvent.php
new file mode 100755
index 0000000..a8cc130
--- /dev/null
+++ b/Licensing/Models/LicenseWebhookEvent.php
@@ -0,0 +1,12 @@
+ 'array', 'processed_at' => 'datetime'];
+}
diff --git a/Licensing/Providers/.gitkeep b/Licensing/Providers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Licensing/Providers/IntegratedLicensingServiceProvider.php
new file mode 100644
index 0000000..d55e06b
--- /dev/null
+++ b/Licensing/Providers/IntegratedLicensingServiceProvider.php
@@ -0,0 +1,35 @@
+ [
+ Limit::perMinute(12)->by($request->ip()),
+ Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
+ ]);
+
+ $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
+ $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing');
+ $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing');
+
+ // Explicit role-based authorization; deny access by default.
+ Gate::define('manage-sonardesk-licensing', static fn (User $user): bool =>
+ $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator'])
+ );
+
+ Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php');
+ Route::group([], __DIR__.'/../Routes/admin.php');
+ }
+}
diff --git a/Licensing/Providers/LicensingServiceProvider.php b/Licensing/Providers/LicensingServiceProvider.php
new file mode 100644
index 0000000..c514a59
--- /dev/null
+++ b/Licensing/Providers/LicensingServiceProvider.php
@@ -0,0 +1,171 @@
+ Limit::perMinute(5)
+ ->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
+
+ RateLimiter::for('registration', fn (Request $request) =>
+ Limit::perHour(5)->by($request->ip())
+ );
+
+ RateLimiter::for('license-activation', function (Request $request): array {
+ return [
+ Limit::perMinute(12)->by($request->ip()),
+ Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
+ ];
+ });
+ $this->registerTranslations();
+ $this->registerConfig();
+ $this->registerViews();
+ $this->registerHelpers();
+ $this->registerCommands();
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ }
+
+ /**
+ * Register the service provider.
+ *
+ * @return void
+ */
+ public function register(): void
+ {
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ /**
+ * Register the helpers.
+ *
+ * @return void
+ */
+ protected function registerHelpers(): void
+ {
+ foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
+ require_once $filename;
+ }
+ }
+
+ /**
+ * Dynamically Register Commands.
+ *
+ * @return void
+ */
+ public function registerCommands(): void
+ {
+ if (!$this->app->runningInConsole()) {
+ return;
+ }
+ $commandPath = __DIR__ . '/../Console';
+ $commands = collect(File::allFiles($commandPath))
+ ->map(function ($file) {
+ $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
+ return $class;
+ })
+ ->filter(function ($class) {
+ return class_exists($class);
+ })
+ ->values()
+ ->all();
+ $this->commands($commands);
+ }
+
+ /**
+ * Register config.
+ *
+ * @return void
+ */
+ protected function registerConfig(): void
+ {
+ $this->publishes([
+ module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
+ ], 'config');
+ $this->mergeConfigFrom(
+ module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
+ );
+ }
+
+ /**
+ * Register views.
+ *
+ * @return void
+ */
+ public function registerViews(): void
+ {
+ $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
+ $sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
+ $this->publishes([
+ $sourcePath => $viewPath
+ ], ['views', ModuleInfo::nameLower() . '-module-views']);
+ $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
+ }
+
+ /**
+ * Register translations.
+ *
+ * @return void
+ */
+ public function registerTranslations(): void
+ {
+ $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
+
+ if (is_dir($langPath)) {
+ $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
+ } else {
+ $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
+ }
+ }
+
+ /**
+ * Regsiter View Paths
+ * @return array
+ */
+ private function getPublishableViewPaths(): array
+ {
+ $paths = [];
+ foreach (Config::get('view.paths') as $path) {
+ if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
+ $paths[] = $path . '/modules/' . ModuleInfo::nameLower();
+ }
+ }
+ return $paths;
+ }
+}
diff --git a/Licensing/Providers/RouteServiceProvider.php b/Licensing/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c691ba8
--- /dev/null
+++ b/Licensing/Providers/RouteServiceProvider.php
@@ -0,0 +1,69 @@
+mapApiRoutes();
+
+ $this->mapWebRoutes();
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapWebRoutes()
+ {
+ Route::middleware('web')
+ ->namespace($this->moduleNamespace)
+ ->group(module_path('Licensing', '/Routes/web.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ *
+ * These routes are typically stateless.
+ *
+ * @return void
+ */
+ protected function mapApiRoutes()
+ {
+ Route::prefix('api')
+ ->middleware('api')
+ ->namespace($this->moduleNamespace)
+ ->group(module_path('Licensing', '/Routes/api.php'));
+ }
+}
diff --git a/Licensing/Resources/assets/.gitkeep b/Licensing/Resources/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Resources/assets/js/app.js b/Licensing/Resources/assets/js/app.js
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Resources/assets/sass/app.scss b/Licensing/Resources/assets/sass/app.scss
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Resources/lang/.gitkeep b/Licensing/Resources/lang/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Resources/views/.gitkeep b/Licensing/Resources/views/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Resources/views/admin/index.blade.php b/Licensing/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..8c72c09
--- /dev/null
+++ b/Licensing/Resources/views/admin/index.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licensing Overview')
+@section('content')SonarDesk licensing Total licences
{{ number_format($licenseCount) }}
Usable licences
{{ number_format($activeCount) }}
Products
{{ number_format($productCount) }}
Recent licences Customer Product Edition Status Created @forelse($recentLicenses as $license){{ $license->customer_email ?: 'Unknown customer' }} {{ $license->product?->name ?? '—' }} {{ $license->edition }} {{ $license->is_usable ? 'Usable' : 'Inactive' }} {{ $license->created_at?->format('d M Y') }} @emptyNo licences found. @endforelse
View all licences →
@endsection
diff --git a/Licensing/Resources/views/admin/layout.blade.php b/Licensing/Resources/views/admin/layout.blade.php
new file mode 100644
index 0000000..4bd80e4
--- /dev/null
+++ b/Licensing/Resources/views/admin/layout.blade.php
@@ -0,0 +1,3 @@
+@yield('title', 'SonarDesk Licensing') · ABSDev @yield('content')
diff --git a/Licensing/Resources/views/admin/licenses.blade.php b/Licensing/Resources/views/admin/licenses.blade.php
new file mode 100644
index 0000000..9f3f47d
--- /dev/null
+++ b/Licensing/Resources/views/admin/licenses.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licences')
+@section('content')Licences Customer Product Edition Provider Status Created @forelse($licenses as $license){{ $license->customer_email ?: 'Unknown customer' }} {{ $license->product?->name ?? '—' }} {{ $license->edition }} {{ $license->payment_provider ?? '—' }} {{ $license->is_usable ? 'Usable' : 'Inactive' }} {{ $license->created_at?->format('d M Y') }} @emptyNo licences found. @endforelse
@endsection
diff --git a/Licensing/Resources/views/admin/show.blade.php b/Licensing/Resources/views/admin/show.blade.php
new file mode 100644
index 0000000..42f12b3
--- /dev/null
+++ b/Licensing/Resources/views/admin/show.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licence details')
+@section('content')Licence details Customer and licence Customer {{ $license->customer_name ?? '—' }} Email {{ $license->customer_email ?? '—' }} Product {{ $license->product?->name ?? '—' }} Edition {{ $license->edition }} Key (last four only) •••• {{ $license->license_key_last4 }} Status {{ $license->is_usable ? 'Usable' : 'Inactive' }} Subscription Provider {{ $license->payment_provider ?? '—' }} Subscription status {{ $license->subscription_status ?? '—' }} Period ends {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }} Expires {{ $license->expires_at?->format('d M Y') ?? '—' }} Activation limit {{ $license->activation_limit }} Device activations Platform Version Activated Last seen Status @forelse($license->activations as $activation){{ $activation->platform ?? '—' }} {{ $activation->app_version ?? '—' }} {{ $activation->activated_at?->format('d M Y H:i') ?? '—' }} {{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }} {{ $activation->deactivated_at ? 'Deactivated' : 'Active' }} @emptyNo device activations. @endforelse
@endsection
diff --git a/Licensing/Resources/views/index.blade.php b/Licensing/Resources/views/index.blade.php
new file mode 100644
index 0000000..4277b06
--- /dev/null
+++ b/Licensing/Resources/views/index.blade.php
@@ -0,0 +1,9 @@
+@extends('licensing::layouts.master')
+
+@section('content')
+ Hello World
+
+
+ This view is loaded from module: {!! config('licensing.name') !!}
+
+@endsection
diff --git a/Licensing/Resources/views/layouts/master.blade.php b/Licensing/Resources/views/layouts/master.blade.php
new file mode 100644
index 0000000..1e51f1c
--- /dev/null
+++ b/Licensing/Resources/views/layouts/master.blade.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Module Licensing
+
+ {{-- Laravel Mix - CSS File --}}
+ {{-- --}}
+
+
+
+ @yield('content')
+
+ {{-- Laravel Mix - JS File --}}
+ {{-- --}}
+
+
diff --git a/Licensing/Routes/.gitkeep b/Licensing/Routes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Routes/admin.php b/Licensing/Routes/admin.php
new file mode 100644
index 0000000..592524a
--- /dev/null
+++ b/Licensing/Routes/admin.php
@@ -0,0 +1,14 @@
+prefix('administration/sonardesk')
+ ->name('administration.sonardesk.')
+ ->group(function (): void {
+ Route::get('/', [LicensingDashboardController::class, 'index'])->name('index');
+ Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses');
+ Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show');
+ });
diff --git a/Licensing/Routes/api.php b/Licensing/Routes/api.php
new file mode 100644
index 0000000..a923714
--- /dev/null
+++ b/Licensing/Routes/api.php
@@ -0,0 +1,12 @@
+group(function (): void {
+ Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
+ Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
+ Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
+ Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
+});
diff --git a/Licensing/Routes/web.php b/Licensing/Routes/web.php
new file mode 100644
index 0000000..cfe4d88
--- /dev/null
+++ b/Licensing/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('portal')->name('portal.')->group(function (): void {
+ Route::get('/', [PortalController::class, 'index'])->name('index');
+ Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
+ Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
+ Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
+ Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
+ Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
+});
diff --git a/Licensing/Services/LicenseDeliveryService.php b/Licensing/Services/LicenseDeliveryService.php
new file mode 100755
index 0000000..053600d
--- /dev/null
+++ b/Licensing/Services/LicenseDeliveryService.php
@@ -0,0 +1,54 @@
+with('license')
+ ->whereNull('delivered_at')
+ ->whereNotNull('recipient_email')
+ ->where('recipient_email', '!=', '')
+ ->oldest()
+ ->limit(max(1, min($limit, 100)))
+ ->get();
+
+ foreach ($pending as $outbox) {
+ $license = $outbox->license;
+ if (! $license) {
+ continue;
+ }
+
+ $plain = Crypt::decryptString($outbox->encrypted_license_key);
+ $recipient = strtolower(trim((string) $outbox->recipient_email));
+ if ($recipient === '') {
+ continue;
+ }
+
+ $data = [
+ 'customerName' => $license->customer_name,
+ 'edition' => $license->edition,
+ 'licenseKey' => $plain,
+ 'expiresAt' => $license->expires_at,
+ 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
+ 'downloadUrl' => route('release'),
+ ];
+
+ Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
+ $message->to($recipient)->subject('Your SonarDesk licence key');
+ });
+
+ $outbox->update(['delivered_at' => now()]);
+ $delivered++;
+ }
+
+ return $delivered;
+ }
+}
diff --git a/Licensing/Services/LicenseIssuer.php b/Licensing/Services/LicenseIssuer.php
new file mode 100755
index 0000000..7171dd8
--- /dev/null
+++ b/Licensing/Services/LicenseIssuer.php
@@ -0,0 +1,46 @@
+where('external_order_id', $orderID)->first();
+ if ($existing) {
+ return [$existing, null];
+ }
+ }
+ $plain = LicenseKey::generate();
+ $license = License::create([
+ 'license_product_id' => $product->id,
+ 'license_key_hash' => LicenseKey::hash($plain),
+ 'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
+ 'customer_name' => $name,
+ 'customer_email' => $email ? strtolower(trim($email)) : null,
+ 'edition' => $product->edition,
+ 'activation_limit' => $product->activation_limit,
+ 'max_major_version' => $product->max_major_version,
+ 'payment_provider' => $provider,
+ 'external_order_id' => $orderID,
+ 'external_customer_id' => $customerID,
+ ]);
+ if ($queueDelivery) {
+ LicenseDeliveryOutbox::updateOrCreate(
+ ['license_id' => $license->id],
+ ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
+ );
+ }
+
+ return [$license, $plain];
+ });
+ }
+}
diff --git a/Licensing/Services/LicenseKey.php b/Licensing/Services/LicenseKey.php
new file mode 100755
index 0000000..4e2501f
--- /dev/null
+++ b/Licensing/Services/LicenseKey.php
@@ -0,0 +1,27 @@
+privateSeed();
+ $pair = sodium_crypto_sign_seed_keypair($seed);
+ $secret = sodium_crypto_sign_secretkey($pair);
+
+ $payload = [
+ 'customerEmail' => $license->customer_email,
+ 'customerName' => $license->customer_name,
+ 'edition' => $license->edition,
+ 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
+ 'installationID' => strtolower($installationID),
+ 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
+ 'licenseID' => strtolower((string) $license->id),
+ 'maxMajorVersion' => $license->max_major_version,
+ ];
+ ksort($payload, SORT_STRING);
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ $sig = sodium_crypto_sign_detached($json, $secret);
+
+ return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
+ }
+
+ public function publicKeyBase64(): string
+ {
+ $pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
+
+ return base64_encode(sodium_crypto_sign_publickey($pair));
+ }
+
+ private function privateSeed(): string
+ {
+ $path = config('sonardesk.private_key_path');
+ if (! $path || ! is_file($path)) {
+ throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ }
+ $decoded = base64_decode(trim((string) file_get_contents($path)), true);
+ if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
+ throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ }
+
+ return $decoded;
+ }
+
+ private static function b64url(string $value): string
+ {
+ return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
+ }
+}
diff --git a/Licensing/Services/LicenseTokenVerifier.php b/Licensing/Services/LicenseTokenVerifier.php
new file mode 100755
index 0000000..052f633
--- /dev/null
+++ b/Licensing/Services/LicenseTokenVerifier.php
@@ -0,0 +1,58 @@
+decode($parts[1]);
+ $signature = $this->decode($parts[2]);
+ // Derive the verification key from the same private seed used by LicenseSigner.
+ // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
+ // activation would sign a perfectly valid token with the new private key, then
+ // /validate would reject that same token using a stale public key.
+ $public = $this->publicKeyFromPrivateSeed();
+ if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
+ throw new RuntimeException('Licence token signature is invalid.');
+ }
+ $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
+
+ return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
+ }
+
+ private function publicKeyFromPrivateSeed(): string
+ {
+ $path = config('sonardesk.private_key_path');
+ if (! $path || ! is_file($path)) {
+ throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ }
+
+ $seed = base64_decode(trim((string) file_get_contents($path)), true);
+ if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
+ throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ }
+
+ $pair = sodium_crypto_sign_seed_keypair($seed);
+
+ return sodium_crypto_sign_publickey($pair);
+ }
+
+ private function decode(string $value): string
+ {
+ $value = strtr($value, '-_', '+/');
+ $value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
+ $out = base64_decode($value, true);
+ if ($out === false) {
+ throw new RuntimeException('Invalid Base64URL value.');
+ }
+
+ return $out;
+ }
+}
diff --git a/Licensing/Support/.gitkeep b/Licensing/Support/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Licensing/Support/ModuleInfo.php b/Licensing/Support/ModuleInfo.php
new file mode 100644
index 0000000..28b9983
--- /dev/null
+++ b/Licensing/Support/ModuleInfo.php
@@ -0,0 +1,26 @@
+morphMany(LocalizedContent::class, 'translatable');
+ }
+
+ public function translated(string $field, ?string $locale = null, mixed $fallback = null): mixed
+ {
+ $locale ??= app()->getLocale();
+ $translation = $this->translations->first(fn (LocalizedContent $item): bool => $item->field === $field && $item->locale?->code === $locale);
+ if ($translation && filled($translation->value)) {
+ return $translation->value;
+ }
+
+ return $fallback ?? $this->getAttribute($field);
+ }
+
+ public function setTranslation(string $field, string $localeCode, ?string $value, ?int $userId = null): LocalizedContent
+ {
+ $locale = Locale::query()->where('code', $localeCode)->firstOrFail();
+
+ return $this->translations()->updateOrCreate(
+ ['locale_id' => $locale->id, 'field' => $field],
+ ['value' => $value, 'updated_by' => $userId]
+ );
+ }
+}
diff --git a/Localization/Config/config.php b/Localization/Config/config.php
new file mode 100644
index 0000000..da2392d
--- /dev/null
+++ b/Localization/Config/config.php
@@ -0,0 +1,16 @@
+ env('APP_LOCALE', 'en'),
+ 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
+ 'session_key' => 'platform_locale',
+ 'supported_subjects' => [
+ 'pages' => ['model' => Page::class, 'label' => 'Pages', 'fields' => ['title', 'summary', 'content', 'meta_title', 'meta_description']],
+ 'blog' => ['model' => Blog::class, 'label' => 'Blog', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
+ 'news' => ['model' => News::class, 'label' => 'News', 'fields' => ['title', 'excerpt', 'content', 'meta_title', 'meta_description']],
+ ],
+];
diff --git a/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php b/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php
new file mode 100644
index 0000000..24f8aba
--- /dev/null
+++ b/Localization/Database/Migrations/2026_07_24_06000_create_localization_tables.php
@@ -0,0 +1,46 @@
+id();
+ $table->string('code', 20)->unique();
+ $table->string('name', 100);
+ $table->string('native_name', 100);
+ $table->string('regional_code', 20)->nullable();
+ $table->string('direction', 3)->default('ltr');
+ $table->string('date_format', 40)->default('d/m/Y');
+ $table->string('time_format', 40)->default('H:i');
+ $table->string('timezone', 80)->default('UTC');
+ $table->string('currency_code', 3)->nullable();
+ $table->string('currency_symbol', 10)->nullable();
+ $table->unsignedInteger('position')->default(0);
+ $table->boolean('is_default')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('localized_contents', function (Blueprint $table): void {
+ $table->id();
+ $table->morphs('translatable');
+ $table->foreignId('locale_id')->constrained()->cascadeOnDelete();
+ $table->string('field', 100);
+ $table->longText('value')->nullable();
+ $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->unique(['translatable_type', 'translatable_id', 'locale_id', 'field'], 'localized_content_unique');
+ $table->index(['locale_id', 'field']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('localized_contents');
+ Schema::dropIfExists('locales');
+ }
+};
diff --git a/Localization/Database/Seeders/LocaleSeeder.php b/Localization/Database/Seeders/LocaleSeeder.php
new file mode 100644
index 0000000..1bf793d
--- /dev/null
+++ b/Localization/Database/Seeders/LocaleSeeder.php
@@ -0,0 +1,19 @@
+updateOrCreate(['code' => 'en'], [
+ 'name' => 'English', 'native_name' => 'English', 'regional_code' => 'en-GB',
+ 'direction' => 'ltr', 'date_format' => 'd/m/Y', 'time_format' => 'H:i',
+ 'timezone' => config('app.timezone', 'UTC'), 'currency_code' => 'GBP',
+ 'currency_symbol' => '£', 'position' => 0, 'is_default' => true, 'is_active' => true,
+ ]);
+ }
+}
diff --git a/Localization/Http/Controllers/LocaleController.php b/Localization/Http/Controllers/LocaleController.php
new file mode 100644
index 0000000..8e3a54c
--- /dev/null
+++ b/Localization/Http/Controllers/LocaleController.php
@@ -0,0 +1,78 @@
+user()->can('localization.manage'), 403);
+ $subjects = collect(config('localization.supported_subjects'))->map(function (array $config): array {
+ $model = $config['model'];
+
+ return $config + ['records' => $model::query()->latest()->limit(8)->get()];
+ });
+
+ return view('localization::admin.index', ['locales' => Locale::query()->orderBy('position')->get(), 'subjects' => $subjects]);
+ }
+
+ public function switch(string $locale, LocaleManager $manager): RedirectResponse
+ {
+ $manager->activate($locale);
+
+ return back();
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ abort_unless($request->user()->can('localization.manage'), 403);
+ $data = $request->validate($this->rules());
+ if (($data['is_default'] ?? false) === true) {
+ Locale::query()->update(['is_default' => false]);
+ }
+ Locale::query()->create($data);
+
+ return back()->with('success', 'Locale created.');
+ }
+
+ public function update(Request $request, Locale $locale): RedirectResponse
+ {
+ abort_unless($request->user()->can('localization.manage'), 403);
+ $data = $request->validate($this->rules($locale));
+ if (($data['is_default'] ?? false) === true) {
+ Locale::query()->whereKeyNot($locale->id)->update(['is_default' => false]);
+ }
+ $locale->update($data);
+
+ return back()->with('success', 'Locale updated.');
+ }
+
+ public function destroy(Request $request, Locale $locale): RedirectResponse
+ {
+ abort_unless($request->user()->can('localization.manage'), 403);
+ abort_if($locale->is_default, 422, 'The default locale cannot be deleted.');
+ $locale->delete();
+
+ return back()->with('success', 'Locale deleted.');
+ }
+
+ private function rules(?Locale $locale = null): array
+ {
+ return [
+ 'code' => ['required', 'string', 'max:20', 'unique:locales,code,'.($locale?->id ?? 'NULL')],
+ 'name' => ['required', 'string', 'max:100'], 'native_name' => ['required', 'string', 'max:100'],
+ 'regional_code' => ['nullable', 'string', 'max:20'], 'direction' => ['required', 'in:ltr,rtl'],
+ 'date_format' => ['required', 'string', 'max:40'], 'time_format' => ['required', 'string', 'max:40'],
+ 'timezone' => ['required', 'timezone'], 'currency_code' => ['nullable', 'string', 'size:3'],
+ 'currency_symbol' => ['nullable', 'string', 'max:10'], 'position' => ['nullable', 'integer', 'min:0'],
+ 'is_default' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
+ ];
+ }
+}
diff --git a/Localization/Http/Controllers/TranslationController.php b/Localization/Http/Controllers/TranslationController.php
new file mode 100644
index 0000000..69fd8b5
--- /dev/null
+++ b/Localization/Http/Controllers/TranslationController.php
@@ -0,0 +1,44 @@
+user()->can('localization.translate'), 403);
+ [$record, $config] = $this->resolve($subject, $id);
+ $record->load('translations.locale');
+
+ return view('localization::admin.translate', ['record' => $record, 'subject' => $subject, 'config' => $config, 'locales' => Locale::query()->active()->get()]);
+ }
+
+ public function update(Request $request, string $subject, int $id): RedirectResponse
+ {
+ abort_unless($request->user()->can('localization.translate'), 403);
+ [$record, $config] = $this->resolve($subject, $id);
+ $data = $request->validate(['translations' => ['array'], 'translations.*' => ['array']]);
+ foreach ($data['translations'] ?? [] as $localeCode => $fields) {
+ foreach ($config['fields'] as $field) {
+ $record->setTranslation($field, $localeCode, $fields[$field] ?? null, $request->user()->id);
+ }
+ }
+
+ return back()->with('success', 'Translations saved.');
+ }
+
+ private function resolve(string $subject, int $id): array
+ {
+ $config = config("localization.supported_subjects.$subject");
+ abort_unless(is_array($config), 404);
+ $record = $config['model']::query()->findOrFail($id);
+
+ return [$record, $config];
+ }
+}
diff --git a/Localization/Http/Middleware/SetLocale.php b/Localization/Http/Middleware/SetLocale.php
new file mode 100644
index 0000000..1d93a23
--- /dev/null
+++ b/Localization/Http/Middleware/SetLocale.php
@@ -0,0 +1,53 @@
+route('locale');
+
+ if (! $code && $request->hasSession()) {
+ $code = $request->session()->get($sessionKey);
+ }
+
+ if (! Schema::hasTable('locales')) {
+ app()->setLocale(is_string($code) && $code !== '' ? $code : $fallback);
+
+ return $next($request);
+ }
+
+ if (! $code) {
+ $code = Locale::query()
+ ->where('is_default', true)
+ ->value('code') ?: $fallback;
+ }
+
+ $isActive = Locale::query()
+ ->where('code', $code)
+ ->where('is_active', true)
+ ->exists();
+
+ if ($isActive) {
+ app()->setLocale((string) $code);
+
+ if ($request->hasSession()) {
+ $request->session()->put($sessionKey, $code);
+ }
+ } else {
+ app()->setLocale($fallback);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/Localization/Models/Locale.php b/Localization/Models/Locale.php
new file mode 100644
index 0000000..97fa973
--- /dev/null
+++ b/Localization/Models/Locale.php
@@ -0,0 +1,27 @@
+ 'boolean', 'is_active' => 'boolean'];
+ }
+
+ public function translations(): HasMany
+ {
+ return $this->hasMany(LocalizedContent::class);
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true)->orderBy('position')->orderBy('name');
+ }
+}
diff --git a/Localization/Models/LocalizedContent.php b/Localization/Models/LocalizedContent.php
new file mode 100644
index 0000000..2a10675
--- /dev/null
+++ b/Localization/Models/LocalizedContent.php
@@ -0,0 +1,28 @@
+morphTo();
+ }
+
+ public function locale(): BelongsTo
+ {
+ return $this->belongsTo(Locale::class);
+ }
+
+ public function editor(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'updated_by');
+ }
+}
diff --git a/Localization/Providers/LocalizationServiceProvider.php b/Localization/Providers/LocalizationServiceProvider.php
new file mode 100644
index 0000000..6383fa8
--- /dev/null
+++ b/Localization/Providers/LocalizationServiceProvider.php
@@ -0,0 +1,22 @@
+mergeConfigFrom(module_path('Localization', 'Config/config.php'), 'localization');
+ $this->app->singleton(LocaleManager::class);
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Localization', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Localization', 'Resources/views'), 'localization');
+ }
+}
diff --git a/Localization/Providers/RouteServiceProvider.php b/Localization/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..39dae19
--- /dev/null
+++ b/Localization/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Localization', 'Routes/web.php'));
+ }
+}
diff --git a/Localization/Resources/views/admin/index.blade.php b/Localization/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..4dca7c8
--- /dev/null
+++ b/Localization/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Localisation Languages, regional formats and translated content.
Add locale @csrfLTR RTL Add locale Translate content @foreach($subjects as $key=>$subject)
@endforeach
diff --git a/Localization/Resources/views/admin/translate.blade.php b/Localization/Resources/views/admin/translate.blade.php
new file mode 100644
index 0000000..c71edbc
--- /dev/null
+++ b/Localization/Resources/views/admin/translate.blade.php
@@ -0,0 +1 @@
+Translate: {{ $record->title ?? ('#'.$record->id) }} {{ $config['label'] }} · source language values are shown for reference.
@csrf @method('PUT')@foreach($locales as $locale){{ $locale->native_name }} {{ $locale->code }}
@foreach($config['fields'] as $field)@php($existing=$record->translations->first(fn($item)=>$item->locale_id===$locale->id&&$item->field===$field)?->value){{ str($field)->headline() }}
Source: {{ str(strip_tags((string)$record->getAttribute($field)))->limit(180) }}
@if(in_array($field,['content','summary','excerpt','meta_description']))
{{ $existing }} @else
@endif
@endforeach @endforeachSave translations
diff --git a/Localization/Routes/web.php b/Localization/Routes/web.php
new file mode 100644
index 0000000..119020e
--- /dev/null
+++ b/Localization/Routes/web.php
@@ -0,0 +1,15 @@
+name('localization.switch');
+Route::middleware(['auth', 'verified'])->prefix('administration/localization')->name('administration.localization.')->group(function (): void {
+ Route::get('/', [LocaleController::class, 'index'])->name('index');
+ Route::post('/locales', [LocaleController::class, 'store'])->name('locales.store');
+ Route::put('/locales/{locale}', [LocaleController::class, 'update'])->name('locales.update');
+ Route::delete('/locales/{locale}', [LocaleController::class, 'destroy'])->name('locales.destroy');
+ Route::get('/translate/{subject}/{id}', [TranslationController::class, 'edit'])->name('translations.edit');
+ Route::put('/translate/{subject}/{id}', [TranslationController::class, 'update'])->name('translations.update');
+});
diff --git a/Localization/Services/LocaleManager.php b/Localization/Services/LocaleManager.php
new file mode 100644
index 0000000..0454a58
--- /dev/null
+++ b/Localization/Services/LocaleManager.php
@@ -0,0 +1,28 @@
+active()->get();
+ }
+
+ public function default(): ?Locale
+ {
+ return Locale::query()->where('is_default', true)->first() ?? Locale::query()->active()->first();
+ }
+
+ public function activate(string $code): Locale
+ {
+ $locale = Locale::query()->active()->where('code', $code)->firstOrFail();
+ app()->setLocale($locale->code);
+ session()->put(config('localization.session_key'), $locale->code);
+
+ return $locale;
+ }
+}
diff --git a/Localization/Tests/Feature/ModuleBootTest.php b/Localization/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..c4aea49
--- /dev/null
+++ b/Localization/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,14 @@
+assertTrue(app()->providerIsLoaded(LocalizationServiceProvider::class));
+ }
+}
diff --git a/Localization/module.json b/Localization/module.json
new file mode 100644
index 0000000..a5e28f7
--- /dev/null
+++ b/Localization/module.json
@@ -0,0 +1,9 @@
+{
+ "name": "Localization",
+ "alias": "localization",
+ "description": "Locales, translated content, regional settings and language switching.",
+ "keywords": ["localization", "translation", "locale", "language"],
+ "priority": 36,
+ "providers": ["Modules\\Localization\\Providers\\LocalizationServiceProvider"],
+ "files": []
+}
diff --git a/Locations/Config/config.php b/Locations/Config/config.php
new file mode 100644
index 0000000..ba035af
--- /dev/null
+++ b/Locations/Config/config.php
@@ -0,0 +1,3 @@
+ 'Locations'];
diff --git a/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php b/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php
new file mode 100644
index 0000000..8ef54fb
--- /dev/null
+++ b/Locations/Database/Migrations/2026_07_24_04400_create_locations_table.php
@@ -0,0 +1,34 @@
+id();
+ $t->string('name');
+ $t->string('slug')->unique();
+ $t->string('type')->default('office');
+ $t->text('address')->nullable();
+ $t->string('postcode')->nullable();
+ $t->string('country')->nullable();
+ $t->string('phone')->nullable();
+ $t->string('email')->nullable();
+ $t->decimal('latitude', 10, 7)->nullable();
+ $t->decimal('longitude', 10, 7)->nullable();
+ $t->json('opening_hours')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->unsignedInteger('sort_order')->default(0);
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('locations');
+ }
+};
diff --git a/Locations/Http/Controllers/LocationController.php b/Locations/Http/Controllers/LocationController.php
new file mode 100644
index 0000000..a1b2606
--- /dev/null
+++ b/Locations/Http/Controllers/LocationController.php
@@ -0,0 +1,31 @@
+ Location::orderBy('sort_order')->orderBy('name')->paginate(25)]);
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ Location::create($r->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:locations,slug'], 'type' => ['required', 'in:office,store,warehouse,venue,service'], 'address' => ['nullable', 'string'], 'postcode' => ['nullable', 'string', 'max:30'], 'country' => ['nullable', 'string', 'max:120'], 'phone' => ['nullable', 'string', 'max:80'], 'email' => ['nullable', 'email', 'max:255'], 'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'], 'sort_order' => ['nullable', 'integer', 'min:0']]) + ['is_active' => $r->boolean('is_active', true)]);
+
+ return back()->with('status', 'Location created.');
+ }
+
+ public function destroy(Location $location): RedirectResponse
+ {
+ $location->delete();
+
+ return back()->with('status', 'Location deleted.');
+ }
+}
diff --git a/Locations/Models/Location.php b/Locations/Models/Location.php
new file mode 100644
index 0000000..913fd40
--- /dev/null
+++ b/Locations/Models/Location.php
@@ -0,0 +1,15 @@
+ 'decimal:7', 'longitude' => 'decimal:7', 'opening_hours' => 'array', 'is_active' => 'boolean'];
+ }
+}
diff --git a/Locations/Providers/LocationsServiceProvider.php b/Locations/Providers/LocationsServiceProvider.php
new file mode 100644
index 0000000..de2419b
--- /dev/null
+++ b/Locations/Providers/LocationsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Locations', 'Config/config.php'), 'locations');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Locations', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Locations', 'Resources/views'), 'locations');
+ }
+}
diff --git a/Locations/Providers/RouteServiceProvider.php b/Locations/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..cb2ee3e
--- /dev/null
+++ b/Locations/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Locations', 'Routes/web.php'));
+ }
+}
diff --git a/Locations/Resources/views/admin/index.blade.php b/Locations/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..3b1f2c1
--- /dev/null
+++ b/Locations/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Locations Manage offices, stores, venues and service locations.
@if(session('status'))
{{ session('status') }}
@endif
@csrfAdd location Office Store Warehouse Venue Service
Create location Location Type Contact Status @forelse($locations as $location){{ $location->name }}
{{ $location->address }}
{{ ucfirst($location->type) }} {{ $location->phone ?: $location->email ?: '—' }} {{ $location->is_active ? 'Active' : 'Inactive' }} @csrf @method('DELETE')Delete @emptyNo locations yet. @endforelse
{{ $locations->links() }}
diff --git a/Locations/Routes/web.php b/Locations/Routes/web.php
new file mode 100644
index 0000000..2e5d437
--- /dev/null
+++ b/Locations/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/locations')->name('administration.locations.')->group(function (): void {
+ Route::get('/', [LocationController::class, 'index'])->name('index');
+ Route::post('/', [LocationController::class, 'store'])->name('store');
+ Route::delete('/{location}', [LocationController::class, 'destroy'])->name('destroy');
+});
diff --git a/Locations/Tests/Feature/ModuleBootTest.php b/Locations/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..c8dec12
--- /dev/null
+++ b/Locations/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Locations', config('locations.name'));
+ }
+}
diff --git a/Locations/module.json b/Locations/module.json
new file mode 100644
index 0000000..86865e1
--- /dev/null
+++ b/Locations/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Locations",
+ "alias": "locations",
+ "version": "1.0.0",
+ "description": "Office and service location management.",
+ "keywords": ["business", "locations"],
+ "priority": 44,
+ "providers": ["Modules\\Locations\\Providers\\LocationsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Marketplace/Config/config.php b/Marketplace/Config/config.php
new file mode 100644
index 0000000..2274c18
--- /dev/null
+++ b/Marketplace/Config/config.php
@@ -0,0 +1,14 @@
+ (bool) env('MARKETPLACE_ENABLED', true),
+ 'catalogue_url' => env('MARKETPLACE_CATALOGUE_URL'),
+ 'allow_remote_install' => (bool) env('MARKETPLACE_ALLOW_REMOTE_INSTALL', false),
+ 'require_checksum' => (bool) env('MARKETPLACE_REQUIRE_CHECKSUM', true),
+ 'require_signature' => (bool) env('MARKETPLACE_REQUIRE_SIGNATURE', false),
+ 'trusted_publishers' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARKETPLACE_TRUSTED_PUBLISHERS', ''))))),
+ 'storage_disk' => env('MARKETPLACE_STORAGE_DISK', 'local'),
+ 'package_path' => env('MARKETPLACE_PACKAGE_PATH', 'marketplace/packages'),
+ 'snapshot_path' => env('MARKETPLACE_SNAPSHOT_PATH', 'marketplace/snapshots'),
+ 'supported_types' => ['module', 'theme', 'extension'],
+];
diff --git a/Marketplace/Console/Commands/MarketplaceAuditCommand.php b/Marketplace/Console/Commands/MarketplaceAuditCommand.php
new file mode 100644
index 0000000..b8a36b0
--- /dev/null
+++ b/Marketplace/Console/Commands/MarketplaceAuditCommand.php
@@ -0,0 +1,24 @@
+with('package')->get();
+ foreach ($packages as $package) {
+ $this->line(sprintf('%s %s [%s]%s', $package->package_name, $package->version, $package->status, $package->hasUpdate() ? ' update available' : ''));
+ }
+ $this->info($packages->count().' installed package(s) audited.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Marketplace/Console/Commands/MarketplaceSyncCommand.php b/Marketplace/Console/Commands/MarketplaceSyncCommand.php
new file mode 100644
index 0000000..060aa58
--- /dev/null
+++ b/Marketplace/Console/Commands/MarketplaceSyncCommand.php
@@ -0,0 +1,24 @@
+warn('MARKETPLACE_CATALOGUE_URL is not configured.');
+
+ return self::SUCCESS;
+ }
+ $this->info('Catalogue synchronisation is configured. Remote installs remain disabled unless explicitly enabled.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php b/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php
new file mode 100644
index 0000000..eb2a3b9
--- /dev/null
+++ b/Marketplace/Database/Migrations/2026_07_24_170000_create_marketplace_tables.php
@@ -0,0 +1,96 @@
+id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('email')->nullable();
+ $table->string('website')->nullable();
+ $table->text('public_key')->nullable();
+ $table->boolean('is_verified')->default(false)->index();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('marketplace_packages', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('publisher_id')->nullable()->constrained('marketplace_publishers')->nullOnDelete();
+ $table->string('type', 30)->index();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->string('package_name')->unique();
+ $table->text('description')->nullable();
+ $table->string('latest_version')->nullable();
+ $table->string('homepage')->nullable();
+ $table->string('repository_url')->nullable();
+ $table->string('download_url')->nullable();
+ $table->string('checksum', 128)->nullable();
+ $table->text('signature')->nullable();
+ $table->json('manifest')->nullable();
+ $table->boolean('is_featured')->default(false)->index();
+ $table->boolean('is_active')->default(true)->index();
+ $table->timestamp('catalogue_updated_at')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('marketplace_installed_packages', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
+ $table->string('package_name')->unique();
+ $table->string('type', 30)->index();
+ $table->string('version');
+ $table->string('status', 30)->default('installed')->index();
+ $table->json('manifest')->nullable();
+ $table->string('checksum', 128)->nullable();
+ $table->string('install_path')->nullable();
+ $table->boolean('is_enabled')->default(true)->index();
+ $table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('installed_at')->nullable();
+ $table->timestamp('updated_at_marketplace')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('marketplace_installations', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('installed_package_id')->nullable()->constrained('marketplace_installed_packages')->nullOnDelete();
+ $table->string('package_name')->index();
+ $table->string('operation', 30)->index();
+ $table->string('from_version')->nullable();
+ $table->string('to_version')->nullable();
+ $table->string('status', 30)->default('pending')->index();
+ $table->string('snapshot_path')->nullable();
+ $table->text('message')->nullable();
+ $table->json('context')->nullable();
+ $table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('started_at')->nullable();
+ $table->timestamp('completed_at')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('marketplace_licences', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
+ $table->string('licence_key')->nullable();
+ $table->string('status', 30)->default('inactive')->index();
+ $table->string('plan')->nullable();
+ $table->string('licensed_to')->nullable();
+ $table->timestamp('activated_at')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('marketplace_licences');
+ Schema::dropIfExists('marketplace_installations');
+ Schema::dropIfExists('marketplace_installed_packages');
+ Schema::dropIfExists('marketplace_packages');
+ Schema::dropIfExists('marketplace_publishers');
+ }
+};
diff --git a/Marketplace/Http/Controllers/MarketplaceApiController.php b/Marketplace/Http/Controllers/MarketplaceApiController.php
new file mode 100644
index 0000000..958d8f3
--- /dev/null
+++ b/Marketplace/Http/Controllers/MarketplaceApiController.php
@@ -0,0 +1,40 @@
+json(MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->paginate(min(100, max(1, $request->integer('per_page', 25)))));
+ }
+
+ public function installed(): JsonResponse
+ {
+ return response()->json(InstalledPackage::query()->with('package')->paginate(50));
+ }
+
+ public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ return response()->json($manager->install($package, $request->user()?->id)->load('package'), 201);
+ }
+
+ public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ return response()->json($manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id));
+ }
+
+ public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
+ {
+ $manager->remove($package, $request->user()?->id);
+
+ return response()->json(['status' => 'removed']);
+ }
+}
diff --git a/Marketplace/Http/Controllers/MarketplaceController.php b/Marketplace/Http/Controllers/MarketplaceController.php
new file mode 100644
index 0000000..45744a0
--- /dev/null
+++ b/Marketplace/Http/Controllers/MarketplaceController.php
@@ -0,0 +1,64 @@
+with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%')->orWhere('description', 'like', '%'.$request->string('search').'%')))->latest('is_featured')->paginate(20)->withQueryString();
+
+ return view('marketplace::index', compact('packages'));
+ }
+
+ public function installed(): View
+ {
+ $packages = InstalledPackage::query()->with('package')->latest()->paginate(25);
+
+ return view('marketplace::installed', compact('packages'));
+ }
+
+ public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->install($package, $request->user()?->id);
+
+ return back()->with('status', 'Package installed successfully.');
+ }
+
+ public function update(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->install($package, $request->user()?->id);
+
+ return back()->with('status', 'Package updated successfully.');
+ }
+
+ public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id);
+
+ return back()->with('status', 'Package state updated.');
+ }
+
+ public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->remove($package, $request->user()?->id);
+
+ return back()->with('status', 'Package removed.');
+ }
+
+ public function rollback(InstalledPackage $package, Installation $installation, MarketplaceManager $manager, Request $request): RedirectResponse
+ {
+ $manager->rollback($package, $installation, $request->user()?->id);
+
+ return back()->with('status','Package rolled back.');
+ }
+}
diff --git a/Marketplace/Models/Installation.php b/Marketplace/Models/Installation.php
new file mode 100644
index 0000000..c152b8c
--- /dev/null
+++ b/Marketplace/Models/Installation.php
@@ -0,0 +1,14 @@
+ 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
+}
diff --git a/Marketplace/Models/InstalledPackage.php b/Marketplace/Models/InstalledPackage.php
new file mode 100644
index 0000000..bb7eda5
--- /dev/null
+++ b/Marketplace/Models/InstalledPackage.php
@@ -0,0 +1,31 @@
+ 'array', 'is_enabled' => 'boolean', 'installed_at' => 'datetime', 'updated_at_marketplace' => 'datetime'];
+
+ public function package(): BelongsTo
+ {
+ return $this->belongsTo(MarketplacePackage::class, 'marketplace_package_id');
+ }
+
+ public function installations(): HasMany
+ {
+ return $this->hasMany(Installation::class, 'installed_package_id');
+ }
+
+ public function hasUpdate(): bool
+ {
+ return $this->package?->latest_version && version_compare($this->package->latest_version, $this->version, '>');
+ }
+}
diff --git a/Marketplace/Models/MarketplaceLicence.php b/Marketplace/Models/MarketplaceLicence.php
new file mode 100644
index 0000000..22038e8
--- /dev/null
+++ b/Marketplace/Models/MarketplaceLicence.php
@@ -0,0 +1,16 @@
+ 'datetime', 'expires_at' => 'datetime', 'metadata' => 'array'];
+}
diff --git a/Marketplace/Models/MarketplacePackage.php b/Marketplace/Models/MarketplacePackage.php
new file mode 100644
index 0000000..ad4a0c2
--- /dev/null
+++ b/Marketplace/Models/MarketplacePackage.php
@@ -0,0 +1,26 @@
+ 'array', 'is_featured' => 'boolean', 'is_active' => 'boolean', 'catalogue_updated_at' => 'datetime'];
+
+ public function publisher(): BelongsTo
+ {
+ return $this->belongsTo(Publisher::class);
+ }
+
+ public function licences(): HasMany
+ {
+ return $this->hasMany(MarketplaceLicence::class, 'marketplace_package_id');
+ }
+}
diff --git a/Marketplace/Models/Publisher.php b/Marketplace/Models/Publisher.php
new file mode 100644
index 0000000..16558ef
--- /dev/null
+++ b/Marketplace/Models/Publisher.php
@@ -0,0 +1,20 @@
+ 'boolean', 'metadata' => 'array'];
+
+ public function packages(): HasMany
+ {
+ return $this->hasMany(MarketplacePackage::class, 'publisher_id');
+ }
+}
diff --git a/Marketplace/Providers/MarketplaceServiceProvider.php b/Marketplace/Providers/MarketplaceServiceProvider.php
new file mode 100644
index 0000000..0edd17f
--- /dev/null
+++ b/Marketplace/Providers/MarketplaceServiceProvider.php
@@ -0,0 +1,36 @@
+mergeConfigFrom(module_path('Marketplace', 'Config/config.php'), 'marketplace');
+ $this->app->singleton(PackageManifest::class);
+ $this->app->singleton(CompatibilityChecker::class);
+ $this->app->singleton(DependencyResolver::class);
+ $this->app->singleton(PackageVerifier::class);
+ $this->app->singleton(MarketplaceManager::class);
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Marketplace', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Marketplace', 'Resources/views'), 'marketplace');
+ $this->loadTranslationsFrom(module_path('Marketplace', 'Resources/lang'), 'marketplace');
+ if ($this->app->runningInConsole()) {
+ $this->commands([MarketplaceSyncCommand::class, MarketplaceAuditCommand::class]);
+ }
+ }
+}
diff --git a/Marketplace/Providers/RouteServiceProvider.php b/Marketplace/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..6bf0725
--- /dev/null
+++ b/Marketplace/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Marketplace', 'Routes/web.php'));
+ Route::middleware('api')->prefix('api/v1')->group(module_path('Marketplace', 'Routes/api.php'));
+ }
+}
diff --git a/Marketplace/Resources/views/index.blade.php b/Marketplace/Resources/views/index.blade.php
new file mode 100644
index 0000000..6399b57
--- /dev/null
+++ b/Marketplace/Resources/views/index.blade.php
@@ -0,0 +1,97 @@
+
+
+
+
+ Marketplace
+
+ Discover verified modules, themes and extensions.
+
+
+
+
+ Installed packages
+
+
+
+
+
+
+
+ All types
+
+ @foreach (config('marketplace.supported_types', []) as $type)
+
+ {{ \Illuminate\Support\Str::headline($type) }}
+
+ @endforeach
+
+
+ Filter
+
+
+
+ @forelse ($packages as $package)
+
+
+
+
+
{{ $package->name }}
+
+ @if ($package->is_featured)
+ Featured
+ @endif
+
+
+
+ {{ $package->package_name }}
+ · {{ \Illuminate\Support\Str::headline($package->type) }}
+ · {{ $package->latest_version ?: 'No release' }}
+
+
+
+ @can('marketplace.install')
+
+ @csrf
+
+
+ Install
+
+
+ @endcan
+
+
+ {{ $package->description }}
+
+ @if ($package->publisher)
+
+ Published by {{ $package->publisher->name }}
+
+ @if ($package->publisher->is_verified)
+ · Verified
+ @endif
+
+ @endif
+
+ @empty
+
+ No marketplace packages found.
+
+ @endforelse
+
+
+ {{ $packages->links() }}
+
+
diff --git a/Marketplace/Resources/views/installed.blade.php b/Marketplace/Resources/views/installed.blade.php
new file mode 100644
index 0000000..7609352
--- /dev/null
+++ b/Marketplace/Resources/views/installed.blade.php
@@ -0,0 +1,104 @@
+
+
+
+
+ Installed Packages
+
+ Manage package state, updates and removals.
+
+
+
+
+ Browse marketplace
+
+
+
+
+
+
+
+ Package
+ Version
+ Status
+ Update
+ Actions
+
+
+
+
+ @forelse ($packages as $package)
+
+
+ {{ $package->package_name }}
+
+ {{ \Illuminate\Support\Str::headline($package->type) }}
+
+
+
+ {{ $package->version }}
+ {{ ucfirst($package->status) }}
+
+ {{ $package->hasUpdate() ? $package->package->latest_version : 'Current' }}
+
+
+
+
+ @can('marketplace.update')
+
+ @csrf
+
+
+ {{ $package->is_enabled ? 'Disable' : 'Enable' }}
+
+
+
+ @if ($package->hasUpdate())
+
+ @csrf
+
+
+ Update
+
+
+ @endif
+ @endcan
+
+ @can('marketplace.remove')
+
+ @csrf
+ @method('DELETE')
+
+
+ Remove
+
+
+ @endcan
+
+
+
+ @empty
+
+
+ No packages installed through the marketplace.
+
+
+ @endforelse
+
+
+
+
+ {{ $packages->links() }}
+
+
diff --git a/Marketplace/Routes/api.php b/Marketplace/Routes/api.php
new file mode 100644
index 0000000..5c04c0e
--- /dev/null
+++ b/Marketplace/Routes/api.php
@@ -0,0 +1,12 @@
+prefix('marketplace')->name('api.marketplace.')->group(function () {
+ Route::get('/packages', [MarketplaceApiController::class, 'packages'])->middleware('permission:marketplace.view')->name('packages');
+ Route::get('/installed', [MarketplaceApiController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
+ Route::post('/packages/{package}/install', [MarketplaceApiController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
+ Route::post('/installed/{package}/toggle', [MarketplaceApiController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
+ Route::delete('/installed/{package}', [MarketplaceApiController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
+});
diff --git a/Marketplace/Routes/web.php b/Marketplace/Routes/web.php
new file mode 100644
index 0000000..2ac0b30
--- /dev/null
+++ b/Marketplace/Routes/web.php
@@ -0,0 +1,14 @@
+prefix('administration/marketplace')->name('administration.marketplace.')->group(function () {
+ Route::get('/', [MarketplaceController::class, 'index'])->middleware('permission:marketplace.view')->name('index');
+ Route::get('/installed', [MarketplaceController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
+ Route::post('/packages/{package}/install', [MarketplaceController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
+ Route::post('/packages/{package}/update', [MarketplaceController::class, 'update'])->middleware('permission:marketplace.update')->name('update');
+ Route::post('/installed/{package}/toggle', [MarketplaceController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
+ Route::delete('/installed/{package}', [MarketplaceController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
+ Route::post('/installed/{package}/rollback/{installation}', [MarketplaceController::class, 'rollback'])->middleware('permission:marketplace.update')->name('rollback');
+});
diff --git a/Marketplace/Services/CompatibilityChecker.php b/Marketplace/Services/CompatibilityChecker.php
new file mode 100644
index 0000000..4ef2e12
--- /dev/null
+++ b/Marketplace/Services/CompatibilityChecker.php
@@ -0,0 +1,30 @@
+constraints->matches(PHP_VERSION, $php)) {
+ $errors[] = "Requires PHP $php; running ".PHP_VERSION.'.';
+ }
+ if (! $this->constraints->matches(App::version(), $laravel)) {
+ $errors[] = "Requires Laravel $laravel; running ".App::version().'.';
+ }
+ if (! $this->constraints->matches($platformVersion, $platform)) {
+ $errors[] = "Requires Content Manager $platform; running $platformVersion.";
+ }
+
+ return ['compatible' => $errors === [], 'errors' => $errors];
+ }
+}
diff --git a/Marketplace/Services/DependencyResolver.php b/Marketplace/Services/DependencyResolver.php
new file mode 100644
index 0000000..02c711a
--- /dev/null
+++ b/Marketplace/Services/DependencyResolver.php
@@ -0,0 +1,29 @@
+ $constraint) {
+ $installed = InstalledPackage::query()->where('package_name', $package)->where('status', 'installed')->first();
+ if (! $installed) {
+ $missing[$package] = $constraint;
+
+ continue;
+ }
+ if (! $this->constraints->matches($installed->version, (string) $constraint)) {
+ $incompatible[$package] = ['required' => $constraint, 'installed' => $installed->version];
+ }
+ }
+
+ return ['satisfied' => $missing === [] && $incompatible === [], 'missing' => $missing, 'incompatible' => $incompatible];
+ }
+}
diff --git a/Marketplace/Services/MarketplaceManager.php b/Marketplace/Services/MarketplaceManager.php
new file mode 100644
index 0000000..a457800
--- /dev/null
+++ b/Marketplace/Services/MarketplaceManager.php
@@ -0,0 +1,72 @@
+manifests->parse($package->manifest ?: ['name' => $package->package_name, 'type' => $package->type, 'version' => $package->latest_version]);
+ $compatibility = $this->compatibility->check($manifest);
+ if (! $compatibility['compatible']) {
+ throw new RuntimeException(implode(' ', $compatibility['errors']));
+ }
+ $dependencies = $this->dependencies->resolve($manifest);
+ if (! $dependencies['satisfied']) {
+ throw new RuntimeException('Package dependencies are not satisfied.');
+ }
+
+ return DB::transaction(function () use ($package, $manifest, $userId) {
+ $installed = InstalledPackage::query()->firstOrNew(['package_name' => $package->package_name]);
+ $from = $installed->exists ? $installed->version : null;
+ $installed->fill(['marketplace_package_id' => $package->id, 'type' => $package->type, 'version' => $manifest['version'], 'status' => 'installed', 'manifest' => $manifest, 'checksum' => $package->checksum, 'is_enabled' => true, 'installed_by' => $userId, 'installed_at' => $installed->installed_at ?: now(), 'updated_at_marketplace' => now()])->save();
+ $this->history($installed, $from ? 'update' : 'install', $from, $manifest['version'], $userId, 'completed');
+
+ return $installed->refresh();
+ });
+ }
+
+ public function setEnabled(InstalledPackage $package, bool $enabled, ?int $userId = null): InstalledPackage
+ {
+ $package->update(['is_enabled' => $enabled, 'status' => $enabled ? 'installed' : 'disabled']);
+ $this->history($package, $enabled ? 'enable' : 'disable', $package->version, $package->version, $userId, 'completed');
+
+ return $package->refresh();
+ }
+
+ public function remove(InstalledPackage $package, ?int $userId = null): void
+ {
+ DB::transaction(function () use ($package, $userId) {
+ $this->history($package, 'remove', $package->version, null, $userId, 'completed');
+ $package->update(['status' => 'removed', 'is_enabled' => false]);
+ });
+ }
+
+ public function rollback(InstalledPackage $package, Installation $installation, ?int $userId = null): InstalledPackage
+ {
+ if ($installation->package_name !== $package->package_name || blank($installation->from_version)) {
+ throw new RuntimeException('This installation cannot be rolled back.');
+ }
+ $current = $package->version;
+ $package->update(['version' => $installation->from_version, 'status' => 'installed', 'is_enabled' => true]);
+ $this->history($package, 'rollback', $current, $installation->from_version, $userId, 'completed');
+
+ return $package->refresh();
+ }
+
+ private function history(InstalledPackage $package, string $operation, ?string $from, ?string $to, ?int $userId, string $status): Installation
+ {
+ return Installation::query()->create(['installed_package_id' => $package->id, 'package_name' => $package->package_name, 'operation' => $operation, 'from_version' => $from, 'to_version' => $to, 'status' => $status, 'performed_by' => $userId, 'started_at' => now(), 'completed_at' => now()]);
+ }
+}
diff --git a/Marketplace/Services/PackageManifest.php b/Marketplace/Services/PackageManifest.php
new file mode 100644
index 0000000..2fecb73
--- /dev/null
+++ b/Marketplace/Services/PackageManifest.php
@@ -0,0 +1,38 @@
+ 'The package manifest is not valid JSON.']);
+ }
+ $manifest = $decoded;
+ }
+ foreach (['name', 'type', 'version'] as $field) {
+ if (! filled(Arr::get($manifest, $field))) {
+ throw ValidationException::withMessages(['manifest' => "Manifest field [$field] is required."]);
+ }
+ }
+ if (! in_array($manifest['type'], config('marketplace.supported_types', []), true)) {
+ throw ValidationException::withMessages(['manifest' => 'Unsupported marketplace package type.']);
+ }
+ $manifest['requires'] = $manifest['requires'] ?? [];
+ $manifest['content_manager'] = $manifest['content_manager'] ?? '*';
+ $manifest['laravel'] = $manifest['laravel'] ?? '*';
+
+ return $manifest;
+ }
+
+ public function fromFile(string $path): array
+ {
+ return $this->parse((string) file_get_contents($path));
+ }
+}
diff --git a/Marketplace/Services/PackageVerifier.php b/Marketplace/Services/PackageVerifier.php
new file mode 100644
index 0000000..1f909f7
--- /dev/null
+++ b/Marketplace/Services/PackageVerifier.php
@@ -0,0 +1,28 @@
+ true, 'checksum' => $actual, 'signature_present' => filled($manifest['signature'] ?? null)];
+ }
+}
diff --git a/Marketplace/Services/VersionConstraint.php b/Marketplace/Services/VersionConstraint.php
new file mode 100644
index 0000000..14e206c
--- /dev/null
+++ b/Marketplace/Services/VersionConstraint.php
@@ -0,0 +1,47 @@
+=') && version_compare($version, ($major + 1).'.0.0', '<');
+
+ continue;
+ }
+ if (str_starts_with($part, '~')) {
+ $base = substr($part, 1);
+ $bits = array_pad(explode('.', $base), 3, '0');
+ $upper = $bits[0].'.'.((int) $bits[1] + 1).'.0';
+ $ok = $ok && version_compare($version, $base, '>=') && version_compare($version, $upper, '<');
+
+ continue;
+ }
+ if (preg_match('/^(>=|<=|>|<|=)?\s*([0-9][0-9A-Za-z.\-+]*)$/', $part, $m)) {
+ $ok = $ok && version_compare($version, $m[2], $m[1] ?: '=');
+ } else {
+ $ok = $ok && $version === $part;
+ }
+ }
+ if ($ok) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Marketplace/Tests/Feature/MarketplaceModuleTest.php b/Marketplace/Tests/Feature/MarketplaceModuleTest.php
new file mode 100644
index 0000000..663809b
--- /dev/null
+++ b/Marketplace/Tests/Feature/MarketplaceModuleTest.php
@@ -0,0 +1,23 @@
+parse(['name' => 'vendor/example', 'type' => 'module', 'version' => '1.0.0']);
+ $this->assertSame('module', $manifest['type']);
+ $this->assertSame([], $manifest['requires']);
+ }
+
+ public function test_current_platform_is_compatible_with_wildcard_manifest(): void
+ {
+ $result = app(CompatibilityChecker::class)->check(['php' => '*', 'laravel' => '*', 'content_manager' => '*']);
+ $this->assertTrue($result['compatible']);
+ }
+}
diff --git a/Marketplace/module.json b/Marketplace/module.json
new file mode 100644
index 0000000..936c435
--- /dev/null
+++ b/Marketplace/module.json
@@ -0,0 +1 @@
+{"name":"Marketplace","alias":"marketplace","version":"1.0.0","description":"Secure package catalogue, installation, updates, licences, publishers and rollback management.","keywords":["marketplace","modules","themes","extensions","packages"],"priority":170,"providers":["Modules\\Marketplace\\Providers\\MarketplaceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Developer","Security"]}
diff --git a/Media/Config/config.php b/Media/Config/config.php
new file mode 100644
index 0000000..ba65b69
--- /dev/null
+++ b/Media/Config/config.php
@@ -0,0 +1,6 @@
+ 'Media',
+ 'enabled' => true,
+];
diff --git a/Media/Database/Migrations/2026_07_23_000300_create_media_table.php b/Media/Database/Migrations/2026_07_23_000300_create_media_table.php
new file mode 100644
index 0000000..581799e
--- /dev/null
+++ b/Media/Database/Migrations/2026_07_23_000300_create_media_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->uuid('uuid')->unique();
+ $table->nullableMorphs('attachable');
+ $table->string('collection')->default('default')->index();
+ $table->string('disk')->default('public');
+ $table->string('path');
+ $table->string('original_name');
+ $table->string('mime_type')->nullable();
+ $table->unsignedBigInteger('size')->default(0);
+ $table->string('title')->nullable();
+ $table->string('alt_text')->nullable();
+ $table->text('caption')->nullable();
+ $table->json('metadata')->nullable();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->foreignId('uploaded_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->softDeletes();
+ $table->index(['attachable_type', 'attachable_id', 'collection', 'sort_order'], 'media_attachable_collection_index');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('media');
+ }
+};
diff --git a/Media/Http/Controllers/MediaController.php b/Media/Http/Controllers/MediaController.php
new file mode 100644
index 0000000..ee5c15e
--- /dev/null
+++ b/Media/Http/Controllers/MediaController.php
@@ -0,0 +1,34 @@
+ Media::query()->latest()->paginate(30)]);
+ }
+
+ public function store(Request $request)
+ {
+ $request->validate(['file' => ['required', 'file', 'max:20480'], 'title' => ['nullable', 'string', 'max:255'], 'alt_text' => ['nullable', 'string', 'max:255']]);
+ $file = $request->file('file');
+ $path = $file->store('media', 'public');
+ Media::create(['disk' => 'public', 'path' => $path, 'original_name' => $file->getClientOriginalName(), 'mime_type' => $file->getMimeType(), 'size' => $file->getSize(), 'title' => $request->string('title')->toString() ?: pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME), 'alt_text' => $request->string('alt_text')->toString() ?: null, 'uploaded_by' => $request->user()->id]);
+
+ return back()->with('status', 'Media uploaded.');
+ }
+
+ public function destroy(Media $medium)
+ {
+ Storage::disk($medium->disk)->delete($medium->path);
+ $medium->delete();
+
+ return back()->with('status', 'Media deleted.');
+ }
+}
diff --git a/Media/Models/Media.php b/Media/Models/Media.php
new file mode 100644
index 0000000..56af7ee
--- /dev/null
+++ b/Media/Models/Media.php
@@ -0,0 +1,26 @@
+ $m->uuid ??= (string) Str::uuid());
+ }
+
+ protected function casts(): array
+ {
+ return ['metadata' => 'array', 'size' => 'integer', 'sort_order' => 'integer'];
+ }
+}
diff --git a/Media/Providers/MediaServiceProvider.php b/Media/Providers/MediaServiceProvider.php
new file mode 100644
index 0000000..eee1f9a
--- /dev/null
+++ b/Media/Providers/MediaServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Media/Providers/RouteServiceProvider.php b/Media/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ec522c8
--- /dev/null
+++ b/Media/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Media', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Media/Resources/views/index.blade.php b/Media/Resources/views/index.blade.php
new file mode 100644
index 0000000..e574feb
--- /dev/null
+++ b/Media/Resources/views/index.blade.php
@@ -0,0 +1 @@
+Media library Upload and manage reusable files.
@if(session('status'))
{{ session('status') }}
@endif
@csrfUpload
@forelse($media as $item)
@if(str_starts_with((string)$item->mime_type,'image/')) @else
@endif{{ $item->title ?: $item->original_name }}
{{ Number::fileSize($item->size) }}
@csrf @method('DELETE')Delete @empty
No media uploaded.
@endforelse
{{ $media->links() }}
diff --git a/Media/Routes/web.php b/Media/Routes/web.php
new file mode 100644
index 0000000..172cab2
--- /dev/null
+++ b/Media/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/media')->name('administration.media.')->group(function (): void {
+ Route::get('/', [MediaController::class, 'index'])->name('index');
+ Route::post('/', [MediaController::class, 'store'])->name('store');
+ Route::delete('/{medium}', [MediaController::class, 'destroy'])->name('destroy');
+});
diff --git a/Media/Support/ModuleInfo.php b/Media/Support/ModuleInfo.php
new file mode 100644
index 0000000..91bfbd1
--- /dev/null
+++ b/Media/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Media');
+});
diff --git a/Media/composer.json b/Media/composer.json
new file mode 100644
index 0000000..3d22a93
--- /dev/null
+++ b/Media/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/media-module",
+ "description": "Media foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Media\\": ""
+ }
+ }
+}
diff --git a/Media/module.json b/Media/module.json
new file mode 100644
index 0000000..23a97df
--- /dev/null
+++ b/Media/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Media",
+ "alias": "media",
+ "version": "1.0.0",
+ "description": "Media foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "media"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Media\\Providers\\MediaServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Menus/Config/config.php b/Menus/Config/config.php
new file mode 100644
index 0000000..634ea78
--- /dev/null
+++ b/Menus/Config/config.php
@@ -0,0 +1,3 @@
+ 'Menus'];
diff --git a/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php b/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php
new file mode 100644
index 0000000..6ab61c5
--- /dev/null
+++ b/Menus/Database/Migrations/2026_07_24_000200_create_menus_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->string('name');
+ $table->string('location');
+ $table->json('items')->nullable();
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('menus');
+ }
+};
diff --git a/Menus/Http/Controllers/MenuController.php b/Menus/Http/Controllers/MenuController.php
new file mode 100644
index 0000000..3154304
--- /dev/null
+++ b/Menus/Http/Controllers/MenuController.php
@@ -0,0 +1,90 @@
+ Menu::query()->orderBy('location')->paginate(30)]);
+ }
+
+ public function create(): View
+ {
+ return view('menus::admin.form', ['locations' => $this->locations()]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ Menu::create($this->validated($request));
+
+ return redirect()->route('administration.menus.index')->with('status', 'Menu created. Every header or footer using this location has been updated.');
+ }
+
+ public function edit(Menu $menu): View
+ {
+ return view('menus::admin.form', ['record' => $menu, 'locations' => $this->locations()]);
+ }
+
+ public function update(Request $request, Menu $menu): RedirectResponse
+ {
+ $menu->update($this->validated($request));
+
+ return redirect()->route('administration.menus.index')->with('status', 'Menu updated everywhere it is used.');
+ }
+
+ public function destroy(Menu $menu): RedirectResponse
+ {
+ $menu->delete();
+
+ return back()->with('status', 'Menu deleted. Components using its location will use their safe fallback.');
+ }
+
+ /** @return array */
+ private function validated(Request $request): array
+ {
+ $data = $request->validate([
+ 'name' => ['required', 'string', 'max:255'],
+ 'location' => ['required', 'string', 'max:100', 'regex:/^[a-z0-9_-]+$/'],
+ 'items_json' => ['nullable', 'string'],
+ 'enabled' => ['nullable', 'boolean'],
+ ]);
+
+ try {
+ $items = json_decode($data['items_json'] ?: '[]', true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException) {
+ throw ValidationException::withMessages(['items_json' => 'The menu items could not be saved. Please reload the editor and try again.']);
+ }
+
+ if (! is_array($items)) {
+ throw ValidationException::withMessages(['items_json' => 'Menu items must be a list.']);
+ }
+
+ return [
+ 'name' => $data['name'],
+ 'location' => $data['location'],
+ 'items' => array_values($items),
+ 'enabled' => $request->boolean('enabled'),
+ ];
+ }
+
+ /** @return array */
+ private function locations(): array
+ {
+ return [
+ 'primary' => 'Primary navigation',
+ 'footer' => 'Footer — Explore',
+ 'footer_account' => 'Footer — Account',
+ 'footer_legal' => 'Footer — Legal',
+ 'mobile' => 'Mobile navigation override',
+ ];
+ }
+}
diff --git a/Menus/Models/Menu.php b/Menus/Models/Menu.php
new file mode 100644
index 0000000..0f957c6
--- /dev/null
+++ b/Menus/Models/Menu.php
@@ -0,0 +1,24 @@
+ 'array',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Menus/Providers/MenusServiceProvider.php b/Menus/Providers/MenusServiceProvider.php
new file mode 100644
index 0000000..9572a1a
--- /dev/null
+++ b/Menus/Providers/MenusServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Menus', 'Config/config.php'), 'menus');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Menus', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Menus', 'Resources/views'), 'menus');
+ }
+}
diff --git a/Menus/Providers/RouteServiceProvider.php b/Menus/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..2e19213
--- /dev/null
+++ b/Menus/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Menus', 'Routes/web.php')));
+ }
+}
diff --git a/Menus/Resources/views/admin/form.blade.php b/Menus/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..73a9d39
--- /dev/null
+++ b/Menus/Resources/views/admin/form.blade.php
@@ -0,0 +1,57 @@
+
+ @php($initialItems = old('items_json', json_encode($record->items ?? [], JSON_UNESCAPED_SLASHES)))
+
+
+
Reusable navigation
+
{{ isset($record) ? 'Edit' : 'Create' }} menu
+
Edit links once. Every header and footer assigned to this menu location updates automatically.
+
+
+
+ @csrf
+ @isset($record) @method('PUT') @endisset
+
+
+
+
+ Menu items Relative paths such as /blog, anchors and full external URLs are supported.
Add link
+
+ @error('items_json'){{ $message }}
@enderror
+
+
+
+
+
+
+
+
+ @push('scripts')
+
+ @endpush
+
diff --git a/Menus/Resources/views/admin/index.blade.php b/Menus/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..db7b2a4
--- /dev/null
+++ b/Menus/Resources/views/admin/index.blade.php
@@ -0,0 +1,14 @@
+
+
+
Single source of navigation
Menus Reusable menus update every header and footer that references their location.
+ @if(session('status'))
{{ session('status') }}
@endif
+
+ @forelse($records as $record)
+
{{ $record->location }} {{ $record->name }} {{ count($record->items ?? []) }} links · {{ $record->enabled ? 'Enabled' : 'Disabled' }}
+ @empty
+
No menus yet Run the Saasland installer or create your first reusable menu.
+ @endforelse
+
+ {{ $records->links() }}
+
+
diff --git a/Menus/Routes/web.php b/Menus/Routes/web.php
new file mode 100644
index 0000000..f76c43f
--- /dev/null
+++ b/Menus/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/menus')->name('administration.menus.')->group(function (): void {
+ Route::get('/', [MenuController::class, 'index'])->name('index');
+ Route::get('/create', [MenuController::class, 'create'])->name('create');
+ Route::post('/', [MenuController::class, 'store'])->name('store');
+ Route::get('/{menu}/edit', [MenuController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{menu}', [MenuController::class, 'update'])->name('update');
+ Route::delete('/{menu}', [MenuController::class, 'destroy'])->name('destroy');
+});
diff --git a/Menus/Support/ModuleInfo.php b/Menus/Support/ModuleInfo.php
new file mode 100644
index 0000000..e96b94b
--- /dev/null
+++ b/Menus/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+create(['name' => 'Primary', 'location' => 'primary', 'enabled' => true, 'items' => [['enabled' => true, 'label' => 'Home', 'url' => '/']]]);
+ expect(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary())->toHaveCount(1)->and(app(\Modules\Stylist\Support\FrontendNavigation::class)->primary()[0]['label'])->toBe('Home');
+});
diff --git a/Menus/Tests/Feature/ModuleBootTest.php b/Menus/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..516d963
--- /dev/null
+++ b/Menus/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,7 @@
+toBeTrue();
+});
diff --git a/Menus/module.json b/Menus/module.json
new file mode 100644
index 0000000..6474504
--- /dev/null
+++ b/Menus/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Menus",
+ "alias": "menus",
+ "version": "1.0.0",
+ "description": "Menus CMS module.",
+ "keywords": [
+ "cms",
+ "menus"
+ ],
+ "priority": 22,
+ "providers": [
+ "Modules\\Menus\\Providers\\MenusServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Modules/Licensing/Config/.gitkeep b/Modules/Licensing/Config/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Config/config.php b/Modules/Licensing/Config/config.php
new file mode 100644
index 0000000..3b94eda
--- /dev/null
+++ b/Modules/Licensing/Config/config.php
@@ -0,0 +1,8 @@
+ 'Licensing',
+ 'features' =>
+ [
+ ],
+];
diff --git a/Modules/Licensing/Console/.gitkeep b/Modules/Licensing/Console/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Console/SonarDeskLicenseDeliver.php b/Modules/Licensing/Console/SonarDeskLicenseDeliver.php
new file mode 100644
index 0000000..67e5050
--- /dev/null
+++ b/Modules/Licensing/Console/SonarDeskLicenseDeliver.php
@@ -0,0 +1,20 @@
+option('limit'), 100));
+ $count = $delivery->deliverPending($limit);
+ $this->info("Delivered {$count} licence email(s).");
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Licensing/Console/SonarDeskLicenseIssue.php b/Modules/Licensing/Console/SonarDeskLicenseIssue.php
new file mode 100755
index 0000000..12469cb
--- /dev/null
+++ b/Modules/Licensing/Console/SonarDeskLicenseIssue.php
@@ -0,0 +1,29 @@
+argument('product'))->firstOrFail();
+ [$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
+ if (! $key) {
+ $this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
+
+ return self::FAILURE;
+ } $this->line('Licence ID: '.$l->id);
+ $this->line('Licence key: '.$key);
+ $this->warn('This is the only time the plaintext key is shown.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Licensing/Console/SonarDeskLicenseRevoke.php b/Modules/Licensing/Console/SonarDeskLicenseRevoke.php
new file mode 100755
index 0000000..ad34c16
--- /dev/null
+++ b/Modules/Licensing/Console/SonarDeskLicenseRevoke.php
@@ -0,0 +1,22 @@
+argument('id'));
+ $l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
+ $this->info('Licence revoked.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Licensing/Console/SonarDeskProductCreate.php b/Modules/Licensing/Console/SonarDeskProductCreate.php
new file mode 100755
index 0000000..8622a85
--- /dev/null
+++ b/Modules/Licensing/Console/SonarDeskProductCreate.php
@@ -0,0 +1,21 @@
+ $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
+ $this->info("Product {$p->code} ready.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Modules/Licensing/Database/Migrations/.gitkeep b/Modules/Licensing/Database/Migrations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php b/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php
new file mode 100755
index 0000000..babc7a3
--- /dev/null
+++ b/Modules/Licensing/Database/Migrations/2026_09_08_200000_create_sonardesk_licensing_tables.php
@@ -0,0 +1,92 @@
+id();
+ $table->string('code')->unique();
+ $table->string('name');
+ $table->string('edition')->default('Professional');
+ $table->unsignedInteger('activation_limit')->default(1);
+ $table->unsignedInteger('max_major_version')->nullable();
+ $table->string('payment_provider')->nullable();
+ $table->string('external_product_id')->nullable();
+ $table->string('external_price_id')->nullable();
+ $table->boolean('active')->default(true);
+ $table->timestamps();
+ $table->unique(['payment_provider', 'external_price_id']);
+ });
+
+ Schema::create('licenses', function (Blueprint $table) {
+ $table->uuid('id')->primary();
+ $table->foreignId('license_product_id')->constrained('license_products');
+ $table->string('license_key_hash', 64)->unique();
+ $table->string('license_key_last4', 4);
+ $table->string('customer_name')->nullable();
+ $table->string('customer_email')->nullable()->index();
+ $table->string('edition');
+ $table->unsignedInteger('activation_limit');
+ $table->unsignedInteger('max_major_version')->nullable();
+ $table->timestamp('expires_at')->nullable();
+ $table->timestamp('revoked_at')->nullable();
+ $table->string('revoke_reason')->nullable();
+ $table->string('payment_provider')->nullable();
+ $table->string('external_order_id')->nullable();
+ $table->string('external_customer_id')->nullable();
+ $table->timestamps();
+ $table->unique(['payment_provider', 'external_order_id']);
+ });
+
+ Schema::create('license_delivery_outbox', function (Blueprint $table) {
+ $table->id();
+ $table->uuid('license_id')->unique();
+ $table->text('encrypted_license_key');
+ $table->string('recipient_email')->nullable();
+ $table->timestamp('delivered_at')->nullable();
+ $table->timestamps();
+ $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
+ });
+
+ Schema::create('license_activations', function (Blueprint $table) {
+ $table->id();
+ $table->uuid('license_id');
+ $table->uuid('installation_id');
+ $table->string('platform', 32)->nullable();
+ $table->string('app_version', 32)->nullable();
+ $table->timestamp('activated_at');
+ $table->timestamp('last_seen_at')->nullable();
+ $table->timestamp('deactivated_at')->nullable();
+ $table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
+ $table->unique(['license_id', 'installation_id']);
+ $table->index(['license_id', 'deactivated_at']);
+ });
+
+ Schema::create('license_webhook_events', function (Blueprint $table) {
+ $table->id();
+ $table->string('provider');
+ $table->string('external_event_id');
+ $table->string('event_type');
+ $table->string('payload_sha256', 64);
+ $table->json('payload');
+ $table->timestamp('processed_at')->nullable();
+ $table->text('processing_error')->nullable();
+ $table->timestamps();
+ $table->unique(['provider', 'external_event_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('license_webhook_events');
+ Schema::dropIfExists('license_activations');
+ Schema::dropIfExists('license_delivery_outbox');
+ Schema::dropIfExists('licenses');
+ Schema::dropIfExists('license_products');
+ }
+};
diff --git a/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php b/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php
new file mode 100755
index 0000000..b302e12
--- /dev/null
+++ b/Modules/Licensing/Database/Migrations/2026_09_08_210000_add_sonardesk_portal_fields.php
@@ -0,0 +1,30 @@
+ $table->boolean('is_admin')->default(false)->index());
+ }
+
+ if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
+ Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
+ Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
+ }
+
+ if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
+ Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
+ }
+ }
+};
diff --git a/Modules/Licensing/Database/Seeders/.gitkeep b/Modules/Licensing/Database/Seeders/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php b/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php
new file mode 100644
index 0000000..fbb0b58
--- /dev/null
+++ b/Modules/Licensing/Database/Seeders/LicensingDatabaseSeeder.php
@@ -0,0 +1,21 @@
+call("OthersTableSeeder");
+ }
+}
diff --git a/Modules/Licensing/Database/factories/.gitkeep b/Modules/Licensing/Database/factories/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Helpers/.gitkeep b/Modules/Licensing/Helpers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Helpers/Helper.php b/Modules/Licensing/Helpers/Helper.php
new file mode 100644
index 0000000..7607c60
--- /dev/null
+++ b/Modules/Licensing/Helpers/Helper.php
@@ -0,0 +1,12 @@
+ License::query()->count(),
+ 'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void {
+ $q->whereNull('expires_at')->orWhere('expires_at', '>', now());
+ })->count(),
+ 'productCount' => LicenseProduct::query()->count(),
+ 'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(),
+ ]);
+ }
+
+ public function licenses(): View
+ {
+ return view('licensing::admin.licenses', [
+ 'licenses' => License::query()->with('product')->latest()->paginate(25),
+ ]);
+ }
+
+ public function show(License $license): View
+ {
+ return view('licensing::admin.show', [
+ 'license' => $license->load(['product', 'activations']),
+ ]);
+ }
+
+ /** Read-only snapshot of existing licensing subscription metadata. */
+ public function subscriptions(): View
+ {
+ $subscriptions = License::query()
+ ->whereNotNull('external_subscription_id')
+ ->with('product')
+ ->orderByDesc('created_at')
+ ->paginate(25);
+
+ $total = License::query()->whereNotNull('external_subscription_id')->count();
+ $active = License::query()->whereNotNull('external_subscription_id')
+ ->whereIn('subscription_status', ['active', 'trialing'])->count();
+ $cancelScheduled = License::query()->whereNotNull('external_subscription_id')
+ ->whereNotNull('cancel_scheduled_at')->count();
+
+ return view('licensing::admin.subscriptions', compact('subscriptions', 'total', 'active', 'cancelScheduled'));
+ }
+
+}
diff --git a/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php b/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php
new file mode 100755
index 0000000..7580871
--- /dev/null
+++ b/Modules/Licensing/Http/Controllers/Api/LicenseActivationController.php
@@ -0,0 +1,164 @@
+validate([
+ 'licenseKey' => ['required', 'string', 'max:128'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
+ ]);
+
+ return DB::transaction(function () use ($data, $signer) {
+ $license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
+ ->lockForUpdate()
+ ->first();
+
+ if (! $license) {
+ throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
+ }
+
+ if (! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $installationID = strtolower($data['installationID']);
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', $installationID)
+ ->first();
+
+ if ($activation && $activation->deactivated_at === null) {
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ $activeCount = LicenseActivation::where('license_id', $license->id)
+ ->whereNull('deactivated_at')
+ ->count();
+
+ if ($activeCount >= $license->activation_limit) {
+ return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
+ }
+
+ if ($activation) {
+ $activation->update([
+ 'deactivated_at' => null,
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+ } else {
+ LicenseActivation::create([
+ 'license_id' => $license->id,
+ 'installation_id' => $installationID,
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ 'activated_at' => now(),
+ 'last_seen_at' => now(),
+ ]);
+ }
+ }
+
+ return response()->json([
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
+ });
+ }
+
+ public function validateInstallation(
+ Request $request,
+ LicenseTokenVerifier $verifier,
+ LicenseSigner $signer,
+ ): JsonResponse {
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ 'appVersion' => ['required', 'string', 'max:32'],
+ 'platform' => ['required', 'string', 'max:32'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ return DB::transaction(function () use ($data, $payload, $signer) {
+ $license = License::whereKey((string) ($payload['licenseID'] ?? ''))
+ ->lockForUpdate()
+ ->first();
+
+ if (! $license || ! $license->is_usable) {
+ return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
+ }
+
+ $activation = LicenseActivation::where('license_id', $license->id)
+ ->where('installation_id', strtolower($data['installationID']))
+ ->first();
+
+ if (! $activation || $activation->deactivated_at !== null) {
+ return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
+ }
+
+ $activation->update([
+ 'last_seen_at' => now(),
+ 'platform' => $data['platform'],
+ 'app_version' => $data['appVersion'],
+ ]);
+
+ return response()->json([
+ 'active' => true,
+ // Refreshing the signed token also applies subscription edition/expiry changes
+ // without requiring the user to reactivate manually.
+ 'licenseToken' => $signer->sign($license, $data['installationID']),
+ ]);
+ });
+ }
+
+ public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
+ {
+ $data = $request->validate([
+ 'licenseToken' => ['required', 'string', 'max:8192'],
+ 'installationID' => ['required', 'uuid'],
+ ]);
+
+ try {
+ $payload = $verifier->verify($data['licenseToken']);
+ } catch (\Throwable) {
+ return response()->json(['message' => 'The licence token is invalid.'], 400);
+ }
+
+ if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
+ return response()->json(['message' => 'The installation does not match this licence token.'], 400);
+ }
+
+ LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
+ ->where('installation_id', strtolower($data['installationID']))
+ ->whereNull('deactivated_at')
+ ->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
+
+ return response()->json((object) []);
+ }
+}
diff --git a/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php b/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php
new file mode 100755
index 0000000..61d9512
--- /dev/null
+++ b/Modules/Licensing/Http/Controllers/Api/PaymentWebhookController.php
@@ -0,0 +1,191 @@
+getContent();
+
+ $verifier = match ($provider) {
+ 'paddle' => app(PaddleWebhookVerifier::class),
+ 'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
+ default => null,
+ };
+
+ if (! $verifier) {
+ return response()->json([
+ 'message' => 'Unknown webhook provider.',
+ ], 404);
+ }
+
+ try {
+ $verifier->verify($request, $raw);
+ } catch (Throwable $e) {
+ report($e);
+
+ return response()->json([
+ 'message' => 'Invalid webhook signature.',
+ ], 400);
+ }
+
+ try {
+ $payload = json_decode(
+ $raw,
+ true,
+ flags: JSON_THROW_ON_ERROR
+ );
+ } catch (Throwable) {
+ return response()->json([
+ 'message' => 'Invalid JSON.',
+ ], 400);
+ }
+
+ $externalID = (string) (
+ $provider === 'paddle'
+ ? ($payload['event_id'] ?? '')
+ : ($payload['meta']['event_name'] ?? '')
+ .':'
+ .($payload['data']['id'] ?? '')
+ );
+
+ $eventType = (string) (
+ $provider === 'paddle'
+ ? ($payload['event_type'] ?? 'unknown')
+ : ($payload['meta']['event_name'] ?? 'unknown')
+ );
+
+ if ($externalID === '') {
+ return response()->json([
+ 'message' => 'Webhook event has no stable identifier.',
+ ], 400);
+ }
+
+ $payloadHash = hash('sha256', $raw);
+
+ try {
+ /*
+ * Paddle can deliver several related events concurrently.
+ *
+ * Laravel retries this complete transaction when MySQL reports
+ * a deadlock. Five attempts gives short-lived lock contention
+ * enough opportunity to resolve without acknowledging an event
+ * that was not actually processed.
+ */
+ DB::transaction(
+ function () use (
+ $provider,
+ $externalID,
+ $eventType,
+ $payload,
+ $payloadHash,
+ $provisioner
+ ): void {
+ /*
+ * Insert the event if this is its first delivery.
+ *
+ * Once present, lock the row so duplicate deliveries of
+ * the same event cannot provision concurrently.
+ */
+ LicenseWebhookEvent::firstOrCreate(
+ [
+ 'provider' => $provider,
+ 'external_event_id' => $externalID,
+ ],
+ [
+ 'event_type' => $eventType,
+ 'payload_sha256' => $payloadHash,
+ 'payload' => $payload,
+ ]
+ );
+
+ $event = LicenseWebhookEvent::query()
+ ->where('provider', $provider)
+ ->where('external_event_id', $externalID)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ if ($event->processed_at) {
+ return;
+ }
+
+ if (! hash_equals(
+ (string) $event->payload_sha256,
+ $payloadHash
+ )) {
+ throw new RuntimeException(
+ 'Webhook ID was reused with a different payload.'
+ );
+ }
+
+ try {
+ $provisioner->provision(
+ $provider,
+ $payload
+ );
+
+ $event->processed_at = now();
+ $event->processing_error = null;
+ $event->save();
+ } catch (Throwable $e) {
+ /*
+ * This assignment is useful when the surrounding
+ * transaction eventually succeeds. If processing
+ * throws, the transaction itself is rolled back and
+ * Paddle will retry the event.
+ */
+ $event->processing_error = mb_substr(
+ $e->getMessage(),
+ 0,
+ 4000
+ );
+
+ $event->save();
+
+ throw $e;
+ }
+ },
+ 5
+ );
+ } catch (Throwable $e) {
+ report($e);
+ return response()->json([
+ 'message' => 'Webhook could not be processed.',
+ ], 500);
+ }
+
+ /*
+ * Delivery deliberately happens outside the webhook transaction.
+ *
+ * A temporary SMTP failure must not undo a successfully processed
+ * payment event. The outbox retains anything that still needs
+ * delivery.
+ */
+ try {
+ $delivery->deliverPending(10);
+ } catch (Throwable $e) {
+ report($e);
+ }
+
+ return response()->json([
+ 'ok' => true,
+ ]);
+ }
+}
diff --git a/Modules/Licensing/Http/Controllers/LicensingController.php b/Modules/Licensing/Http/Controllers/LicensingController.php
new file mode 100644
index 0000000..8ece907
--- /dev/null
+++ b/Modules/Licensing/Http/Controllers/LicensingController.php
@@ -0,0 +1,79 @@
+whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
+ }
+
+ public function index(Request $request)
+ {
+ $licenses = $this->owned($request)->latest()->get();
+ $hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
+
+ return view('portal.index', compact('licenses', 'hasProfessional'));
+ }
+
+ public function show(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $license->load(['product', 'activations']);
+
+ return view('portal.show', compact('license'));
+ }
+
+ public function upgrade(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
+
+ $targetPriceID = $this->professionalPriceFor($license);
+ $targetProduct = LicenseProduct::query()
+ ->where('payment_provider', 'paddle')
+ ->where('external_price_id', $targetPriceID)
+ ->where('active', true)
+ ->firstOrFail();
+
+ try {
+ $subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ // Apply the entitlement immediately. Paddle webhooks remain authoritative and
+ // will reconcile subscription status/current-period details afterwards.
+ $license->update([
+ 'license_product_id' => $targetProduct->id,
+ 'edition' => $targetProduct->edition,
+ 'activation_limit' => $targetProduct->activation_limit,
+ 'max_major_version' => $targetProduct->max_major_version,
+ 'external_price_id' => $targetPriceID,
+ 'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
+ 'revoked_at' => null,
+ 'revoke_reason' => null,
+ ]);
+
+ return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
+ }
+
+ public function cancel(Request $request, License $license): RedirectResponse
+ {
+ $this->authorise($request, $license);
+ abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
+ abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
+
+ try {
+ $this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
+ } catch (RuntimeException $exception) {
+ return back()->withErrors(['licence' => $exception->getMessage()]);
+ }
+
+ $license->update([
+ 'subscription_status' => 'canceled',
+ 'expires_at' => now(),
+ 'revoked_at' => now(),
+ 'revoke_reason' => 'Subscription canceled from licence portal',
+ 'cancel_scheduled_at' => null,
+ ]);
+
+ return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
+ }
+
+ public function deactivate(Request $request, License $license, int $activation)
+ {
+ $this->authorise($request, $license);
+ $item = $license->activations()->whereKey($activation)->firstOrFail();
+ if (! $item->deactivated_at) {
+ $item->update(['deactivated_at' => now()]);
+ }
+
+ return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.');
+ }
+
+ public function resend(Request $request, License $license)
+ {
+ $this->authorise($request, $license);
+ $out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
+ if (! $out) {
+ return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
+ }
+
+ $out->update([
+ 'recipient_email' => strtolower($request->user()->email),
+ 'delivered_at' => null,
+ ]);
+ $this->delivery->deliverPending(1);
+
+ return back()->with('status', 'Licence key sent to your verified email address.');
+ }
+
+ private function authorise(Request $request, License $license): void
+ {
+ abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
+ }
+
+ private function professionalPriceFor(License $license): string
+ {
+ $paddle = config('paddle');
+ $current = (string) $license->external_price_id;
+
+ if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
+ return (string) ($paddle['professional_annual_price_id'] ?? '');
+ }
+
+ return (string) ($paddle['professional_monthly_price_id'] ?? '');
+ }
+}
diff --git a/Modules/Licensing/Http/Middleware/.gitkeep b/Modules/Licensing/Http/Middleware/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Http/Requests/.gitkeep b/Modules/Licensing/Http/Requests/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Models/.gitkeep b/Modules/Licensing/Models/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Models/License.php b/Modules/Licensing/Models/License.php
new file mode 100755
index 0000000..86e5c6a
--- /dev/null
+++ b/Modules/Licensing/Models/License.php
@@ -0,0 +1,36 @@
+ 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
+
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(LicenseProduct::class, 'license_product_id');
+ }
+
+ public function activations(): HasMany
+ {
+ return $this->hasMany(LicenseActivation::class);
+ }
+
+ public function getIsUsableAttribute(): bool
+ {
+ return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
+ }
+}
diff --git a/Modules/Licensing/Models/LicenseActivation.php b/Modules/Licensing/Models/LicenseActivation.php
new file mode 100755
index 0000000..fb454a0
--- /dev/null
+++ b/Modules/Licensing/Models/LicenseActivation.php
@@ -0,0 +1,14 @@
+ 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
+}
diff --git a/Modules/Licensing/Models/LicenseDeliveryOutbox.php b/Modules/Licensing/Models/LicenseDeliveryOutbox.php
new file mode 100755
index 0000000..89d1d5f
--- /dev/null
+++ b/Modules/Licensing/Models/LicenseDeliveryOutbox.php
@@ -0,0 +1,20 @@
+ 'datetime'];
+
+ public function license(): BelongsTo
+ {
+ return $this->belongsTo(License::class);
+ }
+}
diff --git a/Modules/Licensing/Models/LicenseProduct.php b/Modules/Licensing/Models/LicenseProduct.php
new file mode 100755
index 0000000..eb8a4cc
--- /dev/null
+++ b/Modules/Licensing/Models/LicenseProduct.php
@@ -0,0 +1,12 @@
+ 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
+}
diff --git a/Modules/Licensing/Models/LicenseWebhookEvent.php b/Modules/Licensing/Models/LicenseWebhookEvent.php
new file mode 100755
index 0000000..a8cc130
--- /dev/null
+++ b/Modules/Licensing/Models/LicenseWebhookEvent.php
@@ -0,0 +1,12 @@
+ 'array', 'processed_at' => 'datetime'];
+}
diff --git a/Modules/Licensing/Providers/.gitkeep b/Modules/Licensing/Providers/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php
new file mode 100644
index 0000000..65e02df
--- /dev/null
+++ b/Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php
@@ -0,0 +1,35 @@
+ [
+ Limit::perMinute(12)->by($request->ip()),
+ Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
+ ]);
+
+ $this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
+ $this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing');
+ $this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing');
+
+ // Explicit role-based authorization; deny access by default.
+ Gate::define('manage-sonardesk-licensing', static fn (User $user): bool =>
+ ($user->hasPermissionTo('manage-sonardesk-licensing', 'web') || $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator']))
+ );
+
+ Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php');
+ Route::group([], __DIR__.'/../Routes/admin.php');
+ }
+}
diff --git a/Modules/Licensing/Providers/LicensingServiceProvider.php b/Modules/Licensing/Providers/LicensingServiceProvider.php
new file mode 100644
index 0000000..c514a59
--- /dev/null
+++ b/Modules/Licensing/Providers/LicensingServiceProvider.php
@@ -0,0 +1,171 @@
+ Limit::perMinute(5)
+ ->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
+
+ RateLimiter::for('registration', fn (Request $request) =>
+ Limit::perHour(5)->by($request->ip())
+ );
+
+ RateLimiter::for('license-activation', function (Request $request): array {
+ return [
+ Limit::perMinute(12)->by($request->ip()),
+ Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
+ ];
+ });
+ $this->registerTranslations();
+ $this->registerConfig();
+ $this->registerViews();
+ $this->registerHelpers();
+ $this->registerCommands();
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ }
+
+ /**
+ * Register the service provider.
+ *
+ * @return void
+ */
+ public function register(): void
+ {
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ /**
+ * Register the helpers.
+ *
+ * @return void
+ */
+ protected function registerHelpers(): void
+ {
+ foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
+ require_once $filename;
+ }
+ }
+
+ /**
+ * Dynamically Register Commands.
+ *
+ * @return void
+ */
+ public function registerCommands(): void
+ {
+ if (!$this->app->runningInConsole()) {
+ return;
+ }
+ $commandPath = __DIR__ . '/../Console';
+ $commands = collect(File::allFiles($commandPath))
+ ->map(function ($file) {
+ $class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
+ return $class;
+ })
+ ->filter(function ($class) {
+ return class_exists($class);
+ })
+ ->values()
+ ->all();
+ $this->commands($commands);
+ }
+
+ /**
+ * Register config.
+ *
+ * @return void
+ */
+ protected function registerConfig(): void
+ {
+ $this->publishes([
+ module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
+ ], 'config');
+ $this->mergeConfigFrom(
+ module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
+ );
+ }
+
+ /**
+ * Register views.
+ *
+ * @return void
+ */
+ public function registerViews(): void
+ {
+ $viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
+ $sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
+ $this->publishes([
+ $sourcePath => $viewPath
+ ], ['views', ModuleInfo::nameLower() . '-module-views']);
+ $this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
+ }
+
+ /**
+ * Register translations.
+ *
+ * @return void
+ */
+ public function registerTranslations(): void
+ {
+ $langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
+
+ if (is_dir($langPath)) {
+ $this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
+ } else {
+ $this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
+ }
+ }
+
+ /**
+ * Regsiter View Paths
+ * @return array
+ */
+ private function getPublishableViewPaths(): array
+ {
+ $paths = [];
+ foreach (Config::get('view.paths') as $path) {
+ if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
+ $paths[] = $path . '/modules/' . ModuleInfo::nameLower();
+ }
+ }
+ return $paths;
+ }
+}
diff --git a/Modules/Licensing/Providers/RouteServiceProvider.php b/Modules/Licensing/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..c691ba8
--- /dev/null
+++ b/Modules/Licensing/Providers/RouteServiceProvider.php
@@ -0,0 +1,69 @@
+mapApiRoutes();
+
+ $this->mapWebRoutes();
+ }
+
+ /**
+ * Define the "web" routes for the application.
+ *
+ * These routes all receive session state, CSRF protection, etc.
+ *
+ * @return void
+ */
+ protected function mapWebRoutes()
+ {
+ Route::middleware('web')
+ ->namespace($this->moduleNamespace)
+ ->group(module_path('Licensing', '/Routes/web.php'));
+ }
+
+ /**
+ * Define the "api" routes for the application.
+ *
+ * These routes are typically stateless.
+ *
+ * @return void
+ */
+ protected function mapApiRoutes()
+ {
+ Route::prefix('api')
+ ->middleware('api')
+ ->namespace($this->moduleNamespace)
+ ->group(module_path('Licensing', '/Routes/api.php'));
+ }
+}
diff --git a/Modules/Licensing/Resources/assets/.gitkeep b/Modules/Licensing/Resources/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Resources/assets/js/app.js b/Modules/Licensing/Resources/assets/js/app.js
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Resources/assets/sass/app.scss b/Modules/Licensing/Resources/assets/sass/app.scss
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Resources/lang/.gitkeep b/Modules/Licensing/Resources/lang/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Resources/views/.gitkeep b/Modules/Licensing/Resources/views/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Resources/views/admin/index.blade.php b/Modules/Licensing/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..8c72c09
--- /dev/null
+++ b/Modules/Licensing/Resources/views/admin/index.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licensing Overview')
+@section('content')SonarDesk licensing Total licences
{{ number_format($licenseCount) }}
Usable licences
{{ number_format($activeCount) }}
Products
{{ number_format($productCount) }}
Recent licences Customer Product Edition Status Created @forelse($recentLicenses as $license){{ $license->customer_email ?: 'Unknown customer' }} {{ $license->product?->name ?? '—' }} {{ $license->edition }} {{ $license->is_usable ? 'Usable' : 'Inactive' }} {{ $license->created_at?->format('d M Y') }} @emptyNo licences found. @endforelse
View all licences →
@endsection
diff --git a/Modules/Licensing/Resources/views/admin/layout.blade.php b/Modules/Licensing/Resources/views/admin/layout.blade.php
new file mode 100644
index 0000000..88a45f3
--- /dev/null
+++ b/Modules/Licensing/Resources/views/admin/layout.blade.php
@@ -0,0 +1,37 @@
+{{-- Shared ABSDev administration shell; no separate SonarDesk layout or navigation. --}}
+
+
+
+
+
ABSDev / Products
+
SonarDesk
+
Licensing administration · Read only
+
+
+
+
@yield('content')
+
+
+
diff --git a/Modules/Licensing/Resources/views/admin/licenses.blade.php b/Modules/Licensing/Resources/views/admin/licenses.blade.php
new file mode 100644
index 0000000..9f3f47d
--- /dev/null
+++ b/Modules/Licensing/Resources/views/admin/licenses.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licences')
+@section('content')Licences Customer Product Edition Provider Status Created @forelse($licenses as $license){{ $license->customer_email ?: 'Unknown customer' }} {{ $license->product?->name ?? '—' }} {{ $license->edition }} {{ $license->payment_provider ?? '—' }} {{ $license->is_usable ? 'Usable' : 'Inactive' }} {{ $license->created_at?->format('d M Y') }} @emptyNo licences found. @endforelse
@endsection
diff --git a/Modules/Licensing/Resources/views/admin/show.blade.php b/Modules/Licensing/Resources/views/admin/show.blade.php
new file mode 100644
index 0000000..42f12b3
--- /dev/null
+++ b/Modules/Licensing/Resources/views/admin/show.blade.php
@@ -0,0 +1,3 @@
+@extends('licensing::admin.layout')
+@section('title', 'Licence details')
+@section('content')Licence details Customer and licence Customer {{ $license->customer_name ?? '—' }} Email {{ $license->customer_email ?? '—' }} Product {{ $license->product?->name ?? '—' }} Edition {{ $license->edition }} Key (last four only) •••• {{ $license->license_key_last4 }} Status {{ $license->is_usable ? 'Usable' : 'Inactive' }} Subscription Provider {{ $license->payment_provider ?? '—' }} Subscription status {{ $license->subscription_status ?? '—' }} Period ends {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }} Expires {{ $license->expires_at?->format('d M Y') ?? '—' }} Activation limit {{ $license->activation_limit }} Device activations Platform Version Activated Last seen Status @forelse($license->activations as $activation){{ $activation->platform ?? '—' }} {{ $activation->app_version ?? '—' }} {{ $activation->activated_at?->format('d M Y H:i') ?? '—' }} {{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }} {{ $activation->deactivated_at ? 'Deactivated' : 'Active' }} @emptyNo device activations. @endforelse
@endsection
diff --git a/Modules/Licensing/Resources/views/admin/subscriptions.blade.php b/Modules/Licensing/Resources/views/admin/subscriptions.blade.php
new file mode 100644
index 0000000..a4817f6
--- /dev/null
+++ b/Modules/Licensing/Resources/views/admin/subscriptions.blade.php
@@ -0,0 +1,25 @@
+@extends('licensing::admin.layout')
+@section('title', 'SonarDesk Subscriptions')
+@section('content')
+Subscriptions
+Read-only subscription records stored in ABSDev. This is not a live Paddle reconciliation.
+
+
Subscription-linked licences
{{ number_format($total) }}
+
Active / trialing
{{ number_format($active) }}
+
Cancellation scheduled
{{ number_format($cancelScheduled) }}
+
+Customer Product Provider Status Period ends Cancellation Licence
+@forelse($subscriptions as $subscription)
+
+{{ $subscription->customer_email ?: '—' }}
+{{ $subscription->product?->name ?? '—' }}
+{{ $subscription->payment_provider ?: '—' }}
+{{ $subscription->subscription_status ?: 'Unknown' }}
+{{ $subscription->current_period_ends_at?->format('d M Y') ?? '—' }}
+{{ $subscription->cancel_scheduled_at?->format('d M Y') ?? '—' }}
+View licence
+
+@emptyNo subscription-linked licences in the local database. @endforelse
+
+
+@endsection
diff --git a/Modules/Licensing/Resources/views/index.blade.php b/Modules/Licensing/Resources/views/index.blade.php
new file mode 100644
index 0000000..4277b06
--- /dev/null
+++ b/Modules/Licensing/Resources/views/index.blade.php
@@ -0,0 +1,9 @@
+@extends('licensing::layouts.master')
+
+@section('content')
+ Hello World
+
+
+ This view is loaded from module: {!! config('licensing.name') !!}
+
+@endsection
diff --git a/Modules/Licensing/Resources/views/layouts/master.blade.php b/Modules/Licensing/Resources/views/layouts/master.blade.php
new file mode 100644
index 0000000..1e51f1c
--- /dev/null
+++ b/Modules/Licensing/Resources/views/layouts/master.blade.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+ Module Licensing
+
+ {{-- Laravel Mix - CSS File --}}
+ {{-- --}}
+
+
+
+ @yield('content')
+
+ {{-- Laravel Mix - JS File --}}
+ {{-- --}}
+
+
diff --git a/Modules/Licensing/Routes/.gitkeep b/Modules/Licensing/Routes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Routes/admin.php b/Modules/Licensing/Routes/admin.php
new file mode 100644
index 0000000..e11b495
--- /dev/null
+++ b/Modules/Licensing/Routes/admin.php
@@ -0,0 +1,15 @@
+prefix('administration/sonardesk')
+ ->name('administration.sonardesk.')
+ ->group(function (): void {
+ Route::get('/', [LicensingDashboardController::class, 'index'])->name('index');
+ Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses');
+ Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions');
+ Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show');
+ });
diff --git a/Modules/Licensing/Routes/api.php b/Modules/Licensing/Routes/api.php
new file mode 100644
index 0000000..a923714
--- /dev/null
+++ b/Modules/Licensing/Routes/api.php
@@ -0,0 +1,12 @@
+group(function (): void {
+ Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
+ Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
+ Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
+ Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
+});
diff --git a/Modules/Licensing/Routes/web.php b/Modules/Licensing/Routes/web.php
new file mode 100644
index 0000000..cfe4d88
--- /dev/null
+++ b/Modules/Licensing/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('portal')->name('portal.')->group(function (): void {
+ Route::get('/', [PortalController::class, 'index'])->name('index');
+ Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
+ Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
+ Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
+ Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
+ Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
+});
diff --git a/Modules/Licensing/Services/LicenseDeliveryService.php b/Modules/Licensing/Services/LicenseDeliveryService.php
new file mode 100755
index 0000000..053600d
--- /dev/null
+++ b/Modules/Licensing/Services/LicenseDeliveryService.php
@@ -0,0 +1,54 @@
+with('license')
+ ->whereNull('delivered_at')
+ ->whereNotNull('recipient_email')
+ ->where('recipient_email', '!=', '')
+ ->oldest()
+ ->limit(max(1, min($limit, 100)))
+ ->get();
+
+ foreach ($pending as $outbox) {
+ $license = $outbox->license;
+ if (! $license) {
+ continue;
+ }
+
+ $plain = Crypt::decryptString($outbox->encrypted_license_key);
+ $recipient = strtolower(trim((string) $outbox->recipient_email));
+ if ($recipient === '') {
+ continue;
+ }
+
+ $data = [
+ 'customerName' => $license->customer_name,
+ 'edition' => $license->edition,
+ 'licenseKey' => $plain,
+ 'expiresAt' => $license->expires_at,
+ 'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
+ 'downloadUrl' => route('release'),
+ ];
+
+ Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
+ $message->to($recipient)->subject('Your SonarDesk licence key');
+ });
+
+ $outbox->update(['delivered_at' => now()]);
+ $delivered++;
+ }
+
+ return $delivered;
+ }
+}
diff --git a/Modules/Licensing/Services/LicenseIssuer.php b/Modules/Licensing/Services/LicenseIssuer.php
new file mode 100755
index 0000000..7171dd8
--- /dev/null
+++ b/Modules/Licensing/Services/LicenseIssuer.php
@@ -0,0 +1,46 @@
+where('external_order_id', $orderID)->first();
+ if ($existing) {
+ return [$existing, null];
+ }
+ }
+ $plain = LicenseKey::generate();
+ $license = License::create([
+ 'license_product_id' => $product->id,
+ 'license_key_hash' => LicenseKey::hash($plain),
+ 'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
+ 'customer_name' => $name,
+ 'customer_email' => $email ? strtolower(trim($email)) : null,
+ 'edition' => $product->edition,
+ 'activation_limit' => $product->activation_limit,
+ 'max_major_version' => $product->max_major_version,
+ 'payment_provider' => $provider,
+ 'external_order_id' => $orderID,
+ 'external_customer_id' => $customerID,
+ ]);
+ if ($queueDelivery) {
+ LicenseDeliveryOutbox::updateOrCreate(
+ ['license_id' => $license->id],
+ ['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
+ );
+ }
+
+ return [$license, $plain];
+ });
+ }
+}
diff --git a/Modules/Licensing/Services/LicenseKey.php b/Modules/Licensing/Services/LicenseKey.php
new file mode 100755
index 0000000..4e2501f
--- /dev/null
+++ b/Modules/Licensing/Services/LicenseKey.php
@@ -0,0 +1,27 @@
+privateSeed();
+ $pair = sodium_crypto_sign_seed_keypair($seed);
+ $secret = sodium_crypto_sign_secretkey($pair);
+
+ $payload = [
+ 'customerEmail' => $license->customer_email,
+ 'customerName' => $license->customer_name,
+ 'edition' => $license->edition,
+ 'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
+ 'installationID' => strtolower($installationID),
+ 'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
+ 'licenseID' => strtolower((string) $license->id),
+ 'maxMajorVersion' => $license->max_major_version,
+ ];
+ ksort($payload, SORT_STRING);
+ $json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
+ $sig = sodium_crypto_sign_detached($json, $secret);
+
+ return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
+ }
+
+ public function publicKeyBase64(): string
+ {
+ $pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
+
+ return base64_encode(sodium_crypto_sign_publickey($pair));
+ }
+
+ private function privateSeed(): string
+ {
+ $path = config('sonardesk.private_key_path');
+ if (! $path || ! is_file($path)) {
+ throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ }
+ $decoded = base64_decode(trim((string) file_get_contents($path)), true);
+ if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
+ throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ }
+
+ return $decoded;
+ }
+
+ private static function b64url(string $value): string
+ {
+ return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
+ }
+}
diff --git a/Modules/Licensing/Services/LicenseTokenVerifier.php b/Modules/Licensing/Services/LicenseTokenVerifier.php
new file mode 100755
index 0000000..052f633
--- /dev/null
+++ b/Modules/Licensing/Services/LicenseTokenVerifier.php
@@ -0,0 +1,58 @@
+decode($parts[1]);
+ $signature = $this->decode($parts[2]);
+ // Derive the verification key from the same private seed used by LicenseSigner.
+ // Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
+ // activation would sign a perfectly valid token with the new private key, then
+ // /validate would reject that same token using a stale public key.
+ $public = $this->publicKeyFromPrivateSeed();
+ if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
+ throw new RuntimeException('Licence token signature is invalid.');
+ }
+ $decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
+
+ return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
+ }
+
+ private function publicKeyFromPrivateSeed(): string
+ {
+ $path = config('sonardesk.private_key_path');
+ if (! $path || ! is_file($path)) {
+ throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
+ }
+
+ $seed = base64_decode(trim((string) file_get_contents($path)), true);
+ if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
+ throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
+ }
+
+ $pair = sodium_crypto_sign_seed_keypair($seed);
+
+ return sodium_crypto_sign_publickey($pair);
+ }
+
+ private function decode(string $value): string
+ {
+ $value = strtr($value, '-_', '+/');
+ $value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
+ $out = base64_decode($value, true);
+ if ($out === false) {
+ throw new RuntimeException('Invalid Base64URL value.');
+ }
+
+ return $out;
+ }
+}
diff --git a/Modules/Licensing/Support/.gitkeep b/Modules/Licensing/Support/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/Modules/Licensing/Support/ModuleInfo.php b/Modules/Licensing/Support/ModuleInfo.php
new file mode 100644
index 0000000..28b9983
--- /dev/null
+++ b/Modules/Licensing/Support/ModuleInfo.php
@@ -0,0 +1,26 @@
+ 'News'];
diff --git a/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php b/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
new file mode 100644
index 0000000..d939ffc
--- /dev/null
+++ b/News/Database/Migrations/2026_07_24_03042_create_news_articles_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('author_id')->nullable()->constrained('authors')->nullOnDelete();
+ $table->foreignId('category_id')->nullable()->constrained('categories')->nullOnDelete();
+ $table->string('title');
+ $table->string('slug')->unique();
+ $table->text('excerpt')->nullable();
+ $table->longText('content')->nullable();
+ $table->string('featured_image')->nullable();
+ $table->string('status')->default('draft')->index();
+ $table->boolean('is_featured')->default(false);
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamp('expires_at')->nullable();
+ $table->string('meta_title')->nullable();
+ $table->text('meta_description')->nullable();
+ $table->timestamps();
+ });
+ Schema::create('news_article_tag', function (Blueprint $table): void {
+ $table->foreignId('news_article_id')->constrained('news_articles')->cascadeOnDelete();
+ $table->foreignId('tag_id')->constrained('tags')->cascadeOnDelete();
+ $table->primary(['news_article_id', 'tag_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('news_article_tag');
+ Schema::dropIfExists('news_articles');
+ }
+};
diff --git a/News/Http/Controllers/NewsController.php b/News/Http/Controllers/NewsController.php
new file mode 100644
index 0000000..cc69cc2
--- /dev/null
+++ b/News/Http/Controllers/NewsController.php
@@ -0,0 +1,66 @@
+ News::with(['author', 'category'])->latest()->paginate(25)]);
+ }
+
+ public function create(): View
+ {
+ return view('news::admin.form', $this->lookups());
+ }
+
+ public function store(Request $r): RedirectResponse
+ {
+ $record = News::create($this->data($r));
+ $record->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.news.index')->with('status', 'News created.');
+ }
+
+ public function edit(News $news_article): View
+ {
+ return view('news::admin.form', array_merge($this->lookups(), ['record' => $news_article]));
+ }
+
+ public function update(Request $r, News $news_article): RedirectResponse
+ {
+ $news_article->update($this->data($r));
+ $news_article->tags()->sync($r->input('tag_ids', []));
+
+ return redirect()->route('administration.news.index')->with('status', 'News updated.');
+ }
+
+ public function destroy(News $news_article): RedirectResponse
+ {
+ $news_article->delete();
+
+ return back()->with('status', 'News deleted.');
+ }
+
+ private function lookups(): array
+ {
+ return ['authors' => Author::where('is_active', true)->orderBy('name')->get(), 'categories' => Category::where('is_active', true)->orderBy('name')->get(), 'tags' => Tag::orderBy('name')->get()];
+ }
+
+ private function data(Request $r): array
+ {
+ $d = $r->validate(['author_id' => ['nullable', 'exists:authors,id'], 'category_id' => ['nullable', 'exists:categories,id'], 'title' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255'], 'excerpt' => ['nullable', 'string'], 'content' => ['nullable', 'string'], 'featured_image' => ['nullable', 'string', 'max:255'], 'status' => ['required', 'in:draft,review,published,archived'], 'is_featured' => ['sometimes', 'boolean'], 'published_at' => ['nullable', 'date'], 'expires_at' => ['nullable', 'date', 'after:published_at'], 'meta_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string']]);
+ $d['is_featured'] = $r->boolean('is_featured');
+
+ return $d;
+ }
+}
diff --git a/News/Http/Controllers/PublicNewsController.php b/News/Http/Controllers/PublicNewsController.php
new file mode 100644
index 0000000..5783072
--- /dev/null
+++ b/News/Http/Controllers/PublicNewsController.php
@@ -0,0 +1,20 @@
+ News::published()->with(['author', 'category'])->latest('published_at')->paginate(12)]);
+ }
+
+ public function show(string $slug): View
+ {
+ return view('news::public.show', ['record' => News::published()->with(['author', 'category', 'tags'])->where('slug', $slug)->firstOrFail()]);
+ }
+}
diff --git a/News/Models/News.php b/News/Models/News.php
new file mode 100644
index 0000000..586c952
--- /dev/null
+++ b/News/Models/News.php
@@ -0,0 +1,48 @@
+ 'boolean', 'published_at' => 'datetime', 'expires_at' => 'datetime'];
+ }
+
+ public function author(): BelongsTo
+ {
+ return $this->belongsTo(Author::class);
+ }
+
+ public function category(): BelongsTo
+ {
+ return $this->belongsTo(Category::class);
+ }
+
+ public function tags(): BelongsToMany
+ {
+ return $this->belongsToMany(Tag::class, 'news_article_tag');
+ }
+
+ public function scopePublished(Builder $query): Builder
+ {
+ return $query->where('status', 'published')->where(fn (Builder $q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->where(fn (Builder $q) => $q->whereNull('expires_at')->orWhere('expires_at', '>', now()));
+ }
+}
diff --git a/News/Providers/NewsServiceProvider.php b/News/Providers/NewsServiceProvider.php
new file mode 100644
index 0000000..959998d
--- /dev/null
+++ b/News/Providers/NewsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('News', 'Config/config.php'), 'news');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('News', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('News', 'Resources/views'), 'news');
+ }
+}
diff --git a/News/Providers/RouteServiceProvider.php b/News/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..cfa0850
--- /dev/null
+++ b/News/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('News', 'Routes/web.php'));
+ }
+}
diff --git a/News/Resources/views/admin/form.blade.php b/News/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..2e122f0
--- /dev/null
+++ b/News/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+@csrf @isset($record) @method('PUT') @endisset None @foreach($authors as $author)author_id ?? null)==$author->id)>{{ $author->name }} @endforeachNone @foreach($categories as $category)category_id ?? null)==$category->id)>{{ $category->name }} @endforeach@foreach(['draft','review','published','archived'] as $status)status ?? 'draft')===$status)>{{ $status }} @endforeach
{{ old('excerpt',$record->excerpt ?? '') }} {{ old('content',$record->content ?? '') }} {{ old('meta_description',$record->meta_description ?? '') }} Save
diff --git a/News/Resources/views/admin/index.blade.php b/News/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..f8860b7
--- /dev/null
+++ b/News/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+News Manage publishing news.
Create @if(session('status')){{ session('status') }}
@endifName / title Status Actions @forelse($records as $record){{ $record->title ?? $record->name }} {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
diff --git a/News/Resources/views/public/index.blade.php b/News/Resources/views/public/index.blade.php
new file mode 100644
index 0000000..5413c83
--- /dev/null
+++ b/News/Resources/views/public/index.blade.php
@@ -0,0 +1,31 @@
+@extends('stylist-theme-saasland::layouts.app')
+
+@php
+ $title = 'News';
+ $description = 'The latest announcements, releases and company updates.';
+ $eyebrow = 'Updates';
+ $breadcrumbs = ['News' => null];
+@endphp
+
+@section('content')
+
+
+
+ @forelse($records as $record)
+
+
+
+
{{ optional($record->published_at)->format('j F Y') ?: 'Latest update' }}
+
+ @if($record->excerpt)
{{ $record->excerpt }}
@endif
+
Read update →
+
+
+ @empty
+
No news has been published yet.
+ @endforelse
+
+
+
+
+@endsection
diff --git a/News/Resources/views/public/show.blade.php b/News/Resources/views/public/show.blade.php
new file mode 100644
index 0000000..ddb3795
--- /dev/null
+++ b/News/Resources/views/public/show.blade.php
@@ -0,0 +1,23 @@
+@extends('stylist-theme-saasland::layouts.app')
+
+@php
+ $title = $record->title;
+ $description = $record->excerpt;
+ $eyebrow = 'News';
+ $breadcrumbs = ['News' => route('news.public.index'), $record->title => null];
+@endphp
+
+@section('content')
+
+
+
+
+ @if($record->author){{ $record->author->name }} @endif
+ @if($record->published_at){{ $record->published_at->format('j F Y') }} @endif
+
+ @if($record->excerpt){{ $record->excerpt }}
@endif
+ {!! nl2br(e($record->content)) !!}
+
+
+
+@endsection
diff --git a/News/Routes/web.php b/News/Routes/web.php
new file mode 100644
index 0000000..672eba1
--- /dev/null
+++ b/News/Routes/web.php
@@ -0,0 +1,16 @@
+prefix('administration/news')->name('administration.news.')->group(function (): void {
+ Route::get('/', [NewsController::class, 'index'])->name('index');
+ Route::get('/create', [NewsController::class, 'create'])->name('create');
+ Route::post('/', [NewsController::class, 'store'])->name('store');
+ Route::get('/{news_article}/edit', [NewsController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{news_article}', [NewsController::class, 'update'])->name('update');
+ Route::delete('/{news_article}', [NewsController::class, 'destroy'])->name('destroy');
+});
+Route::get('/news', [PublicNewsController::class, 'index'])->name('news.public.index');
+Route::get('/news/{slug}', [PublicNewsController::class, 'show'])->name('news.public.show');
diff --git a/News/Tests/Feature/ModuleBootTest.php b/News/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..dbe1c8a
--- /dev/null
+++ b/News/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('News', config('news.name'));
+ }
+}
diff --git a/News/module.json b/News/module.json
new file mode 100644
index 0000000..dcb3490
--- /dev/null
+++ b/News/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "News",
+ "alias": "news",
+ "version": "1.0.0",
+ "description": "News publishing module.",
+ "keywords": ["publishing", "news"],
+ "priority": 30,
+ "providers": ["Modules\\News\\Providers\\NewsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Newsletter/Config/config.php b/Newsletter/Config/config.php
new file mode 100644
index 0000000..293dea2
--- /dev/null
+++ b/Newsletter/Config/config.php
@@ -0,0 +1,3 @@
+ 'Newsletter'];
diff --git a/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php b/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php
new file mode 100644
index 0000000..613856a
--- /dev/null
+++ b/Newsletter/Database/Migrations/2026_07_24_04100_create_newsletter_tables.php
@@ -0,0 +1,57 @@
+id();
+ $t->string('name');
+ $t->text('description')->nullable();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('newsletter_subscribers', function (Blueprint $t): void {
+ $t->id();
+ $t->string('email')->unique();
+ $t->string('name')->nullable();
+ $t->string('status')->default('pending')->index();
+ $t->string('confirmation_token')->nullable()->unique();
+ $t->timestamp('confirmed_at')->nullable();
+ $t->timestamp('unsubscribed_at')->nullable();
+ $t->json('metadata')->nullable();
+ $t->timestamps();
+ });
+ Schema::create('newsletter_list_subscriber', function (Blueprint $t): void {
+ $t->foreignId('list_id')->constrained('newsletter_lists')->cascadeOnDelete();
+ $t->foreignId('subscriber_id')->constrained('newsletter_subscribers')->cascadeOnDelete();
+ $t->primary(['list_id', 'subscriber_id']);
+ });
+ Schema::create('newsletter_campaigns', function (Blueprint $t): void {
+ $t->id();
+ $t->foreignId('list_id')->nullable()->constrained('newsletter_lists')->nullOnDelete();
+ $t->string('name');
+ $t->string('subject');
+ $t->longText('content')->nullable();
+ $t->string('status')->default('draft')->index();
+ $t->timestamp('scheduled_at')->nullable();
+ $t->timestamp('sent_at')->nullable();
+ $t->unsignedInteger('sent_count')->default(0);
+ $t->unsignedInteger('open_count')->default(0);
+ $t->unsignedInteger('click_count')->default(0);
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('newsletter_campaigns');
+ Schema::dropIfExists('newsletter_list_subscriber');
+ Schema::dropIfExists('newsletter_subscribers');
+ Schema::dropIfExists('newsletter_lists');
+ }
+};
diff --git a/Newsletter/Http/Controllers/NewsletterController.php b/Newsletter/Http/Controllers/NewsletterController.php
new file mode 100644
index 0000000..8e37e2c
--- /dev/null
+++ b/Newsletter/Http/Controllers/NewsletterController.php
@@ -0,0 +1,52 @@
+ Subscriber::latest()->paginate(20), 'campaigns' => Campaign::latest()->limit(10)->get(), 'lists' => MailingList::orderBy('name')->get()]);
+ }
+
+ public function storeSubscriber(Request $r): RedirectResponse
+ {
+ $d = $r->validate(['email' => ['required', 'email', 'unique:newsletter_subscribers,email'], 'name' => ['nullable', 'string', 'max:255']]);
+ Subscriber::create($d + ['status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()]);
+
+ return back()->with('status', 'Subscriber added.');
+ }
+
+ public function storeCampaign(Request $r): RedirectResponse
+ {
+ Campaign::create($r->validate(['list_id' => ['nullable', 'exists:newsletter_lists,id'], 'name' => ['required', 'string', 'max:255'], 'subject' => ['required', 'string', 'max:255'], 'content' => ['nullable', 'string'], 'status' => ['required', 'in:draft,scheduled,sent'], 'scheduled_at' => ['nullable', 'date']]));
+
+ return back()->with('status', 'Campaign created.');
+ }
+
+ public function publicSubscribe(Request $r): RedirectResponse
+ {
+ $data = $r->validate(['email' => ['required', 'email', 'max:255'], 'name' => ['nullable', 'string', 'max:255']]);
+ Subscriber::query()->updateOrCreate(
+ ['email' => strtolower($data['email'])],
+ ['name' => $data['name'] ?? null, 'status' => 'confirmed', 'confirmation_token' => Str::random(48), 'confirmed_at' => now()]
+ );
+
+ return back()->with('status', 'Thank you for subscribing.');
+ }
+
+ public function destroySubscriber(Subscriber $subscriber): RedirectResponse
+ {
+ $subscriber->delete();
+
+ return back()->with('status', 'Subscriber deleted.');
+ }
+}
diff --git a/Newsletter/Models/Campaign.php b/Newsletter/Models/Campaign.php
new file mode 100644
index 0000000..924a6a8
--- /dev/null
+++ b/Newsletter/Models/Campaign.php
@@ -0,0 +1,17 @@
+ 'datetime', 'sent_at' => 'datetime'];
+ }
+}
diff --git a/Newsletter/Models/MailingList.php b/Newsletter/Models/MailingList.php
new file mode 100644
index 0000000..786b233
--- /dev/null
+++ b/Newsletter/Models/MailingList.php
@@ -0,0 +1,17 @@
+ 'boolean'];
+ }
+}
diff --git a/Newsletter/Models/Subscriber.php b/Newsletter/Models/Subscriber.php
new file mode 100644
index 0000000..9d0bd30
--- /dev/null
+++ b/Newsletter/Models/Subscriber.php
@@ -0,0 +1,17 @@
+ 'datetime', 'unsubscribed_at' => 'datetime', 'metadata' => 'array'];
+ }
+}
diff --git a/Newsletter/Providers/NewsletterServiceProvider.php b/Newsletter/Providers/NewsletterServiceProvider.php
new file mode 100644
index 0000000..d10726f
--- /dev/null
+++ b/Newsletter/Providers/NewsletterServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Newsletter', 'Config/config.php'), 'newsletter');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Newsletter', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Newsletter', 'Resources/views'), 'newsletter');
+ }
+}
diff --git a/Newsletter/Providers/RouteServiceProvider.php b/Newsletter/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..47472fc
--- /dev/null
+++ b/Newsletter/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Newsletter', 'Routes/web.php'));
+ }
+}
diff --git a/Newsletter/Resources/views/admin/index.blade.php b/Newsletter/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..93c0489
--- /dev/null
+++ b/Newsletter/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Newsletter Manage subscribers and email campaigns.
@if(session('status'))
{{ session('status') }}
@endif
@csrfAdd subscriber Add subscriber @csrfCreate campaign Draft Scheduled Create campaign Subscriber Status Confirmed @forelse($subscribers as $subscriber){{ $subscriber->email }}
{{ $subscriber->name }}
{{ ucfirst($subscriber->status) }} {{ optional($subscriber->confirmed_at)->format('j M Y') ?: '—' }} @csrf @method('DELETE')Delete @emptyNo subscribers yet. @endforelse
{{ $subscribers->links() }}
diff --git a/Newsletter/Routes/web.php b/Newsletter/Routes/web.php
new file mode 100644
index 0000000..045e8f9
--- /dev/null
+++ b/Newsletter/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/newsletter')->name('administration.newsletter.')->group(function (): void {
+ Route::get('/', [NewsletterController::class, 'index'])->name('index');
+ Route::post('/subscribers', [NewsletterController::class, 'storeSubscriber'])->name('subscribers.store');
+ Route::delete('/subscribers/{subscriber}', [NewsletterController::class, 'destroySubscriber'])->name('subscribers.destroy');
+ Route::post('/campaigns', [NewsletterController::class, 'storeCampaign'])->name('campaigns.store');
+});
+
+Route::post('/newsletter/subscribe', [NewsletterController::class, 'publicSubscribe'])->middleware('throttle:10,1')->name('newsletter.public.subscribe');
diff --git a/Newsletter/Tests/Feature/ModuleBootTest.php b/Newsletter/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..d76ba2b
--- /dev/null
+++ b/Newsletter/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Newsletter', config('newsletter.name'));
+ }
+}
diff --git a/Newsletter/module.json b/Newsletter/module.json
new file mode 100644
index 0000000..620f465
--- /dev/null
+++ b/Newsletter/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Newsletter",
+ "alias": "newsletter",
+ "version": "1.0.0",
+ "description": "Subscriber lists and email campaign management.",
+ "keywords": ["business", "newsletter"],
+ "priority": 41,
+ "providers": ["Modules\\Newsletter\\Providers\\NewsletterServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Pages/Config/config.php b/Pages/Config/config.php
new file mode 100644
index 0000000..68aabbf
--- /dev/null
+++ b/Pages/Config/config.php
@@ -0,0 +1,3 @@
+ 'Pages'];
diff --git a/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php b/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php
new file mode 100644
index 0000000..d4cb696
--- /dev/null
+++ b/Pages/Database/Migrations/2026_07_24_000100_create_pages_table.php
@@ -0,0 +1,29 @@
+id();
+ $table->string('title');
+ $table->string('slug');
+ $table->text('summary')->nullable();
+ $table->longText('content')->nullable();
+ $table->string('status')->default('draft');
+ $table->timestamp('published_at')->nullable();
+ $table->boolean('is_home')->default(false);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('pages');
+ }
+};
diff --git a/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php b/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php
new file mode 100644
index 0000000..ac8810e
--- /dev/null
+++ b/Pages/Database/Migrations/2026_07_24_030700_enhance_pages_for_publishing.php
@@ -0,0 +1,37 @@
+string('template')->default('default');
+ $table->timestamp('expires_at')->nullable();
+ $table->string('preview_token', 64)->nullable()->unique();
+ $table->foreignId('locked_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamp('locked_at')->nullable();
+ });
+ Schema::create('page_revisions', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('page_id')->constrained('pages')->cascadeOnDelete();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('title');
+ $table->longText('content')->nullable();
+ $table->json('snapshot');
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('page_revisions');
+ Schema::table('pages', function (Blueprint $table): void {
+ $table->dropConstrainedForeignId('locked_by');
+ $table->dropColumn(['template', 'expires_at', 'preview_token', 'locked_at']);
+ });
+ }
+};
diff --git a/Pages/Http/Controllers/PageController.php b/Pages/Http/Controllers/PageController.php
new file mode 100644
index 0000000..f9f2e02
--- /dev/null
+++ b/Pages/Http/Controllers/PageController.php
@@ -0,0 +1,151 @@
+withCount('contentPlacements')
+ ->when($request->string('search')->isNotEmpty(), function ($query) use ($request): void {
+ $search = $request->string('search')->toString();
+ $query->where(fn ($page) => $page
+ ->where('title', 'like', "%{$search}%")
+ ->orWhere('slug', 'like', "%{$search}%")
+ ->orWhere('summary', 'like', "%{$search}%"));
+ })
+ ->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')->toString()))
+ ->orderByDesc('is_home')
+ ->orderBy('title')
+ ->paginate(24)
+ ->withQueryString();
+
+ return view('pages::admin.index', compact('records'));
+ }
+
+ public function create(): View
+ {
+ return view('pages::admin.form', ['record' => new Page(['status' => 'draft', 'template' => 'default'])]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $this->validated($request);
+ $data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title']);
+ $data['is_home'] = $request->boolean('is_home');
+ $data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? now()) : null;
+
+ $page = Page::query()->create($data);
+ $this->enforceSingleHomepage($page);
+
+ return redirect()->route('administration.pages.edit', $page)->with('status', 'Page created. Add content sections when you are ready.');
+ }
+
+ public function edit(Page $page): View
+ {
+ $page->loadCount('contentPlacements');
+
+ return view('pages::admin.form', ['record' => $page]);
+ }
+
+ public function update(Request $request, Page $page): RedirectResponse
+ {
+ $oldSlug = $page->slug;
+ $data = $this->validated($request, $page);
+ $data['slug'] = $this->uniqueSlug($data['slug'] ?: $data['title'], $page);
+ $data['is_home'] = $request->boolean('is_home');
+ $data['published_at'] = $data['status'] === 'published' ? ($data['published_at'] ?? $page->published_at ?? now()) : null;
+
+ $page->update($data);
+ $this->enforceSingleHomepage($page);
+
+ if ($oldSlug !== $page->slug) {
+ PageContent::query()->where('page_id', $page->id)->orWhere('page_key', $oldSlug)->update(['page_key' => $page->slug, 'page_id' => $page->id]);
+ }
+
+ return back()->with('status', 'Page saved.');
+ }
+
+ public function duplicate(Request $request, Page $page): RedirectResponse
+ {
+ $copy = $page->replicate(['slug', 'status', 'published_at', 'preview_token', 'locked_by', 'locked_at', 'is_home']);
+ $copy->title = $page->title.' copy';
+ $copy->slug = $this->uniqueSlug($copy->title);
+ $copy->status = 'draft';
+ $copy->published_at = null;
+ $copy->is_home = false;
+ $copy->save();
+
+ foreach ($page->contentPlacements()->orderBy('region')->orderBy('sort_order')->get() as $placement) {
+ $copy->contentPlacements()->create([
+ 'page_key' => $copy->slug,
+ 'content_document_id' => $placement->content_document_id,
+ 'region' => $placement->region,
+ 'sort_order' => $placement->sort_order,
+ 'is_enabled' => $placement->is_enabled,
+ 'visibility_rules' => $placement->visibility_rules,
+ 'presentation' => $placement->presentation,
+ 'starts_at' => $placement->starts_at,
+ 'ends_at' => $placement->ends_at,
+ ]);
+ }
+
+ return redirect()->route('administration.pages.edit', $copy)->with('status', 'Draft copy created with the same content structure.');
+ }
+
+ public function destroy(Page $page): RedirectResponse
+ {
+ if ($page->is_home) {
+ return back()->withErrors(['page' => 'Choose another homepage before deleting the current homepage.']);
+ }
+
+ $page->delete();
+
+ return redirect()->route('administration.pages.index')->with('status', 'Page moved to the bin.');
+ }
+
+ private function validated(Request $request, ?Page $page = null): array
+ {
+ return $request->validate([
+ 'title' => ['required', 'string', 'max:255'],
+ 'slug' => ['nullable', 'string', 'max:255', Rule::unique('pages', 'slug')->ignore($page?->id)->whereNull('deleted_at')],
+ 'summary' => ['nullable', 'string', 'max:1000'],
+ 'content' => ['nullable', 'string'],
+ 'status' => ['required', Rule::in(['draft', 'published'])],
+ 'template' => ['required', Rule::in(['default', 'landing', 'full-width'])],
+ 'published_at' => ['nullable', 'date'],
+ 'expires_at' => ['nullable', 'date', 'after:published_at'],
+ 'is_home' => ['sometimes', 'boolean'],
+ ]);
+ }
+
+ private function uniqueSlug(string $value, ?Page $except = null): string
+ {
+ $base = Str::slug($value) ?: 'page';
+ $slug = $base;
+ $suffix = 2;
+
+ while (Page::withTrashed()->where('slug', $slug)->when($except, fn ($query) => $query->whereKeyNot($except->getKey()))->exists()) {
+ $slug = $base.'-'.$suffix++;
+ }
+
+ return $slug;
+ }
+
+ private function enforceSingleHomepage(Page $page): void
+ {
+ if ($page->is_home) {
+ Page::query()->whereKeyNot($page->id)->where('is_home', true)->update(['is_home' => false]);
+ }
+ }
+}
diff --git a/Pages/Http/Controllers/PublicPageController.php b/Pages/Http/Controllers/PublicPageController.php
new file mode 100644
index 0000000..c9e531f
--- /dev/null
+++ b/Pages/Http/Controllers/PublicPageController.php
@@ -0,0 +1,17 @@
+where('slug', $slug)->where('status', 'published')->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail();
+
+ return view('pages::public.show', compact('page'));
+ }
+}
diff --git a/Pages/Models/Page.php b/Pages/Models/Page.php
new file mode 100644
index 0000000..22e2d3f
--- /dev/null
+++ b/Pages/Models/Page.php
@@ -0,0 +1,35 @@
+ 'datetime',
+ 'expires_at' => 'datetime',
+ 'is_home' => 'boolean',
+ ];
+ }
+
+ public function contentPlacements(): HasMany
+ {
+ return $this->hasMany(PageContent::class)->orderBy('region')->orderBy('sort_order');
+ }
+}
diff --git a/Pages/Providers/PagesServiceProvider.php b/Pages/Providers/PagesServiceProvider.php
new file mode 100644
index 0000000..0a1d101
--- /dev/null
+++ b/Pages/Providers/PagesServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Pages', 'Config/config.php'), 'pages');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Pages', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Pages', 'Resources/views'), 'pages');
+ }
+}
diff --git a/Pages/Providers/RouteServiceProvider.php b/Pages/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..7984a9e
--- /dev/null
+++ b/Pages/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Pages', 'Routes/web.php')));
+ }
+}
diff --git a/Pages/Resources/views/admin/form.blade.php b/Pages/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..3a513d2
--- /dev/null
+++ b/Pages/Resources/views/admin/form.blade.php
@@ -0,0 +1,87 @@
+
+
+
+
+
← Pages
+
+
{{ $record->exists ? $record->title : 'New page' }}
+ @if($record->exists && $record->is_home)Homepage @endif
+
+
Page details, search metadata, publishing and content structure.
+
+ @if($record->exists)
+
+ @if($record->status === 'published')Open live page @endif
+ Edit content
+
@csrfDuplicate
+
+ @endif
+
+
+ @if(session('status'))
{{ session('status') }}
@endif
+ @if($errors->any())
@foreach($errors->all() as $error){{ $error }} @endforeach @endif
+
+
+
+ Page details
+ SEO & URL
+ Publishing
+
+
+
+
+ @csrf @if($record->exists) @method('PUT') @endif
+
+
+
+ Page identity Use a clear title editors and visitors can recognise.
+
+ {{ old('summary', $record->summary) }}
+
+
+
+ Content structure Build the page with ordered, reusable sections in the visual composer.
@if($record->exists)
{{ $record->content_placements_count ?? 0 }} sections @endif
+ @if($record->exists)
+ Page composer Add hero areas, text, feeds, calls to action and shared site sections, then drag them into order.
Open content composer
+ @else
+ Save the page first, then add and arrange its content sections.
+ @endif
+ Legacy body content Kept for compatibility with older templates. New pages should use the content composer.
{{ old('content', $record->content) }}
+
+
+
+
+
+ URL and presentation Keep URLs short, readable and stable.
+
+ template ?: 'default') === 'default')>Default template) === 'landing')>Landing page template) === 'full-width')>Full width
+
+ Search preview {{ url('/') }}/
{{ old('title', $record->title ?: 'Page title') }}
{{ Str::limit(old('summary', $record->summary ?: 'Add a concise summary to improve how this page appears in search and content listings.'), 160) }}
+
+
+
+
+
+
+
Page status {{ ucfirst($record->status ?: 'draft') }} @if($record->exists)
Sections {{ $record->content_placements_count ?? 0 }}
Updated {{ $record->updated_at?->diffForHumans() }}
Template {{ ucfirst($record->template ?? 'default') }} @endif{{ $record->exists ? 'Save page' : 'Create page' }}
+ @if($record->exists && !$record->is_home)Danger zone Deleting a page also removes its page placements, but reusable content documents remain available.
@csrf @method('DELETE')Delete page @endif
+
+
+
+
+
+
diff --git a/Pages/Resources/views/admin/index.blade.php b/Pages/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..1858e3a
--- /dev/null
+++ b/Pages/Resources/views/admin/index.blade.php
@@ -0,0 +1,75 @@
+
+
+
+
+
Website
+
Pages
+
Create, organise, compose and publish every page from one place.
+
+
+ Content library
+ New page
+
+
+
+ @if(session('status'))
+
{{ session('status') }}
+ @endif
+ @if($errors->any())
+
{{ $errors->first() }}
+ @endif
+
+
+
+
+ All pages
+ Draft
+ Published
+
+ Filter Reset
+
+
+
+ @forelse($records as $record)
+ @php
+ $isLive = $record->status === 'published' && (!$record->published_at || $record->published_at->isPast()) && (!$record->expires_at || $record->expires_at->isFuture());
+ @endphp
+
+
+
+
+
{{ $record->title }}
+ @if($record->is_home)Homepage @endif
+ {{ $isLive ? 'Live' : ucfirst($record->status) }}
+
+
/{{ $record->slug }}
+
+
+
{{ $record->content_placements_count }}
+
sections
+
+
+
+ {{ Str::limit($record->summary ?: 'No page summary has been added yet.', 150) }}
+
+
+
Updated {{ $record->updated_at?->diffForHumans() }} · {{ ucfirst($record->template ?? 'default') }}
+
+ @if($record->status === 'published')View @endif
+ Content
+ Edit page
+
+
+
+ @empty
+
+
No pages found
+
Create your first page or clear the current filters.
+
Create page
+
+ @endforelse
+
+
+ {{ $records->links() }}
+
+
diff --git a/Pages/Resources/views/public/show.blade.php b/Pages/Resources/views/public/show.blade.php
new file mode 100644
index 0000000..f066034
--- /dev/null
+++ b/Pages/Resources/views/public/show.blade.php
@@ -0,0 +1,22 @@
+@extends('stylist-theme-saasland::layouts.app')
+
+@php
+ $title = $page->title;
+ $description = $page->summary;
+ $eyebrow = 'Page';
+ $breadcrumbs = [$page->title => null];
+@endphp
+
+@section('content')
+
+
+
+ @if ($page->summary)
+ {{ $page->summary }}
+ @endif
+
+ {!! $page->content !!}
+
+
+
+@endsection
diff --git a/Pages/Routes/web.php b/Pages/Routes/web.php
new file mode 100644
index 0000000..dadc7f4
--- /dev/null
+++ b/Pages/Routes/web.php
@@ -0,0 +1,17 @@
+prefix('administration/pages')->name('administration.pages.')->group(function (): void {
+ Route::get('/', [PageController::class, 'index'])->name('index');
+ Route::get('/create', [PageController::class, 'create'])->name('create');
+ Route::post('/', [PageController::class, 'store'])->name('store');
+ Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{page}', [PageController::class, 'update'])->name('update');
+ Route::post('/{page}/duplicate', [PageController::class, 'duplicate'])->name('duplicate');
+ Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy');
+});
+
+Route::get('/pages/{slug}', [PublicPageController::class, 'show'])->name('pages.public.show');
diff --git a/Pages/Tests/Feature/ModuleBootTest.php b/Pages/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..2a22cc4
--- /dev/null
+++ b/Pages/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,7 @@
+toBeTrue();
+});
diff --git a/Pages/module.json b/Pages/module.json
new file mode 100644
index 0000000..d891304
--- /dev/null
+++ b/Pages/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Pages",
+ "alias": "pages",
+ "version": "1.0.0",
+ "description": "Pages CMS module.",
+ "keywords": [
+ "cms",
+ "pages"
+ ],
+ "priority": 21,
+ "providers": [
+ "Modules\\Pages\\Providers\\PagesServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Products/Config/config.php b/Products/Config/config.php
new file mode 100644
index 0000000..3bfe783
--- /dev/null
+++ b/Products/Config/config.php
@@ -0,0 +1,3 @@
+ 'Products'];
diff --git a/Products/Database/Migrations/2026_07_24_105054_create_products_table.php b/Products/Database/Migrations/2026_07_24_105054_create_products_table.php
new file mode 100644
index 0000000..a009d1a
--- /dev/null
+++ b/Products/Database/Migrations/2026_07_24_105054_create_products_table.php
@@ -0,0 +1,23 @@
+id();
+ $table->string('name');
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('products');
+ }
+};
diff --git a/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php b/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php
new file mode 100644
index 0000000..f6b9a80
--- /dev/null
+++ b/Products/Database/Migrations/2026_07_24_150000_add_pricing_fields_to_products_table.php
@@ -0,0 +1,34 @@
+string('product_type')->default('product')->after('name');
+ $table->text('description')->nullable()->after('product_type');
+ $table->decimal('price', 12, 2)->nullable()->after('description');
+ $table->string('currency', 3)->default('GBP')->after('price');
+ $table->string('billing_period')->nullable()->after('currency');
+ $table->json('features')->nullable()->after('billing_period');
+ $table->string('cta_label')->nullable()->after('features');
+ $table->string('cta_url')->nullable()->after('cta_label');
+ $table->boolean('is_featured')->default(false)->after('cta_url');
+ $table->unsignedInteger('sort_order')->default(0)->after('is_featured');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('products', function (Blueprint $table): void {
+ $table->dropColumn([
+ 'product_type', 'description', 'price', 'currency', 'billing_period',
+ 'features', 'cta_label', 'cta_url', 'is_featured', 'sort_order',
+ ]);
+ });
+ }
+};
diff --git a/Products/Http/Controllers/ProductController.php b/Products/Http/Controllers/ProductController.php
new file mode 100644
index 0000000..c3ccf6f
--- /dev/null
+++ b/Products/Http/Controllers/ProductController.php
@@ -0,0 +1,23 @@
+ Product::query()->latest()->paginate()]);
+ }
+
+ public function store(StoreProductRequest $request): RedirectResponse
+ {
+ Product::query()->create($request->validated());
+
+ return back()->with('status', 'Product created.');
+ }
+}
diff --git a/Products/Http/Requests/StoreProductRequest.php b/Products/Http/Requests/StoreProductRequest.php
new file mode 100644
index 0000000..ae953bc
--- /dev/null
+++ b/Products/Http/Requests/StoreProductRequest.php
@@ -0,0 +1,45 @@
+user() !== null;
+ }
+
+ protected function prepareForValidation(): void
+ {
+ $this->merge([
+ 'is_active' => $this->boolean('is_active'),
+ 'is_featured' => $this->boolean('is_featured'),
+ 'features' => collect(preg_split('/\r\n|\r|\n/', (string) $this->input('features')))
+ ->map(fn (string $feature) => trim($feature))
+ ->filter()
+ ->values()
+ ->all(),
+ ]);
+ }
+
+ public function rules(): array
+ {
+ return [
+ 'name' => ['required', 'string', 'max:255'],
+ 'product_type' => ['required', 'in:product,plan'],
+ 'description' => ['nullable', 'string'],
+ 'price' => ['nullable', 'numeric', 'min:0'],
+ 'currency' => ['required', 'string', 'size:3'],
+ 'billing_period' => ['nullable', 'string', 'max:40'],
+ 'features' => ['array'],
+ 'features.*' => ['string', 'max:255'],
+ 'cta_label' => ['nullable', 'string', 'max:80'],
+ 'cta_url' => ['nullable', 'string', 'max:2048'],
+ 'is_featured' => ['boolean'],
+ 'sort_order' => ['integer', 'min:0'],
+ 'is_active' => ['boolean'],
+ ];
+ }
+}
diff --git a/Products/Models/Product.php b/Products/Models/Product.php
new file mode 100644
index 0000000..30dbf31
--- /dev/null
+++ b/Products/Models/Product.php
@@ -0,0 +1,21 @@
+ 'decimal:2',
+ 'features' => 'array',
+ 'is_active' => 'boolean',
+ 'is_featured' => 'boolean',
+ 'sort_order' => 'integer',
+ ];
+ }
+}
diff --git a/Products/Providers/ProductsServiceProvider.php b/Products/Providers/ProductsServiceProvider.php
new file mode 100644
index 0000000..98d5e46
--- /dev/null
+++ b/Products/Providers/ProductsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Products', 'Config/config.php'), 'products');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Products', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Products', 'Resources/views'), 'products');
+ }
+}
diff --git a/Products/Providers/RouteServiceProvider.php b/Products/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..337a787
--- /dev/null
+++ b/Products/Providers/RouteServiceProvider.php
@@ -0,0 +1,14 @@
+group(module_path('Products', 'Routes/web.php'));
+ }
+}
diff --git a/Products/Resources/views/admin/products/form.blade.php b/Products/Resources/views/admin/products/form.blade.php
new file mode 100644
index 0000000..a98563c
--- /dev/null
+++ b/Products/Resources/views/admin/products/form.blade.php
@@ -0,0 +1,25 @@
+
+ @csrf
+
+
+
+ Product
+ Pricing plan
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ __('Save Product') }}
+
diff --git a/Products/Resources/views/admin/products/index.blade.php b/Products/Resources/views/admin/products/index.blade.php
new file mode 100644
index 0000000..757785c
--- /dev/null
+++ b/Products/Resources/views/admin/products/index.blade.php
@@ -0,0 +1,26 @@
+
+
+
+
Products & Pricing
+
Manage catalogue products and public-facing pricing plans from one place.
+
+
+ @include('products::admin.products.form')
+
+
+ @forelse ($records as $record)
+
+
+
{{ $record->name }} {{ ucfirst($record->product_type ?? 'product') }}
+ @if($record->is_featured)
Featured @endif
+
+ @if($record->price !== null){{ $record->currency }} {{ $record->price }} /{{ $record->billing_period }}
@elseCustom
@endif
+ {{ $record->description }}
+
+ @empty
+
No products or plans have been created.
+ @endforelse
+
+ {{ $records->links() }}
+
+
diff --git a/Products/Routes/web.php b/Products/Routes/web.php
new file mode 100644
index 0000000..eecbc02
--- /dev/null
+++ b/Products/Routes/web.php
@@ -0,0 +1,7 @@
+prefix('administration/products')->name('administration.products.')->group(function (): void {
+ // Register module routes here.
+});
diff --git a/Products/Tests/Feature/ProductCrudTest.php b/Products/Tests/Feature/ProductCrudTest.php
new file mode 100644
index 0000000..f596dc9
--- /dev/null
+++ b/Products/Tests/Feature/ProductCrudTest.php
@@ -0,0 +1,7 @@
+create(['name' => 'Example']))->toBeInstanceOf(Product::class);
+});
diff --git a/Products/Tests/Feature/ProductsModuleBootTest.php b/Products/Tests/Feature/ProductsModuleBootTest.php
new file mode 100644
index 0000000..f89049e
--- /dev/null
+++ b/Products/Tests/Feature/ProductsModuleBootTest.php
@@ -0,0 +1,7 @@
+providerIsLoaded(ProductsServiceProvider::class))->toBeTrue();
+});
diff --git a/Products/module.json b/Products/module.json
new file mode 100644
index 0000000..5473559
--- /dev/null
+++ b/Products/module.json
@@ -0,0 +1,19 @@
+{
+ "name": "Products",
+ "alias": "products",
+ "version": "1.0.0",
+ "description": "Products module.",
+ "keywords": [
+ "products"
+ ],
+ "priority": 100,
+ "providers": [
+ "Modules\\Products\\Providers\\ProductsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Redirects/Config/config.php b/Redirects/Config/config.php
new file mode 100644
index 0000000..e5170bd
--- /dev/null
+++ b/Redirects/Config/config.php
@@ -0,0 +1,3 @@
+ 'Redirects'];
diff --git a/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php b/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php
new file mode 100644
index 0000000..5635594
--- /dev/null
+++ b/Redirects/Database/Migrations/2026_07_24_000300_create_redirects_table.php
@@ -0,0 +1,26 @@
+id();
+ $table->string('source');
+ $table->string('destination');
+ $table->unsignedInteger('status_code')->default(301);
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('redirects');
+ }
+};
diff --git a/Redirects/Http/Controllers/RedirectController.php b/Redirects/Http/Controllers/RedirectController.php
new file mode 100644
index 0000000..efb0a93
--- /dev/null
+++ b/Redirects/Http/Controllers/RedirectController.php
@@ -0,0 +1,58 @@
+ Redirect::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('redirects::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['source' => ['required', 'string', 'max:255'],
+ 'destination' => ['required', 'string', 'max:255'],
+ 'status_code' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Redirect::create($data);
+
+ return redirect()->route('administration.redirects.index')->with('status', 'Redirect created.');
+ }
+
+ public function edit(Redirect $redirect): View
+ {
+ return view('redirects::admin.form', ['record' => $redirect]);
+ }
+
+ public function update(Request $request, Redirect $redirect): RedirectResponse
+ {
+ $data = $request->validate(['source' => ['required', 'string', 'max:255'],
+ 'destination' => ['required', 'string', 'max:255'],
+ 'status_code' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $redirect->update($data);
+
+ return redirect()->route('administration.redirects.index')->with('status', 'Redirect updated.');
+ }
+
+ public function destroy(Redirect $redirect): RedirectResponse
+ {
+ $redirect->delete();
+
+ return back()->with('status', 'Redirect deleted.');
+ }
+}
diff --git a/Redirects/Models/Redirect.php b/Redirects/Models/Redirect.php
new file mode 100644
index 0000000..ba4db4e
--- /dev/null
+++ b/Redirects/Models/Redirect.php
@@ -0,0 +1,24 @@
+ 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Redirects/Providers/RedirectsServiceProvider.php b/Redirects/Providers/RedirectsServiceProvider.php
new file mode 100644
index 0000000..d32dbda
--- /dev/null
+++ b/Redirects/Providers/RedirectsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Redirects', 'Config/config.php'), 'redirects');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Redirects', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Redirects', 'Resources/views'), 'redirects');
+ }
+}
diff --git a/Redirects/Providers/RouteServiceProvider.php b/Redirects/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ba5a142
--- /dev/null
+++ b/Redirects/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Redirects', 'Routes/web.php')));
+ }
+}
diff --git a/Redirects/Resources/views/admin/form.blade.php b/Redirects/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..18dccf9
--- /dev/null
+++ b/Redirects/Resources/views/admin/form.blade.php
@@ -0,0 +1,8 @@
+
+
+
diff --git a/Redirects/Resources/views/admin/index.blade.php b/Redirects/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..cf7590b
--- /dev/null
+++ b/Redirects/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+Redirects Manage redirects content.
Add new
+@if(session('status'))
{{ session('status') }}
@endif
+
Source Destination Status Code Actions @forelse($records as $record){{ is_array($record->source) ? json_encode($record->source) : Str::limit((string) $record->source, 70) }} {{ is_array($record->destination) ? json_encode($record->destination) : Str::limit((string) $record->destination, 70) }} {{ is_array($record->status_code) ? json_encode($record->status_code) : Str::limit((string) $record->status_code, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Redirects/Routes/web.php b/Redirects/Routes/web.php
new file mode 100644
index 0000000..9faf228
--- /dev/null
+++ b/Redirects/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/redirects')->name('administration.redirects.')->group(function (): void {
+ Route::get('/', [RedirectController::class, 'index'])->name('index');
+ Route::get('/create', [RedirectController::class, 'create'])->name('create');
+ Route::post('/', [RedirectController::class, 'store'])->name('store');
+ Route::get('/{redirect}/edit', [RedirectController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{redirect}', [RedirectController::class, 'update'])->name('update');
+ Route::delete('/{redirect}', [RedirectController::class, 'destroy'])->name('destroy');
+});
diff --git a/Redirects/Support/ModuleInfo.php b/Redirects/Support/ModuleInfo.php
new file mode 100644
index 0000000..a226dac
--- /dev/null
+++ b/Redirects/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Redirects/module.json b/Redirects/module.json
new file mode 100644
index 0000000..cebfd49
--- /dev/null
+++ b/Redirects/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Redirects",
+ "alias": "redirects",
+ "version": "1.0.0",
+ "description": "Redirects CMS module.",
+ "keywords": [
+ "cms",
+ "redirects"
+ ],
+ "priority": 23,
+ "providers": [
+ "Modules\\Redirects\\Providers\\RedirectsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Roles/Config/config.php b/Roles/Config/config.php
new file mode 100644
index 0000000..60fa263
--- /dev/null
+++ b/Roles/Config/config.php
@@ -0,0 +1,6 @@
+ 'Roles',
+ 'enabled' => true,
+];
diff --git a/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php b/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php
new file mode 100644
index 0000000..d74934a
--- /dev/null
+++ b/Roles/Database/Migrations/2026_07_23_000050_create_permission_tables.php
@@ -0,0 +1,119 @@
+id();
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+
+ $table->unique(['name', 'guard_name']);
+ });
+
+ Schema::create($tableNames['roles'], function (Blueprint $table): void {
+ $table->id();
+ $table->string('name');
+ $table->string('guard_name');
+ $table->timestamps();
+
+ $table->unique(['name', 'guard_name']);
+ });
+
+ Schema::create($tableNames['model_has_permissions'], function (Blueprint $table) use (
+ $tableNames,
+ $pivotPermission,
+ $modelMorphKey
+ ): void {
+ $table->unsignedBigInteger($pivotPermission);
+ $table->string('model_type');
+ $table->unsignedBigInteger($modelMorphKey);
+
+ $table->index([$modelMorphKey, 'model_type'], 'model_has_permissions_model_id_model_type_index');
+ $table->foreign($pivotPermission)
+ ->references('id')
+ ->on($tableNames['permissions'])
+ ->cascadeOnDelete();
+
+ $table->primary(
+ [$pivotPermission, $modelMorphKey, 'model_type'],
+ 'model_has_permissions_permission_model_type_primary'
+ );
+ });
+
+ Schema::create($tableNames['model_has_roles'], function (Blueprint $table) use (
+ $tableNames,
+ $pivotRole,
+ $modelMorphKey
+ ): void {
+ $table->unsignedBigInteger($pivotRole);
+ $table->string('model_type');
+ $table->unsignedBigInteger($modelMorphKey);
+
+ $table->index([$modelMorphKey, 'model_type'], 'model_has_roles_model_id_model_type_index');
+ $table->foreign($pivotRole)
+ ->references('id')
+ ->on($tableNames['roles'])
+ ->cascadeOnDelete();
+
+ $table->primary(
+ [$pivotRole, $modelMorphKey, 'model_type'],
+ 'model_has_roles_role_model_type_primary'
+ );
+ });
+
+ Schema::create($tableNames['role_has_permissions'], function (Blueprint $table) use (
+ $tableNames,
+ $pivotRole,
+ $pivotPermission
+ ): void {
+ $table->unsignedBigInteger($pivotPermission);
+ $table->unsignedBigInteger($pivotRole);
+
+ $table->foreign($pivotPermission)
+ ->references('id')
+ ->on($tableNames['permissions'])
+ ->cascadeOnDelete();
+ $table->foreign($pivotRole)
+ ->references('id')
+ ->on($tableNames['roles'])
+ ->cascadeOnDelete();
+
+ $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
+ });
+
+ app('cache')->store(config('permission.cache.store') === 'default'
+ ? null
+ : config('permission.cache.store'))
+ ->forget(config('permission.cache.key'));
+ }
+
+ public function down(): void
+ {
+ $tableNames = config('permission.table_names');
+
+ Schema::dropIfExists($tableNames['role_has_permissions']);
+ Schema::dropIfExists($tableNames['model_has_roles']);
+ Schema::dropIfExists($tableNames['model_has_permissions']);
+ Schema::dropIfExists($tableNames['roles']);
+ Schema::dropIfExists($tableNames['permissions']);
+ }
+};
diff --git a/Roles/Database/Seeders/RolePermissionSeeder.php b/Roles/Database/Seeders/RolePermissionSeeder.php
new file mode 100644
index 0000000..9322669
--- /dev/null
+++ b/Roles/Database/Seeders/RolePermissionSeeder.php
@@ -0,0 +1,96 @@
+forgetCachedPermissions();
+
+ $permissions = [
+ 'administration.access',
+ 'users.view',
+ 'users.create',
+ 'users.update',
+ 'users.delete',
+ 'roles.manage',
+ 'settings.manage',
+ 'audit.view',
+ 'media.view',
+ 'media.upload',
+ 'media.update',
+ 'media.delete',
+ 'seo.manage',
+ 'search.manage',
+ 'themes.manage',
+ 'pages.manage',
+ 'menus.manage',
+ 'redirects.manage',
+ 'forms.manage',
+ 'documents.manage',
+ 'banners.manage',
+ 'faq.manage',
+ 'team.manage',
+ 'testimonials.manage',
+ 'blog.manage',
+ 'news.manage',
+ 'categories.manage',
+ 'tags.manage',
+ 'authors.manage',
+ 'comments.moderate',
+ 'crm.manage',
+ 'newsletter.manage',
+ 'events.manage',
+ 'careers.manage',
+ 'locations.manage',
+ 'workflow.view',
+ 'workflow.manage',
+ 'workflow.review',
+ 'localization.manage',
+ 'localization.translate',
+ 'sites.view',
+ 'sites.manage',
+ 'sites.assign',
+ 'developer.access',
+ 'api.view',
+ 'api.manage',
+ 'enterprise.view',
+ 'enterprise.manage',
+ 'commerce.view',
+ 'commerce.manage',
+ 'commerce.orders.manage',
+ 'commerce.products.manage',
+ 'security.view',
+ 'security.manage',
+ 'security.audit',
+ 'automation.view',
+ 'automation.manage',
+ 'automation.execute',
+ 'ai.view',
+ 'ai.manage',
+ 'ai.execute',
+ 'analytics.view',
+ 'analytics.manage',
+ 'analytics.export',
+ 'builder.view', 'builder.manage', 'builder.publish',
+ 'marketplace.view', 'marketplace.install', 'marketplace.update', 'marketplace.remove',
+ 'cloud-devops.view', 'cloud-devops.manage', 'cloud-devops.deploy', 'cloud-devops.rollback',
+ ];
+
+ foreach ($permissions as $permission) {
+ Permission::findOrCreate($permission, 'web');
+ }
+
+ $administrator = Role::findOrCreate('admin', 'web');
+ $administrator->syncPermissions($permissions);
+
+ $registrar->forgetCachedPermissions();
+ }
+}
diff --git a/Roles/Http/Controllers/RoleController.php b/Roles/Http/Controllers/RoleController.php
new file mode 100644
index 0000000..d5b0e16
--- /dev/null
+++ b/Roles/Http/Controllers/RoleController.php
@@ -0,0 +1,58 @@
+ Role::withCount(['users', 'permissions'])->orderBy('name')->get()]);
+ }
+
+ public function create()
+ {
+ return view('roles::form', ['role' => new Role(['guard_name' => 'web']), 'permissions' => Permission::orderBy('name')->get()]);
+ }
+
+ public function store(Request $request)
+ {
+ $data = $this->validated($request);
+ $role = Role::create(['name' => $data['name'], 'guard_name' => 'web']);
+ $role->syncPermissions($data['permissions'] ?? []);
+
+ return redirect()->route('administration.roles.index')->with('status', 'Role created.');
+ }
+
+ public function edit(Role $role)
+ {
+ return view('roles::form', ['role' => $role->load('permissions'), 'permissions' => Permission::orderBy('name')->get()]);
+ }
+
+ public function update(Request $request, Role $role)
+ {
+ $data = $this->validated($request, $role);
+ $role->update(['name' => $data['name']]);
+ $role->syncPermissions($data['permissions'] ?? []);
+
+ return redirect()->route('administration.roles.index')->with('status', 'Role updated.');
+ }
+
+ public function destroy(Role $role)
+ {
+ abort_if($role->name === 'admin', 422, 'The administrator role cannot be deleted.');
+ $role->delete();
+
+ return back()->with('status', 'Role deleted.');
+ }
+
+ private function validated(Request $request, ?Role $role = null): array
+ {
+ return $request->validate(['name' => ['required', 'string', 'max:100', Rule::unique('roles', 'name')->ignore($role)], 'permissions' => ['array'], 'permissions.*' => ['string', 'exists:permissions,name']]);
+ }
+}
diff --git a/Roles/Providers/RolesServiceProvider.php b/Roles/Providers/RolesServiceProvider.php
new file mode 100644
index 0000000..120c1c0
--- /dev/null
+++ b/Roles/Providers/RolesServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Roles/Providers/RouteServiceProvider.php b/Roles/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..a8104fd
--- /dev/null
+++ b/Roles/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Roles', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Roles/Resources/views/form.blade.php b/Roles/Resources/views/form.blade.php
new file mode 100644
index 0000000..202fec9
--- /dev/null
+++ b/Roles/Resources/views/form.blade.php
@@ -0,0 +1 @@
+
{{ $role->exists ? 'Edit role' : 'Create role' }} @csrf @if($role->exists)@method('PUT')@endifCancel Save
diff --git a/Roles/Resources/views/index.blade.php b/Roles/Resources/views/index.blade.php
new file mode 100644
index 0000000..6ccb021
--- /dev/null
+++ b/Roles/Resources/views/index.blade.php
@@ -0,0 +1 @@
+Roles & permissions Control administrative access.
Create role @if(session('status'))
{{ session('status') }}
@endif
@foreach($roles as $role)
{{ $role->name }}
{{ $role->users_count }} users · {{ $role->permissions_count }} permissions
Edit @if($role->name!=='admin')
@csrf @method('DELETE')Delete @endif@endforeach
diff --git a/Roles/Routes/web.php b/Roles/Routes/web.php
new file mode 100644
index 0000000..f611599
--- /dev/null
+++ b/Roles/Routes/web.php
@@ -0,0 +1,8 @@
+prefix('administration/roles')->name('administration.roles.')->group(function (): void {
+ Route::resource('', RoleController::class)->parameters(['' => 'role'])->except(['show']);
+});
diff --git a/Roles/Support/ModuleInfo.php b/Roles/Support/ModuleInfo.php
new file mode 100644
index 0000000..339da0a
--- /dev/null
+++ b/Roles/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Roles');
+});
diff --git a/Roles/composer.json b/Roles/composer.json
new file mode 100644
index 0000000..0bdaa9f
--- /dev/null
+++ b/Roles/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/roles-module",
+ "description": "Roles foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Roles\\": ""
+ }
+ }
+}
diff --git a/Roles/module.json b/Roles/module.json
new file mode 100644
index 0000000..b3f52b7
--- /dev/null
+++ b/Roles/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Roles",
+ "alias": "roles",
+ "version": "1.0.0",
+ "description": "Roles foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "roles"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Roles\\Providers\\RolesServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Search/Config/config.php b/Search/Config/config.php
new file mode 100644
index 0000000..9b3b517
--- /dev/null
+++ b/Search/Config/config.php
@@ -0,0 +1,6 @@
+ 'Search',
+ 'enabled' => true,
+];
diff --git a/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php b/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php
new file mode 100644
index 0000000..ca700a7
--- /dev/null
+++ b/Search/Database/Migrations/2026_07_23_000500_create_search_index_table.php
@@ -0,0 +1,30 @@
+id();
+ $table->morphs('searchable');
+ $table->string('title')->index();
+ $table->text('summary')->nullable();
+ $table->longText('content')->nullable();
+ $table->string('url')->nullable();
+ $table->json('metadata')->nullable();
+ $table->boolean('is_public')->default(true)->index();
+ $table->timestamp('published_at')->nullable()->index();
+ $table->timestamps();
+ $table->unique(['searchable_type', 'searchable_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('search_index');
+ }
+};
diff --git a/Search/Http/Controllers/SearchController.php b/Search/Http/Controllers/SearchController.php
new file mode 100644
index 0000000..9c591c6
--- /dev/null
+++ b/Search/Http/Controllers/SearchController.php
@@ -0,0 +1,18 @@
+query('q'));
+ $results = DB::table('search_index')->when($q !== '', fn ($query) => $query->where(fn ($w) => $w->where('title', 'like', "%{$q}%")->orWhere('summary', 'like', "%{$q}%")->orWhere('content', 'like', "%{$q}%")))->latest('updated_at')->paginate(25)->withQueryString();
+
+ return view('search::index', compact('results', 'q'));
+ }
+}
diff --git a/Search/Providers/RouteServiceProvider.php b/Search/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..d94acd2
--- /dev/null
+++ b/Search/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Search', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Search/Providers/SearchServiceProvider.php b/Search/Providers/SearchServiceProvider.php
new file mode 100644
index 0000000..12ad9c9
--- /dev/null
+++ b/Search/Providers/SearchServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Search/Resources/views/index.blade.php b/Search/Resources/views/index.blade.php
new file mode 100644
index 0000000..07ea084
--- /dev/null
+++ b/Search/Resources/views/index.blade.php
@@ -0,0 +1 @@
+Search index Inspect content currently available to platform search.
Search @forelse($results as $result)
{{ $result->title }}
{{ str($result->summary ?: $result->content)->limit(180) }}
{{ $result->searchable_type }} #{{ $result->searchable_id }}
@empty
No indexed content found.
@endforelse
{{ $results->links() }}
diff --git a/Search/Routes/web.php b/Search/Routes/web.php
new file mode 100644
index 0000000..ad6c1f7
--- /dev/null
+++ b/Search/Routes/web.php
@@ -0,0 +1,6 @@
+get('administration/search', [SearchController::class, 'index'])->name('administration.search.index');
diff --git a/Search/Support/ModuleInfo.php b/Search/Support/ModuleInfo.php
new file mode 100644
index 0000000..98f93c7
--- /dev/null
+++ b/Search/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Search');
+});
diff --git a/Search/composer.json b/Search/composer.json
new file mode 100644
index 0000000..62e3b1a
--- /dev/null
+++ b/Search/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/search-module",
+ "description": "Search foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Search\\": ""
+ }
+ }
+}
diff --git a/Search/module.json b/Search/module.json
new file mode 100644
index 0000000..6eda4a2
--- /dev/null
+++ b/Search/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Search",
+ "alias": "search",
+ "version": "1.0.0",
+ "description": "Search foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "search"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Search\\Providers\\SearchServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Security/Config/config.php b/Security/Config/config.php
new file mode 100644
index 0000000..b4e3156
--- /dev/null
+++ b/Security/Config/config.php
@@ -0,0 +1,9 @@
+ (bool) env('SECURITY_RECORD_AUTH_EVENTS', true),
+ 'trusted_device_days' => (int) env('SECURITY_TRUSTED_DEVICE_DAYS', 30),
+ 'session_idle_minutes' => (int) env('SECURITY_SESSION_IDLE_MINUTES', 120),
+ 'max_failed_logins' => (int) env('SECURITY_MAX_FAILED_LOGINS', 10),
+ 'lockout_minutes' => (int) env('SECURITY_LOCKOUT_MINUTES', 15),
+];
diff --git a/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php b/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php
new file mode 100644
index 0000000..a393e34
--- /dev/null
+++ b/Security/Database/Migrations/2026_07_24_120000_create_security_tables.php
@@ -0,0 +1,76 @@
+id();
+ $table->string('name')->default('Default security policy');
+ $table->unsignedSmallInteger('minimum_password_length')->default(12);
+ $table->boolean('require_mixed_case')->default(true);
+ $table->boolean('require_numbers')->default(true);
+ $table->boolean('require_symbols')->default(true);
+ $table->boolean('require_mfa')->default(false);
+ $table->unsignedInteger('password_expiry_days')->nullable();
+ $table->unsignedSmallInteger('password_history')->default(5);
+ $table->unsignedSmallInteger('max_failed_logins')->default(10);
+ $table->unsignedSmallInteger('lockout_minutes')->default(15);
+ $table->unsignedSmallInteger('session_idle_minutes')->default(120);
+ $table->boolean('single_session')->default(false);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('security_login_events', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
+ $table->string('email')->nullable()->index();
+ $table->string('event', 30)->index();
+ $table->string('ip_address', 45)->nullable()->index();
+ $table->text('user_agent')->nullable();
+ $table->string('session_id')->nullable()->index();
+ $table->string('reason')->nullable();
+ $table->json('metadata')->nullable();
+ $table->timestamp('occurred_at')->useCurrent();
+ $table->timestamps();
+ });
+ Schema::create('security_devices', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ $table->string('fingerprint', 64)->index();
+ $table->string('ip_address', 45)->nullable();
+ $table->text('user_agent')->nullable();
+ $table->timestamp('trusted_until')->nullable();
+ $table->timestamp('last_seen_at')->nullable();
+ $table->timestamp('revoked_at')->nullable();
+ $table->timestamps();
+ $table->unique(['user_id', 'fingerprint']);
+ });
+ Schema::create('security_ip_rules', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->string('type', 10)->default('deny');
+ $table->string('address', 64);
+ $table->text('description')->nullable();
+ $table->boolean('applies_to_admin')->default(true);
+ $table->boolean('applies_to_login')->default(true);
+ $table->boolean('is_active')->default(true);
+ $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
+ $table->timestamps();
+ $table->index(['type', 'is_active']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('security_ip_rules');
+ Schema::dropIfExists('security_devices');
+ Schema::dropIfExists('security_login_events');
+ Schema::dropIfExists('security_policies');
+ }
+};
diff --git a/Security/Http/Controllers/SecurityController.php b/Security/Http/Controllers/SecurityController.php
new file mode 100644
index 0000000..77e2633
--- /dev/null
+++ b/Security/Http/Controllers/SecurityController.php
@@ -0,0 +1,52 @@
+ SecurityPolicy::first(), 'events' => LoginEvent::latest('occurred_at')->paginate(25), 'devices' => TrustedDevice::whereNull('revoked_at')->latest('last_seen_at')->limit(20)->get(), 'rules' => IpRule::latest()->get(), 'failed24h' => LoginEvent::where('event', 'failed')->where('occurred_at', '>=', now()->subDay())->count()]);
+ }
+
+ public function savePolicy(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['minimum_password_length' => ['required', 'integer', 'min:8', 'max:128'], 'password_expiry_days' => ['nullable', 'integer', 'min:1'], 'password_history' => ['required', 'integer', 'min:0', 'max:24'], 'max_failed_logins' => ['required', 'integer', 'min:1', 'max:100'], 'lockout_minutes' => ['required', 'integer', 'min:1', 'max:1440'], 'session_idle_minutes' => ['required', 'integer', 'min:5', 'max:10080']]);
+ foreach (['require_mixed_case', 'require_numbers', 'require_symbols', 'require_mfa', 'single_session'] as $key) {
+ $data[$key] = $request->boolean($key);
+ }
+ SecurityPolicy::updateOrCreate(['id' => SecurityPolicy::value('id')], $data + ['name' => 'Default security policy', 'is_active' => true]);
+
+ return back()->with('status', 'Security policy updated.');
+ }
+
+ public function storeRule(Request $request): RedirectResponse
+ {
+ IpRule::create($request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:allow,deny'], 'address' => ['required', 'string', 'max:64'], 'description' => ['nullable', 'string']]) + ['applies_to_admin' => $request->boolean('applies_to_admin'), 'applies_to_login' => $request->boolean('applies_to_login'), 'is_active' => true, 'created_by' => $request->user()->id]);
+
+ return back()->with('status', 'IP rule created.');
+ }
+
+ public function destroyRule(IpRule $rule): RedirectResponse
+ {
+ $rule->delete();
+
+ return back()->with('status', 'IP rule deleted.');
+ }
+
+ public function revokeDevice(TrustedDevice $device): RedirectResponse
+ {
+ $device->update(['revoked_at' => now()]);
+
+ return back()->with('status', 'Device trust revoked.');
+ }
+}
diff --git a/Security/Http/Middleware/EnforceIpRestrictions.php b/Security/Http/Middleware/EnforceIpRestrictions.php
new file mode 100644
index 0000000..e009e1e
--- /dev/null
+++ b/Security/Http/Middleware/EnforceIpRestrictions.php
@@ -0,0 +1,20 @@
+rules->isBlocked((string) $request->ip(), $scope), 403, 'Access is not permitted from this IP address.');
+
+ return $next($request);
+ }
+}
diff --git a/Security/Listeners/RecordFailedLogin.php b/Security/Listeners/RecordFailedLogin.php
new file mode 100644
index 0000000..6bcd118
--- /dev/null
+++ b/Security/Listeners/RecordFailedLogin.php
@@ -0,0 +1,19 @@
+user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'failed', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
+ }
+}
diff --git a/Security/Listeners/RecordLogin.php b/Security/Listeners/RecordLogin.php
new file mode 100644
index 0000000..83face4
--- /dev/null
+++ b/Security/Listeners/RecordLogin.php
@@ -0,0 +1,19 @@
+user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'login', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
+ }
+}
diff --git a/Security/Listeners/RecordLogout.php b/Security/Listeners/RecordLogout.php
new file mode 100644
index 0000000..f29feed
--- /dev/null
+++ b/Security/Listeners/RecordLogout.php
@@ -0,0 +1,19 @@
+user : null;
+ LoginEvent::create(['user_id' => $user?->getAuthIdentifier(), 'email' => $user?->email ?? (property_exists($event, 'credentials') ? ($event->credentials['email'] ?? null) : null), 'event' => 'logout', 'ip_address' => $request?->ip(), 'user_agent' => $request?->userAgent(), 'session_id' => $request?->hasSession() ? $request->session()->getId() : null, 'occurred_at' => now()]);
+ }
+}
diff --git a/Security/Models/IpRule.php b/Security/Models/IpRule.php
new file mode 100644
index 0000000..2996dfe
--- /dev/null
+++ b/Security/Models/IpRule.php
@@ -0,0 +1,14 @@
+ 'boolean', 'applies_to_login' => 'boolean', 'is_active' => 'boolean'];
+}
diff --git a/Security/Models/LoginEvent.php b/Security/Models/LoginEvent.php
new file mode 100644
index 0000000..1fce0ab
--- /dev/null
+++ b/Security/Models/LoginEvent.php
@@ -0,0 +1,14 @@
+ 'array', 'occurred_at' => 'datetime'];
+}
diff --git a/Security/Models/SecurityPolicy.php b/Security/Models/SecurityPolicy.php
new file mode 100644
index 0000000..e790c60
--- /dev/null
+++ b/Security/Models/SecurityPolicy.php
@@ -0,0 +1,12 @@
+ 'boolean', 'require_numbers' => 'boolean', 'require_symbols' => 'boolean', 'require_mfa' => 'boolean', 'single_session' => 'boolean', 'is_active' => 'boolean'];
+}
diff --git a/Security/Models/TrustedDevice.php b/Security/Models/TrustedDevice.php
new file mode 100644
index 0000000..6777067
--- /dev/null
+++ b/Security/Models/TrustedDevice.php
@@ -0,0 +1,14 @@
+ 'datetime', 'last_seen_at' => 'datetime', 'revoked_at' => 'datetime'];
+}
diff --git a/Security/Providers/RouteServiceProvider.php b/Security/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..ed3f4c3
--- /dev/null
+++ b/Security/Providers/RouteServiceProvider.php
@@ -0,0 +1,14 @@
+group(module_path('Security', 'Routes/web.php'));
+ }
+}
diff --git a/Security/Providers/SecurityServiceProvider.php b/Security/Providers/SecurityServiceProvider.php
new file mode 100644
index 0000000..cf9e892
--- /dev/null
+++ b/Security/Providers/SecurityServiceProvider.php
@@ -0,0 +1,33 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Security', 'Config/config.php'), 'security');
+ }
+
+ public function boot(Router $router): void
+ {
+ $this->loadMigrationsFrom(module_path('Security', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Security', 'Resources/views'), 'security');
+ $router->aliasMiddleware('security.ip', EnforceIpRestrictions::class);
+ Event::listen(Login::class, RecordLogin::class);
+ Event::listen(Failed::class, RecordFailedLogin::class);
+ Event::listen(Logout::class, RecordLogout::class);
+ }
+}
diff --git a/Security/Resources/views/admin/index.blade.php b/Security/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..b07de13
--- /dev/null
+++ b/Security/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+Security Centre Identity policy, authentication history, trusted devices and network access controls.
@if(session('status'))
{{ session('status') }}
@endif
+
Failed logins (24h)
{{ $failed24h }}
Active trusted devices
{{ $devices->count() }}
IP rules
{{ $rules->count() }}
MFA required
{{ $policy?->require_mfa ? 'Yes' : 'No' }}
+
@csrf @method('PUT')Security policy
Save policy
+
@csrfAdd IP rule Deny Allow
Create rule @foreach($rules as $rule)
{{ $rule->name }} {{ $rule->type }} {{ $rule->address }}
@csrf @method('DELETE')Delete @endforeach
+
Authentication history
When Event User IP address Client @forelse($events as $event){{ $event->occurred_at?->format('d M Y H:i:s') }} {{ $event->event }} {{ $event->email ?: 'Unknown' }} {{ $event->ip_address ?: '—' }} {{ $event->user_agent ?: '—' }} @emptyNo authentication events recorded. @endforelse
{{ $events->links() }}
diff --git a/Security/Routes/web.php b/Security/Routes/web.php
new file mode 100644
index 0000000..b92281e
--- /dev/null
+++ b/Security/Routes/web.php
@@ -0,0 +1,12 @@
+name('administration.security.')->middleware(['auth', 'permission:security.view', 'security.ip:admin'])->group(function () {
+ Route::get('/', [SecurityController::class, 'index'])->name('index');
+ Route::put('/policy', [SecurityController::class, 'savePolicy'])->middleware('permission:security.manage')->name('policy.update');
+ Route::post('/ip-rules', [SecurityController::class, 'storeRule'])->middleware('permission:security.manage')->name('rules.store');
+ Route::delete('/ip-rules/{rule}', [SecurityController::class, 'destroyRule'])->middleware('permission:security.manage')->name('rules.destroy');
+ Route::delete('/devices/{device}', [SecurityController::class, 'revokeDevice'])->middleware('permission:security.manage')->name('devices.revoke');
+});
diff --git a/Security/Services/IpRuleService.php b/Security/Services/IpRuleService.php
new file mode 100644
index 0000000..490f000
--- /dev/null
+++ b/Security/Services/IpRuleService.php
@@ -0,0 +1,39 @@
+where('is_active', true)->where($column, true)->get();
+ $allow = $rules->where('type', 'allow');
+ if ($allow->isNotEmpty() && ! $allow->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address))) {
+ return true;
+ }
+
+ return $rules->where('type', 'deny')->contains(fn (IpRule $rule) => $this->matches($ip, $rule->address));
+ }
+
+ private function matches(string $ip, string $rule): bool
+ {
+ if ($ip === $rule) {
+ return true;
+ }
+ if (! str_contains($rule, '/')) {
+ return false;
+ }
+ [$subnet,$bits] = explode('/', $rule, 2);
+ $bits = (int) $bits;
+ if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
+ $mask = -1 << (32 - $bits);
+
+ return (ip2long($ip) & $mask) === (ip2long($subnet) & $mask);
+ }
+
+ return false;
+ }
+}
diff --git a/Security/Tests/Feature/ModuleBootTest.php b/Security/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..7f2e420
--- /dev/null
+++ b/Security/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,18 @@
+assertTrue(app('router')->has('administration.security.index'));
+ }
+
+ public function test_security_middleware_alias_is_registered(): void
+ {
+ $this->assertArrayHasKey('security.ip', app('router')->getMiddleware());
+ }
+}
diff --git a/Security/module.json b/Security/module.json
new file mode 100644
index 0000000..515918b
--- /dev/null
+++ b/Security/module.json
@@ -0,0 +1 @@
+{"name":"Security","alias":"security","version":"1.0.0","description":"Identity policies, login history, trusted devices, IP restrictions and session security.","keywords":["identity","security","sessions","devices","authentication"],"priority":120,"providers":["Modules\\Security\\Providers\\SecurityServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit"]}
diff --git a/Seo/Config/config.php b/Seo/Config/config.php
new file mode 100644
index 0000000..b2892d1
--- /dev/null
+++ b/Seo/Config/config.php
@@ -0,0 +1,6 @@
+ 'Seo',
+ 'enabled' => true,
+];
diff --git a/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php b/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php
new file mode 100644
index 0000000..1867c1e
--- /dev/null
+++ b/Seo/Database/Migrations/2026_07_23_000400_create_seo_metadata_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->morphs('seoable');
+ $table->string('title')->nullable();
+ $table->text('description')->nullable();
+ $table->string('canonical_url')->nullable();
+ $table->string('robots')->default('index,follow');
+ $table->string('og_title')->nullable();
+ $table->text('og_description')->nullable();
+ $table->string('og_image')->nullable();
+ $table->string('twitter_card')->default('summary_large_image');
+ $table->string('schema_type')->nullable();
+ $table->json('schema_data')->nullable();
+ $table->boolean('include_in_sitemap')->default(true);
+ $table->decimal('sitemap_priority', 2, 1)->default(0.5);
+ $table->string('sitemap_change_frequency')->default('monthly');
+ $table->timestamps();
+ $table->unique(['seoable_type', 'seoable_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('seo_metadata');
+ }
+};
diff --git a/Seo/Http/Controllers/SeoController.php b/Seo/Http/Controllers/SeoController.php
new file mode 100644
index 0000000..99349c0
--- /dev/null
+++ b/Seo/Http/Controllers/SeoController.php
@@ -0,0 +1,27 @@
+pluck('value', 'key');
+
+ return view('seo::index', compact('values'));
+ }
+
+ public function update(Request $request)
+ {
+ $data = $request->validate(['site_title' => ['nullable', 'string', 'max:255'], 'meta_description' => ['nullable', 'string', 'max:500'], 'robots' => ['required', 'string', 'max:100'], 'canonical_base' => ['nullable', 'url', 'max:255']]);
+ foreach ($data as $key => $value) {
+ Setting::updateOrCreate(['group' => 'seo', 'key' => $key], ['value' => $value, 'type' => 'string', 'is_public' => true]);
+ }
+
+return back()->with('status', 'SEO defaults saved.');
+ }
+}
diff --git a/Seo/Models/SeoMetadata.php b/Seo/Models/SeoMetadata.php
new file mode 100644
index 0000000..fa17538
--- /dev/null
+++ b/Seo/Models/SeoMetadata.php
@@ -0,0 +1,17 @@
+ 'array', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1'];
+ }
+}
diff --git a/Seo/Providers/RouteServiceProvider.php b/Seo/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..d2a362d
--- /dev/null
+++ b/Seo/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Seo', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Seo/Providers/SeoServiceProvider.php b/Seo/Providers/SeoServiceProvider.php
new file mode 100644
index 0000000..1a134fc
--- /dev/null
+++ b/Seo/Providers/SeoServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Seo/Resources/views/index.blade.php b/Seo/Resources/views/index.blade.php
new file mode 100644
index 0000000..9939a86
--- /dev/null
+++ b/Seo/Resources/views/index.blade.php
@@ -0,0 +1 @@
+SEO defaults Site-wide metadata used when content does not provide its own values.
@if(session('status'))
{{ session('status') }}
@endif
@csrf @method('PUT'){{ old('meta_description',$values['meta_description']??'') }} Save SEO defaults
diff --git a/Seo/Routes/web.php b/Seo/Routes/web.php
new file mode 100644
index 0000000..fa3652f
--- /dev/null
+++ b/Seo/Routes/web.php
@@ -0,0 +1,9 @@
+prefix('administration/seo')->name('administration.seo.')->group(function (): void {
+ Route::get('/', [SeoController::class, 'index'])->name('index');
+ Route::put('/', [SeoController::class, 'update'])->name('update');
+});
diff --git a/Seo/Support/ModuleInfo.php b/Seo/Support/ModuleInfo.php
new file mode 100644
index 0000000..ac5145e
--- /dev/null
+++ b/Seo/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Seo');
+});
diff --git a/Seo/composer.json b/Seo/composer.json
new file mode 100644
index 0000000..a7eae0a
--- /dev/null
+++ b/Seo/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/seo-module",
+ "description": "Seo foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Seo\\": ""
+ }
+ }
+}
diff --git a/Seo/module.json b/Seo/module.json
new file mode 100644
index 0000000..4d886f8
--- /dev/null
+++ b/Seo/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Seo",
+ "alias": "seo",
+ "version": "1.0.0",
+ "description": "Seo foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "seo"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Seo\\Providers\\SeoServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Settings/Config/config.php b/Settings/Config/config.php
new file mode 100644
index 0000000..c9342ba
--- /dev/null
+++ b/Settings/Config/config.php
@@ -0,0 +1,6 @@
+ 'Settings',
+ 'enabled' => true,
+];
diff --git a/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php b/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php
new file mode 100644
index 0000000..62b7134
--- /dev/null
+++ b/Settings/Database/Migrations/2026_07_23_000100_create_settings_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->string('group')->default('general')->index();
+ $table->string('key');
+ $table->longText('value')->nullable();
+ $table->string('type')->default('string');
+ $table->boolean('is_public')->default(false);
+ $table->timestamps();
+ $table->unique(['group', 'key']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('settings');
+ }
+};
diff --git a/Settings/Http/Controllers/SettingController.php b/Settings/Http/Controllers/SettingController.php
new file mode 100644
index 0000000..ceb3f78
--- /dev/null
+++ b/Settings/Http/Controllers/SettingController.php
@@ -0,0 +1,109 @@
+ $settings->site(),
+ 'sections' => $this->sections($themes),
+ ]);
+ }
+
+ public function update(Request $request, SettingsRepository $settings, Loader $themes): RedirectResponse
+ {
+ $rules = [];
+ foreach ($this->sections($themes) as $fields) {
+ foreach ($fields as $key => $definition) {
+ $rules["settings.$key"] = $definition['rules'];
+ }
+ }
+
+ $validated = $request->validate($rules);
+ foreach ($validated['settings'] ?? [] as $key => $value) {
+ $settings->put($key, $value, 'site', 'string', true);
+ }
+
+ return back()->with('status', 'Site settings saved. The active frontend and backend themes have been updated.');
+ }
+
+ /** @return array>> */
+ private function sections(Loader $themes): array
+ {
+ $text = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:255']];
+ $url = ['type' => 'text', 'rules' => ['nullable', 'string', 'max:2048'], 'placeholder' => '/path or https://example.com'];
+ $textarea = ['type' => 'textarea', 'rules' => ['nullable', 'string', 'max:5000']];
+
+ $backendThemes = $themes->all()
+ ->filter(fn ($theme): bool => $theme->type() === 'backend')
+ ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()])
+ ->all();
+
+ $frontendThemes = $themes->all()
+ ->filter(fn ($theme): bool => $theme->type() === 'frontend')
+ ->mapWithKeys(fn ($theme): array => [$theme->key() => $theme->name()])
+ ->all();
+
+ return [
+ 'Themes' => [
+ 'backend_theme' => [
+ 'type' => 'select',
+ 'rules' => ['required', 'string', Rule::in(array_keys($backendThemes))],
+ 'label' => 'Backend theme',
+ 'help' => 'Used for the authenticated administration area.',
+ 'options' => $backendThemes,
+ ],
+ 'frontend_theme' => [
+ 'type' => 'select',
+ 'rules' => ['required', 'string', Rule::in(array_keys($frontendThemes))],
+ 'label' => 'Frontend theme',
+ 'help' => 'Used for the public website.',
+ 'options' => $frontendThemes,
+ ],
+ ],
+ 'General' => [
+ 'site_name' => $text + ['label' => 'Site name', 'help' => 'Used in the header, footer, browser titles and metadata.'],
+ 'tagline' => $text + ['label' => 'Tagline'],
+ 'site_description' => $textarea + ['label' => 'Site description'],
+ 'company_name' => $text + ['label' => 'Legal company name'],
+ 'site_url' => $url + ['label' => 'Canonical site URL'],
+ 'copyright' => $text + ['label' => 'Copyright text', 'help' => 'Leave blank to generate it automatically.'],
+ ],
+ 'Branding' => [
+ 'logo_url' => $url + ['label' => 'Primary logo'],
+ 'logo_dark_url' => $url + ['label' => 'Dark-mode logo'],
+ 'favicon_url' => $url + ['label' => 'Favicon'],
+ ],
+ 'Contact' => [
+ 'contact_email' => ['type' => 'email', 'rules' => ['nullable', 'email', 'max:255'], 'label' => 'Public email'],
+ 'contact_phone' => $text + ['label' => 'Public telephone'],
+ 'address' => $textarea + ['label' => 'Address'],
+ ],
+ 'Social' => [
+ 'facebook_url' => $url + ['label' => 'Facebook'],
+ 'x_url' => $url + ['label' => 'X / Twitter'],
+ 'linkedin_url' => $url + ['label' => 'LinkedIn'],
+ 'instagram_url' => $url + ['label' => 'Instagram'],
+ ],
+ 'Account navigation' => [
+ 'dashboard_label' => $text + ['label' => 'Dashboard label'],
+ 'login_label' => $text + ['label' => 'Login label'],
+ 'registration_label' => $text + ['label' => 'Registration label'],
+ ],
+ 'SEO defaults' => [
+ 'default_meta_title' => $text + ['label' => 'Default meta title', 'help' => 'Leave blank to use the site name.'],
+ 'default_meta_description' => $textarea + ['label' => 'Default meta description', 'help' => 'Leave blank to use the site description.'],
+ ],
+ ];
+ }
+}
diff --git a/Settings/Models/Setting.php b/Settings/Models/Setting.php
new file mode 100644
index 0000000..79c0ed6
--- /dev/null
+++ b/Settings/Models/Setting.php
@@ -0,0 +1,15 @@
+ 'boolean'];
+ }
+}
diff --git a/Settings/Providers/RouteServiceProvider.php b/Settings/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..a5a3776
--- /dev/null
+++ b/Settings/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Settings', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Settings/Providers/SettingsServiceProvider.php b/Settings/Providers/SettingsServiceProvider.php
new file mode 100644
index 0000000..6a5ea24
--- /dev/null
+++ b/Settings/Providers/SettingsServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Settings/Resources/views/index.blade.php b/Settings/Resources/views/index.blade.php
new file mode 100644
index 0000000..fcf669e
--- /dev/null
+++ b/Settings/Resources/views/index.blade.php
@@ -0,0 +1,55 @@
+
+
+
+
+
Single source of truth
+
Site settings
+
+ Global identity, branding, contact details and SEO are entered once here and reused by every theme, page, header and footer.
+
+
+
Manage menus
+
+
+ @if (session('status'))
+
{{ session('status') }}
+ @endif
+
+
+ @csrf
+ @method('PUT')
+
+ @foreach ($sections as $section => $fields)
+
+ @endforeach
+
+
+ Save site settings
+
+
+
+
diff --git a/Settings/Routes/web.php b/Settings/Routes/web.php
new file mode 100644
index 0000000..3f28a23
--- /dev/null
+++ b/Settings/Routes/web.php
@@ -0,0 +1,9 @@
+prefix('administration/settings')->name('administration.settings.')->group(function (): void {
+ Route::get('/', [SettingController::class, 'index'])->name('index');
+ Route::put('/', [SettingController::class, 'update'])->name('update');
+});
diff --git a/Settings/Services/SettingsRepository.php b/Settings/Services/SettingsRepository.php
new file mode 100644
index 0000000..4e80ad7
--- /dev/null
+++ b/Settings/Services/SettingsRepository.php
@@ -0,0 +1,137 @@
+tableExists()) {
+ return $default;
+ }
+
+ /** @var array{value: ?string, type: string}|null $setting */
+ $setting = Cache::rememberForever(
+ "settings.value.v2.$group.$key",
+ fn (): ?array => Setting::query()
+ ->where([
+ 'group' => $group,
+ 'key' => $key,
+ ])
+ ->first(['value', 'type'])
+ ?->only(['value', 'type'])
+ );
+
+ return $setting !== null
+ ? $this->decode($setting['value'], $setting['type'])
+ : $default;
+ }
+
+ /** @return array */
+ public function group(string $group): array
+ {
+ if (! $this->tableExists()) {
+ return [];
+ }
+
+ return Cache::rememberForever("settings.group.$group", function () use ($group): array {
+ return Setting::query()
+ ->where('group', $group)
+ ->get()
+ ->mapWithKeys(fn (Setting $setting): array => [
+ $setting->key => $this->decode($setting->value, $setting->type),
+ ])
+ ->all();
+ });
+ }
+
+ /** @return array */
+ public function site(): array
+ {
+ $defaults = [
+ 'site_name' => config('app.name', 'Content Manager'),
+ 'tagline' => '',
+ 'site_description' => 'Modern software for modern companies.',
+ 'company_name' => config('app.name', 'Content Manager'),
+ 'site_url' => url('/'),
+ 'logo_url' => '',
+ 'logo_dark_url' => '',
+ 'favicon_url' => '/favicon.ico',
+ 'contact_email' => '',
+ 'contact_phone' => '',
+ 'address' => '',
+ 'facebook_url' => '',
+ 'x_url' => '',
+ 'linkedin_url' => '',
+ 'instagram_url' => '',
+ 'copyright' => '',
+ 'dashboard_label' => 'Dashboard',
+ 'login_label' => 'Log in',
+ 'registration_label' => 'Get started',
+ 'default_meta_title' => '',
+ 'default_meta_description' => '',
+ 'backend_theme' => config('stylist.default_backend_theme', 'admin'),
+ 'frontend_theme' => config('stylist.default_frontend_theme', 'saasland'),
+ ];
+
+ return array_replace($defaults, $this->group('site'));
+ }
+
+ public function put(string $key, mixed $value, string $group = 'general', string $type = 'string', bool $public = false): Setting
+ {
+ $setting = Setting::query()->updateOrCreate(
+ compact('group', 'key'),
+ [
+ 'value' => $this->encode($value, $type),
+ 'type' => $type,
+ 'is_public' => $public,
+ ]
+ );
+
+ // Remove both the legacy model cache and the scalar-only cache.
+ Cache::forget("settings.$group.$key");
+ Cache::forget("settings.value.v2.$group.$key");
+ Cache::forget("settings.group.$group");
+
+ return $setting;
+ }
+
+ private function tableExists(): bool
+ {
+ try {
+ return Schema::hasTable((new Setting())->getTable());
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ private function encode(mixed $value, string $type): ?string
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return match ($type) {
+ 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL) ? '1' : '0',
+ 'integer' => (string) (int) $value,
+ 'float' => (string) (float) $value,
+ 'json', 'array' => json_encode($value, JSON_THROW_ON_ERROR),
+ default => (string) $value,
+ };
+ }
+
+ private function decode(?string $value, string $type): mixed
+ {
+ return match ($type) {
+ 'boolean' => filter_var($value, FILTER_VALIDATE_BOOL),
+ 'integer' => (int) $value,
+ 'float' => (float) $value,
+ 'json', 'array' => filled($value) ? json_decode($value, true, 512, JSON_THROW_ON_ERROR) : [],
+ default => $value,
+ };
+ }
+}
diff --git a/Settings/Support/ModuleInfo.php b/Settings/Support/ModuleInfo.php
new file mode 100644
index 0000000..6c5d3ee
--- /dev/null
+++ b/Settings/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Settings');
+});
diff --git a/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php b/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php
new file mode 100644
index 0000000..41b4f99
--- /dev/null
+++ b/Settings/Tests/Feature/SiteSettingsSingleSourceTest.php
@@ -0,0 +1,9 @@
+put('site_name', 'Unified CMS', 'site', 'string', true);
+ expect($repository->site()['site_name'])->toBe('Unified CMS');
+});
diff --git a/Settings/Tests/Feature/ThemeSelectionSettingsTest.php b/Settings/Tests/Feature/ThemeSelectionSettingsTest.php
new file mode 100644
index 0000000..cae583a
--- /dev/null
+++ b/Settings/Tests/Feature/ThemeSelectionSettingsTest.php
@@ -0,0 +1,27 @@
+put('frontend_theme', 'swiss', 'site', 'string', true);
+ $settings->put('backend_theme', 'admin', 'site', 'string', true);
+
+ expect($settings->site())
+ ->frontend_theme->toBe('swiss')
+ ->backend_theme->toBe('admin');
+
+ expect(app(Stylist::class)->frontend()->key())->toBe('swiss')
+ ->and(app(Stylist::class)->backend()->key())->toBe('admin');
+});
+
+it('falls back to configured themes when stored theme keys are unavailable', function (): void {
+ $settings = app(SettingsRepository::class);
+ $settings->put('frontend_theme', 'missing-theme', 'site', 'string', true);
+ $settings->put('backend_theme', 'missing-theme', 'site', 'string', true);
+
+ expect(app(Stylist::class)->frontend()->key())->toBe(config('stylist.default_frontend_theme'))
+ ->and(app(Stylist::class)->backend()->key())->toBe(config('stylist.default_backend_theme'));
+});
diff --git a/Settings/composer.json b/Settings/composer.json
new file mode 100644
index 0000000..2701db4
--- /dev/null
+++ b/Settings/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/settings-module",
+ "description": "Settings foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Settings\\": ""
+ }
+ }
+}
diff --git a/Settings/module.json b/Settings/module.json
new file mode 100644
index 0000000..a65856b
--- /dev/null
+++ b/Settings/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Settings",
+ "alias": "settings",
+ "version": "1.0.0",
+ "description": "Settings foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "settings"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Settings\\Providers\\SettingsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Sitemap/Config/config.php b/Sitemap/Config/config.php
new file mode 100644
index 0000000..02edfd4
--- /dev/null
+++ b/Sitemap/Config/config.php
@@ -0,0 +1,3 @@
+ 'Sitemap'];
diff --git a/Sitemap/Http/Controllers/SitemapController.php b/Sitemap/Http/Controllers/SitemapController.php
new file mode 100644
index 0000000..3486a49
--- /dev/null
+++ b/Sitemap/Http/Controllers/SitemapController.php
@@ -0,0 +1,23 @@
+push(['loc' => url('/'), 'updated_at' => now()]);
+ Page::query()->where('status', 'published')->get()->each(fn ($r) => $urls->push(['loc' => route('pages.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+ Blog::published()->get()->each(fn ($r) => $urls->push(['loc' => route('blog.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+ News::published()->get()->each(fn ($r) => $urls->push(['loc' => route('news.public.show', $r->slug), 'updated_at' => $r->updated_at]));
+
+ return response()->view('sitemap::index', compact('urls'))->header('Content-Type', 'application/xml; charset=UTF-8');
+ }
+}
diff --git a/Sitemap/Providers/RouteServiceProvider.php b/Sitemap/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..d3894cc
--- /dev/null
+++ b/Sitemap/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Sitemap', 'Routes/web.php'));
+ }
+}
diff --git a/Sitemap/Providers/SitemapServiceProvider.php b/Sitemap/Providers/SitemapServiceProvider.php
new file mode 100644
index 0000000..4549e61
--- /dev/null
+++ b/Sitemap/Providers/SitemapServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Sitemap', 'Config/config.php'), 'sitemap');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Sitemap', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Sitemap', 'Resources/views'), 'sitemap');
+ }
+}
diff --git a/Sitemap/Resources/views/index.blade.php b/Sitemap/Resources/views/index.blade.php
new file mode 100644
index 0000000..0464fe2
--- /dev/null
+++ b/Sitemap/Resources/views/index.blade.php
@@ -0,0 +1,7 @@
+{!! '' !!}
+@foreach($urls as $url)
+
+ {{ $url['loc'] }}
+ {{ $url['updated_at']->toAtomString() }}
+
+ @endforeach
diff --git a/Sitemap/Routes/web.php b/Sitemap/Routes/web.php
new file mode 100644
index 0000000..e6ebc68
--- /dev/null
+++ b/Sitemap/Routes/web.php
@@ -0,0 +1,6 @@
+name('sitemap.index');
diff --git a/Sitemap/Tests/Feature/ModuleBootTest.php b/Sitemap/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..4b1361f
--- /dev/null
+++ b/Sitemap/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Sitemap', config('sitemap.name'));
+ }
+}
diff --git a/Sitemap/module.json b/Sitemap/module.json
new file mode 100644
index 0000000..ecf9761
--- /dev/null
+++ b/Sitemap/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Sitemap",
+ "alias": "sitemap",
+ "version": "1.0.0",
+ "description": "Sitemap publishing module.",
+ "keywords": ["publishing", "sitemap"],
+ "priority": 30,
+ "providers": ["Modules\\Sitemap\\Providers\\SitemapServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Sites/Concerns/BelongsToSites.php b/Sites/Concerns/BelongsToSites.php
new file mode 100644
index 0000000..7634d6e
--- /dev/null
+++ b/Sites/Concerns/BelongsToSites.php
@@ -0,0 +1,32 @@
+morphToMany(Site::class, 'content', 'site_content', 'content_id', 'site_id')->withPivot('is_primary')->withTimestamps();
+ }
+
+ public function scopeForCurrentSite(Builder $query): Builder
+ {
+ $site = app(SiteManager::class)->current();
+
+ return $site ? $query->whereHas('sites', fn (Builder $sites) => $sites->whereKey($site->id)) : $query;
+ }
+
+ public function assignToSite(Site|int $site, bool $primary = false): void
+ {
+ $id = $site instanceof Site ? $site->getKey() : $site;
+ if ($primary) {
+ $this->sites()->updateExistingPivot($this->sites()->pluck('sites.id'), ['is_primary' => false]);
+ }
+ $this->sites()->syncWithoutDetaching([$id => ['is_primary' => $primary]]);
+ }
+}
diff --git a/Sites/Config/config.php b/Sites/Config/config.php
new file mode 100644
index 0000000..4941723
--- /dev/null
+++ b/Sites/Config/config.php
@@ -0,0 +1,16 @@
+ true,
+ 'session_key' => 'platform_site_id',
+ 'central_domains' => array_values(array_filter(explode(',', (string) env('CENTRAL_DOMAINS', '')))),
+ 'content_types' => [
+ 'pages' => Page::class,
+ 'blog' => Blog::class,
+ 'news' => News::class,
+ ],
+];
diff --git a/Sites/Contracts/SiteAwareModule.php b/Sites/Contracts/SiteAwareModule.php
new file mode 100644
index 0000000..61f13f5
--- /dev/null
+++ b/Sites/Contracts/SiteAwareModule.php
@@ -0,0 +1,10 @@
+id();
+ $table->string('name');
+ $table->string('key')->unique();
+ $table->string('status')->default('active');
+ $table->string('theme')->nullable();
+ $table->string('default_locale', 20)->default('en');
+ $table->string('timezone')->default('UTC');
+ $table->boolean('is_default')->default(false);
+ $table->timestamps();
+ });
+ Schema::create('site_domains', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('domain')->unique();
+ $table->boolean('is_primary')->default(false);
+ $table->boolean('redirect_to_primary')->default(false);
+ $table->boolean('force_https')->default(true);
+ $table->boolean('is_active')->default(true);
+ $table->timestamps();
+ });
+ Schema::create('site_settings', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('group')->default('general');
+ $table->string('key');
+ $table->longText('value')->nullable();
+ $table->string('type')->default('string');
+ $table->timestamps();
+ $table->unique(['site_id', 'key']);
+ });
+ Schema::create('site_content', function (Blueprint $table): void {
+ $table->id();
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->string('content_type');
+ $table->unsignedBigInteger('content_id');
+ $table->boolean('is_primary')->default(false);
+ $table->timestamps();
+ $table->unique(['site_id', 'content_type', 'content_id']);
+ $table->index(['content_type', 'content_id']);
+ });
+ Schema::create('site_user', function (Blueprint $table): void {
+ $table->foreignId('site_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->string('role')->default('editor');
+ $table->timestamps();
+ $table->primary(['site_id', 'user_id']);
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('site_user');
+ Schema::dropIfExists('site_content');
+ Schema::dropIfExists('site_settings');
+ Schema::dropIfExists('site_domains');
+ Schema::dropIfExists('sites');
+ }
+};
diff --git a/Sites/Database/Seeders/SiteSeeder.php b/Sites/Database/Seeders/SiteSeeder.php
new file mode 100644
index 0000000..905f213
--- /dev/null
+++ b/Sites/Database/Seeders/SiteSeeder.php
@@ -0,0 +1,16 @@
+updateOrCreate(['key' => 'default'], ['name' => 'Default Site', 'status' => 'active', 'default_locale' => config('app.locale', 'en'), 'timezone' => config('app.timezone', 'UTC'), 'is_default' => true]);
+ $site->domains()->updateOrCreate(['domain' => $host], ['is_primary' => true, 'redirect_to_primary' => false, 'force_https' => str_starts_with((string) config('app.url'), 'https://'), 'is_active' => true]);
+ }
+}
diff --git a/Sites/Http/Controllers/DomainController.php b/Sites/Http/Controllers/DomainController.php
new file mode 100644
index 0000000..90e2dca
--- /dev/null
+++ b/Sites/Http/Controllers/DomainController.php
@@ -0,0 +1,33 @@
+validate(['domain' => ['required', 'string', 'max:255', 'regex:/^[a-z0-9.-]+$/i', Rule::unique('site_domains', 'domain')], 'is_primary' => ['sometimes', 'boolean'], 'redirect_to_primary' => ['sometimes', 'boolean'], 'force_https' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean']]);
+ if ($request->boolean('is_primary')) {
+ $site->domains()->update(['is_primary' => false]);
+ }
+ $site->domains()->create($data + ['is_primary' => $request->boolean('is_primary'), 'redirect_to_primary' => $request->boolean('redirect_to_primary'), 'force_https' => $request->boolean('force_https'), 'is_active' => $request->boolean('is_active', true)]);
+
+ return back()->with('success', 'Domain added.');
+ }
+
+ public function destroy(Site $site, SiteDomain $domain): RedirectResponse
+ {
+ abort_unless($domain->site_id === $site->id, 404);
+ abort_if($domain->is_primary && $site->domains()->count() === 1, 422, 'A site must retain a domain.');
+ $domain->delete();
+
+ return back()->with('success', 'Domain removed.');
+ }
+}
diff --git a/Sites/Http/Controllers/SiteController.php b/Sites/Http/Controllers/SiteController.php
new file mode 100644
index 0000000..3e719f9
--- /dev/null
+++ b/Sites/Http/Controllers/SiteController.php
@@ -0,0 +1,68 @@
+ Site::query()->with('domains')->orderByDesc('is_default')->orderBy('name')->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('sites::admin.sites.form', ['site' => new Site]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $site = Site::query()->create($this->validated($request));
+
+ return redirect()->route('administration.sites.edit', $site)->with('success', 'Site created.');
+ }
+
+ public function edit(Site $site): View
+ {
+ return view('sites::admin.sites.form', ['site' => $site->load(['domains', 'settings'])]);
+ }
+
+ public function update(Request $request, Site $site): RedirectResponse
+ {
+ DB::transaction(function () use ($request, $site): void {
+ $data = $this->validated($request, $site);
+ if ($data['is_default']) {
+ Site::query()->whereKeyNot($site->id)->update(['is_default' => false]);
+ } $site->update($data);
+ });
+
+ return back()->with('success', 'Site updated.');
+ }
+
+ public function destroy(Site $site): RedirectResponse
+ {
+ abort_if($site->is_default, 422, 'The default site cannot be deleted.');
+ $site->delete();
+
+ return redirect()->route('administration.sites.index')->with('success', 'Site deleted.');
+ }
+
+ public function switch(Request $request, Site $site): RedirectResponse
+ {
+ $request->session()->put(config('sites.session_key'), $site->id);
+
+ return back()->with('success', "Site context changed to {$site->name}.");
+ }
+
+ private function validated(Request $request, ?Site $site = null): array
+ {
+ return $request->validate(['name' => ['required', 'string', 'max:150'], 'key' => ['required', 'alpha_dash', 'max:100', Rule::unique('sites', 'key')->ignore($site)], 'status' => ['required', Rule::in(['active', 'inactive', 'maintenance'])], 'theme' => ['nullable', 'string', 'max:100'], 'default_locale' => ['required', 'string', 'max:20'], 'timezone' => ['required', 'timezone'], 'is_default' => ['sometimes', 'boolean']]) + ['is_default' => $request->boolean('is_default')];
+ }
+}
diff --git a/Sites/Http/Middleware/ResolveSite.php b/Sites/Http/Middleware/ResolveSite.php
new file mode 100644
index 0000000..fc3207d
--- /dev/null
+++ b/Sites/Http/Middleware/ResolveSite.php
@@ -0,0 +1,24 @@
+sites->resolve($request);
+ if ($site) {
+ config(['app.url' => $request->getSchemeAndHttpHost(), 'app.timezone' => $site->timezone ?: config('app.timezone')]);
+ date_default_timezone_set((string) config('app.timezone'));
+ }
+
+ return $next($request);
+ }
+}
diff --git a/Sites/Models/Site.php b/Sites/Models/Site.php
new file mode 100644
index 0000000..a9c2689
--- /dev/null
+++ b/Sites/Models/Site.php
@@ -0,0 +1,44 @@
+ 'boolean'];
+ }
+
+ public function domains(): HasMany
+ {
+ return $this->hasMany(SiteDomain::class);
+ }
+
+ public function settings(): HasMany
+ {
+ return $this->hasMany(SiteSetting::class);
+ }
+
+ public function assignments(): HasMany
+ {
+ return $this->hasMany(SiteContent::class);
+ }
+
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('status', 'active');
+ }
+
+ public function setting(string $key, mixed $default = null): mixed
+ {
+ $setting = $this->relationLoaded('settings') ? $this->settings->firstWhere('key', $key) : $this->settings()->where('key', $key)->first();
+
+ return $setting?->typedValue() ?? $default;
+ }
+}
diff --git a/Sites/Models/SiteContent.php b/Sites/Models/SiteContent.php
new file mode 100644
index 0000000..c359fbb
--- /dev/null
+++ b/Sites/Models/SiteContent.php
@@ -0,0 +1,29 @@
+ 'boolean'];
+ }
+
+ public function site(): BelongsTo
+ {
+ return $this->belongsTo(Site::class);
+ }
+
+ public function content(): MorphTo
+ {
+ return $this->morphTo(__FUNCTION__, 'content_type', 'content_id');
+ }
+}
diff --git a/Sites/Models/SiteDomain.php b/Sites/Models/SiteDomain.php
new file mode 100644
index 0000000..b92e2cf
--- /dev/null
+++ b/Sites/Models/SiteDomain.php
@@ -0,0 +1,21 @@
+ 'boolean', 'redirect_to_primary' => 'boolean', 'force_https' => 'boolean', 'is_active' => 'boolean'];
+ }
+
+ public function site(): BelongsTo
+ {
+ return $this->belongsTo(Site::class);
+ }
+}
diff --git a/Sites/Models/SiteSetting.php b/Sites/Models/SiteSetting.php
new file mode 100644
index 0000000..249314a
--- /dev/null
+++ b/Sites/Models/SiteSetting.php
@@ -0,0 +1,26 @@
+belongsTo(Site::class);
+ }
+
+ public function typedValue(): mixed
+ {
+ return match ($this->type) {
+ 'boolean' => filter_var($this->value, FILTER_VALIDATE_BOOLEAN),
+ 'integer' => (int) $this->value,
+ 'json' => json_decode((string) $this->value, true),
+ default => $this->value,
+ };
+ }
+}
diff --git a/Sites/Providers/RouteServiceProvider.php b/Sites/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..f4820fa
--- /dev/null
+++ b/Sites/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Sites', 'Routes/web.php'));
+ }
+}
diff --git a/Sites/Providers/SitesServiceProvider.php b/Sites/Providers/SitesServiceProvider.php
new file mode 100644
index 0000000..fbfd18f
--- /dev/null
+++ b/Sites/Providers/SitesServiceProvider.php
@@ -0,0 +1,22 @@
+mergeConfigFrom(module_path('Sites', 'Config/config.php'), 'sites');
+ $this->app->singleton(SiteManager::class);
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Sites', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Sites', 'Resources/views'), 'sites');
+ }
+}
diff --git a/Sites/Resources/views/admin/sites/form.blade.php b/Sites/Resources/views/admin/sites/form.blade.php
new file mode 100644
index 0000000..987ee09
--- /dev/null
+++ b/Sites/Resources/views/admin/sites/form.blade.php
@@ -0,0 +1 @@
+{{ $site->exists ? 'Edit site' : 'New site' }} Configure the site identity, theme and regional defaults.
@csrf @if($site->exists) @method('PUT') @endifstatus)==='active')>Active status)==='inactive')>Inactive status)==='maintenance')>Maintenance
Default site Save site Cancel
@if($site->exists)
Domains @foreach($site->domains as $domain)
{{ $domain->domain }} @if($domain->is_primary)Primary @endif
@csrf @method('DELETE')Remove @endforeach
@csrfPrimary Force HTTPS Active
Add domain
@endif
diff --git a/Sites/Resources/views/admin/sites/index.blade.php b/Sites/Resources/views/admin/sites/index.blade.php
new file mode 100644
index 0000000..dc7414e
--- /dev/null
+++ b/Sites/Resources/views/admin/sites/index.blade.php
@@ -0,0 +1 @@
+Sites Manage websites, domains, themes and regional defaults.
New site Site Domains Theme Status @foreach($sites as $site){{ $site->name }} @if($site->is_default)Default @endif {{ $site->domains->pluck('domain')->join(', ') ?: 'No domains' }} {{ $site->theme ?: 'Inherited' }} {{ ucfirst($site->status) }} Edit @endforeach
{{ $sites->links() }}
diff --git a/Sites/Routes/web.php b/Sites/Routes/web.php
new file mode 100644
index 0000000..78ebd55
--- /dev/null
+++ b/Sites/Routes/web.php
@@ -0,0 +1,12 @@
+prefix('administration')->name('administration.')->group(function (): void {
+ Route::post('sites/{site}/switch', [SiteController::class, 'switch'])->name('sites.switch');
+ Route::resource('sites', SiteController::class)->except('show');
+ Route::post('sites/{site}/domains', [DomainController::class, 'store'])->name('sites.domains.store');
+ Route::delete('sites/{site}/domains/{domain}', [DomainController::class, 'destroy'])->name('sites.domains.destroy');
+});
diff --git a/Sites/Services/SiteManager.php b/Sites/Services/SiteManager.php
new file mode 100644
index 0000000..c9e1306
--- /dev/null
+++ b/Sites/Services/SiteManager.php
@@ -0,0 +1,76 @@
+current;
+ }
+
+ public function setCurrent(?Site $site): void
+ {
+ $this->current = $site;
+
+ if ($site) {
+ app()->instance(Site::class, $site);
+ app()->instance('currentSite', $site);
+ }
+ }
+
+ public function all(): Collection
+ {
+ if (! $this->tablesExist()) {
+ return collect();
+ }
+
+ return Site::query()
+ ->active()
+ ->orderByDesc('is_default')
+ ->orderBy('name')
+ ->get();
+ }
+
+ public function resolve(Request $request): ?Site
+ {
+ if (! $this->tablesExist()) {
+ $this->setCurrent(null);
+
+ return null;
+ }
+
+ $host = strtolower(preg_replace('/:\d+$/', '', $request->getHost()));
+
+ $site = Site::query()
+ ->with(['domains', 'settings'])
+ ->whereHas('domains', fn ($query) => $query
+ ->where('domain', $host)
+ ->where('is_active', true))
+ ->first();
+
+ if (! $site && config('sites.fallback_to_default')) {
+ $site = Site::query()
+ ->with(['domains', 'settings'])
+ ->active()
+ ->where('is_default', true)
+ ->first();
+ }
+
+ $this->setCurrent($site);
+
+ return $site;
+ }
+
+ private function tablesExist(): bool
+ {
+ return Schema::hasTable('sites') && Schema::hasTable('site_domains');
+ }
+}
diff --git a/Sites/Tests/Feature/SitesModuleBootTest.php b/Sites/Tests/Feature/SitesModuleBootTest.php
new file mode 100644
index 0000000..94916ed
--- /dev/null
+++ b/Sites/Tests/Feature/SitesModuleBootTest.php
@@ -0,0 +1,14 @@
+assertTrue(app('router')->has('administration.sites.index'));
+ $this->assertTrue(app('router')->has('administration.sites.domains.store'));
+ }
+}
diff --git a/Sites/module.json b/Sites/module.json
new file mode 100644
index 0000000..a8886dd
--- /dev/null
+++ b/Sites/module.json
@@ -0,0 +1,9 @@
+{
+ "name": "Sites",
+ "alias": "sites",
+ "description": "Multi-site domains, context, settings, content assignments and administration.",
+ "keywords": ["multisite", "sites", "domains", "tenancy"],
+ "priority": 35,
+ "providers": ["Modules\\Sites\\Providers\\SitesServiceProvider"],
+ "files": []
+}
diff --git a/Stylist/Config/config.php b/Stylist/Config/config.php
new file mode 100644
index 0000000..71b15d3
--- /dev/null
+++ b/Stylist/Config/config.php
@@ -0,0 +1,29 @@
+ env('STYLIST_DEFAULT_THEME', 'admin'),
+ 'default_backend_theme' => env('STYLIST_BACKEND_THEME', 'admin'),
+ 'default_frontend_theme' => env('STYLIST_FRONTEND_THEME', 'saasland'),
+
+ /*
+ | Themes are application resources, never module resources.
+ | Every theme is self-contained and owns its views and assets.
+ */
+ 'themes_path' => env('STYLIST_THEMES_PATH', resource_path('themes')),
+
+ 'tenant_theme_column' => 'theme_key',
+ 'tenant_brand_columns' => [
+ 'brand_primary' => 'brand-primary',
+ 'brand_secondary' => 'brand-secondary',
+ 'brand_accent' => 'brand-accent',
+ ],
+
+ 'fallback_variables' => [
+ 'brand-primary' => '#2563eb',
+ 'brand-secondary' => '#0f172a',
+ 'brand-accent' => '#22c55e',
+ 'surface' => '#ffffff',
+ 'surface-muted' => '#f8fafc',
+ 'text-primary' => '#0f172a',
+ ],
+];
diff --git a/Stylist/Console/CreateThemeCommand.php b/Stylist/Console/CreateThemeCommand.php
new file mode 100644
index 0000000..c7e1dff
--- /dev/null
+++ b/Stylist/Console/CreateThemeCommand.php
@@ -0,0 +1,64 @@
+argument('key')));
+ if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
+ $this->error('Theme keys may contain lowercase letters, numbers, dashes, and underscores only.');
+
+ return self::FAILURE;
+ }
+
+ $path = rtrim((string) config('stylist.themes_path'), '/\\').DIRECTORY_SEPARATOR.$key;
+ if ($this->files->exists($path)) {
+ $this->error("Theme [{$key}] already exists.");
+
+ return self::FAILURE;
+ }
+
+ $parent = trim((string) $this->option('parent'));
+ $name = trim((string) ($this->option('name') ?: str($key)->headline()));
+ $this->files->ensureDirectoryExists($path.'/views');
+
+ $manifest = [
+ 'manifest_version' => 1,
+ 'name' => $name,
+ 'slug' => $key,
+ 'version' => '1.0.0',
+ 'description' => "Child theme for {$name}.",
+ 'extends' => $parent !== '' ? $parent : null,
+ 'entry_view' => 'home',
+ 'views' => true,
+ 'variables' => [],
+ 'settings' => [],
+ ];
+
+ if ($manifest['extends'] === null) {
+ unset($manifest['extends']);
+ }
+
+ $this->files->put($path.'/theme.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES).PHP_EOL);
+ $this->files->put($path.'/views/.gitkeep', '');
+
+ $this->info("Theme [{$key}] created at {$path}.");
+ $this->line('Shared views will resolve through its parent; only add genuine overrides.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Stylist/Console/FindDuplicateViewsCommand.php b/Stylist/Console/FindDuplicateViewsCommand.php
new file mode 100644
index 0000000..2304ca1
--- /dev/null
+++ b/Stylist/Console/FindDuplicateViewsCommand.php
@@ -0,0 +1,40 @@
+inspector->duplicateViews();
+ if ($duplicates === []) {
+ $this->info('No duplicate theme views found.');
+
+ return self::SUCCESS;
+ }
+
+ $rows = [];
+ foreach ($duplicates as $view => $owners) {
+ foreach ($owners as $owner) {
+ $rows[] = [$view, $owner['theme'], $owner['path']];
+ }
+ }
+
+ $this->table(['View', 'Theme', 'Path'], $rows);
+ $this->error(count($duplicates).' duplicate view task(s) found.');
+
+ return self::FAILURE;
+ }
+}
diff --git a/Stylist/Console/InspectThemesCommand.php b/Stylist/Console/InspectThemesCommand.php
new file mode 100644
index 0000000..0764a1f
--- /dev/null
+++ b/Stylist/Console/InspectThemesCommand.php
@@ -0,0 +1,28 @@
+inspector->themes();
+ $this->table(['Key', 'Name', 'Parent', 'Entry view', 'Views', 'Path'], array_map(fn (array $theme): array => [
+ $theme['key'], $theme['name'], $theme['parent'] ?? '—', $theme['entry_view'], $theme['views'], $theme['path'],
+ ], $themes));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Stylist/Console/PublishAssetsCommand.php b/Stylist/Console/PublishAssetsCommand.php
new file mode 100644
index 0000000..ab661fa
--- /dev/null
+++ b/Stylist/Console/PublishAssetsCommand.php
@@ -0,0 +1,34 @@
+option('force');
+
+ $this->call('vendor:publish', [
+ '--tag' => 'stylist-config',
+ '--force' => $force,
+ ]);
+
+ $themesPath = (string) config('stylist.themes_path', resource_path('themes'));
+
+ if (! is_dir($themesPath) && ! mkdir($themesPath, 0775, true) && ! is_dir($themesPath)) {
+ $this->components->error("Unable to create theme directory: {$themesPath}");
+
+ return self::FAILURE;
+ }
+
+ $this->components->info("Stylist config published. Themes remain in: {$themesPath}");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/Stylist/Console/ThemeDoctorCommand.php b/Stylist/Console/ThemeDoctorCommand.php
new file mode 100644
index 0000000..d59cb3d
--- /dev/null
+++ b/Stylist/Console/ThemeDoctorCommand.php
@@ -0,0 +1,38 @@
+inspector->issues();
+ if ($issues === []) {
+ $this->info('Stylist is healthy. No theme issues found.');
+
+ return self::SUCCESS;
+ }
+
+ $this->table(['Severity', 'Theme', 'Message'], array_map(fn (array $issue): array => [
+ strtoupper($issue['severity']), $issue['theme'], $issue['message'],
+ ], $issues));
+
+ $errors = count(array_filter($issues, fn (array $issue): bool => $issue['severity'] === 'error'));
+ $warnings = count($issues) - $errors;
+ $this->line("{$errors} error(s), {$warnings} warning(s).");
+
+ return $errors > 0 ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/Stylist/Console/ValidateThemeCommand.php b/Stylist/Console/ValidateThemeCommand.php
new file mode 100644
index 0000000..5e21f93
--- /dev/null
+++ b/Stylist/Console/ValidateThemeCommand.php
@@ -0,0 +1,108 @@
+resolveThemePath((string) $this->argument('theme'));
+ $theme = $this->loader->fromPath($path);
+ $issues = $this->validator->validate($theme);
+
+ $this->info("Theme: {$theme->name()} ({$theme->key()})");
+ $this->line('Manifest schema: '.$theme->manifestVersion());
+
+ if ($issues === []) {
+ $this->info('Theme validation passed with no issues.');
+
+ return self::SUCCESS;
+ }
+
+ $this->table(
+ ['Severity', 'File', 'Message'],
+ array_map(fn (array $issue) => [
+ strtoupper($issue['severity']),
+ $issue['file'],
+ $issue['message'],
+ ], $issues),
+ );
+
+ $errors = count(array_filter($issues, fn (array $issue) => $issue['severity'] === 'error'));
+ $warnings = count($issues) - $errors;
+ $this->line("{$errors} error(s), {$warnings} warning(s).");
+
+ return $errors > 0 ? self::FAILURE : self::SUCCESS;
+ } catch (\Throwable $exception) {
+ $this->error($exception->getMessage());
+
+ return self::FAILURE;
+ } finally {
+ if ($temporary !== null) {
+ $this->files->deleteDirectory($temporary);
+ }
+ }
+ }
+
+ /** @return array{0:string,1:?string} */
+ private function resolveThemePath(string $input): array
+ {
+ if ($this->files->isDirectory($input)) {
+ return [realpath($input) ?: $input, null];
+ }
+
+ if ($this->files->isFile($input) && strtolower(pathinfo($input, PATHINFO_EXTENSION)) === 'zip') {
+ $temporary = storage_path('app/stylist/validate-'.bin2hex(random_bytes(8)));
+ $this->files->ensureDirectoryExists($temporary);
+
+ $zip = new ZipArchive;
+ if ($zip->open($input) !== true) {
+ throw new RuntimeException('The theme ZIP could not be opened.');
+ }
+
+ for ($index = 0; $index < $zip->numFiles; $index++) {
+ $name = (string) $zip->getNameIndex($index);
+ if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
+ $zip->close();
+ throw new RuntimeException('The ZIP contains an unsafe path.');
+ }
+ }
+
+ $zip->extractTo($temporary);
+ $zip->close();
+
+ $manifest = collect($this->files->allFiles($temporary))
+ ->first(fn ($file) => $file->getFilename() === 'theme.json');
+
+ if (! $manifest) {
+ throw new RuntimeException('The package does not contain theme.json.');
+ }
+
+ return [$manifest->getPath(), $temporary];
+ }
+
+ return [$this->loader->find($input)->path(), null];
+ }
+}
diff --git a/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php b/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php
new file mode 100644
index 0000000..2396070
--- /dev/null
+++ b/Stylist/Database/Migrations/2026_07_08_000000_add_stylist_fields_to_companies_table.php
@@ -0,0 +1,56 @@
+string('theme_key')->default('default')->after('timezone');
+ }
+
+ if (! Schema::hasColumn('companies', 'brand_primary')) {
+ $table->string('brand_primary')->nullable()->after('theme_key');
+ }
+
+ if (! Schema::hasColumn('companies', 'brand_secondary')) {
+ $table->string('brand_secondary')->nullable()->after('brand_primary');
+ }
+
+ if (! Schema::hasColumn('companies', 'brand_accent')) {
+ $table->string('brand_accent')->nullable()->after('brand_secondary');
+ }
+
+ if (! Schema::hasColumn('companies', 'logo_path')) {
+ $table->string('logo_path')->nullable()->after('brand_accent');
+ }
+
+ if (! Schema::hasColumn('companies', 'theme_settings')) {
+ $table->json('theme_settings')->nullable()->after('logo_path');
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ if (! Schema::hasTable('companies')) {
+ return;
+ }
+
+ Schema::table('companies', function (Blueprint $table): void {
+ foreach (['theme_settings', 'logo_path', 'brand_accent', 'brand_secondary', 'brand_primary', 'theme_key'] as $column) {
+ if (Schema::hasColumn('companies', $column)) {
+ $table->dropColumn($column);
+ }
+ }
+ });
+ }
+};
diff --git a/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php b/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php
new file mode 100644
index 0000000..57b6955
--- /dev/null
+++ b/Stylist/Database/Migrations/2026_07_21_000001_create_stylist_theme_settings_table.php
@@ -0,0 +1,24 @@
+id();
+ $table->string('theme_key')->unique();
+ $table->string('variant')->default('light');
+ $table->json('settings')->nullable();
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('stylist_theme_settings');
+ }
+};
diff --git a/Stylist/Diagnostics/ThemeInspector.php b/Stylist/Diagnostics/ThemeInspector.php
new file mode 100644
index 0000000..371d543
--- /dev/null
+++ b/Stylist/Diagnostics/ThemeInspector.php
@@ -0,0 +1,147 @@
+> */
+ public function themes(): array
+ {
+ return $this->loader->all()->map(function (Theme $theme): array {
+ return [
+ 'key' => $theme->key(),
+ 'name' => $theme->name(),
+ 'parent' => $theme->parent(),
+ 'path' => $theme->path(),
+ 'views' => count($this->viewFiles($theme)),
+ 'entry_view' => $theme->entryView(),
+ ];
+ })->values()->all();
+ }
+
+ /** @return array> */
+ public function duplicateViews(): array
+ {
+ $ownership = [];
+
+ foreach ($this->loader->all() as $theme) {
+ foreach ($this->viewFiles($theme) as $relative => $absolute) {
+ $ownership[$relative][] = [
+ 'theme' => $theme->key(),
+ 'path' => $absolute,
+ ];
+ }
+ }
+
+ return array_filter($ownership, fn (array $owners): bool => count($owners) > 1);
+ }
+
+ /** @return array */
+ public function issues(): array
+ {
+ $issues = [];
+ $themes = $this->loader->all();
+
+ foreach ($themes as $theme) {
+ $parent = $theme->parent();
+
+ if ($parent !== null && ! $themes->has($parent)) {
+ $issues[] = [
+ 'severity' => 'error',
+ 'theme' => $theme->key(),
+ 'message' => "Parent theme [{$parent}] is not installed.",
+ ];
+ }
+
+ if ($this->hasInheritanceCycle($theme, $themes->all())) {
+ $issues[] = [
+ 'severity' => 'error',
+ 'theme' => $theme->key(),
+ 'message' => 'Theme inheritance contains a cycle.',
+ ];
+ }
+
+ $entry = $theme->path().'/views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
+ if (! $this->files->exists($entry) && $parent === null) {
+ $issues[] = [
+ 'severity' => 'warning',
+ 'theme' => $theme->key(),
+ 'message' => "Entry view [{$theme->entryView()}] does not exist in this theme.",
+ ];
+ }
+ }
+
+ foreach ($this->duplicateViews() as $view => $owners) {
+ $issues[] = [
+ 'severity' => 'error',
+ 'theme' => implode(', ', array_column($owners, 'theme')),
+ 'message' => "View [{$view}] has multiple owners.",
+ ];
+ }
+
+ $default = (string) config('stylist.default_theme');
+ if ($default !== '' && ! $themes->has($default)) {
+ $issues[] = [
+ 'severity' => 'warning',
+ 'theme' => $default,
+ 'message' => 'Configured default theme is not installed.',
+ ];
+ }
+
+ return $issues;
+ }
+
+ /** @return array */
+ private function viewFiles(Theme $theme): array
+ {
+ $root = $theme->path().'/views';
+ if (! $this->files->isDirectory($root)) {
+ return [];
+ }
+
+ $views = [];
+ foreach ($this->files->allFiles($root) as $file) {
+ if (! str_ends_with($file->getFilename(), '.blade.php')) {
+ continue;
+ }
+
+ $absolute = str_replace('\\', '/', $file->getPathname());
+ $relative = ltrim(str_replace(str_replace('\\', '/', $root), '', $absolute), '/');
+ $views[$relative] = $absolute;
+ }
+
+ ksort($views);
+
+ return $views;
+ }
+
+ /** @param array $themes */
+ private function hasInheritanceCycle(Theme $theme, array $themes): bool
+ {
+ $seen = [];
+ $key = $theme->key();
+
+ while (isset($themes[$key])) {
+ if (isset($seen[$key])) {
+ return true;
+ }
+
+ $seen[$key] = true;
+ $key = $themes[$key]->parent() ?? '';
+ if ($key === '') {
+ return false;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Stylist/Facades/StylistFacade.php b/Stylist/Facades/StylistFacade.php
new file mode 100644
index 0000000..77d173e
--- /dev/null
+++ b/Stylist/Facades/StylistFacade.php
@@ -0,0 +1,13 @@
+make($view, $data, $mergeData);
+ }
+}
+
+if (! function_exists('stylist_theme_view')) {
+ function stylist_theme_view(string $theme, string $view, array $data = [], array $mergeData = []): View
+ {
+ return app(ThemeViewResolver::class)->makeForTheme($theme, $view, $data, $mergeData);
+ }
+}
+
+if (! function_exists('stylist_frontend_view')) {
+ function stylist_frontend_view(string $view, array $data = [], array $mergeData = []): View
+ {
+ return app(ThemeViewResolver::class)->makeForTheme(stylist()->frontend()->key(), $view, $data, $mergeData);
+ }
+}
+
+if (! function_exists('stylist_backend_view')) {
+ function stylist_backend_view(string $view, array $data = [], array $mergeData = []): View
+ {
+ return app(ThemeViewResolver::class)->makeForTheme(stylist()->backend()->key(), $view, $data, $mergeData);
+ }
+}
+
+if (! function_exists('theme_asset')) {
+ function theme_asset(string $path): string
+ {
+ return app(UrlGenerator::class)->asset($path);
+ }
+}
+
+if (! function_exists('theme_switcher')) {
+ function theme_switcher(): HtmlString
+ {
+ return stylist_html()->switcher();
+ }
+}
diff --git a/Stylist/Html/ThemeHtmlBuilder.php b/Stylist/Html/ThemeHtmlBuilder.php
new file mode 100644
index 0000000..b7b3c16
--- /dev/null
+++ b/Stylist/Html/ThemeHtmlBuilder.php
@@ -0,0 +1,99 @@
+stylist->current()->stylesheet();
+ if (! $this->stylist->current()->has($path)) {
+ return new HtmlString('');
+ }
+
+ return new HtmlString(' ');
+ }
+
+ public function script(?string $path = null): HtmlString
+ {
+ $path ??= $this->stylist->current()->script();
+ if (! $this->stylist->current()->has($path)) {
+ return new HtmlString('');
+ }
+
+ return new HtmlString('');
+ }
+
+ public function variables(): HtmlString
+ {
+ $theme = $this->stylist->current();
+ $default = $this->declarations($this->stylist->variables());
+ $rules = [':root{'.$default.'}'];
+
+ foreach ($theme->appearances() as $name => $definition) {
+ $vars = (array) data_get($definition, 'variables', []);
+ if ($vars) {
+ $rules[] = ':root[data-theme="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ }
+ }
+ foreach ($theme->accents() as $name => $definition) {
+ $vars = (array) data_get($definition, 'variables', []);
+ if ($vars) {
+ $rules[] = ':root[data-accent="'.$this->selector($name).'"]{'.$this->declarations($vars).'}';
+ }
+ }
+
+ if ($theme->appearances() === [] && $theme->accents() === []) {
+ return new HtmlString('');
+ }
+
+ $appearance = json_encode($this->stylist->appearance(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
+ $accent = json_encode($this->stylist->accent(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
+ $themeKey = json_encode($theme->key(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
+ $hasAppearances = $theme->appearances() !== [] ? 'true' : 'false';
+ $hasAccents = $theme->accents() !== [] ? 'true' : 'false';
+
+ $script = <<(function(){var d=document.documentElement,k={$themeKey},ha={$hasAppearances},hc={$hasAccents},a=ha?(localStorage.getItem('stylist:'+k+':appearance')||{$appearance}):'',c=hc?(localStorage.getItem('stylist:'+k+':accent')||{$accent}):'';function apply(){if(ha){var dark=a==='dark'||((a==='auto'||a==='system')&&matchMedia('(prefers-color-scheme: dark)').matches);d.dataset.theme=dark?'dark':'light';d.dataset.appearance=a;d.classList.toggle('dark',dark)}if(hc){d.dataset.accent=c}}apply();window.StylistTheme={setAppearance:function(v){if(!ha)return;a=v;localStorage.setItem('stylist:'+k+':appearance',v);apply()},setAccent:function(v){if(!hc)return;c=v;localStorage.setItem('stylist:'+k+':accent',v);apply()},appearance:function(){return a},accent:function(){return c}};if(ha){matchMedia('(prefers-color-scheme: dark)').addEventListener?.('change',function(){if(a==='auto'||a==='system')apply()})}document.dispatchEvent(new CustomEvent('stylist:ready',{detail:window.StylistTheme}))})();
+JS;
+
+ return new HtmlString(''.$script);
+ }
+
+ public function switcher(): HtmlString
+ {
+ $theme = $this->stylist->current();
+ $appearances = array_keys($theme->appearances());
+ $accents = array_keys($theme->accents());
+
+ if ($appearances === [] && $accents === []) {
+ return new HtmlString('');
+ }
+
+ $appearanceOptions = collect($appearances)->map(fn ($name) => ''.e(ucfirst($name)).' ')->implode('');
+ $accentOptions = collect($accents)->map(fn ($name) => ''.e(ucfirst($name)).' ')->implode('');
+ $appearanceSelect = $appearances ? 'Appearance '.$appearanceOptions.' ' : '';
+ $accentSelect = $accents ? 'Accent '.$accentOptions.' ' : '';
+
+ return new HtmlString(''.$appearanceSelect.$accentSelect.'
');
+ }
+
+ private function declarations(array $variables): string
+ {
+ return collect($variables)
+ ->filter(fn ($value, $key) => is_string($key) && (is_string($value) || is_numeric($value)))
+ ->map(fn ($value, string $key) => '--'.ltrim($key, '-').': '.e((string) $value).';')
+ ->implode('');
+ }
+
+ private function selector(string $value): string
+ {
+ return preg_replace('/[^a-z0-9_-]/i', '', $value) ?: 'default';
+ }
+}
diff --git a/Stylist/Html/ThemeSeoBuilder.php b/Stylist/Html/ThemeSeoBuilder.php
new file mode 100644
index 0000000..28d5840
--- /dev/null
+++ b/Stylist/Html/ThemeSeoBuilder.php
@@ -0,0 +1,44 @@
+stylist->current();
+ $seo = array_replace($theme->seo(), $overrides);
+ $settings = $this->stylist->settings();
+ $title = (string) ($seo['title'] ?? $settings['site_title'] ?? $theme->name());
+ $description = (string) ($seo['description'] ?? $settings['site_description'] ?? $theme->description());
+ $image = $seo['image'] ?? $theme->preview();
+ if ($image && $theme->has($image)) {
+ $image = $this->urls->asset($image);
+ }
+ $tags = [''.e($title).' '];
+ if ($description !== '') {
+ $tags[] = ' ';
+ }
+ foreach (['og:title' => $title, 'og:description' => $description, 'twitter:card' => 'summary_large_image', 'twitter:title' => $title, 'twitter:description' => $description] as $property => $content) {
+ if ($content === '') {
+ continue;
+ }$attribute = str_starts_with($property, 'og:') ? 'property' : 'name';
+ $tags[] = ' ';
+ }
+ if ($image) {
+ $tags[] = ' ';
+ $tags[] = ' ';
+ }
+ if (isset($seo['json_ld']) && is_array($seo['json_ld'])) {
+ $tags[] = '';
+ }
+
+ return new HtmlString(implode("\n", $tags));
+ }
+}
diff --git a/Stylist/Http/Controllers/StylistController.php b/Stylist/Http/Controllers/StylistController.php
new file mode 100644
index 0000000..219359e
--- /dev/null
+++ b/Stylist/Http/Controllers/StylistController.php
@@ -0,0 +1,23 @@
+ $stylist->current(), 'themes' => $stylist->all(), 'variables' => $stylist->variables(),
+ 'settingsService' => $settings, 'accessibility' => $accessibility, 'urls' => $urls,
+ 'activeFrontendTheme' => $siteSettings->get('frontend_theme', config('stylist.default_frontend_theme', 'saasland'), 'site'),
+ 'activeBackendTheme' => $siteSettings->get('backend_theme', config('stylist.default_backend_theme', 'admin'), 'site'),
+ ]);
+ }
+}
diff --git a/Stylist/Http/Controllers/ThemeAssetController.php b/Stylist/Http/Controllers/ThemeAssetController.php
new file mode 100644
index 0000000..e7695d1
--- /dev/null
+++ b/Stylist/Http/Controllers/ThemeAssetController.php
@@ -0,0 +1,64 @@
+find($theme);
+ $themeRoot = realpath($loadedTheme->path());
+ abort_unless($themeRoot !== false, 404);
+
+ $asset = realpath($themeRoot.DIRECTORY_SEPARATOR.$normalised);
+
+ abort_unless(
+ $asset !== false
+ && str_starts_with($asset, $themeRoot.DIRECTORY_SEPARATOR)
+ && is_file($asset),
+ 404
+ );
+
+ $mime = match ($extension) {
+ 'css' => 'text/css; charset=UTF-8',
+ 'js', 'mjs' => 'application/javascript; charset=UTF-8',
+ 'svg' => 'image/svg+xml',
+ 'json', 'map', 'webmanifest' => 'application/json',
+ 'woff' => 'font/woff',
+ 'woff2' => 'font/woff2',
+ 'ttf' => 'font/ttf',
+ 'otf' => 'font/otf',
+ default => mime_content_type($asset) ?: 'application/octet-stream',
+ };
+
+ return response()->file($asset, [
+ 'Content-Type' => $mime,
+ 'Cache-Control' => app()->isProduction()
+ ? 'public, max-age=31536000, immutable'
+ : 'no-cache, private',
+ 'X-Content-Type-Options' => 'nosniff',
+ 'Content-Security-Policy' => "default-src 'none'; style-src 'unsafe-inline'",
+ ]);
+ }
+}
diff --git a/Stylist/Http/Controllers/ThemeManagementController.php b/Stylist/Http/Controllers/ThemeManagementController.php
new file mode 100644
index 0000000..3c4f12f
--- /dev/null
+++ b/Stylist/Http/Controllers/ThemeManagementController.php
@@ -0,0 +1,87 @@
+find($theme);
+ abort_unless(in_array($item->type(), ['frontend', 'backend'], true), 422, 'Only frontend and backend themes can be activated.');
+
+ $settings->put($item->type().'_theme', $item->key(), 'site', 'string', true);
+
+ return back()->with('status', $item->name().' is now the active '.$item->type().' theme.');
+ }
+
+ public function preview(string $theme, Loader $loader, ThemeViewResolver $views)
+ {
+ $item = $loader->find($theme);
+
+ return $views->makeForTheme($item->key(), $item->entryView(), [
+ 'themePreview' => true,
+ 'previewTheme' => $item,
+ ]);
+ }
+
+ public function settings(Request $request, string $theme, Loader $loader, ThemeSettings $settings)
+ {
+ $item = $loader->find($theme);
+ $settings->save(
+ $item,
+ (array) $request->input('settings', []),
+ (string) $request->input('variant', 'light'),
+ $request->string('appearance')->toString() ?: null,
+ $request->string('accent')->toString() ?: null,
+ );
+
+ return back()->with('status', 'Theme settings saved.');
+ }
+
+ public function install(Request $request, ThemePackageManager $packages)
+ {
+ $request->validate(['theme' => ['required', 'file', 'mimes:zip', 'max:51200']]);
+ $theme = $packages->install($request->file('theme'));
+
+ return back()->with('status', $theme->name().' installed.');
+ }
+
+ public function uninstall(string $theme, Loader $loader, ThemePackageManager $packages)
+ {
+ $item = $loader->find($theme);
+ $packages->uninstall($item);
+
+ return back()->with('status', $item->name().' uninstalled.');
+ }
+
+ public function export(string $theme, Loader $loader, ThemePackageManager $packages)
+ {
+ $item = $loader->find($theme);
+ $path = $packages->export($item);
+
+ return response()->download($path)->deleteFileAfterSend();
+ }
+
+ public function optimize(string $theme, Loader $loader, ThemeOptimizer $optimizer)
+ {
+ $item = $loader->find($theme);
+ $result = $optimizer->optimize($item);
+
+ return back()->with('status', "Optimised {$result['css']} CSS, {$result['js']} JS and {$result['images']} images.");
+ }
+
+ public function validateTheme(string $theme, Loader $loader, ThemeAccessibilityValidator $validator)
+ {
+ return response()->json(['issues' => $validator->validate($loader->find($theme))]);
+ }
+}
diff --git a/Stylist/Livewire/ThemePreview.php b/Stylist/Livewire/ThemePreview.php
new file mode 100644
index 0000000..5e8560d
--- /dev/null
+++ b/Stylist/Livewire/ThemePreview.php
@@ -0,0 +1,21 @@
+ 12,
+ 'warehouses' => 34,
+ 'users' => 156,
+ ];
+
+ public function render()
+ {
+ return stylist_view('livewire.theme-preview', [
+ 'stats' => $this->stats,
+ ]);
+ }
+}
diff --git a/Stylist/Models/ThemeSetting.php b/Stylist/Models/ThemeSetting.php
new file mode 100644
index 0000000..fb01e6f
--- /dev/null
+++ b/Stylist/Models/ThemeSetting.php
@@ -0,0 +1,14 @@
+ 'array'];
+}
diff --git a/Stylist/Providers/RouteServiceProvider.php b/Stylist/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..68adb78
--- /dev/null
+++ b/Stylist/Providers/RouteServiceProvider.php
@@ -0,0 +1,22 @@
+group(module_path('Stylist', 'Routes/assets.php'));
+
+ Route::middleware('web')
+ ->prefix('stylist')
+ ->name('stylist.')
+ ->group(module_path('Stylist', 'Routes/web.php'));
+ }
+}
diff --git a/Stylist/Providers/StylistServiceProvider.php b/Stylist/Providers/StylistServiceProvider.php
new file mode 100644
index 0000000..31ee054
--- /dev/null
+++ b/Stylist/Providers/StylistServiceProvider.php
@@ -0,0 +1,215 @@
+app->register(RouteServiceProvider::class);
+
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+
+ $this->app->singleton('stylist', fn ($app) => $app->make(Stylist::class));
+ $this->app->singleton('stylist.html', fn ($app) => $app->make(ThemeHtmlBuilder::class));
+ $this->app->singleton('stylist.seo', fn ($app) => $app->make(ThemeSeoBuilder::class));
+ }
+
+ public function boot(): void
+ {
+ $this->registerViews();
+ $this->registerThemeViews();
+ $this->registerTranslations();
+ $this->registerPublishing();
+ $this->registerHelpers();
+ $this->registerBlade();
+
+ if ($this->app->runningInConsole()) {
+ $this->commands([
+ PublishAssetsCommand::class,
+ ValidateThemeCommand::class,
+ InspectThemesCommand::class,
+ FindDuplicateViewsCommand::class,
+ ThemeDoctorCommand::class,
+ CreateThemeCommand::class,
+ ]);
+ }
+
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+
+ View::composer('*', function ($view): void {
+ if (! $this->app->bound(Stylist::class)) {
+ return;
+ }
+
+ $view->with('activeTheme', $this->app->make(Stylist::class)->current());
+ });
+
+
+ View::composer('stylist-theme-saasland::*', function ($view): void {
+ $theme = $this->app->make(Loader::class)->find('saasland');
+ $settings = $this->app->make(ThemeSettings::class)->get($theme);
+ $siteSettings = $this->app->make(SettingsRepository::class)->site();
+ $navigation = $this->app->make(FrontendNavigation::class);
+ $homepage = $this->app->make(FrontendContent::class)->homepage($settings);
+
+ $view->with([
+ 'siteSettings' => $siteSettings,
+ 'siteTitle' => $siteSettings['site_name'],
+ 'siteDescription' => $siteSettings['site_description'],
+ 'logoUrl' => $siteSettings['logo_url'] ?: null,
+ 'facebookUrl' => $siteSettings['facebook_url'] ?: null,
+ 'xUrl' => $siteSettings['x_url'] ?: null,
+ 'linkedinUrl' => $siteSettings['linkedin_url'] ?: null,
+ 'primaryNavigation' => $navigation->primary(),
+ 'footerNavigation' => $navigation->footer(),
+ 'themeSettings' => $settings,
+ ...$homepage,
+ ]);
+ });
+ }
+
+ private function registerViews(): void
+ {
+ $sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
+ $viewPath = resource_path('views/modules/'.ModuleInfo::nameLower());
+
+ $this->publishes([$sourcePath => $viewPath], ['views', 'stylist-module-views']);
+ $this->loadViewsFrom([$viewPath, $sourcePath], ModuleInfo::nameLower());
+ }
+
+ private function registerThemeViews(): void
+ {
+ foreach ($this->themeRoots() as $root) {
+ if (! is_dir($root)) {
+ logger()->warning('Stylist theme root does not exist', [
+ 'root' => $root,
+ ]);
+
+ continue;
+ }
+
+ $themeDirectories = glob($root.'/*', GLOB_ONLYDIR) ?: [];
+
+ foreach ($themeDirectories as $themeDirectory) {
+ $themeViewsPath = $themeDirectory.'/views';
+
+ if (! is_dir($themeViewsPath)) {
+ continue;
+ }
+
+ $themeKey = strtolower(basename($themeDirectory));
+
+ // Themes always live outside the module, normally in resources/themes.
+ // Register the canonical namespace and the legacy namespace as an alias
+ // to the same application-owned directory. The alias keeps existing
+ // routes and Blade templates working without reintroducing module themes.
+ $namespaces = [
+ "stylist-theme-{$themeKey}",
+ "stylist-module-theme-{$themeKey}",
+ ];
+
+ $componentsPath = $themeViewsPath.'/components';
+
+ // The common theme is the application-wide owner of layouts,
+ // authentication, settings and shared components. Livewire 4
+ // resolves its default full-page layout through layouts::app,
+ // so point that compatibility namespace at the common theme.
+ if ($themeKey === 'common') {
+ $layoutsPath = $componentsPath.'/layouts';
+
+ if (is_dir($layoutsPath)) {
+ View::addNamespace('layouts', $layoutsPath);
+ Blade::anonymousComponentPath($layoutsPath, 'layouts');
+ }
+
+ View::addNamespace('common', $themeViewsPath);
+
+ if (is_dir($componentsPath)) {
+ Blade::anonymousComponentPath($componentsPath, 'common');
+ }
+ }
+
+ foreach ($namespaces as $namespace) {
+ View::addNamespace($namespace, $themeViewsPath);
+
+ if (is_dir($componentsPath)) {
+ Blade::anonymousComponentPath($componentsPath, $namespace);
+ }
+ }
+ }
+ }
+ }
+
+ private function normalisePath(string $path): string
+ {
+ return rtrim(
+ str_replace('\\', '/', realpath($path) ?: $path),
+ '/'
+ );
+ }
+
+ /** @return array */
+ private function themeRoots(): array
+ {
+ return array_values(array_unique(array_filter([
+ (string) config('stylist.themes_path'),
+ ])));
+ }
+
+ private function registerTranslations(): void
+ {
+ $langPath = resource_path('lang/modules/'.ModuleInfo::nameLower());
+ $sourcePath = module_path(ModuleInfo::name(), 'Resources/lang');
+
+ $this->loadTranslationsFrom(is_dir($langPath) ? $langPath : $sourcePath, ModuleInfo::nameLower());
+ }
+
+ private function registerPublishing(): void
+ {
+ $this->publishes([
+ module_path(ModuleInfo::name(), 'Config/config.php') => config_path('stylist.php'),
+ ], ['config', 'stylist-config']);
+
+ }
+
+ private function registerHelpers(): void
+ {
+ foreach (glob(module_path(ModuleInfo::name(), 'Helpers/*.php')) ?: [] as $filename) {
+ require_once $filename;
+ }
+ }
+
+ private function registerBlade(): void
+ {
+ Blade::directive('themeVariables', fn () => 'variables(); ?>');
+ Blade::directive('themeStylesheet', fn ($expression) => 'stylesheet('.($expression ?: 'null').'); ?>');
+ Blade::directive('themeScript', fn ($expression) => 'script('.($expression ?: 'null').'); ?>');
+ Blade::directive('themeSwitcher', fn () => 'switcher(); ?>');
+ Blade::directive('themeSeo', fn ($expression) => 'render('.($expression ?: '[]').'); ?>');
+ }
+}
diff --git a/Stylist/README.md b/Stylist/README.md
new file mode 100644
index 0000000..810bf53
--- /dev/null
+++ b/Stylist/README.md
@@ -0,0 +1,183 @@
+# Stylist Module
+
+Tenant-aware styling module for Laravel 13 / nwidart Laravel Modules.
+
+## Theme layout
+
+Each theme is self-contained:
+
+```text
+resources/themes/{theme}/
+├── theme.json
+├── css/theme.css
+└── views/
+ ├── layouts/app.blade.php
+ ├── components/page-hero.blade.php
+ └── livewire/theme-preview.blade.php
+```
+
+The active tenant/company resolves to a theme key such as `default`, `mpba`, or `abbott`.
+
+## Rendering themed views
+
+Use the helper from controllers, Blade components, and Livewire components:
+
+```php
+return stylist_view('layouts.app');
+```
+
+It checks views in this order:
+
+```text
+stylist-theme-{active-theme}::{view}
+stylist-theme-{default-theme}::{view}
+stylist::{view}
+```
+
+## Livewire
+
+Keep the PHP component class shared and put the template in each theme:
+
+```text
+Livewire/ThemePreview.php
+resources/themes/default/views/livewire/theme-preview.blade.php
+resources/themes/mpba/views/livewire/theme-preview.blade.php
+```
+
+The component renders with:
+
+```php
+return stylist_view('livewire.theme-preview', ['stats' => $this->stats]);
+```
+
+## Tenant assignment
+
+Add or use these fields on `companies`:
+
+```text
+theme_key
+brand_primary
+brand_secondary
+brand_accent
+logo_path
+favicon_path
+theme_settings
+```
+
+Then assign:
+
+```php
+$company->update(['theme_key' => 'mpba']);
+```
+
+## Publishing
+
+```bash
+php artisan vendor:publish --tag=stylist-config
+# or
+php artisan stylist:publish-assets
+
+Themes are not published from or stored inside the module. Install them directly under `resources/themes`.
+```
+
+## Blade helpers
+
+```blade
+
+ @themeVariables
+ @themeStylesheet
+
+```
+
+## Self-contained application themes
+
+Application themes live in `resources/themes/{theme}` and own their runtime assets:
+
+```text
+resources/themes/example/
+├── theme.json
+├── css/theme.css
+├── js/app.js
+└── views/home.blade.php
+```
+
+Use `@themeStylesheet('css/theme.css')`, `@themeScript('js/app.js')`, or `theme_asset('images/example.svg')`. Assets are served from the selected theme directory by the `stylist.theme.asset` route; they do not need to be copied into `public/themes` or added to the global Vite inputs.
+
+
+## Appearance and colour variants
+
+Themes may declare `appearances` (`light`, `dark`, `auto`) and named `accents` in `theme.json`. Include `@themeVariables` in the document `` before the theme stylesheet to prevent a flash of the wrong appearance. Add `@themeSwitcher` anywhere in a Blade layout for a ready-made runtime selector. User choices are stored in localStorage and are applied without rebuilding assets or reloading the page.
+
+## Theme manifest compatibility
+
+Stylist accepts versioned manifests while remaining compatible with older themes. New manifests should declare:
+
+```json
+{
+ "manifest_version": 1,
+ "key": "design-gallery",
+ "name": "Design Gallery"
+}
+```
+
+`manifest_version` defaults to `1` when omitted. Unknown manifest fields are preserved and ignored by Stylist unless the current version explicitly uses them, allowing Theme Builder to add future metadata without preventing theme discovery.
+
+Appearance and accent features are optional. Stylist derives the internal capability flags only when the corresponding definitions exist:
+
+```json
+{
+ "appearances": ["light", "dark", "system"],
+ "accents": ["blue", "neutral"],
+ "default_appearance": "system",
+ "default_accent": "neutral"
+}
+```
+
+Legacy themes with none of these fields continue to load unchanged and do not receive appearance or accent controls.
+
+Additional boolean capabilities may be declared explicitly:
+
+```json
+{
+ "capabilities": {
+ "marketplace": true
+ }
+}
+```
+
+## Validate a theme
+
+Validate an installed theme key, unpacked theme directory, or ZIP package:
+
+```bash
+php artisan stylist:validate design-gallery
+php artisan stylist:validate resources/themes/design-gallery
+php artisan stylist:validate ~/Downloads/design-gallery.zip
+```
+
+The command checks the manifest, required entry view, stylesheet, script, declared assets, preview and accessibility issues. It exits with a failure status when errors are found.
+
+## Theme inheritance and diagnostics
+
+Shared application views belong to the `common` theme. Child themes declare inheritance without copying those views:
+
+```json
+{
+ "name": "Dashboard",
+ "slug": "admin",
+ "extends": "common"
+}
+```
+
+Resolution order is the active theme, each declared parent, the configured fallback, the common theme, module defaults, then Laravel's normal views.
+
+Use these maintenance commands:
+
+```bash
+php artisan stylist:inspect
+php artisan stylist:duplicates
+php artisan stylist:doctor
+php artisan stylist:create-theme customer --parent=common --name="Customer Portal"
+```
+
+`stylist:duplicates` fails when the same relative Blade task path exists in more than one theme. Add a child view only when it is a deliberate replacement; this application enforces one authoritative view per task.
diff --git a/Stylist/Resources/assets/css/app.css b/Stylist/Resources/assets/css/app.css
new file mode 100644
index 0000000..32b4fbc
--- /dev/null
+++ b/Stylist/Resources/assets/css/app.css
@@ -0,0 +1,5 @@
+[data-theme] {
+ --brand-primary: var(--brand-primary, #2563eb);
+ --brand-secondary: var(--brand-secondary, #0f172a);
+ --brand-accent: var(--brand-accent, #22c55e);
+}
diff --git a/Stylist/Resources/assets/js/app.js b/Stylist/Resources/assets/js/app.js
new file mode 100644
index 0000000..f65cc9b
--- /dev/null
+++ b/Stylist/Resources/assets/js/app.js
@@ -0,0 +1 @@
+import '../css/app.css';
diff --git a/Stylist/Resources/lang/en/messages.php b/Stylist/Resources/lang/en/messages.php
new file mode 100644
index 0000000..56494b1
--- /dev/null
+++ b/Stylist/Resources/lang/en/messages.php
@@ -0,0 +1,5 @@
+ 'Stylist',
+];
diff --git a/Stylist/Resources/views/index.blade.php b/Stylist/Resources/views/index.blade.php
new file mode 100644
index 0000000..19ffd23
--- /dev/null
+++ b/Stylist/Resources/views/index.blade.php
@@ -0,0 +1,78 @@
+
+
+
+ @if (session('status'))
+ {{ session('status') }}
+ @endif
+ @if ($errors->any())
+ {{ $errors->first() }}
+ @endif
+
+
+ @csrf
+ Install theme ZIP
+ Install theme
+
+
+
+ @foreach ($themes as $item)
+ @php($saved = $settingsService->get($item))
+ @php($variant = $settingsService->variant($item))
+ @php($issues = $accessibility->validate($item))
+ @php($appearance = $settingsService->appearance($item))
+ @php($accent = $settingsService->accent($item))
+
+
+ @if ($item->preview() && $item->has($item->preview()))
+
+ @else
+
Add preview.webp to this theme
+ @endif
+
+
+
{{ $item->name() }} v{{ $item->version() }}@if($item->author()) · {{ $item->author() }}@endif
@if(($item->type() === 'frontend' && $item->key() === $activeFrontendTheme) || ($item->type() === 'backend' && $item->key() === $activeBackendTheme))
Active {{ ucfirst($item->type()) }} @endif
+ @if($item->description())
{{ $item->description() }}
@endif
+ @if($item->parent())
Child of {{ $item->parent() }}
@endif
+
+ @if(in_array($item->type(), ['frontend', 'backend'], true))
+
@csrfActivate {{ ucfirst($item->type()) }}
+
Preview
+ @endif
+
Export ZIP
+
@csrfOptimise assets
+ @if(str_starts_with(realpath($item->path()) ?: '', realpath(config('stylist.themes_path')) ?: '/__never__'))
+
@csrf @method('DELETE')Uninstall
+ @endif
+
+
+ Theme settings
+ @csrf @method('PUT')
+ @if($item->variants())
+ Variant @foreach(array_keys($item->variants()) as $name){{ ucfirst($name) }} @endforeach
+ @endif
+ @if($item->supports('dark_mode') || $item->appearances())
+ Appearance @foreach(array_keys($item->appearances()) ?: ['light', 'dark', 'auto'] as $name){{ ucfirst($name) }} @endforeach
+ @endif
+ @if($item->accents())
+
+ @endif
+ @foreach($item->settingsSchema() as $key => $definition)
+ @php($definition = is_array($definition) ? $definition : ['default' => $definition])
+ {{ $definition['label'] ?? str($key)->headline() }}
+ @switch($definition['type'] ?? 'text')
+ @case('color') @break
+ @case('textarea'){{ $saved[$key] ?? ($definition['default'] ?? '') }} @break
+ @case('checkbox')Enabled @break
+ @default
+ @endswitch
+
+ @endforeach
+ Save settings
+
+
+
Accessibility: {{ count($issues) ? count($issues).' issue(s)' : 'passed static checks' }}
+
+
+ @endforeach
+
+
diff --git a/Stylist/Routes/assets.php b/Stylist/Routes/assets.php
new file mode 100644
index 0000000..9662db6
--- /dev/null
+++ b/Stylist/Routes/assets.php
@@ -0,0 +1,16 @@
+where('theme', '[A-Za-z0-9_-]+')
+ ->where('path', '.*')
+ ->name('stylist.theme.asset');
diff --git a/Stylist/Routes/web.php b/Stylist/Routes/web.php
new file mode 100644
index 0000000..c39ec85
--- /dev/null
+++ b/Stylist/Routes/web.php
@@ -0,0 +1,17 @@
+group(function (): void {
+ Route::get('/', StylistController::class)->name('index');
+ Route::post('/install', [ThemeManagementController::class, 'install'])->name('install');
+ Route::put('/{theme}/settings', [ThemeManagementController::class, 'settings'])->name('settings');
+ Route::post('/{theme}/activate', [ThemeManagementController::class, 'activate'])->name('activate');
+ Route::get('/{theme}/preview', [ThemeManagementController::class, 'preview'])->name('preview');
+ Route::delete('/{theme}', [ThemeManagementController::class, 'uninstall'])->name('uninstall');
+ Route::get('/{theme}/export', [ThemeManagementController::class, 'export'])->name('export');
+ Route::post('/{theme}/optimize', [ThemeManagementController::class, 'optimize'])->name('optimize');
+ Route::get('/{theme}/accessibility', [ThemeManagementController::class, 'validateTheme'])->name('accessibility');
+});
diff --git a/Stylist/Services/FrontendContent.php b/Stylist/Services/FrontendContent.php
new file mode 100644
index 0000000..e57aac5
--- /dev/null
+++ b/Stylist/Services/FrontendContent.php
@@ -0,0 +1,102 @@
+ */
+ public function homepage(array $settings): array
+ {
+ // Always provide source collections. Visibility is owned by page placements
+ // in the composable content system; the legacy theme still applies its
+ // existing show_* settings while no composition has been installed.
+ return [
+ 'pricingPlans' => $this->pricingPlans(),
+ 'testimonials' => $this->testimonials(),
+ 'faqs' => $this->faqs(),
+ 'teamMembers' => $this->team(),
+ 'latestBlogPosts' => $this->blogPosts(),
+ 'latestNewsArticles' => $this->newsArticles(),
+ ];
+ }
+
+ private function pricingPlans(): Collection
+ {
+ return $this->query('products', fn () => Product::query()
+ ->where('is_active', true)
+ ->where('product_type', 'plan')
+ ->orderBy('sort_order')
+ ->orderBy('id')
+ ->limit(6)
+ ->get());
+ }
+
+ private function testimonials(): Collection
+ {
+ return $this->query('testimonials', fn () => Testimonial::query()
+ ->where('enabled', true)
+ ->latest('id')
+ ->limit(6)
+ ->get());
+ }
+
+ private function faqs(): Collection
+ {
+ return $this->query('faqs', fn () => Faq::query()
+ ->where('enabled', true)
+ ->orderBy('sort_order')
+ ->limit(12)
+ ->get());
+ }
+
+ private function team(): Collection
+ {
+ return $this->query('team_members', fn () => TeamMember::query()
+ ->where('enabled', true)
+ ->orderBy('sort_order')
+ ->limit(8)
+ ->get());
+ }
+
+ private function blogPosts(): Collection
+ {
+ return $this->query('blog_posts', fn () => Blog::query()
+ ->published()
+ ->latest('published_at')
+ ->limit(3)
+ ->get());
+ }
+
+ private function newsArticles(): Collection
+ {
+ return $this->query('news_articles', fn () => News::query()
+ ->published()
+ ->latest('published_at')
+ ->limit(3)
+ ->get());
+ }
+
+ private function query(string $table, callable $callback): Collection
+ {
+ try {
+ return Schema::hasTable($table) ? $callback() : collect();
+ } catch (Throwable) {
+ return collect();
+ }
+ }
+
+ private function enabled(array $settings, string $key, bool $default): bool
+ {
+ return filter_var($settings[$key] ?? $default, FILTER_VALIDATE_BOOL);
+ }
+}
diff --git a/Stylist/Services/TenantThemeResolver.php b/Stylist/Services/TenantThemeResolver.php
new file mode 100644
index 0000000..58d4920
--- /dev/null
+++ b/Stylist/Services/TenantThemeResolver.php
@@ -0,0 +1,65 @@
+context();
+ $column = config('stylist.tenant_theme_column', 'theme_key');
+ $user = Auth::user();
+ $company = $user?->company ?? null;
+
+ if ($company && filled($company->{$column} ?? null)) {
+ return (string) $company->{$column};
+ }
+
+ if (function_exists('tenant')) {
+ $tenant = tenant();
+ if ($tenant && filled($tenant->{$column} ?? null)) {
+ return (string) $tenant->{$column};
+ }
+ }
+
+ $settingKey = $context === 'frontend' ? 'frontend_theme' : 'backend_theme';
+ $default = $context === 'frontend'
+ ? config('stylist.default_frontend_theme', 'saasland')
+ : config('stylist.default_backend_theme', config('stylist.default_theme', 'admin'));
+
+ return (string) $this->settings->get($settingKey, $default, 'site');
+ }
+
+ public function context(): string
+ {
+ if (app()->runningInConsole()) {
+ return 'backend';
+ }
+
+ $request = request();
+
+ return $request->routeIs('home', 'blog.public.*', 'news.public.*', 'pages.public.*')
+ || ! $request->user()
+ ? 'frontend'
+ : 'backend';
+ }
+
+ public function variables(): array
+ {
+ $variables = config('stylist.fallback_variables', []);
+ $company = Auth::user()?->company ?? null;
+
+ foreach (config('stylist.tenant_brand_columns', []) as $column => $variable) {
+ if ($company && filled($company->{$column} ?? null)) {
+ $variables[$variable] = (string) $company->{$column};
+ }
+ }
+
+ return $variables;
+ }
+}
diff --git a/Stylist/Services/ThemeAccessibilityValidator.php b/Stylist/Services/ThemeAccessibilityValidator.php
new file mode 100644
index 0000000..70b68bb
--- /dev/null
+++ b/Stylist/Services/ThemeAccessibilityValidator.php
@@ -0,0 +1,32 @@
+ */
+ public function validate(Theme $theme): array
+ {
+ $issues = [];
+ foreach ($this->files->allFiles($theme->path().'/views') as $file) {
+ $content = $file->getContents();
+ $relative = $file->getRelativePathname();
+ if (preg_match_all('/ ]*\balt=)[^>]*>/i', $content, $matches)) {
+ $issues[] = ['severity' => 'error', 'message' => count($matches[0]).' image(s) missing alt text.', 'file' => $relative];
+ }
+ if (preg_match('/]*\blang=)/i', $content)) {
+ $issues[] = ['severity' => 'warning', 'message' => 'HTML element is missing a lang attribute.', 'file' => $relative];
+ }
+ if (preg_match_all('/]*>\s*(?:<[^>]+>\s*)*<\/button>/is', $content, $matches)) {
+ $issues[] = ['severity' => 'warning', 'message' => count($matches[0]).' button(s) may lack an accessible name.', 'file' => $relative];
+ }
+ }
+
+ return $issues;
+ }
+}
diff --git a/Stylist/Services/ThemeOptimizer.php b/Stylist/Services/ThemeOptimizer.php
new file mode 100644
index 0000000..def6927
--- /dev/null
+++ b/Stylist/Services/ThemeOptimizer.php
@@ -0,0 +1,57 @@
+ 0, 'js' => 0, 'images' => 0];
+ foreach (['css' => 'css', 'js' => 'js'] as $type => $folder) {
+ $directory = $theme->path().'/'.$folder;
+ if (! $this->files->isDirectory($directory)) {
+ continue;
+ }
+ foreach ($this->files->files($directory) as $file) {
+ if ($file->getExtension() !== $type || str_ends_with($file->getFilename(), '.min.'.$type)) {
+ continue;
+ }
+ $content = $file->getContents();
+ $content = preg_replace('!/\*.*?\*/!s', '', $content) ?? $content;
+ $content = preg_replace('/\s+/', ' ', $content) ?? $content;
+ $content = preg_replace('/\s*([{}:;,>])\s*/', '$1', $content) ?? $content;
+ $target = $file->getPath().'/'.$file->getBasename('.'.$type).'.min.'.$type;
+ $this->files->put($target, trim($content));
+ $result[$type]++;
+ }
+ }
+ if (function_exists('imagewebp')) {
+ foreach (['images', 'icons'] as $folder) {
+ $directory = $theme->path().'/'.$folder;
+ if (! $this->files->isDirectory($directory)) {
+ continue;
+ }
+ foreach ($this->files->files($directory) as $file) {
+ $ext = strtolower($file->getExtension());
+ if (! in_array($ext, ['jpg', 'jpeg', 'png'], true)) {
+ continue;
+ }
+ $image = $ext === 'png' ? @imagecreatefrompng($file->getPathname()) : @imagecreatefromjpeg($file->getPathname());
+ if ($image) {
+ imagepalettetotruecolor($image);
+ imagewebp($image, $file->getPath().'/'.$file->getBasename('.'.$ext).'.webp', 82);
+ imagedestroy($image);
+ $result['images']++;
+ }
+ }
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/Stylist/Services/ThemePackageManager.php b/Stylist/Services/ThemePackageManager.php
new file mode 100644
index 0000000..56d1a4a
--- /dev/null
+++ b/Stylist/Services/ThemePackageManager.php
@@ -0,0 +1,98 @@
+getClientOriginalExtension()) !== 'zip') {
+ throw new RuntimeException('Only ZIP theme packages may be installed.');
+ }
+ $temporary = storage_path('app/stylist/install-'.bin2hex(random_bytes(8)));
+ $this->files->ensureDirectoryExists($temporary);
+ $zip = new ZipArchive;
+ if ($zip->open($upload->getRealPath()) !== true) {
+ throw new RuntimeException('The uploaded ZIP could not be opened.');
+ }
+ for ($i = 0; $i < $zip->numFiles; $i++) {
+ $name = $zip->getNameIndex($i);
+ if (str_contains($name, '..') || str_starts_with($name, '/') || str_contains($name, "\0")) {
+ $zip->close();
+ throw new RuntimeException('The ZIP contains an unsafe path.');
+ }
+ }
+ $zip->extractTo($temporary);
+ $zip->close();
+ $manifest = collect($this->files->allFiles($temporary))->first(fn ($file) => $file->getFilename() === 'theme.json');
+ if (! $manifest) {
+ $this->files->deleteDirectory($temporary);
+ throw new RuntimeException('The package does not contain theme.json.');
+ }
+ $source = $manifest->getPath();
+ $theme = $this->loader->fromPath($source);
+ if ($theme->parent() && ! $this->loader->all()->has($theme->parent())) {
+ throw new RuntimeException('Parent theme '.$theme->parent().' is not installed.');
+ }
+ $issues = $this->validator->validate($theme);
+ if (collect($issues)->contains('severity', 'error')) {
+ throw new RuntimeException('Accessibility validation failed: '.collect($issues)->where('severity', 'error')->pluck('message')->implode(' '));
+ }
+ $target = rtrim((string) config('stylist.themes_path'), '/').'/'.$theme->key();
+ if ($this->files->isDirectory($target)) {
+ $this->files->deleteDirectory($target);
+ }
+ $this->files->copyDirectory($source, $target);
+ $this->files->deleteDirectory($temporary);
+ $this->loader->flush();
+
+ return $this->loader->find($theme->key());
+ }
+
+ public function uninstall(Theme $theme): void
+ {
+ $active = $theme->type() === 'frontend'
+ ? $this->settings->get('frontend_theme', config('stylist.default_frontend_theme'), 'site')
+ : $this->settings->get('backend_theme', config('stylist.default_backend_theme'), 'site');
+ if ($active === $theme->key()) {
+ throw new RuntimeException('The active theme cannot be uninstalled. Activate another theme first.');
+ }
+ $projectRoot = realpath((string) config('stylist.themes_path'));
+ $themePath = realpath($theme->path());
+ if (! $projectRoot || ! $themePath || ! str_starts_with($themePath, $projectRoot.DIRECTORY_SEPARATOR)) {
+ throw new RuntimeException('Bundled themes cannot be uninstalled.');
+ }
+ if (collect($this->loader->all())->contains(fn (Theme $candidate) => $candidate->parent() === $theme->key())) {
+ throw new RuntimeException('This theme has installed child themes.');
+ }
+ $this->files->deleteDirectory($themePath);
+ $this->loader->flush();
+ }
+
+ public function export(Theme $theme): string
+ {
+ $directory = storage_path('app/stylist/exports');
+ $this->files->ensureDirectoryExists($directory);
+ $path = $directory.'/'.$theme->key().'-'.$theme->version().'.zip';
+ $zip = new ZipArchive;
+ if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
+ throw new RuntimeException('Unable to create theme archive.');
+ }
+ foreach ($this->files->allFiles($theme->path()) as $file) {
+ $zip->addFile($file->getPathname(), $theme->key().'/'.$file->getRelativePathname());
+ }
+ $zip->close();
+
+ return $path;
+ }
+}
diff --git a/Stylist/Services/ThemeSettings.php b/Stylist/Services/ThemeSettings.php
new file mode 100644
index 0000000..0a1f253
--- /dev/null
+++ b/Stylist/Services/ThemeSettings.php
@@ -0,0 +1,97 @@
+stored($theme);
+ $defaults = collect($theme->settingsSchema())
+ ->mapWithKeys(fn ($definition, $key) => [$key => is_array($definition) ? ($definition['default'] ?? null) : $definition])
+ ->all();
+
+ return array_replace($defaults, array_diff_key((array) ($stored?->settings ?? []), array_flip([self::APPEARANCE_KEY, self::ACCENT_KEY])));
+ }
+
+ public function variant(Theme $theme): string
+ {
+ $variant = (string) ($this->stored($theme)?->variant ?? array_key_first($theme->variants()) ?? 'light');
+
+ return array_key_exists($variant, $theme->variants()) ? $variant : (array_key_first($theme->variants()) ?? 'light');
+ }
+
+ public function appearance(Theme $theme): string
+ {
+ $stored = (array) ($this->stored($theme)?->settings ?? []);
+ $allowed = array_keys($theme->appearances());
+ if ($allowed === []) {
+ return '';
+ }
+ $appearance = (string) ($stored[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
+
+ return in_array($appearance, $allowed, true) ? $appearance : (string) array_key_first($theme->appearances());
+ }
+
+ public function accent(Theme $theme): string
+ {
+ $stored = (array) ($this->stored($theme)?->settings ?? []);
+ $allowed = array_keys($theme->accents());
+ if ($allowed === []) {
+ return '';
+ }
+ $accent = (string) ($stored[self::ACCENT_KEY] ?? $theme->defaultAccent());
+
+ return in_array($accent, $allowed, true) ? $accent : (string) array_key_first($theme->accents());
+ }
+
+ public function save(Theme $theme, array $settings, string $variant, ?string $appearance = null, ?string $accent = null): ThemeSetting
+ {
+ $settings = array_intersect_key($settings, array_flip(array_keys($theme->settingsSchema())));
+ $existing = (array) ($this->stored($theme)?->settings ?? []);
+
+ $allowedAppearances = array_keys($theme->appearances());
+ if ($allowedAppearances !== []) {
+ $appearance ??= (string) ($existing[self::APPEARANCE_KEY] ?? $theme->defaultAppearance());
+ if (! in_array($appearance, $allowedAppearances, true)) {
+ $appearance = (string) array_key_first($theme->appearances());
+ }
+ $settings[self::APPEARANCE_KEY] = $appearance;
+ }
+
+ $allowedAccents = array_keys($theme->accents());
+ if ($allowedAccents !== []) {
+ $accent ??= (string) ($existing[self::ACCENT_KEY] ?? $theme->defaultAccent());
+ if (! in_array($accent, $allowedAccents, true)) {
+ $accent = (string) array_key_first($theme->accents());
+ }
+ $settings[self::ACCENT_KEY] = $accent;
+ }
+
+ if ($theme->variants() && ! array_key_exists($variant, $theme->variants())) {
+ $variant = array_key_first($theme->variants());
+ }
+
+ return ThemeSetting::query()->updateOrCreate(
+ ['theme_key' => $theme->key()],
+ ['settings' => $settings, 'variant' => $variant]
+ );
+ }
+
+ private function stored(Theme $theme): ?ThemeSetting
+ {
+ try {
+ return ThemeSetting::query()->where('theme_key', $theme->key())->first();
+ } catch (QueryException) {
+ return null;
+ }
+ }
+}
diff --git a/Stylist/Services/ThemeValidator.php b/Stylist/Services/ThemeValidator.php
new file mode 100644
index 0000000..472397c
--- /dev/null
+++ b/Stylist/Services/ThemeValidator.php
@@ -0,0 +1,74 @@
+ */
+ public function validatePath(string $path): array
+ {
+ $theme = $this->loader->fromPath($path);
+
+ return $this->validate($theme);
+ }
+
+ /** @return array */
+ public function validate(Theme $theme): array
+ {
+ $issues = [];
+ $entryView = 'views/'.str_replace('.', '/', $theme->entryView()).'.blade.php';
+
+ if (! $theme->has($entryView)) {
+ $issues[] = [
+ 'severity' => 'error',
+ 'message' => "Entry view [{$theme->entryView()}] was not found.",
+ 'file' => $entryView,
+ ];
+ }
+
+ foreach (['stylesheet' => $theme->stylesheet(), 'script' => $theme->script()] as $label => $relativePath) {
+ if ($relativePath !== '' && ! $theme->has($relativePath)) {
+ $issues[] = [
+ 'severity' => 'error',
+ 'message' => ucfirst($label)." file [{$relativePath}] was not found.",
+ 'file' => $relativePath,
+ ];
+ }
+ }
+
+ $preview = $theme->preview();
+ if ($preview !== null && ! $theme->has($preview)) {
+ $issues[] = [
+ 'severity' => 'warning',
+ 'message' => "Preview file [{$preview}] was not found.",
+ 'file' => $preview,
+ ];
+ }
+
+ foreach ((array) ($theme->meta()['assets'] ?? []) as $asset) {
+ if (! is_string($asset) || $asset === '') {
+ continue;
+ }
+
+ if (! $theme->has($asset)) {
+ $issues[] = [
+ 'severity' => 'warning',
+ 'message' => "Declared asset [{$asset}] was not found.",
+ 'file' => $asset,
+ ];
+ }
+ }
+
+ return array_merge($issues, $this->accessibility->validate($theme));
+ }
+}
diff --git a/Stylist/Support/FrontendNavigation.php b/Stylist/Support/FrontendNavigation.php
new file mode 100644
index 0000000..711f089
--- /dev/null
+++ b/Stylist/Support/FrontendNavigation.php
@@ -0,0 +1,76 @@
+> */
+ public function primary(): array
+ {
+ return $this->location('primary', $this->fallbackPrimary());
+ }
+
+ /** @return array> */
+ public function footer(): array
+ {
+ return $this->location('footer', $this->fallbackPrimary());
+ }
+
+ /** @param array> $fallback
+ * @return array>
+ */
+ public function location(string $location, array $fallback = []): array
+ {
+ try {
+ if (! Schema::hasTable('menus')) {
+ return $fallback;
+ }
+
+ $menu = Menu::query()->where('enabled', true)->where('location', $location)->first();
+ if (! $menu || ! is_array($menu->items)) {
+ return $fallback;
+ }
+
+ $items = $this->normaliseItems($menu->items);
+
+ return $items === [] ? $fallback : $items;
+ } catch (Throwable) {
+ return $fallback;
+ }
+ }
+
+ /** @param array $items
+ * @return array>
+ */
+ private function normaliseItems(array $items): array
+ {
+ return collect($items)
+ ->filter(fn (mixed $item): bool => is_array($item) && filter_var($item['enabled'] ?? true, FILTER_VALIDATE_BOOL))
+ ->map(function (array $item): ?array {
+ $label = trim((string) ($item['label'] ?? ''));
+ $url = trim((string) ($item['url'] ?? ''));
+ if ($label === '' || $url === '') return null;
+
+ return [
+ 'label' => $label,
+ 'url' => $url,
+ 'target' => ($item['target'] ?? '') === '_blank' ? '_blank' : null,
+ 'children' => $this->normaliseItems((array) ($item['children'] ?? [])),
+ ];
+ })->filter()->values()->all();
+ }
+
+ /** @return array> */
+ private function fallbackPrimary(): array
+ {
+ $items = [['label' => 'Home', 'url' => url('/'), 'target' => null, 'children' => []]];
+ if (Route::has('blog.public.index')) $items[] = ['label' => 'Blog', 'url' => route('blog.public.index'), 'target' => null, 'children' => []];
+ if (Route::has('news.public.index')) $items[] = ['label' => 'News', 'url' => route('news.public.index'), 'target' => null, 'children' => []];
+ return $items;
+ }
+}
diff --git a/Stylist/Support/ModuleInfo.php b/Stylist/Support/ModuleInfo.php
new file mode 100644
index 0000000..6c2f429
--- /dev/null
+++ b/Stylist/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+assertTrue(view()->exists($view), "Expected common view [{$view}] to exist.");
+ }
+ }
+
+ public function test_authentication_and_settings_views_are_not_duplicated(): void
+ {
+ $this->assertDirectoryDoesNotExist(resource_path('views'));
+ $this->assertDirectoryDoesNotExist(resource_path('themes/admin/views/livewire'));
+ $this->assertDirectoryDoesNotExist(resource_path('themes/welcome/views/livewire'));
+
+ $commonViews = resource_path('themes/common/views');
+
+ foreach ([
+ 'livewire/auth/login.blade.php',
+ 'livewire/auth/register.blade.php',
+ 'livewire/auth/forgot-password.blade.php',
+ 'livewire/auth/reset-password.blade.php',
+ 'livewire/settings/profile.blade.php',
+ 'livewire/settings/appearance.blade.php',
+ 'livewire/settings/security.blade.php',
+ ] as $relativePath) {
+ $matches = collect(File::allFiles(resource_path('themes')))
+ ->filter(fn ($file) => str_ends_with(
+ str_replace('\\', '/', $file->getPathname()),
+ '/views/'.$relativePath
+ ));
+
+ $this->assertCount(1, $matches, "Expected one authoritative view for [{$relativePath}].");
+ $this->assertStringStartsWith(
+ str_replace('\\', '/', $commonViews),
+ str_replace('\\', '/', $matches->first()->getPathname())
+ );
+ }
+ }
+}
diff --git a/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php b/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php
new file mode 100644
index 0000000..2d0fd45
--- /dev/null
+++ b/Stylist/Tests/Feature/LegacyThemeNamespaceAliasTest.php
@@ -0,0 +1,14 @@
+assertTrue(view()->exists('stylist-module-theme-admin::dashboard'));
+ $this->assertTrue(view()->exists('stylist-theme-admin::dashboard'));
+ }
+}
diff --git a/Stylist/Tests/Feature/StylistModuleTest.php b/Stylist/Tests/Feature/StylistModuleTest.php
new file mode 100644
index 0000000..386ac03
--- /dev/null
+++ b/Stylist/Tests/Feature/StylistModuleTest.php
@@ -0,0 +1,21 @@
+assertInstanceOf(Stylist::class, app(Stylist::class));
+ }
+
+ public function test_default_theme_resolves(): void
+ {
+ config(['stylist.default_theme' => 'admin']);
+
+ $this->assertSame('admin', app(Stylist::class)->current()->key());
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemeAssetControllerTest.php b/Stylist/Tests/Feature/ThemeAssetControllerTest.php
new file mode 100644
index 0000000..876ee02
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemeAssetControllerTest.php
@@ -0,0 +1,41 @@
+ resource_path('themes')]);
+ app(Loader::class)->flush();
+
+ $this->get(route('stylist.theme.asset', [
+ 'theme' => 'saasland',
+ 'path' => 'css/theme.css',
+ ]))->assertOk()->assertHeader('X-Content-Type-Options', 'nosniff');
+ }
+
+ public function test_asset_url_uses_public_themes_path(): void
+ {
+ $url = route('stylist.theme.asset', [
+ 'theme' => 'saasland',
+ 'path' => 'images/example.jpg',
+ ], false);
+
+ $this->assertSame('/themes/saasland/images/example.jpg', $url);
+ }
+
+ public function test_non_asset_theme_files_are_not_served(): void
+ {
+ config(['stylist.themes_path' => resource_path('themes')]);
+ app(Loader::class)->flush();
+
+ $this->get(route('stylist.theme.asset', [
+ 'theme' => 'saasland',
+ 'path' => 'views/home.blade.php',
+ ]))->assertNotFound();
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php b/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php
new file mode 100644
index 0000000..65b03aa
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemeHtmlBuilderTest.php
@@ -0,0 +1,18 @@
+ '#111827']);
+
+ $html = app(ThemeHtmlBuilder::class)->variables()->toHtml();
+
+ $this->assertStringContainsString('--brand-primary: #111827;', $html);
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemeInheritanceTest.php b/Stylist/Tests/Feature/ThemeInheritanceTest.php
new file mode 100644
index 0000000..495f581
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemeInheritanceTest.php
@@ -0,0 +1,95 @@
+themesPath = storage_path('framework/testing/stylist-inheritance-'.uniqid());
+ mkdir($this->themesPath, 0777, true);
+ config([
+ 'stylist.themes_path' => $this->themesPath,
+ 'stylist.default_theme' => 'common',
+ ]);
+ app(Loader::class)->flush();
+ }
+
+ protected function tearDown(): void
+ {
+ app(Filesystem::class)->deleteDirectory($this->themesPath);
+ parent::tearDown();
+ }
+
+ public function test_child_theme_resolves_views_through_parent_without_copying_them(): void
+ {
+ $this->theme('common', null, ['components/layouts/app.blade.php' => '{{ $slot }} ']);
+ $this->theme('admin', 'common', ['dashboard.blade.php' => 'Dashboard ']);
+ app(Loader::class)->flush();
+
+ $candidates = app(ThemeViewResolver::class)->candidatesForTheme('admin', 'components.layouts.app');
+
+ $this->assertSame('stylist-theme-admin::components.layouts.app', $candidates[0]);
+ $this->assertContains('stylist-theme-common::components.layouts.app', $candidates);
+ $this->assertLessThan(
+ array_search('stylist-theme-common::components.layouts.app', $candidates, true),
+ array_search('stylist-theme-admin::components.layouts.app', $candidates, true),
+ );
+ }
+
+ public function test_parent_alias_is_normalised_to_extends(): void
+ {
+ $path = $this->themesPath.'/child';
+ mkdir($path, 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'name' => 'Child',
+ 'parent' => 'common',
+ ]));
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame('common', $theme->parent());
+ }
+
+ public function test_duplicate_view_ownership_is_reported(): void
+ {
+ $this->theme('common', null, ['livewire/auth/login.blade.php' => 'common']);
+ $this->theme('admin', 'common', ['livewire/auth/login.blade.php' => 'admin']);
+ app(Loader::class)->flush();
+
+ $duplicates = app(ThemeInspector::class)->duplicateViews();
+
+ $this->assertArrayHasKey('livewire/auth/login.blade.php', $duplicates);
+ $this->assertEqualsCanonicalizing(['common', 'admin'], array_column($duplicates['livewire/auth/login.blade.php'], 'theme'));
+ }
+
+ /** @param array $views */
+ private function theme(string $key, ?string $parent, array $views): void
+ {
+ $path = $this->themesPath.'/'.$key;
+ mkdir($path.'/views', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode(array_filter([
+ 'name' => ucfirst($key),
+ 'slug' => $key,
+ 'extends' => $parent,
+ ], fn ($value) => $value !== null)));
+
+ foreach ($views as $relative => $contents) {
+ $file = $path.'/views/'.$relative;
+ if (! is_dir(dirname($file))) {
+ mkdir(dirname($file), 0777, true);
+ }
+ file_put_contents($file, $contents);
+ }
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemeManifestTest.php b/Stylist/Tests/Feature/ThemeManifestTest.php
new file mode 100644
index 0000000..11d681e
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemeManifestTest.php
@@ -0,0 +1,157 @@
+themesPath = storage_path('framework/testing/stylist-themes-'.uniqid());
+ mkdir($this->themesPath, 0777, true);
+ config(['stylist.themes_path' => $this->themesPath]);
+ app(Loader::class)->flush();
+ }
+
+ protected function tearDown(): void
+ {
+ app(Filesystem::class)->deleteDirectory($this->themesPath);
+ parent::tearDown();
+ }
+
+ public function test_manifest_is_normalised(): void
+ {
+ $path = $this->themesPath.'/sample';
+ mkdir($path.'/css', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode(['name' => 'Sample']));
+ file_put_contents($path.'/css/theme.css', 'body{}');
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame('sample', $theme->key());
+ $this->assertSame('css/theme.css', $theme->stylesheet());
+ $this->assertSame('js/app.js', $theme->script());
+ $this->assertSame('home', $theme->entryView());
+ }
+
+ public function test_unsafe_manifest_path_is_rejected(): void
+ {
+ $path = $this->themesPath.'/unsafe';
+ mkdir($path, 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'name' => 'Unsafe',
+ 'stylesheet' => '../secret.css',
+ ]));
+
+ $this->expectException(InvalidThemeManifestException::class);
+ app(Loader::class)->fromPath($path);
+ }
+
+ public function test_phase_one_manifest_optional_capabilities_are_normalised(): void
+ {
+ $path = $this->themesPath.'/design-gallery';
+ mkdir($path.'/css', 0777, true);
+ mkdir($path.'/js', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'key' => 'design-gallery',
+ 'name' => 'Design Gallery',
+ 'stylesheet' => 'css/app.css',
+ 'script' => 'js/app.js',
+ 'appearances' => ['light', 'dark', 'system'],
+ 'accents' => ['blue', 'emerald', 'neutral'],
+ 'default_appearance' => 'system',
+ 'default_accent' => 'neutral',
+ // Phase 1 incorrectly duplicated accents here; Stylist must not
+ // reject the whole theme because of that compact legacy list.
+ 'variants' => ['blue', 'emerald', 'neutral'],
+ ]));
+ file_put_contents($path.'/css/app.css', 'body{}');
+ file_put_contents($path.'/js/app.js', '');
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame(['light', 'dark', 'system'], array_keys($theme->appearances()));
+ $this->assertSame(['blue', 'emerald', 'neutral'], array_keys($theme->accents()));
+ $this->assertSame([], $theme->variants());
+ $this->assertSame('system', $theme->defaultAppearance());
+ $this->assertSame('neutral', $theme->defaultAccent());
+ }
+
+ public function test_legacy_manifest_does_not_gain_appearance_or_accent_capabilities(): void
+ {
+ $path = $this->themesPath.'/legacy';
+ mkdir($path.'/css', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode(['name' => 'Legacy']));
+ file_put_contents($path.'/css/theme.css', 'body{}');
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame([], $theme->appearances());
+ $this->assertSame([], $theme->accents());
+ $this->assertSame('', $theme->defaultAppearance());
+ $this->assertSame('', $theme->defaultAccent());
+ }
+
+ public function test_defaults_must_reference_declared_optional_capabilities(): void
+ {
+ $path = $this->themesPath.'/invalid-default';
+ mkdir($path, 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'name' => 'Invalid Default',
+ 'appearances' => ['light', 'dark'],
+ 'default_appearance' => 'system',
+ ]));
+
+ $this->expectException(InvalidThemeManifestException::class);
+ app(Loader::class)->fromPath($path);
+ }
+
+ public function test_manifest_version_and_capabilities_are_normalised(): void
+ {
+ $path = $this->themesPath.'/capable';
+ mkdir($path.'/css', 0777, true);
+ mkdir($path.'/js', 0777, true);
+ mkdir($path.'/views', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'manifest_version' => 1,
+ 'name' => 'Capable',
+ 'appearances' => ['light', 'dark'],
+ 'accents' => ['blue'],
+ 'capabilities' => ['marketplace' => true],
+ ]));
+ file_put_contents($path.'/css/theme.css', 'body{}');
+ file_put_contents($path.'/js/app.js', '');
+ file_put_contents($path.'/views/home.blade.php', 'Capable
');
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame(1, $theme->manifestVersion());
+ $this->assertTrue($theme->hasCapability('appearances'));
+ $this->assertTrue($theme->hasCapability('accents'));
+ $this->assertTrue($theme->hasCapability('marketplace'));
+ $this->assertFalse($theme->hasCapability('preview'));
+ }
+
+ public function test_unknown_manifest_fields_are_preserved_without_preventing_loading(): void
+ {
+ $path = $this->themesPath.'/future';
+ mkdir($path.'/css', 0777, true);
+ file_put_contents($path.'/theme.json', json_encode([
+ 'name' => 'Future',
+ 'future_builder_metadata' => ['anything' => true],
+ ]));
+ file_put_contents($path.'/css/theme.css', 'body{}');
+
+ $theme = app(Loader::class)->fromPath($path);
+
+ $this->assertSame(['anything' => true], $theme->meta()['future_builder_metadata']);
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemePackageRoundTripTest.php b/Stylist/Tests/Feature/ThemePackageRoundTripTest.php
new file mode 100644
index 0000000..633b3dc
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemePackageRoundTripTest.php
@@ -0,0 +1,79 @@
+themesPath = storage_path('framework/testing/stylist-installed-'.uniqid());
+ $this->workspace = storage_path('framework/testing/stylist-package-'.uniqid());
+ mkdir($this->themesPath, 0777, true);
+ mkdir($this->workspace, 0777, true);
+ config(['stylist.themes_path' => $this->themesPath]);
+ app(Loader::class)->flush();
+ }
+
+ protected function tearDown(): void
+ {
+ app(Filesystem::class)->deleteDirectory($this->themesPath);
+ app(Filesystem::class)->deleteDirectory($this->workspace);
+ app(Loader::class)->flush();
+ parent::tearDown();
+ }
+
+ public function test_generated_theme_zip_installs_discovers_and_renders(): void
+ {
+ $source = $this->workspace.'/design-gallery';
+ mkdir($source.'/css', 0777, true);
+ mkdir($source.'/js', 0777, true);
+ mkdir($source.'/views', 0777, true);
+
+ file_put_contents($source.'/theme.json', json_encode([
+ 'manifest_version' => 1,
+ 'key' => 'design-gallery',
+ 'name' => 'Design Gallery',
+ 'stylesheet' => 'css/app.css',
+ 'script' => 'js/app.js',
+ 'entry_view' => 'home',
+ 'appearances' => ['light', 'dark', 'system'],
+ 'accents' => ['blue', 'neutral'],
+ 'default_appearance' => 'system',
+ 'default_accent' => 'neutral',
+ ]));
+ file_put_contents($source.'/css/app.css', 'body{}');
+ file_put_contents($source.'/js/app.js', '');
+ file_put_contents($source.'/views/home.blade.php', 'Design Gallery works ');
+
+ $zipPath = $this->workspace.'/design-gallery.zip';
+ $zip = new ZipArchive;
+ $this->assertTrue($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE));
+ foreach (app(Filesystem::class)->allFiles($source) as $file) {
+ $zip->addFile($file->getPathname(), 'design-gallery/'.$file->getRelativePathname());
+ }
+ $zip->close();
+
+ $upload = new UploadedFile($zipPath, 'design-gallery.zip', 'application/zip', null, true);
+ $theme = app(ThemePackageManager::class)->install($upload);
+
+ $this->assertSame('design-gallery', $theme->key());
+ $this->assertTrue(app(Loader::class)->all()->has('design-gallery'));
+ $this->assertStringContainsString(
+ 'Design Gallery works',
+ stylist_theme_view('design-gallery', 'home')->render(),
+ );
+ }
+}
diff --git a/Stylist/Tests/Feature/ThemeViewResolverTest.php b/Stylist/Tests/Feature/ThemeViewResolverTest.php
new file mode 100644
index 0000000..0ae83e8
--- /dev/null
+++ b/Stylist/Tests/Feature/ThemeViewResolverTest.php
@@ -0,0 +1,22 @@
+ 'admin',
+ 'stylist.themes_path' => resource_path('themes'),
+ ]);
+
+ $candidates = app(ThemeViewResolver::class)->candidates('components.page-hero');
+
+ $this->assertContains('stylist-theme-admin::components.page-hero', $candidates);
+ $this->assertContains('stylist::components.page-hero', $candidates);
+ }
+}
diff --git a/Stylist/Tests/Feature/WelcomeThemeViewTest.php b/Stylist/Tests/Feature/WelcomeThemeViewTest.php
new file mode 100644
index 0000000..ec7624b
--- /dev/null
+++ b/Stylist/Tests/Feature/WelcomeThemeViewTest.php
@@ -0,0 +1,21 @@
+assertTrue(view()->exists('stylist-theme-saasland::home'));
+ }
+
+ public function test_home_route_renders_the_saasland_theme(): void
+ {
+ $this->get('/')
+ ->assertOk()
+ ->assertViewIs('stylist-theme-saasland::home')
+ ->assertSee('Transform Your Business With Our SaaS Solution');
+ }
+}
diff --git a/Stylist/Theme/Exceptions/InvalidThemeManifestException.php b/Stylist/Theme/Exceptions/InvalidThemeManifestException.php
new file mode 100644
index 0000000..a548f9f
--- /dev/null
+++ b/Stylist/Theme/Exceptions/InvalidThemeManifestException.php
@@ -0,0 +1,18 @@
+ $errors */
+ public static function forPath(string $path, array $errors): self
+ {
+ return new self(sprintf(
+ 'Invalid theme manifest at [%s]: %s',
+ rtrim($path, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'theme.json',
+ implode(' ', $errors),
+ ));
+ }
+}
diff --git a/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php b/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php
new file mode 100644
index 0000000..746d52e
--- /dev/null
+++ b/Stylist/Theme/Exceptions/ThemeJsonNotFoundException.php
@@ -0,0 +1,13 @@
+files->exists($manifest)) {
+ throw ThemeJsonNotFoundException::forPath($path);
+ }
+
+ return json_decode($this->files->get($manifest), true, flags: JSON_THROW_ON_ERROR);
+ }
+}
diff --git a/Stylist/Theme/Loader.php b/Stylist/Theme/Loader.php
new file mode 100644
index 0000000..e93708d
--- /dev/null
+++ b/Stylist/Theme/Loader.php
@@ -0,0 +1,83 @@
+themes !== null) {
+ return $this->themes;
+ }
+
+ $themes = collect();
+
+ foreach ($this->roots() as $root) {
+ if (! $this->files->isDirectory($root)) {
+ continue;
+ }
+
+ foreach ($this->files->directories($root) as $themePath) {
+ try {
+ $theme = $this->fromPath($themePath);
+ $themes->put($theme->key(), $theme);
+ } catch (\Throwable $exception) {
+ report($exception);
+ }
+ }
+ }
+
+ return $this->themes = $themes->sortKeys();
+ }
+
+ public function find(string $key): Theme
+ {
+ $theme = $this->all()->get($key);
+
+ if (! $theme) {
+ throw ThemeNotFoundException::forKey($key);
+ }
+
+ return $theme;
+ }
+
+ public function fromPath(string $path): Theme
+ {
+ $data = $this->validator->validate($this->json->read($path), $path);
+
+ return new Theme(
+ key: $data['key'],
+ name: $data['name'],
+ path: rtrim($path, DIRECTORY_SEPARATOR),
+ stylesheet: $data['stylesheet'],
+ script: $data['script'],
+ variables: $data['variables'],
+ meta: $data,
+ );
+ }
+
+ public function flush(): void
+ {
+ $this->themes = null;
+ }
+
+ /** @return array */
+ public function roots(): array
+ {
+ return array_values(array_unique(array_filter([
+ (string) config('stylist.themes_path'),
+ ])));
+ }
+}
diff --git a/Stylist/Theme/ManifestValidator.php b/Stylist/Theme/ManifestValidator.php
new file mode 100644
index 0000000..cc75998
--- /dev/null
+++ b/Stylist/Theme/ManifestValidator.php
@@ -0,0 +1,179 @@
+ */
+ public function validate(array $manifest, string $path): array
+ {
+ $errors = [];
+ $manifestVersion = $manifest['manifest_version'] ?? $manifest['schema_version'] ?? 1;
+ if ((is_string($manifestVersion) && ctype_digit($manifestVersion))) {
+ $manifestVersion = (int) $manifestVersion;
+ }
+ if (! is_int($manifestVersion) || $manifestVersion < 1) {
+ $errors[] = 'The manifest_version must be a positive integer.';
+ }
+
+ $key = $manifest['key'] ?? $manifest['slug'] ?? basename($path);
+
+ if (! is_string($key) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $key)) {
+ $errors[] = 'The key/slug must contain only lowercase letters, numbers, dashes, and underscores.';
+ }
+ if (isset($manifest['name']) && (! is_string($manifest['name']) || trim($manifest['name']) === '')) {
+ $errors[] = 'The name must be a non-empty string.';
+ }
+ if (isset($manifest['version']) && (! is_string($manifest['version']) || ! preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $manifest['version']))) {
+ $errors[] = 'The version must use semantic versioning, for example 1.2.0.';
+ }
+ if (isset($manifest['type']) && ! in_array($manifest['type'], ['frontend', 'backend', 'common'], true)) {
+ $errors[] = 'The type must be frontend, backend, or common.';
+ }
+ $parent = $manifest['extends'] ?? $manifest['parent'] ?? null;
+ if ($parent !== null && (! is_string($parent) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $parent))) {
+ $errors[] = 'The extends/parent value must be a valid theme key.';
+ }
+
+ foreach (['stylesheet', 'script', 'preview'] as $field) {
+ if (isset($manifest[$field]) && ! $this->isSafeRelativePath($manifest[$field])) {
+ $errors[] = "The {$field} value must be a safe relative path.";
+ }
+ }
+
+ foreach (['assets', 'variables', 'supports', 'capabilities', 'variants', 'appearances', 'accents', 'settings', 'seo'] as $field) {
+ if (isset($manifest[$field]) && ! is_array($manifest[$field])) {
+ $errors[] = "The {$field} value must be an object or array.";
+ }
+ }
+
+ $variants = $this->normaliseDefinitions($manifest['variants'] ?? [], 'variants', $errors, false);
+ $appearances = $this->normaliseDefinitions($manifest['appearances'] ?? [], 'appearances', $errors);
+ $accents = $this->normaliseDefinitions($manifest['accents'] ?? [], 'accents', $errors);
+ $capabilities = $this->normaliseCapabilities($manifest['capabilities'] ?? [], $errors);
+ $capabilities['appearances'] = $appearances !== [];
+ $capabilities['accents'] = $accents !== [];
+ $capabilities['preview'] = isset($manifest['preview']) || is_file($path.'/preview.webp');
+
+ $defaultAppearance = $manifest['default_appearance'] ?? null;
+ if ($defaultAppearance !== null) {
+ if (! is_string($defaultAppearance) || $defaultAppearance === '') {
+ $errors[] = 'The default_appearance value must be a non-empty string.';
+ } elseif ($appearances === [] || ! array_key_exists($defaultAppearance, $appearances)) {
+ $errors[] = 'The default_appearance value must be declared in appearances.';
+ }
+ }
+
+ $defaultAccent = $manifest['default_accent'] ?? null;
+ if ($defaultAccent !== null) {
+ if (! is_string($defaultAccent) || $defaultAccent === '') {
+ $errors[] = 'The default_accent value must be a non-empty string.';
+ } elseif ($accents === [] || ! array_key_exists($defaultAccent, $accents)) {
+ $errors[] = 'The default_accent value must be declared in accents.';
+ }
+ }
+
+ if ($errors !== []) {
+ throw InvalidThemeManifestException::forPath($path, $errors);
+ }
+
+ $manifest['manifest_version'] = $manifestVersion;
+ unset($manifest['schema_version']);
+ $manifest['key'] = $key;
+ $manifest['slug'] = $key;
+ $manifest['name'] = $manifest['name'] ?? str($key)->headline()->toString();
+ $manifest['extends'] = $parent;
+ $manifest['version'] = (string) ($manifest['version'] ?? '1.0.0');
+ $manifest['entry_view'] = (string) ($manifest['entry_view'] ?? 'home');
+ $manifest['stylesheet'] = $manifest['stylesheet'] ?? $manifest['css'] ?? 'css/theme.css';
+ $manifest['script'] = $manifest['script'] ?? 'js/app.js';
+ $manifest['assets'] = is_array($manifest['assets'] ?? null) ? $manifest['assets'] : [];
+ $manifest['variables'] = is_array($manifest['variables'] ?? null) ? $manifest['variables'] : [];
+ $manifest['supports'] = is_array($manifest['supports'] ?? null) ? $manifest['supports'] : [];
+ $manifest['capabilities'] = $capabilities;
+ $manifest['variants'] = $variants;
+ $manifest['appearances'] = $appearances;
+ $manifest['accents'] = $accents;
+ $manifest['settings'] = is_array($manifest['settings'] ?? null) ? $manifest['settings'] : [];
+ $manifest['seo'] = is_array($manifest['seo'] ?? null) ? $manifest['seo'] : [];
+ $manifest['preview'] = $manifest['preview'] ?? (is_file($path.'/preview.webp') ? 'preview.webp' : null);
+
+ return $manifest;
+ }
+
+ /**
+ * Compact lists such as ["light", "dark"] are accepted and converted to
+ * keyed empty definitions. Object definitions remain unchanged.
+ *
+ * @param array $value
+ * @param array $errors
+ * @return array>
+ */
+ private function normaliseDefinitions(array $value, string $field, array &$errors, bool $allowCompact = true): array
+ {
+ if ($value === []) {
+ return [];
+ }
+
+ if (array_is_list($value)) {
+ if (! $allowCompact && $field === 'variants') {
+ // Older Theme Builder Phase 1 builds incorrectly wrote accent names
+ // to variants. Ignore that list while preserving object-style variants.
+ return [];
+ }
+
+ $normalised = [];
+ foreach ($value as $name) {
+ if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name)) {
+ $errors[] = "Every {$field} entry must be a valid lowercase key.";
+
+ continue;
+ }
+ $normalised[$name] = [];
+ }
+
+ return $normalised;
+ }
+
+ $normalised = [];
+ foreach ($value as $name => $definition) {
+ if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_array($definition)) {
+ $errors[] = "Every {$field} definition must use a valid lowercase key and an object value.";
+
+ continue;
+ }
+ $normalised[$name] = $definition;
+ }
+
+ return $normalised;
+ }
+
+ /**
+ * @param array $value
+ * @param array $errors
+ * @return array
+ */
+ private function normaliseCapabilities(array $value, array &$errors): array
+ {
+ $normalised = [];
+
+ foreach ($value as $name => $enabled) {
+ if (! is_string($name) || ! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $name) || ! is_bool($enabled)) {
+ $errors[] = 'Every capability must use a valid lowercase key and a boolean value.';
+
+ continue;
+ }
+
+ $normalised[$name] = $enabled;
+ }
+
+ return $normalised;
+ }
+
+ private function isSafeRelativePath(mixed $path): bool
+ {
+ return is_string($path) && $path !== '' && ! str_starts_with($path, '/') && ! str_contains($path, '..') && ! str_contains($path, "\0");
+ }
+}
diff --git a/Stylist/Theme/Stylist.php b/Stylist/Theme/Stylist.php
new file mode 100644
index 0000000..82793ef
--- /dev/null
+++ b/Stylist/Theme/Stylist.php
@@ -0,0 +1,103 @@
+resolver->resolve($context);
+ try {
+ return $this->loader->find($requested);
+ } catch (ThemeNotFoundException) {
+ $fallback = $context === 'frontend'
+ ? (string) config('stylist.default_frontend_theme', 'saasland')
+ : (string) config('stylist.default_backend_theme', config('stylist.default_theme', 'admin'));
+ if ($requested !== $fallback) {
+ return $this->loader->find($fallback);
+ }
+ throw ThemeNotFoundException::forKey($requested);
+ }
+ }
+
+ public function frontend(): Theme
+ {
+ return $this->current('frontend');
+ }
+
+ public function backend(): Theme
+ {
+ return $this->current('backend');
+ }
+
+ public function variables(): array
+ {
+ $theme = $this->current();
+ $variant = $this->settings->variant($theme);
+ $variantVars = (array) data_get($theme->variants(), $variant.'.variables', []);
+ $appearanceVars = (array) data_get($theme->appearances(), $this->appearance().'.variables', []);
+ $accentVars = (array) data_get($theme->accents(), $this->accent().'.variables', []);
+ $settingVars = collect($this->settings->get($theme))
+ ->filter(fn ($value, $key) => is_string($key) && str_starts_with($key, '--'))
+ ->mapWithKeys(fn ($value, $key) => [ltrim($key, '-') => $value])
+ ->all();
+
+ return array_replace(
+ (array) config('stylist.fallback_variables', []),
+ $this->inheritedVariables($theme),
+ $variantVars,
+ $appearanceVars,
+ $accentVars,
+ $settingVars,
+ $this->resolver->variables(),
+ );
+ }
+
+ public function settings(): array
+ {
+ return $this->settings->get($this->current());
+ }
+
+ public function variant(): string
+ {
+ return $this->settings->variant($this->current());
+ }
+
+ public function appearance(): string
+ {
+ return $this->settings->appearance($this->current());
+ }
+
+ public function accent(): string
+ {
+ return $this->settings->accent($this->current());
+ }
+
+ public function all()
+ {
+ return $this->loader->all();
+ }
+
+ private function inheritedVariables(Theme $theme, array $seen = []): array
+ {
+ if (in_array($theme->key(), $seen, true)) {
+ return [];
+ }
+ $seen[] = $theme->key();
+ $parent = [];
+ if ($theme->parent()) {
+ try {
+ $parent = $this->inheritedVariables($this->loader->find($theme->parent()), $seen);
+ } catch (ThemeNotFoundException) {
+ }
+ }
+
+ return array_replace($parent, $theme->variables());
+ }
+}
diff --git a/Stylist/Theme/Theme.php b/Stylist/Theme/Theme.php
new file mode 100644
index 0000000..2010173
--- /dev/null
+++ b/Stylist/Theme/Theme.php
@@ -0,0 +1,161 @@
+key;
+ }
+
+ public function name(): string
+ {
+ return $this->name;
+ }
+
+ public function path(): string
+ {
+ return $this->path;
+ }
+
+ public function stylesheet(): string
+ {
+ return $this->stylesheet;
+ }
+
+ public function script(): string
+ {
+ return $this->script;
+ }
+
+ public function variables(): array
+ {
+ return $this->variables;
+ }
+
+ public function meta(): array
+ {
+ return $this->meta;
+ }
+
+ public function manifestVersion(): int
+ {
+ return (int) ($this->meta['manifest_version'] ?? 1);
+ }
+
+ public function version(): string
+ {
+ return (string) ($this->meta['version'] ?? '1.0.0');
+ }
+
+ public function description(): string
+ {
+ return (string) ($this->meta['description'] ?? '');
+ }
+
+ public function type(): string
+ {
+ return (string) ($this->meta['type'] ?? 'frontend');
+ }
+
+ public function author(): string
+ {
+ return (string) ($this->meta['author'] ?? '');
+ }
+
+ public function parent(): ?string
+ {
+ $parent = $this->meta['extends'] ?? null;
+
+ return is_string($parent) && $parent !== '' ? $parent : null;
+ }
+
+ public function preview(): ?string
+ {
+ $preview = $this->meta['preview'] ?? null;
+
+ return is_string($preview) && $preview !== '' ? $preview : null;
+ }
+
+ public function variants(): array
+ {
+ return (array) ($this->meta['variants'] ?? []);
+ }
+
+ public function appearances(): array
+ {
+ return (array) ($this->meta['appearances'] ?? []);
+ }
+
+ public function accents(): array
+ {
+ return (array) ($this->meta['accents'] ?? []);
+ }
+
+ public function defaultAppearance(): string
+ {
+ return (string) ($this->meta['default_appearance'] ?? array_key_first($this->appearances()) ?? '');
+ }
+
+ public function defaultAccent(): string
+ {
+ return (string) ($this->meta['default_accent'] ?? array_key_first($this->accents()) ?? '');
+ }
+
+ public function settingsSchema(): array
+ {
+ return (array) ($this->meta['settings'] ?? []);
+ }
+
+ public function seo(): array
+ {
+ return (array) ($this->meta['seo'] ?? []);
+ }
+
+ public function entryView(): string
+ {
+ return (string) ($this->meta['entry_view'] ?? 'home');
+ }
+
+ public function capabilities(): array
+ {
+ return (array) ($this->meta['capabilities'] ?? []);
+ }
+
+ public function hasCapability(string $feature): bool
+ {
+ return (bool) ($this->capabilities()[$feature] ?? false);
+ }
+
+ public function supports(string $feature): bool
+ {
+ return (bool) data_get($this->meta, 'supports.'.$feature, $this->hasCapability($feature));
+ }
+
+ public function has(string $relativePath): bool
+ {
+ return is_file($this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/'));
+ }
+
+ public function absolutePath(string $relativePath): string
+ {
+ return $this->path.DIRECTORY_SEPARATOR.ltrim($relativePath, '/');
+ }
+
+ /** @deprecated Theme assets are served through UrlGenerator. */
+ public function assetsPath(): string
+ {
+ return 'stylist/theme-assets/'.$this->key;
+ }
+}
diff --git a/Stylist/Theme/UrlGenerator.php b/Stylist/Theme/UrlGenerator.php
new file mode 100644
index 0000000..77d08f8
--- /dev/null
+++ b/Stylist/Theme/UrlGenerator.php
@@ -0,0 +1,20 @@
+forTheme($this->stylist->current(), $path);
+ }
+
+ public function forTheme(Theme|string $theme, string $path): string
+ {
+ $key = $theme instanceof Theme ? $theme->key() : $theme;
+
+ return route('stylist.theme.asset', ['theme' => $key, 'path' => ltrim($path, '/')]);
+ }
+}
diff --git a/Stylist/View/ThemeViewResolver.php b/Stylist/View/ThemeViewResolver.php
new file mode 100644
index 0000000..91d263d
--- /dev/null
+++ b/Stylist/View/ThemeViewResolver.php
@@ -0,0 +1,130 @@
+> */
+ private array $candidateCache = [];
+
+ public function __construct(
+ private readonly Stylist $stylist,
+ private readonly ViewFactory $views,
+ private readonly Loader $loader,
+ ) {}
+
+ public function make(string $view, array $data = [], array $mergeData = []): View
+ {
+ return $this->views->first(
+ $this->candidates($view),
+ array_replace([
+ 'themeSettings' => $this->stylist->settings(),
+ 'themeVariant' => $this->stylist->variant(),
+ ], $data),
+ $mergeData,
+ );
+ }
+
+ public function makeForTheme(string $theme, string $view, array $data = [], array $mergeData = []): View
+ {
+ return $this->views->first($this->candidatesForTheme($theme, $view), $data, $mergeData);
+ }
+
+ /** @return array */
+ public function candidates(string $view): array
+ {
+ return $this->candidatesForTheme($this->stylist->current()->key(), $view);
+ }
+
+ /** @return array */
+ public function candidatesForTheme(string $theme, string $view): array
+ {
+ $view = ltrim($view, '.');
+ $cacheKey = $theme.'|'.$view;
+
+ if (isset($this->candidateCache[$cacheKey])) {
+ return $this->candidateCache[$cacheKey];
+ }
+
+ $keys = $this->inheritanceChain($theme);
+ $fallback = (string) config('stylist.default_theme', 'common');
+
+ if ($fallback !== '' && ! in_array($fallback, $keys, true)) {
+ try {
+ $keys = array_merge($keys, $this->inheritanceChain($fallback));
+ } catch (ThemeNotFoundException) {
+ // A missing configured fallback is diagnosed by stylist:doctor.
+ }
+ }
+
+ if (! in_array('common', $keys, true)) {
+ try {
+ $keys = array_merge($keys, $this->inheritanceChain('common'));
+ } catch (ThemeNotFoundException) {
+ // The module fallback below remains available.
+ }
+ }
+
+ $candidates = [];
+ foreach (array_values(array_unique($keys)) as $key) {
+ $this->registerNamespace($key);
+ $candidates[] = "stylist-theme-{$key}::{$view}";
+ $candidates[] = "stylist-module-theme-{$key}::{$view}";
+ }
+
+ $candidates[] = "stylist::{$view}";
+ $candidates[] = $view;
+
+ return $this->candidateCache[$cacheKey] = array_values(array_unique($candidates));
+ }
+
+ public function flush(): void
+ {
+ $this->candidateCache = [];
+ }
+
+ /** @return array */
+ private function inheritanceChain(string $theme): array
+ {
+ $keys = [];
+ $seen = [];
+ $key = $theme;
+
+ while ($key !== '') {
+ if (isset($seen[$key])) {
+ throw new RuntimeException('Theme inheritance cycle detected at ['.$key.'].');
+ }
+
+ $seen[$key] = true;
+ $loaded = $this->loader->find($key);
+ $keys[] = $loaded->key();
+ $key = $loaded->parent() ?? '';
+ }
+
+ return $keys;
+ }
+
+ private function registerNamespace(string $theme): void
+ {
+ try {
+ $loaded = $this->loader->find($theme);
+ } catch (ThemeNotFoundException) {
+ return;
+ }
+
+ $viewsPath = $loaded->path().DIRECTORY_SEPARATOR.'views';
+ if (! is_dir($viewsPath)) {
+ return;
+ }
+
+ $this->views->addNamespace("stylist-theme-{$theme}", $viewsPath);
+ $this->views->addNamespace("stylist-module-theme-{$theme}", $viewsPath);
+ }
+}
diff --git a/Stylist/changelog.yaml b/Stylist/changelog.yaml
new file mode 100644
index 0000000..7a8e4e0
--- /dev/null
+++ b/Stylist/changelog.yaml
@@ -0,0 +1,24 @@
+latest: 1.1.0
+releases:
+ - version: 1.1.0
+ date: 2026-07-08
+ changes:
+ - Added self-contained theme folders with theme.json, CSS, Blade views, and Livewire views.
+ - Added ThemeViewResolver and stylist_view() fallback helper.
+ - Added default and MPBA example themes.
+ - Added theme publishing support.
+ - Updated Vite inputs to include theme CSS files.
+ - version: 1.0.0
+ date: 2026-07-08
+ changes:
+ - Initial Laravel 13 Stylist module.
+
+ - version: 1.1.0
+ date: 2026-07-23
+ changes:
+ - Added optional manifest_version/schema_version normalisation.
+ - Added capability normalisation for appearances, accents, previews and future features.
+ - Added stylist:validate for installed themes, directories and ZIP packages.
+ - Added complete package validation during installation.
+ - Added dynamic view namespace registration after installing a theme.
+ - Added manifest compatibility and install-render regression coverage.
diff --git a/Stylist/composer.json b/Stylist/composer.json
new file mode 100644
index 0000000..d537e3a
--- /dev/null
+++ b/Stylist/composer.json
@@ -0,0 +1,22 @@
+{
+ "name": "mpba/stylist-module",
+ "description": "Tenant-aware theme and asset switching module for Laravel 13.",
+ "type": "laravel-module",
+ "require": {
+ "php": "^8.3",
+ "illuminate/support": "^13.0",
+ "illuminate/view": "^13.0",
+ "illuminate/filesystem": "^13.0",
+ "illuminate/console": "^13.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "Modules\\Stylist\\": ""
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Modules\\Stylist\\Tests\\": "Tests/"
+ }
+ }
+}
diff --git a/Stylist/module.json b/Stylist/module.json
new file mode 100644
index 0000000..214c2d2
--- /dev/null
+++ b/Stylist/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Stylist",
+ "alias": "stylist",
+ "version": "1.1.0",
+ "description": "Tenant-aware theme and asset switching module for Laravel 13.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "themes",
+ "tenancy",
+ "styling"
+ ],
+ "priority": 0,
+ "providers": [
+ "Modules\\Stylist\\Providers\\StylistServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": []
+}
diff --git a/Stylist/package.json b/Stylist/package.json
new file mode 100644
index 0000000..e0b33af
--- /dev/null
+++ b/Stylist/package.json
@@ -0,0 +1,15 @@
+{
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+
+ "stylist:dev": "vite --config Modules/Stylist/vite.config.js",
+ "stylist:build": "vite build --config Modules/Stylist/vite.config.js",
+
+ "matches:dev": "vite --config Modules/Matches/vite.config.js",
+ "matches:build": "vite build --config Modules/Matches/vite.config.js",
+
+ "modules:build": "npm run stylist:build && npm run matches:build",
+ "build:all": "npm run build && npm run modules:build"
+ }
+}
diff --git a/Stylist/vite.config.js b/Stylist/vite.config.js
new file mode 100644
index 0000000..de55470
--- /dev/null
+++ b/Stylist/vite.config.js
@@ -0,0 +1,25 @@
+import { defineConfig } from 'vite';
+import laravel from 'laravel-vite-plugin';
+import tailwindcss from '@tailwindcss/vite';
+
+export default defineConfig({
+ plugins: [
+ laravel({
+ input: [
+ 'Modules/Stylist/Resources/assets/css/app.css',
+ 'Modules/Stylist/Resources/assets/js/app.js',
+ ],
+
+ refresh: [
+ 'Modules/Stylist/Resources/views/**',
+ 'resources/themes/**/views/**',
+ 'Modules/Stylist/App/**',
+ 'Modules/Stylist/routes/**',
+ ],
+
+ buildDirectory: 'build/stylist',
+ }),
+
+ tailwindcss(),
+ ],
+});
diff --git a/Tags/Config/config.php b/Tags/Config/config.php
new file mode 100644
index 0000000..4f63319
--- /dev/null
+++ b/Tags/Config/config.php
@@ -0,0 +1,3 @@
+ 'Tags'];
diff --git a/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php b/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
new file mode 100644
index 0000000..8a969e7
--- /dev/null
+++ b/Tags/Database/Migrations/2026_07_24_030200_create_tags_table.php
@@ -0,0 +1,25 @@
+id();
+ $table->string('name');
+ $table->string('slug')->unique();
+ $table->text('description')->nullable();
+ $table->unsignedInteger('usage_count')->default(0);
+ $table->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('tags');
+ }
+};
diff --git a/Tags/Http/Controllers/TagController.php b/Tags/Http/Controllers/TagController.php
new file mode 100644
index 0000000..08e0a08
--- /dev/null
+++ b/Tags/Http/Controllers/TagController.php
@@ -0,0 +1,48 @@
+ Tag::orderByDesc('usage_count')->paginate(30)]);
+ }
+
+ public function create(): View
+ {
+ return view('tags::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ Tag::create($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug'], 'description' => ['nullable', 'string']]));
+
+ return redirect()->route('administration.tags.index')->with('status', 'Tag created.');
+ }
+
+ public function edit(Tag $tag): View
+ {
+ return view('tags::admin.form', ['record' => $tag]);
+ }
+
+ public function update(Request $request, Tag $tag): RedirectResponse
+ {
+ $tag->update($request->validate(['name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'string', 'max:255', 'unique:tags,slug,'.$tag->id], 'description' => ['nullable', 'string']]));
+
+ return redirect()->route('administration.tags.index')->with('status', 'Tag updated.');
+ }
+
+ public function destroy(Tag $tag): RedirectResponse
+ {
+ $tag->delete();
+
+ return back()->with('status', 'Tag deleted.');
+ }
+}
diff --git a/Tags/Models/Tag.php b/Tags/Models/Tag.php
new file mode 100644
index 0000000..da7b39c
--- /dev/null
+++ b/Tags/Models/Tag.php
@@ -0,0 +1,15 @@
+ 'integer'];
+ }
+}
diff --git a/Tags/Providers/RouteServiceProvider.php b/Tags/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..3605636
--- /dev/null
+++ b/Tags/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Tags', 'Routes/web.php'));
+ }
+}
diff --git a/Tags/Providers/TagsServiceProvider.php b/Tags/Providers/TagsServiceProvider.php
new file mode 100644
index 0000000..51407ff
--- /dev/null
+++ b/Tags/Providers/TagsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Tags', 'Config/config.php'), 'tags');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Tags', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Tags', 'Resources/views'), 'tags');
+ }
+}
diff --git a/Tags/Resources/views/admin/form.blade.php b/Tags/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..9dfe384
--- /dev/null
+++ b/Tags/Resources/views/admin/form.blade.php
@@ -0,0 +1 @@
+@csrf @isset($record) @method('PUT') @endisset {{ old('description',$record->description ?? '') }} Save
diff --git a/Tags/Resources/views/admin/index.blade.php b/Tags/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..537b5d9
--- /dev/null
+++ b/Tags/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Tags Manage publishing tags.
Create @if(session('status')){{ session('status') }}
@endifName / title Status Actions @forelse($records as $record){{ $record->title ?? $record->name }} {{ $record->status ?? ($record->is_active ? 'Active' : 'Inactive') }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
diff --git a/Tags/Routes/web.php b/Tags/Routes/web.php
new file mode 100644
index 0000000..2b425f6
--- /dev/null
+++ b/Tags/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/tags')->name('administration.tags.')->group(function (): void {
+ Route::get('/', [TagController::class, 'index'])->name('index');
+ Route::get('/create', [TagController::class, 'create'])->name('create');
+ Route::post('/', [TagController::class, 'store'])->name('store');
+ Route::get('/{tag}/edit', [TagController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{tag}', [TagController::class, 'update'])->name('update');
+ Route::delete('/{tag}', [TagController::class, 'destroy'])->name('destroy');
+});
diff --git a/Tags/Tests/Feature/ModuleBootTest.php b/Tags/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..1bc2dd9
--- /dev/null
+++ b/Tags/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,13 @@
+assertSame('Tags', config('tags.name'));
+ }
+}
diff --git a/Tags/module.json b/Tags/module.json
new file mode 100644
index 0000000..53001bb
--- /dev/null
+++ b/Tags/module.json
@@ -0,0 +1,12 @@
+{
+ "name": "Tags",
+ "alias": "tags",
+ "version": "1.0.0",
+ "description": "Tags publishing module.",
+ "keywords": ["publishing", "tags"],
+ "priority": 30,
+ "providers": ["Modules\\Tags\\Providers\\TagsServiceProvider"],
+ "aliases": {},
+ "files": [],
+ "requires": ["Core", "Administration"]
+}
diff --git a/Team/Config/config.php b/Team/Config/config.php
new file mode 100644
index 0000000..3342295
--- /dev/null
+++ b/Team/Config/config.php
@@ -0,0 +1,3 @@
+ 'Team'];
diff --git a/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php b/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php
new file mode 100644
index 0000000..67bfdcc
--- /dev/null
+++ b/Team/Database/Migrations/2026_07_24_000800_create_team_members_table.php
@@ -0,0 +1,28 @@
+id();
+ $table->string('name');
+ $table->string('job_title')->nullable();
+ $table->text('biography')->nullable();
+ $table->string('email')->nullable();
+ $table->unsignedInteger('sort_order')->default(0);
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('team_members');
+ }
+};
diff --git a/Team/Http/Controllers/TeamMemberController.php b/Team/Http/Controllers/TeamMemberController.php
new file mode 100644
index 0000000..270e4ad
--- /dev/null
+++ b/Team/Http/Controllers/TeamMemberController.php
@@ -0,0 +1,62 @@
+ TeamMember::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('team::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'job_title' => ['nullable', 'string'],
+ 'biography' => ['nullable', 'string'],
+ 'email' => ['nullable', 'string'],
+ 'sort_order' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ TeamMember::create($data);
+
+ return redirect()->route('administration.team.index')->with('status', 'TeamMember created.');
+ }
+
+ public function edit(TeamMember $teamMember): View
+ {
+ return view('team::admin.form', ['record' => $teamMember]);
+ }
+
+ public function update(Request $request, TeamMember $teamMember): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'job_title' => ['nullable', 'string'],
+ 'biography' => ['nullable', 'string'],
+ 'email' => ['nullable', 'string'],
+ 'sort_order' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $teamMember->update($data);
+
+ return redirect()->route('administration.team.index')->with('status', 'TeamMember updated.');
+ }
+
+ public function destroy(TeamMember $teamMember): RedirectResponse
+ {
+ $teamMember->delete();
+
+ return back()->with('status', 'TeamMember deleted.');
+ }
+}
diff --git a/Team/Models/TeamMember.php b/Team/Models/TeamMember.php
new file mode 100644
index 0000000..1ada6eb
--- /dev/null
+++ b/Team/Models/TeamMember.php
@@ -0,0 +1,24 @@
+ 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Team/Providers/RouteServiceProvider.php b/Team/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..a50f441
--- /dev/null
+++ b/Team/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Team', 'Routes/web.php')));
+ }
+}
diff --git a/Team/Providers/TeamServiceProvider.php b/Team/Providers/TeamServiceProvider.php
new file mode 100644
index 0000000..6cdcac1
--- /dev/null
+++ b/Team/Providers/TeamServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Team', 'Config/config.php'), 'team');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Team', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Team', 'Resources/views'), 'team');
+ }
+}
diff --git a/Team/Resources/views/admin/form.blade.php b/Team/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..621f780
--- /dev/null
+++ b/Team/Resources/views/admin/form.blade.php
@@ -0,0 +1,10 @@
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Team member @csrf @isset($record) @method('PUT') @endisset
+Name @error('name')
{{ $message }}
@enderror
+Job Title @error('job_title')
{{ $message }}
@enderror
+Biography {{ old('biography', $record->biography ?? '') }} @error('biography')
{{ $message }}
@enderror
+Email @error('email')
{{ $message }}
@enderror
+Sort Order @error('sort_order')
{{ $message }}
@enderror
+ enabled ?? true))>Enabled
+
+
diff --git a/Team/Resources/views/admin/index.blade.php b/Team/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..6345c12
--- /dev/null
+++ b/Team/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+
+@if(session('status'))
{{ session('status') }}
@endif
+
Name Job Title Biography Actions @forelse($records as $record){{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }} {{ is_array($record->job_title) ? json_encode($record->job_title) : Str::limit((string) $record->job_title, 70) }} {{ is_array($record->biography) ? json_encode($record->biography) : Str::limit((string) $record->biography, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Team/Routes/web.php b/Team/Routes/web.php
new file mode 100644
index 0000000..0c6678d
--- /dev/null
+++ b/Team/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/team')->name('administration.team.')->group(function (): void {
+ Route::get('/', [TeamMemberController::class, 'index'])->name('index');
+ Route::get('/create', [TeamMemberController::class, 'create'])->name('create');
+ Route::post('/', [TeamMemberController::class, 'store'])->name('store');
+ Route::get('/{teamMember}/edit', [TeamMemberController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{teamMember}', [TeamMemberController::class, 'update'])->name('update');
+ Route::delete('/{teamMember}', [TeamMemberController::class, 'destroy'])->name('destroy');
+});
diff --git a/Team/Support/ModuleInfo.php b/Team/Support/ModuleInfo.php
new file mode 100644
index 0000000..4fcd387
--- /dev/null
+++ b/Team/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Team/module.json b/Team/module.json
new file mode 100644
index 0000000..7cbacd8
--- /dev/null
+++ b/Team/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Team",
+ "alias": "team",
+ "version": "1.0.0",
+ "description": "Team CMS module.",
+ "keywords": [
+ "cms",
+ "team"
+ ],
+ "priority": 28,
+ "providers": [
+ "Modules\\Team\\Providers\\TeamServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Testimonials/Config/config.php b/Testimonials/Config/config.php
new file mode 100644
index 0000000..ab940e8
--- /dev/null
+++ b/Testimonials/Config/config.php
@@ -0,0 +1,3 @@
+ 'Testimonials'];
diff --git a/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php b/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php
new file mode 100644
index 0000000..bebe283
--- /dev/null
+++ b/Testimonials/Database/Migrations/2026_07_24_000900_create_testimonials_table.php
@@ -0,0 +1,27 @@
+id();
+ $table->string('name');
+ $table->string('company')->nullable();
+
+ $table->unsignedInteger('rating')->default(5);
+ $table->boolean('enabled')->default(true);
+ $table->timestamps();
+ $table->softDeletes();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('testimonials');
+ }
+};
diff --git a/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php b/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php
new file mode 100644
index 0000000..e81f8a9
--- /dev/null
+++ b/Testimonials/Database/Migrations/2026_07_27_070000_add_content_to_testimonials_table.php
@@ -0,0 +1,26 @@
+text('content')->nullable()->after('company');
+ });
+ }
+ }
+
+ public function down(): void
+ {
+ if (Schema::hasTable('testimonials') && Schema::hasColumn('testimonials', 'content')) {
+ Schema::table('testimonials', function (Blueprint $table): void {
+ $table->dropColumn('content');
+ });
+ }
+ }
+};
diff --git a/Testimonials/Http/Controllers/TestimonialController.php b/Testimonials/Http/Controllers/TestimonialController.php
new file mode 100644
index 0000000..908e974
--- /dev/null
+++ b/Testimonials/Http/Controllers/TestimonialController.php
@@ -0,0 +1,60 @@
+ Testimonial::query()->latest()->paginate(20)]);
+ }
+
+ public function create(): View
+ {
+ return view('testimonials::admin.form');
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'company' => ['nullable', 'string'],
+ 'content' => ['required', 'string'],
+ 'rating' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ Testimonial::create($data);
+
+ return redirect()->route('administration.testimonials.index')->with('status', 'Testimonial created.');
+ }
+
+ public function edit(Testimonial $testimonial): View
+ {
+ return view('testimonials::admin.form', ['record' => $testimonial]);
+ }
+
+ public function update(Request $request, Testimonial $testimonial): RedirectResponse
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'],
+ 'company' => ['nullable', 'string'],
+ 'content' => ['required', 'string'],
+ 'rating' => ['nullable', 'integer', 'min:0'],
+ 'enabled' => ['sometimes', 'boolean']]);
+ $data['enabled'] = $request->boolean('enabled');
+ $testimonial->update($data);
+
+ return redirect()->route('administration.testimonials.index')->with('status', 'Testimonial updated.');
+ }
+
+ public function destroy(Testimonial $testimonial): RedirectResponse
+ {
+ $testimonial->delete();
+
+ return back()->with('status', 'Testimonial deleted.');
+ }
+}
diff --git a/Testimonials/Models/Testimonial.php b/Testimonials/Models/Testimonial.php
new file mode 100644
index 0000000..590160f
--- /dev/null
+++ b/Testimonials/Models/Testimonial.php
@@ -0,0 +1,24 @@
+ 'integer',
+ 'enabled' => 'boolean',
+ ];
+ }
+}
diff --git a/Testimonials/Providers/RouteServiceProvider.php b/Testimonials/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..98aac75
--- /dev/null
+++ b/Testimonials/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+routes(fn () => Route::middleware('web')->group(module_path('Testimonials', 'Routes/web.php')));
+ }
+}
diff --git a/Testimonials/Providers/TestimonialsServiceProvider.php b/Testimonials/Providers/TestimonialsServiceProvider.php
new file mode 100644
index 0000000..fde762a
--- /dev/null
+++ b/Testimonials/Providers/TestimonialsServiceProvider.php
@@ -0,0 +1,20 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path('Testimonials', 'Config/config.php'), 'testimonials');
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Testimonials', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Testimonials', 'Resources/views'), 'testimonials');
+ }
+}
diff --git a/Testimonials/Resources/views/admin/form.blade.php b/Testimonials/Resources/views/admin/form.blade.php
new file mode 100644
index 0000000..c6f4e86
--- /dev/null
+++ b/Testimonials/Resources/views/admin/form.blade.php
@@ -0,0 +1,9 @@
+
+
{{ isset($record) ? 'Edit' : 'Create' }} Testimonial @csrf @isset($record) @method('PUT') @endisset
+Name @error('name')
{{ $message }}
@enderror
+Company @error('company')
{{ $message }}
@enderror
+Content {{ old('content', $record->content ?? '') }} @error('content')
{{ $message }}
@enderror
+Rating @error('rating')
{{ $message }}
@enderror
+ enabled ?? true))>Enabled
+
+
diff --git a/Testimonials/Resources/views/admin/index.blade.php b/Testimonials/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..ab55427
--- /dev/null
+++ b/Testimonials/Resources/views/admin/index.blade.php
@@ -0,0 +1,5 @@
+
+Testimonials Manage testimonials content.
Add new
+@if(session('status'))
{{ session('status') }}
@endif
+
Name Company Content Actions @forelse($records as $record){{ is_array($record->name) ? json_encode($record->name) : Str::limit((string) $record->name, 70) }} {{ is_array($record->company) ? json_encode($record->company) : Str::limit((string) $record->company, 70) }} {{ is_array($record->content) ? json_encode($record->content) : Str::limit((string) $record->content, 70) }} Edit @csrf @method('DELETE')Delete @emptyNo records yet. @endforelse
{{ $records->links() }}
+
diff --git a/Testimonials/Routes/web.php b/Testimonials/Routes/web.php
new file mode 100644
index 0000000..3527aff
--- /dev/null
+++ b/Testimonials/Routes/web.php
@@ -0,0 +1,13 @@
+prefix('administration/testimonials')->name('administration.testimonials.')->group(function (): void {
+ Route::get('/', [TestimonialController::class, 'index'])->name('index');
+ Route::get('/create', [TestimonialController::class, 'create'])->name('create');
+ Route::post('/', [TestimonialController::class, 'store'])->name('store');
+ Route::get('/{testimonial}/edit', [TestimonialController::class, 'edit'])->name('edit');
+ Route::match(['put', 'patch'], '/{testimonial}', [TestimonialController::class, 'update'])->name('update');
+ Route::delete('/{testimonial}', [TestimonialController::class, 'destroy'])->name('destroy');
+});
diff --git a/Testimonials/Support/ModuleInfo.php b/Testimonials/Support/ModuleInfo.php
new file mode 100644
index 0000000..afc0468
--- /dev/null
+++ b/Testimonials/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBeTrue();
+});
diff --git a/Testimonials/module.json b/Testimonials/module.json
new file mode 100644
index 0000000..9ee5cfa
--- /dev/null
+++ b/Testimonials/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Testimonials",
+ "alias": "testimonials",
+ "version": "1.0.0",
+ "description": "Testimonials CMS module.",
+ "keywords": [
+ "cms",
+ "testimonials"
+ ],
+ "priority": 29,
+ "providers": [
+ "Modules\\Testimonials\\Providers\\TestimonialsServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core",
+ "Administration"
+ ]
+}
diff --git a/Users/Config/config.php b/Users/Config/config.php
new file mode 100644
index 0000000..d0471c5
--- /dev/null
+++ b/Users/Config/config.php
@@ -0,0 +1,6 @@
+ 'Users',
+ 'enabled' => true,
+];
diff --git a/Users/Http/Controllers/UserController.php b/Users/Http/Controllers/UserController.php
new file mode 100644
index 0000000..79191a5
--- /dev/null
+++ b/Users/Http/Controllers/UserController.php
@@ -0,0 +1,56 @@
+ User::with('roles')->latest()->paginate(20)]);
+ }
+
+ public function create()
+ {
+ return view('stylist-theme-admin::administration.users.create', ['roles' => Role::orderBy('name')->pluck('name')]);
+ }
+
+ public function store(Request $request)
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]);
+ $user = User::create($data);
+ $user->syncRoles($data['roles'] ?? []);
+
+ return redirect()->route('administration.users.index')->with('status', 'User created.');
+ }
+
+ public function edit(User $user)
+ {
+ return view('stylist-theme-admin::administration.users.edit', ['user' => $user->load('roles'), 'roles' => Role::orderBy('name')->pluck('name')]);
+ }
+
+ public function update(Request $request, User $user)
+ {
+ $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user)], 'password' => ['nullable', 'string', 'min:8', 'confirmed'], 'roles' => ['array'], 'roles.*' => ['string', 'exists:roles,name']]);
+ if (blank($data['password'] ?? null)) {
+ unset($data['password']);
+ }
+ $user->update($data);
+ $user->syncRoles($data['roles'] ?? []);
+
+ return redirect()->route('administration.users.index')->with('status', 'User updated.');
+ }
+
+ public function destroy(User $user)
+ {
+ abort_if($user->is(auth()->user()), 422, 'You cannot delete your own account.');
+ $user->delete();
+
+ return redirect()->route('administration.users.index')->with('status', 'User deleted.');
+ }
+}
diff --git a/Users/Providers/RouteServiceProvider.php b/Users/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..89f776a
--- /dev/null
+++ b/Users/Providers/RouteServiceProvider.php
@@ -0,0 +1,17 @@
+routes(function (): void {
+ Route::middleware('web')->group(module_path('Users', 'Routes/web.php'));
+ });
+ }
+}
diff --git a/Users/Providers/UsersServiceProvider.php b/Users/Providers/UsersServiceProvider.php
new file mode 100644
index 0000000..96446ad
--- /dev/null
+++ b/Users/Providers/UsersServiceProvider.php
@@ -0,0 +1,21 @@
+app->register(RouteServiceProvider::class);
+ $this->mergeConfigFrom(module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower());
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
+ $this->loadViewsFrom(module_path(ModuleInfo::name(), 'Resources/views'), ModuleInfo::nameLower());
+ }
+}
diff --git a/Users/Routes/web.php b/Users/Routes/web.php
new file mode 100644
index 0000000..d4d0dc8
--- /dev/null
+++ b/Users/Routes/web.php
@@ -0,0 +1,8 @@
+prefix('administration/users')->name('administration.users.')->group(function (): void {
+ Route::resource('', UserController::class)->parameters(['' => 'user'])->except(['show']);
+});
diff --git a/Users/Support/ModuleInfo.php b/Users/Support/ModuleInfo.php
new file mode 100644
index 0000000..6122d1c
--- /dev/null
+++ b/Users/Support/ModuleInfo.php
@@ -0,0 +1,16 @@
+toBe('Users');
+});
diff --git a/Users/composer.json b/Users/composer.json
new file mode 100644
index 0000000..cbcc957
--- /dev/null
+++ b/Users/composer.json
@@ -0,0 +1,10 @@
+{
+ "name": "absdev/users-module",
+ "description": "Users foundation module for the ABSDEV Laravel application platform.",
+ "type": "laravel-module",
+ "autoload": {
+ "psr-4": {
+ "Modules\\Users\\": ""
+ }
+ }
+}
diff --git a/Users/module.json b/Users/module.json
new file mode 100644
index 0000000..fb2c657
--- /dev/null
+++ b/Users/module.json
@@ -0,0 +1,20 @@
+{
+ "name": "Users",
+ "alias": "users",
+ "version": "1.0.0",
+ "description": "Users foundation module for the ABSDEV Laravel application platform.",
+ "keywords": [
+ "laravel",
+ "modules",
+ "users"
+ ],
+ "priority": 10,
+ "providers": [
+ "Modules\\Users\\Providers\\UsersServiceProvider"
+ ],
+ "aliases": {},
+ "files": [],
+ "requires": [
+ "Core"
+ ]
+}
diff --git a/Workflow/Concerns/HasWorkflow.php b/Workflow/Concerns/HasWorkflow.php
new file mode 100644
index 0000000..d018a87
--- /dev/null
+++ b/Workflow/Concerns/HasWorkflow.php
@@ -0,0 +1,19 @@
+morphOne(WorkflowInstance::class, 'subject');
+ }
+
+ public function workflowLabel(): string
+ {
+ return (string) ($this->title ?? $this->name ?? class_basename($this).' #'.$this->getKey());
+ }
+}
diff --git a/Workflow/Config/config.php b/Workflow/Config/config.php
new file mode 100644
index 0000000..c61a662
--- /dev/null
+++ b/Workflow/Config/config.php
@@ -0,0 +1,3 @@
+ 'Workflow', 'default_due_days' => 3];
diff --git a/Workflow/Contracts/Workflowable.php b/Workflow/Contracts/Workflowable.php
new file mode 100644
index 0000000..9d76383
--- /dev/null
+++ b/Workflow/Contracts/Workflowable.php
@@ -0,0 +1,12 @@
+id();
+ $t->string('name');
+ $t->string('slug')->unique();
+ $t->text('description')->nullable();
+ $t->string('subject_type')->nullable()->index();
+ $t->boolean('is_active')->default(true);
+ $t->timestamps();
+ });
+ Schema::create('workflow_stages', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->string('name');
+ $t->string('slug');
+ $t->unsignedInteger('position')->default(0);
+ $t->string('required_permission')->nullable();
+ $t->boolean('is_initial')->default(false);
+ $t->boolean('is_terminal')->default(false);
+ $t->boolean('allows_rejection')->default(true);
+ $t->unsignedInteger('due_days')->nullable();
+ $t->timestamps();
+ $t->unique(['workflow_definition_id', 'slug']);
+ });
+ Schema::create('workflow_transitions', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('from_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
+ $t->foreignId('to_stage_id')->constrained('workflow_stages')->cascadeOnDelete();
+ $t->string('name');
+ $t->string('action');
+ $t->string('required_permission')->nullable();
+ $t->boolean('requires_comment')->default(false);
+ $t->timestamps();
+ });
+ Schema::create('workflow_instances', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_definition_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('workflow_stage_id')->constrained('workflow_stages');
+ $t->string('subject_type');
+ $t->unsignedBigInteger('subject_id');
+ $t->string('status')->default('active');
+ $t->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete();
+ $t->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
+ $t->timestamp('due_at')->nullable();
+ $t->timestamp('completed_at')->nullable();
+ $t->timestamps();
+ $t->unique(['subject_type', 'subject_id']);
+ $t->index(['status', 'workflow_stage_id']);
+ });
+ Schema::create('workflow_history', function (Blueprint $t) {
+ $t->id();
+ $t->foreignId('workflow_instance_id')->constrained()->cascadeOnDelete();
+ $t->foreignId('from_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
+ $t->foreignId('to_stage_id')->nullable()->constrained('workflow_stages')->nullOnDelete();
+ $t->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
+ $t->string('action');
+ $t->text('comment')->nullable();
+ $t->json('metadata')->nullable();
+ $t->timestamps();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('workflow_history');
+ Schema::dropIfExists('workflow_instances');
+ Schema::dropIfExists('workflow_transitions');
+ Schema::dropIfExists('workflow_stages');
+ Schema::dropIfExists('workflow_definitions');
+ }
+};
diff --git a/Workflow/Http/Controllers/WorkflowController.php b/Workflow/Http/Controllers/WorkflowController.php
new file mode 100644
index 0000000..62479b1
--- /dev/null
+++ b/Workflow/Http/Controllers/WorkflowController.php
@@ -0,0 +1,44 @@
+user()->can('workflow.view'), 403);
+
+ return view('workflow::admin.index', ['definitions' => WorkflowDefinition::with('stages')->latest()->get(), 'queue' => WorkflowInstance::with(['definition', 'stage', 'subject'])->where('status', 'active')->latest()->paginate(20), 'completed' => WorkflowInstance::where('status', 'completed')->count()]);
+ }
+
+ public function store(Request $request): RedirectResponse
+ {
+ abort_unless($request->user()->can('workflow.manage'), 403);
+ $data = $request->validate(['name' => 'required|string|max:120', 'description' => 'nullable|string', 'subject_type' => 'nullable|string|max:255']);
+ $definition = WorkflowDefinition::create($data + ['slug' => Str::slug($data['name']), 'is_active' => true]);
+ $draft = $definition->stages()->create(['name' => 'Draft', 'slug' => 'draft', 'position' => 0, 'is_initial' => true, 'due_days' => null]);
+ $review = $definition->stages()->create(['name' => 'Review', 'slug' => 'review', 'position' => 10, 'required_permission' => 'workflow.review', 'due_days' => 3]);
+ $published = $definition->stages()->create(['name' => 'Approved', 'slug' => 'approved', 'position' => 20, 'is_terminal' => true, 'allows_rejection' => false]);
+ $definition->transitions()->createMany([['from_stage_id' => $draft->id, 'to_stage_id' => $review->id, 'name' => 'Submit for review', 'action' => 'submit'], ['from_stage_id' => $review->id, 'to_stage_id' => $published->id, 'name' => 'Approve', 'action' => 'approve', 'required_permission' => 'workflow.review'], ['from_stage_id' => $review->id, 'to_stage_id' => $draft->id, 'name' => 'Request changes', 'action' => 'reject', 'required_permission' => 'workflow.review', 'requires_comment' => true]]);
+
+ return back()->with('success', 'Workflow created.');
+ }
+
+ public function transition(Request $request, WorkflowInstance $instance, WorkflowManager $manager): RedirectResponse
+ {
+ abort_unless($request->user()->can('workflow.review'), 403);
+ $data = $request->validate(['action' => 'required|string', 'comment' => 'nullable|string|max:2000']);
+ $manager->transition($instance, $data['action'], $request->user(), $data['comment'] ?? null);
+
+ return back()->with('success', 'Workflow updated.');
+ }
+}
diff --git a/Workflow/Models/WorkflowDefinition.php b/Workflow/Models/WorkflowDefinition.php
new file mode 100644
index 0000000..2316842
--- /dev/null
+++ b/Workflow/Models/WorkflowDefinition.php
@@ -0,0 +1,26 @@
+ 'boolean'];
+ }
+
+ public function stages(): HasMany
+ {
+ return $this->hasMany(WorkflowStage::class)->orderBy('position');
+ }
+
+ public function transitions(): HasMany
+ {
+ return $this->hasMany(WorkflowTransition::class);
+ }
+}
diff --git a/Workflow/Models/WorkflowHistory.php b/Workflow/Models/WorkflowHistory.php
new file mode 100644
index 0000000..2b8786f
--- /dev/null
+++ b/Workflow/Models/WorkflowHistory.php
@@ -0,0 +1,17 @@
+ 'array'];
+ }
+}
diff --git a/Workflow/Models/WorkflowInstance.php b/Workflow/Models/WorkflowInstance.php
new file mode 100644
index 0000000..b3d1af4
--- /dev/null
+++ b/Workflow/Models/WorkflowInstance.php
@@ -0,0 +1,38 @@
+ 'datetime', 'completed_at' => 'datetime'];
+ }
+
+ public function definition(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
+ }
+
+ public function stage(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowStage::class, 'workflow_stage_id');
+ }
+
+ public function subject(): MorphTo
+ {
+ return $this->morphTo();
+ }
+
+ public function history(): HasMany
+ {
+ return $this->hasMany(WorkflowHistory::class)->latest();
+ }
+}
diff --git a/Workflow/Models/WorkflowStage.php b/Workflow/Models/WorkflowStage.php
new file mode 100644
index 0000000..3d8a8ad
--- /dev/null
+++ b/Workflow/Models/WorkflowStage.php
@@ -0,0 +1,21 @@
+ 'boolean', 'is_terminal' => 'boolean', 'allows_rejection' => 'boolean'];
+ }
+
+ public function definition(): BelongsTo
+ {
+ return $this->belongsTo(WorkflowDefinition::class, 'workflow_definition_id');
+ }
+}
diff --git a/Workflow/Models/WorkflowTransition.php b/Workflow/Models/WorkflowTransition.php
new file mode 100644
index 0000000..66bb87f
--- /dev/null
+++ b/Workflow/Models/WorkflowTransition.php
@@ -0,0 +1,15 @@
+ 'boolean'];
+ }
+}
diff --git a/Workflow/Providers/RouteServiceProvider.php b/Workflow/Providers/RouteServiceProvider.php
new file mode 100644
index 0000000..4197f68
--- /dev/null
+++ b/Workflow/Providers/RouteServiceProvider.php
@@ -0,0 +1,15 @@
+group(module_path('Workflow', 'Routes/web.php'));
+ }
+}
diff --git a/Workflow/Providers/WorkflowServiceProvider.php b/Workflow/Providers/WorkflowServiceProvider.php
new file mode 100644
index 0000000..ab10b81
--- /dev/null
+++ b/Workflow/Providers/WorkflowServiceProvider.php
@@ -0,0 +1,22 @@
+mergeConfigFrom(module_path('Workflow', 'Config/config.php'), 'workflow');
+ $this->app->singleton(WorkflowManager::class);
+ $this->app->register(RouteServiceProvider::class);
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(module_path('Workflow', 'Database/Migrations'));
+ $this->loadViewsFrom(module_path('Workflow', 'Resources/views'), 'workflow');
+ }
+}
diff --git a/Workflow/Resources/views/admin/index.blade.php b/Workflow/Resources/views/admin/index.blade.php
new file mode 100644
index 0000000..673cde3
--- /dev/null
+++ b/Workflow/Resources/views/admin/index.blade.php
@@ -0,0 +1 @@
+Workflow Approval definitions, active reviews and completed work.
Definitions {{ $definitions->count() }}
Awaiting action {{ $queue->total() }}
Completed {{ $completed }}
Review queue @forelse($queue as $item)
{{ method_exists($item->subject,'workflowLabel') ? $item->subject->workflowLabel() : class_basename($item->subject_type).' #'.$item->subject_id }}
{{ $item->definition->name }} · {{ $item->stage->name }} @if($item->due_at) · due {{ $item->due_at->diffForHumans() }} @endif
@csrfApprove @csrfChanges @empty
No items are awaiting review. @endforelse
{{ $queue->links() }}
Create workflow @csrfCreate @foreach($definitions as $definition)
{{ $definition->name }}
{{ $definition->stages->pluck('name')->join(' → ') }}
@endforeach
diff --git a/Workflow/Routes/web.php b/Workflow/Routes/web.php
new file mode 100644
index 0000000..6653370
--- /dev/null
+++ b/Workflow/Routes/web.php
@@ -0,0 +1,10 @@
+prefix('administration/workflows')->name('administration.workflows.')->group(function () {
+ Route::get('/', [WorkflowController::class, 'index'])->name('index');
+ Route::post('/', [WorkflowController::class, 'store'])->name('store');
+ Route::post('/{instance}/transition', [WorkflowController::class, 'transition'])->name('transition');
+});
diff --git a/Workflow/Services/WorkflowManager.php b/Workflow/Services/WorkflowManager.php
new file mode 100644
index 0000000..7b8dc1b
--- /dev/null
+++ b/Workflow/Services/WorkflowManager.php
@@ -0,0 +1,46 @@
+stages()->where('is_initial', true)->first() ?? $definition->stages()->firstOrFail();
+ $instance = WorkflowInstance::updateOrCreate(['subject_type' => $subject->getMorphClass(), 'subject_id' => $subject->getKey()], ['workflow_definition_id' => $definition->getKey(), 'workflow_stage_id' => $stage->getKey(), 'status' => $stage->is_terminal ? 'completed' : 'active', 'started_by' => $user?->getAuthIdentifier(), 'due_at' => $stage->due_days ? now()->addDays($stage->due_days) : null, 'completed_at' => $stage->is_terminal ? now() : null]);
+ WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'to_stage_id' => $stage->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => 'started']);
+
+ return $instance->fresh(['definition', 'stage']);
+ });
+ }
+
+ public function transition(WorkflowInstance $instance, string $action, ?Authenticatable $user = null, ?string $comment = null): WorkflowInstance
+ {
+ return DB::transaction(function () use ($instance, $action, $user, $comment) {
+ $transition = WorkflowTransition::where('workflow_definition_id', $instance->workflow_definition_id)->where('from_stage_id', $instance->workflow_stage_id)->where('action', $action)->first();
+ if (! $transition) {
+ throw ValidationException::withMessages(['action' => 'This transition is not available.']);
+ } if ($transition->requires_comment && blank($comment)) {
+ throw ValidationException::withMessages(['comment' => 'A comment is required.']);
+ } if ($transition->required_permission && (! $user || ! method_exists($user, 'can') || ! $user->can($transition->required_permission))) {
+ abort(403);
+ } $target = WorkflowStage::findOrFail($transition->to_stage_id);
+ $from = $instance->workflow_stage_id;
+ $instance->update(['workflow_stage_id' => $target->id, 'status' => $target->is_terminal ? 'completed' : 'active', 'due_at' => $target->due_days ? now()->addDays($target->due_days) : null, 'completed_at' => $target->is_terminal ? now() : null]);
+ WorkflowHistory::create(['workflow_instance_id' => $instance->id, 'from_stage_id' => $from, 'to_stage_id' => $target->id, 'user_id' => $user?->getAuthIdentifier(), 'action' => $action, 'comment' => $comment]);
+
+ return $instance->fresh(['definition', 'stage', 'subject']);
+ });
+ }
+}
diff --git a/Workflow/Tests/Feature/ModuleBootTest.php b/Workflow/Tests/Feature/ModuleBootTest.php
new file mode 100644
index 0000000..e489747
--- /dev/null
+++ b/Workflow/Tests/Feature/ModuleBootTest.php
@@ -0,0 +1,14 @@
+assertTrue(app()->providerIsLoaded(WorkflowServiceProvider::class));
+ }
+}
diff --git a/Workflow/module.json b/Workflow/module.json
new file mode 100644
index 0000000..2a022f0
--- /dev/null
+++ b/Workflow/module.json
@@ -0,0 +1,11 @@
+{
+ "name": "Workflow",
+ "alias": "workflow",
+ "description": "Reusable approval workflows, review queues, transitions and audit history.",
+ "keywords": ["workflow", "approval", "review"],
+ "priority": 35,
+ "providers": [
+ "Modules\\Workflow\\Providers\\WorkflowServiceProvider"
+ ],
+ "files": []
+}
diff --git a/integration/PHASE5.md b/integration/PHASE5.md
new file mode 100644
index 0000000..ca2ade8
--- /dev/null
+++ b/integration/PHASE5.md
@@ -0,0 +1,25 @@
+# Phase 5 — Administration authorization correction
+
+This is a **delta package** against Phase 4, not a full project.
+
+## What changed
+- Updated the SonarDesk admin Gate to accept an explicitly granted Spatie `manage-sonardesk-licensing` permission (web guard) as well as the existing approved administrator roles.
+- Added Pest authorization smoke tests for anonymous users, unprivileged verified users, and the explicit permission grant.
+- Did not change any licensing API, checkout, webhook, or customer records.
+
+## Install
+1. Back up `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php`.
+2. Copy the included `Modules/Licensing/Providers/IntegratedLicensingServiceProvider.php` over the existing file.
+3. Copy the included test into `tests/Feature/` if desired.
+4. Run `composer dump-autoload && php artisan optimize:clear && php artisan permission:cache-reset`.
+5. Run `php artisan route:list --path=administration/sonardesk -v`.
+6. Run `php artisan test --filter=SonarDeskAdminAuthorizationTest` against a **disposable test database**. Do not run destructive test migrations against live customer data.
+
+## Next implementation milestones
+- Phase 6: audited commerce checkout, webhook idempotency, customer identity mapping and data import.
+- Phase 7: downloads, releases, appcast, system requirements, and ABSDev Stylist frontend.
+- Phase 8: complete route parity, automated regression tests, staging deployment and cutover.
+
+## Limitations
+- The tests have not been run against your local ABSDev database.
+- No production-readiness claim is made; commerce/customer migration is still pending.
diff --git a/integration/PHASE6.md b/integration/PHASE6.md
new file mode 100644
index 0000000..bb2c396
--- /dev/null
+++ b/integration/PHASE6.md
@@ -0,0 +1,22 @@
+# Phase 6 — ABSDev sidebar and administration theme integration
+
+## Included changes
+- Adds a SonarDesk sidebar group to the **existing ABSDev shared admin sidebar**, only when the licensing route exists and the current user is authorised.
+- Adds links to SonarDesk overview and licences, with active route highlighting and automatic group expansion.
+- Replaces the standalone licensing HTML document with the existing ABSDev `` shell, preserving licensing views and their read-only controllers.
+- Does not modify payment processing, customer records, database schema, licence keys or API endpoints.
+
+## Install
+Copy the `resources/` and `Modules/` directories over the matching directories in your ABSDev checkout. Back up modified files or commit existing changes first; this patch is based on the uploaded ABSDev source snapshot and may overwrite subsequent local sidebar changes.
+
+Run:
+```
+php artisan optimize:clear
+php artisan view:cache
+php artisan route:list --path=administration/sonardesk -v
+```
+
+Check the sidebar and each licensing page as an authorised user. Confirm a non-authorised user cannot access the routes. Test mobile layout and dark/light modes.
+
+## Next integration
+Paddle checkout, webhook idempotency, subscription mapping, customer migration and downloads require a separate integration and live-environment verification. These are **not** implemented in this patch.
diff --git a/integration/PHASE7.md b/integration/PHASE7.md
new file mode 100644
index 0000000..e956235
--- /dev/null
+++ b/integration/PHASE7.md
@@ -0,0 +1,20 @@
+# Phase 7 — Subscription visibility (read-only)
+
+Adds a SonarDesk **Subscriptions** sidebar link and an authorised `/administration/sonardesk/subscriptions` page showing existing subscription-linked licence records, status, provider, period end, and scheduled cancellation. Uses the same ABSDev administration layout as Phase 6.
+
+**No checkout, Paddle API calls, webhook changes, database migrations, customer merges, or billing mutations.** This phase is intentionally a safe first step toward commerce integration. Figures reflect local records only, not live Paddle data.
+
+## Install
+
+Back up/commit local changes first. Overlay the `Modules/` and `resources/` folders onto ABSDev, preserving paths. The sidebar file is a full replacement based on Phase 6; if you have edited it since then, merge the SonarDesk group manually instead.
+
+```bash
+php artisan optimize:clear
+php artisan route:list --path=administration/sonardesk -v
+```
+
+Check subscriptions page with authorised and unauthorised users. This view requires the licensing database migrations and the Phase 6 layout to be present.
+
+## Still required before billing cutover
+
+Verify Paddle credentials, webhook signatures and idempotency, customer mapping, checkout redirects, and existing production licence continuity in staging. Do not repoint production webhooks yet.
diff --git a/modules_statuses.json b/modules_statuses.json
index 30a8fa7..2acd30e 100644
--- a/modules_statuses.json
+++ b/modules_statuses.json
@@ -46,5 +46,6 @@
"Builder": true,
"Marketplace": true,
"CloudDevOps": true,
- "Content": true
+ "Content": true,
+ "Licensing": true
}
diff --git a/resources/themes/common/views/components/layouts/app/sidebar.blade.php b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
index c848c25..44adba8 100644
--- a/resources/themes/common/views/components/layouts/app/sidebar.blade.php
+++ b/resources/themes/common/views/components/layouts/app/sidebar.blade.php
@@ -46,6 +46,16 @@
{{ __('Locations') }}
+ @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.index') && auth()->user()?->can('manage-sonardesk-licensing'))
+
+ {{ __('Overview') }}
+ {{ __('Licences & Activations') }}
+ @if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.subscriptions'))
+ {{ __('Subscriptions') }}
+ @endif
+
+ @endif
+
{{ __('Users') }}
{{ __('Roles & Permissions') }}
diff --git a/tests/Feature/SonarDeskAdminAuthorizationTest.php b/tests/Feature/SonarDeskAdminAuthorizationTest.php
new file mode 100644
index 0000000..2bedf45
--- /dev/null
+++ b/tests/Feature/SonarDeskAdminAuthorizationTest.php
@@ -0,0 +1,24 @@
+get('/administration/sonardesk')->assertRedirect();
+});
+
+it('denies an authenticated verified user without licensing permission', function (): void {
+ $user = User::factory()->create(['email_verified_at' => now()]);
+ $this->actingAs($user)->get('/administration/sonardesk')->assertForbidden();
+});
+
+it('allows a verified user granted the licensing permission past authorization', function (): void {
+ Permission::findOrCreate('manage-sonardesk-licensing', 'web');
+ $user = User::factory()->create(['email_verified_at' => now()]);
+ $user->givePermissionTo('manage-sonardesk-licensing');
+ expect($user->can('manage-sonardesk-licensing'))->toBeTrue();
+ // Full dashboard request requires licensing migrations and database fixtures.
+});