feat: initial core/php-service — service discovery + landing pages

Extracts Core\Service (discovery, dependencies, health) and
Core\Website\Service (marketing landing pages) from core/php.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 18:55:24 +00:00
commit 2dc7bcd3e6
23 changed files with 2761 additions and 0 deletions

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
European Union Public Licence
Version 1.2
EUPL © the European Union 2007, 2016
This European Union Public Licence (the 'EUPL') applies to the Work (as
defined below) which is provided under the terms of this Licence. Any use
of the Work, other than as authorised under this Licence is prohibited (to
the extent such use is covered by a right of the copyright holder of the
Work).
The Work is provided under the terms of this Licence when the Licensor (as
defined below) has placed the following notice immediately following the
copyright notice for the Work:
Licensed under the EUPL
or has expressed by any other means his willingness to license under the
EUPL.
For the full licence text, see:
https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12

25
composer.json Normal file
View file

@ -0,0 +1,25 @@
{
"name": "lthn/service",
"description": "SaaS service discovery, dependency resolution, and landing pages",
"license": "EUPL-1.2",
"require": {
"php": "^8.2",
"lthn/php": "*"
},
"autoload": {
"psr-4": {
"Core\\Service\\": "src/Core/Service/",
"Core\\Website\\Service\\": "src/Website/Service/"
}
},
"replace": {
"core/php-service": "self.version"
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"minimum-stability": "dev",
"prefer-stable": true
}

View file

@ -0,0 +1,78 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Concerns;
use Core\Service\Contracts\ServiceDependency;
use Core\Service\ServiceVersion;
/**
* Default implementation of service versioning and dependencies.
*
* Use this trait in ServiceDefinition implementations to provide
* backward-compatible defaults for versioning and dependencies.
* Override version() or dependencies() to customise.
*
* Example:
* ```php
* class MyService implements ServiceDefinition
* {
* use HasServiceVersion;
*
* // Uses default version 1.0.0 and no dependencies
* }
* ```
*
* Or with custom version and dependencies:
* ```php
* class MyService implements ServiceDefinition
* {
* use HasServiceVersion;
*
* public static function version(): ServiceVersion
* {
* return new ServiceVersion(2, 3, 1);
* }
*
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ];
* }
* }
* ```
*/
trait HasServiceVersion
{
/**
* Get the service contract version.
*
* Override this method to specify a custom version or deprecation.
*/
public static function version(): ServiceVersion
{
return ServiceVersion::initial();
}
/**
* Get the service dependencies.
*
* Override this method to declare dependencies on other services.
* By default, services have no dependencies.
*
* @return array<ServiceDependency>
*/
public static function dependencies(): array
{
return [];
}
}

View file

@ -0,0 +1,80 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Contracts;
use Core\Service\HealthCheckResult;
/**
* Contract for services that provide health checks.
*
* Services implementing this interface can report their operational status
* for monitoring, load balancing, and alerting purposes. Health endpoints
* can aggregate results from all registered HealthCheckable services.
*
* ## Health Check Guidelines
*
* Health checks should be:
*
* - **Fast** - Complete within 5 seconds (preferably < 1 second)
* - **Non-destructive** - Perform read-only operations only
* - **Representative** - Actually test the critical dependencies
* - **Safe** - Handle all exceptions and return HealthCheckResult
*
* ## Result States
*
* Use `HealthCheckResult` factory methods:
*
* - `healthy()` - Service is fully operational
* - `degraded()` - Service works but with reduced performance/capability
* - `unhealthy()` - Service is not operational
* - `fromException()` - Convert exception to unhealthy result
*
* ## Example Implementation
*
* ```php
* public function healthCheck(): HealthCheckResult
* {
* try {
* $start = microtime(true);
* $this->database->select('SELECT 1');
* $responseTime = (microtime(true) - $start) * 1000;
*
* if ($responseTime > 1000) {
* return HealthCheckResult::degraded(
* 'Database responding slowly',
* ['response_time_ms' => $responseTime]
* );
* }
*
* return HealthCheckResult::healthy(
* 'All systems operational',
* responseTimeMs: $responseTime
* );
* } catch (\Exception $e) {
* return HealthCheckResult::fromException($e);
* }
* }
* ```
*
*
* @see HealthCheckResult For result factory methods
*/
interface HealthCheckable
{
/**
* Perform a health check and return the result.
*
* Implementations should catch all exceptions and return
* an appropriate HealthCheckResult rather than throwing.
*/
public function healthCheck(): HealthCheckResult;
}

View file

@ -0,0 +1,158 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Contracts;
use Core\Front\Admin\Contracts\AdminMenuProvider;
use Core\Service\ServiceVersion;
/**
* Contract for SaaS service definitions.
*
* Services are the product layer of the framework - they define how modules are
* presented to users as SaaS products. Each service has a definition that:
*
* - Populates the `platform_services` table for entitlement management
* - Integrates with the admin menu system via `AdminMenuProvider`
* - Provides versioning for API compatibility and deprecation tracking
*
* ## Service Lifecycle Stages
*
* Services progress through several lifecycle stages:
*
* | Stage | Description | Key Methods |
* |-------|-------------|-------------|
* | Discovery | Service class found in module paths | `definition()` |
* | Validation | Dependencies checked and verified | `dependencies()`, `version()` |
* | Initialization | Service instantiated in correct order | Constructor, DI |
* | Runtime | Service handles requests, provides menu | `menuItems()`, `healthCheck()` |
* | Deprecation | Service marked for removal | `version()->deprecate()` |
* | Sunset | Service no longer available | `version()->isPastSunset()` |
*
* ## Service Definition Array
*
* The `definition()` method returns an array with service metadata:
*
* ```php
* public static function definition(): array
* {
* return [
* 'code' => 'bio', // Unique service code
* 'module' => 'Mod\\Bio', // Module namespace
* 'name' => 'BioHost', // Display name
* 'tagline' => 'Link in bio pages', // Short description
* 'description' => 'Create beautiful...', // Full description
* 'icon' => 'link', // FontAwesome icon
* 'color' => '#3B82F6', // Brand color
* 'entitlement_code' => 'core.srv.bio', // Access control code
* 'sort_order' => 10, // Menu ordering
* ];
* }
* ```
*
* ## Versioning
*
* Services should implement `version()` to declare their contract version.
* This enables tracking breaking changes and deprecation:
*
* ```php
* public static function version(): ServiceVersion
* {
* return new ServiceVersion(2, 1, 0);
* }
*
* // For deprecated services:
* public static function version(): ServiceVersion
* {
* return (new ServiceVersion(1, 0, 0))
* ->deprecate('Use ServiceV2 instead', new \DateTimeImmutable('2025-06-01'));
* }
* ```
*
* ## Health Monitoring
*
* For services that need health monitoring, also implement `HealthCheckable`:
*
* ```php
* class MyService implements ServiceDefinition, HealthCheckable
* {
* public function healthCheck(): HealthCheckResult
* {
* return HealthCheckResult::healthy('All systems operational');
* }
* }
* ```
*
*
* @see AdminMenuProvider For menu integration
* @see ServiceVersion For versioning
* @see ServiceDependency For declaring dependencies
* @see HealthCheckable For health monitoring
* @see ServiceDiscovery For the discovery and resolution process
*/
interface ServiceDefinition extends AdminMenuProvider
{
/**
* Get the service definition for seeding platform_services.
*
* @return array{
* code: string,
* module: string,
* name: string,
* tagline?: string,
* description?: string,
* icon?: string,
* color?: string,
* entitlement_code?: string,
* sort_order?: int,
* }
*/
public static function definition(): array;
/**
* Get the service contract version.
*
* Implementations should return a ServiceVersion indicating the
* current version of this service's contract. This is used for:
* - Compatibility checking between service consumers and providers
* - Deprecation tracking and sunset enforcement
* - Migration planning when breaking changes are introduced
*
* Default implementation returns version 1.0.0 for backward
* compatibility with existing services.
*/
public static function version(): ServiceVersion;
/**
* Get the service dependencies.
*
* Declare other services that this service depends on. The framework
* uses this information to:
* - Validate that required dependencies are available at boot time
* - Resolve services in correct dependency order
* - Detect circular dependencies
*
* ## Example
*
* ```php
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ServiceDependency::optional('analytics'),
* ];
* }
* ```
*
* @return array<ServiceDependency>
*/
public static function dependencies(): array;
}

View file

@ -0,0 +1,150 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Contracts;
/**
* Represents a dependency on another service.
*
* Services can declare dependencies on other services using this class,
* enabling the framework to validate service availability, resolve
* dependencies in the correct order, and detect circular dependencies.
*
* ## Dependency Types
*
* - **Required**: Service cannot function without this dependency
* - **Optional**: Service works with reduced functionality if absent
*
* ## Example Usage
*
* ```php
* public static function dependencies(): array
* {
* return [
* // Required dependency on auth service
* ServiceDependency::required('auth', '>=1.0.0'),
*
* // Optional dependency on analytics
* ServiceDependency::optional('analytics'),
*
* // Required with minimum version
* ServiceDependency::required('billing', '>=2.0.0', '<3.0.0'),
* ];
* }
* ```
*/
final readonly class ServiceDependency
{
/**
* @param string $serviceCode The code of the required service
* @param bool $required Whether this dependency is required
* @param string|null $minVersion Minimum version constraint (e.g., ">=1.0.0")
* @param string|null $maxVersion Maximum version constraint (e.g., "<3.0.0")
*/
public function __construct(
public string $serviceCode,
public bool $required = true,
public ?string $minVersion = null,
public ?string $maxVersion = null,
) {}
/**
* Create a required dependency.
*/
public static function required(
string $serviceCode,
?string $minVersion = null,
?string $maxVersion = null
): self {
return new self($serviceCode, true, $minVersion, $maxVersion);
}
/**
* Create an optional dependency.
*/
public static function optional(
string $serviceCode,
?string $minVersion = null,
?string $maxVersion = null
): self {
return new self($serviceCode, false, $minVersion, $maxVersion);
}
/**
* Check if a version satisfies this dependency's constraints.
*/
public function satisfiedBy(string $version): bool
{
if ($this->minVersion !== null && ! $this->checkConstraint($version, $this->minVersion)) {
return false;
}
if ($this->maxVersion !== null && ! $this->checkConstraint($version, $this->maxVersion)) {
return false;
}
return true;
}
/**
* Check a single version constraint.
*/
protected function checkConstraint(string $version, string $constraint): bool
{
// Parse constraint (e.g., ">=1.0.0" or "<3.0.0")
if (preg_match('/^([<>=!]+)?(.+)$/', $constraint, $matches)) {
$operator = $matches[1] ?: '==';
$constraintVersion = ltrim($matches[2], 'v');
$version = ltrim($version, 'v');
return version_compare($version, $constraintVersion, $operator);
}
return true;
}
/**
* Get a human-readable description of the constraint.
*/
public function getConstraintDescription(): string
{
$parts = [];
if ($this->minVersion !== null) {
$parts[] = $this->minVersion;
}
if ($this->maxVersion !== null) {
$parts[] = $this->maxVersion;
}
if (empty($parts)) {
return 'any version';
}
return implode(' ', $parts);
}
/**
* Convert to array for serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'service_code' => $this->serviceCode,
'required' => $this->required,
'min_version' => $this->minVersion,
'max_version' => $this->maxVersion,
], fn ($v) => $v !== null);
}
}

View file

@ -0,0 +1,96 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Enums;
/**
* Service operational status.
*
* Represents the current state of a service for health monitoring
* and status reporting purposes.
*/
enum ServiceStatus: string
{
case HEALTHY = 'healthy';
case DEGRADED = 'degraded';
case UNHEALTHY = 'unhealthy';
case UNKNOWN = 'unknown';
/**
* Check if the status indicates the service is operational.
*/
public function isOperational(): bool
{
return match ($this) {
self::HEALTHY, self::DEGRADED => true,
self::UNHEALTHY, self::UNKNOWN => false,
};
}
/**
* Get a human-readable label for the status.
*/
public function label(): string
{
return match ($this) {
self::HEALTHY => 'Healthy',
self::DEGRADED => 'Degraded',
self::UNHEALTHY => 'Unhealthy',
self::UNKNOWN => 'Unknown',
};
}
/**
* Get the severity level for logging/alerting.
* Lower values = more severe.
*/
public function severity(): int
{
return match ($this) {
self::HEALTHY => 0,
self::DEGRADED => 1,
self::UNHEALTHY => 2,
self::UNKNOWN => 3,
};
}
/**
* Create status from a boolean health check result.
*/
public static function fromBoolean(bool $healthy): self
{
return $healthy ? self::HEALTHY : self::UNHEALTHY;
}
/**
* Get the worst status from multiple statuses.
*
* @param array<self> $statuses
*/
public static function worst(array $statuses): self
{
if (empty($statuses)) {
return self::UNKNOWN;
}
$worstSeverity = -1;
$worstStatus = self::HEALTHY;
foreach ($statuses as $status) {
if ($status->severity() > $worstSeverity) {
$worstSeverity = $status->severity();
$worstStatus = $status;
}
}
return $worstStatus;
}
}

View file

@ -0,0 +1,110 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service;
use Core\Service\Enums\ServiceStatus;
/**
* Result of a service health check.
*
* Encapsulates the status, message, and any diagnostic data
* returned from a health check operation.
*/
final readonly class HealthCheckResult
{
/**
* @param array<string, mixed> $data Additional diagnostic data
*/
public function __construct(
public ServiceStatus $status,
public string $message = '',
public array $data = [],
public ?float $responseTimeMs = null,
) {}
/**
* Create a healthy result.
*
* @param array<string, mixed> $data
*/
public static function healthy(string $message = 'Service is healthy', array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::HEALTHY, $message, $data, $responseTimeMs);
}
/**
* Create a degraded result.
*
* @param array<string, mixed> $data
*/
public static function degraded(string $message, array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::DEGRADED, $message, $data, $responseTimeMs);
}
/**
* Create an unhealthy result.
*
* @param array<string, mixed> $data
*/
public static function unhealthy(string $message, array $data = [], ?float $responseTimeMs = null): self
{
return new self(ServiceStatus::UNHEALTHY, $message, $data, $responseTimeMs);
}
/**
* Create an unknown status result.
*
* @param array<string, mixed> $data
*/
public static function unknown(string $message = 'Health check not available', array $data = []): self
{
return new self(ServiceStatus::UNKNOWN, $message, $data);
}
/**
* Create a result from an exception.
*/
public static function fromException(\Throwable $e): self
{
return self::unhealthy(
message: $e->getMessage(),
data: [
'exception' => get_class($e),
'code' => $e->getCode(),
]
);
}
/**
* Check if the result indicates operational status.
*/
public function isOperational(): bool
{
return $this->status->isOperational();
}
/**
* Convert to array for JSON serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'status' => $this->status->value,
'message' => $this->message,
'data' => $this->data ?: null,
'response_time_ms' => $this->responseTimeMs,
], fn ($v) => $v !== null);
}
}

View file

@ -0,0 +1,78 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service;
/**
* Exception thrown when service dependencies cannot be resolved.
*
* This exception is thrown in the following scenarios:
*
* - Circular dependency detected between services
* - Required dependency is missing
* - Dependency version constraint not satisfied
*/
class ServiceDependencyException extends \RuntimeException
{
/**
* @var array<string>
*/
protected array $dependencyChain = [];
/**
* Create exception for circular dependency.
*
* @param array<string> $chain The chain of services that form the cycle
*/
public static function circular(array $chain): self
{
$exception = new self(
'Circular dependency detected: '.implode(' -> ', $chain)
);
$exception->dependencyChain = $chain;
return $exception;
}
/**
* Create exception for missing required dependency.
*/
public static function missing(string $service, string $dependency): self
{
return new self(
"Service '{$service}' requires '{$dependency}' which is not available"
);
}
/**
* Create exception for version constraint not satisfied.
*/
public static function versionMismatch(
string $service,
string $dependency,
string $required,
string $available
): self {
return new self(
"Service '{$service}' requires '{$dependency}' {$required}, but {$available} is installed"
);
}
/**
* Get the dependency chain that caused the error.
*
* @return array<string>
*/
public function getDependencyChain(): array
{
return $this->dependencyChain;
}
}

View file

@ -0,0 +1,748 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service;
use Core\Service\Contracts\ServiceDefinition;
use Core\Service\Contracts\ServiceDependency;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
/**
* Discovers and manages service definitions across the codebase.
*
* Scans configured module paths for classes implementing ServiceDefinition,
* validates dependencies between services, and provides resolution order
* for service initialization.
*
* ## Service Lifecycle Overview
*
* The Core PHP framework manages services through a well-defined lifecycle:
*
* ```
* ┌─────────────────────────────────────────────────────────────────┐
* SERVICE LIFECYCLE
* ├─────────────────────────────────────────────────────────────────┤
*
* 1. DISCOVERY PHASE
* ├── Scan module paths for ServiceDefinition implementations
* ├── Parse definition() arrays to extract service metadata
* └── Build service registry indexed by service code
*
* 2. DEPENDENCY RESOLUTION PHASE
* ├── Collect dependencies() from each service
* ├── Validate required dependencies are available
* ├── Check version constraints are satisfied
* └── Detect and prevent circular dependencies
*
* 3. INITIALIZATION PHASE (dependency order)
* ├── Services initialized in topologically sorted order
* ├── Dependencies always initialized before dependents
* └── Service instances registered in container
*
* 4. RUNTIME PHASE
* ├── Services respond to health checks (HealthCheckable)
* ├── Version() provides compatibility information
* └── Admin menu items rendered (AdminMenuProvider)
*
* 5. DEPRECATION/SUNSET PHASE
* ├── Deprecated services log warnings on access
* ├── Services past sunset date may throw exceptions
* └── Migration to replacement services should occur
*
* └─────────────────────────────────────────────────────────────────┘
* ```
*
* ## Creating a New Service
*
* 1. **Implement ServiceDefinition**: Create a class implementing the interface
* 2. **Define Metadata**: Provide service code, name, description via `definition()`
* 3. **Declare Dependencies**: List required/optional services via `dependencies()`
* 4. **Set Version**: Specify the service contract version via `version()`
* 5. **Add Health Checks**: Implement `HealthCheckable` for monitoring
*
* ```php
* use Core\Service\Contracts\ServiceDefinition;
* use Core\Service\Contracts\HealthCheckable;
* use Core\Service\Concerns\HasServiceVersion;
*
* class BillingService implements ServiceDefinition, HealthCheckable
* {
* use HasServiceVersion;
*
* public static function definition(): array
* {
* return [
* 'code' => 'billing',
* 'module' => 'Mod\\Billing',
* 'name' => 'Billing Service',
* 'tagline' => 'Handle payments and subscriptions',
* 'icon' => 'credit-card',
* 'color' => '#10B981',
* ];
* }
*
* public static function dependencies(): array
* {
* return [
* ServiceDependency::required('auth', '>=1.0.0'),
* ServiceDependency::optional('analytics'),
* ];
* }
*
* public function healthCheck(): HealthCheckResult
* {
* // Check payment provider connectivity
* return HealthCheckResult::healthy();
* }
* }
* ```
*
* ## Discovery Process
*
* 1. Scans module paths for classes implementing ServiceDefinition
* 2. Validates each service's dependencies are available
* 3. Detects circular dependencies
* 4. Returns services in dependency-resolved order
*
* ## Caching
*
* Discovery results are cached for 1 hour (configurable). Clear the cache when
* adding new services or modifying dependencies:
*
* ```php
* $discovery->clearCache();
* ```
*
* Caching can be disabled via config: `core.services.cache_discovery => false`
*
* ## Example Usage
*
* ```php
* $discovery = app(ServiceDiscovery::class);
*
* // Get all registered services
* $services = $discovery->discover();
*
* // Get services in dependency order
* $ordered = $discovery->getResolutionOrder();
*
* // Check if a service is available
* $available = $discovery->has('billing');
*
* // Get a specific service definition class
* $billingClass = $discovery->get('billing');
*
* // Get instantiated service
* $billing = $discovery->getInstance('billing');
*
* // Validate all dependencies are satisfied
* $missing = $discovery->validateDependencies();
* if (!empty($missing)) {
* foreach ($missing as $service => $deps) {
* logger()->error("Service {$service} missing: " . implode(', ', $deps));
* }
* }
* ```
*
*
* @see ServiceDefinition For the service contract interface
* @see ServiceVersion For versioning and deprecation
* @see ServiceDependency For declaring dependencies
* @see HealthCheckable For service health monitoring
*/
class ServiceDiscovery
{
/**
* Cache key for discovered services.
*/
protected const CACHE_KEY = 'core.services.discovered';
/**
* Cache TTL in seconds (1 hour by default).
*/
protected const CACHE_TTL = 3600;
/**
* Discovered service definitions indexed by service code.
*
* @var array<string, class-string<ServiceDefinition>>
*/
protected array $services = [];
/**
* Whether discovery has been performed.
*/
protected bool $discovered = false;
/**
* Additional paths to scan for services.
*
* @var array<string>
*/
protected array $additionalPaths = [];
/**
* Manually registered service classes.
*
* @var array<class-string<ServiceDefinition>>
*/
protected array $registered = [];
/**
* Add a path to scan for service definitions.
*/
public function addPath(string $path): self
{
$this->additionalPaths[] = $path;
$this->discovered = false;
return $this;
}
/**
* Manually register a service definition class.
*
* Validates the service definition before registering. The validation checks:
* - Class exists and implements ServiceDefinition
* - Definition array contains required 'code' field
* - Definition array contains required 'module' field
* - Definition array contains required 'name' field
* - Code is a non-empty string with valid format
*
* @param class-string<ServiceDefinition> $serviceClass
* @param bool $validate Whether to validate the service definition (default: true)
*
* @throws \InvalidArgumentException If validation fails
*/
public function register(string $serviceClass, bool $validate = true): self
{
if ($validate) {
$this->validateServiceClass($serviceClass);
}
$this->registered[] = $serviceClass;
$this->discovered = false;
return $this;
}
/**
* Validate a service definition class.
*
* @param class-string<ServiceDefinition> $serviceClass
*
* @throws \InvalidArgumentException If validation fails
*/
public function validateServiceClass(string $serviceClass): void
{
// Check class exists
if (! class_exists($serviceClass)) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' does not exist"
);
}
// Check implements interface
if (! is_subclass_of($serviceClass, ServiceDefinition::class)) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' must implement ".ServiceDefinition::class
);
}
// Validate definition array
try {
$definition = $serviceClass::definition();
} catch (\Throwable $e) {
throw new \InvalidArgumentException(
"Service class '{$serviceClass}' definition() method threw an exception: ".$e->getMessage()
);
}
$this->validateDefinitionArray($serviceClass, $definition);
}
/**
* Validate a service definition array.
*
*
* @throws \InvalidArgumentException If validation fails
*/
protected function validateDefinitionArray(string $serviceClass, array $definition): void
{
$requiredFields = ['code', 'module', 'name'];
$missingFields = [];
foreach ($requiredFields as $field) {
if (! isset($definition[$field]) || $definition[$field] === '') {
$missingFields[] = $field;
}
}
if (! empty($missingFields)) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' definition() is missing required fields: ".implode(', ', $missingFields)
);
}
// Validate code format (should be a simple identifier)
if (! is_string($definition['code'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' code must be a string"
);
}
if (! preg_match('/^[a-z][a-z0-9_-]*$/i', $definition['code'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' code '{$definition['code']}' is invalid. ".
'Code must start with a letter and contain only letters, numbers, underscores, and hyphens.'
);
}
// Validate module namespace format
if (! is_string($definition['module'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' module must be a string"
);
}
// Validate optional fields if present
if (isset($definition['sort_order']) && ! is_int($definition['sort_order'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' sort_order must be an integer"
);
}
if (isset($definition['color']) && ! $this->isValidColor($definition['color'])) {
throw new \InvalidArgumentException(
"Service '{$serviceClass}' color '{$definition['color']}' is invalid. ".
'Color must be a valid hex color (e.g., #3B82F6)'
);
}
}
/**
* Check if a color string is a valid hex color.
*/
protected function isValidColor(mixed $color): bool
{
if (! is_string($color)) {
return false;
}
return (bool) preg_match('/^#[0-9A-Fa-f]{3}([0-9A-Fa-f]{3})?$/', $color);
}
/**
* Validate all registered services and return validation errors.
*
* @return array<string, array<string>> Array of service code => validation errors
*/
public function validateAll(): array
{
$this->discover();
$errors = [];
foreach ($this->services as $code => $class) {
try {
$this->validateServiceClass($class);
} catch (\InvalidArgumentException $e) {
$errors[$code][] = $e->getMessage();
}
// Also validate dependencies
$depErrors = $this->validateServiceDependenciesForService($code, $class);
if (! empty($depErrors)) {
$errors[$code] = array_merge($errors[$code] ?? [], $depErrors);
}
}
return $errors;
}
/**
* Validate dependencies for a specific service.
*
* @param class-string<ServiceDefinition> $class
* @return array<string>
*/
protected function validateServiceDependenciesForService(string $code, string $class): array
{
$errors = [];
$dependencies = $this->getServiceDependencies($class);
foreach ($dependencies as $dependency) {
if ($dependency->required && ! $this->has($dependency->serviceCode)) {
$errors[] = "Required dependency '{$dependency->serviceCode}' is not available";
}
// Check version constraint
if ($this->has($dependency->serviceCode)) {
$depClass = $this->get($dependency->serviceCode);
if ($depClass !== null) {
$version = $depClass::version()->toString();
if (! $dependency->satisfiedBy($version)) {
$errors[] = sprintf(
"Dependency '%s' version mismatch: requires %s, found %s",
$dependency->serviceCode,
$dependency->getConstraintDescription(),
$version
);
}
}
}
}
return $errors;
}
/**
* Discover all service definitions.
*
* @return Collection<string, class-string<ServiceDefinition>>
*/
public function discover(): Collection
{
if ($this->discovered) {
return collect($this->services);
}
// Try to load from cache
$cached = $this->loadFromCache();
if ($cached !== null) {
$this->services = $cached;
$this->discovered = true;
return collect($this->services);
}
// Perform discovery
$this->services = $this->performDiscovery();
$this->discovered = true;
// Cache results
$this->saveToCache($this->services);
return collect($this->services);
}
/**
* Check if a service is available.
*/
public function has(string $serviceCode): bool
{
$this->discover();
return isset($this->services[$serviceCode]);
}
/**
* Get a service definition class by code.
*
* @return class-string<ServiceDefinition>|null
*/
public function get(string $serviceCode): ?string
{
$this->discover();
return $this->services[$serviceCode] ?? null;
}
/**
* Get service instance by code.
*/
public function getInstance(string $serviceCode): ?ServiceDefinition
{
$class = $this->get($serviceCode);
if ($class === null) {
return null;
}
return app($class);
}
/**
* Get services in dependency resolution order.
*
* Services are ordered so that dependencies come before dependents.
*
* @return Collection<int, class-string<ServiceDefinition>>
*
* @throws ServiceDependencyException If circular dependency detected
*/
public function getResolutionOrder(): Collection
{
$this->discover();
$resolved = [];
$resolving = [];
foreach ($this->services as $code => $class) {
$this->resolveService($code, $resolved, $resolving);
}
return collect($resolved);
}
/**
* Validate all service dependencies.
*
* @return array<string, array<string>> Array of service code => missing dependencies
*/
public function validateDependencies(): array
{
$this->discover();
$missing = [];
foreach ($this->services as $code => $class) {
$dependencies = $this->getServiceDependencies($class);
foreach ($dependencies as $dependency) {
if (! $dependency->required) {
continue;
}
if (! $this->has($dependency->serviceCode)) {
$missing[$code][] = $dependency->serviceCode;
continue;
}
// Check version constraint
$depClass = $this->get($dependency->serviceCode);
if ($depClass !== null) {
$version = $depClass::version()->toString();
if (! $dependency->satisfiedBy($version)) {
$missing[$code][] = sprintf(
'%s (%s required, %s available)',
$dependency->serviceCode,
$dependency->getConstraintDescription(),
$version
);
}
}
}
}
return $missing;
}
/**
* Clear the discovery cache.
*/
public function clearCache(): void
{
Cache::forget(self::CACHE_KEY);
$this->discovered = false;
$this->services = [];
}
/**
* Perform the actual discovery process.
*
* @return array<string, class-string<ServiceDefinition>>
*/
protected function performDiscovery(): array
{
$services = [];
// Scan configured module paths
$paths = array_merge(
config('core.module_paths', []),
$this->additionalPaths
);
foreach ($paths as $path) {
if (! is_dir($path)) {
continue;
}
$this->scanPath($path, $services);
}
// Add manually registered services
foreach ($this->registered as $class) {
if (! class_exists($class)) {
continue;
}
if (! is_subclass_of($class, ServiceDefinition::class)) {
continue;
}
$definition = $class::definition();
$code = $definition['code'] ?? null;
if ($code !== null) {
$services[$code] = $class;
}
}
return $services;
}
/**
* Scan a path for service definitions.
*
* @param array<string, class-string<ServiceDefinition>> $services
*/
protected function scanPath(string $path, array &$services): void
{
$files = File::allFiles($path);
foreach ($files as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
// Skip test files
if (str_contains($file->getPathname(), '/Tests/')) {
continue;
}
// Skip vendor directories
if (str_contains($file->getPathname(), '/vendor/')) {
continue;
}
$class = $this->getClassFromFile($file->getPathname());
if ($class === null) {
continue;
}
if (! class_exists($class)) {
continue;
}
if (! is_subclass_of($class, ServiceDefinition::class)) {
continue;
}
try {
$definition = $class::definition();
$code = $definition['code'] ?? null;
if ($code !== null) {
$services[$code] = $class;
}
} catch (\Throwable) {
// Skip services that throw during definition()
}
}
}
/**
* Extract class name from a PHP file.
*/
protected function getClassFromFile(string $filePath): ?string
{
$contents = file_get_contents($filePath);
if ($contents === false) {
return null;
}
$namespace = null;
$class = null;
// Extract namespace
if (preg_match('/namespace\s+([^;]+);/', $contents, $matches)) {
$namespace = $matches[1];
}
// Extract class name
if (preg_match('/class\s+(\w+)/', $contents, $matches)) {
$class = $matches[1];
}
if ($namespace !== null && $class !== null) {
return $namespace.'\\'.$class;
}
return null;
}
/**
* Get dependencies for a service class.
*
* @param class-string<ServiceDefinition> $class
* @return array<ServiceDependency>
*/
protected function getServiceDependencies(string $class): array
{
if (! method_exists($class, 'dependencies')) {
return [];
}
return $class::dependencies();
}
/**
* Resolve a service and its dependencies.
*
* @param array<class-string<ServiceDefinition>> $resolved
* @param array<string> $resolving
*
* @throws ServiceDependencyException If circular dependency detected
*/
protected function resolveService(string $code, array &$resolved, array &$resolving): void
{
if (in_array($code, $resolving)) {
throw new ServiceDependencyException(
'Circular dependency detected: '.implode(' -> ', [...$resolving, $code])
);
}
$class = $this->services[$code] ?? null;
if ($class === null || in_array($class, $resolved)) {
return;
}
$resolving[] = $code;
foreach ($this->getServiceDependencies($class) as $dependency) {
if ($this->has($dependency->serviceCode)) {
$this->resolveService($dependency->serviceCode, $resolved, $resolving);
}
}
array_pop($resolving);
$resolved[] = $class;
}
/**
* Load discovered services from cache.
*
* @return array<string, class-string<ServiceDefinition>>|null
*/
protected function loadFromCache(): ?array
{
if (! config('core.services.cache_discovery', true)) {
return null;
}
return Cache::get(self::CACHE_KEY);
}
/**
* Save discovered services to cache.
*
* @param array<string, class-string<ServiceDefinition>> $services
*/
protected function saveToCache(array $services): void
{
if (! config('core.services.cache_discovery', true)) {
return;
}
Cache::put(self::CACHE_KEY, $services, self::CACHE_TTL);
}
}

View file

@ -0,0 +1,184 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service;
/**
* Represents a service version with deprecation information.
*
* Follows semantic versioning (major.minor.patch) with support
* for deprecation notices and sunset dates.
*
* ## Semantic Versioning
*
* Versions follow [SemVer](https://semver.org/) conventions:
*
* - **Major**: Breaking changes to the service contract
* - **Minor**: New features, backwards compatible
* - **Patch**: Bug fixes, backwards compatible
*
* ## Lifecycle States
*
* ```
* [Active] ──deprecate()──> [Deprecated] ──isPastSunset()──> [Sunset]
* ```
*
* - **Active**: Service is fully supported
* - **Deprecated**: Service works but consumers should migrate
* - **Sunset**: Service should no longer be used (past sunset date)
*
* ## Usage Examples
*
* ```php
* // Create a version
* $version = new ServiceVersion(2, 1, 0);
* echo $version; // "2.1.0"
*
* // Parse from string
* $version = ServiceVersion::fromString('v2.1.0');
*
* // Mark as deprecated with sunset date
* $version = (new ServiceVersion(1, 0, 0))
* ->deprecate(
* 'Migrate to v2.x - see docs/migration.md',
* new \DateTimeImmutable('2025-06-01')
* );
*
* // Check compatibility
* $minimum = new ServiceVersion(1, 5, 0);
* $current = new ServiceVersion(1, 8, 2);
* $current->isCompatibleWith($minimum); // true (same major, >= minor)
*
* // Check if past sunset
* if ($version->isPastSunset()) {
* throw new ServiceSunsetException('Service no longer available');
* }
* ```
*/
final readonly class ServiceVersion
{
public function __construct(
public int $major,
public int $minor = 0,
public int $patch = 0,
public bool $deprecated = false,
public ?string $deprecationMessage = null,
public ?\DateTimeInterface $sunsetDate = null,
) {}
/**
* Create a version from a string (e.g., "1.2.3").
*/
public static function fromString(string $version): self
{
$parts = explode('.', ltrim($version, 'v'));
return new self(
major: (int) ($parts[0] ?? 1),
minor: (int) ($parts[1] ?? 0),
patch: (int) ($parts[2] ?? 0),
);
}
/**
* Create a version 1.0.0 instance.
*/
public static function initial(): self
{
return new self(1, 0, 0);
}
/**
* Mark this version as deprecated.
*/
public function deprecate(string $message, ?\DateTimeInterface $sunsetDate = null): self
{
return new self(
major: $this->major,
minor: $this->minor,
patch: $this->patch,
deprecated: true,
deprecationMessage: $message,
sunsetDate: $sunsetDate,
);
}
/**
* Check if the version is past its sunset date.
*/
public function isPastSunset(): bool
{
if ($this->sunsetDate === null) {
return false;
}
return $this->sunsetDate < new \DateTimeImmutable;
}
/**
* Compare with another version.
*
* @return int -1 if less, 0 if equal, 1 if greater
*/
public function compare(self $other): int
{
if ($this->major !== $other->major) {
return $this->major <=> $other->major;
}
if ($this->minor !== $other->minor) {
return $this->minor <=> $other->minor;
}
return $this->patch <=> $other->patch;
}
/**
* Check if this version is compatible with a minimum version.
* Compatible if same major version and >= minor.patch.
*/
public function isCompatibleWith(self $minimum): bool
{
if ($this->major !== $minimum->major) {
return false;
}
return $this->compare($minimum) >= 0;
}
/**
* Get version as string.
*/
public function toString(): string
{
return "{$this->major}.{$this->minor}.{$this->patch}";
}
public function __toString(): string
{
return $this->toString();
}
/**
* Convert to array for serialization.
*
* @return array<string, mixed>
*/
public function toArray(): array
{
return array_filter([
'version' => $this->toString(),
'deprecated' => $this->deprecated ?: null,
'deprecation_message' => $this->deprecationMessage,
'sunset_date' => $this->sunsetDate?->format('Y-m-d'),
], fn ($v) => $v !== null);
}
}

View file

@ -0,0 +1,117 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Tests\Unit;
use Core\Service\Enums\ServiceStatus;
use Core\Service\HealthCheckResult;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class HealthCheckResultTest extends TestCase
{
#[Test]
public function it_creates_healthy_result(): void
{
$result = HealthCheckResult::healthy('All good', ['key' => 'value'], 15.5);
$this->assertSame(ServiceStatus::HEALTHY, $result->status);
$this->assertSame('All good', $result->message);
$this->assertSame(['key' => 'value'], $result->data);
$this->assertSame(15.5, $result->responseTimeMs);
}
#[Test]
public function it_creates_degraded_result(): void
{
$result = HealthCheckResult::degraded('Slow response');
$this->assertSame(ServiceStatus::DEGRADED, $result->status);
$this->assertSame('Slow response', $result->message);
}
#[Test]
public function it_creates_unhealthy_result(): void
{
$result = HealthCheckResult::unhealthy('Connection failed');
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
$this->assertSame('Connection failed', $result->message);
}
#[Test]
public function it_creates_unknown_result(): void
{
$result = HealthCheckResult::unknown();
$this->assertSame(ServiceStatus::UNKNOWN, $result->status);
$this->assertSame('Health check not available', $result->message);
}
#[Test]
public function it_creates_from_exception(): void
{
$exception = new \RuntimeException('Database error', 500);
$result = HealthCheckResult::fromException($exception);
$this->assertSame(ServiceStatus::UNHEALTHY, $result->status);
$this->assertSame('Database error', $result->message);
$this->assertSame('RuntimeException', $result->data['exception']);
$this->assertSame(500, $result->data['code']);
}
#[Test]
public function it_checks_operational_status(): void
{
$this->assertTrue(HealthCheckResult::healthy()->isOperational());
$this->assertTrue(HealthCheckResult::degraded('Slow')->isOperational());
$this->assertFalse(HealthCheckResult::unhealthy('Down')->isOperational());
$this->assertFalse(HealthCheckResult::unknown()->isOperational());
}
#[Test]
public function it_converts_to_array(): void
{
$result = HealthCheckResult::healthy(
'Service operational',
['version' => '1.0'],
12.34
);
$array = $result->toArray();
$this->assertSame('healthy', $array['status']);
$this->assertSame('Service operational', $array['message']);
$this->assertSame(['version' => '1.0'], $array['data']);
$this->assertSame(12.34, $array['response_time_ms']);
}
#[Test]
public function it_omits_null_values_in_array(): void
{
$result = HealthCheckResult::healthy();
$array = $result->toArray();
$this->assertArrayHasKey('status', $array);
$this->assertArrayHasKey('message', $array);
$this->assertArrayNotHasKey('data', $array);
$this->assertArrayNotHasKey('response_time_ms', $array);
}
#[Test]
public function it_uses_default_healthy_message(): void
{
$result = HealthCheckResult::healthy();
$this->assertSame('Service is healthy', $result->message);
}
}

View file

@ -0,0 +1,102 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Tests\Unit;
use Core\Service\Enums\ServiceStatus;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ServiceStatusTest extends TestCase
{
#[Test]
public function it_has_expected_values(): void
{
$this->assertSame('healthy', ServiceStatus::HEALTHY->value);
$this->assertSame('degraded', ServiceStatus::DEGRADED->value);
$this->assertSame('unhealthy', ServiceStatus::UNHEALTHY->value);
$this->assertSame('unknown', ServiceStatus::UNKNOWN->value);
}
#[Test]
public function it_checks_operational_status(): void
{
$this->assertTrue(ServiceStatus::HEALTHY->isOperational());
$this->assertTrue(ServiceStatus::DEGRADED->isOperational());
$this->assertFalse(ServiceStatus::UNHEALTHY->isOperational());
$this->assertFalse(ServiceStatus::UNKNOWN->isOperational());
}
#[Test]
public function it_provides_human_readable_labels(): void
{
$this->assertSame('Healthy', ServiceStatus::HEALTHY->label());
$this->assertSame('Degraded', ServiceStatus::DEGRADED->label());
$this->assertSame('Unhealthy', ServiceStatus::UNHEALTHY->label());
$this->assertSame('Unknown', ServiceStatus::UNKNOWN->label());
}
#[Test]
public function it_has_severity_ordering(): void
{
$this->assertLessThan(
ServiceStatus::DEGRADED->severity(),
ServiceStatus::HEALTHY->severity()
);
$this->assertLessThan(
ServiceStatus::UNHEALTHY->severity(),
ServiceStatus::DEGRADED->severity()
);
$this->assertLessThan(
ServiceStatus::UNKNOWN->severity(),
ServiceStatus::UNHEALTHY->severity()
);
}
#[Test]
public function it_creates_from_boolean(): void
{
$this->assertSame(ServiceStatus::HEALTHY, ServiceStatus::fromBoolean(true));
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::fromBoolean(false));
}
#[Test]
public function it_finds_worst_status(): void
{
$statuses = [
ServiceStatus::HEALTHY,
ServiceStatus::DEGRADED,
ServiceStatus::HEALTHY,
];
$this->assertSame(ServiceStatus::DEGRADED, ServiceStatus::worst($statuses));
}
#[Test]
public function it_returns_unknown_for_empty_array(): void
{
$this->assertSame(ServiceStatus::UNKNOWN, ServiceStatus::worst([]));
}
#[Test]
public function it_finds_most_severe_status(): void
{
$statuses = [
ServiceStatus::HEALTHY,
ServiceStatus::UNHEALTHY,
ServiceStatus::DEGRADED,
];
$this->assertSame(ServiceStatus::UNHEALTHY, ServiceStatus::worst($statuses));
}
}

View file

@ -0,0 +1,165 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Service\Tests\Unit;
use Core\Service\ServiceVersion;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class ServiceVersionTest extends TestCase
{
#[Test]
public function it_creates_version_with_all_components(): void
{
$version = new ServiceVersion(2, 3, 4);
$this->assertSame(2, $version->major);
$this->assertSame(3, $version->minor);
$this->assertSame(4, $version->patch);
$this->assertFalse($version->deprecated);
$this->assertNull($version->deprecationMessage);
$this->assertNull($version->sunsetDate);
}
#[Test]
public function it_creates_initial_version(): void
{
$version = ServiceVersion::initial();
$this->assertSame('1.0.0', $version->toString());
}
#[Test]
public function it_creates_version_from_string(): void
{
$version = ServiceVersion::fromString('2.3.4');
$this->assertSame(2, $version->major);
$this->assertSame(3, $version->minor);
$this->assertSame(4, $version->patch);
}
#[Test]
public function it_handles_string_with_v_prefix(): void
{
$version = ServiceVersion::fromString('v1.2.3');
$this->assertSame('1.2.3', $version->toString());
}
#[Test]
public function it_handles_partial_version_string(): void
{
$version = ServiceVersion::fromString('2');
$this->assertSame(2, $version->major);
$this->assertSame(0, $version->minor);
$this->assertSame(0, $version->patch);
}
#[Test]
public function it_marks_version_as_deprecated(): void
{
$version = new ServiceVersion(1, 0, 0);
$sunset = new \DateTimeImmutable('2025-06-01');
$deprecated = $version->deprecate('Use v2 instead', $sunset);
$this->assertTrue($deprecated->deprecated);
$this->assertSame('Use v2 instead', $deprecated->deprecationMessage);
$this->assertEquals($sunset, $deprecated->sunsetDate);
// Original version should be unchanged
$this->assertFalse($version->deprecated);
}
#[Test]
public function it_detects_past_sunset_date(): void
{
$version = (new ServiceVersion(1, 0, 0))
->deprecate('Old version', new \DateTimeImmutable('2020-01-01'));
$this->assertTrue($version->isPastSunset());
}
#[Test]
public function it_detects_future_sunset_date(): void
{
$version = (new ServiceVersion(1, 0, 0))
->deprecate('Will be removed', new \DateTimeImmutable('2099-01-01'));
$this->assertFalse($version->isPastSunset());
}
#[Test]
public function it_compares_versions_correctly(): void
{
$v1 = new ServiceVersion(1, 0, 0);
$v2 = new ServiceVersion(2, 0, 0);
$v1_1 = new ServiceVersion(1, 1, 0);
$v1_0_1 = new ServiceVersion(1, 0, 1);
$this->assertSame(-1, $v1->compare($v2));
$this->assertSame(1, $v2->compare($v1));
$this->assertSame(0, $v1->compare(new ServiceVersion(1, 0, 0)));
$this->assertSame(-1, $v1->compare($v1_1));
$this->assertSame(-1, $v1->compare($v1_0_1));
}
#[Test]
public function it_checks_compatibility(): void
{
$v2_3 = new ServiceVersion(2, 3, 0);
$v2_1 = new ServiceVersion(2, 1, 0);
$v2_5 = new ServiceVersion(2, 5, 0);
$v3_0 = new ServiceVersion(3, 0, 0);
$this->assertTrue($v2_3->isCompatibleWith($v2_1));
$this->assertFalse($v2_3->isCompatibleWith($v2_5));
$this->assertFalse($v2_3->isCompatibleWith($v3_0));
}
#[Test]
public function it_converts_to_string(): void
{
$version = new ServiceVersion(1, 2, 3);
$this->assertSame('1.2.3', $version->toString());
$this->assertSame('1.2.3', (string) $version);
}
#[Test]
public function it_converts_to_array(): void
{
$version = (new ServiceVersion(1, 2, 3))
->deprecate('Use v2', new \DateTimeImmutable('2025-06-01'));
$array = $version->toArray();
$this->assertSame('1.2.3', $array['version']);
$this->assertTrue($array['deprecated']);
$this->assertSame('Use v2', $array['deprecation_message']);
$this->assertSame('2025-06-01', $array['sunset_date']);
}
#[Test]
public function it_omits_null_values_in_array(): void
{
$version = new ServiceVersion(1, 0, 0);
$array = $version->toArray();
$this->assertArrayHasKey('version', $array);
$this->assertArrayNotHasKey('deprecated', $array);
$this->assertArrayNotHasKey('deprecation_message', $array);
$this->assertArrayNotHasKey('sunset_date', $array);
}
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Core\Website\Service;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
/**
* Generic Service Mod.
*
* Serves the public marketing/landing pages for services.
* Uses workspace data (name, icon, color) for dynamic theming.
*
* Services can override this by having their own Mod/{Service} module.
*/
class Boot extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
View::addNamespace('service', __DIR__.'/View/Blade');
Route::middleware('web')
->domain(request()->getHost())
->group(__DIR__.'/Routes/web.php');
}
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Core\Website\Service\View\Features;
use Core\Website\Service\View\Landing;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| Service Mod Routes
|--------------------------------------------------------------------------
|
| Generic marketing/landing pages for services.
| Uses workspace data for dynamic theming based on service color.
|
*/
Route::get('/', Landing::class)->name('service.home');
Route::get('/features', Features::class)->name('service.features');

View file

@ -0,0 +1,34 @@
<footer class="mt-auto">
{{-- Footer gradient border --}}
<div class="h-px w-full" style="background: linear-gradient(to right, transparent, color-mix(in srgb, var(--service-accent) 20%, transparent), transparent);"></div>
<div class="max-w-5xl mx-auto px-4 sm:px-6 py-8">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-4">
@if(config('core.app.logo') && file_exists(public_path(config('core.app.logo'))))
<img src="/{{ config('core.app.logo') }}" alt="{{ config('core.app.name', 'Service') }}" class="w-6 h-6 opacity-50">
@else
<div class="w-6 h-6 rounded flex items-center justify-center opacity-50" style="background-color: var(--service-accent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }} text-xs text-slate-900"></i>
</div>
@endif
<span class="text-sm text-slate-500">
&copy; {{ date('Y') }} {{ config('core.app.name', $workspace['name'] ?? 'Service') }}
</span>
</div>
<div class="flex items-center gap-6 text-sm text-slate-500">
@if(config('core.app.privacy_url'))
<a href="{{ config('core.app.privacy_url') }}" class="hover:text-slate-300 transition">Privacy</a>
@endif
@if(config('core.app.terms_url'))
<a href="{{ config('core.app.terms_url') }}" class="hover:text-slate-300 transition">Terms</a>
@endif
@if(config('core.app.powered_by'))
<a href="{{ config('core.app.powered_by_url', '#') }}" class="hover:text-slate-300 transition flex items-center gap-1">
<i class="fa-solid fa-bolt text-xs" style="color: var(--service-accent);"></i>
Powered by {{ config('core.app.powered_by') }}
</a>
@endif
</div>
</div>
</div>
</footer>

View file

@ -0,0 +1,32 @@
<header class="sticky top-0 z-30 bg-slate-900/90 backdrop-blur-xl">
<div class="max-w-5xl mx-auto px-4 sm:px-6">
<div class="flex items-center justify-between h-16">
{{-- Branding --}}
<a href="/" class="flex items-center gap-3 group">
<div class="w-10 h-10 rounded-lg flex items-center justify-center" style="background-color: color-mix(in srgb, var(--service-accent) 20%, transparent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }}" style="color: var(--service-accent);"></i>
</div>
<span class="font-bold text-lg text-slate-200 group-hover:text-white transition">
{{ $workspace['name'] ?? 'Service' }}
</span>
</a>
{{-- Navigation --}}
<nav class="hidden sm:flex items-center gap-6">
<a href="/" class="text-sm text-slate-400 hover:text-white transition">Home</a>
<a href="/features" class="text-sm text-slate-400 hover:text-white transition">Features</a>
<a href="/pricing" class="text-sm text-slate-400 hover:text-white transition">Pricing</a>
</nav>
{{-- Actions --}}
<div class="flex items-center gap-4">
<a href="{{ config('app.url') }}/login" class="text-sm text-slate-400 hover:text-white transition">Login</a>
<a href="{{ config('app.url') }}/register" class="px-4 py-2 text-sm font-semibold rounded-lg transition" style="background-color: var(--service-accent); color: #1e293b;">
Get started
</a>
</div>
</div>
</div>
{{-- Header gradient border --}}
<div class="h-px w-full" style="background: linear-gradient(to right, transparent, color-mix(in srgb, var(--service-accent) 30%, transparent), transparent);"></div>
</header>

View file

@ -0,0 +1,92 @@
<div>
{{-- Hero Section --}}
<section class="relative overflow-hidden">
<div class="relative max-w-7xl mx-auto px-6 md:px-10 xl:px-8 py-20 lg:py-28">
<div class="text-center max-w-3xl mx-auto">
{{-- Badge --}}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm mb-6" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent); border: 1px solid color-mix(in srgb, var(--service-accent) 30%, transparent); color: var(--service-accent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }}"></i>
{{ $workspace['name'] ?? 'Service' }} Features
</div>
{{-- Headline --}}
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Everything you need to
<span style="color: var(--service-accent);">succeed</span>
</h1>
{{-- Description --}}
<p class="text-lg text-slate-400 mb-8 max-w-xl mx-auto">
{{ $workspace['description'] ?? 'Powerful features built for creators and businesses.' }}
</p>
</div>
</div>
</section>
{{-- Features Grid --}}
<section class="py-20 lg:py-28 bg-slate-900/50">
<div class="max-w-7xl mx-auto px-6 md:px-10 xl:px-8">
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
@foreach($features as $feature)
<div class="rounded-2xl p-8 transition group" style="background-color: color-mix(in srgb, var(--service-accent) 5%, rgb(30 41 59)); border: 1px solid color-mix(in srgb, var(--service-accent) 15%, transparent);">
<div class="w-14 h-14 rounded-xl flex items-center justify-center mb-6 transition" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent);">
<i class="fa-solid fa-{{ $feature['icon'] }} text-xl" style="color: var(--service-accent);"></i>
</div>
<h3 class="text-xl font-semibold text-white mb-3">{{ $feature['title'] }}</h3>
<p class="text-slate-400 leading-relaxed">{{ $feature['description'] }}</p>
</div>
@endforeach
</div>
</div>
</section>
{{-- Integration Section --}}
<section class="py-20 lg:py-28">
<div class="max-w-4xl mx-auto px-6 md:px-10 xl:px-8 text-center">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
Part of the Host UK ecosystem
</h2>
<p class="text-lg text-slate-400 mb-10 max-w-2xl mx-auto">
{{ $workspace['name'] ?? 'This service' }} works seamlessly with other Host UK services.
One account, unified billing, integrated tools.
</p>
<div class="flex flex-wrap justify-center gap-4">
<a href="https://{{ app()->environment('local') ? 'social.host.test' : 'social.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-share-nodes mr-2"></i>SocialHost
</a>
<a href="https://{{ app()->environment('local') ? 'analytics.host.test' : 'analytics.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-chart-line mr-2"></i>AnalyticsHost
</a>
<a href="https://{{ app()->environment('local') ? 'notify.host.test' : 'notify.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-bell mr-2"></i>NotifyHost
</a>
<a href="https://{{ app()->environment('local') ? 'trust.host.test' : 'trust.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-star mr-2"></i>TrustHost
</a>
<a href="https://{{ app()->environment('local') ? 'support.host.test' : 'support.host.uk.com' }}" class="px-4 py-2 rounded-lg bg-slate-800 text-slate-300 text-sm hover:bg-slate-700 transition">
<i class="fa-solid fa-headset mr-2"></i>SupportHost
</a>
</div>
</div>
</section>
{{-- CTA Section --}}
<section class="py-20" style="background: linear-gradient(135deg, var(--service-accent), color-mix(in srgb, var(--service-accent) 70%, black));">
<div class="max-w-4xl mx-auto px-6 md:px-10 xl:px-8 text-center">
<h2 class="text-3xl sm:text-4xl font-bold mb-4 text-slate-900">
Ready to get started?
</h2>
<p class="text-lg mb-8 text-slate-800">
Start your free trial today. No credit card required.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="{{ config('app.url') }}/register" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition shadow-lg bg-slate-900" style="color: var(--service-accent);">
Get started free
</a>
<a href="/pricing" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition bg-white/20 text-slate-900 border border-slate-900/20 hover:bg-white/30">
View pricing
</a>
</div>
</div>
</section>
</div>

