Add dependency guards and PHPDoc documentation

Dependency guards for optional modules:
- Remove hard use statements for Core\Mod\*, Core\Plug\* classes
- Add class_exists() guards before using optional dependencies
- Change type hints to ?object with docblock annotations
- Add fallback behavior when optional modules not installed

Files with dependency guards added:
- Cdn/Console/CdnPurge.php (Workspace, Purge)
- Cdn/Console/PushAssetsToCdn.php (VBucket)
- Cdn/Boot.php (CdnManager, StorageManager)
- Cdn/Jobs/PushAssetToCdn.php (StorageManager)
- Front/Admin/AdminMenuRegistry.php (User, Workspace, EntitlementService)
- Front/Admin/Contracts/*.php (User, Workspace)
- Front/Admin/View/Components/Sidemenu.php (User, WorkspaceService)
- Front/Mcp/McpContext.php (AgentPlan)

Comprehensive PHPDoc documentation:
- ModuleScanner.php - Scanner mechanics and priority system
- ModuleRegistry.php - Registration flow and querying
- LazyModuleListener.php - Lazy loading mechanism
- LifecycleEventProvider.php - Lifecycle phases
- All Event classes - When fired, context, examples
- Service contracts - HealthCheckable, ServiceDefinition
- Admin contracts - AdminMenuProvider, DynamicMenuProvider

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-22 19:24:39 +00:00
parent c6993dbfca
commit 13670ebb34
47 changed files with 1491 additions and 350 deletions

View file

@ -20,8 +20,6 @@ use Core\Cdn\Services\BunnyStorageService;
use Core\Cdn\Services\FluxCdnService; use Core\Cdn\Services\FluxCdnService;
use Core\Cdn\Services\StorageOffload; use Core\Cdn\Services\StorageOffload;
use Core\Cdn\Services\StorageUrlResolver; use Core\Cdn\Services\StorageUrlResolver;
use Core\Plug\Cdn\CdnManager;
use Core\Plug\Storage\StorageManager;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
/** /**
@ -45,9 +43,13 @@ class Boot extends ServiceProvider
$this->mergeConfigFrom(__DIR__.'/config.php', 'cdn'); $this->mergeConfigFrom(__DIR__.'/config.php', 'cdn');
$this->mergeConfigFrom(__DIR__.'/offload.php', 'offload'); $this->mergeConfigFrom(__DIR__.'/offload.php', 'offload');
// Register Plug managers as singletons // Register Plug managers as singletons (when available)
$this->app->singleton(CdnManager::class); if (class_exists(\Core\Plug\Cdn\CdnManager::class)) {
$this->app->singleton(StorageManager::class); $this->app->singleton(\Core\Plug\Cdn\CdnManager::class);
}
if (class_exists(\Core\Plug\Storage\StorageManager::class)) {
$this->app->singleton(\Core\Plug\Storage\StorageManager::class);
}
// Register legacy services as singletons (for backward compatibility) // Register legacy services as singletons (for backward compatibility)
$this->app->singleton(BunnyCdnService::class); $this->app->singleton(BunnyCdnService::class);

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Cdn\Console; namespace Core\Cdn\Console;
use Core\Mod\Tenant\Models\Workspace;
use Core\Plug\Cdn\Bunny\Purge;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class CdnPurge extends Command class CdnPurge extends Command
@ -35,12 +33,18 @@ class CdnPurge extends Command
*/ */
protected $description = 'Purge content from CDN edge cache'; protected $description = 'Purge content from CDN edge cache';
protected Purge $purger; /**
* Purger instance (Core\Plug\Cdn\Bunny\Purge when available).
*/
protected ?object $purger = null;
public function __construct() public function __construct()
{ {
parent::__construct(); parent::__construct();
$this->purger = new Purge;
if (class_exists(\Core\Plug\Cdn\Bunny\Purge::class)) {
$this->purger = new \Core\Plug\Cdn\Bunny\Purge;
}
} }
/** /**
@ -48,6 +52,12 @@ class CdnPurge extends Command
*/ */
public function handle(): int public function handle(): int
{ {
if ($this->purger === null) {
$this->error('CDN Purge requires Core\Plug\Cdn\Bunny\Purge class. Plug module not installed.');
return self::FAILURE;
}
$workspaceArg = $this->argument('workspace'); $workspaceArg = $this->argument('workspace');
$urls = $this->option('url'); $urls = $this->option('url');
$tag = $this->option('tag'); $tag = $this->option('tag');
@ -84,9 +94,13 @@ class CdnPurge extends Command
// Purge by workspace // Purge by workspace
if (empty($workspaceArg)) { if (empty($workspaceArg)) {
$workspaceOptions = ['all', 'Select specific URLs'];
if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$workspaceOptions = array_merge($workspaceOptions, \Core\Mod\Tenant\Models\Workspace::pluck('slug')->toArray());
}
$workspaceArg = $this->choice( $workspaceArg = $this->choice(
'What would you like to purge?', 'What would you like to purge?',
array_merge(['all', 'Select specific URLs'], Workspace::pluck('slug')->toArray()), $workspaceOptions,
0 0
); );
@ -203,7 +217,13 @@ class CdnPurge extends Command
protected function purgeAllWorkspaces(bool $dryRun): int protected function purgeAllWorkspaces(bool $dryRun): int
{ {
$workspaces = Workspace::all(); if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$this->error('Workspace purge requires Tenant module to be installed.');
return self::FAILURE;
}
$workspaces = \Core\Mod\Tenant\Models\Workspace::all();
if ($workspaces->isEmpty()) { if ($workspaces->isEmpty()) {
$this->error('No workspaces found'); $this->error('No workspaces found');
@ -255,13 +275,19 @@ class CdnPurge extends Command
protected function purgeWorkspace(string $slug, bool $dryRun): int protected function purgeWorkspace(string $slug, bool $dryRun): int
{ {
$workspace = Workspace::where('slug', $slug)->first(); if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$this->error('Workspace purge requires Tenant module to be installed.');
return self::FAILURE;
}
$workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $slug)->first();
if (! $workspace) { if (! $workspace) {
$this->error("Workspace not found: {$slug}"); $this->error("Workspace not found: {$slug}");
$this->newLine(); $this->newLine();
$this->info('Available workspaces:'); $this->info('Available workspaces:');
Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}")); \Core\Mod\Tenant\Models\Workspace::pluck('slug')->each(fn ($s) => $this->line(" - {$s}"));
return self::FAILURE; return self::FAILURE;
} }

View file

@ -12,7 +12,6 @@ namespace Core\Cdn\Console;
use Core\Cdn\Services\FluxCdnService; use Core\Cdn\Services\FluxCdnService;
use Core\Cdn\Services\StorageUrlResolver; use Core\Cdn\Services\StorageUrlResolver;
use Core\Plug\Storage\Bunny\VBucket;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\File;
@ -30,7 +29,10 @@ class PushAssetsToCdn extends Command
protected StorageUrlResolver $cdn; protected StorageUrlResolver $cdn;
protected VBucket $vbucket; /**
* VBucket instance (Core\Plug\Storage\Bunny\VBucket when available).
*/
protected ?object $vbucket = null;
protected bool $dryRun = false; protected bool $dryRun = false;
@ -40,12 +42,18 @@ class PushAssetsToCdn extends Command
public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int public function handle(FluxCdnService $flux, StorageUrlResolver $cdn): int
{ {
if (! class_exists(\Core\Plug\Storage\Bunny\VBucket::class)) {
$this->error('Push assets to CDN requires Core\Plug\Storage\Bunny\VBucket class. Plug module not installed.');
return self::FAILURE;
}
$this->cdn = $cdn; $this->cdn = $cdn;
$this->dryRun = $this->option('dry-run'); $this->dryRun = $this->option('dry-run');
// Create vBucket for workspace isolation // Create vBucket for workspace isolation
$domain = $this->option('domain'); $domain = $this->option('domain');
$this->vbucket = VBucket::public($domain); $this->vbucket = \Core\Plug\Storage\Bunny\VBucket::public($domain);
$pushFlux = $this->option('flux'); $pushFlux = $this->option('flux');
$pushFontawesome = $this->option('fontawesome'); $pushFontawesome = $this->option('fontawesome');

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Cdn\Jobs; namespace Core\Cdn\Jobs;
use Core\Plug\Storage\StorageManager;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -55,9 +54,22 @@ class PushAssetToCdn implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* @param object|null $storage StorageManager instance when Plug module available
*/ */
public function handle(StorageManager $storage): void public function handle(?object $storage = null): void
{ {
if (! class_exists(\Core\Plug\Storage\StorageManager::class)) {
Log::warning('PushAssetToCdn: StorageManager not available, Plug module not installed');
return;
}
// Resolve from container if not injected
if ($storage === null) {
$storage = app(\Core\Plug\Storage\StorageManager::class);
}
if (! config('cdn.bunny.push_enabled', false)) { if (! config('cdn.bunny.push_enabled', false)) {
Log::debug('PushAssetToCdn: Push disabled, skipping', [ Log::debug('PushAssetToCdn: Push disabled, skipping', [
'disk' => $this->disk, 'disk' => $this->disk,

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Cdn\Services; namespace Core\Cdn\Services;
use Core\Cdn\Jobs\PushAssetToCdn; use Core\Cdn\Jobs\PushAssetToCdn;
use Core\Plug\Storage\StorageManager;
use Illuminate\Http\UploadedFile; use Illuminate\Http\UploadedFile;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -37,9 +36,12 @@ class AssetPipeline
{ {
protected StorageUrlResolver $urlResolver; protected StorageUrlResolver $urlResolver;
protected StorageManager $storage; /**
* Storage manager instance (Core\Plug\Storage\StorageManager when available).
*/
protected ?object $storage = null;
public function __construct(StorageUrlResolver $urlResolver, StorageManager $storage) public function __construct(StorageUrlResolver $urlResolver, ?object $storage = null)
{ {
$this->urlResolver = $urlResolver; $this->urlResolver = $urlResolver;
$this->storage = $storage; $this->storage = $storage;
@ -220,8 +222,10 @@ class AssetPipeline
$results[$path] = $disk->delete($path); $results[$path] = $disk->delete($path);
} }
// Bulk delete from CDN storage // Bulk delete from CDN storage (requires StorageManager from Plug module)
$this->storage->zone($bucket)->delete()->paths($paths); if ($this->storage !== null) {
$this->storage->zone($bucket)->delete()->paths($paths);
}
// Purge from CDN cache if enabled // Purge from CDN cache if enabled
if (config('cdn.pipeline.auto_purge', true)) { if (config('cdn.pipeline.auto_purge', true)) {
@ -299,11 +303,11 @@ class AssetPipeline
if ($queue) { if ($queue) {
PushAssetToCdn::dispatch($disk, $path, $zone); PushAssetToCdn::dispatch($disk, $path, $zone);
} else { } elseif ($this->storage !== null) {
// Synchronous push if no queue configured // Synchronous push if no queue configured (requires StorageManager from Plug module)
$disk = \Illuminate\Support\Facades\Storage::disk($disk); $diskInstance = \Illuminate\Support\Facades\Storage::disk($disk);
if ($disk->exists($path)) { if ($diskInstance->exists($path)) {
$contents = $disk->get($path); $contents = $diskInstance->get($path);
$this->storage->zone($zone)->upload()->contents($path, $contents); $this->storage->zone($zone)->upload()->contents($path, $contents);
} }
} }

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Cdn\Services; namespace Core\Cdn\Services;
use Core\Config\ConfigService; use Core\Config\ConfigService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -167,8 +166,10 @@ class BunnyCdnService
/** /**
* Purge all cached content for a workspace. * Purge all cached content for a workspace.
*
* @param object $workspace Workspace model instance (requires uuid property)
*/ */
public function purgeWorkspace(Workspace $workspace): bool public function purgeWorkspace(object $workspace): bool
{ {
return $this->purgeByTag("workspace-{$workspace->uuid}"); return $this->purgeByTag("workspace-{$workspace->uuid}");
} }

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Config; namespace Core\Config;
use Core\Mod\Social\Contracts\Config as ConfigContract;
use Core\Mod\Social\Models\Config as ConfigModel;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
@ -21,8 +19,11 @@ use Illuminate\Support\Facades\Cache;
* *
* Provides a standardised interface for managing configuration settings * Provides a standardised interface for managing configuration settings
* with validation, caching, and database persistence. * with validation, caching, and database persistence.
*
* Note: This class requires Core\Mod\Social module to be installed for
* database persistence functionality. Implements ConfigContract when available.
*/ */
abstract class Config implements ConfigContract abstract class Config
{ {
/** /**
* Create a new config instance. * Create a new config instance.
@ -58,12 +59,18 @@ abstract class Config implements ConfigContract
/** /**
* Insert or update configuration in database. * Insert or update configuration in database.
* *
* Requires Core\Mod\Social module to be installed.
*
* @param string $name Configuration field name * @param string $name Configuration field name
* @param mixed $payload Value to store * @param mixed $payload Value to store
*/ */
public function insert(string $name, mixed $payload): void public function insert(string $name, mixed $payload): void
{ {
ConfigModel::updateOrCreate( if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
return;
}
\Core\Mod\Social\Models\Config::updateOrCreate(
['name' => $name, 'group' => $this->group()], ['name' => $name, 'group' => $this->group()],
['payload' => $payload] ['payload' => $payload]
); );
@ -73,15 +80,22 @@ abstract class Config implements ConfigContract
* Get a configuration value. * Get a configuration value.
* *
* Checks cache first, then database, finally falls back to default from form(). * Checks cache first, then database, finally falls back to default from form().
* Requires Core\Mod\Social module for database lookup.
* *
* @param string $name Configuration field name * @param string $name Configuration field name
*/ */
public function get(string $name): mixed public function get(string $name): mixed
{ {
return $this->getCache($name, function () use ($name) { return $this->getCache($name, function () use ($name) {
$payload = ConfigModel::get( $default = Arr::get($this->form(), $name);
if (! class_exists(\Core\Mod\Social\Models\Config::class)) {
return $default;
}
$payload = \Core\Mod\Social\Models\Config::get(
property: "{$this->group()}.{$name}", property: "{$this->group()}.{$name}",
default: Arr::get($this->form(), $name) default: $default
); );
$this->putCache($name, $payload); $this->putCache($name, $payload);

View file

@ -15,7 +15,6 @@ use Core\Config\Models\Channel;
use Core\Config\Models\ConfigKey; use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile; use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue; use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
/** /**
@ -150,11 +149,12 @@ class ConfigResolver
* NOTE: This is the expensive path - only called when lazy-priming. * NOTE: This is the expensive path - only called when lazy-priming.
* Normal reads hit the hash directly via ConfigService. * Normal reads hit the hash directly via ConfigService.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @param string|Channel|null $channel Channel code or object * @param string|Channel|null $channel Channel code or object
*/ */
public function resolve( public function resolve(
string $keyCode, string $keyCode,
?Workspace $workspace = null, ?object $workspace = null,
string|Channel|null $channel = null, string|Channel|null $channel = null,
): ConfigResult { ): ConfigResult {
// Get key definition (DB query - only during resolve, not normal reads) // Get key definition (DB query - only during resolve, not normal reads)
@ -229,9 +229,12 @@ class ConfigResolver
/** /**
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON). * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
*/ */
/**
* @param object|null $workspace Workspace model instance or null for system scope
*/
protected function resolveJsonSubKey( protected function resolveJsonSubKey(
string $keyCode, string $keyCode,
?Workspace $workspace, ?object $workspace,
string|Channel|null $channel, string|Channel|null $channel,
): ConfigResult { ): ConfigResult {
// Guard against stack overflow from deep nesting // Guard against stack overflow from deep nesting
@ -277,11 +280,12 @@ class ConfigResolver
/** /**
* Build the channel inheritance chain. * Build the channel inheritance chain.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, Channel|null> * @return Collection<int, Channel|null>
*/ */
public function buildChannelChain( public function buildChannelChain(
string|Channel|null $channel, string|Channel|null $channel,
?Workspace $workspace = null, ?object $workspace = null,
): Collection { ): Collection {
$chain = new Collection; $chain = new Collection;
@ -407,7 +411,7 @@ class ConfigResolver
* Providers supply values from module data without database storage. * Providers supply values from module data without database storage.
* *
* @param string $pattern Key pattern (supports * wildcard) * @param string $pattern Key pattern (supports * wildcard)
* @param callable $provider fn(string $key, ?Workspace $workspace, ?Channel $channel): mixed * @param callable $provider fn(string $key, ?object $workspace, ?Channel $channel): mixed
*/ */
public function registerProvider(string $pattern, callable $provider): void public function registerProvider(string $pattern, callable $provider): void
{ {
@ -416,10 +420,12 @@ class ConfigResolver
/** /**
* Resolve value from virtual providers. * Resolve value from virtual providers.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
public function resolveFromProviders( public function resolveFromProviders(
string $keyCode, string $keyCode,
?Workspace $workspace, ?object $workspace,
string|Channel|null $channel, string|Channel|null $channel,
): mixed { ): mixed {
foreach ($this->providers as $pattern => $provider) { foreach ($this->providers as $pattern => $provider) {
@ -455,9 +461,10 @@ class ConfigResolver
* *
* NOTE: Only called during prime, not normal reads. * NOTE: Only called during prime, not normal reads.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult> * @return array<string, ConfigResult>
*/ */
public function resolveAll(?Workspace $workspace = null, string|Channel|null $channel = null): array public function resolveAll(?object $workspace = null, string|Channel|null $channel = null): array
{ {
$results = []; $results = [];
@ -474,11 +481,12 @@ class ConfigResolver
* *
* NOTE: Only called during prime, not normal reads. * NOTE: Only called during prime, not normal reads.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult> * @return array<string, ConfigResult>
*/ */
public function resolveCategory( public function resolveCategory(
string $category, string $category,
?Workspace $workspace = null, ?object $workspace = null,
string|Channel|null $channel = null, string|Channel|null $channel = null,
): array { ): array {
$results = []; $results = [];
@ -497,9 +505,10 @@ class ConfigResolver
* Returns profiles ordered from most specific (workspace) to least (system). * Returns profiles ordered from most specific (workspace) to least (system).
* Chain: workspace org system * Chain: workspace org system
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, ConfigProfile> * @return Collection<int, ConfigProfile>
*/ */
public function buildProfileChain(?Workspace $workspace = null): Collection public function buildProfileChain(?object $workspace = null): Collection
{ {
$chain = new Collection; $chain = new Collection;
@ -531,8 +540,10 @@ class ConfigResolver
* *
* Stub for now - will connect to Tenant module when org model exists. * Stub for now - will connect to Tenant module when org model exists.
* Organisation = multi-workspace grouping (agency accounts, teams). * Organisation = multi-workspace grouping (agency accounts, teams).
*
* @param object|null $workspace Workspace model instance or null
*/ */
protected function resolveOrgId(?Workspace $workspace): ?int protected function resolveOrgId(?object $workspace): ?int
{ {
if ($workspace === null) { if ($workspace === null) {
return null; return null;
@ -590,10 +601,12 @@ class ConfigResolver
* Check if a key prefix is configured. * Check if a key prefix is configured.
* *
* Optimised to use EXISTS query instead of resolving each key. * Optimised to use EXISTS query instead of resolving each key.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
public function isPrefixConfigured( public function isPrefixConfigured(
string $prefix, string $prefix,
?Workspace $workspace = null, ?object $workspace = null,
string|Channel|null $channel = null, string|Channel|null $channel = null,
): bool { ): bool {
// Get profile IDs for this workspace // Get profile IDs for this workspace

View file

@ -19,7 +19,6 @@ use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile; use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigResolved; use Core\Config\Models\ConfigResolved;
use Core\Config\Models\ConfigValue; use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
/** /**
* Configuration service - main API. * Configuration service - main API.
@ -37,7 +36,10 @@ use Core\Mod\Tenant\Models\Workspace;
*/ */
class ConfigService class ConfigService
{ {
protected ?Workspace $workspace = null; /**
* Current workspace context (Workspace model instance or null for system scope).
*/
protected ?object $workspace = null;
protected ?Channel $channel = null; protected ?Channel $channel = null;
@ -47,8 +49,10 @@ class ConfigService
/** /**
* Set the current context (called by middleware). * Set the current context (called by middleware).
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
public function setContext(?Workspace $workspace, ?Channel $channel = null): void public function setContext(?object $workspace, ?Channel $channel = null): void
{ {
$this->workspace = $workspace; $this->workspace = $workspace;
$this->channel = $channel; $this->channel = $channel;
@ -56,8 +60,10 @@ class ConfigService
/** /**
* Get current workspace context. * Get current workspace context.
*
* @return object|null Workspace model instance or null
*/ */
public function getWorkspace(): ?Workspace public function getWorkspace(): ?object
{ {
return $this->workspace; return $this->workspace;
} }
@ -79,8 +85,10 @@ class ConfigService
* Get config for a specific workspace (admin use only). * Get config for a specific workspace (admin use only).
* *
* Use this when you need another workspace's settings - requires explicit intent. * Use this when you need another workspace's settings - requires explicit intent.
*
* @param object $workspace Workspace model instance
*/ */
public function getForWorkspace(string $key, Workspace $workspace, mixed $default = null): mixed public function getForWorkspace(string $key, object $workspace, mixed $default = null): mixed
{ {
$result = $this->resolve($key, $workspace, null); $result = $this->resolve($key, $workspace, null);
@ -96,11 +104,12 @@ class ConfigService
* 3. Hash lookup again * 3. Hash lookup again
* 4. Compute via resolver if still not found (lazy prime) * 4. Compute via resolver if still not found (lazy prime)
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @param string|Channel|null $channel Channel code or object * @param string|Channel|null $channel Channel code or object
*/ */
public function resolve( public function resolve(
string $key, string $key,
?Workspace $workspace = null, ?object $workspace = null,
string|Channel|null $channel = null, string|Channel|null $channel = null,
): ConfigResult { ): ConfigResult {
$workspaceId = $workspace?->id; $workspaceId = $workspace?->id;
@ -207,10 +216,12 @@ class ConfigService
/** /**
* Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON). * Try to resolve a JSON sub-key (e.g., "website.title" from "website" JSON).
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
protected function resolveJsonSubKey( protected function resolveJsonSubKey(
string $keyCode, string $keyCode,
?Workspace $workspace, ?object $workspace,
string|Channel|null $channel, string|Channel|null $channel,
): ConfigResult { ): ConfigResult {
$parts = explode('.', $keyCode); $parts = explode('.', $keyCode);
@ -394,9 +405,10 @@ class ConfigService
/** /**
* Get all config values for a workspace. * Get all config values for a workspace.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function all(?Workspace $workspace = null, string|Channel|null $channel = null): array public function all(?object $workspace = null, string|Channel|null $channel = null): array
{ {
$workspaceId = $workspace?->id; $workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace); $channelId = $this->resolveChannelId($channel, $workspace);
@ -414,11 +426,12 @@ class ConfigService
/** /**
* Get all config values for a category. * Get all config values for a category.
* *
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, mixed> * @return array<string, mixed>
*/ */
public function category( public function category(
string $category, string $category,
?Workspace $workspace = null, ?object $workspace = null,
string|Channel|null $channel = null, string|Channel|null $channel = null,
): array { ): array {
$workspaceId = $workspace?->id; $workspaceId = $workspace?->id;
@ -447,8 +460,10 @@ class ConfigService
* Call after workspace creation, config changes, or on schedule. * Call after workspace creation, config changes, or on schedule.
* *
* Populates both hash (process-scoped) and database (persistent). * Populates both hash (process-scoped) and database (persistent).
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
public function prime(?Workspace $workspace = null, string|Channel|null $channel = null): void public function prime(?object $workspace = null, string|Channel|null $channel = null): void
{ {
$workspaceId = $workspace?->id; $workspaceId = $workspace?->id;
$channelId = $this->resolveChannelId($channel, $workspace); $channelId = $this->resolveChannelId($channel, $workspace);
@ -500,7 +515,10 @@ class ConfigService
->delete(); ->delete();
// Re-compute this key for the affected scope // Re-compute this key for the affected scope
$workspace = $workspaceId !== null ? Workspace::find($workspaceId) : null; $workspace = null;
if ($workspaceId !== null && class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$workspace = \Core\Mod\Tenant\Models\Workspace::find($workspaceId);
}
$channel = $channelId ? Channel::find($channelId) : null; $channel = $channelId ? Channel::find($channelId) : null;
$result = $this->resolver->resolve($keyCode, $workspace, $channel); $result = $this->resolver->resolve($keyCode, $workspace, $channel);
@ -526,18 +544,21 @@ class ConfigService
* Prime cache for all workspaces. * Prime cache for all workspaces.
* *
* Run this from a scheduled command or queue job. * Run this from a scheduled command or queue job.
* Requires Core\Mod\Tenant module to prime workspace-level config.
*/ */
public function primeAll(): void public function primeAll(): void
{ {
// Prime system config // Prime system config
$this->prime(null); $this->prime(null);
// Prime each workspace // Prime each workspace (requires Tenant module)
Workspace::chunk(100, function ($workspaces) { if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
foreach ($workspaces as $workspace) { \Core\Mod\Tenant\Models\Workspace::chunk(100, function ($workspaces) {
$this->prime($workspace); foreach ($workspaces as $workspace) {
} $this->prime($workspace);
}); }
});
}
} }
/** /**
@ -545,8 +566,10 @@ class ConfigService
* *
* Clears both hash and database. Next read will lazy-prime. * Clears both hash and database. Next read will lazy-prime.
* Fires ConfigInvalidated event. * Fires ConfigInvalidated event.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/ */
public function invalidateWorkspace(?Workspace $workspace = null): void public function invalidateWorkspace(?object $workspace = null): void
{ {
$workspaceId = $workspace?->id; $workspaceId = $workspace?->id;

View file

@ -12,7 +12,6 @@ namespace Core\Config\Console;
use Core\Config\ConfigService; use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey; use Core\Config\Models\ConfigKey;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ConfigListCommand extends Command class ConfigListCommand extends Command
@ -33,7 +32,13 @@ class ConfigListCommand extends Command
$workspace = null; $workspace = null;
if ($workspaceSlug) { if ($workspaceSlug) {
$workspace = Workspace::where('slug', $workspaceSlug)->first(); if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$this->error('Tenant module not installed. Cannot filter by workspace.');
return self::FAILURE;
}
$workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) { if (! $workspace) {
$this->error("Workspace not found: {$workspaceSlug}"); $this->error("Workspace not found: {$workspaceSlug}");

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Config\Console; namespace Core\Config\Console;
use Core\Config\ConfigService; use Core\Config\ConfigService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Console\Command; use Illuminate\Console\Command;
class ConfigPrimeCommand extends Command class ConfigPrimeCommand extends Command
@ -36,7 +35,13 @@ class ConfigPrimeCommand extends Command
} }
if ($workspaceSlug) { if ($workspaceSlug) {
$workspace = Workspace::where('slug', $workspaceSlug)->first(); if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$this->error('Tenant module not installed. Cannot prime workspace config.');
return self::FAILURE;
}
$workspace = \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)->first();
if (! $workspace) { if (! $workspace) {
$this->error("Workspace not found: {$workspaceSlug}"); $this->error("Workspace not found: {$workspaceSlug}");
@ -53,7 +58,15 @@ class ConfigPrimeCommand extends Command
$this->info('Priming config cache for all workspaces...'); $this->info('Priming config cache for all workspaces...');
$this->withProgressBar(Workspace::all(), function ($workspace) use ($config) { if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
$this->warn('Tenant module not installed. Only priming system config.');
$config->prime(null);
$this->info('System config cached.');
return self::SUCCESS;
}
$this->withProgressBar(\Core\Mod\Tenant\Models\Workspace::all(), function ($workspace) use ($config) {
$config->prime($workspace); $config->prime($workspace);
}); });

View file

@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Mod\Tenant\Models\Workspace;
/** /**
* Configuration channel (voice/context substrate). * Configuration channel (voice/context substrate).
@ -71,10 +70,17 @@ class Channel extends Model
/** /**
* Workspace this channel belongs to (null = system channel). * Workspace this channel belongs to (null = system channel).
*
* Requires Core\Mod\Tenant module to be installed.
*/ */
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return $this->belongsTo(\Core\Mod\Tenant\Models\Workspace::class);
}
// Return a null relationship when Tenant module is not installed
return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0');
} }
/** /**

View file

@ -12,7 +12,6 @@ namespace Core\Config\Models;
use Core\Config\ConfigResult; use Core\Config\ConfigResult;
use Core\Config\Enums\ConfigType; use Core\Config\Enums\ConfigType;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -66,10 +65,17 @@ class ConfigResolved extends Model
/** /**
* Workspace this resolution is for (null = system). * Workspace this resolution is for (null = system).
*
* Requires Core\Mod\Tenant module to be installed.
*/ */
public function workspace(): BelongsTo public function workspace(): BelongsTo
{ {
return $this->belongsTo(Workspace::class); if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return $this->belongsTo(\Core\Mod\Tenant\Models\Workspace::class);
}
// Return a null relationship when Tenant module is not installed
return $this->belongsTo(self::class, 'workspace_id')->whereRaw('1 = 0');
} }
/** /**

View file

@ -14,7 +14,6 @@ use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey; use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile; use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue; use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
use Livewire\Component; use Livewire\Component;
@ -68,10 +67,17 @@ class ConfigPanel extends Component
->toArray(); ->toArray();
} }
/**
* Get all workspaces (requires Tenant module).
*/
#[Computed] #[Computed]
public function workspaces(): \Illuminate\Database\Eloquent\Collection public function workspaces(): \Illuminate\Database\Eloquent\Collection
{ {
return Workspace::orderBy('name')->get(); if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return new \Illuminate\Database\Eloquent\Collection;
}
return \Core\Mod\Tenant\Models\Workspace::orderBy('name')->get();
} }
#[Computed] #[Computed]
@ -97,11 +103,16 @@ class ConfigPanel extends Component
return ConfigProfile::ensureSystem(); return ConfigProfile::ensureSystem();
} }
/**
* Get selected workspace (requires Tenant module).
*
* @return object|null Workspace model instance or null
*/
#[Computed] #[Computed]
public function selectedWorkspace(): ?Workspace public function selectedWorkspace(): ?object
{ {
if ($this->workspaceId) { if ($this->workspaceId && class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return Workspace::find($this->workspaceId); return \Core\Mod\Tenant\Models\Workspace::find($this->workspaceId);
} }
return null; return null;

View file

@ -14,8 +14,6 @@ use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey; use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile; use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue; use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
use Livewire\Attributes\Computed; use Livewire\Attributes\Computed;
use Livewire\Attributes\On; use Livewire\Attributes\On;
use Livewire\Component; use Livewire\Component;
@ -26,12 +24,19 @@ class WorkspaceConfig extends Component
protected ConfigService $config; protected ConfigService $config;
protected WorkspaceService $workspaceService; /**
* Workspace service instance (from Tenant module when available).
*/
protected ?object $workspaceService = null;
public function boot(ConfigService $config, WorkspaceService $workspaceService): void public function boot(ConfigService $config): void
{ {
$this->config = $config; $this->config = $config;
$this->workspaceService = $workspaceService;
// Try to resolve WorkspaceService if Tenant module is installed
if (class_exists(\Core\Mod\Tenant\Services\WorkspaceService::class)) {
$this->workspaceService = app(\Core\Mod\Tenant\Services\WorkspaceService::class);
}
} }
public function mount(?string $path = null): void public function mount(?string $path = null): void
@ -55,10 +60,15 @@ class WorkspaceConfig extends Component
unset($this->currentKeys); unset($this->currentKeys);
} }
/**
* Get current workspace (requires Tenant module).
*
* @return object|null Workspace model instance or null
*/
#[Computed] #[Computed]
public function workspace(): ?Workspace public function workspace(): ?object
{ {
return $this->workspaceService->currentModel(); return $this->workspaceService?->currentModel();
} }
#[Computed] #[Computed]

View file

@ -13,13 +13,44 @@ namespace Core\Events;
/** /**
* Fired when the admin panel is being bootstrapped. * Fired when the admin panel is being bootstrapped.
* *
* Modules listen to this event to register: * Modules listen to this event to register admin-specific resources including
* - Admin navigation items * routes, views, Livewire components, and translations for the admin dashboard.
* - Admin routes (wrapped with admin middleware)
* - Admin view namespaces
* - Admin Livewire components
* *
* Only fired for requests to admin routes, not public pages or API calls. * ## When This Event Fires
*
* Fired by `LifecycleEventProvider::fireAdminBooting()` only for requests
* to admin routes. Not fired for public pages, API calls, or client dashboard.
*
* ## Middleware
*
* Routes registered through this event are automatically wrapped with the 'admin'
* middleware group, which typically includes authentication, admin authorization, etc.
*
* ## Navigation Items
*
* For admin navigation, consider implementing `AdminMenuProvider` interface
* for more control over menu items including permissions, entitlements, and groups.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* AdminPanelBooting::class => 'onAdmin',
* ];
*
* public function onAdmin(AdminPanelBooting $event): void
* {
* $event->views('commerce', __DIR__.'/Views/Admin');
* $event->translations('commerce', __DIR__.'/Lang');
* $event->livewire('commerce-dashboard', DashboardComponent::class);
* $event->routes(fn () => require __DIR__.'/Routes/admin.php');
* }
* ```
*
* @package Core\Events
*
* @see AdminMenuProvider For navigation registration
* @see WebRoutesRegistering For public web routes
*/ */
class AdminPanelBooting extends LifecycleEvent class AdminPanelBooting extends LifecycleEvent
{ {

View file

@ -11,12 +11,41 @@ declare(strict_types=1);
namespace Core\Events; namespace Core\Events;
/** /**
* Fired when API routes are being registered. * Fired when REST API routes are being registered.
* *
* Modules listen to this event to register their REST API endpoints. * Modules listen to this event to register their REST API endpoints for
* Routes are automatically wrapped with the 'api' middleware group. * programmatic access by external applications, mobile apps, or SPAs.
* *
* Only fired for API requests, not web or admin requests. * ## When This Event Fires
*
* Fired by `LifecycleEventProvider::fireApiRoutes()` when the API frontage
* initializes, typically for requests to `/api/*` routes.
*
* ## Middleware and Prefix
*
* Routes registered through this event are automatically:
* - Wrapped with the 'api' middleware group (typically stateless, rate limiting)
* - Prefixed with `/api`
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* ApiRoutesRegistering::class => 'onApi',
* ];
*
* public function onApi(ApiRoutesRegistering $event): void
* {
* $event->routes(fn () => require __DIR__.'/Routes/api.php');
* }
* ```
*
* Note: API routes typically don't need views or Livewire components, but
* all LifecycleEvent methods are available if needed.
*
* @package Core\Events
*
* @see WebRoutesRegistering For web routes with session state
*/ */
class ApiRoutesRegistering extends LifecycleEvent class ApiRoutesRegistering extends LifecycleEvent
{ {

View file

@ -11,17 +11,48 @@ declare(strict_types=1);
namespace Core\Events; namespace Core\Events;
/** /**
* Fired when client routes are being registered. * Fired when client dashboard routes are being registered.
* *
* Modules listen to this event to register routes for namespace owners * Modules listen to this event to register routes for namespace owners -
* (authenticated SaaS customers managing their space). * authenticated SaaS customers who manage their own space within the platform.
* *
* Routes are automatically wrapped with the 'client' middleware group. * ## When This Event Fires
* *
* Use this for authenticated namespace management pages: * Fired by `LifecycleEventProvider::fireClientRoutes()` when the client
* - Bio/link editors * frontage initializes, typically for requests to client dashboard routes.
* - Settings pages *
* ## Middleware
*
* Routes registered through this event are automatically wrapped with the 'client'
* middleware group, which typically includes authentication and workspace context.
*
* ## Typical Use Cases
*
* - Bio/link page editors
* - User settings and preferences
* - Analytics dashboards * - Analytics dashboards
* - Content management
* - Billing and subscription management
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* ClientRoutesRegistering::class => 'onClient',
* ];
*
* public function onClient(ClientRoutesRegistering $event): void
* {
* $event->views('bio', __DIR__.'/Views/Client');
* $event->livewire('bio-editor', BioEditorComponent::class);
* $event->routes(fn () => require __DIR__.'/Routes/client.php');
* }
* ```
*
* @package Core\Events
*
* @see AdminPanelBooting For admin/staff routes
* @see WebRoutesRegistering For public-facing routes
*/ */
class ClientRoutesRegistering extends LifecycleEvent class ClientRoutesRegistering extends LifecycleEvent
{ {

View file

@ -11,13 +11,33 @@ declare(strict_types=1);
namespace Core\Events; namespace Core\Events;
/** /**
* Fired when running in console/CLI context. * Fired when the application runs in console/CLI context.
* *
* Modules listen to this event to register Artisan commands. * Modules listen to this event to register Artisan commands for CLI operations
* Commands are registered via the command() method inherited * such as maintenance tasks, data processing, or administrative functions.
* from LifecycleEvent.
* *
* Only fired when running artisan commands, not web requests. * ## When This Event Fires
*
* Fired when the application is invoked via `php artisan`, not during web
* requests. This allows modules to only register commands when actually needed.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* ConsoleBooting::class => 'onConsole',
* ];
*
* public function onConsole(ConsoleBooting $event): void
* {
* $event->command(ProcessOrdersCommand::class);
* $event->command(SyncInventoryCommand::class);
* }
* ```
*
* @package Core\Events
*
* @see QueueWorkerBooting For queue worker specific initialization
*/ */
class ConsoleBooting extends LifecycleEvent class ConsoleBooting extends LifecycleEvent
{ {

View file

@ -13,19 +13,63 @@ namespace Core\Events;
/** /**
* Fired when resolving a domain to a website provider. * Fired when resolving a domain to a website provider.
* *
* Mod Boot classes listen for this event and register themselves * This event enables multi-tenancy by domain, allowing different modules to
* if their domain pattern matches the incoming host. * handle requests based on the incoming hostname. Website modules listen
* to this event and register themselves if their domain pattern matches.
*
* ## When This Event Fires
*
* Fired early in the request lifecycle when the framework needs to determine
* which website provider should handle the current request. Only the first
* provider to register wins.
*
* ## Domain Pattern Matching
*
* Use regex patterns to match domains:
* - `/^example\.com$/` - Exact domain match
* - `/\.example\.com$/` - Subdomain wildcard
* - `/^(www\.)?example\.com$/` - Optional www prefix
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* DomainResolving::class => 'onDomain',
* ];
*
* public function onDomain(DomainResolving $event): void
* {
* if ($event->matches('/^(www\.)?mysite\.com$/')) {
* $event->register(MySiteProvider::class);
* }
* }
* ```
*
* @package Core\Events
*/ */
class DomainResolving class DomainResolving
{ {
/**
* The matched provider class, if any.
*/
protected ?string $matchedProvider = null; protected ?string $matchedProvider = null;
/**
* Create a new DomainResolving event.
*
* @param string $host The incoming request hostname
*/
public function __construct( public function __construct(
public readonly string $host public readonly string $host
) {} ) {}
/** /**
* Check if host matches a domain pattern. * Check if the incoming host matches a regex pattern.
*
* The host is normalized to lowercase before matching.
*
* @param string $pattern Regex pattern to match against (e.g., '/^example\.com$/')
* @return bool True if the pattern matches the host
*/ */
public function matches(string $pattern): bool public function matches(string $pattern): bool
{ {
@ -35,7 +79,12 @@ class DomainResolving
} }
/** /**
* Register as the matching provider. * Register as the matching provider for this domain.
*
* Only the first provider to register wins. Subsequent registrations
* are ignored.
*
* @param string $providerClass Fully qualified provider class name
*/ */
public function register(string $providerClass): void public function register(string $providerClass): void
{ {
@ -43,7 +92,9 @@ class DomainResolving
} }
/** /**
* Get the matched provider (if any). * Get the matched provider class name.
*
* @return string|null Provider class name, or null if no match
*/ */
public function matchedProvider(): ?string public function matchedProvider(): ?string
{ {

View file

@ -15,16 +15,50 @@ use Illuminate\Support\Facades\Log;
/** /**
* Tracks lifecycle event execution for debugging and monitoring. * Tracks lifecycle event execution for debugging and monitoring.
* *
* Records when events fire and which handlers respond. This is useful for: * EventAuditLog records when lifecycle events fire and which handlers respond,
* - Debugging module loading issues * including timing information and success/failure status. This is invaluable for:
* - Performance monitoring
* - Understanding application bootstrap flow
* *
* Usage: * - **Debugging** - Understanding why modules aren't loading
* EventAuditLog::enable(); // Enable logging * - **Performance** - Identifying slow event handlers
* EventAuditLog::enableLog(); // Also write to Laravel log * - **Monitoring** - Tracking application bootstrap flow
* // ... events fire ... * - **Diagnostics** - Finding failed handlers in production
* $entries = EventAuditLog::entries(); // Get recorded entries *
* ## Enabling Audit Logging
*
* Logging is disabled by default for performance. Enable it when needed:
*
* ```php
* EventAuditLog::enable(); // Enable in-memory logging
* EventAuditLog::enableLog(); // Also write to Laravel log channel
* ```
*
* ## Retrieving Entries
*
* ```php
* $entries = EventAuditLog::entries(); // All entries
* $failures = EventAuditLog::failures(); // Only failed handlers
* $webEntries = EventAuditLog::entriesFor(WebRoutesRegistering::class);
* $summary = EventAuditLog::summary(); // Statistics
* ```
*
* ## Entry Structure
*
* Each entry contains:
* - `event` - Event class name
* - `handler` - Handler module class name
* - `duration_ms` - Execution time in milliseconds
* - `failed` - Whether the handler threw an exception
* - `error` - Error message (if failed)
* - `timestamp` - Unix timestamp with microseconds
*
* ## Integration with LazyModuleListener
*
* The `LazyModuleListener` automatically records to EventAuditLog when
* enabled, so you don't need to manually instrument event handlers.
*
* @package Core\Events
*
* @see LazyModuleListener For automatic audit logging integration
*/ */
class EventAuditLog class EventAuditLog
{ {

View file

@ -11,12 +11,47 @@ declare(strict_types=1);
namespace Core\Events; namespace Core\Events;
/** /**
* Fired after the framework has fully booted. * Fired after all service providers have booted.
* *
* Use this for late-stage initialisation that needs the full * This event fires via Laravel's `$app->booted()` callback, after all service
* application context available. Most modules should use more * providers have completed their `boot()` methods. Use this for late-stage
* specific events (AdminPanelBooting, ApiRoutesRegistering, etc.) * initialization that requires the full application context.
* rather than this general event. *
* ## When This Event Fires
*
* Fires after all service providers have booted, regardless of request type
* (web, API, console, queue). This is one of the last events in the bootstrap
* sequence.
*
* ## When to Use This Event
*
* Use FrameworkBooted sparingly. Most modules should prefer context-specific
* events that only fire when relevant:
*
* - **WebRoutesRegistering** - Web routes only
* - **AdminPanelBooting** - Admin requests only
* - **ApiRoutesRegistering** - API requests only
* - **ConsoleBooting** - CLI only
*
* Good use cases for FrameworkBooted:
* - Cross-cutting concerns that apply to all contexts
* - Initialization that depends on other modules being registered
* - Late-binding configuration that needs full container state
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* FrameworkBooted::class => 'onBooted',
* ];
*
* public function onBooted(FrameworkBooted $event): void
* {
* // Late-stage initialization
* }
* ```
*
* @package Core\Events
*/ */
class FrameworkBooted extends LifecycleEvent class FrameworkBooted extends LifecycleEvent
{ {

View file

@ -13,35 +13,86 @@ namespace Core\Events;
/** /**
* Base class for lifecycle events. * Base class for lifecycle events.
* *
* Lifecycle events are fired at key points during application bootstrap. * Lifecycle events are fired at key points during application bootstrap. Modules
* Modules listen to these events via static $listens arrays and register * listen to these events via static `$listens` arrays in their Boot class and
* their resources (routes, views, navigation, etc.) through request methods. * register their resources through the request methods provided here.
* *
* Core collects all requests and processes them with validation, ensuring * ## Request/Collect Pattern
* modules cannot directly mutate infrastructure. *
* This class implements a "request/collect" pattern rather than direct mutation:
*
* 1. **Modules request** resources via methods like `routes()`, `views()`, etc.
* 2. **Requests are collected** in arrays during event dispatch
* 3. **LifecycleEventProvider processes** collected requests with validation
*
* This pattern ensures modules cannot directly mutate infrastructure and allows
* the framework to validate, sort, and process requests centrally.
*
* ## Available Request Methods
*
* | Method | Purpose |
* |--------|---------|
* | `routes()` | Register route files/callbacks |
* | `views()` | Register view namespaces |
* | `livewire()` | Register Livewire components |
* | `middleware()` | Register middleware aliases |
* | `command()` | Register Artisan commands |
* | `translations()` | Register translation namespaces |
* | `bladeComponentPath()` | Register anonymous Blade component paths |
* | `policy()` | Register model policies |
* | `navigation()` | Register navigation items |
*
* ## Usage Example
*
* ```php
* public function onWebRoutes(WebRoutesRegistering $event): void
* {
* $event->views('mymodule', __DIR__.'/Views');
* $event->livewire('my-component', MyComponent::class);
* $event->routes(fn () => require __DIR__.'/Routes/web.php');
* }
* ```
*
* @package Core\Events
*
* @see LifecycleEventProvider For event processing
*/ */
abstract class LifecycleEvent abstract class LifecycleEvent
{ {
/** @var array<int, array<string, mixed>> Collected navigation item requests */
protected array $navigationRequests = []; protected array $navigationRequests = [];
/** @var array<int, callable> Collected route registration callbacks */
protected array $routeRequests = []; protected array $routeRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected view namespace requests [namespace, path] */
protected array $viewRequests = []; protected array $viewRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected middleware alias requests [alias, class] */
protected array $middlewareRequests = []; protected array $middlewareRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected Livewire component requests [alias, class] */
protected array $livewireRequests = []; protected array $livewireRequests = [];
/** @var array<int, string> Collected Artisan command class names */
protected array $commandRequests = []; protected array $commandRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected translation namespace requests [namespace, path] */
protected array $translationRequests = []; protected array $translationRequests = [];
/** @var array<int, array{0: string, 1: string|null}> Collected Blade component path requests [path, namespace] */
protected array $bladeComponentRequests = []; protected array $bladeComponentRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected policy requests [model, policy] */
protected array $policyRequests = []; protected array $policyRequests = [];
/** /**
* Request a navigation item be added. * Request a navigation item be added.
*
* Navigation items are collected and processed by the admin menu system.
* Consider implementing AdminMenuProvider for more control over menu items.
*
* @param array<string, mixed> $item Navigation item configuration
*/ */
public function navigation(array $item): void public function navigation(array $item): void
{ {
@ -50,6 +101,19 @@ abstract class LifecycleEvent
/** /**
* Request routes be registered. * Request routes be registered.
*
* The callback is invoked within the appropriate middleware group
* (web, admin, api, client) depending on which event fired.
*
* ```php
* $event->routes(fn () => require __DIR__.'/Routes/web.php');
* // or
* $event->routes(function () {
* Route::get('/example', ExampleController::class);
* });
* ```
*
* @param callable $callback Route registration callback
*/ */
public function routes(callable $callback): void public function routes(callable $callback): void
{ {
@ -58,6 +122,16 @@ abstract class LifecycleEvent
/** /**
* Request a view namespace be registered. * Request a view namespace be registered.
*
* After registration, views can be referenced as `namespace::view.name`.
*
* ```php
* $event->views('commerce', __DIR__.'/Views');
* // Later: view('commerce::products.index')
* ```
*
* @param string $namespace The view namespace (e.g., 'commerce')
* @param string $path Absolute path to the views directory
*/ */
public function views(string $namespace, string $path): void public function views(string $namespace, string $path): void
{ {
@ -66,6 +140,9 @@ abstract class LifecycleEvent
/** /**
* Request a middleware alias be registered. * Request a middleware alias be registered.
*
* @param string $alias The middleware alias (e.g., 'commerce.auth')
* @param string $class Fully qualified middleware class name
*/ */
public function middleware(string $alias, string $class): void public function middleware(string $alias, string $class): void
{ {
@ -74,6 +151,14 @@ abstract class LifecycleEvent
/** /**
* Request a Livewire component be registered. * Request a Livewire component be registered.
*
* ```php
* $event->livewire('commerce-cart', CartComponent::class);
* // Later: <livewire:commerce-cart />
* ```
*
* @param string $alias The component alias used in Blade templates
* @param string $class Fully qualified Livewire component class name
*/ */
public function livewire(string $alias, string $class): void public function livewire(string $alias, string $class): void
{ {
@ -82,6 +167,10 @@ abstract class LifecycleEvent
/** /**
* Request an Artisan command be registered. * Request an Artisan command be registered.
*
* Only processed during ConsoleBooting event.
*
* @param string $class Fully qualified command class name
*/ */
public function command(string $class): void public function command(string $class): void
{ {
@ -90,6 +179,16 @@ abstract class LifecycleEvent
/** /**
* Request translations be loaded for a namespace. * Request translations be loaded for a namespace.
*
* After registration, translations can be accessed as `namespace::key`.
*
* ```php
* $event->translations('commerce', __DIR__.'/Lang');
* // Later: __('commerce::products.title')
* ```
*
* @param string $namespace The translation namespace
* @param string $path Absolute path to the lang directory
*/ */
public function translations(string $namespace, string $path): void public function translations(string $namespace, string $path): void
{ {
@ -98,6 +197,11 @@ abstract class LifecycleEvent
/** /**
* Request an anonymous Blade component path be registered. * Request an anonymous Blade component path be registered.
*
* Anonymous components in this path can be used in templates.
*
* @param string $path Absolute path to the components directory
* @param string|null $namespace Optional prefix for component names
*/ */
public function bladeComponentPath(string $path, ?string $namespace = null): void public function bladeComponentPath(string $path, ?string $namespace = null): void
{ {
@ -106,6 +210,9 @@ abstract class LifecycleEvent
/** /**
* Request a policy be registered for a model. * Request a policy be registered for a model.
*
* @param string $model Fully qualified model class name
* @param string $policy Fully qualified policy class name
*/ */
public function policy(string $model, string $policy): void public function policy(string $model, string $policy): void
{ {
@ -114,6 +221,10 @@ abstract class LifecycleEvent
/** /**
* Get all navigation requests for processing. * Get all navigation requests for processing.
*
* @return array<int, array<string, mixed>>
*
* @internal Used by LifecycleEventProvider
*/ */
public function navigationRequests(): array public function navigationRequests(): array
{ {
@ -122,6 +233,10 @@ abstract class LifecycleEvent
/** /**
* Get all route requests for processing. * Get all route requests for processing.
*
* @return array<int, callable>
*
* @internal Used by LifecycleEventProvider
*/ */
public function routeRequests(): array public function routeRequests(): array
{ {
@ -130,6 +245,10 @@ abstract class LifecycleEvent
/** /**
* Get all view namespace requests for processing. * Get all view namespace requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function viewRequests(): array public function viewRequests(): array
{ {
@ -138,6 +257,10 @@ abstract class LifecycleEvent
/** /**
* Get all middleware alias requests for processing. * Get all middleware alias requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function middlewareRequests(): array public function middlewareRequests(): array
{ {
@ -146,6 +269,10 @@ abstract class LifecycleEvent
/** /**
* Get all Livewire component requests for processing. * Get all Livewire component requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function livewireRequests(): array public function livewireRequests(): array
{ {
@ -154,6 +281,10 @@ abstract class LifecycleEvent
/** /**
* Get all command requests for processing. * Get all command requests for processing.
*
* @return array<int, string>
*
* @internal Used by LifecycleEventProvider
*/ */
public function commandRequests(): array public function commandRequests(): array
{ {
@ -162,6 +293,10 @@ abstract class LifecycleEvent
/** /**
* Get all translation requests for processing. * Get all translation requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function translationRequests(): array public function translationRequests(): array
{ {
@ -170,6 +305,10 @@ abstract class LifecycleEvent
/** /**
* Get all Blade component path requests for processing. * Get all Blade component path requests for processing.
*
* @return array<int, array{0: string, 1: string|null}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function bladeComponentRequests(): array public function bladeComponentRequests(): array
{ {
@ -178,6 +317,10 @@ abstract class LifecycleEvent
/** /**
* Get all policy requests for processing. * Get all policy requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/ */
public function policyRequests(): array public function policyRequests(): array
{ {

View file

@ -13,18 +13,40 @@ namespace Core\Events;
/** /**
* Fired when mail functionality is needed. * Fired when mail functionality is needed.
* *
* Modules listen to this event to register mail templates, * Modules listen to this event to register mail templates, custom mailers,
* custom mailers, or mail-related services. * or mail-related services. This enables lazy loading of mail dependencies
* until email actually needs to be sent.
* *
* Allows lazy loading of mail dependencies until email * ## When This Event Fires
* actually needs to be sent. *
* Fired when the mail system initializes, typically just before sending
* the first email in a request.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* MailSending::class => 'onMail',
* ];
*
* public function onMail(MailSending $event): void
* {
* $event->mailable(OrderConfirmationMail::class);
* $event->mailable(WelcomeEmail::class);
* }
* ```
*
* @package Core\Events
*/ */
class MailSending extends LifecycleEvent class MailSending extends LifecycleEvent
{ {
/** @var array<int, string> Collected mailable class names */
protected array $mailableRequests = []; protected array $mailableRequests = [];
/** /**
* Register a mailable class. * Register a mailable class.
*
* @param string $class Fully qualified mailable class name
*/ */
public function mailable(string $class): void public function mailable(string $class): void
{ {
@ -32,7 +54,11 @@ class MailSending extends LifecycleEvent
} }
/** /**
* Get all registered mailable classes. * Get all registered mailable class names.
*
* @return array<int, string>
*
* @internal Used by mail system
*/ */
public function mailableRequests(): array public function mailableRequests(): array
{ {

View file

@ -13,20 +13,51 @@ namespace Core\Events;
use Core\Front\Mcp\Contracts\McpToolHandler; use Core\Front\Mcp\Contracts\McpToolHandler;
/** /**
* Fired when MCP tools are being registered. * Fired when MCP (Model Context Protocol) tools are being registered.
* *
* Modules listen to this event to register their MCP tool handlers. * Modules listen to this event to register their MCP tool handlers, which
* Each handler class must implement McpToolHandler interface. * expose functionality to AI assistants and LLM-powered applications.
* *
* Fired at MCP server startup (stdio transport) or when MCP routes * ## When This Event Fires
* are accessed (HTTP transport). *
* Fired by `LifecycleEventProvider::fireMcpTools()` when:
* - MCP server starts up (stdio transport for CLI usage)
* - MCP routes are accessed (HTTP transport for web-based integration)
*
* ## Handler Requirements
*
* Each handler class must implement `McpToolHandler` interface. Handlers
* define the tools, their input schemas, and execution logic.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* McpToolsRegistering::class => 'onMcp',
* ];
*
* public function onMcp(McpToolsRegistering $event): void
* {
* $event->handler(ProductSearchHandler::class);
* $event->handler(InventoryQueryHandler::class);
* }
* ```
*
* @package Core\Events
*
* @see \Core\Front\Mcp\Contracts\McpToolHandler
*/ */
class McpToolsRegistering extends LifecycleEvent class McpToolsRegistering extends LifecycleEvent
{ {
/** @var array<int, string> Collected MCP tool handler class names */
protected array $handlers = []; protected array $handlers = [];
/** /**
* Register an MCP tool handler class. * Register an MCP tool handler class.
*
* @param string $handlerClass Fully qualified class name implementing McpToolHandler
*
* @throws \InvalidArgumentException If class doesn't implement McpToolHandler
*/ */
public function handler(string $handlerClass): void public function handler(string $handlerClass): void
{ {
@ -37,7 +68,11 @@ class McpToolsRegistering extends LifecycleEvent
} }
/** /**
* Get all registered handler classes. * Get all registered handler class names.
*
* @return array<int, string>
*
* @internal Used by LifecycleEventProvider
*/ */
public function handlers(): array public function handlers(): array
{ {

View file

@ -15,15 +15,47 @@ namespace Core\Events;
* *
* Modules listen to this event to provide media handling capabilities * Modules listen to this event to provide media handling capabilities
* such as image processing, video transcoding, CDN integration, etc. * such as image processing, video transcoding, CDN integration, etc.
* This enables lazy loading of heavy media processing dependencies.
* *
* Allows lazy loading of heavy media processing dependencies. * ## When This Event Fires
*
* Fired when the media system initializes, typically when media
* upload or processing is triggered.
*
* ## Processor Types
*
* Register processors by type to handle different media formats:
* - `image` - Image processing (resize, crop, optimize)
* - `video` - Video transcoding and thumbnail generation
* - `audio` - Audio processing and format conversion
* - `document` - Document preview and text extraction
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* MediaRequested::class => 'onMedia',
* ];
*
* public function onMedia(MediaRequested $event): void
* {
* $event->processor('image', ImageProcessor::class);
* $event->processor('video', VideoProcessor::class);
* }
* ```
*
* @package Core\Events
*/ */
class MediaRequested extends LifecycleEvent class MediaRequested extends LifecycleEvent
{ {
/** @var array<string, string> Collected processor registrations [type => class] */
protected array $processorRequests = []; protected array $processorRequests = [];
/** /**
* Register a media processor. * Register a media processor for a specific type.
*
* @param string $type Media type (e.g., 'image', 'video', 'audio')
* @param string $class Fully qualified processor class name
*/ */
public function processor(string $type, string $class): void public function processor(string $type, string $class): void
{ {
@ -32,6 +64,10 @@ class MediaRequested extends LifecycleEvent
/** /**
* Get all registered processors. * Get all registered processors.
*
* @return array<string, string> [type => class]
*
* @internal Used by media system
*/ */
public function processorRequests(): array public function processorRequests(): array
{ {

View file

@ -13,17 +13,47 @@ namespace Core\Events;
/** /**
* Fired when a queue worker is starting up. * Fired when a queue worker is starting up.
* *
* Modules listen to this event to register job classes or * Modules listen to this event to perform queue-specific initialization or
* perform queue-specific initialisation. * register job classes that need explicit registration.
* *
* Only fired in queue worker context, not web requests. * ## When This Event Fires
*
* Fired by `LifecycleEventProvider::fireQueueWorkerBooting()` when the
* application detects it's running in queue worker context (i.e., when
* `queue.worker` is bound in the container).
*
* Not fired during web requests, API calls, or console commands.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* QueueWorkerBooting::class => 'onQueueWorker',
* ];
*
* public function onQueueWorker(QueueWorkerBooting $event): void
* {
* $event->job(ProcessOrderJob::class);
* $event->job(SendNotificationJob::class);
* }
* ```
*
* Note: Most Laravel jobs don't need explicit registration. This event
* is primarily for queue-specific initialization or custom job handling.
*
* @package Core\Events
*
* @see ConsoleBooting For CLI-specific initialization
*/ */
class QueueWorkerBooting extends LifecycleEvent class QueueWorkerBooting extends LifecycleEvent
{ {
/** @var array<int, string> Collected job class names */
protected array $jobRequests = []; protected array $jobRequests = [];
/** /**
* Register a job class. * Register a job class.
*
* @param string $class Fully qualified job class name
*/ */
public function job(string $class): void public function job(string $class): void
{ {
@ -31,7 +61,11 @@ class QueueWorkerBooting extends LifecycleEvent
} }
/** /**
* Get all registered job classes. * Get all registered job class names.
*
* @return array<int, string>
*
* @internal Used by LifecycleEventProvider
*/ */
public function jobRequests(): array public function jobRequests(): array
{ {

View file

@ -13,17 +13,40 @@ namespace Core\Events;
/** /**
* Fired when search functionality is requested. * Fired when search functionality is requested.
* *
* Modules listen to this event to register searchable models * Modules listen to this event to register searchable models or search
* or search providers. * providers. This enables lazy loading of search indexing dependencies
* until search is actually needed.
* *
* Allows lazy loading of search indexing dependencies. * ## When This Event Fires
*
* Fired when the search system initializes, typically when a search
* query is performed or search indexing is triggered.
*
* ## Usage Example
*
* ```php
* public static array $listens = [
* SearchRequested::class => 'onSearch',
* ];
*
* public function onSearch(SearchRequested $event): void
* {
* $event->searchable(Product::class);
* $event->searchable(Article::class);
* }
* ```
*
* @package Core\Events
*/ */
class SearchRequested extends LifecycleEvent class SearchRequested extends LifecycleEvent
{ {
/** @var array<int, string> Collected searchable model class names */
protected array $searchableRequests = []; protected array $searchableRequests = [];
/** /**
* Register a searchable model. * Register a searchable model.
*
* @param string $model Fully qualified model class name
*/ */
public function searchable(string $model): void public function searchable(string $model): void
{ {
@ -31,7 +54,11 @@ class SearchRequested extends LifecycleEvent
} }
/** /**
* Get all registered searchable models. * Get all registered searchable model class names.
*
* @return array<int, string>
*
* @internal Used by search system
*/ */
public function searchableRequests(): array public function searchableRequests(): array
{ {

View file

@ -11,13 +11,46 @@ declare(strict_types=1);
namespace Core\Events; namespace Core\Events;
/** /**
* Fired when web routes are being registered. * Fired when public web routes are being registered.
* *
* Modules listen to this event to register public-facing web routes. * Modules listen to this event to register public-facing web routes such as
* Routes are automatically wrapped with the 'web' middleware group. * marketing pages, product listings, or any routes accessible without authentication.
* *
* Use this for marketing pages, public product pages, etc. * ## When This Event Fires
* For authenticated dashboard routes, use AdminPanelBooting instead. *
* Fired by `LifecycleEventProvider::fireWebRoutes()` when the web frontage
* initializes, typically early in the request lifecycle for web requests.
*
* ## Middleware
*
* Routes registered through this event are automatically wrapped with the 'web'
* middleware group, which typically includes session handling, CSRF protection, etc.
*
* ## Usage Example
*
* ```php
* // In your module's Boot class:
* public static array $listens = [
* WebRoutesRegistering::class => 'onWebRoutes',
* ];
*
* public function onWebRoutes(WebRoutesRegistering $event): void
* {
* $event->views('marketing', __DIR__.'/Views');
* $event->routes(fn () => require __DIR__.'/Routes/web.php');
* }
* ```
*
* ## When to Use Other Events
*
* - **AdminPanelBooting** - For admin dashboard routes
* - **ClientRoutesRegistering** - For authenticated customer/namespace routes
* - **ApiRoutesRegistering** - For REST API endpoints
*
* @package Core\Events
*
* @see AdminPanelBooting For admin routes
* @see ClientRoutesRegistering For client dashboard routes
*/ */
class WebRoutesRegistering extends LifecycleEvent class WebRoutesRegistering extends LifecycleEvent
{ {

View file

@ -12,9 +12,6 @@ namespace Core\Front\Admin;
use Core\Front\Admin\Contracts\AdminMenuProvider; use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Front\Admin\Contracts\DynamicMenuProvider; use Core\Front\Admin\Contracts\DynamicMenuProvider;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
/** /**
@ -92,9 +89,19 @@ class AdminMenuRegistry
*/ */
protected int $cacheTtl; protected int $cacheTtl;
public function __construct( /**
protected EntitlementService $entitlements, * EntitlementService instance (Core\Mod\Tenant\Services\EntitlementService when available).
) { */
protected ?object $entitlements = null;
public function __construct(?object $entitlements = null)
{
if ($entitlements === null && class_exists(\Core\Mod\Tenant\Services\EntitlementService::class)) {
$this->entitlements = app(\Core\Mod\Tenant\Services\EntitlementService::class);
} else {
$this->entitlements = $entitlements;
}
$this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL); $this->cacheTtl = (int) config('core.admin_menu.cache_ttl', self::DEFAULT_CACHE_TTL);
$this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true); $this->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true);
} }
@ -139,12 +146,12 @@ class AdminMenuRegistry
/** /**
* Build the complete menu structure. * Build the complete menu structure.
* *
* @param Workspace|null $workspace Current workspace for entitlement checks * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance)
* @param bool $isAdmin Whether user is admin (Hades) * @param bool $isAdmin Whether user is admin (Hades)
* @param User|null $user The authenticated user for permission checks * @param object|null $user The authenticated user for permission checks (User model instance)
* @return array<int, array> * @return array<int, array>
*/ */
public function build(?Workspace $workspace, bool $isAdmin = false, ?User $user = null): array public function build(?object $workspace, bool $isAdmin = false, ?object $user = null): array
{ {
// Get static items (potentially cached) // Get static items (potentially cached)
$staticItems = $this->getStaticItems($workspace, $isAdmin, $user); $staticItems = $this->getStaticItems($workspace, $isAdmin, $user);
@ -162,9 +169,12 @@ class AdminMenuRegistry
/** /**
* Get static menu items, using cache if enabled. * Get static menu items, using cache if enabled.
* *
* @param object|null $workspace Workspace model instance
* @param bool $isAdmin
* @param object|null $user User model instance
* @return array<string, array<int, array{priority: int, item: \Closure}>> * @return array<string, array<int, array{priority: int, item: \Closure}>>
*/ */
protected function getStaticItems(?Workspace $workspace, bool $isAdmin, ?User $user): array protected function getStaticItems(?object $workspace, bool $isAdmin, ?object $user): array
{ {
if (! $this->cachingEnabled) { if (! $this->cachingEnabled) {
return $this->collectItems($workspace, $isAdmin, $user); return $this->collectItems($workspace, $isAdmin, $user);
@ -180,9 +190,12 @@ class AdminMenuRegistry
/** /**
* Get dynamic menu items from dynamic providers. * Get dynamic menu items from dynamic providers.
* *
* @param object|null $workspace Workspace model instance
* @param bool $isAdmin
* @param object|null $user User model instance
* @return array<string, array<int, array{priority: int, item: \Closure}>> * @return array<string, array<int, array{priority: int, item: \Closure}>>
*/ */
protected function getDynamicItems(?Workspace $workspace, bool $isAdmin, ?User $user): array protected function getDynamicItems(?object $workspace, bool $isAdmin, ?object $user): array
{ {
$grouped = []; $grouped = [];
@ -201,7 +214,7 @@ class AdminMenuRegistry
} }
// Skip if entitlement check fails // Skip if entitlement check fails
if ($entitlement && $workspace) { if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue; continue;
} }
@ -249,8 +262,13 @@ class AdminMenuRegistry
/** /**
* Build the final menu structure from collected items. * Build the final menu structure from collected items.
*
* @param array $allItems
* @param object|null $workspace Workspace model instance
* @param bool $isAdmin
* @return array
*/ */
protected function buildMenuStructure(array $allItems, ?Workspace $workspace, bool $isAdmin): array protected function buildMenuStructure(array $allItems, ?object $workspace, bool $isAdmin): array
{ {
// Build flat structure with dividers // Build flat structure with dividers
$menu = []; $menu = [];
@ -343,8 +361,13 @@ class AdminMenuRegistry
/** /**
* Build the cache key for menu items. * Build the cache key for menu items.
*
* @param object|null $workspace Workspace model instance
* @param bool $isAdmin
* @param object|null $user User model instance
* @return string
*/ */
protected function buildCacheKey(?Workspace $workspace, bool $isAdmin, ?User $user): string protected function buildCacheKey(?object $workspace, bool $isAdmin, ?object $user): string
{ {
$parts = [ $parts = [
self::CACHE_PREFIX, self::CACHE_PREFIX,
@ -367,9 +390,12 @@ class AdminMenuRegistry
/** /**
* Collect items from all providers, filtering by entitlements and permissions. * Collect items from all providers, filtering by entitlements and permissions.
* *
* @param object|null $workspace Workspace model instance
* @param bool $isAdmin
* @param object|null $user User model instance
* @return array<string, array<int, array{priority: int, item: \Closure}>> * @return array<string, array<int, array{priority: int, item: \Closure}>>
*/ */
protected function collectItems(?Workspace $workspace, bool $isAdmin, ?User $user): array protected function collectItems(?object $workspace, bool $isAdmin, ?object $user): array
{ {
$grouped = []; $grouped = [];
@ -391,7 +417,7 @@ class AdminMenuRegistry
} }
// Skip if entitlement check fails // Skip if entitlement check fails
if ($entitlement && $workspace) { if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue; continue;
} }
@ -420,12 +446,12 @@ class AdminMenuRegistry
/** /**
* Check if a user has all required permissions. * Check if a user has all required permissions.
* *
* @param User|null $user * @param object|null $user User model instance
* @param array<string> $permissions * @param array<string> $permissions
* @param Workspace|null $workspace * @param object|null $workspace Workspace model instance
* @return bool * @return bool
*/ */
protected function checkPermissions(?User $user, array $permissions, ?Workspace $workspace): bool protected function checkPermissions(?object $user, array $permissions, ?object $workspace): bool
{ {
if (empty($permissions)) { if (empty($permissions)) {
return true; return true;
@ -448,10 +474,10 @@ class AdminMenuRegistry
/** /**
* Invalidate cached menu for a specific context. * Invalidate cached menu for a specific context.
* *
* @param Workspace|null $workspace * @param object|null $workspace Workspace model instance
* @param User|null $user * @param object|null $user User model instance
*/ */
public function invalidateCache(?Workspace $workspace = null, ?User $user = null): void public function invalidateCache(?object $workspace = null, ?object $user = null): void
{ {
if ($workspace !== null && $user !== null) { if ($workspace !== null && $user !== null) {
// Invalidate specific cache keys // Invalidate specific cache keys
@ -469,8 +495,10 @@ class AdminMenuRegistry
/** /**
* Invalidate all cached menus for a workspace. * Invalidate all cached menus for a workspace.
*
* @param object $workspace Workspace model instance
*/ */
public function invalidateWorkspaceCache(Workspace $workspace): void public function invalidateWorkspaceCache(object $workspace): void
{ {
// We can't easily clear pattern-based cache keys with all drivers, // We can't easily clear pattern-based cache keys with all drivers,
// so we rely on TTL expiration for non-tagged caches // so we rely on TTL expiration for non-tagged caches
@ -481,8 +509,10 @@ class AdminMenuRegistry
/** /**
* Invalidate all cached menus for a user. * Invalidate all cached menus for a user.
*
* @param object $user User model instance
*/ */
public function invalidateUserCache(User $user): void public function invalidateUserCache(object $user): void
{ {
if (method_exists(Cache::getStore(), 'tags')) { if (method_exists(Cache::getStore(), 'tags')) {
Cache::tags([self::CACHE_PREFIX, 'user:' . $user->id])->flush(); Cache::tags([self::CACHE_PREFIX, 'user:' . $user->id])->flush();
@ -512,12 +542,12 @@ class AdminMenuRegistry
/** /**
* Get all service menu items indexed by service key. * Get all service menu items indexed by service key.
* *
* @param Workspace|null $workspace Current workspace for entitlement checks * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance)
* @param bool $isAdmin Whether user is admin (Hades) * @param bool $isAdmin Whether user is admin (Hades)
* @param User|null $user The authenticated user for permission checks * @param object|null $user The authenticated user for permission checks (User model instance)
* @return array<string, array> Service items indexed by service key * @return array<string, array> Service items indexed by service key
*/ */
public function getAllServiceItems(?Workspace $workspace, bool $isAdmin = false, ?User $user = null): array public function getAllServiceItems(?object $workspace, bool $isAdmin = false, ?object $user = null): array
{ {
$services = []; $services = [];
@ -547,7 +577,7 @@ class AdminMenuRegistry
} }
// Skip if entitlement check fails // Skip if entitlement check fails
if ($entitlement && $workspace) { if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue; continue;
} }
@ -578,12 +608,12 @@ class AdminMenuRegistry
* Get a specific service's menu item including its children (tabs). * Get a specific service's menu item including its children (tabs).
* *
* @param string $serviceKey The service identifier (e.g., 'commerce', 'support') * @param string $serviceKey The service identifier (e.g., 'commerce', 'support')
* @param Workspace|null $workspace Current workspace for entitlement checks * @param object|null $workspace Current workspace for entitlement checks (Workspace model instance)
* @param bool $isAdmin Whether user is admin (Hades) * @param bool $isAdmin Whether user is admin (Hades)
* @param User|null $user The authenticated user for permission checks * @param object|null $user The authenticated user for permission checks (User model instance)
* @return array|null The service menu item with children, or null if not found * @return array|null The service menu item with children, or null if not found
*/ */
public function getServiceItem(string $serviceKey, ?Workspace $workspace, bool $isAdmin = false, ?User $user = null): ?array public function getServiceItem(string $serviceKey, ?object $workspace, bool $isAdmin = false, ?object $user = null): ?array
{ {
foreach ($this->providers as $provider) { foreach ($this->providers as $provider) {
// Check provider-level permissions // Check provider-level permissions
@ -611,7 +641,7 @@ class AdminMenuRegistry
} }
// Skip if entitlement check fails // Skip if entitlement check fails
if ($entitlement && $workspace) { if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) { if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue; continue;
} }

View file

@ -10,9 +10,6 @@ declare(strict_types=1);
namespace Core\Front\Admin\Concerns; namespace Core\Front\Admin\Concerns;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
/** /**
* Provides default permission handling for AdminMenuProvider implementations. * Provides default permission handling for AdminMenuProvider implementations.
* *
@ -40,11 +37,11 @@ trait HasMenuPermissions
* By default, checks that the user has all permissions returned by * By default, checks that the user has all permissions returned by
* menuPermissions(). Override for custom logic. * menuPermissions(). Override for custom logic.
* *
* @param User|null $user The authenticated user * @param object|null $user The authenticated user (User model instance)
* @param Workspace|null $workspace The current workspace context * @param object|null $workspace The current workspace context (Workspace model instance)
* @return bool * @return bool
*/ */
public function canViewMenu(?User $user, ?Workspace $workspace): bool public function canViewMenu(?object $user, ?object $workspace): bool
{ {
// No user means no permission (unless we have no requirements) // No user means no permission (unless we have no requirements)
$permissions = $this->menuPermissions(); $permissions = $this->menuPermissions();
@ -73,12 +70,12 @@ trait HasMenuPermissions
* Override this method to customise how permission checks are performed. * Override this method to customise how permission checks are performed.
* By default, uses Laravel's Gate/Authorization system. * By default, uses Laravel's Gate/Authorization system.
* *
* @param User $user * @param object $user User model instance
* @param string $permission * @param string $permission
* @param Workspace|null $workspace * @param object|null $workspace Workspace model instance
* @return bool * @return bool
*/ */
protected function userHasPermission(User $user, string $permission, ?Workspace $workspace): bool protected function userHasPermission(object $user, string $permission, ?object $workspace): bool
{ {
// Check using Laravel's authorization // Check using Laravel's authorization
if (method_exists($user, 'can')) { if (method_exists($user, 'can')) {

View file

@ -10,14 +10,39 @@ declare(strict_types=1);
namespace Core\Front\Admin\Contracts; namespace Core\Front\Admin\Contracts;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
/** /**
* Interface for modules that provide admin menu items. * Interface for modules that provide admin menu items.
* *
* Modules implement this interface and register themselves with AdminMenuRegistry * Modules implement this interface to contribute navigation items to the admin
* during boot. The registry collects all items and builds the final menu structure. * panel sidebar. The `AdminMenuRegistry` collects items from all registered
* providers and builds the final menu structure with proper ordering, grouping,
* and permission filtering.
*
* ## Menu Item Structure
*
* Each item returned by `adminMenuItems()` specifies:
*
* - **group** - Where in the menu hierarchy (`dashboard`, `webhost`, `services`, `settings`, `admin`)
* - **priority** - Order within group (lower = earlier)
* - **entitlement** - Optional feature code for workspace-level access
* - **permissions** - Optional array of required user permissions
* - **admin** - Whether item requires Hades/admin user
* - **item** - Closure returning the actual menu item data (lazy-evaluated)
*
* ## Lazy Evaluation
*
* The `item` closure is only called when the menu is rendered, after permission
* checks pass. This avoids unnecessary work for filtered items and allows
* route-dependent data (like `active` state) to be computed at render time.
*
* ## Registration
*
* Providers are typically registered via `AdminMenuRegistry::register()` during
* the AdminPanelBooting event or in a service provider's boot method.
*
* @package Core\Front\Admin\Contracts
*
* @see DynamicMenuProvider For uncached, real-time menu items
*/ */
interface AdminMenuProvider interface AdminMenuProvider
{ {
@ -81,9 +106,9 @@ interface AdminMenuProvider
* Override this method to implement custom permission logic beyond * Override this method to implement custom permission logic beyond
* simple permission key checks. * simple permission key checks.
* *
* @param User|null $user The authenticated user * @param object|null $user The authenticated user (User model instance)
* @param Workspace|null $workspace The current workspace context * @param object|null $workspace The current workspace context (Workspace model instance)
* @return bool * @return bool
*/ */
public function canViewMenu(?User $user, ?Workspace $workspace): bool; public function canViewMenu(?object $user, ?object $workspace): bool;
} }

View file

@ -10,19 +10,38 @@ declare(strict_types=1);
namespace Core\Front\Admin\Contracts; namespace Core\Front\Admin\Contracts;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
/** /**
* Interface for providers that supply dynamic menu items. * Interface for providers that supply dynamic (uncached) menu items.
* *
* Dynamic menu items are computed at runtime based on context (user, workspace, * Dynamic menu items are computed at runtime based on context and are never
* database state, etc.) and are never cached. Use this interface when menu items * cached. Use this interface when menu items need to reflect real-time data
* need to reflect real-time data such as notification counts, recent items, or * that changes frequently or per-request.
* user-specific content.
* *
* Classes implementing this interface are processed separately from static * ## When to Use DynamicMenuProvider
* AdminMenuProvider items - their results are merged after cache retrieval. *
* - **Notification counts** - Unread messages, pending approvals
* - **Recent items** - Recently accessed documents, pages
* - **User-specific content** - Personalized shortcuts, favorites
* - **Real-time status** - Online users, active sessions
*
* ## Performance Considerations
*
* Dynamic items are computed on every request, so keep the `dynamicMenuItems()`
* method efficient:
*
* - Use eager loading for database queries
* - Cache intermediate results if possible
* - Limit the number of items returned
*
* ## Cache Integration
*
* Static menu items from `AdminMenuProvider` are cached. Dynamic items are
* merged in after cache retrieval. The `dynamicCacheKey()` method can be used
* to invalidate the static cache when dynamic state changes significantly.
*
* @package Core\Front\Admin\Contracts
*
* @see AdminMenuProvider For static (cached) menu items
*/ */
interface DynamicMenuProvider interface DynamicMenuProvider
{ {
@ -35,8 +54,8 @@ interface DynamicMenuProvider
* Each item should include the same structure as AdminMenuProvider::adminMenuItems() * Each item should include the same structure as AdminMenuProvider::adminMenuItems()
* plus an optional 'dynamic' key set to true for identification. * plus an optional 'dynamic' key set to true for identification.
* *
* @param User|null $user The authenticated user * @param object|null $user The authenticated user (User model instance)
* @param Workspace|null $workspace The current workspace context * @param object|null $workspace The current workspace context (Workspace model instance)
* @param bool $isAdmin Whether the user is an admin * @param bool $isAdmin Whether the user is an admin
* @return array<int, array{ * @return array<int, array{
* group: string, * group: string,
@ -48,7 +67,7 @@ interface DynamicMenuProvider
* item: \Closure * item: \Closure
* }> * }>
*/ */
public function dynamicMenuItems(?User $user, ?Workspace $workspace, bool $isAdmin): array; public function dynamicMenuItems(?object $user, ?object $workspace, bool $isAdmin): array;
/** /**
* Get the cache key modifier for dynamic items. * Get the cache key modifier for dynamic items.
@ -57,9 +76,9 @@ interface DynamicMenuProvider
* this key changes. Return null if dynamic items should never affect * this key changes. Return null if dynamic items should never affect
* cache invalidation. * cache invalidation.
* *
* @param User|null $user * @param object|null $user User model instance
* @param Workspace|null $workspace * @param object|null $workspace Workspace model instance
* @return string|null * @return string|null
*/ */
public function dynamicCacheKey(?User $user, ?Workspace $workspace): ?string; public function dynamicCacheKey(?object $user, ?object $workspace): ?string;
} }

View file

@ -13,8 +13,6 @@ namespace Core\Front\Admin\View\Components;
use Core\Front\Admin\AdminMenuRegistry; use Core\Front\Admin\AdminMenuRegistry;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\View\Component; use Illuminate\View\Component;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Services\WorkspaceService;
class Sidemenu extends Component class Sidemenu extends Component
{ {
@ -37,9 +35,17 @@ class Sidemenu extends Component
protected function buildFromRegistry(): array protected function buildFromRegistry(): array
{ {
$user = Auth::user(); $user = Auth::user();
// Use current workspace from session, not default // Use current workspace from session, not default
$workspace = app(WorkspaceService::class)->currentModel(); $workspace = null;
$isAdmin = $user instanceof User && $user->isHades(); if (class_exists(\Core\Mod\Tenant\Services\WorkspaceService::class)) {
$workspace = app(\Core\Mod\Tenant\Services\WorkspaceService::class)->currentModel();
}
$isAdmin = false;
if (class_exists(\Core\Mod\Tenant\Models\User::class) && $user instanceof \Core\Mod\Tenant\Models\User) {
$isAdmin = $user->isHades();
}
return app(AdminMenuRegistry::class)->build($workspace, $isAdmin); return app(AdminMenuRegistry::class)->build($workspace, $isAdmin);
} }

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Front\Mcp; namespace Core\Front\Mcp;
use Closure; use Closure;
use Core\Mod\Agentic\Models\AgentPlan;
/** /**
* Context object passed to MCP tool handlers. * Context object passed to MCP tool handlers.
@ -27,9 +26,12 @@ use Core\Mod\Agentic\Models\AgentPlan;
*/ */
class McpContext class McpContext
{ {
/**
* @param object|null $currentPlan AgentPlan model instance when Agentic module installed
*/
public function __construct( public function __construct(
private ?string $sessionId = null, private ?string $sessionId = null,
private ?AgentPlan $currentPlan = null, private ?object $currentPlan = null,
private ?Closure $notificationCallback = null, private ?Closure $notificationCallback = null,
private ?Closure $logCallback = null, private ?Closure $logCallback = null,
) {} ) {}
@ -52,16 +54,20 @@ class McpContext
/** /**
* Get the current plan if one is active. * Get the current plan if one is active.
*
* @return object|null AgentPlan model instance when Agentic module installed
*/ */
public function getCurrentPlan(): ?AgentPlan public function getCurrentPlan(): ?object
{ {
return $this->currentPlan; return $this->currentPlan;
} }
/** /**
* Set the current plan. * Set the current plan.
*
* @param object|null $plan AgentPlan model instance
*/ */
public function setCurrentPlan(?AgentPlan $plan): void public function setCurrentPlan(?object $plan): void
{ {
$this->currentPlan = $plan; $this->currentPlan = $plan;
} }

View file

@ -12,7 +12,6 @@ namespace Core\Front\Web\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Core\Mod\Tenant\Models\Workspace;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/** /**
@ -76,11 +75,20 @@ class FindDomainRecord
/** /**
* Resolve workspace from the domain. * Resolve workspace from the domain.
*
* Requires Core\Mod\Tenant module to be installed.
*
* @return object|null Workspace model instance or null
*/ */
protected function resolveWorkspaceFromDomain(string $host): ?Workspace protected function resolveWorkspaceFromDomain(string $host): ?object
{ {
// Check if Tenant module is installed
if (! class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return null;
}
// Check for custom domain first // Check for custom domain first
$workspace = Workspace::where('domain', $host)->first(); $workspace = \Core\Mod\Tenant\Models\Workspace::where('domain', $host)->first();
if ($workspace) { if ($workspace) {
return $workspace; return $workspace;
} }
@ -95,7 +103,7 @@ class FindDomainRecord
if (count($parts) >= 1) { if (count($parts) >= 1) {
$workspaceSlug = $parts[0]; $workspaceSlug = $parts[0];
return Workspace::where('slug', $workspaceSlug) return \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)
->where('is_active', true) ->where('is_active', true)
->first(); ->first();
} }

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Helpers; namespace Core\Helpers;
use Core\Mod\Social\Enums\ServiceGroup;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
/** /**
@ -33,10 +32,12 @@ class ServiceCollection
/** /**
* Filter services by group (social, AI, media, miscellaneous). * Filter services by group (social, AI, media, miscellaneous).
* *
* @param ServiceGroup|array<int, ServiceGroup>|null $group Service group(s) to filter by * Requires Core\Mod\Social module to be installed for ServiceGroup enum.
*
* @param object|array<int, object>|null $group Service group(s) to filter by (ServiceGroup enum)
* @return static New collection containing only services in the specified group(s) * @return static New collection containing only services in the specified group(s)
*/ */
public function group(ServiceGroup|array|null $group = null): static public function group(object|array|null $group = null): static
{ {
return new static( return new static(
array_values( array_values(
@ -75,7 +76,7 @@ class ServiceCollection
* - group: The service group enum (social, AI, media, miscellaneous) * - group: The service group enum (social, AI, media, miscellaneous)
* - form: The form configuration array for the service * - form: The form configuration array for the service
* *
* @return array<int, array{name: string, group: ServiceGroup, form: array}> * @return array<int, array{name: string, group: object, form: array}>
*/ */
public function getCollection(): array public function getCollection(): array
{ {

View file

@ -14,24 +14,69 @@ use Core\Events\EventAuditLog;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
/** /**
* Wraps a module method as an event listener. * Wraps a module method as a lazy-loading event listener.
* *
* The module is only instantiated when the event fires, * LazyModuleListener is the key to the framework's lazy loading strategy. Instead of
* enabling lazy loading of modules based on actual usage. * instantiating all modules at boot time, modules are only created when their
* registered events actually fire. This significantly reduces memory usage and
* speeds up application bootstrap for requests that don't use all modules.
* *
* Handles both plain classes and ServiceProviders correctly. * ## How Lazy Loading Works
* Integrates with EventAuditLog for debugging and monitoring.
* *
* Usage: * 1. During registration, a LazyModuleListener wraps each module class name and method
* Event::listen( * 2. The listener is registered with Laravel's event system
* AdminPanelBooting::class, * 3. When an event fires, `__invoke()` is called
* new LazyModuleListener(Commerce\Boot::class, 'registerAdmin') * 4. The module is instantiated via Laravel's container (first time only)
* ); * 5. The specified method is called with the event object
*
* ## ServiceProvider Support
*
* If the module class extends `ServiceProvider`, it's instantiated using
* `$app->resolveProvider()` to ensure proper `$app` injection. Plain classes
* use standard container resolution via `$app->make()`.
*
* ## Instance Caching
*
* Once instantiated, the module instance is cached for the lifetime of the
* LazyModuleListener. This means if the same module listens to multiple events,
* it will be instantiated once per event type.
*
* ## Audit Logging
*
* All event handling is tracked via EventAuditLog when enabled. This records:
* - Event class name
* - Handler module class name
* - Execution duration
* - Success/failure status
*
* ## Usage Example
*
* ```php
* // Typically used by ModuleRegistry, but can be used directly:
* Event::listen(
* AdminPanelBooting::class,
* new LazyModuleListener(Commerce\Boot::class, 'registerAdmin')
* );
* ```
*
* @package Core
*
* @see ModuleRegistry For the automatic registration system
* @see EventAuditLog For execution monitoring
*/ */
class LazyModuleListener class LazyModuleListener
{ {
/**
* Cached module instance (created on first event).
*/
private ?object $instance = null; private ?object $instance = null;
/**
* Create a new lazy module listener.
*
* @param string $moduleClass Fully qualified class name of the module Boot class
* @param string $method Method name to call when the event fires
*/
public function __construct( public function __construct(
private string $moduleClass, private string $moduleClass,
private string $method private string $method
@ -40,8 +85,15 @@ class LazyModuleListener
/** /**
* Handle the event by instantiating the module and calling its method. * Handle the event by instantiating the module and calling its method.
* *
* This is the callable interface for Laravel's event dispatcher. * This is the callable interface for Laravel's event dispatcher. The module
* Records execution to EventAuditLog when enabled. * is instantiated on first call and cached for subsequent events.
*
* Records execution timing and success/failure to EventAuditLog when enabled.
* Any exceptions thrown by the handler are re-thrown after logging.
*
* @param object $event The lifecycle event instance
*
* @throws \Throwable Re-throws any exception from the module handler
*/ */
public function __invoke(object $event): void public function __invoke(object $event): void
{ {

View file

@ -23,29 +23,83 @@ use Illuminate\Support\ServiceProvider;
use Livewire\Livewire; use Livewire\Livewire;
/** /**
* Manages lifecycle events for lazy module loading. * Orchestrates lifecycle events for lazy module loading.
* *
* This provider: * The LifecycleEventProvider is the entry point for the event-driven module system.
* 1. Scans modules for $listens declarations during register() * It coordinates module discovery, listener registration, and event firing at
* 2. Wires up lazy listeners for each event-module pair * appropriate points during the application lifecycle.
* 3. Fires lifecycle events at appropriate times during boot()
* *
* Modules declare interest via static $listens arrays: * ## Lifecycle Phases
* *
* **Registration Phase (register())**
* - Registers ModuleScanner and ModuleRegistry as singletons
* - Scans configured paths for Boot classes with `$listens` declarations
* - Wires lazy listeners for each event-module pair
*
* **Boot Phase (boot())**
* - Fires queue worker event if in queue context
* - Schedules FrameworkBooted event via `$app->booted()`
*
* **Event Firing (static fire* methods)**
* - Called by frontage modules (Web, Admin, Api, etc.) at appropriate times
* - Fire events, collect requests, and process them with appropriate middleware
*
* ## Module Declaration
*
* Modules declare interest in events via static `$listens` arrays in their Boot class:
*
* ```php
* class Boot
* {
* public static array $listens = [ * public static array $listens = [
* WebRoutesRegistering::class => 'onWebRoutes', * WebRoutesRegistering::class => 'onWebRoutes',
* AdminPanelBooting::class => 'onAdmin', * AdminPanelBooting::class => 'onAdmin',
* ConsoleBooting::class => ['onConsole', 10], // With priority
* ]; * ];
* *
* The module is only instantiated when its events fire. * public function onWebRoutes(WebRoutesRegistering $event): void
* {
* $event->routes(fn () => require __DIR__.'/Routes/web.php');
* $event->views('mymodule', __DIR__.'/Views');
* }
* }
* ```
*
* The module is only instantiated when its registered events actually fire,
* enabling efficient lazy loading based on request context.
*
* ## Default Scan Paths
*
* By default, scans these directories under `app_path()`:
* - `Core` - Core system modules
* - `Mod` - Feature modules
* - `Website` - Website/domain-specific modules
*
* @package Core
*
* @see ModuleScanner For module discovery
* @see ModuleRegistry For listener registration
* @see LazyModuleListener For lazy instantiation
*/ */
class LifecycleEventProvider extends ServiceProvider class LifecycleEventProvider extends ServiceProvider
{ {
/** /**
* Directories to scan for modules with $listens declarations. * Directories to scan for modules with $listens declarations.
*
* @var array<string>
*/ */
protected array $scanPaths = []; protected array $scanPaths = [];
/**
* Register module infrastructure and wire lazy listeners.
*
* This method:
* 1. Registers ModuleScanner and ModuleRegistry as singletons
* 2. Configures default scan paths (Core, Mod, Website)
* 3. Triggers module scanning and listener registration
*
* Runs early in the application lifecycle before boot().
*/
public function register(): void public function register(): void
{ {
// Register infrastructure // Register infrastructure
@ -66,6 +120,15 @@ class LifecycleEventProvider extends ServiceProvider
$registry->register($this->scanPaths); $registry->register($this->scanPaths);
} }
/**
* Boot the provider and schedule late-stage events.
*
* Fires queue worker event if running in queue context, and schedules
* the FrameworkBooted event to fire after all providers have booted.
*
* Note: Most lifecycle events (Web, Admin, API, etc.) are fired by their
* respective frontage modules, not here.
*/
public function boot(): void public function boot(): void
{ {
// Console event now fired by Core\Front\Cli\Boot // Console event now fired by Core\Front\Cli\Boot
@ -82,9 +145,18 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire WebRoutesRegistering and process requests. * Fire WebRoutesRegistering and process collected requests.
* *
* Called by Front/Web/Boot when web middleware is being set up. * Called by Front/Web/Boot when web middleware is being set up. This method:
*
* 1. Fires the WebRoutesRegistering event to all listeners
* 2. Processes view namespace requests (adds them to the view finder)
* 3. Processes Livewire component requests (registers with Livewire)
* 4. Processes route requests (wraps with 'web' middleware)
* 5. Refreshes route name and action lookups
*
* Routes registered through this event are automatically wrapped with
* the 'web' middleware group for session, CSRF, etc.
*/ */
public static function fireWebRoutes(): void public static function fireWebRoutes(): void
{ {
@ -116,9 +188,20 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire AdminPanelBooting and process requests. * Fire AdminPanelBooting and process collected requests.
* *
* Called by Front/Admin/Boot when admin routes are being set up. * Called by Front/Admin/Boot when admin routes are being set up. This method:
*
* 1. Fires the AdminPanelBooting event to all listeners
* 2. Processes view namespace requests
* 3. Processes translation namespace requests
* 4. Processes Livewire component requests
* 5. Processes route requests (wraps with 'admin' middleware)
*
* Routes registered through this event are automatically wrapped with
* the 'admin' middleware group for authentication, authorization, etc.
*
* Navigation items are handled separately via AdminMenuProvider interface.
*/ */
public static function fireAdminBooting(): void public static function fireAdminBooting(): void
{ {
@ -159,9 +242,14 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire ClientRoutesRegistering and process requests. * Fire ClientRoutesRegistering and process collected requests.
* *
* Called by Front/Client/Boot when client routes are being set up. * Called by Front/Client/Boot when client dashboard routes are being set up.
* This is for authenticated SaaS customers managing their namespace (bio pages,
* settings, analytics, etc.).
*
* Routes registered through this event are automatically wrapped with
* the 'client' middleware group.
*/ */
public static function fireClientRoutes(): void public static function fireClientRoutes(): void
{ {
@ -193,9 +281,13 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire ApiRoutesRegistering and process requests. * Fire ApiRoutesRegistering and process collected requests.
* *
* Called by Front/Api/Boot when API routes are being set up. * Called by Front/Api/Boot when REST API routes are being set up.
*
* Routes registered through this event are automatically:
* - Wrapped with the 'api' middleware group
* - Prefixed with '/api'
*/ */
public static function fireApiRoutes(): void public static function fireApiRoutes(): void
{ {
@ -209,11 +301,14 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire McpToolsRegistering and return collected handlers. * Fire McpToolsRegistering and return collected handler classes.
* *
* Called by MCP server command when loading tools. * Called by the MCP (Model Context Protocol) server command when loading tools.
* Modules register their MCP tool handlers through this event.
* *
* @return array<string> Handler class names * @return array<string> Fully qualified class names of McpToolHandler implementations
*
* @see \Core\Front\Mcp\Contracts\McpToolHandler
*/ */
public static function fireMcpTools(): array public static function fireMcpTools(): array
{ {
@ -224,7 +319,10 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire ConsoleBooting and process requests. * Fire ConsoleBooting and register collected Artisan commands.
*
* Called when running in CLI context. Modules register their Artisan
* commands through the event's `command()` method.
*/ */
protected function fireConsoleBooting(): void protected function fireConsoleBooting(): void
{ {
@ -238,7 +336,10 @@ class LifecycleEventProvider extends ServiceProvider
} }
/** /**
* Fire QueueWorkerBooting and process requests. * Fire QueueWorkerBooting for queue worker context.
*
* Called when the application is running as a queue worker. Modules can
* use this event for queue-specific initialization.
*/ */
protected function fireQueueWorkerBooting(): void protected function fireQueueWorkerBooting(): void
{ {

View file

@ -13,23 +13,81 @@ namespace Core;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
/** /**
* Manages lazy module registration via events. * Manages lazy module registration via Laravel's event system.
* *
* Scans module directories, extracts $listens declarations, * The ModuleRegistry is the central coordinator for the event-driven module loading
* and wires up lazy listeners for each event-module pair. * system. It uses ModuleScanner to discover modules, then wires up LazyModuleListener
* instances for each event-module pair.
* *
* Listeners are registered in priority order (higher priority runs first). * ## Registration Flow
* *
* Usage: * 1. `register()` is called with paths to scan (typically in a ServiceProvider)
* $registry = new ModuleRegistry(new ModuleScanner()); * 2. ModuleScanner discovers all Boot classes with `$listens` declarations
* $registry->register([app_path('Core'), app_path('Mod')]); * 3. For each event-listener pair, a LazyModuleListener is registered
* 4. Listeners are sorted by priority (highest first) before registration
* 5. When events fire, LazyModuleListener instantiates modules on-demand
*
* ## Priority System
*
* Listeners are sorted by priority before registration with Laravel's event system.
* Higher priority values run first:
*
* - Priority 100: Runs first
* - Priority 0: Default
* - Priority -100: Runs last
*
* ## Usage Example
*
* ```php
* // In a ServiceProvider's register() method:
* $registry = new ModuleRegistry(new ModuleScanner());
* $registry->register([
* app_path('Core'),
* app_path('Mod'),
* app_path('Website'),
* ]);
*
* // Query registered modules:
* $events = $registry->getEvents();
* $modules = $registry->getModules();
* $listeners = $registry->getListenersFor(WebRoutesRegistering::class);
* ```
*
* ## Adding Paths After Initial Registration
*
* Use `addPaths()` to register additional module directories after the initial
* registration (e.g., for dynamically loaded plugins):
*
* ```php
* $registry->addPaths([base_path('plugins/custom-module')]);
* ```
*
* @package Core
*
* @see ModuleScanner For the discovery mechanism
* @see LazyModuleListener For the lazy-loading wrapper
*/ */
class ModuleRegistry class ModuleRegistry
{ {
/**
* Event-to-module mappings discovered by the scanner.
*
* Structure: [EventClass => [ModuleClass => ['method' => string, 'priority' => int]]]
*
* @var array<string, array<string, array{method: string, priority: int}>>
*/
private array $mappings = []; private array $mappings = [];
/**
* Whether initial registration has been performed.
*/
private bool $registered = false; private bool $registered = false;
/**
* Create a new ModuleRegistry instance.
*
* @param ModuleScanner $scanner The scanner used to discover module listeners
*/
public function __construct( public function __construct(
private ModuleScanner $scanner private ModuleScanner $scanner
) {} ) {}

View file

@ -15,19 +15,54 @@ use ReflectionClass;
/** /**
* Scans module Boot.php files for event listener declarations. * Scans module Boot.php files for event listener declarations.
* *
* Reads the static $listens property from Boot classes without * The ModuleScanner is responsible for discovering modules that wish to participate
* instantiating them, enabling lazy loading of modules. * in the lifecycle event system. It reads the static `$listens` property from Boot
* classes without instantiating them, enabling lazy loading of modules.
* *
* Supports priority via array syntax: * ## How It Works
*
* The scanner looks for `Boot.php` files in immediate subdirectories of the given paths.
* Each Boot class can declare a `$listens` array mapping events to handler methods:
*
* ```php
* class Boot
* {
* public static array $listens = [ * public static array $listens = [
* WebRoutesRegistering::class => 'onWebRoutes', // Default priority 0 * WebRoutesRegistering::class => 'onWebRoutes',
* AdminPanelBooting::class => ['onAdmin', 10], // Priority 10 (higher = runs first) * AdminPanelBooting::class => ['onAdmin', 10], // With priority
* ]; * ];
* }
* ```
* *
* Usage: * ## Priority System
* $scanner = new ModuleScanner(); *
* $mappings = $scanner->scan([app_path('Core'), app_path('Mod')]); * Listeners can optionally specify a priority (default: 0). Higher priority values
* // Returns: [EventClass => [ModuleClass => ['method' => 'name', 'priority' => 0]]] * run first. Use array syntax to specify priority:
*
* - `'methodName'` - Default priority 0
* - `['methodName', 10]` - Priority 10 (runs before priority 0)
* - `['methodName', -5]` - Priority -5 (runs after priority 0)
*
* ## Namespace Detection
*
* The scanner automatically determines namespaces based on path:
* - `/Core` paths map to `Core\` namespace
* - `/Mod` paths map to `Mod\` namespace
* - `/Website` paths map to `Website\` namespace
* - `/Plug` paths map to `Plug\` namespace
*
* ## Usage Example
*
* ```php
* $scanner = new ModuleScanner();
* $mappings = $scanner->scan([app_path('Core'), app_path('Mod')]);
* // Returns: [EventClass => [ModuleClass => ['method' => 'name', 'priority' => 0]]]
* ```
*
* @package Core
*
* @see ModuleRegistry For registering discovered listeners with Laravel's event system
* @see LazyModuleListener For the lazy-loading listener wrapper
*/ */
class ModuleScanner class ModuleScanner
{ {
@ -133,10 +168,18 @@ class ModuleScanner
} }
/** /**
* Derive class name from file path. * Derive fully qualified class name from file path.
* *
* Converts: app/Mod/Commerce/Boot.php Mod\Commerce\Boot * Maps file paths to PSR-4 namespaces based on directory structure:
* Converts: app/Core/Cdn/Boot.php Core\Cdn\Boot *
* - `app/Mod/Commerce/Boot.php` becomes `Mod\Commerce\Boot`
* - `app/Core/Cdn/Boot.php` becomes `Core\Cdn\Boot`
* - `app/Website/Acme/Boot.php` becomes `Website\Acme\Boot`
* - `app/Plug/Analytics/Boot.php` becomes `Plug\Analytics\Boot`
*
* @param string $file Absolute path to the Boot.php file
* @param string $basePath Base directory path (e.g., app_path('Mod'))
* @return string|null Fully qualified class name, or null if path doesn't match expected structure
*/ */
private function classFromFile(string $file, string $basePath): ?string private function classFromFile(string $file, string $basePath): ?string
{ {

View file

@ -10,10 +10,6 @@ declare(strict_types=1);
namespace Core\Search; namespace Core\Search;
use Core\Mod\Agentic\Models\AgentPlan;
use Core\Mod\Uptelligence\Models\Asset;
use Core\Mod\Uptelligence\Models\Pattern;
use Core\Mod\Uptelligence\Models\UpstreamTodo;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -253,14 +249,14 @@ class Unified
*/ */
protected function searchPatterns(string $query): Collection protected function searchPatterns(string $query): Collection
{ {
if (! class_exists(Pattern::class)) { if (! class_exists(\Core\Mod\Uptelligence\Models\Pattern::class)) {
return collect(); return collect();
} }
$escaped = $this->escapeLikeQuery($query); $escaped = $this->escapeLikeQuery($query);
try { try {
return Pattern::where('name', 'like', "%{$escaped}%") return \Core\Mod\Uptelligence\Models\Pattern::where('name', 'like', "%{$escaped}%")
->orWhere('description', 'like', "%{$escaped}%") ->orWhere('description', 'like', "%{$escaped}%")
->orWhere('category', 'like', "%{$escaped}%") ->orWhere('category', 'like', "%{$escaped}%")
->limit(20) ->limit(20)
@ -291,14 +287,14 @@ class Unified
*/ */
protected function searchAssets(string $query): Collection protected function searchAssets(string $query): Collection
{ {
if (! class_exists(Asset::class)) { if (! class_exists(\Core\Mod\Uptelligence\Models\Asset::class)) {
return collect(); return collect();
} }
$escaped = $this->escapeLikeQuery($query); $escaped = $this->escapeLikeQuery($query);
try { try {
return Asset::where('name', 'like', "%{$escaped}%") return \Core\Mod\Uptelligence\Models\Asset::where('name', 'like', "%{$escaped}%")
->orWhere('slug', 'like', "%{$escaped}%") ->orWhere('slug', 'like', "%{$escaped}%")
->orWhere('description', 'like', "%{$escaped}%") ->orWhere('description', 'like', "%{$escaped}%")
->limit(20) ->limit(20)
@ -330,14 +326,14 @@ class Unified
*/ */
protected function searchTodos(string $query): Collection protected function searchTodos(string $query): Collection
{ {
if (! class_exists(UpstreamTodo::class)) { if (! class_exists(\Core\Mod\Uptelligence\Models\UpstreamTodo::class)) {
return collect(); return collect();
} }
$escaped = $this->escapeLikeQuery($query); $escaped = $this->escapeLikeQuery($query);
try { try {
return UpstreamTodo::where('title', 'like', "%{$escaped}%") return \Core\Mod\Uptelligence\Models\UpstreamTodo::where('title', 'like', "%{$escaped}%")
->orWhere('description', 'like', "%{$escaped}%") ->orWhere('description', 'like', "%{$escaped}%")
->limit(20) ->limit(20)
->get() ->get()
@ -368,14 +364,14 @@ class Unified
*/ */
protected function searchPlans(string $query): Collection protected function searchPlans(string $query): Collection
{ {
if (! class_exists(AgentPlan::class)) { if (! class_exists(\Core\Mod\Agentic\Models\AgentPlan::class)) {
return collect(); return collect();
} }
$escaped = $this->escapeLikeQuery($query); $escaped = $this->escapeLikeQuery($query);
try { try {
return AgentPlan::where('title', 'like', "%{$escaped}%") return \Core\Mod\Agentic\Models\AgentPlan::where('title', 'like', "%{$escaped}%")
->orWhere('slug', 'like', "%{$escaped}%") ->orWhere('slug', 'like', "%{$escaped}%")
->orWhere('description', 'like', "%{$escaped}%") ->orWhere('description', 'like', "%{$escaped}%")
->limit(20) ->limit(20)

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Seo\Jobs; namespace Core\Seo\Jobs;
use Core\Mod\Web\Models\Page;
use Core\Mod\Web\Services\DynamicOgImageService;
use Illuminate\Bus\Queueable; use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Foundation\Bus\Dispatchable;
@ -59,10 +57,26 @@ class GenerateOgImageJob implements ShouldQueue
/** /**
* Execute the job. * Execute the job.
*
* Requires Core\Mod\Web module to be installed for full functionality.
*/ */
public function handle(DynamicOgImageService $ogService): void public function handle(): void
{ {
$biolink = Page::find($this->biolinkId); // Check if required Web module classes exist
if (! class_exists(\Core\Mod\Web\Models\Page::class)) {
Log::warning('OG image generation skipped: Web module not installed');
return;
}
if (! class_exists(\Core\Mod\Web\Services\DynamicOgImageService::class)) {
Log::warning('OG image generation skipped: DynamicOgImageService not available');
return;
}
$ogService = app(\Core\Mod\Web\Services\DynamicOgImageService::class);
$biolink = \Core\Mod\Web\Models\Page::find($this->biolinkId);
if (! $biolink) { if (! $biolink) {
Log::warning("OG image generation skipped: biolink {$this->biolinkId} not found"); Log::warning("OG image generation skipped: biolink {$this->biolinkId} not found");

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Seo; namespace Core\Seo;
use Core\Mod\Content\Models\ContentItem;
use Core\Seo\Validation\SchemaValidator; use Core\Seo\Validation\SchemaValidator;
/** /**
@ -62,8 +61,10 @@ class Schema
/** /**
* Generate complete JSON-LD schema for a content item. * Generate complete JSON-LD schema for a content item.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function generateSchema(ContentItem $item, array $options = []): array public function generateSchema(object $item, array $options = []): array
{ {
$graph = []; $graph = [];
@ -94,8 +95,10 @@ class Schema
/** /**
* Generate article schema. * Generate article schema.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function articleSchema(ContentItem $item, array $options = []): array public function articleSchema(object $item, array $options = []): array
{ {
$type = $options['type'] ?? 'TechArticle'; $type = $options['type'] ?? 'TechArticle';
$wordCount = str_word_count(strip_tags($item->display_content ?? '')); $wordCount = str_word_count(strip_tags($item->display_content ?? ''));
@ -161,8 +164,10 @@ class Schema
/** /**
* Generate HowTo schema from article content. * Generate HowTo schema from article content.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function howToSchema(ContentItem $item): array public function howToSchema(object $item): array
{ {
$steps = $this->extractSteps($item); $steps = $this->extractSteps($item);
@ -214,8 +219,10 @@ class Schema
/** /**
* Generate breadcrumb schema. * Generate breadcrumb schema.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function breadcrumbSchema(ContentItem $item): array public function breadcrumbSchema(object $item): array
{ {
$workspace = $item->workspace; $workspace = $item->workspace;
$domain = $workspace?->domain ?? $this->baseDomain(); $domain = $workspace?->domain ?? $this->baseDomain();
@ -295,8 +302,10 @@ class Schema
/** /**
* Check if article content contains numbered steps. * Check if article content contains numbered steps.
*
* @param object $item Content item model instance
*/ */
protected function hasSteps(ContentItem $item): bool protected function hasSteps(object $item): bool
{ {
$content = $item->display_content ?? ''; $content = $item->display_content ?? '';
@ -306,8 +315,10 @@ class Schema
/** /**
* Extract steps from article content. * Extract steps from article content.
*
* @param object $item Content item model instance
*/ */
protected function extractSteps(ContentItem $item): array protected function extractSteps(object $item): array
{ {
$content = $item->display_content ?? ''; $content = $item->display_content ?? '';
$steps = []; $steps = [];
@ -340,8 +351,10 @@ class Schema
/** /**
* Extract FAQ from article content. * Extract FAQ from article content.
*
* @param object $item Content item model instance
*/ */
protected function extractFaq(ContentItem $item): ?array protected function extractFaq(object $item): ?array
{ {
$content = $item->display_content ?? ''; $content = $item->display_content ?? '';
$faqs = []; $faqs = [];
@ -367,8 +380,10 @@ class Schema
/** /**
* Get the full URL for an article. * Get the full URL for an article.
*
* @param object $item Content item model instance
*/ */
protected function getArticleUrl(ContentItem $item): string protected function getArticleUrl(object $item): string
{ {
$workspace = $item->workspace; $workspace = $item->workspace;
$domain = $workspace?->domain ?? $this->baseDomain(); $domain = $workspace?->domain ?? $this->baseDomain();
@ -382,8 +397,10 @@ class Schema
/** /**
* Generate an excerpt from content. * Generate an excerpt from content.
*
* @param object $item Content item model instance
*/ */
protected function generateExcerpt(ContentItem $item, int $length = 155): string protected function generateExcerpt(object $item, int $length = 155): string
{ {
$content = strip_tags($item->display_content ?? ''); $content = strip_tags($item->display_content ?? '');
$content = preg_replace('/\s+/', ' ', $content); $content = preg_replace('/\s+/', ' ', $content);
@ -421,9 +438,11 @@ class Schema
/** /**
* Generate schema with validation. * Generate schema with validation.
* *
* @param object $item Content item model instance (expects ContentItem-like interface)
*
* @throws \InvalidArgumentException if schema validation fails * @throws \InvalidArgumentException if schema validation fails
*/ */
public function generateValidatedSchema(ContentItem $item, array $options = []): array public function generateValidatedSchema(object $item, array $options = []): array
{ {
$schema = $this->generateSchema($item, $options); $schema = $this->generateSchema($item, $options);
$result = $this->validate($schema); $result = $this->validate($schema);

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Seo\Services; namespace Core\Seo\Services;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Tenant\Models\Workspace;
use Core\Seo\Validation\SchemaValidator; use Core\Seo\Validation\SchemaValidator;
/** /**
@ -31,8 +29,10 @@ class SchemaBuilderService
{ {
/** /**
* Build Article schema for a content item. * Build Article schema for a content item.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function buildArticleSchema(ContentItem $item): array public function buildArticleSchema(object $item): array
{ {
return [ return [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
@ -55,8 +55,10 @@ class SchemaBuilderService
/** /**
* Build BlogPosting schema (more specific than Article). * Build BlogPosting schema (more specific than Article).
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function buildBlogPostingSchema(ContentItem $item): array public function buildBlogPostingSchema(object $item): array
{ {
$schema = $this->buildArticleSchema($item); $schema = $this->buildArticleSchema($item);
$schema['@type'] = 'BlogPosting'; $schema['@type'] = 'BlogPosting';
@ -73,8 +75,10 @@ class SchemaBuilderService
/** /**
* Build HowTo schema for instructional content. * Build HowTo schema for instructional content.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*/ */
public function buildHowToSchema(ContentItem $item, array $steps): array public function buildHowToSchema(object $item, array $steps): array
{ {
return [ return [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
@ -130,13 +134,15 @@ class SchemaBuilderService
/** /**
* Build Organization schema. * Build Organization schema.
*
* @param object|null $workspace Workspace model instance (expects name and domain properties)
*/ */
public function getOrganizationSchema(?Workspace $workspace = null): array public function getOrganizationSchema(?object $workspace = null): array
{ {
return [ return [
'@type' => 'Organization', '@type' => 'Organization',
'name' => $workspace?->name ?? 'Host UK', 'name' => $workspace?->name ?? 'Host UK',
'url' => $workspace ? "https://{$workspace->domain}" : 'https://host.uk.com', 'url' => $workspace !== null ? "https://{$workspace->domain}" : 'https://host.uk.com',
'logo' => [ 'logo' => [
'@type' => 'ImageObject', '@type' => 'ImageObject',
'url' => 'https://host.uk.com/images/logo.png', 'url' => 'https://host.uk.com/images/logo.png',
@ -146,8 +152,10 @@ class SchemaBuilderService
/** /**
* Build WebSite schema. * Build WebSite schema.
*
* @param object $workspace Workspace model instance (expects name and domain properties)
*/ */
public function buildWebsiteSchema(Workspace $workspace): array public function buildWebsiteSchema(object $workspace): array
{ {
return [ return [
'@context' => 'https://schema.org', '@context' => 'https://schema.org',
@ -243,8 +251,10 @@ class SchemaBuilderService
/** /**
* Get the canonical URL for a content item. * Get the canonical URL for a content item.
*
* @param object $item Content item model instance
*/ */
private function getContentUrl(ContentItem $item): string private function getContentUrl(object $item): string
{ {
$domain = $item->workspace?->domain ?? 'host.uk.com'; $domain = $item->workspace?->domain ?? 'host.uk.com';

View file

@ -15,15 +15,30 @@ use Core\Service\HealthCheckResult;
/** /**
* Contract for services that provide health checks. * Contract for services that provide health checks.
* *
* Services implementing this interface can report their operational * Services implementing this interface can report their operational status
* status for monitoring, load balancing, and alerting purposes. * for monitoring, load balancing, and alerting purposes. Health endpoints
* can aggregate results from all registered HealthCheckable services.
*
* ## Health Check Guidelines
* *
* Health checks should be: * Health checks should be:
* - Fast (< 5 seconds timeout recommended)
* - Non-destructive (read-only operations)
* - Representative of actual service health
* *
* Example implementation: * - **Fast** - Complete within 5 seconds (preferably < 1 second)
* - **Non-destructive** - Perform read-only operations only
* - **Representative** - Actually test the critical dependencies
* - **Safe** - Handle all exceptions and return HealthCheckResult
*
* ## Result States
*
* Use `HealthCheckResult` factory methods:
*
* - `healthy()` - Service is fully operational
* - `degraded()` - Service works but with reduced performance/capability
* - `unhealthy()` - Service is not operational
* - `fromException()` - Convert exception to unhealthy result
*
* ## Example Implementation
*
* ```php * ```php
* public function healthCheck(): HealthCheckResult * public function healthCheck(): HealthCheckResult
* { * {
@ -48,6 +63,10 @@ use Core\Service\HealthCheckResult;
* } * }
* } * }
* ``` * ```
*
* @package Core\Service\Contracts
*
* @see HealthCheckResult For result factory methods
*/ */
interface HealthCheckable interface HealthCheckable
{ {

View file

@ -14,41 +14,59 @@ use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Service\ServiceVersion; use Core\Service\ServiceVersion;
/** /**
* Contract for service definitions. * Contract for SaaS service definitions.
* *
* Services are the product layer - they define how modules are presented * Services are the product layer of the framework - they define how modules are
* to users as SaaS products. Each service has a definition used to populate * presented to users as SaaS products. Each service has a definition that:
* the platform_services table and admin menu registration.
* *
* Extends AdminMenuProvider to integrate with the admin menu system. * - Populates the `platform_services` table for entitlement management
* - Integrates with the admin menu system via `AdminMenuProvider`
* - Provides versioning for API compatibility and deprecation tracking
*
* ## Service Definition Array
*
* The `definition()` method returns an array with service metadata:
*
* ```php
* public static function definition(): array
* {
* return [
* 'code' => 'bio', // Unique service code
* 'module' => 'Mod\\Bio', // Module namespace
* 'name' => 'BioHost', // Display name
* 'tagline' => 'Link in bio pages', // Short description
* 'description' => 'Create beautiful...', // Full description
* 'icon' => 'link', // FontAwesome icon
* 'color' => '#3B82F6', // Brand color
* 'entitlement_code' => 'core.srv.bio', // Access control code
* 'sort_order' => 10, // Menu ordering
* ];
* }
* ```
* *
* ## Versioning * ## Versioning
* *
* Services should implement the version() method to declare their contract * Services should implement `version()` to declare their contract version.
* version. This enables: * This enables tracking breaking changes and deprecation:
* - Tracking breaking changes in service contracts
* - Deprecation warnings before removing features
* - Sunset date enforcement for deprecated versions
* *
* Example:
* ```php * ```php
* public static function version(): ServiceVersion * public static function version(): ServiceVersion
* { * {
* return new ServiceVersion(2, 1, 0); * return new ServiceVersion(2, 1, 0);
* } * }
* ```
* *
* For deprecated services: * // For deprecated services:
* ```php
* public static function version(): ServiceVersion * public static function version(): ServiceVersion
* { * {
* return (new ServiceVersion(1, 0, 0)) * return (new ServiceVersion(1, 0, 0))
* ->deprecate( * ->deprecate('Use ServiceV2 instead', new \DateTimeImmutable('2025-06-01'));
* 'Use ServiceV2 instead',
* new \DateTimeImmutable('2025-06-01')
* );
* } * }
* ``` * ```
*
* @package Core\Service\Contracts
*
* @see AdminMenuProvider For menu integration
* @see ServiceVersion For versioning
*/ */
interface ServiceDefinition extends AdminMenuProvider interface ServiceDefinition extends AdminMenuProvider
{ {