php-framework/docs/patterns.md

1336 lines
36 KiB
Markdown

# 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
<?php
namespace Mod\Example\Actions;
use Core\Actions\Action;
class CreatePost
{
use Action;
public function __construct(
protected PostRepository $posts,
protected ImageService $images
) {}
public function handle(User $user, array $data): Post
{
// Your business logic here
$post = $this->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
<?php
namespace Mod\Projects\Actions;
use Core\Actions\Action;
use Core\Mod\Tenant\Exceptions\EntitlementException;
use Core\Mod\Tenant\Models\User;
use Core\Mod\Tenant\Models\Workspace;
use Core\Mod\Tenant\Services\EntitlementService;
use Mod\Projects\Models\Project;
use Spatie\Activitylog\Facades\Activity;
class CreateProject
{
public function __construct(
protected EntitlementService $entitlements
) {}
/**
* @throws EntitlementException
*/
public function handle(User $user, array $data): Project
{
$workspace = $user->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
<?php
namespace Mod\Example\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Project extends Model
{
use BelongsToWorkspace;
protected $fillable = [
'workspace_id',
'name',
'description',
];
}
```
That's it! The trait automatically:
- Assigns `workspace_id` when creating records
- Scopes all queries to the current workspace
- Throws exceptions if workspace context is missing (in strict mode)
- Invalidates cache when records change
### Full Example
**Model with workspace isolation:**
```php
<?php
namespace Mod\Inventory\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Mod\Tenant\Concerns\BelongsToWorkspace;
class Product extends Model
{
use BelongsToWorkspace;
protected $fillable = [
'workspace_id',
'name',
'sku',
'price',
];
// Model methods work normally - scoping is automatic
public function scopeActive($query)
{
return $query->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
<?php
namespace Mod\Example;
use Core\Events\WebRoutesRegistering;
class Boot
{
public static array $listens = [
WebRoutesRegistering::class => '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
<?php
namespace Mod\Inventory;
use Core\Events\AdminPanelBooting;
use Core\Events\ApiRoutesRegistering;
use Core\Events\ConsoleBooting;
use Core\Events\WebRoutesRegistering;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
class Boot extends ServiceProvider
{
protected string $moduleName = 'inventory';
public static array $listens = [
AdminPanelBooting::class => '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
<?php
namespace Mod\Example\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Activity\Concerns\LogsActivity;
class Document extends Model
{
use LogsActivity;
protected $fillable = ['title', 'content', 'status'];
}
```
That's it! All creates, updates, and deletes are now logged with:
- Changed attributes (old and new values)
- The user who made the change
- The workspace context
- Timestamp
### Full Example
**Model with customized logging:**
```php
<?php
namespace Mod\Contracts\Models;
use Illuminate\Database\Eloquent\Model;
use Core\Activity\Concerns\LogsActivity;
use Spatie\Activitylog\Contracts\Activity;
class Contract extends Model
{
use LogsActivity;
protected $fillable = [
'workspace_id',
'client_name',
'value',
'status',
'signed_at',
'internal_notes', // Don't log this
];
// Only log these attributes
protected array $activityLogAttributes = [
'client_name',
'value',
'status',
'signed_at',
];
// Custom log name
protected string $activityLogName = 'contracts';
// Events to log (default: created, updated, deleted)
protected array $activityLogEvents = ['created', 'updated', 'deleted'];
// Add custom data to activity
public function customizeActivity(Activity $activity, string $eventName): void
{
$activity->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
<?php
namespace Mod\Example\Database\Seeders;
use Illuminate\Database\Seeder;
class ExampleFeatureSeeder extends Seeder
{
// Optional: Lower values run first (default: 50)
public int $priority = 10;
public function run(): void
{
// Seed your data
Feature::updateOrCreate(
['code' => '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
<?php
namespace Mod\Billing\Database\Seeders;
use Core\Database\Seeders\Attributes\SeederAfter;
use Core\Database\Seeders\Attributes\SeederPriority;
use Core\Mod\Tenant\Database\Seeders\FeatureSeeder;
use Core\Mod\Tenant\Database\Seeders\WorkspaceSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Schema;
use Mod\Billing\Models\Plan;
#[SeederPriority(30)]
#[SeederAfter(FeatureSeeder::class)]
class PlanSeeder extends Seeder
{
public function run(): void
{
// Guard against missing table
if (! Schema::hasTable('billing_plans')) {
return;
}
$plans = [
[
'code' => '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
<?php
namespace Mod\Billing;
use Core\Service\Contracts\ServiceDefinition;
use Core\Service\ServiceVersion;
class BillingService implements ServiceDefinition
{
public static function definition(): array
{
return [
'code' => '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
<?php
namespace Mod\Analytics;
use Core\Service\Contracts\HealthCheckable;
use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\ServiceDependency;
use Core\Service\HealthCheckResult;
use Core\Service\ServiceVersion;
class AnalyticsService implements ServiceDefinition, HealthCheckable
{
public function __construct(
protected ClickHouseConnection $clickhouse,
protected RedisConnection $redis
) {}
public static function definition(): array
{
return [
'code' => '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.