View file

@ -0,0 +1,76 @@
<div>
{{-- Hero Section --}}
<section class="relative overflow-hidden">
<div class="relative max-w-7xl mx-auto px-6 md:px-10 xl:px-8 py-20 lg:py-32">
<div class="max-w-3xl">
{{-- Badge --}}
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full text-sm mb-6" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent); border: 1px solid color-mix(in srgb, var(--service-accent) 30%, transparent); color: var(--service-accent);">
<i class="fa-solid fa-{{ $workspace['icon'] ?? 'cube' }}"></i>
{{ $workspace['name'] ?? 'Service' }}
</div>
{{-- Headline --}}
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
{{ $workspace['description'] ?? 'A powerful service' }}
<span style="color: var(--service-accent);">built for creators</span>
</h1>
{{-- Description --}}
<p class="text-lg text-slate-400 mb-8 max-w-xl">
{{ config('core.app.tagline', 'Simple, powerful tools that help you build your online presence.') }}
</p>
{{-- CTAs --}}
<div class="flex flex-col sm:flex-row gap-4">
<a href="{{ config('app.url') }}/register" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition" style="background-color: var(--service-accent); color: var(--service-bg); box-shadow: 0 0 20px color-mix(in srgb, var(--service-accent) 30%, transparent);">
Start free trial
</a>
<a href="/features" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent); color: var(--service-accent); border: 1px solid color-mix(in srgb, var(--service-accent) 25%, transparent);">
See all features
</a>
</div>
</div>
</div>
</section>
{{-- Features Section --}}
<section class="py-20 lg:py-28 bg-slate-900/50">
<div class="max-w-7xl mx-auto px-6 md:px-10 xl:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl sm:text-4xl font-bold text-white mb-4">
Everything you need
</h2>
<p class="text-lg text-slate-400 max-w-2xl mx-auto">
Powerful tools built for creators and businesses.
</p>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
@foreach($features as $feature)
<div class="rounded-xl p-6 transition bg-slate-800/50" style="border: 1px solid color-mix(in srgb, var(--service-accent) 15%, transparent);">
<div class="w-12 h-12 rounded-xl flex items-center justify-center mb-4" style="background-color: color-mix(in srgb, var(--service-accent) 15%, transparent);">
<i class="fa-solid fa-{{ $feature['icon'] }} text-lg" style="color: var(--service-accent);"></i>
</div>
<h3 class="text-lg font-semibold text-white mb-2">{{ $feature['title'] }}</h3>
<p class="text-sm text-slate-400">{{ $feature['description'] }}</p>
</div>
@endforeach
</div>
</div>
</section>
{{-- CTA Section --}}
<section class="py-20" style="background: linear-gradient(135deg, var(--service-accent), color-mix(in srgb, var(--service-accent) 70%, black));">
<div class="max-w-4xl mx-auto px-6 md:px-10 xl:px-8 text-center">
<h2 class="text-3xl sm:text-4xl font-bold mb-4 text-slate-900">
Ready to get started?
</h2>
<p class="text-lg mb-8 text-slate-800">
{{ config('core.app.cta_text', 'Join thousands of users building with our platform.') }}
</p>
<a href="{{ config('app.url') }}/register" class="inline-flex items-center justify-center px-8 py-3 font-semibold rounded-xl transition shadow-lg bg-slate-900" style="color: var(--service-accent);">
Get started free
</a>
</div>
</section>
</div>

