php-framework/docs/patterns.md

36 KiB

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
  2. Multi-Tenant Data Isolation
  3. Module System (Lifecycle Events)
  4. Activity Logging
  5. Seeder Auto-Discovery
  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

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:

// 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

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

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

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:

// 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.

// 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.

// 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

// 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

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

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

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

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:

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:

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):

'activity' => [
    'enabled' => env('ACTIVITY_LOG_ENABLED', true),
    'log_name' => 'default',
    'include_workspace' => true,
    'default_events' => ['created', 'updated', 'deleted'],
    'retention_days' => 90,
],

Temporarily Disabling Logging

// Disable for a callback
Document::withoutActivityLogging(function () {
    $document->update(['status' => 'processed']);
});

// Check if logging is enabled
if (Document::activityLoggingEnabled()) {
    // ...
}

Pruning Old Logs

# 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

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):

// 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):

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:

#[SeederAfter(FeatureSeeder::class, PackageSeeder::class)]
class WorkspaceSeeder extends Seeder
{
    // Runs after both FeatureSeeder and PackageSeeder
}

Full Example

<?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

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

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

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

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

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:

// 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.