php-framework/docs/packages/php/service-contracts.md
Snider 632bca9111 feat(docs): restructure with PHP/Go framework sections
- Add auto-discovery sidebar with nested directory support
- Create packages index with search and grid layout
- Move framework docs to packages/php/
- Update nav: Guide | PHP | Go | Packages | Security

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 10:47:50 +00:00

14 KiB

Service Contracts

The Service Contracts system provides a structured way to define SaaS services as first-class citizens in the framework. Services are the product layer - they define how modules are presented to users as SaaS products.

Overview

Services in Core PHP are:

  • Discoverable - Automatically found in configured module paths
  • Versioned - Support semantic versioning with deprecation tracking
  • Dependency-aware - Declare and validate dependencies on other services
  • Health-monitored - Optional health checks for operational status

Core Components

Class Purpose
ServiceDefinition Interface for defining a service
ServiceDiscovery Discovers and resolves services
ServiceVersion Semantic versioning with deprecation
ServiceDependency Declares service dependencies
HealthCheckable Optional health monitoring
HasServiceVersion Trait with default implementations

Creating a Service

Basic Service Definition

Implement the ServiceDefinition interface to create a service:

<?php

namespace Mod\Billing;

use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\ServiceDependency;
use Core\Service\Concerns\HasServiceVersion;
use Core\Service\ServiceVersion;

class BillingService implements ServiceDefinition
{
    use HasServiceVersion;

    /**
     * Service metadata for the platform_services table.
     */
    public static function definition(): array
    {
        return [
            'code' => 'billing',                          // Unique identifier
            'module' => 'Mod\\Billing',                   // Module namespace
            'name' => 'Billing Service',                  // Display name
            'tagline' => 'Handle payments and invoices',  // Short description
            'description' => 'Complete billing solution with Stripe integration',
            'icon' => 'credit-card',                      // FontAwesome icon
            'color' => '#10B981',                         // Brand color (hex)
            'entitlement_code' => 'core.srv.billing',     // Access control
            'sort_order' => 20,                           // Menu ordering
        ];
    }

    /**
     * Declare dependencies on other services.
     */
    public static function dependencies(): array
    {
        return [
            ServiceDependency::required('auth', '>=1.0.0'),
            ServiceDependency::optional('analytics'),
        ];
    }

    /**
     * Admin menu items provided by this service.
     */
    public function menuItems(): array
    {
        return [
            [
                'label' => 'Billing',
                'icon' => 'credit-card',
                'route' => 'admin.billing.index',
                'order' => 20,
            ],
        ];
    }
}

Definition Array Fields

