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.
- Actions Pattern
- Multi-Tenant Data Isolation
- Module System (Lifecycle Events)
- Activity Logging
- Seeder Auto-Discovery
- Service Definition
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.
- 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
- Simple CRUD operations that don't need validation beyond form requests
- One-line operations that don't benefit from abstraction
<?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);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;
}
}Mod/Example/Actions/
├── CreateThing.php
├── UpdateThing.php
├── DeleteThing.php
└── Thing/
├── PublishThing.php
└── ArchiveThing.php
Actions use Laravel's service container for dependency injection. No additional configuration is required.
| 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. |
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.
- 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
| 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 |
<?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_idwhen creating records - Scopes all queries to the current workspace
- Throws exceptions if workspace context is missing (in strict mode)
- Invalidates cache when records change
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 (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(); // MissingWorkspaceContextExceptionPermissive 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
}| 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.
- Never disable strict mode globally in production
- Audit uses of
acrossWorkspaces()- each use should be justified - CLI commands automatically bypass strict mode (they have no request context)
- Test data isolation - write tests that verify cross-tenant queries fail
- The
workspace_idcolumn must exist on all tenant-scoped tables
// 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
});| 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 |
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.
- Creating a new feature module
- Registering routes, views, Livewire components
- Adding admin panel navigation
- Registering console commands
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.)
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');
}
}| 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 |
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);
}
}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 |
Events use a "request/collect" pattern:
- Modules request resources via methods like
routes(),views() - Requests are collected in arrays during event dispatch
- LifecycleEventProvider processes all requests with validation
This ensures modules don't directly mutate infrastructure and allows central validation.
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/
Namespace detection is automatic based on path:
/Corepaths →Core\namespace/Modpaths →Mod\namespace/Websitepaths →Website\namespace/Plugpaths →Plug\namespace
| 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 |
Location: packages/core-php/src/Core/Activity/
Activity logging tracks model changes and user actions. Built on spatie/laravel-activitylog with workspace-aware enhancements.
- Audit trails for compliance
- User activity history
- Model change tracking
- Debugging and support
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
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']}";
}
}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,
],// Disable for a callback
Document::withoutActivityLogging(function () {
$document->update(['status' => 'processed']);
});
// Check if logging is enabled
if (Document::activityLoggingEnabled()) {
// ...
}# 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| 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 |
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.
- Creating seeders for a module
- Seeding reference data (features, packages, configuration)
- Establishing dependencies between seeders
- Demo/test data setup
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.
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
}<?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.');
}
}SeederDiscoveryscans configured paths for*Seeder.phpfiles- Files are read to extract namespace and class name
- Priority and dependency attributes/properties are parsed
- Seeders are topologically sorted (dependencies first, then by priority)
CoreDatabaseSeederexecutes them in order
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/
| 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 |
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.
- Defining a new SaaS product/service
- Adding health monitoring to a service
- Declaring service dependencies
- Managing service lifecycle (deprecation, sunset)
| 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 |
<?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',
],
];
}
}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'],
],
],
];
}
}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');
}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'),
];
}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 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 |
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
]| 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 |
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.