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\StorageOffload;
use Core\Cdn\Services\StorageUrlResolver;
use Core\Plug\Cdn\CdnManager;
use Core\Plug\Storage\StorageManager;
use Illuminate\Support\ServiceProvider;
/**
@ -45,9 +43,13 @@ class Boot extends ServiceProvider
$this->mergeConfigFrom(__DIR__.'/config.php', 'cdn');
$this->mergeConfigFrom(__DIR__.'/offload.php', 'offload');
// Register Plug managers as singletons
$this->app->singleton(CdnManager::class);
$this->app->singleton(StorageManager::class);
// Register Plug managers as singletons (when available)
if (class_exists(\Core\Plug\Cdn\CdnManager::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)
$this->app->singleton(BunnyCdnService::class);

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Cdn\Console;
use Core\Mod\Tenant\Models\Workspace;
use Core\Plug\Cdn\Bunny\Purge;
use Illuminate\Console\Command;
class CdnPurge extends Command
@ -35,12 +33,18 @@ class CdnPurge extends Command
*/
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()
{
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
{
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');
$urls = $this->option('url');
$tag = $this->option('tag');
@ -84,9 +94,13 @@ class CdnPurge extends Command
// Purge by workspace
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(
'What would you like to purge?',
array_merge(['all', 'Select specific URLs'], Workspace::pluck('slug')->toArray()),
$workspaceOptions,
0
);
@ -203,7 +217,13 @@ class CdnPurge extends Command
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()) {
$this->error('No workspaces found');
@ -255,13 +275,19 @@ class CdnPurge extends Command
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) {
$this->error("Workspace not found: {$slug}");
$this->newLine();
$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;
}

View file

@ -12,7 +12,6 @@ namespace Core\Cdn\Console;
use Core\Cdn\Services\FluxCdnService;
use Core\Cdn\Services\StorageUrlResolver;
use Core\Plug\Storage\Bunny\VBucket;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
@ -30,7 +29,10 @@ class PushAssetsToCdn extends Command
protected StorageUrlResolver $cdn;
protected VBucket $vbucket;
/**
* VBucket instance (Core\Plug\Storage\Bunny\VBucket when available).
*/
protected ?object $vbucket = null;
protected bool $dryRun = false;
@ -40,12 +42,18 @@ class PushAssetsToCdn extends Command
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->dryRun = $this->option('dry-run');
// Create vBucket for workspace isolation
$domain = $this->option('domain');
$this->vbucket = VBucket::public($domain);
$this->vbucket = \Core\Plug\Storage\Bunny\VBucket::public($domain);
$pushFlux = $this->option('flux');
$pushFontawesome = $this->option('fontawesome');

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Cdn\Jobs;
use Core\Plug\Storage\StorageManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -55,9 +54,22 @@ class PushAssetToCdn implements ShouldQueue
/**
* 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)) {
Log::debug('PushAssetToCdn: Push disabled, skipping', [
'disk' => $this->disk,

View file

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

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Cdn\Services;
use Core\Config\ConfigService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@ -167,8 +166,10 @@ class BunnyCdnService
/**
* 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}");
}

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
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\Support\Arr;
use Illuminate\Support\Facades\Cache;
@ -21,8 +19,11 @@ use Illuminate\Support\Facades\Cache;
*
* Provides a standardised interface for managing configuration settings
* 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.
@ -58,12 +59,18 @@ abstract class Config implements ConfigContract
/**
* Insert or update configuration in database.
*
* Requires Core\Mod\Social module to be installed.
*
* @param string $name Configuration field name
* @param mixed $payload Value to store
*/
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()],
['payload' => $payload]
);
@ -73,15 +80,22 @@ abstract class Config implements ConfigContract
* Get a configuration value.
*
* 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
*/
public function get(string $name): mixed
{
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}",
default: Arr::get($this->form(), $name)
default: $default
);
$this->putCache($name, $payload);

View file