Field Required Type Description
code Yes string Unique service identifier (lowercase, alphanumeric)
module Yes string Module namespace
name Yes string Display name
tagline No string Short description
description No string Full description
icon No string FontAwesome icon name
color No string Hex color (e.g., #3B82F6)
entitlement_code No string Access control entitlement
sort_order No int Menu/display ordering

Service Versioning

Services use semantic versioning to track API compatibility and manage deprecation.

Basic Versioning

use Core\Service\ServiceVersion;

// Create version 2.1.0
$version = new ServiceVersion(2, 1, 0);
echo $version; // "2.1.0"

// Parse from string
$version = ServiceVersion::fromString('v2.1.0');

// Default version (1.0.0)
$version = ServiceVersion::initial();

Semantic Versioning Rules

Change Version Bump Description
Major 1.0.0 -> 2.0.0 Breaking changes to the service contract
Minor 1.0.0 -> 1.1.0 New features, backwards compatible
Patch 1.0.0 -> 1.0.1 Bug fixes, backwards compatible

Implementing Custom Versions

Override the version() method from the trait:

use Core\Service\ServiceVersion;
use Core\Service\Concerns\HasServiceVersion;

class MyService implements ServiceDefinition
{
    use HasServiceVersion;

    public static function version(): ServiceVersion
    {
        return new ServiceVersion(2, 3, 1);
    }
}

Service Deprecation

Mark services as deprecated with migration guidance:

public static function version(): ServiceVersion
{
    return (new ServiceVersion(1, 0, 0))
        ->deprecate(
            'Migrate to BillingV2 - see docs/migration.md',
            new \DateTimeImmutable('2026-06-01')
        );
}

Deprecation Lifecycle

[Active] ──deprecate()──> [Deprecated] ──isPastSunset()──> [Sunset]
State Behavior
Active Service fully operational
Deprecated Works but logs warnings; consumers should migrate
Sunset Past sunset date; may throw exceptions

Checking Deprecation Status

$version = MyService::version();

// Check if deprecated
if ($version->deprecated) {
    echo $version->deprecationMessage;
    echo $version->sunsetDate->format('Y-m-d');
}

// Check if past sunset
if ($version->isPastSunset()) {
    throw new ServiceSunsetException('This service is no longer available');
}

// Version compatibility
$minimum = new ServiceVersion(1, 5, 0);
$current = new ServiceVersion(1, 8, 2);
$current->isCompatibleWith($minimum); // true (same major, >= minor.patch)

Dependency Resolution

Services can declare dependencies on other services, and the framework resolves them automatically.

Declaring Dependencies

use Core\Service\Contracts\ServiceDependency;

public static function dependencies(): array
{
    return [
        // Required dependency - service fails if not available
        ServiceDependency::required('auth', '>=1.0.0'),

        // Optional dependency - service works with reduced functionality
        ServiceDependency::optional('analytics'),

        // Version range constraints
        ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
    ];
}

Version Constraints

Constraint Meaning
>=1.0.0 Minimum version 1.0.0
<3.0.0 Maximum version below 3.0.0
>=2.0.0, <3.0.0 Version 2.x only
null Any version

Using ServiceDiscovery

use Core\Service\ServiceDiscovery;

$discovery = app(ServiceDiscovery::class);

// Get all registered services
$services = $discovery->discover();

// Check if a service is available
if ($discovery->has('billing')) {
    $billingClass = $discovery->get('billing');
    $billing = $discovery->getInstance('billing');
}

// Get services in dependency order
$ordered = $discovery->getResolutionOrder();

// Validate all dependencies
$missing = $discovery->validateDependencies();
if (!empty($missing)) {
    foreach ($missing as $service => $deps) {
        logger()->error("Service {$service} missing: " . implode(', ', $deps));
    }
}

Resolution Order

The framework uses topological sorting to resolve services in the correct order:

// Services are resolved so dependencies come first
$ordered = $discovery->getResolutionOrder();
// Returns: ['auth', 'analytics', 'billing']
// (auth before billing if billing depends on auth)

Handling Circular Dependencies

Circular dependencies are detected and throw ServiceDependencyException:

use Core\Service\ServiceDependencyException;

try {
    $ordered = $discovery->getResolutionOrder();
} catch (ServiceDependencyException $e) {
    // Circular dependency: auth -> billing -> auth
    echo $e->getMessage();
    print_r($e->getDependencyChain());
}

Manual Service Registration

Register services programmatically when auto-discovery is not desired:

$discovery = app(ServiceDiscovery::class);

// Register with validation
$discovery->register(BillingService::class);

// Register without validation
$discovery->register(BillingService::class, validate: false);

// Add additional scan paths
$discovery->addPath(base_path('packages/my-package/src'));

// Clear discovery cache
$discovery->clearCache();

Health Monitoring

Services can implement health checks for operational monitoring.

Implementing HealthCheckable

use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\HealthCheckable;
use Core\Service\HealthCheckResult;

class BillingService implements ServiceDefinition, HealthCheckable
{
    // ... service definition methods ...

    public function healthCheck(): HealthCheckResult
    {
        try {
            $start = microtime(true);

            // Test critical dependencies
            $stripeConnected = $this->stripe->testConnection();

            $responseTime = (microtime(true) - $start) * 1000;

            if (!$stripeConnected) {
                return HealthCheckResult::unhealthy(
                    'Cannot connect to Stripe',
                    ['stripe_status' => 'disconnected']
                );
            }

            if ($responseTime > 1000) {
                return HealthCheckResult::degraded(
                    'Stripe responding slowly',
                    ['response_time_ms' => $responseTime],
                    responseTimeMs: $responseTime
                );
            }

            return HealthCheckResult::healthy(
                'All billing systems operational',
                ['stripe_status' => 'connected'],
                responseTimeMs: $responseTime
            );
        } catch (\Exception $e) {
            return HealthCheckResult::fromException($e);
        }
    }
}

Health Check Result States

Status Method Description
Healthy HealthCheckResult::healthy() Fully operational
Degraded HealthCheckResult::degraded() Working with reduced performance
Unhealthy HealthCheckResult::unhealthy() Not operational
Unknown HealthCheckResult::unknown() Status cannot be determined

Health Check Guidelines

  • Fast - Complete within 5 seconds (preferably < 1 second)
  • Non-destructive - Read-only operations only
  • Representative - Test actual critical dependencies
  • Safe - Catch all exceptions, return HealthCheckResult

Aggregating Health Checks

use Core\Service\Enums\ServiceStatus;

// Get all health check results
$results = [];
foreach ($discovery->discover() as $code => $class) {
    $instance = $discovery->getInstance($code);

    if ($instance instanceof HealthCheckable) {
        $results[$code] = $instance->healthCheck();
    }
}

// Determine overall status
$statuses = array_map(fn($r) => $r->status, $results);
$overall = ServiceStatus::worst($statuses);

if (!$overall->isOperational()) {
    // Alert on-call team
}

Complete Example

Here is a complete service implementation with all features:

<?php

namespace Mod\Blog;

use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\ServiceDependency;
use Core\Service\Contracts\HealthCheckable;
use Core\Service\HealthCheckResult;
use Core\Service\ServiceVersion;

class BlogService implements ServiceDefinition, HealthCheckable
{
    public static function definition(): array
    {
        return [
            'code' => 'blog',
            'module' => 'Mod\\Blog',
            'name' => 'Blog',
            'tagline' => 'Content publishing platform',
            'description' => 'Full-featured blog with categories, tags, and comments',
            'icon' => 'newspaper',
            'color' => '#6366F1',
            'entitlement_code' => 'core.srv.blog',
            'sort_order' => 30,
        ];
    }

    public static function version(): ServiceVersion
    {
        return new ServiceVersion(2, 0, 0);
    }

    public static function dependencies(): array
    {
        return [
            ServiceDependency::required('auth', '>=1.0.0'),
            ServiceDependency::required('media', '>=1.0.0'),
            ServiceDependency::optional('seo'),
            ServiceDependency::optional('analytics'),
        ];
    }

    public function menuItems(): array
    {
        return [
            [
                'label' => 'Blog',
                'icon' => 'newspaper',
                'route' => 'admin.blog.index',
                'order' => 30,
                'children' => [
                    ['label' => 'Posts', 'route' => 'admin.blog.posts'],
                    ['label' => 'Categories', 'route' => 'admin.blog.categories'],
                    ['label' => 'Tags', 'route' => 'admin.blog.tags'],
                ],
            ],
        ];
    }

    public function healthCheck(): HealthCheckResult
    {
        try {
            $postsTable = \DB::table('posts')->exists();

            if (!$postsTable) {
                return HealthCheckResult::unhealthy('Posts table not found');
            }

            return HealthCheckResult::healthy('Blog service operational');
        } catch (\Exception $e) {
            return HealthCheckResult::fromException($e);
        }
    }
}

Configuration

Configure service discovery in config/core.php:

return [
    'services' => [
        // Enable/disable discovery caching
        'cache_discovery' => env('CORE_CACHE_SERVICES', true),

        // Cache TTL in seconds (default: 1 hour)
        'cache_ttl' => 3600,
    ],

    // Paths to scan for services
    'module_paths' => [
        app_path('Core'),
        app_path('Mod'),
        app_path('Website'),
        app_path('Plug'),
    ],
];

Learn More