diff --git a/.claude/skills/core-patterns.md b/.claude/skills/core-patterns.md
new file mode 100644
index 0000000..05dbb94
--- /dev/null
+++ b/.claude/skills/core-patterns.md
@@ -0,0 +1,455 @@
+---
+name: core-patterns
+description: Scaffold Core PHP Framework patterns (Actions, Multi-tenant, Activity Logging, Modules, Seeders)
+---
+
+# Core Patterns Scaffolding
+
+You are helping the user scaffold common Core PHP Framework patterns. This is an interactive skill - gather information through conversation before generating code.
+
+## Start by asking what the user wants to create
+
+Present these options:
+
+1. **Action class** - Single-purpose business logic class
+2. **Multi-tenant model** - Add workspace isolation to a model
+3. **Activity logging** - Add change tracking to a model
+4. **Module** - Create a new module with Boot class
+5. **Seeder** - Create a seeder with dependency ordering
+
+Ask: "What would you like to scaffold? (1-5 or describe what you need)"
+
+---
+
+## Option 1: Action Class
+
+Actions are small, focused classes that do one thing well. They extract complex logic from controllers and Livewire components.
+
+### Gather information
+
+Ask the user for:
+- **Action name** (e.g., `CreateInvoice`, `PublishPost`, `SendNotification`)
+- **Module** (e.g., `Billing`, `Content`, `Notification`)
+- **What it does** (brief description to understand parameters needed)
+
+### Generate the Action
+
+Location: `packages/core-php/src/Mod/{Module}/Actions/{ActionName}.php`
+
+```php
+handle($param1, $param2);
+ *
+ * // Or via static helper:
+ * $result = {ActionName}::run($param1, $param2);
+ */
+class {ActionName}
+{
+ use Action;
+
+ public function __construct(
+ // Inject dependencies here
+ ) {}
+
+ /**
+ * Execute the action.
+ */
+ public function handle(/* parameters */): mixed
+ {
+ // Implementation
+ }
+}
+```
+
+### Key points to explain
+
+- Actions use the `Core\Actions\Action` trait for the static `run()` helper
+- Dependencies are constructor-injected
+- The `handle()` method contains the business logic
+- Can optionally implement `Core\Actions\Actionable` for type-hinting
+- Naming convention: verb + noun (CreateThing, UpdateThing, DeleteThing)
+
+---
+
+## Option 2: Multi-tenant Model
+
+The `BelongsToWorkspace` trait enforces workspace isolation with automatic scoping and caching.
+
+### Gather information
+
+Ask the user for:
+- **Model name** (e.g., `Invoice`, `Project`)
+- **Whether workspace context is always required** (default: yes)
+
+### Migration requirement
+
+Ensure the model's table has a `workspace_id` column:
+
+```php
+$table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
+```
+
+### Add the trait
+
+```php
+where('status', 'paid')->get();
+
+// Cached collection for current workspace
+$invoices = Invoice::ownedByCurrentWorkspaceCached();
+
+// Query for specific workspace
+$invoices = Invoice::forWorkspace($workspace)->get();
+
+// Check ownership
+if ($invoice->belongsToCurrentWorkspace()) {
+ // safe to display
+}
+```
+
+---
+
+## Option 3: Activity Logging
+
+The `LogsActivity` trait wraps spatie/laravel-activitylog with framework defaults and workspace tagging.
+
+### Gather information
+
+Ask the user for:
+- **Model name** to add logging to
+- **Which attributes to log** (all, or specific ones)
+- **Which events to log** (created, updated, deleted - default: all)
+
+### Add the trait
+
+```php
+properties = $activity->properties->merge([
+ 'custom_field' => $this->some_field,
+ ]);
+}
+```
+
+### Key points to explain
+
+- Automatically includes `workspace_id` in activity properties
+- Empty logs are not submitted
+- Uses sensible defaults that can be overridden via model properties
+- Can temporarily disable logging with `Model::withoutActivityLogging(fn() => ...)`
+
+---
+
+## Option 4: Module
+
+Modules are the core organizational unit. Each module has a Boot class that declares which lifecycle events it listens to.
+
+### Gather information
+
+Ask the user for:
+- **Module name** (e.g., `Billing`, `Notifications`)
+- **What the module provides** (web routes, admin panel, API, console commands)
+
+### Create the directory structure
+
+```
+packages/core-php/src/Mod/{ModuleName}/
+├── Boot.php # Module entry point
+├── Models/ # Eloquent models
+├── Actions/ # Business logic
+├── Routes/
+│ ├── web.php # Web routes
+│ └── api.php # API routes
+├── View/
+│ └── Blade/ # Blade views
+├── Console/ # Artisan commands
+├── Database/
+│ ├── Migrations/ # Database migrations
+│ └── Seeders/ # Database seeders
+└── Lang/
+ └── en_GB/ # Translations
+```
+
+### Generate Boot.php
+
+```php
+
+ */
+ public static array $listens = [
+ WebRoutesRegistering::class => 'onWebRoutes',
+ ApiRoutesRegistering::class => 'onApiRoutes',
+ AdminPanelBooting::class => 'onAdminPanel',
+ ConsoleBooting::class => 'onConsole',
+ ];
+
+ public function register(): void
+ {
+ // Register singletons and bindings
+ }
+
+ public function boot(): void
+ {
+ $this->loadMigrationsFrom(__DIR__.'/Database/Migrations');
+ $this->loadTranslationsFrom(__DIR__.'/Lang/en_GB', $this->moduleName);
+ }
+
+ // -------------------------------------------------------------------------
+ // Event-driven handlers
+ // -------------------------------------------------------------------------
+
+ public function onWebRoutes(WebRoutesRegistering $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+
+ if (file_exists(__DIR__.'/Routes/web.php')) {
+ $event->routes(fn () => Route::middleware('web')->group(__DIR__.'/Routes/web.php'));
+ }
+
+ // Register Livewire components
+ // $event->livewire('{module}.component-name', View\Components\ComponentName::class);
+ }
+
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ if (file_exists(__DIR__.'/Routes/api.php')) {
+ $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
+ }
+ }
+
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+ }
+
+ public function onConsole(ConsoleBooting $event): void
+ {
+ // Register commands
+ // $event->command(Console\MyCommand::class);
+ }
+}
+```
+
+### Available lifecycle events
+
+| Event | Purpose | Handler receives |
+|-------|---------|------------------|
+| `WebRoutesRegistering` | Public web routes | views, routes, livewire |
+| `AdminPanelBooting` | Admin panel setup | views, routes |
+| `ApiRoutesRegistering` | REST API routes | routes |
+| `ClientRoutesRegistering` | Authenticated client routes | routes |
+| `ConsoleBooting` | Artisan commands | command, middleware |
+| `McpToolsRegistering` | MCP tools | tools |
+| `FrameworkBooted` | Late initialization | - |
+
+### Key points to explain
+
+- The `$listens` array declares which events trigger which methods
+- Modules are lazy-loaded - only instantiated when their events fire
+- Keep Boot classes thin - delegate to services and actions
+- Use the `$moduleName` for consistent view namespace and translations
+
+---
+
+## Option 5: Seeder with Dependencies
+
+Seeders can declare ordering via attributes for dependencies between seeders.
+
+### Gather information
+
+Ask the user for:
+- **Seeder name** (e.g., `PackageSeeder`, `DemoDataSeeder`)
+- **Module** it belongs to
+- **Dependencies** - which seeders must run before this one
+- **Priority** (optional) - lower numbers run first (default: 50)
+
+### Generate the Seeder
+
+Location: `packages/core-php/src/Mod/{Module}/Database/Seeders/{SeederName}.php`
+
+```php
+ [
- app_path('Core'),
- app_path('Mod'),
- ],
-];
-```
-
-## Creating Modules
-
-Use the artisan commands to scaffold modules:
-
-```bash
-# Create a full module
php artisan make:mod Commerce
-
-# Create a website module (domain-scoped)
-php artisan make:website Marketing
-
-# Create a plugin
-php artisan make:plug Stripe
```
-## Module Structure
-
-Modules are organised with a `Boot.php` entry point:
-
-```
-app/Mod/Commerce/
-├── Boot.php
-├── Routes/
-│ ├── web.php
-│ ├── admin.php
-│ └── api.php
-├── Views/
-└── config.php
-```
-
-## Lifecycle Events
-
-Modules declare interest in lifecycle events via a static `$listens` array:
+This creates a module at `app/Mod/Commerce/` with a `Boot.php` entry point:
```php
routes(fn () => require __DIR__.'/routes.php');
+use Core\Actions\Action;
-// Register view namespace
-$event->views('namespace', __DIR__.'/Views');
+class CreateOrder
+{
+ use Action;
-// Register Livewire component
-$event->livewire('alias', ComponentClass::class);
+ public function handle(User $user, array $data): Order
+ {
+ // Business logic here
+ return Order::create($data);
+ }
+}
-// Register navigation item
-$event->navigation(['label' => 'Products', 'icon' => 'box']);
-
-// Register Artisan command (ConsoleBooting)
-$event->command(MyCommand::class);
-
-// Register middleware alias
-$event->middleware('alias', MiddlewareClass::class);
-
-// Register translations
-$event->translations('namespace', __DIR__.'/lang');
-
-// Register Blade component path
-$event->bladeComponentPath(__DIR__.'/components', 'prefix');
-
-// Register policy
-$event->policy(Model::class, Policy::class);
+// Usage
+$order = CreateOrder::run($user, $validated);
```
-## Firing Events
+### Multi-Tenant Isolation
-Create frontage service providers to fire events at appropriate times:
+Automatic workspace scoping for models:
```php
-use Core\LifecycleEventProvider;
+use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
-class WebServiceProvider extends ServiceProvider
+class Product extends Model
{
- public function boot(): void
- {
- LifecycleEventProvider::fireWebRoutes();
- }
+ use BelongsToWorkspace;
+}
+
+// Queries are automatically scoped to the current workspace
+$products = Product::all();
+
+// workspace_id is auto-assigned on create
+$product = Product::create(['name' => 'Widget']);
+```
+
+### Activity Logging
+
+Track model changes with minimal setup:
+
+```php
+use Core\Activity\Concerns\LogsActivity;
+
+class Order extends Model
+{
+ use LogsActivity;
+
+ protected array $activityLogAttributes = ['status', 'total'];
}
```
-## Lazy Loading
+### HLCRF Layout System
-Modules are only instantiated when their subscribed events fire. A web request doesn't load admin-only modules. An API request doesn't load web modules. This keeps your application fast.
-
-## Custom Namespace Mapping
-
-For non-standard directory structures:
+Data-driven layouts with infinite nesting:
```php
-$scanner = app(ModuleScanner::class);
-$scanner->setNamespaceMap([
- 'CustomMod' => 'App\\CustomMod',
-]);
+use Core\Front\Components\Layout;
+
+$page = Layout::make('HCF')
+ ->h('')
+ ->c('Main content')
+ ->f('');
+
+echo $page;
```
-## Contracts
+Variant strings define structure: `HCF` (Header-Content-Footer), `HLCRF` (all five regions), `H[LC]CF` (nested layouts).
-### AdminMenuProvider
+See [HLCRF.md](packages/core-php/src/Core/Front/HLCRF.md) for full documentation.
-Implement for admin navigation:
+## Configuration
+
+Publish the config file:
+
+```bash
+php artisan vendor:publish --tag=core-config
+```
+
+Configure module paths in `config/core.php`:
```php
-use Core\Front\Admin\Contracts\AdminMenuProvider;
-
-class Boot implements AdminMenuProvider
-{
- public function adminMenuItems(): array
- {
- return [
- [
- 'group' => 'services',
- 'priority' => 20,
- 'item' => fn () => [
- 'label' => 'Products',
- 'icon' => 'box',
- 'href' => route('admin.products.index'),
- ],
- ],
- ];
- }
-}
+return [
+ 'module_paths' => [
+ app_path('Core'),
+ app_path('Mod'),
+ ],
+];
```
-### ServiceDefinition
+## Artisan Commands
-For SaaS service registration:
-
-```php
-use Core\Service\Contracts\ServiceDefinition;
-
-class Boot implements ServiceDefinition
-{
- public static function definition(): array
- {
- return [
- 'code' => 'commerce',
- 'module' => 'Commerce',
- 'name' => 'Commerce',
- 'tagline' => 'E-commerce platform',
- ];
- }
-}
+```bash
+php artisan make:mod Commerce # Create a module
+php artisan make:website Marketing # Create a website module
+php artisan make:plug Stripe # Create a plugin
```
+## Module Structure
+
+```
+app/Mod/Commerce/
+├── Boot.php # Module entry point
+├── Actions/ # Business logic
+├── Models/ # Eloquent models
+├── Routes/
+│ ├── web.php
+│ ├── admin.php
+│ └── api.php
+├── Views/
+├── Migrations/
+└── config.php
+```
+
+## Documentation
+
+- [Patterns Guide](docs/patterns.md) - Detailed documentation for all framework patterns
+- [HLCRF Layout System](packages/core-php/src/Core/Front/HLCRF.md) - Composable layout documentation
+
## Testing
```bash
composer test
```
+## Requirements
+
+- PHP 8.2+
+- Laravel 11+
+
## License
-EUPL-1.2. See [LICENSE](LICENSE) for details.
+EUPL-1.2 - See [LICENSE](LICENSE) for details.
diff --git a/TODO.md b/TODO.md
new file mode 100644
index 0000000..6dda50f
--- /dev/null
+++ b/TODO.md
@@ -0,0 +1,45 @@
+# Core PHP Framework - TODO
+
+## Code Cleanup
+
+- [ ] **ApiExplorer** - Update biolinks endpoint examples
+
+---
+
+## Completed (January 2026)
+
+### Security Fixes
+
+- [x] **MCP: Database Connection Fallback** - Fixed to throw exception instead of silently falling back to default connection
+ - See: `packages/core-mcp/changelog/2026/jan/security.md`
+
+- [x] **MCP: SQL Validator Regex** - Strengthened WHERE clause patterns to prevent SQL injection vectors
+ - See: `packages/core-mcp/changelog/2026/jan/security.md`
+
+### Features
+
+- [x] **MCP: EXPLAIN Plan** - Added query optimization analysis with human-readable performance insights
+ - See: `packages/core-mcp/changelog/2026/jan/features.md`
+
+- [x] **CDN: Integration Tests** - Comprehensive test suite for CDN operations and asset pipeline
+ - See: `packages/core-php/changelog/2026/jan/features.md`
+
+### Documentation & Code Quality
+
+- [x] **API docs** - Genericized vendor-specific content (removed Host UK branding, lt.hn references)
+ - See: `packages/core-api/changelog/2026/jan/features.md`
+
+- [x] **Admin: Route Audit** - Verified admin routes use Livewire modals instead of traditional controllers; #[Action] attributes not applicable
+
+- [x] **ServicesAdmin** - Reviewed stubbed bio service methods; intentionally stubbed pending module extraction (documented with TODO comments)
+
+---
+
+## Package Changelogs
+
+For complete feature lists and implementation details:
+- `packages/core-php/changelog/2026/jan/features.md`
+- `packages/core-admin/changelog/2026/jan/features.md`
+- `packages/core-api/changelog/2026/jan/features.md`
+- `packages/core-mcp/changelog/2026/jan/features.md`
+- `packages/core-mcp/changelog/2026/jan/security.md` ⚠️ Security fixes
diff --git a/bootstrap/app.php b/bootstrap/app.php
new file mode 100644
index 0000000..d654276
--- /dev/null
+++ b/bootstrap/app.php
@@ -0,0 +1,19 @@
+withRouting(
+ web: __DIR__.'/../routes/web.php',
+ api: __DIR__.'/../routes/api.php',
+ commands: __DIR__.'/../routes/console.php',
+ health: '/up',
+ )
+ ->withMiddleware(function (Middleware $middleware) {
+ //
+ })
+ ->withExceptions(function (Exceptions $exceptions) {
+ //
+ })->create();
diff --git a/composer.json b/composer.json
index d8b3e9f..b0b7486 100644
--- a/composer.json
+++ b/composer.json
@@ -39,6 +39,8 @@
"psr-4": {
"Tests\\": "tests/",
"Core\\Tests\\": "packages/core-php/tests/",
+ "Core\\Mod\\Mcp\\Tests\\": "packages/core-mcp/tests/",
+ "Core\\Mod\\Tenant\\Tests\\": "packages/core-php/src/Mod/Tenant/Tests/",
"Mod\\": "packages/core-php/tests/Fixtures/Mod/",
"Plug\\": "packages/core-php/tests/Fixtures/Plug/",
"Website\\": "packages/core-php/tests/Fixtures/Website/"
diff --git a/config/database.php b/config/database.php
index df933e7..033b966 100644
--- a/config/database.php
+++ b/config/database.php
@@ -113,6 +113,43 @@ return [
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
],
+ /*
+ |--------------------------------------------------------------------------
+ | MCP Read-Only Connection
+ |--------------------------------------------------------------------------
+ |
+ | This connection is used by the MCP QueryDatabase tool. It should be
+ | configured with a database user that has SELECT-only permissions.
+ |
+ | For MySQL, create a read-only user:
+ | CREATE USER 'mcp_readonly'@'localhost' IDENTIFIED BY 'password';
+ | GRANT SELECT ON your_database.* TO 'mcp_readonly'@'localhost';
+ | FLUSH PRIVILEGES;
+ |
+ | If MCP_DB_CONNECTION is not set, this falls back to the default connection.
+ | In production, always configure a dedicated read-only user.
+ |
+ */
+ 'mcp_readonly' => [
+ 'driver' => env('MCP_DB_DRIVER', env('DB_CONNECTION', 'mysql')),
+ 'url' => env('MCP_DB_URL'),
+ 'host' => env('MCP_DB_HOST', env('DB_HOST', '127.0.0.1')),
+ 'port' => env('MCP_DB_PORT', env('DB_PORT', '3306')),
+ 'database' => env('MCP_DB_DATABASE', env('DB_DATABASE', 'laravel')),
+ 'username' => env('MCP_DB_USERNAME', env('DB_USERNAME', 'root')),
+ 'password' => env('MCP_DB_PASSWORD', env('DB_PASSWORD', '')),
+ 'unix_socket' => env('MCP_DB_SOCKET', env('DB_SOCKET', '')),
+ 'charset' => env('DB_CHARSET', 'utf8mb4'),
+ 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
+ 'prefix' => '',
+ 'prefix_indexes' => true,
+ 'strict' => true,
+ 'engine' => null,
+ 'options' => extension_loaded('pdo_mysql') ? array_filter([
+ (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
+ ]) : [],
+ ],
+
],
/*
diff --git a/config/mcp.php b/config/mcp.php
new file mode 100644
index 0000000..e271434
--- /dev/null
+++ b/config/mcp.php
@@ -0,0 +1,160 @@
+ [
+ /*
+ |--------------------------------------------------------------------------
+ | Read-Only Connection
+ |--------------------------------------------------------------------------
+ |
+ | The database connection to use for MCP query execution. This should
+ | be configured with a read-only database user for defence in depth.
+ |
+ | Set to null to use the default connection (not recommended for production).
+ |
+ */
+ 'connection' => env('MCP_DB_CONNECTION', 'mcp_readonly'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Query Whitelist
+ |--------------------------------------------------------------------------
+ |
+ | Enable or disable whitelist-based query validation. When enabled,
+ | queries must match at least one pattern in the whitelist to execute.
+ |
+ */
+ 'use_whitelist' => env('MCP_DB_USE_WHITELIST', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Custom Whitelist Patterns
+ |--------------------------------------------------------------------------
+ |
+ | Additional regex patterns to allow. The default whitelist allows basic
+ | SELECT queries. Add patterns here for application-specific queries.
+ |
+ | Example:
+ | '/^\s*SELECT\s+.*\s+FROM\s+`?users`?\s+WHERE\s+id\s*=\s*\d+;?\s*$/i'
+ |
+ */
+ 'whitelist_patterns' => [
+ // Add custom patterns here
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Blocked Tables
+ |--------------------------------------------------------------------------
+ |
+ | Tables that cannot be queried even with valid SELECT queries.
+ | Use this to protect sensitive tables from MCP access.
+ |
+ */
+ 'blocked_tables' => [
+ 'users',
+ 'password_reset_tokens',
+ 'sessions',
+ 'personal_access_tokens',
+ 'failed_jobs',
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Row Limit
+ |--------------------------------------------------------------------------
+ |
+ | Maximum number of rows that can be returned from a query.
+ | This prevents accidentally returning huge result sets.
+ |
+ */
+ 'max_rows' => env('MCP_DB_MAX_ROWS', 1000),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Tool Usage Analytics
+ |--------------------------------------------------------------------------
+ |
+ | Configuration for MCP tool usage analytics and metrics tracking.
+ |
+ */
+
+ 'analytics' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Enable Analytics
+ |--------------------------------------------------------------------------
+ |
+ | Enable or disable tool usage analytics. When disabled, no metrics
+ | will be recorded for tool executions.
+ |
+ */
+ 'enabled' => env('MCP_ANALYTICS_ENABLED', true),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Data Retention
+ |--------------------------------------------------------------------------
+ |
+ | Number of days to retain analytics data before pruning.
+ | Use the mcp:prune-metrics command to clean up old data.
+ |
+ */
+ 'retention_days' => env('MCP_ANALYTICS_RETENTION_DAYS', 90),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Batch Size
+ |--------------------------------------------------------------------------
+ |
+ | Number of metrics to accumulate before flushing to the database.
+ | Higher values improve write performance but may lose data on crashes.
+ |
+ */
+ 'batch_size' => env('MCP_ANALYTICS_BATCH_SIZE', 100),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Log Retention
+ |--------------------------------------------------------------------------
+ |
+ | Configuration for MCP log retention and cleanup.
+ |
+ */
+
+ 'log_retention' => [
+ /*
+ |--------------------------------------------------------------------------
+ | Detailed Logs Retention
+ |--------------------------------------------------------------------------
+ |
+ | Number of days to retain detailed tool call logs.
+ |
+ */
+ 'days' => env('MCP_LOG_RETENTION_DAYS', 90),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Statistics Retention
+ |--------------------------------------------------------------------------
+ |
+ | Number of days to retain aggregated statistics.
+ | Should typically be longer than detailed logs.
+ |
+ */
+ 'stats_days' => env('MCP_LOG_RETENTION_STATS_DAYS', 365),
+ ],
+
+];
diff --git a/docs/patterns.md b/docs/patterns.md
new file mode 100644
index 0000000..de2d66e
--- /dev/null
+++ b/docs/patterns.md
@@ -0,0 +1,1336 @@
+# Core PHP Framework Patterns
+
+This guide covers the key architectural patterns used throughout the Core PHP Framework. Each pattern includes guidance on when to use it, quick start examples, and common pitfalls to avoid.
+
+## Table of Contents
+
+1. [Actions Pattern](#1-actions-pattern)
+2. [Multi-Tenant Data Isolation](#2-multi-tenant-data-isolation)
+3. [Module System (Lifecycle Events)](#3-module-system-lifecycle-events)
+4. [Activity Logging](#4-activity-logging)
+5. [Seeder Auto-Discovery](#5-seeder-auto-discovery)
+6. [Service Definition](#6-service-definition)
+
+---
+
+## 1. Actions Pattern
+
+**Location:** `packages/core-php/src/Core/Actions/`
+
+Actions are single-purpose classes that encapsulate business logic. They extract complex operations from controllers and Livewire components into testable, reusable units.
+
+### When to Use
+
+- Business operations with multiple steps (validation, authorization, persistence, side effects)
+- Logic that should be reusable across controllers, commands, and API endpoints
+- Operations that benefit from dependency injection
+- Any operation complex enough to warrant unit testing in isolation
+
+### When NOT to Use
+
+- Simple CRUD operations that don't need validation beyond form requests
+- One-line operations that don't benefit from abstraction
+
+### Quick Start
+
+```php
+posts->create([
+ 'user_id' => $user->id,
+ 'title' => $data['title'],
+ 'content' => $data['content'],
+ ]);
+
+ if (isset($data['image'])) {
+ $this->images->attach($post, $data['image']);
+ }
+
+ return $post;
+ }
+}
+```
+
+**Usage:**
+
+```php
+// Via dependency injection (preferred)
+public function __construct(private CreatePost $createPost) {}
+
+$post = $this->createPost->handle($user, $validated);
+
+// Via static helper
+$post = CreatePost::run($user, $validated);
+
+// Via container
+$post = app(CreatePost::class)->handle($user, $validated);
+```
+
+### Full Example
+
+Here's an Action that creates a project with entitlement checking:
+
+```php
+defaultWorkspace();
+
+ // Check entitlements
+ if ($workspace) {
+ $this->checkEntitlements($workspace);
+ }
+
+ // Generate unique slug if not provided
+ $data['slug'] = $data['slug'] ?? $this->generateUniqueSlug($data['name']);
+ $data['user_id'] = $user->id;
+ $data['workspace_id'] = $data['workspace_id'] ?? $workspace?->id;
+
+ // Create the project
+ $project = Project::create($data);
+
+ // Record usage
+ if ($workspace) {
+ $this->entitlements->recordUsage(
+ $workspace,
+ 'projects.count',
+ 1,
+ $user,
+ ['project_id' => $project->id]
+ );
+ }
+
+ // Log activity
+ Activity::causedBy($user)
+ ->performedOn($project)
+ ->withProperties(['name' => $project->name])
+ ->log('created');
+
+ return $project;
+ }
+
+ public static function run(User $user, array $data): Project
+ {
+ return app(static::class)->handle($user, $data);
+ }
+
+ protected function checkEntitlements(Workspace $workspace): void
+ {
+ $result = $this->entitlements->can($workspace, 'projects.count');
+
+ if ($result->isDenied()) {
+ throw new EntitlementException(
+ "You have reached your project limit. Please upgrade your plan.",
+ 'projects.count'
+ );
+ }
+ }
+
+ protected function generateUniqueSlug(string $name): string
+ {
+ $slug = \Illuminate\Support\Str::slug($name);
+ $original = $slug;
+ $counter = 1;
+
+ while (Project::where('slug', $slug)->exists()) {
+ $slug = $original . '-' . $counter++;
+ }
+
+ return $slug;
+ }
+}
+```
+
+### Directory Structure
+
+```
+Mod/Example/Actions/
+├── CreateThing.php
+├── UpdateThing.php
+├── DeleteThing.php
+└── Thing/
+ ├── PublishThing.php
+ └── ArchiveThing.php
+```
+
+### Configuration
+
+Actions use Laravel's service container for dependency injection. No additional configuration is required.
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Too many responsibilities | Keep actions focused on one operation. Split into multiple actions if needed. |
+| Returning void | Always return something useful (the created/updated model, a result DTO). |
+| Not using dependency injection | Inject dependencies via constructor, not `app()` calls inside methods. |
+| Catching all exceptions | Let exceptions bubble up for proper error handling. |
+| Direct database queries | Use repositories or model methods for testability. |
+
+---
+
+## 2. Multi-Tenant Data Isolation
+
+**Location:** `packages/core-php/src/Mod/Tenant/`
+
+The multi-tenant system ensures data isolation between workspaces using global scopes and traits. This is a **security-critical** pattern that prevents cross-tenant data leakage.
+
+### When to Use
+
+- Any model that "belongs" to a workspace and should be isolated
+- Data that must never be visible across tenant boundaries
+- Resources that should be automatically scoped to the current workspace context
+
+### Key Components
+
+| Component | Purpose |
+|-----------|---------|
+| `BelongsToWorkspace` trait | Add to models that belong to a workspace |
+| `WorkspaceScope` | Global scope that filters queries |
+| `MissingWorkspaceContextException` | Security exception when context is missing |
+
+### Quick Start
+
+```php
+where('is_active', true);
+ }
+}
+```
+
+**Using the model:**
+
+```php
+// Automatically scoped to current workspace
+$products = Product::all();
+$product = Product::where('sku', 'ABC123')->first();
+$activeProducts = Product::active()->get();
+
+// Creating - workspace_id is auto-assigned
+$product = Product::create(['name' => 'Widget', 'sku' => 'W001']);
+
+// Cached collection for current workspace
+$products = Product::ownedByCurrentWorkspaceCached();
+
+// Query a specific workspace (admin use)
+$products = Product::forWorkspace($workspace)->get();
+
+// Query across all workspaces (admin use - be careful!)
+$allProducts = Product::acrossWorkspaces()->get();
+```
+
+### Strict Mode vs Permissive Mode
+
+**Strict mode (default):** Throws `MissingWorkspaceContextException` when workspace context is unavailable. This is the secure default.
+
+```php
+// In strict mode, this throws an exception if no workspace context
+$products = Product::all(); // MissingWorkspaceContextException
+```
+
+**Permissive mode:** Returns empty results instead of throwing. Use sparingly.
+
+```php
+// Disable strict mode globally (not recommended)
+WorkspaceScope::disableStrictMode();
+
+// Disable for a specific callback
+WorkspaceScope::withoutStrictMode(function () {
+ // Operations here return empty results if no context
+ $products = Product::all(); // Returns empty collection
+});
+
+// Disable for a specific model
+class LegacyProduct extends Model
+{
+ use BelongsToWorkspace;
+
+ protected bool $workspaceScopeStrict = false; // Not recommended
+}
+```
+
+### When to Use `forWorkspace()` vs `acrossWorkspaces()`
+
+| Method | Use Case |
+|--------|----------|
+| `forWorkspace($workspace)` | Admin viewing a specific workspace's data |
+| `acrossWorkspaces()` | Global reports, admin dashboards, CLI commands |
+| Neither (default) | Normal application code - uses current context |
+
+**Always prefer the default scoping** unless you have a specific reason to query across workspaces.
+
+### Security Considerations
+
+1. **Never disable strict mode globally** in production
+2. **Audit uses of `acrossWorkspaces()`** - each use should be justified
+3. **CLI commands** automatically bypass strict mode (they have no request context)
+4. **Test data isolation** - write tests that verify cross-tenant queries fail
+5. **The `workspace_id` column must exist** on all tenant-scoped tables
+
+### Configuration
+
+```php
+// In migrations - always add workspace_id
+Schema::create('products', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('workspace_id')->constrained()->cascadeOnDelete();
+ $table->string('name');
+ // ...
+ $table->index('workspace_id'); // Important for performance
+});
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Forgetting `workspace_id` in migrations | Always add the foreign key and index |
+| Using `acrossWorkspaces()` casually | Audit every usage, prefer default scoping |
+| Suppressing the exception | Don't catch `MissingWorkspaceContextException` - fix the context |
+| Direct DB queries | Use Eloquent models so scopes apply |
+| Joining across tenant boundaries | Ensure joins respect workspace_id |
+
+---
+
+## 3. Module System (Lifecycle Events)
+
+**Location:** `packages/core-php/src/Core/Events/`, `packages/core-php/src/Core/Module/`
+
+The module system uses lifecycle events for lazy-loading modules. Modules declare what events they listen to, and are only instantiated when those events fire.
+
+### When to Use
+
+- Creating a new feature module
+- Registering routes, views, Livewire components
+- Adding admin panel navigation
+- Registering console commands
+
+### How It Works
+
+```
+Application Start
+ │
+ ▼
+ModuleScanner scans for Boot.php files with $listens arrays
+ │
+ ▼
+ModuleRegistry registers lazy listeners for each event-module pair
+ │
+ ▼
+Request comes in → Appropriate lifecycle event fires
+ │
+ ▼
+LazyModuleListener instantiates module and calls handler
+ │
+ ▼
+Module registers its resources (routes, views, etc.)
+```
+
+### Quick Start
+
+Create a `Boot.php` file in your module directory:
+
+```php
+ 'onWebRoutes',
+ ];
+
+ public function onWebRoutes(WebRoutesRegistering $event): void
+ {
+ $event->views('example', __DIR__.'/Views');
+ $event->routes(fn () => require __DIR__.'/Routes/web.php');
+ }
+}
+```
+
+### Available Lifecycle Events
+
+| Event | Context | When It Fires |
+|-------|---------|---------------|
+| `WebRoutesRegistering` | Web requests | Public frontend routes |
+| `AdminPanelBooting` | Admin requests | Admin panel setup |
+| `ApiRoutesRegistering` | API requests | REST API routes |
+| `ClientRoutesRegistering` | Client dashboard | Authenticated client routes |
+| `ConsoleBooting` | CLI commands | Artisan booting |
+| `QueueWorkerBooting` | Queue workers | Queue worker starting |
+| `McpToolsRegistering` | MCP server | MCP tool registration |
+| `FrameworkBooted` | All contexts | After all context-specific events |
+
+### Full Example
+
+A complete module Boot class:
+
+```php
+ 'onAdminPanel',
+ ApiRoutesRegistering::class => 'onApiRoutes',
+ WebRoutesRegistering::class => 'onWebRoutes',
+ ConsoleBooting::class => 'onConsole',
+ ];
+
+ public function register(): void
+ {
+ // Register service bindings
+ $this->app->singleton(
+ Services\InventoryService::class,
+ Services\InventoryService::class
+ );
+ }
+
+ public function boot(): void
+ {
+ // Load migrations
+ $this->loadMigrationsFrom(__DIR__.'/Migrations');
+ $this->loadTranslationsFrom(__DIR__.'/Lang/en', 'inventory');
+ }
+
+ public function onAdminPanel(AdminPanelBooting $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+
+ // Navigation
+ $event->navigation([
+ 'label' => 'Inventory',
+ 'icon' => 'box',
+ 'route' => 'admin.inventory.index',
+ 'sort_order' => 50,
+ ]);
+
+ // Livewire components
+ $event->livewire('inventory-list', View\Livewire\InventoryList::class);
+ $event->livewire('product-form', View\Livewire\ProductForm::class);
+ }
+
+ public function onApiRoutes(ApiRoutesRegistering $event): void
+ {
+ $event->routes(fn () => Route::middleware('api')
+ ->prefix('v1/inventory')
+ ->group(__DIR__.'/Routes/api.php'));
+ }
+
+ public function onWebRoutes(WebRoutesRegistering $event): void
+ {
+ $event->views($this->moduleName, __DIR__.'/View/Blade');
+ $event->routes(fn () => Route::middleware('web')
+ ->group(__DIR__.'/Routes/web.php'));
+ }
+
+ public function onConsole(ConsoleBooting $event): void
+ {
+ $event->command(Console\SyncInventoryCommand::class);
+ $event->command(Console\PruneStaleStockCommand::class);
+ }
+}
+```
+
+### Available Request Methods
+
+Events provide these methods for registering resources:
+
+| Method | Purpose |
+|--------|---------|
+| `routes(callable)` | Register route files/callbacks |
+| `views(namespace, path)` | Register view namespaces |
+| `livewire(alias, class)` | Register Livewire components |
+| `middleware(alias, class)` | Register middleware aliases |
+| `command(class)` | Register Artisan commands |
+| `translations(namespace, path)` | Register translation namespaces |
+| `bladeComponentPath(path, namespace)` | Register anonymous Blade components |
+| `policy(model, policy)` | Register model policies |
+| `navigation(item)` | Register navigation items |
+
+### The Request/Collect Pattern
+
+Events use a "request/collect" pattern:
+
+1. **Modules request** resources via methods like `routes()`, `views()`
+2. **Requests are collected** in arrays during event dispatch
+3. **LifecycleEventProvider processes** all requests with validation
+
+This ensures modules don't directly mutate infrastructure and allows central validation.
+
+### Module Directory Structure
+
+```
+Mod/Inventory/
+├── Boot.php # Module entry point
+├── Routes/
+│ ├── web.php
+│ └── api.php
+├── View/
+│ ├── Blade/ # Blade templates
+│ │ └── admin/
+│ └── Livewire/ # Livewire components
+├── Models/
+├── Actions/
+├── Services/
+├── Console/
+├── Migrations/
+└── Lang/
+```
+
+### Configuration
+
+Namespace detection is automatic based on path:
+- `/Core` paths → `Core\` namespace
+- `/Mod` paths → `Mod\` namespace
+- `/Website` paths → `Website\` namespace
+- `/Plug` paths → `Plug\` namespace
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Registering routes outside events | Always use `$event->routes()` in handlers |
+| Heavy work in `$listens` handlers | Keep handlers lightweight; defer work |
+| Forgetting to add `$listens` | Module won't load without static `$listens` |
+| Using wrong event | Match event to request context (web, api, admin) |
+| Instantiating in `$listens` | The array is read statically; don't call methods |
+
+---
+
+## 4. Activity Logging
+
+**Location:** `packages/core-php/src/Core/Activity/`
+
+Activity logging tracks model changes and user actions. Built on spatie/laravel-activitylog with workspace-aware enhancements.
+
+### When to Use
+
+- Audit trails for compliance
+- User activity history
+- Model change tracking
+- Debugging and support
+
+### Quick Start
+
+Add the trait to any model:
+
+```php
+properties = $activity->properties->merge([
+ 'contract_number' => $this->contract_number,
+ 'client_id' => $this->client_id,
+ ]);
+ }
+}
+```
+
+**Querying activity logs:**
+
+```php
+use Core\Activity\Services\ActivityLogService;
+
+$service = app(ActivityLogService::class);
+
+// Get activities for a specific model
+$activities = $service->logFor($contract)->recent(20);
+
+// Get activities by a user in a workspace
+$activities = $service
+ ->logBy($user)
+ ->forWorkspace($workspace)
+ ->lastDays(7)
+ ->paginate(25);
+
+// Get all "updated" events for a model type
+$activities = $service
+ ->forSubjectType(Contract::class)
+ ->ofType('updated')
+ ->between('2024-01-01', '2024-12-31')
+ ->get();
+
+// Search activities
+$results = $service->search('approved contract');
+
+// Get statistics
+$stats = $service->statistics($workspace);
+// Returns: ['total' => 150, 'by_event' => [...], 'by_subject' => [...], 'by_user' => [...]]
+```
+
+**Using the Activity model directly:**
+
+```php
+use Core\Activity\Models\Activity;
+
+// Get activities for a workspace
+$activities = Activity::forWorkspace($workspace)->newest()->get();
+
+// Filter by event type
+$created = Activity::createdEvents()->lastDays(30)->get();
+$deleted = Activity::deletedEvents()->withDeletedSubject()->get();
+
+// Get activities with changes
+$withChanges = Activity::withChanges()->get();
+
+// Access change details
+foreach ($activities as $activity) {
+ echo $activity->description;
+ echo $activity->causer_name; // "John Doe" or "System"
+ echo $activity->subject_name; // "Contract #123"
+
+ // Get specific changes
+ foreach ($activity->changes as $field => $values) {
+ echo "{$field}: {$values['old']} -> {$values['new']}";
+ }
+}
+```
+
+### Configuration
+
+Configure via model properties:
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `$activityLogAttributes` | array | null (all) | Attributes to log |
+| `$activityLogName` | string | 'default' | Log name for grouping |
+| `$activityLogEvents` | array | ['created', 'updated', 'deleted'] | Events to log |
+| `$activityLogWorkspace` | bool | true | Include workspace_id |
+| `$activityLogOnlyDirty` | bool | true | Only log changed attributes |
+
+**Global configuration (config/core.php):**
+
+```php
+'activity' => [
+ 'enabled' => env('ACTIVITY_LOG_ENABLED', true),
+ 'log_name' => 'default',
+ 'include_workspace' => true,
+ 'default_events' => ['created', 'updated', 'deleted'],
+ 'retention_days' => 90,
+],
+```
+
+### Temporarily Disabling Logging
+
+```php
+// Disable for a callback
+Document::withoutActivityLogging(function () {
+ $document->update(['status' => 'processed']);
+});
+
+// Check if logging is enabled
+if (Document::activityLoggingEnabled()) {
+ // ...
+}
+```
+
+### Pruning Old Logs
+
+```bash
+# Via artisan command
+php artisan activity:prune --days=90
+
+# Via service
+$service = app(ActivityLogService::class);
+$deleted = $service->prune(90); // Delete logs older than 90 days
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Logging sensitive data | Exclude sensitive fields via `$activityLogAttributes` |
+| Excessive logging | Log only meaningful changes, not every field |
+| Performance issues | Use `withoutActivityLogging()` for bulk operations |
+| Missing workspace context | Ensure workspace is set before logging |
+| Large properties | Limit logged data; don't log file contents |
+
+---
+
+## 5. Seeder Auto-Discovery
+
+**Location:** `packages/core-php/src/Core/Database/Seeders/`
+
+The seeder auto-discovery system scans module directories for seeders and executes them in the correct order based on priority and dependencies.
+
+### When to Use
+
+- Creating seeders for a module
+- Seeding reference data (features, packages, configuration)
+- Establishing dependencies between seeders
+- Demo/test data setup
+
+### Quick Start
+
+Create a seeder in your module's `Database/Seeders/` directory:
+
+```php
+ 'example.feature'],
+ ['name' => 'Example Feature', 'type' => 'boolean']
+ );
+ }
+}
+```
+
+Seeders are discovered automatically - no manual registration required.
+
+### Priority and Dependencies
+
+**Using priority (simple ordering):**
+
+```php
+// Using class property
+class FeatureSeeder extends Seeder
+{
+ public int $priority = 10; // Runs early
+}
+
+// Using attribute
+use Core\Database\Seeders\Attributes\SeederPriority;
+
+#[SeederPriority(10)]
+class FeatureSeeder extends Seeder
+{
+ public function run(): void { /* ... */ }
+}
+```
+
+**Priority guidelines:**
+
+| Range | Use Case |
+|-------|----------|
+| 0-20 | Foundation (features, config) |
+| 20-40 | Core data (packages, workspaces) |
+| 40-60 | Default (general seeders) |
+| 60-80 | Content (pages, posts) |
+| 80-100 | Demo/test data |
+
+**Using dependencies (explicit ordering):**
+
+```php
+use Core\Database\Seeders\Attributes\SeederAfter;
+use Core\Database\Seeders\Attributes\SeederBefore;
+use Core\Mod\Tenant\Database\Seeders\FeatureSeeder;
+
+// This seeder runs AFTER FeatureSeeder completes
+#[SeederAfter(FeatureSeeder::class)]
+class PackageSeeder extends Seeder
+{
+ public function run(): void
+ {
+ // Can safely reference features created by FeatureSeeder
+ }
+}
+
+// This seeder runs BEFORE PackageSeeder
+#[SeederBefore(PackageSeeder::class)]
+class FeatureSeeder extends Seeder
+{
+ public function run(): void
+ {
+ // Features are created first
+ }
+}
+```
+
+**Multiple dependencies:**
+
+```php
+#[SeederAfter(FeatureSeeder::class, PackageSeeder::class)]
+class WorkspaceSeeder extends Seeder
+{
+ // Runs after both FeatureSeeder and PackageSeeder
+}
+```
+
+### Full Example
+
+```php
+ 'free',
+ 'name' => 'Free',
+ 'price_monthly' => 0,
+ 'features' => ['basic.access'],
+ 'sort_order' => 1,
+ ],
+ [
+ 'code' => 'pro',
+ 'name' => 'Professional',
+ 'price_monthly' => 29,
+ 'features' => ['basic.access', 'advanced.features', 'support.priority'],
+ 'sort_order' => 2,
+ ],
+ [
+ 'code' => 'enterprise',
+ 'name' => 'Enterprise',
+ 'price_monthly' => 99,
+ 'features' => ['basic.access', 'advanced.features', 'support.priority', 'api.access'],
+ 'sort_order' => 3,
+ ],
+ ];
+
+ foreach ($plans as $planData) {
+ Plan::updateOrCreate(
+ ['code' => $planData['code']],
+ $planData
+ );
+ }
+
+ $this->command->info('Billing plans seeded successfully.');
+ }
+}
+```
+
+### How Discovery Works
+
+1. `SeederDiscovery` scans configured paths for `*Seeder.php` files
+2. Files are read to extract namespace and class name
+3. Priority and dependency attributes/properties are parsed
+4. Seeders are topologically sorted (dependencies first, then by priority)
+5. `CoreDatabaseSeeder` executes them in order
+
+### Configuration
+
+The discovery scans these paths by default:
+- `app/Core/*/Database/Seeders/`
+- `app/Mod/*/Database/Seeders/`
+- `packages/core-php/src/Core/*/Database/Seeders/`
+- `packages/core-php/src/Mod/*/Database/Seeders/`
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Circular dependencies | Review dependencies; use priority instead if possible |
+| Missing table errors | Check `Schema::hasTable()` before seeding |
+| Non-idempotent seeders | Use `updateOrCreate()` or `firstOrCreate()` |
+| Hardcoded IDs | Use codes/slugs for lookups, not numeric IDs |
+| Dependencies on non-existent seeders | Ensure dependent seeders are discoverable |
+| Forgetting to output progress | Use `$this->command->info()` for visibility |
+
+---
+
+## 6. Service Definition
+
+**Location:** `packages/core-php/src/Core/Service/`
+
+Services are the product layer of the framework. They define how modules are presented as SaaS products with versioning, health checks, and dependency management.
+
+### When to Use
+
+- Defining a new SaaS product/service
+- Adding health monitoring to a service
+- Declaring service dependencies
+- Managing service lifecycle (deprecation, sunset)
+
+### Key Components
+
+| Component | Purpose |
+|-----------|---------|
+| `ServiceDefinition` | Interface for service definitions |
+| `ServiceVersion` | Semantic versioning with deprecation |
+| `ServiceDependency` | Declare dependencies on other services |
+| `HealthCheckable` | Interface for health monitoring |
+| `HealthCheckResult` | Health check response object |
+
+### Quick Start
+
+```php
+ 'billing',
+ 'module' => 'Mod\\Billing',
+ 'name' => 'Billing',
+ 'tagline' => 'Subscription management',
+ 'icon' => 'credit-card',
+ 'color' => '#10B981',
+ 'entitlement_code' => 'core.srv.billing',
+ 'sort_order' => 20,
+ ];
+ }
+
+ public static function version(): ServiceVersion
+ {
+ return new ServiceVersion(1, 0, 0);
+ }
+
+ public static function dependencies(): array
+ {
+ return [];
+ }
+
+ // From AdminMenuProvider interface
+ public function menuItems(): array
+ {
+ return [
+ [
+ 'label' => 'Billing',
+ 'icon' => 'credit-card',
+ 'route' => 'admin.billing.index',
+ ],
+ ];
+ }
+}
+```
+
+### Full Example
+
+A complete service with health checks and dependencies:
+
+```php
+ 'analytics',
+ 'module' => 'Mod\\Analytics',
+ 'name' => 'AnalyticsHost',
+ 'tagline' => 'Privacy-first website analytics',
+ 'description' => 'Lightweight, GDPR-compliant analytics without cookies.',
+ 'icon' => 'chart-line',
+ 'color' => '#F59E0B',
+ 'entitlement_code' => 'core.srv.analytics',
+ 'sort_order' => 30,
+ ];
+ }
+
+ public static function version(): ServiceVersion
+ {
+ return new ServiceVersion(2, 1, 0);
+ }
+
+ public static function dependencies(): array
+ {
+ return [
+ ServiceDependency::required('auth', '>=1.0.0'),
+ ServiceDependency::optional('billing'),
+ ];
+ }
+
+ public function healthCheck(): HealthCheckResult
+ {
+ try {
+ $start = microtime(true);
+
+ // Check ClickHouse connection
+ $this->clickhouse->select('SELECT 1');
+
+ // Check Redis connection
+ $this->redis->ping();
+
+ $responseTime = (microtime(true) - $start) * 1000;
+
+ if ($responseTime > 1000) {
+ return HealthCheckResult::degraded(
+ 'Database responding slowly',
+ ['response_time_ms' => $responseTime]
+ );
+ }
+
+ return HealthCheckResult::healthy(
+ 'All systems operational',
+ [],
+ $responseTime
+ );
+ } catch (\Exception $e) {
+ return HealthCheckResult::fromException($e);
+ }
+ }
+
+ public function menuItems(): array
+ {
+ return [
+ [
+ 'label' => 'Analytics',
+ 'icon' => 'chart-line',
+ 'route' => 'admin.analytics.dashboard',
+ 'children' => [
+ ['label' => 'Dashboard', 'route' => 'admin.analytics.dashboard'],
+ ['label' => 'Sites', 'route' => 'admin.analytics.sites'],
+ ['label' => 'Reports', 'route' => 'admin.analytics.reports'],
+ ],
+ ],
+ ];
+ }
+}
+```
+
+### Service Versioning
+
+```php
+use Core\Service\ServiceVersion;
+
+// Create a version
+$version = new ServiceVersion(2, 1, 0);
+echo $version; // "2.1.0"
+
+// Parse from string
+$version = ServiceVersion::fromString('v2.1.0');
+
+// Check compatibility
+$minimum = new ServiceVersion(1, 5, 0);
+$current = new ServiceVersion(1, 8, 2);
+$current->isCompatibleWith($minimum); // true
+
+// Mark as deprecated
+$version = (new ServiceVersion(1, 0, 0))
+ ->deprecate(
+ 'Use v2.x instead. See docs/migration.md',
+ new \DateTimeImmutable('2025-06-01')
+ );
+
+// Check deprecation status
+if ($version->deprecated) {
+ echo $version->deprecationMessage;
+}
+
+if ($version->isPastSunset()) {
+ throw new ServiceSunsetException('Service no longer available');
+}
+```
+
+### Service Dependencies
+
+```php
+use Core\Service\Contracts\ServiceDependency;
+
+public static function dependencies(): array
+{
+ return [
+ // Required with minimum version
+ ServiceDependency::required('auth', '>=1.0.0'),
+
+ // Required with version range
+ ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
+
+ // Optional dependency
+ ServiceDependency::optional('analytics'),
+ ];
+}
+```
+
+### Health Checks
+
+```php
+use Core\Service\Contracts\HealthCheckable;
+use Core\Service\HealthCheckResult;
+
+class MyService implements ServiceDefinition, HealthCheckable
+{
+ public function healthCheck(): HealthCheckResult
+ {
+ // Healthy
+ return HealthCheckResult::healthy('All systems operational');
+
+ // Healthy with response time
+ return HealthCheckResult::healthy(
+ 'Service operational',
+ ['connections' => 5],
+ responseTimeMs: 45.2
+ );
+
+ // Degraded (works but with issues)
+ return HealthCheckResult::degraded(
+ 'High latency detected',
+ ['latency_ms' => 1500]
+ );
+
+ // Unhealthy
+ return HealthCheckResult::unhealthy(
+ 'Database connection failed',
+ ['last_error' => 'Connection refused']
+ );
+
+ // From exception
+ try {
+ $this->checkCriticalSystem();
+ } catch (\Exception $e) {
+ return HealthCheckResult::fromException($e);
+ }
+ }
+}
+```
+
+### Health Check Guidelines
+
+Health checks should be:
+
+| Guideline | Recommendation |
+|-----------|----------------|
+| Fast | Complete within 5 seconds (< 1 second preferred) |
+| Non-destructive | Read-only operations only |
+| Representative | Test actual critical dependencies |
+| Safe | Handle all exceptions, return HealthCheckResult |
+
+### Configuration
+
+Service definitions populate the `platform_services` table:
+
+```php
+// definition() return array
+[
+ 'code' => 'billing', // Unique identifier (required)
+ 'module' => 'Mod\\Billing', // Module namespace (required)
+ 'name' => 'Billing', // Display name (required)
+ 'tagline' => 'Subscription management', // Short description
+ 'description' => '...', // Full description
+ 'icon' => 'link', // FontAwesome icon
+ 'color' => '#3B82F6', // Brand color
+ 'entitlement_code' => '...', // Access control code
+ 'sort_order' => 10, // Menu ordering
+]
+```
+
+### Common Pitfalls
+
+| Pitfall | Solution |
+|---------|----------|
+| Slow health checks | Keep under 1 second; test critical paths only |
+| Circular dependencies | Review service architecture; refactor if needed |
+| Missing version() | Always implement; return `ServiceVersion::initial()` at minimum |
+| Health check exceptions | Catch all exceptions; return `fromException()` |
+| Forgetting dependencies | Document all service interdependencies |
+
+---
+
+## Summary
+
+These patterns form the backbone of the Core PHP Framework:
+
+| Pattern | Purpose | Key Files |
+|---------|---------|-----------|
+| Actions | Encapsulate business logic | `Core\Actions\Action` |
+| Multi-Tenant | Data isolation between workspaces | `BelongsToWorkspace`, `WorkspaceScope` |
+| Module System | Lazy-loading via lifecycle events | `Boot.php`, `$listens` |
+| Activity Logging | Audit trail and change tracking | `LogsActivity`, `ActivityLogService` |
+| Seeder Discovery | Auto-discovered, ordered seeding | `#[SeederPriority]`, `#[SeederAfter]` |
+| Service Definition | SaaS product layer | `ServiceDefinition`, `HealthCheckable` |
+
+For more details, explore the source files in their respective locations or check the inline documentation.
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..9f39f23
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,55 @@
+
+
+
+
+ tests/Feature
+ packages/core-php/tests/Feature
+ packages/core-php/src/Core/**/Tests/Feature
+ packages/core-php/src/Mod/**/Tests/Feature
+ packages/core-admin/tests/Feature
+ packages/core-api/tests/Feature
+ packages/core-mcp/tests/Feature
+
+
+ tests/Unit
+ packages/core-php/tests/Unit
+ packages/core-php/src/Core/**/Tests/Unit
+ packages/core-php/src/Mod/**/Tests/Unit
+ packages/core-admin/tests/Unit
+ packages/core-api/tests/Unit
+ packages/core-mcp/tests/Unit
+
+
+
+
+ app
+ packages/core-php/src
+ packages/core-admin/src
+ packages/core-api/src
+ packages/core-mcp/src
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/routes/api.php b/routes/api.php
new file mode 100644
index 0000000..6dcf813
--- /dev/null
+++ b/routes/api.php
@@ -0,0 +1,3 @@
+loadMigrationsFrom(__DIR__.'/../packages/core-php/src/Mod/Tenant/Migrations');
+ $this->loadMigrationsFrom(__DIR__.'/../packages/core-php/src/Mod/Social/Migrations');
+ }
+}