@ -15,7 +15,6 @@ use Core\Config\Models\Channel;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Support\Collection;
/**
@ -150,11 +149,12 @@ class ConfigResolver
* NOTE: This is the expensive path - only called when lazy-priming.
* 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
*/
public function resolve(
string $keyCode,
?Workspace $workspace = null,
?object $workspace = null,
string|Channel|null $channel = null,
): ConfigResult {
// 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).
*/
/**
* @param object|null $workspace Workspace model instance or null for system scope
*/
protected function resolveJsonSubKey(
string $keyCode,
?Workspace $workspace,
?object $workspace,
string|Channel|null $channel,
): ConfigResult {
// Guard against stack overflow from deep nesting
@ -277,11 +280,12 @@ class ConfigResolver
/**
* Build the channel inheritance chain.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, Channel|null>
*/
public function buildChannelChain(
string|Channel|null $channel,
?Workspace $workspace = null,
?object $workspace = null,
): Collection {
$chain = new Collection;
@ -407,7 +411,7 @@ class ConfigResolver
* Providers supply values from module data without database storage.
*
* @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
{
@ -416,10 +420,12 @@ class ConfigResolver
/**
* Resolve value from virtual providers.
*
* @param object|null $workspace Workspace model instance or null for system scope
*/
public function resolveFromProviders(
string $keyCode,
?Workspace $workspace,
?object $workspace,
string|Channel|null $channel,
): mixed {
foreach ($this->providers as $pattern => $provider) {
@ -455,9 +461,10 @@ class ConfigResolver
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @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 = [];
@ -474,11 +481,12 @@ class ConfigResolver
*
* NOTE: Only called during prime, not normal reads.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, ConfigResult>
*/
public function resolveCategory(
string $category,
?Workspace $workspace = null,
?object $workspace = null,
string|Channel|null $channel = null,
): array {
$results = [];
@ -497,9 +505,10 @@ class ConfigResolver
* Returns profiles ordered from most specific (workspace) to least (system).
* Chain: workspace org system
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return Collection<int, ConfigProfile>
*/
public function buildProfileChain(?Workspace $workspace = null): Collection
public function buildProfileChain(?object $workspace = null): Collection
{
$chain = new Collection;
@ -531,8 +540,10 @@ class ConfigResolver
*
* Stub for now - will connect to Tenant module when org model exists.
* 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) {
return null;
@ -590,10 +601,12 @@ class ConfigResolver
* Check if a key prefix is configured.
*
* 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(
string $prefix,
?Workspace $workspace = null,
?object $workspace = null,
string|Channel|null $channel = null,
): bool {
// 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\ConfigResolved;
use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
/**
* Configuration service - main API.
@ -37,7 +36,10 @@ use Core\Mod\Tenant\Models\Workspace;
*/
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;
@ -47,8 +49,10 @@ class ConfigService
/**
* 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->channel = $channel;
@ -56,8 +60,10 @@ class ConfigService
/**
* Get current workspace context.
*
* @return object|null Workspace model instance or null
*/
public function getWorkspace(): ?Workspace
public function getWorkspace(): ?object
{
return $this->workspace;
}
@ -79,8 +85,10 @@ class ConfigService
* Get config for a specific workspace (admin use only).
*
* 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);
@ -96,11 +104,12 @@ class ConfigService
* 3. Hash lookup again
* 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
*/
public function resolve(
string $key,
?Workspace $workspace = null,
?object $workspace = null,
string|Channel|null $channel = null,
): ConfigResult {
$workspaceId = $workspace?->id;
@ -207,10 +216,12 @@ class ConfigService
/**
* 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(
string $keyCode,
?Workspace $workspace,
?object $workspace,
string|Channel|null $channel,
): ConfigResult {
$parts = explode('.', $keyCode);
@ -394,9 +405,10 @@ class ConfigService
/**
* Get all config values for a workspace.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @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;
$channelId = $this->resolveChannelId($channel, $workspace);
@ -414,11 +426,12 @@ class ConfigService
/**
* Get all config values for a category.
*
* @param object|null $workspace Workspace model instance or null for system scope
* @return array<string, mixed>
*/
public function category(
string $category,
?Workspace $workspace = null,
?object $workspace = null,
string|Channel|null $channel = null,
): array {
$workspaceId = $workspace?->id;
@ -447,8 +460,10 @@ class ConfigService
* Call after workspace creation, config changes, or on schedule.
*
* 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;
$channelId = $this->resolveChannelId($channel, $workspace);
@ -500,7 +515,10 @@ class ConfigService
->delete();
// 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;
$result = $this->resolver->resolve($keyCode, $workspace, $channel);
@ -526,18 +544,21 @@ class ConfigService
* Prime cache for all workspaces.
*
* Run this from a scheduled command or queue job.
* Requires Core\Mod\Tenant module to prime workspace-level config.
*/
public function primeAll(): void
{
// Prime system config
$this->prime(null);
// Prime each workspace
Workspace::chunk(100, function ($workspaces) {
foreach ($workspaces as $workspace) {
$this->prime($workspace);
}
});
// Prime each workspace (requires Tenant module)
if (class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
\Core\Mod\Tenant\Models\Workspace::chunk(100, function ($workspaces) {
foreach ($workspaces as $workspace) {
$this->prime($workspace);
}
});
}
}
/**
@ -545,8 +566,10 @@ class ConfigService
*
* Clears both hash and database. Next read will lazy-prime.
* 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;

View file

@ -12,7 +12,6 @@ namespace Core\Config\Console;
use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Console\Command;
class ConfigListCommand extends Command
@ -33,7 +32,13 @@ class ConfigListCommand extends Command
$workspace = null;
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) {
$this->error("Workspace not found: {$workspaceSlug}");

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Config\Console;
use Core\Config\ConfigService;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Console\Command;
class ConfigPrimeCommand extends Command
@ -36,7 +35,13 @@ class ConfigPrimeCommand extends Command
}
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) {
$this->error("Workspace not found: {$workspaceSlug}");
@ -53,7 +58,15 @@ class ConfigPrimeCommand extends Command
$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);
});

View file

@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Core\Mod\Tenant\Models\Workspace;
/**
* Configuration channel (voice/context substrate).
@ -71,10 +70,17 @@ class Channel extends Model
/**
* Workspace this channel belongs to (null = system channel).
*
* Requires Core\Mod\Tenant module to be installed.
*/
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\Enums\ConfigType;
use Core\Mod\Tenant\Models\Workspace;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@ -66,10 +65,17 @@ class ConfigResolved extends Model
/**
* Workspace this resolution is for (null = system).
*
* Requires Core\Mod\Tenant module to be installed.
*/
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\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Url;
use Livewire\Component;
@ -68,10 +67,17 @@ class ConfigPanel extends Component
->toArray();
}
/**
* Get all workspaces (requires Tenant module).
*/
#[Computed]
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]
@ -97,11 +103,16 @@ class ConfigPanel extends Component
return ConfigProfile::ensureSystem();
}
/**
* Get selected workspace (requires Tenant module).
*
* @return object|null Workspace model instance or null
*/
#[Computed]
public function selectedWorkspace(): ?Workspace
public function selectedWorkspace(): ?object
{
if ($this->workspaceId) {
return Workspace::find($this->workspaceId);
if ($this->workspaceId && class_exists(\Core\Mod\Tenant\Models\Workspace::class)) {
return \Core\Mod\Tenant\Models\Workspace::find($this->workspaceId);
}
return null;

View file

@ -14,8 +14,6 @@ use Core\Config\ConfigService;
use Core\Config\Models\ConfigKey;
use Core\Config\Models\ConfigProfile;
use Core\Config\Models\ConfigValue;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\WorkspaceService;
use Livewire\Attributes\Computed;
use Livewire\Attributes\On;
use Livewire\Component;
@ -26,12 +24,19 @@ class WorkspaceConfig extends Component
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->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
@ -55,10 +60,15 @@ class WorkspaceConfig extends Component
unset($this->currentKeys);
}
/**
* Get current workspace (requires Tenant module).
*
* @return object|null Workspace model instance or null
*/
#[Computed]
public function workspace(): ?Workspace
public function workspace(): ?object
{
return $this->workspaceService->currentModel();
return $this->workspaceService?->currentModel();
}
#[Computed]

View file

@ -13,13 +13,44 @@ namespace Core\Events;
/**
* Fired when the admin panel is being bootstrapped.
*
* Modules listen to this event to register:
* - Admin navigation items
* - Admin routes (wrapped with admin middleware)
* - Admin view namespaces
* - Admin Livewire components
* Modules listen to this event to register admin-specific resources including
* routes, views, Livewire components, and translations for the admin dashboard.
*
* 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
{

View file

@ -11,12 +11,41 @@ declare(strict_types=1);
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.
* Routes are automatically wrapped with the 'api' middleware group.
* Modules listen to this event to register their REST API endpoints for
* 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
{

View file

@ -11,17 +11,48 @@ declare(strict_types=1);
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
* (authenticated SaaS customers managing their space).
* Modules listen to this event to register routes for namespace owners -
* 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:
* - Bio/link editors
* - Settings pages
* Fired by `LifecycleEventProvider::fireClientRoutes()` when the client
* frontage initializes, typically for requests to client dashboard routes.
*
* ## 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
* - 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
{

View file

@ -11,13 +11,33 @@ declare(strict_types=1);
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.
* Commands are registered via the command() method inherited
* from LifecycleEvent.
* Modules listen to this event to register Artisan commands for CLI operations
* such as maintenance tasks, data processing, or administrative functions.
*
* 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
{

View file

@ -13,19 +13,63 @@ namespace Core\Events;
/**
* Fired when resolving a domain to a website provider.
*
* Mod Boot classes listen for this event and register themselves
* if their domain pattern matches the incoming host.
* This event enables multi-tenancy by domain, allowing different modules to
* 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
{
/**
* The matched provider class, if any.
*/
protected ?string $matchedProvider = null;
/**
* Create a new DomainResolving event.
*
* @param string $host The incoming request hostname
*/
public function __construct(
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
{
@ -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
{
@ -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
{

View file

@ -15,16 +15,50 @@ use Illuminate\Support\Facades\Log;
/**
* Tracks lifecycle event execution for debugging and monitoring.
*
* Records when events fire and which handlers respond. This is useful for:
* - Debugging module loading issues
* - Performance monitoring
* - Understanding application bootstrap flow
* EventAuditLog records when lifecycle events fire and which handlers respond,
* including timing information and success/failure status. This is invaluable for:
*
* Usage:
* EventAuditLog::enable(); // Enable logging
* EventAuditLog::enableLog(); // Also write to Laravel log
* // ... events fire ...
* $entries = EventAuditLog::entries(); // Get recorded entries
* - **Debugging** - Understanding why modules aren't loading
* - **Performance** - Identifying slow event handlers
* - **Monitoring** - Tracking application bootstrap flow
* - **Diagnostics** - Finding failed handlers in production
*
* ## 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
{

View file

@ -11,12 +11,47 @@ declare(strict_types=1);
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
* application context available. Most modules should use more
* specific events (AdminPanelBooting, ApiRoutesRegistering, etc.)
* rather than this general event.
* This event fires via Laravel's `$app->booted()` callback, after all service
* providers have completed their `boot()` methods. Use this for late-stage
* initialization that requires the full application context.
*
* ## 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
{

View file

@ -13,35 +13,86 @@ namespace Core\Events;
/**
* Base class for lifecycle events.
*
* Lifecycle events are fired at key points during application bootstrap.
* Modules listen to these events via static $listens arrays and register
* their resources (routes, views, navigation, etc.) through request methods.
* Lifecycle events are fired at key points during application bootstrap. Modules
* listen to these events via static `$listens` arrays in their Boot class and
* register their resources through the request methods provided here.
*
* Core collects all requests and processes them with validation, ensuring
* modules cannot directly mutate infrastructure.
* ## Request/Collect Pattern
*
* 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
{
/** @var array<int, array<string, mixed>> Collected navigation item requests */
protected array $navigationRequests = [];
/** @var array<int, callable> Collected route registration callbacks */
protected array $routeRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected view namespace requests [namespace, path] */
protected array $viewRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected middleware alias requests [alias, class] */
protected array $middlewareRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected Livewire component requests [alias, class] */
protected array $livewireRequests = [];
/** @var array<int, string> Collected Artisan command class names */
protected array $commandRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected translation namespace requests [namespace, path] */
protected array $translationRequests = [];
/** @var array<int, array{0: string, 1: string|null}> Collected Blade component path requests [path, namespace] */
protected array $bladeComponentRequests = [];
/** @var array<int, array{0: string, 1: string}> Collected policy requests [model, policy] */
protected array $policyRequests = [];
/**
* 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
{
@ -50,6 +101,19 @@ abstract class LifecycleEvent
/**
* 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
{
@ -58,6 +122,16 @@ abstract class LifecycleEvent
/**
* 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
{
@ -66,6 +140,9 @@ abstract class LifecycleEvent
/**
* 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
{
@ -74,6 +151,14 @@ abstract class LifecycleEvent
/**
* 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
{
@ -82,6 +167,10 @@ abstract class LifecycleEvent
/**
* 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
{
@ -90,6 +179,16 @@ abstract class LifecycleEvent
/**
* 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
{
@ -98,6 +197,11 @@ abstract class LifecycleEvent
/**
* 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
{
@ -106,6 +210,9 @@ abstract class LifecycleEvent
/**
* 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
{
@ -114,6 +221,10 @@ abstract class LifecycleEvent
/**
* Get all navigation requests for processing.
*
* @return array<int, array<string, mixed>>
*
* @internal Used by LifecycleEventProvider
*/
public function navigationRequests(): array
{
@ -122,6 +233,10 @@ abstract class LifecycleEvent
/**
* Get all route requests for processing.
*
* @return array<int, callable>
*
* @internal Used by LifecycleEventProvider
*/
public function routeRequests(): array
{
@ -130,6 +245,10 @@ abstract class LifecycleEvent
/**
* Get all view namespace requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/
public function viewRequests(): array
{
@ -138,6 +257,10 @@ abstract class LifecycleEvent
/**
* Get all middleware alias requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/
public function middlewareRequests(): array
{
@ -146,6 +269,10 @@ abstract class LifecycleEvent
/**
* Get all Livewire component requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/
public function livewireRequests(): array
{
@ -154,6 +281,10 @@ abstract class LifecycleEvent
/**
* Get all command requests for processing.
*
* @return array<int, string>
*
* @internal Used by LifecycleEventProvider
*/
public function commandRequests(): array
{
@ -162,6 +293,10 @@ abstract class LifecycleEvent
/**
* Get all translation requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/
public function translationRequests(): array
{
@ -170,6 +305,10 @@ abstract class LifecycleEvent
/**
* 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
{
@ -178,6 +317,10 @@ abstract class LifecycleEvent
/**
* Get all policy requests for processing.
*
* @return array<int, array{0: string, 1: string}>
*
* @internal Used by LifecycleEventProvider
*/
public function policyRequests(): array
{

View file

@ -13,18 +13,40 @@ namespace Core\Events;
/**
* Fired when mail functionality is needed.
*
* Modules listen to this event to register mail templates,
* custom mailers, or mail-related services.
* Modules listen to this event to register mail templates, custom mailers,
* 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
* actually needs to be sent.
* ## When This Event Fires
*
* 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
{
/** @var array<int, string> Collected mailable class names */
protected array $mailableRequests = [];
/**
* Register a mailable class.
*
* @param string $class Fully qualified mailable class name
*/
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
{

View file

@ -13,20 +13,51 @@ namespace Core\Events;
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.
* Each handler class must implement McpToolHandler interface.
* Modules listen to this event to register their MCP tool handlers, which
* expose functionality to AI assistants and LLM-powered applications.
*
* Fired at MCP server startup (stdio transport) or when MCP routes
* are accessed (HTTP transport).
* ## When This Event Fires
*
* 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
{
/** @var array<int, string> Collected MCP tool handler class names */
protected array $handlers = [];
/**
* 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
{
@ -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
{

View file

@ -15,15 +15,47 @@ namespace Core\Events;
*
* Modules listen to this event to provide media handling capabilities
* 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
{
/** @var array<string, string> Collected processor registrations [type => class] */
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
{
@ -32,6 +64,10 @@ class MediaRequested extends LifecycleEvent
/**
* Get all registered processors.
*
* @return array<string, string> [type => class]
*
* @internal Used by media system
*/
public function processorRequests(): array
{

View file

@ -13,17 +13,47 @@ namespace Core\Events;
/**
* Fired when a queue worker is starting up.
*
* Modules listen to this event to register job classes or
* perform queue-specific initialisation.
* Modules listen to this event to perform queue-specific initialization or
* 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
{
/** @var array<int, string> Collected job class names */
protected array $jobRequests = [];
/**
* Register a job class.
*
* @param string $class Fully qualified job class name
*/
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
{

View file

@ -13,17 +13,40 @@ namespace Core\Events;
/**
* Fired when search functionality is requested.
*
* Modules listen to this event to register searchable models
* or search providers.
* Modules listen to this event to register searchable models or search
* 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
{
/** @var array<int, string> Collected searchable model class names */
protected array $searchableRequests = [];
/**
* Register a searchable model.
*
* @param string $model Fully qualified model class name
*/
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
{

View file

@ -11,13 +11,46 @@ declare(strict_types=1);
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.
* Routes are automatically wrapped with the 'web' middleware group.
* Modules listen to this event to register public-facing web routes such as
* marketing pages, product listings, or any routes accessible without authentication.
*
* Use this for marketing pages, public product pages, etc.
* For authenticated dashboard routes, use AdminPanelBooting instead.
* ## When This Event Fires
*
* 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
{

View file

@ -12,9 +12,6 @@ namespace Core\Front\Admin;
use Core\Front\Admin\Contracts\AdminMenuProvider;
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;
/**
@ -92,9 +89,19 @@ class AdminMenuRegistry
*/
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->cachingEnabled = (bool) config('core.admin_menu.cache_enabled', true);
}
@ -139,12 +146,12 @@ class AdminMenuRegistry
/**
* 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 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>
*/
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)
$staticItems = $this->getStaticItems($workspace, $isAdmin, $user);
@ -162,9 +169,12 @@ class AdminMenuRegistry
/**
* 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}>>
*/
protected function getStaticItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
protected function getStaticItems(?object $workspace, bool $isAdmin, ?object $user): array
{
if (! $this->cachingEnabled) {
return $this->collectItems($workspace, $isAdmin, $user);
@ -180,9 +190,12 @@ class AdminMenuRegistry
/**
* 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}>>
*/
protected function getDynamicItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
protected function getDynamicItems(?object $workspace, bool $isAdmin, ?object $user): array
{
$grouped = [];
@ -201,7 +214,7 @@ class AdminMenuRegistry
}
// Skip if entitlement check fails
if ($entitlement && $workspace) {
if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue;
}
@ -249,8 +262,13 @@ class AdminMenuRegistry
/**
* 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
$menu = [];
@ -343,8 +361,13 @@ class AdminMenuRegistry
/**
* 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 = [
self::CACHE_PREFIX,
@ -367,9 +390,12 @@ class AdminMenuRegistry
/**
* 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}>>
*/
protected function collectItems(?Workspace $workspace, bool $isAdmin, ?User $user): array
protected function collectItems(?object $workspace, bool $isAdmin, ?object $user): array
{
$grouped = [];
@ -391,7 +417,7 @@ class AdminMenuRegistry
}
// Skip if entitlement check fails
if ($entitlement && $workspace) {
if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue;
}
@ -420,12 +446,12 @@ class AdminMenuRegistry
/**
* Check if a user has all required permissions.
*
* @param User|null $user
* @param object|null $user User model instance
* @param array<string> $permissions
* @param Workspace|null $workspace
* @param object|null $workspace Workspace model instance
* @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)) {
return true;
@ -448,10 +474,10 @@ class AdminMenuRegistry
/**
* Invalidate cached menu for a specific context.
*
* @param Workspace|null $workspace
* @param User|null $user
* @param object|null $workspace Workspace model instance
* @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) {
// Invalidate specific cache keys
@ -469,8 +495,10 @@ class AdminMenuRegistry
/**
* 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,
// so we rely on TTL expiration for non-tagged caches
@ -481,8 +509,10 @@ class AdminMenuRegistry
/**
* 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')) {
Cache::tags([self::CACHE_PREFIX, 'user:' . $user->id])->flush();
@ -512,12 +542,12 @@ class AdminMenuRegistry
/**
* 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 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
*/
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 = [];
@ -547,7 +577,7 @@ class AdminMenuRegistry
}
// Skip if entitlement check fails
if ($entitlement && $workspace) {
if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue;
}
@ -578,12 +608,12 @@ class AdminMenuRegistry
* Get a specific service's menu item including its children (tabs).
*
* @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 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
*/
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) {
// Check provider-level permissions
@ -611,7 +641,7 @@ class AdminMenuRegistry
}
// Skip if entitlement check fails
if ($entitlement && $workspace) {
if ($entitlement && $workspace && $this->entitlements !== null) {
if ($this->entitlements->can($workspace, $entitlement)->isDenied()) {
continue;
}

View file

@ -10,9 +10,6 @@ declare(strict_types=1);
namespace Core\Front\Admin\Concerns;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
/**
* Provides default permission handling for AdminMenuProvider implementations.
*
@ -40,11 +37,11 @@ trait HasMenuPermissions
* By default, checks that the user has all permissions returned by
* menuPermissions(). Override for custom logic.
*
* @param User|null $user The authenticated user
* @param Workspace|null $workspace The current workspace context
* @param object|null $user The authenticated user (User model instance)
* @param object|null $workspace The current workspace context (Workspace model instance)
* @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)
$permissions = $this->menuPermissions();
@ -73,12 +70,12 @@ trait HasMenuPermissions
* Override this method to customise how permission checks are performed.
* By default, uses Laravel's Gate/Authorization system.
*
* @param User $user
* @param object $user User model instance
* @param string $permission
* @param Workspace|null $workspace
* @param object|null $workspace Workspace model instance
* @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
if (method_exists($user, 'can')) {

View file

@ -10,14 +10,39 @@ declare(strict_types=1);
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.
*
* Modules implement this interface and register themselves with AdminMenuRegistry
* during boot. The registry collects all items and builds the final menu structure.
* Modules implement this interface to contribute navigation items to the admin
* 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
{
@ -81,9 +106,9 @@ interface AdminMenuProvider
* Override this method to implement custom permission logic beyond
* simple permission key checks.
*
* @param User|null $user The authenticated user
* @param Workspace|null $workspace The current workspace context
* @param object|null $user The authenticated user (User model instance)
* @param object|null $workspace The current workspace context (Workspace model instance)
* @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;
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,
* database state, etc.) and are never cached. Use this interface when menu items
* need to reflect real-time data such as notification counts, recent items, or
* user-specific content.
* Dynamic menu items are computed at runtime based on context and are never
* cached. Use this interface when menu items need to reflect real-time data
* that changes frequently or per-request.
*
* Classes implementing this interface are processed separately from static
* AdminMenuProvider items - their results are merged after cache retrieval.
* ## When to Use DynamicMenuProvider
*
* - **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
{
@ -35,8 +54,8 @@ interface DynamicMenuProvider
* Each item should include the same structure as AdminMenuProvider::adminMenuItems()
* plus an optional 'dynamic' key set to true for identification.
*
* @param User|null $user The authenticated user
* @param Workspace|null $workspace The current workspace context
* @param object|null $user The authenticated user (User model instance)
* @param object|null $workspace The current workspace context (Workspace model instance)
* @param bool $isAdmin Whether the user is an admin
* @return array<int, array{
* group: string,
@ -48,7 +67,7 @@ interface DynamicMenuProvider
* 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.
@ -57,9 +76,9 @@ interface DynamicMenuProvider
* this key changes. Return null if dynamic items should never affect
* cache invalidation.
*
* @param User|null $user
* @param Workspace|null $workspace
* @param object|null $user User model instance
* @param object|null $workspace Workspace model instance
* @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 Illuminate\Support\Facades\Auth;
use Illuminate\View\Component;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Services\WorkspaceService;
class Sidemenu extends Component
{
@ -37,9 +35,17 @@ class Sidemenu extends Component
protected function buildFromRegistry(): array
{
$user = Auth::user();
// Use current workspace from session, not default
$workspace = app(WorkspaceService::class)->currentModel();
$isAdmin = $user instanceof User && $user->isHades();
$workspace = null;
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);
}

View file

@ -11,7 +11,6 @@ declare(strict_types=1);
namespace Core\Front\Mcp;
use Closure;
use Core\Mod\Agentic\Models\AgentPlan;
/**
* Context object passed to MCP tool handlers.
@ -27,9 +26,12 @@ use Core\Mod\Agentic\Models\AgentPlan;
*/
class McpContext
{
/**
* @param object|null $currentPlan AgentPlan model instance when Agentic module installed
*/
public function __construct(
private ?string $sessionId = null,
private ?AgentPlan $currentPlan = null,
private ?object $currentPlan = null,
private ?Closure $notificationCallback = null,
private ?Closure $logCallback = null,
) {}
@ -52,16 +54,20 @@ class McpContext
/**
* 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;
}
/**
* 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;
}

View file

@ -12,7 +12,6 @@ namespace Core\Front\Web\Middleware;
use Closure;
use Illuminate\Http\Request;
use Core\Mod\Tenant\Models\Workspace;
use Symfony\Component\HttpFoundation\Response;
/**
@ -76,11 +75,20 @@ class FindDomainRecord
/**
* 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
$workspace = Workspace::where('domain', $host)->first();
$workspace = \Core\Mod\Tenant\Models\Workspace::where('domain', $host)->first();
if ($workspace) {
return $workspace;
}
@ -95,7 +103,7 @@ class FindDomainRecord
if (count($parts) >= 1) {
$workspaceSlug = $parts[0];
return Workspace::where('slug', $workspaceSlug)
return \Core\Mod\Tenant\Models\Workspace::where('slug', $workspaceSlug)
->where('is_active', true)
->first();
}

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Helpers;
use Core\Mod\Social\Enums\ServiceGroup;
use Illuminate\Support\Arr;
/**
@ -33,10 +32,12 @@ class ServiceCollection
/**
* 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)
*/
public function group(ServiceGroup|array|null $group = null): static
public function group(object|array|null $group = null): static
{
return new static(
array_values(
@ -75,7 +76,7 @@ class ServiceCollection
* - group: The service group enum (social, AI, media, miscellaneous)
* - 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
{

View file

@ -14,24 +14,69 @@ use Core\Events\EventAuditLog;
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,
* enabling lazy loading of modules based on actual usage.
* LazyModuleListener is the key to the framework's lazy loading strategy. Instead of
* 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.
* Integrates with EventAuditLog for debugging and monitoring.
* ## How Lazy Loading Works
*
* Usage:
* Event::listen(
* AdminPanelBooting::class,
* new LazyModuleListener(Commerce\Boot::class, 'registerAdmin')
* );
* 1. During registration, a LazyModuleListener wraps each module class name and method
* 2. The listener is registered with Laravel's event system
* 3. When an event fires, `__invoke()` is called
* 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
{
/**
* Cached module instance (created on first event).
*/
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(
private string $moduleClass,
private string $method
@ -40,8 +85,15 @@ class LazyModuleListener
/**
* Handle the event by instantiating the module and calling its method.
*
* This is the callable interface for Laravel's event dispatcher.
* Records execution to EventAuditLog when enabled.
* This is the callable interface for Laravel's event dispatcher. The module
* 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
{

View file

@ -23,29 +23,83 @@ use Illuminate\Support\ServiceProvider;
use Livewire\Livewire;
/**
* Manages lifecycle events for lazy module loading.
* Orchestrates lifecycle events for lazy module loading.
*
* This provider:
* 1. Scans modules for $listens declarations during register()
* 2. Wires up lazy listeners for each event-module pair
* 3. Fires lifecycle events at appropriate times during boot()
* The LifecycleEventProvider is the entry point for the event-driven module system.
* It coordinates module discovery, listener registration, and event firing at
* appropriate points during the application lifecycle.
*
* 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 = [
* WebRoutesRegistering::class => 'onWebRoutes',
* 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
{
/**
* Directories to scan for modules with $listens declarations.
*
* @var array<string>
*/
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
{
// Register infrastructure
@ -66,6 +120,15 @@ class LifecycleEventProvider extends ServiceProvider
$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
{
// 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
{
@ -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
{
@ -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
{
@ -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
{
@ -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
{
@ -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
{
@ -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
{

View file

@ -13,23 +13,81 @@ namespace Core;
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,
* and wires up lazy listeners for each event-module pair.
* The ModuleRegistry is the central coordinator for the event-driven module loading
* 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:
* $registry = new ModuleRegistry(new ModuleScanner());
* $registry->register([app_path('Core'), app_path('Mod')]);
* 1. `register()` is called with paths to scan (typically in a ServiceProvider)
* 2. ModuleScanner discovers all Boot classes with `$listens` declarations
* 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
{
/**
* 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 = [];
/**
* Whether initial registration has been performed.
*/
private bool $registered = false;
/**
* Create a new ModuleRegistry instance.
*
* @param ModuleScanner $scanner The scanner used to discover module listeners
*/
public function __construct(
private ModuleScanner $scanner
) {}

View file

@ -15,19 +15,54 @@ use ReflectionClass;
/**
* Scans module Boot.php files for event listener declarations.
*
* Reads the static $listens property from Boot classes without
* instantiating them, enabling lazy loading of modules.
* The ModuleScanner is responsible for discovering modules that wish to participate
* 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 = [
* WebRoutesRegistering::class => 'onWebRoutes', // Default priority 0
* AdminPanelBooting::class => ['onAdmin', 10], // Priority 10 (higher = runs first)
* WebRoutesRegistering::class => 'onWebRoutes',
* AdminPanelBooting::class => ['onAdmin', 10], // With priority
* ];
* }
* ```
*
* Usage:
* $scanner = new ModuleScanner();
* $mappings = $scanner->scan([app_path('Core'), app_path('Mod')]);
* // Returns: [EventClass => [ModuleClass => ['method' => 'name', 'priority' => 0]]]
* ## Priority System
*
* Listeners can optionally specify a priority (default: 0). Higher priority values
* 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
{
@ -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
* Converts: app/Core/Cdn/Boot.php Core\Cdn\Boot
* Maps file paths to PSR-4 namespaces based on directory structure:
*
* - `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
{

View file

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

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Seo\Jobs;
use Core\Mod\Web\Models\Page;
use Core\Mod\Web\Services\DynamicOgImageService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@ -59,10 +57,26 @@ class GenerateOgImageJob implements ShouldQueue
/**
* 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) {
Log::warning("OG image generation skipped: biolink {$this->biolinkId} not found");

View file

@ -10,7 +10,6 @@ declare(strict_types=1);
namespace Core\Seo;
use Core\Mod\Content\Models\ContentItem;
use Core\Seo\Validation\SchemaValidator;
/**
@ -62,8 +61,10 @@ class Schema
/**
* 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 = [];
@ -94,8 +95,10 @@ class 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';
$wordCount = str_word_count(strip_tags($item->display_content ?? ''));
@ -161,8 +164,10 @@ class Schema
/**
* 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);
@ -214,8 +219,10 @@ class 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;
$domain = $workspace?->domain ?? $this->baseDomain();
@ -295,8 +302,10 @@ class Schema
/**
* 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 ?? '';
@ -306,8 +315,10 @@ class Schema
/**
* 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 ?? '';
$steps = [];
@ -340,8 +351,10 @@ class Schema
/**
* 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 ?? '';
$faqs = [];
@ -367,8 +380,10 @@ class Schema
/**
* 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;
$domain = $workspace?->domain ?? $this->baseDomain();
@ -382,8 +397,10 @@ class Schema
/**
* 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 = preg_replace('/\s+/', ' ', $content);
@ -421,9 +438,11 @@ class Schema
/**
* Generate schema with validation.
*
* @param object $item Content item model instance (expects ContentItem-like interface)
*
* @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);
$result = $this->validate($schema);

View file

@ -10,8 +10,6 @@ declare(strict_types=1);
namespace Core\Seo\Services;
use Core\Mod\Content\Models\ContentItem;
use Core\Mod\Tenant\Models\Workspace;
use Core\Seo\Validation\SchemaValidator;
/**
@ -31,8 +29,10 @@ class SchemaBuilderService
{
/**
* 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 [
'@context' => 'https://schema.org',
@ -55,8 +55,10 @@ class SchemaBuilderService
/**
* 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['@type'] = 'BlogPosting';
@ -73,8 +75,10 @@ class SchemaBuilderService
/**
* 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 [
'@context' => 'https://schema.org',
@ -130,13 +134,15 @@ class SchemaBuilderService
/**
* 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 [
'@type' => 'Organization',
'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' => [
'@type' => 'ImageObject',
'url' => 'https://host.uk.com/images/logo.png',
@ -146,8 +152,10 @@ class SchemaBuilderService
/**
* 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 [
'@context' => 'https://schema.org',
@ -243,8 +251,10 @@ class SchemaBuilderService
/**
* 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';

View file

@ -15,15 +15,30 @@ use Core\Service\HealthCheckResult;
/**
* Contract for services that provide health checks.
*
* Services implementing this interface can report their operational
* status for monitoring, load balancing, and alerting purposes.
* Services implementing this interface can report their operational status
* for monitoring, load balancing, and alerting purposes. Health endpoints
* can aggregate results from all registered HealthCheckable services.
*
* ## Health Check Guidelines
*
* 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
* public function healthCheck(): HealthCheckResult
* {
@ -48,6 +63,10 @@ use Core\Service\HealthCheckResult;
* }
* }
* ```
*
* @package Core\Service\Contracts
*
* @see HealthCheckResult For result factory methods
*/
interface HealthCheckable
{

View file

@ -14,41 +14,59 @@ use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Service\ServiceVersion;
/**
* Contract for service definitions.
* Contract for SaaS service definitions.
*
* Services are the product layer - they define how modules are presented
* to users as SaaS products. Each service has a definition used to populate
* the platform_services table and admin menu registration.
* Services are the product layer of the framework - they define how modules are
* presented to users as SaaS products. Each service has a definition that:
*
* 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
*
* Services should implement the version() method to declare their contract
* version. This enables:
* - Tracking breaking changes in service contracts
* - Deprecation warnings before removing features
* - Sunset date enforcement for deprecated versions
* Services should implement `version()` to declare their contract version.
* This enables tracking breaking changes and deprecation:
*
* Example:
* ```php
* public static function version(): ServiceVersion
* {
* return new ServiceVersion(2, 1, 0);
* }
* ```
*
* For deprecated services:
* ```php
* // For deprecated services:
* public static function version(): ServiceVersion
* {
* return (new ServiceVersion(1, 0, 0))
* ->deprecate(
* 'Use ServiceV2 instead',
* new \DateTimeImmutable('2025-06-01')
* );
* ->deprecate('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
{