View file

@ -0,0 +1,97 @@
@php
$color = $workspace['color'] ?? 'violet';
$colorMap = [
'violet' => ['accent' => '#8b5cf6', 'hover' => '#a78bfa'],
'green' => ['accent' => '#22c55e', 'hover' => '#4ade80'],
'yellow' => ['accent' => '#eab308', 'hover' => '#facc15'],
'orange' => ['accent' => '#f97316', 'hover' => '#fb923c'],
'red' => ['accent' => '#ef4444', 'hover' => '#f87171'],
'cyan' => ['accent' => '#06b6d4', 'hover' => '#22d3ee'],
'blue' => ['accent' => '#3b82f6', 'hover' => '#60a5fa'],
];
$colors = $colorMap[$color] ?? $colorMap['violet'];
@endphp
<!DOCTYPE html>
<html lang="en" class="dark scroll-smooth">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ $title ?? $workspace['name'] ?? config('core.app.name', 'Service') }}</title>
<meta name="description" content="{{ $workspace['description'] ?? '' }}">
<meta property="og:title" content="{{ $title ?? $workspace['name'] ?? config('core.app.name', 'Service') }}">
<meta property="og:description" content="{{ $workspace['description'] ?? '' }}">
<meta property="og:url" content="{{ request()->url() }}">
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
@if(View::exists('layouts::partials.fonts'))
@include('layouts::partials.fonts')
@endif
@if(file_exists(public_path('vendor/fontawesome/css/all.min.css')))
<link rel="stylesheet" href="/vendor/fontawesome/css/all.min.css">
@endif
@vite(['resources/css/app.css', 'resources/js/app.js'])
@if(class_exists(\Flux\Flux::class))
@fluxAppearance
@endif
<style>
:root {
--service-accent: {{ $colors['accent'] }};
--service-hover: {{ $colors['hover'] }};
}
/* Swing animation for decorative shapes */
@keyframes swing {
0%, 100% { transform: rotate(-3deg); }
50% { transform: rotate(3deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
</style>
</head>
<body class="font-sans antialiased bg-slate-900 text-slate-100 tracking-tight min-h-screen flex flex-col overflow-x-hidden overscroll-none">
{{-- Decorative background shapes --}}
<div class="fixed inset-0 -z-10 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-[960px] h-24 top-12 left-1/2 -translate-x-1/2 animate-[swing_8s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-accent) 30%, transparent), transparent);"></div>
</div>
<div class="absolute w-[960px] h-24 -top-12 left-1/4 animate-[swing_15s_-1s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-hover) 20%, transparent), transparent);"></div>
</div>
<div class="absolute w-[960px] h-64 bottom-24 right-1/4 animate-[swing_10s_ease-in-out_infinite] blur-3xl">
<div class="absolute inset-0 rounded-full -rotate-[42deg]" style="background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--service-accent) 10%, transparent), transparent);"></div>
</div>
</div>
{{-- Header --}}
@include('service::components.header', ['workspace' => $workspace, 'colors' => $colors])
{{-- Main Content --}}
<main class="flex-1 relative">
{{-- Radial gradient glow at top of content --}}
<div class="absolute flex items-center justify-center top-0 -translate-y-1/2 left-1/2 -translate-x-1/2 pointer-events-none -z-10 w-[800px] aspect-square" aria-hidden="true">
<div class="absolute inset-0 translate-z-0 rounded-full blur-[120px] opacity-20" style="background-color: var(--service-accent);"></div>
<div class="absolute w-64 h-64 translate-z-0 rounded-full blur-[80px] opacity-40" style="background-color: var(--service-hover);"></div>
</div>
{{ $slot }}
</main>
{{-- Footer --}}
@include('service::components.footer', ['workspace' => $workspace, 'colors' => $colors])
@if(class_exists(\Flux\Flux::class))
@fluxScripts
@endif
{{ $scripts ?? '' }}
@stack('scripts')
</body>
</html>

View file

@ -0,0 +1,163 @@
<?php
/*
* Core PHP Framework
*
* Licensed under the European Union Public Licence (EUPL) v1.2.
* See LICENSE file for details.
*/
declare(strict_types=1);
namespace Core\Website\Service\View;
use Illuminate\Contracts\View\View;
use Livewire\Component;
/**
* Generic Service Features Page.
*
* Displays service features with dynamic theming.
*/
class Features extends Component
{
public array $workspace = [];
public function mount(): void
{
// Extract subdomain from host (e.g., social.host.test → social)
$host = request()->getHost();
$slug = $this->extractSubdomain($host);
// Try to resolve workspace from app container if service exists
if ($slug && app()->bound('workspace.service')) {
$this->workspace = app('workspace.service')->get($slug) ?? [];
}
// Fallback to app config defaults
if (empty($this->workspace)) {
$this->workspace = [
'name' => config('core.app.name', config('app.name', 'Service')),
'slug' => 'service',
'icon' => config('core.app.icon', 'cube'),
'color' => config('core.app.color', 'violet'),
'description' => config('core.app.description', 'A powerful platform'),
];
}
}
/**
* Extract subdomain from hostname.
*/
protected function extractSubdomain(string $host): ?string
{
if (preg_match('/^([a-z]+)\.host\.(test|localhost|uk\.com)$/i', $host, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get detailed features for this service.
*/
public function getFeatures(): array
{
$slug = $this->workspace['slug'] ?? 'service';
// Service-specific features
return match ($slug) {
'social' => $this->getSocialFeatures(),
'analytics' => $this->getAnalyticsFeatures(),
'notify' => $this->getNotifyFeatures(),
'trust' => $this->getTrustFeatures(),
'support' => $this->getSupportFeatures(),
default => $this->getDefaultFeatures(),
};
}
protected function getSocialFeatures(): array
{
return [
['icon' => 'calendar', 'title' => 'Schedule posts', 'description' => 'Plan your content calendar weeks in advance with our visual scheduler.'],
['icon' => 'share-nodes', 'title' => 'Multi-platform publishing', 'description' => 'Publish to 20+ social networks from a single dashboard.'],
['icon' => 'chart-pie', 'title' => 'Analytics & insights', 'description' => 'Track engagement, reach, and growth across all your accounts.'],
['icon' => 'users', 'title' => 'Team collaboration', 'description' => 'Work together with approval workflows and role-based access.'],
['icon' => 'wand-magic-sparkles', 'title' => 'AI content assistant', 'description' => 'Generate captions, hashtags, and post ideas with AI.'],
['icon' => 'inbox', 'title' => 'Unified inbox', 'description' => 'Manage comments and messages from all platforms in one place.'],
];
}
protected function getAnalyticsFeatures(): array
{
return [
['icon' => 'cookie-bite', 'title' => 'No cookies required', 'description' => 'Privacy-focused tracking without consent banners.'],
['icon' => 'bolt', 'title' => 'Lightweight script', 'description' => 'Under 1KB script that won\'t slow down your site.'],
['icon' => 'chart-line', 'title' => 'Real-time dashboard', 'description' => 'See visitors on your site as they browse.'],
['icon' => 'route', 'title' => 'Goal tracking', 'description' => 'Set up funnels and conversion goals to measure success.'],
['icon' => 'flask', 'title' => 'A/B testing', 'description' => 'Test variations and measure statistical significance.'],
['icon' => 'file-export', 'title' => 'Data export', 'description' => 'Export your data anytime in CSV or JSON format.'],
];
}
protected function getNotifyFeatures(): array
{
return [
['icon' => 'bell', 'title' => 'Web push notifications', 'description' => 'Reach subscribers directly in their browser.'],
['icon' => 'users-gear', 'title' => 'Audience segments', 'description' => 'Target specific groups based on behaviour and preferences.'],
['icon' => 'diagram-project', 'title' => 'Automation flows', 'description' => 'Create drip campaigns triggered by user actions.'],
['icon' => 'vials', 'title' => 'A/B testing', 'description' => 'Test different messages to optimise engagement.'],
['icon' => 'chart-simple', 'title' => 'Delivery analytics', 'description' => 'Track delivery, clicks, and conversion rates.'],
['icon' => 'clock', 'title' => 'Scheduled sends', 'description' => 'Schedule notifications for optimal delivery times.'],
];
}
protected function getTrustFeatures(): array
{
return [
['icon' => 'comment-dots', 'title' => 'Social proof popups', 'description' => 'Show real-time purchase and signup notifications.'],
['icon' => 'star', 'title' => 'Review collection', 'description' => 'Collect and display customer reviews automatically.'],
['icon' => 'bullseye', 'title' => 'Smart targeting', 'description' => 'Show notifications to the right visitors at the right time.'],
['icon' => 'palette', 'title' => 'Custom styling', 'description' => 'Match notifications to your brand with full CSS control.'],
['icon' => 'chart-column', 'title' => 'Conversion tracking', 'description' => 'Measure the impact on your conversion rates.'],
['icon' => 'plug', 'title' => 'Easy integration', 'description' => 'Add to any website with a single script tag.'],
];
}
protected function getSupportFeatures(): array
{
return [
['icon' => 'inbox', 'title' => 'Shared inbox', 'description' => 'Manage customer emails from a unified team inbox.'],
['icon' => 'book', 'title' => 'Help centre', 'description' => 'Build a self-service knowledge base for customers.'],
['icon' => 'comments', 'title' => 'Live chat widget', 'description' => 'Embed a chat widget on your website for instant support.'],
['icon' => 'clock-rotate-left', 'title' => 'SLA tracking', 'description' => 'Set response time targets and track performance.'],
['icon' => 'reply', 'title' => 'Canned responses', 'description' => 'Save time with pre-written replies for common questions.'],
['icon' => 'tags', 'title' => 'Ticket management', 'description' => 'Organise conversations with tags and custom fields.'],
];
}
protected function getDefaultFeatures(): array
{
return [
['icon' => 'rocket', 'title' => 'Easy to use', 'description' => 'Get started in minutes with our intuitive interface.'],
['icon' => 'shield-check', 'title' => 'Secure by default', 'description' => 'Built with security and privacy at the core.'],
['icon' => 'chart-line', 'title' => 'Analytics included', 'description' => 'Track performance with built-in analytics.'],
['icon' => 'puzzle-piece', 'title' => 'Modular architecture', 'description' => 'Extend with modules to fit your exact needs.'],
['icon' => 'headset', 'title' => 'UK-based support', 'description' => 'Get help from our friendly support team.'],
['icon' => 'code', 'title' => 'Developer friendly', 'description' => 'Full API access and webhook integrations.'],
];
}
public function render(): View
{
$appName = config('core.app.name', config('app.name', 'Service'));
return view('service::features', [
'workspace' => $this->workspace,
'features' => $this->getFeatures(),
])->layout('service::layouts.service', [
'title' => 'Features - '.($this->workspace['name'] ?? $appName),
'workspace' => $this->workspace,
]);
}
}

View file

@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace Core\Website\Service\View;
use Illuminate\Contracts\View\View;
use Livewire\Component;
/**
* Generic Service Landing Page.
*
* Public landing page for services.
* Uses workspace data for dynamic theming.
*/
class Landing extends Component
{
public array $workspace = [];
public function mount(): void
{
// Extract subdomain from host (e.g., social.host.test → social)
$host = request()->getHost();
$slug = $this->extractSubdomain($host);
// Try to resolve workspace from app container if service exists
if ($slug && app()->bound('workspace.service')) {
$this->workspace = app('workspace.service')->get($slug) ?? [];
}
// Fallback to app config defaults
if (empty($this->workspace)) {
$this->workspace = [
'name' => config('core.app.name', config('app.name', 'Service')),
'slug' => 'service',
'icon' => config('core.app.icon', 'cube'),
'color' => config('core.app.color', 'violet'),
'description' => config('core.app.description', 'A powerful platform'),
];
}
}
/**
* Extract subdomain from hostname.
*/
protected function extractSubdomain(string $host): ?string
{
// Handle patterns like social.host.test, social.host.uk.com
if (preg_match('/^([a-z]+)\.host\.(test|localhost|uk\.com)$/i', $host, $matches)) {
return $matches[1];
}
return null;
}
/**
* Get features for the service.
*/
public function getFeatures(): array
{
// Generic features - services can override in their own Landing
return [
[
'icon' => 'rocket',
'title' => 'Easy to use',
'description' => 'Get started in minutes with our intuitive interface.',
],
[
'icon' => 'shield-check',
'title' => 'Secure by default',
'description' => 'Built with security and privacy at the core.',
],
[
'icon' => 'chart-line',
'title' => 'Analytics included',
'description' => 'Track performance with built-in analytics.',
],
[
'icon' => 'puzzle-piece',
'title' => 'Modular architecture',
'description' => 'Extend with modules to fit your exact needs.',
],
];
}
public function render(): View
{
$appName = config('core.app.name', config('app.name', 'Service'));
return view('service::landing', [
'workspace' => $this->workspace,
'features' => $this->getFeatures(),
])->layout('service::layouts.service', [
'title' => $this->workspace['name'] !== $appName
? $this->workspace['name'].' - '.$appName
: $appName,
'workspace' => $this->workspace,
]);
}
}