diff --git a/public/docs/core-concepts/bootstrapping/initializers/event-binding.md b/public/docs/core-concepts/bootstrapping/initializers/event-binding.md index 0081fe1..4c67675 100644 --- a/public/docs/core-concepts/bootstrapping/initializers/event-binding.md +++ b/public/docs/core-concepts/bootstrapping/initializers/event-binding.md @@ -1,5 +1,10 @@ # Event Bindings +> **Related interfaces:** +> - [HasEventBindings](/packages/event/interfaces/has-event-bindings) — The interface you implement +> - [ActionBindingStrategy](/packages/event/interfaces/action-binding-strategy) — Executes the bindings +> - [Event](/packages/event/interfaces/event) — Creating event classes + ## What are Event Bindings? Event bindings are like adapters that connect your application's events to a platform's native event system. Think of @@ -229,4 +234,18 @@ Remember: - Document platform events clearly - Use service classes for complex transformations -With event bindings, your application can easily "speak" to any platform while keeping its own code clean and portable. \ No newline at end of file +With event bindings, your application can easily "speak" to any platform while keeping its own code clean and portable. + +--- + +## Related Documentation + +### Event Package Interfaces +- [HasEventBindings](/packages/event/interfaces/has-event-bindings) — Detailed interface documentation +- [ActionBindingStrategy](/packages/event/interfaces/action-binding-strategy) — How bindings are executed +- [Event](/packages/event/interfaces/event) — Creating event classes that bindings produce + +### Related Guides +- [Event Listeners](event-listeners) — Handling events once they're bound +- [Event Package Overview](/packages/event/introduction) — Full event system documentation +- [Best Practices](/packages/event/patterns/best-practices) — Transformer patterns, testing strategies \ No newline at end of file diff --git a/public/docs/core-concepts/bootstrapping/initializers/event-listeners.md b/public/docs/core-concepts/bootstrapping/initializers/event-listeners.md index df47d6a..ee5e20a 100644 --- a/public/docs/core-concepts/bootstrapping/initializers/event-listeners.md +++ b/public/docs/core-concepts/bootstrapping/initializers/event-listeners.md @@ -1,5 +1,10 @@ # Event Listeners in PHPNomad +> **Related interfaces:** +> - [HasListeners](/packages/event/interfaces/has-listeners) — Declaring which events to listen for +> - [CanHandle](/packages/event/interfaces/can-handle) — Creating handler classes +> - [Event](/packages/event/interfaces/event) — Creating event classes + Event listeners are like friendly observers in your code that wait for specific things to happen, and then spring into action when they do. Think of them as helpful assistants who are always ready to respond when something important occurs in your application. @@ -250,4 +255,20 @@ maintain but also easier to test and modify. Each listener can focus on its spec any services it needs. Think of event listeners as specialized workers in your application - each one has access to exactly the tools they -need (through dependency injection) and knows exactly what to do when certain events occur. \ No newline at end of file +need (through dependency injection) and knows exactly what to do when certain events occur. + +--- + +## Related Documentation + +### Event Package Interfaces +- [HasListeners](/packages/event/interfaces/has-listeners) — Detailed interface documentation for declaring listeners +- [CanHandle](/packages/event/interfaces/can-handle) — Creating handler classes with dependency injection +- [Event](/packages/event/interfaces/event) — Creating event classes +- [EventStrategy](/packages/event/interfaces/event-strategy) — Broadcasting events programmatically + +### Related Guides +- [Event Bindings](event-binding) — Connecting platform events to your application +- [Event Package Overview](/packages/event/introduction) — Full event system documentation +- [Best Practices](/packages/event/patterns/best-practices) — Handler patterns, testing strategies, anti-patterns +- [Logger Package](/packages/logger/introduction) — LoggerStrategy interface used in handler examples \ No newline at end of file diff --git a/public/docs/core-concepts/bootstrapping/initializers/facades.md b/public/docs/core-concepts/bootstrapping/initializers/facades.md index 752dcdc..2b5e8ef 100644 --- a/public/docs/core-concepts/bootstrapping/initializers/facades.md +++ b/public/docs/core-concepts/bootstrapping/initializers/facades.md @@ -290,4 +290,12 @@ Consider alternatives when: Remember: Facades in PHPNomad combine the singleton pattern with static access to provide convenient, globally accessible services while maintaining the flexibility to work across different platforms. The key is to use them -thoughtfully and always remember to include the `WithInstance` trait. \ No newline at end of file +thoughtfully and always remember to include the `WithInstance` trait. + +--- + +## Related Documentation + +- [Singleton Package](/packages/singleton/introduction) — WithInstance trait used by all facades +- [Logger Package](/packages/logger/introduction) — LoggerStrategy interface shown in examples +- [Event Package](/packages/event/introduction) — EventStrategy interface for event facades \ No newline at end of file diff --git a/public/docs/core-concepts/datastores/getting-started-tutorial.md b/public/docs/core-concepts/datastores/getting-started-tutorial.md index 5bf4dd2..61589c6 100644 --- a/public/docs/core-concepts/datastores/getting-started-tutorial.md +++ b/public/docs/core-concepts/datastores/getting-started-tutorial.md @@ -806,6 +806,7 @@ Now that you have built your first datastore, explore these topics to deepen you - **[Query Building](../packages/database/query-building)** — Build complex queries with conditions - **[Junction Tables](../packages/database/junction-tables)** — Implement many-to-many relationships - **[Advanced Patterns](../advanced/advanced-patterns)** — Soft deletes, audit trails, and optimization +- **[Logger Package](../packages/logger/introduction)** — LoggerStrategy interface for handler logging --- diff --git a/public/docs/packages/config/exceptions/config-exception.md b/public/docs/packages/config/exceptions/config-exception.md new file mode 100644 index 0000000..3482921 --- /dev/null +++ b/public/docs/packages/config/exceptions/config-exception.md @@ -0,0 +1,341 @@ +--- +id: config-exception-config-exception +slug: docs/packages/config/exceptions/config-exception +title: ConfigException Class +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The ConfigException class is thrown when configuration operations fail. +llm_summary: > + ConfigException extends the base PHP Exception class and is thrown when configuration operations + fail. Common scenarios include missing configuration files, invalid file formats, parse errors, + and missing required configuration keys. Used by ConfigFileLoaderStrategy implementations and + can be thrown by ConfigStrategy implementations for validation errors. +questions_answered: + - What is ConfigException? + - When is ConfigException thrown? + - How do I handle configuration errors? + - How do I throw ConfigException? +audience: + - developers + - backend engineers +tags: + - exception + - config + - error-handling +llm_tags: + - config-exception + - error-handling +keywords: + - ConfigException class + - configuration errors + - exception handling +related: + - ../introduction + - ../interfaces/config-file-loader-strategy +see_also: + - ../services/config-service + - ../interfaces/config-strategy +noindex: false +--- + +# ConfigException Class + +The `ConfigException` class is thrown when **configuration operations fail**. It extends PHP's base `Exception` class. + +## Class definition + +```php +namespace PHPNomad\Config\Exceptions; + +class ConfigException extends \Exception {} +``` + +--- + +## When it's thrown + +### Missing configuration file + +```php +class PhpConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + // ... + } +} +``` + +### Invalid file format + +```php +class JsonConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + $content = file_get_contents($path); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ConfigException( + "Invalid JSON in {$path}: " . json_last_error_msg() + ); + } + + return $config; + } +} +``` + +### Invalid return type + +```php +class PhpConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + $config = require $path; + + if (!is_array($config)) { + throw new ConfigException( + "Config file must return an array: {$path}" + ); + } + + return $config; + } +} +``` + +### Missing required configuration + +```php +class ConfigValidator +{ + public function validate(ConfigStrategy $config): void + { + $required = ['database.host', 'app.secret_key']; + + foreach ($required as $key) { + if (!$config->has($key)) { + throw new ConfigException("Missing required config: {$key}"); + } + } + } +} +``` + +--- + +## Handling ConfigException + +### Basic try-catch + +```php +use PHPNomad\Config\Exceptions\ConfigException; + +try { + $service->registerConfig('app', '/path/to/app.php'); +} catch (ConfigException $e) { + error_log("Configuration error: " . $e->getMessage()); + // Handle the error appropriately +} +``` + +### With fallback configuration + +```php +try { + $service->registerConfig('cache', '/config/cache.php'); +} catch (ConfigException $e) { + // Use default cache configuration + $strategy->register('cache', [ + 'driver' => 'array', + 'ttl' => 3600, + ]); + + error_log("Using fallback cache config: " . $e->getMessage()); +} +``` + +### Graceful degradation + +```php +class Application +{ + public function loadOptionalConfigs(): void + { + $optional = ['analytics', 'features', 'experiments']; + + foreach ($optional as $config) { + try { + $this->configService->registerConfig( + $config, + "{$this->configDir}/{$config}.php" + ); + } catch (ConfigException $e) { + // Optional configs can fail silently + $this->logger->debug("Optional config not loaded: {$config}"); + } + } + } +} +``` + +### Fail fast for required configs + +```php +class Application +{ + public function loadRequiredConfigs(): void + { + $required = ['app', 'database']; + + foreach ($required as $config) { + try { + $this->configService->registerConfig( + $config, + "{$this->configDir}/{$config}.php" + ); + } catch (ConfigException $e) { + // Required configs must exist - fail the application + throw new RuntimeException( + "Cannot start application: {$e->getMessage()}", + 0, + $e + ); + } + } + } +} +``` + +--- + +## Creating helpful error messages + +Include context in exception messages: + +```php +// Good: specific and actionable +throw new ConfigException( + "Config file not found: /app/config/database.php. " . + "Ensure the file exists and is readable." +); + +// Good: includes the parse error details +throw new ConfigException(sprintf( + "Failed to parse JSON config '%s' at line %d: %s", + $path, + $lineNumber, + $parseError +)); + +// Bad: vague +throw new ConfigException("Config error"); + +// Bad: missing file path +throw new ConfigException("File not found"); +``` + +--- + +## Custom exception subclasses + +For complex applications, create specific exception types: + +```php +class ConfigFileNotFoundException extends ConfigException +{ + public function __construct(string $path) + { + parent::__construct("Config file not found: {$path}"); + } +} + +class ConfigParseException extends ConfigException +{ + public function __construct(string $path, string $error) + { + parent::__construct("Failed to parse {$path}: {$error}"); + } +} + +class MissingConfigKeyException extends ConfigException +{ + public function __construct(string $key) + { + parent::__construct("Missing required config key: {$key}"); + } +} + +// Usage +try { + $config = $this->loadConfig($path); +} catch (ConfigFileNotFoundException $e) { + // Handle missing file specifically +} catch (ConfigParseException $e) { + // Handle parse errors specifically +} catch (ConfigException $e) { + // Handle other config errors +} +``` + +--- + +## Testing exception scenarios + +```php +class ConfigLoaderTest extends TestCase +{ + public function test_throws_for_missing_file(): void + { + $loader = new PhpConfigLoader(); + + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('not found'); + + $loader->loadFileConfigs('/nonexistent/file.php'); + } + + public function test_throws_for_invalid_json(): void + { + $file = $this->createTempFile('{ invalid json }'); + $loader = new JsonConfigLoader(); + + $this->expectException(ConfigException::class); + $this->expectExceptionMessage('Invalid JSON'); + + $loader->loadFileConfigs($file); + } + + public function test_exception_includes_file_path(): void + { + $loader = new PhpConfigLoader(); + $path = '/specific/path/config.php'; + + try { + $loader->loadFileConfigs($path); + $this->fail('Expected ConfigException'); + } catch (ConfigException $e) { + $this->assertStringContainsString($path, $e->getMessage()); + } + } +} +``` + +--- + +## See also + +- [ConfigFileLoaderStrategy](../interfaces/config-file-loader-strategy) — Where exceptions are typically thrown +- [ConfigService](../services/config-service) — Using the service with error handling +- [ConfigStrategy](../interfaces/config-strategy) — Storage interface that may throw exceptions diff --git a/public/docs/packages/config/interfaces/config-file-loader-strategy.md b/public/docs/packages/config/interfaces/config-file-loader-strategy.md new file mode 100644 index 0000000..c7b01c7 --- /dev/null +++ b/public/docs/packages/config/interfaces/config-file-loader-strategy.md @@ -0,0 +1,408 @@ +--- +id: config-interface-config-file-loader-strategy +slug: docs/packages/config/interfaces/config-file-loader-strategy +title: ConfigFileLoaderStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The ConfigFileLoaderStrategy interface defines how configuration files are loaded and parsed into arrays. +llm_summary: > + The ConfigFileLoaderStrategy interface enables pluggable file format support for configuration loading. + It defines a single method loadFileConfigs(string $path): array that reads a file and returns its + contents as a PHP array. Implementations exist for PHP arrays, JSON, YAML, and other formats. + Used by ConfigService to load configuration files before registering them with ConfigStrategy. +questions_answered: + - What is ConfigFileLoaderStrategy? + - How do I load configuration from files? + - How do I support custom file formats? + - How do I implement a YAML config loader? +audience: + - developers + - backend engineers +tags: + - interface + - config + - file-loading +llm_tags: + - config-file-loader + - file-parsing + - pluggable-formats +keywords: + - ConfigFileLoaderStrategy interface + - configuration file loading + - file format support +related: + - ../introduction + - ../services/config-service +see_also: + - config-strategy + - ../exceptions/config-exception +noindex: false +--- + +# ConfigFileLoaderStrategy Interface + +The `ConfigFileLoaderStrategy` interface defines how configuration files are **loaded and parsed**. It enables pluggable file format support. + +## Interface definition + +```php +namespace PHPNomad\Config\Interfaces; + +interface ConfigFileLoaderStrategy +{ + /** + * Loads configurations from the specified file. + */ + public function loadFileConfigs(string $path): array; +} +``` + +## Methods + +### `loadFileConfigs(string $path): array` + +Reads a configuration file and returns its contents as an array. + +**Parameters:** +- `$path` — Absolute path to the configuration file + +**Returns:** `array` — The parsed configuration data + +**Throws:** `ConfigException` — If file doesn't exist or parsing fails + +--- + +## PHP array loader + +The simplest loader—PHP files that return arrays: + +```php +use PHPNomad\Config\Interfaces\ConfigFileLoaderStrategy; +use PHPNomad\Config\Exceptions\ConfigException; + +class PhpConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + + $config = require $path; + + if (!is_array($config)) { + throw new ConfigException("Config file must return an array: {$path}"); + } + + return $config; + } +} +``` + +Configuration file: + +```php + 'mysql', + 'host' => $_ENV['DB_HOST'] ?? 'localhost', + 'port' => (int)($_ENV['DB_PORT'] ?? 3306), + 'credentials' => [ + 'username' => $_ENV['DB_USER'] ?? 'root', + 'password' => $_ENV['DB_PASS'] ?? '', + ], +]; +``` + +**Advantages of PHP config files:** +- Can include PHP logic and environment variable access +- No parsing overhead +- IDE autocompletion works + +--- + +## JSON loader + +```php +class JsonConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + + $content = file_get_contents($path); + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ConfigException( + "Invalid JSON in {$path}: " . json_last_error_msg() + ); + } + + return $config; + } +} +``` + +Configuration file: + +```json +{ + "driver": "mysql", + "host": "localhost", + "port": 3306, + "credentials": { + "username": "app_user", + "password": "secret" + } +} +``` + +**Note:** For production JSON config support, see [json-config-integration](/packages/json-config-integration/introduction). + +--- + +## YAML loader + +```php +use Symfony\Component\Yaml\Yaml; + +class YamlConfigLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + + try { + return Yaml::parseFile($path); + } catch (ParseException $e) { + throw new ConfigException( + "Invalid YAML in {$path}: " . $e->getMessage() + ); + } + } +} +``` + +Configuration file: + +```yaml +driver: mysql +host: localhost +port: 3306 +credentials: + username: app_user + password: secret +``` + +--- + +## With environment variable expansion + +Expand `${VAR}` placeholders in config files: + +```php +class EnvExpandingJsonLoader implements ConfigFileLoaderStrategy +{ + public function loadFileConfigs(string $path): array + { + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + + $content = file_get_contents($path); + + // Expand ${VAR} and ${VAR:-default} syntax + $content = preg_replace_callback( + '/\$\{([A-Z_]+)(?::-([^}]*))?\}/', + function ($matches) { + $value = getenv($matches[1]); + if ($value === false) { + return $matches[2] ?? ''; + } + return $value; + }, + $content + ); + + $config = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new ConfigException( + "Invalid JSON in {$path}: " . json_last_error_msg() + ); + } + + return $config; + } +} +``` + +Configuration file: + +```json +{ + "host": "${DB_HOST:-localhost}", + "credentials": { + "username": "${DB_USER}", + "password": "${DB_PASS}" + } +} +``` + +--- + +## Composite loader + +Support multiple file formats with a single loader: + +```php +class CompositeConfigLoader implements ConfigFileLoaderStrategy +{ + private array $loaders = []; + + public function registerLoader(string $extension, ConfigFileLoaderStrategy $loader): void + { + $this->loaders[$extension] = $loader; + } + + public function loadFileConfigs(string $path): array + { + $extension = pathinfo($path, PATHINFO_EXTENSION); + + if (!isset($this->loaders[$extension])) { + throw new ConfigException( + "No loader registered for .{$extension} files" + ); + } + + return $this->loaders[$extension]->loadFileConfigs($path); + } +} + +// Usage +$loader = new CompositeConfigLoader(); +$loader->registerLoader('php', new PhpConfigLoader()); +$loader->registerLoader('json', new JsonConfigLoader()); +$loader->registerLoader('yaml', new YamlConfigLoader()); + +// Automatically uses correct loader based on extension +$loader->loadFileConfigs('/config/database.json'); +$loader->loadFileConfigs('/config/cache.yaml'); +$loader->loadFileConfigs('/config/app.php'); +``` + +--- + +## Best practices + +### Validate file existence early + +```php +public function loadFileConfigs(string $path): array +{ + if (!file_exists($path)) { + throw new ConfigException("Config file not found: {$path}"); + } + + if (!is_readable($path)) { + throw new ConfigException("Config file not readable: {$path}"); + } + + // ... load and parse +} +``` + +### Provide clear error messages + +```php +if (json_last_error() !== JSON_ERROR_NONE) { + throw new ConfigException(sprintf( + "Failed to parse JSON config file '%s': %s", + $path, + json_last_error_msg() + )); +} +``` + +### Handle encoding issues + +```php +$content = file_get_contents($path); + +// Ensure UTF-8 +if (!mb_check_encoding($content, 'UTF-8')) { + $content = mb_convert_encoding($content, 'UTF-8', 'auto'); +} +``` + +--- + +## Testing + +```php +class PhpConfigLoaderTest extends TestCase +{ + private string $tempDir; + + protected function setUp(): void + { + $this->tempDir = sys_get_temp_dir() . '/config_test_' . uniqid(); + mkdir($this->tempDir); + } + + protected function tearDown(): void + { + array_map('unlink', glob($this->tempDir . '/*')); + rmdir($this->tempDir); + } + + public function test_loads_php_array_file(): void + { + $file = $this->tempDir . '/test.php'; + file_put_contents($file, ' "value"];'); + + $loader = new PhpConfigLoader(); + $config = $loader->loadFileConfigs($file); + + $this->assertEquals(['key' => 'value'], $config); + } + + public function test_throws_for_missing_file(): void + { + $loader = new PhpConfigLoader(); + + $this->expectException(ConfigException::class); + $loader->loadFileConfigs('/nonexistent/file.php'); + } + + public function test_throws_for_non_array_return(): void + { + $file = $this->tempDir . '/test.php'; + file_put_contents($file, 'expectException(ConfigException::class); + $loader->loadFileConfigs($file); + } +} +``` + +--- + +## See also + +- [ConfigStrategy](config-strategy) — Where loaded configuration is stored +- [ConfigService](../services/config-service) — Orchestrates loading and registration +- [json-config-integration](/packages/json-config-integration/introduction) — Production JSON loader diff --git a/public/docs/packages/config/interfaces/config-strategy.md b/public/docs/packages/config/interfaces/config-strategy.md new file mode 100644 index 0000000..7d45b62 --- /dev/null +++ b/public/docs/packages/config/interfaces/config-strategy.md @@ -0,0 +1,421 @@ +--- +id: config-interface-config-strategy +slug: docs/packages/config/interfaces/config-strategy +title: ConfigStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The ConfigStrategy interface defines how configuration data is stored, retrieved, and checked using dot-notation keys. +llm_summary: > + The ConfigStrategy interface is the main contract for configuration management in phpnomad/config. + It defines three methods: register(string $key, array $configData) for storing configuration sections, + has(string $key) for checking existence, and get(string $key, $default) for retrieval. Supports + dot-notation for nested access like 'database.credentials.username'. Implementations can use + in-memory arrays, databases, Redis, or any storage backend. +questions_answered: + - What is ConfigStrategy? + - How do I store configuration data? + - How do I retrieve configuration with dot-notation? + - How do I implement ConfigStrategy? + - What storage backends can I use? +audience: + - developers + - backend engineers +tags: + - interface + - config + - strategy +llm_tags: + - config-strategy + - dot-notation + - configuration-storage +keywords: + - ConfigStrategy interface + - configuration storage + - dot notation + - nested configuration +related: + - ../introduction + - ../services/config-service +see_also: + - config-file-loader-strategy + - ../exceptions/config-exception +noindex: false +--- + +# ConfigStrategy Interface + +The `ConfigStrategy` interface defines how configuration data is **stored and retrieved**. It's the main interface applications interact with for configuration access. + +## Interface definition + +```php +namespace PHPNomad\Config\Interfaces; + +interface ConfigStrategy +{ + /** + * Registers a top-level set of configuration data. + */ + public function register(string $key, array $configData); + + /** + * Checks if a configuration key exists. + */ + public function has(string $key): bool; + + /** + * Gets a configuration value by dot-notated key. + */ + public function get(string $key, $default = null); +} +``` + +## Methods + +### `register(string $key, array $configData): static` + +Stores a section of configuration under a namespace. + +**Parameters:** +- `$key` — The top-level namespace (e.g., `'database'`, `'cache'`) +- `$configData` — The configuration array to store + +**Returns:** `static` — For fluent chaining + +**Example:** +```php +$config->register('database', [ + 'host' => 'localhost', + 'port' => 3306, + 'credentials' => [ + 'username' => 'app_user', + 'password' => 'secret' + ] +]); +``` + +### `has(string $key): bool` + +Checks if a configuration key exists. + +**Parameters:** +- `$key` — Dot-notated key to check + +**Returns:** `bool` — True if key exists, false otherwise + +**Example:** +```php +$config->has('database.host'); // true +$config->has('database.credentials.username'); // true +$config->has('database.nonexistent'); // false +``` + +### `get(string $key, $default = null): mixed` + +Retrieves a configuration value by dot-notated key. + +**Parameters:** +- `$key` — Dot-notated key (e.g., `'database.credentials.username'`) +- `$default` — Value to return if key doesn't exist + +**Returns:** The configuration value, or default if not found + +**Example:** +```php +$config->get('database.host'); // 'localhost' +$config->get('database.timeout', 30); // 30 (default) +$config->get('database.credentials'); // ['username' => '...', 'password' => '...'] +``` + +--- + +## Dot-notation access + +Dot-notation lets you access nested configuration: + +```php +$config->register('app', [ + 'name' => 'MyApp', + 'database' => [ + 'primary' => [ + 'host' => 'db1.example.com', + 'port' => 3306 + ], + 'replica' => [ + 'host' => 'db2.example.com', + 'port' => 3306 + ] + ] +]); + +// Access nested values +$config->get('app.name'); // 'MyApp' +$config->get('app.database.primary.host'); // 'db1.example.com' +$config->get('app.database.replica'); // ['host' => '...', 'port' => 3306] +``` + +--- + +## Basic implementation + +Here's a complete in-memory implementation: + +```php +use PHPNomad\Config\Interfaces\ConfigStrategy; + +class ArrayConfig implements ConfigStrategy +{ + private array $data = []; + + public function register(string $key, array $configData): static + { + $this->data[$key] = $configData; + return $this; + } + + public function has(string $key): bool + { + return $this->resolve($key) !== null; + } + + public function get(string $key, $default = null) + { + return $this->resolve($key) ?? $default; + } + + private function resolve(string $key): mixed + { + $parts = explode('.', $key); + $value = $this->data; + + foreach ($parts as $part) { + if (!is_array($value) || !array_key_exists($part, $value)) { + return null; + } + $value = $value[$part]; + } + + return $value; + } +} +``` + +--- + +## With merge support + +Allow configuration overrides by merging: + +```php +class MergeableConfig implements ConfigStrategy +{ + private array $data = []; + + public function register(string $key, array $configData): static + { + if (isset($this->data[$key])) { + // Deep merge with existing + $this->data[$key] = $this->deepMerge( + $this->data[$key], + $configData + ); + } else { + $this->data[$key] = $configData; + } + return $this; + } + + private function deepMerge(array $base, array $override): array + { + foreach ($override as $key => $value) { + if (is_array($value) && isset($base[$key]) && is_array($base[$key])) { + $base[$key] = $this->deepMerge($base[$key], $value); + } else { + $base[$key] = $value; + } + } + return $base; + } + + // ... has() and get() same as ArrayConfig +} +``` + +Usage for environment overrides: + +```php +// Base configuration +$config->register('database', [ + 'host' => 'localhost', + 'port' => 3306, + 'debug' => false +]); + +// Production override (merges) +$config->register('database', [ + 'host' => 'db.production.com', + 'debug' => false +]); + +$config->get('database.host'); // 'db.production.com' +$config->get('database.port'); // 3306 (preserved from base) +``` + +--- + +## Alternative backends + +### Environment-based + +```php +class EnvConfig implements ConfigStrategy +{ + private array $prefixes = []; + + public function register(string $key, array $configData): static + { + // Store prefix mapping for environment variable lookup + $this->prefixes[$key] = strtoupper($key); + return $this; + } + + public function has(string $key): bool + { + return getenv($this->toEnvKey($key)) !== false; + } + + public function get(string $key, $default = null) + { + $value = getenv($this->toEnvKey($key)); + return $value !== false ? $value : $default; + } + + private function toEnvKey(string $key): string + { + // database.host -> DATABASE_HOST + return strtoupper(str_replace('.', '_', $key)); + } +} +``` + +### Cached + +```php +class CachedConfig implements ConfigStrategy +{ + private array $cache = []; + + public function __construct( + private ConfigStrategy $inner, + private CacheInterface $cacheBackend + ) {} + + public function get(string $key, $default = null) + { + if (!isset($this->cache[$key])) { + $this->cache[$key] = $this->cacheBackend->get( + "config:{$key}", + fn() => $this->inner->get($key, $default) + ); + } + return $this->cache[$key]; + } + + // ... other methods delegate to $inner +} +``` + +--- + +## Best practices + +### Use clear namespaces + +```php +// Good: organized by domain +$config->register('database', [...]); +$config->register('cache', [...]); +$config->register('mail', [...]); + +// Bad: everything flat +$config->register('settings', [ + 'db_host' => '...', + 'cache_ttl' => '...', + 'mail_from' => '...' +]); +``` + +### Provide sensible defaults + +```php +// In your application code +$timeout = $config->get('api.timeout', 30); +$retries = $config->get('api.retries', 3); +``` + +### Validate early + +Check required configuration at bootstrap: + +```php +$required = ['database.host', 'app.secret_key']; +foreach ($required as $key) { + if (!$config->has($key)) { + throw new ConfigException("Missing: {$key}"); + } +} +``` + +--- + +## Testing + +```php +class ArrayConfigTest extends TestCase +{ + public function test_registers_and_retrieves_config(): void + { + $config = new ArrayConfig(); + $config->register('app', ['name' => 'Test']); + + $this->assertEquals('Test', $config->get('app.name')); + } + + public function test_returns_default_for_missing_key(): void + { + $config = new ArrayConfig(); + + $this->assertEquals('default', $config->get('missing.key', 'default')); + } + + public function test_has_returns_true_for_existing_key(): void + { + $config = new ArrayConfig(); + $config->register('app', ['debug' => true]); + + $this->assertTrue($config->has('app.debug')); + $this->assertFalse($config->has('app.nonexistent')); + } + + public function test_supports_deep_nesting(): void + { + $config = new ArrayConfig(); + $config->register('a', ['b' => ['c' => ['d' => 'value']]]); + + $this->assertEquals('value', $config->get('a.b.c.d')); + } +} +``` + +--- + +## See also + +- [ConfigFileLoaderStrategy](config-file-loader-strategy) — Loading configuration from files +- [ConfigService](../services/config-service) — Orchestrating file loading and registration +- [ConfigException](../exceptions/config-exception) — Error handling diff --git a/public/docs/packages/config/interfaces/introduction.md b/public/docs/packages/config/interfaces/introduction.md new file mode 100644 index 0000000..48aef37 --- /dev/null +++ b/public/docs/packages/config/interfaces/introduction.md @@ -0,0 +1,112 @@ +--- +id: config-interfaces-introduction +slug: docs/packages/config/interfaces/introduction +title: Config Interfaces Overview +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of the two interfaces provided by the config package for configuration management. +llm_summary: > + The config package provides two interfaces: ConfigStrategy for storing and retrieving configuration + data with dot-notation access, and ConfigFileLoaderStrategy for loading configuration from files. + ConfigStrategy is the main interface applications interact with, while ConfigFileLoaderStrategy + enables pluggable file format support (PHP arrays, JSON, YAML, etc.). +questions_answered: + - What interfaces does the config package provide? + - How do the config interfaces relate to each other? + - Which interface should I implement? +audience: + - developers + - backend engineers +tags: + - interfaces + - config + - configuration +llm_tags: + - interface-overview + - config-interfaces +keywords: + - config interfaces + - ConfigStrategy + - ConfigFileLoaderStrategy +related: + - ../introduction +see_also: + - config-strategy + - config-file-loader-strategy + - ../services/config-service +noindex: false +--- + +# Config Interfaces + +The config package provides two interfaces that separate configuration storage from file loading: + +| Interface | Purpose | When to Implement | +|-----------|---------|-------------------| +| [ConfigStrategy](config-strategy) | Store and retrieve configuration with dot-notation | Custom storage backends (database, Redis, etc.) | +| [ConfigFileLoaderStrategy](config-file-loader-strategy) | Load configuration from files | Custom file formats (YAML, TOML, etc.) | + +--- + +## How the interfaces relate + +``` +┌──────────────────────────────────┐ +│ Config Files │ +│ (PHP, JSON, YAML, etc.) │ +└─────────────┬────────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ ConfigFileLoaderStrategy │ +│ loadFileConfigs($path) │ +│ → reads file, returns array │ +└─────────────┬────────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ ConfigService │ +│ (orchestrates the flow) │ +└─────────────┬────────────────────┘ + │ + ▼ +┌──────────────────────────────────┐ +│ ConfigStrategy │ +│ register($key, $data) │ +│ get($key) / has($key) │ +└──────────────────────────────────┘ + │ + ▼ + Application +``` + +--- + +## Choosing what to implement + +**Implement ConfigStrategy** when you need: +- A custom storage backend (database, Redis, environment variables) +- Caching or lazy-loading behavior +- Validation on configuration access + +**Implement ConfigFileLoaderStrategy** when you need: +- Support for a new file format (YAML, TOML, INI) +- Custom parsing logic (environment variable expansion, etc.) +- Encrypted configuration files + +**Use existing implementations** when: +- In-memory storage works for your needs +- JSON or PHP array files are sufficient + +--- + +## Next steps + +- [ConfigStrategy](config-strategy) — Configuration storage and retrieval +- [ConfigFileLoaderStrategy](config-file-loader-strategy) — File format loading +- [ConfigService](../services/config-service) — Orchestration service diff --git a/public/docs/packages/config/introduction.md b/public/docs/packages/config/introduction.md new file mode 100644 index 0000000..3289db2 --- /dev/null +++ b/public/docs/packages/config/introduction.md @@ -0,0 +1,327 @@ +--- +id: config-introduction +slug: docs/packages/config/introduction +title: Config Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The config package provides a strategy-based configuration management system with dot-notation access and pluggable file loaders. +llm_summary: > + phpnomad/config provides a flexible configuration management system using the strategy pattern. + ConfigStrategy interface defines how configuration is stored and accessed (with dot-notation support). + ConfigFileLoaderStrategy interface defines how config files are loaded from disk (supporting PHP, JSON, + YAML, etc.). ConfigService orchestrates loading files and registering them with the strategy. The package + has zero dependencies and is used by json-config-integration for JSON file support and wordpress-plugin + for WordPress configuration. Supports nested configuration with dot-notation access like 'database.host'. +questions_answered: + - What is the config package? + - How do I manage configuration in PHPNomad? + - How do I access nested configuration values? + - What is dot-notation configuration access? + - How do I load configuration from files? + - How do I create a custom configuration loader? + - How do I implement ConfigStrategy? + - What packages use the config system? +audience: + - developers + - backend engineers +tags: + - config + - configuration + - strategy-pattern + - settings +llm_tags: + - configuration-management + - dot-notation + - strategy-pattern + - file-loading +keywords: + - phpnomad config + - configuration management php + - ConfigStrategy + - dot notation config + - config file loader +related: + - ../json-config-integration/introduction + - ../di/introduction +see_also: + - interfaces/introduction + - services/introduction + - ../core/introduction +noindex: false +--- + +# Config + +`phpnomad/config` provides a **strategy-based configuration management system** for PHP applications. Instead of hardcoding how configuration is stored or loaded, the package uses interfaces that let you: + +* **Choose your storage** — In-memory, database, Redis, or custom backends +* **Choose your file format** — PHP arrays, JSON, YAML, or any format you need +* **Access nested values** — Use dot-notation like `database.credentials.username` +* **Swap implementations** — Change strategies without touching application code + +--- + +## Key ideas at a glance + +| Component | Purpose | Documentation | +|-----------|---------|---------------| +| **ConfigStrategy** | Interface for storing and retrieving configuration data | [Interface docs](interfaces/config-strategy) | +| **ConfigFileLoaderStrategy** | Interface for loading configuration from files | [Interface docs](interfaces/config-file-loader-strategy) | +| **ConfigService** | Coordinates file loading and strategy registration | [Service docs](services/config-service) | +| **ConfigException** | Thrown when configuration operations fail | [Exception docs](exceptions/config-exception) | + +--- + +## Why this package exists + +Configuration management seems simple until you need to: + +* **Support multiple environments** — Development, staging, production +* **Change file formats** — Move from PHP arrays to JSON or YAML +* **Test configuration** — Mock configuration without file system access +* **Share configuration** — Let packages register their own config sections + +Without abstraction, you end up with: + +| Problem | What happens | +|---------|--------------| +| Hardcoded file loading | Can't switch from PHP to JSON without rewriting code | +| Global arrays | Hard to test, easy to corrupt, no type safety | +| Scattered `$_ENV` calls | Configuration access spread throughout codebase | +| No namespacing | Package configs collide with application configs | + +This package provides **clean interfaces** that separate: + +* **What** configuration you need (the keys) +* **Where** it's stored (the strategy) +* **How** it's loaded (the file loader) + +--- + +## Installation + +```bash +composer require phpnomad/config +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** None (zero dependencies) + +--- + +## The configuration flow + +When loading configuration through `ConfigService`: + +``` +Config file on disk (PHP, JSON, etc.) + │ + ▼ +┌─────────────────────────────────┐ +│ ConfigFileLoaderStrategy │ +│ loadFileConfigs($path) │ +│ → reads file, returns array │ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ ConfigService │ +│ registerConfig($key, $path) │ +│ → coordinates loading │ +└─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────┐ +│ ConfigStrategy │ +│ register($key, $data) │ +│ → stores under namespace │ +└─────────────────────────────────┘ + │ + ▼ +Application calls $config->get('key.nested.value') +``` + +Each component has a single responsibility: +* **Loader** — Knows how to read a file format +* **Service** — Orchestrates the process +* **Strategy** — Stores and retrieves data + +--- + +## Quick example + +```php +use PHPNomad\Config\Interfaces\ConfigStrategy; +use PHPNomad\Config\Services\ConfigService; + +// 1. Create a strategy (in-memory storage) +class ArrayConfig implements ConfigStrategy +{ + private array $data = []; + + public function register(string $key, array $configData): static + { + $this->data[$key] = $configData; + return $this; + } + + public function has(string $key): bool + { + return $this->resolve($key) !== null; + } + + public function get(string $key, $default = null) + { + return $this->resolve($key) ?? $default; + } + + private function resolve(string $key): mixed + { + $parts = explode('.', $key); + $value = $this->data; + foreach ($parts as $part) { + if (!is_array($value) || !array_key_exists($part, $value)) { + return null; + } + $value = $value[$part]; + } + return $value; + } +} + +// 2. Register configuration +$config = new ArrayConfig(); +$config->register('database', [ + 'host' => 'localhost', + 'port' => 3306, + 'credentials' => [ + 'username' => 'app_user', + 'password' => 'secret123' + ] +]); + +// 3. Access with dot-notation +$config->get('database.host'); // 'localhost' +$config->get('database.credentials.username'); // 'app_user' +$config->get('database.timeout', 30); // 30 (default) +``` + +--- + +## When to use this package + +The config package is appropriate when: + +| Scenario | Why it helps | +|----------|--------------| +| Multi-environment apps | Different strategies for dev/staging/prod | +| Plugin systems | Packages register their own config sections | +| Testing | Mock ConfigStrategy for predictable tests | +| Format flexibility | Switch file formats without code changes | +| Centralized access | One place for all configuration | + +### Common use cases + +* **Application settings** — Debug mode, timezone, locale +* **Database connections** — Host, port, credentials +* **External services** — API keys, endpoints, timeouts +* **Feature flags** — Enable/disable functionality +* **Cache settings** — Driver, TTL, prefix + +--- + +## When NOT to use this package + +### Simple scripts + +If you have a single-file script, just use an array: + +```php +$config = ['api_key' => 'xxx', 'timeout' => 30]; +``` + +### Environment-only configuration + +If you only need environment variables: + +```php +$dbHost = getenv('DATABASE_HOST'); +``` + +### No file-based configuration + +If all configuration comes from a database or API, you don't need `ConfigFileLoaderStrategy`—just implement `ConfigStrategy` with your storage backend. + +--- + +## Best practices + +1. **Use namespaced keys** — Register each domain under a clear namespace +2. **Type your configuration access** — Wrap access in typed methods +3. **Validate configuration early** — Check required keys at bootstrap +4. **Use environment variables for secrets** — Don't hardcode credentials + +See the individual component docs for detailed best practices: +- [ConfigStrategy best practices](interfaces/config-strategy#best-practices) +- [ConfigFileLoaderStrategy best practices](interfaces/config-file-loader-strategy#best-practices) +- [ConfigService best practices](services/config-service#best-practices) + +--- + +## Relationship to other packages + +### Packages that depend on config + +| Package | How it uses config | +|---------|-------------------| +| [json-config-integration](/packages/json-config-integration/introduction) | Provides JSON file loader implementation | +| [wordpress-plugin](/packages/wordpress-plugin/introduction) | Configuration management for WordPress plugins | + +### Related packages + +| Package | Relationship | +|---------|-------------| +| [di](/packages/di/introduction) | DI container can inject ConfigStrategy | +| [loader](/packages/loader/introduction) | Loader can load configuration modules | + +--- + +## Package contents + +### Interfaces + +| Interface | Purpose | +|-----------|---------| +| [ConfigStrategy](interfaces/config-strategy) | Configuration storage and retrieval with dot-notation | +| [ConfigFileLoaderStrategy](interfaces/config-file-loader-strategy) | File format loading | + +[View all interfaces →](interfaces/introduction) + +### Services + +| Class | Purpose | +|-------|---------| +| [ConfigService](services/config-service) | Orchestrates file loading and registration | + +[View all services →](services/introduction) + +### Exceptions + +| Class | Purpose | +|-------|---------| +| [ConfigException](exceptions/config-exception) | Configuration operation failures | + +--- + +## Next steps + +* **Need JSON config files?** See [JSON Config Integration](/packages/json-config-integration/introduction) +* **Implementing a strategy?** Read [ConfigStrategy interface](interfaces/config-strategy) +* **Loading configuration?** Check [ConfigService](services/config-service) +* **Building a module system?** See [Loader](/packages/loader/introduction) for loading configuration modules diff --git a/public/docs/packages/config/services/config-service.md b/public/docs/packages/config/services/config-service.md new file mode 100644 index 0000000..0c4d9a2 --- /dev/null +++ b/public/docs/packages/config/services/config-service.md @@ -0,0 +1,367 @@ +--- +id: config-service-config-service +slug: docs/packages/config/services/config-service +title: ConfigService Class +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The ConfigService class orchestrates loading configuration files and registering them with a strategy. +llm_summary: > + ConfigService is the orchestration class that connects ConfigFileLoaderStrategy and ConfigStrategy. + It takes both as constructor dependencies and provides registerConfig(string $key, string $path) + to load a file and register it under a namespace. The method returns $this for fluent chaining. + Simplifies the common pattern of loading multiple configuration files at application bootstrap. +questions_answered: + - What is ConfigService? + - How do I load configuration files? + - How do I use ConfigService with dependency injection? + - How do I load multiple configuration files? +audience: + - developers + - backend engineers +tags: + - service + - config + - orchestration +llm_tags: + - config-service + - file-loading + - orchestration +keywords: + - ConfigService class + - configuration orchestration + - file registration +related: + - ../introduction + - ../interfaces/config-strategy +see_also: + - ../interfaces/config-file-loader-strategy + - ../exceptions/config-exception +noindex: false +--- + +# ConfigService Class + +The `ConfigService` class **orchestrates** loading configuration files and registering them with a strategy. It connects the file loader and storage backend. + +## Class definition + +```php +namespace PHPNomad\Config\Services; + +use PHPNomad\Config\Interfaces\ConfigStrategy; +use PHPNomad\Config\Interfaces\ConfigFileLoaderStrategy; + +class ConfigService +{ + public function __construct( + protected ConfigStrategy $configStrategy, + protected ConfigFileLoaderStrategy $configFileLoaderStrategy + ) {} + + public function registerConfig(string $key, string $path): static + { + $configs = $this->configFileLoaderStrategy->loadFileConfigs($path); + $this->configStrategy->register($key, $configs); + return $this; + } +} +``` + +## Constructor + +### `__construct(ConfigStrategy $configStrategy, ConfigFileLoaderStrategy $configFileLoaderStrategy)` + +**Parameters:** +- `$configStrategy` — The storage backend for configuration data +- `$configFileLoaderStrategy` — The file format loader + +## Methods + +### `registerConfig(string $key, string $path): static` + +Loads a configuration file and registers it under a namespace. + +**Parameters:** +- `$key` — The namespace to register under (e.g., `'database'`) +- `$path` — Absolute path to the configuration file + +**Returns:** `static` — For fluent chaining + +**Throws:** `ConfigException` — If file loading fails + +--- + +## Basic usage + +```php +use PHPNomad\Config\Services\ConfigService; + +// Create dependencies +$strategy = new ArrayConfig(); +$loader = new PhpConfigLoader(); + +// Create service +$service = new ConfigService($strategy, $loader); + +// Load a configuration file +$service->registerConfig('database', '/app/config/database.php'); + +// Access via strategy +$host = $strategy->get('database.host'); +``` + +--- + +## Fluent chaining + +Load multiple files with chained calls: + +```php +$service + ->registerConfig('app', __DIR__ . '/config/app.php') + ->registerConfig('database', __DIR__ . '/config/database.php') + ->registerConfig('cache', __DIR__ . '/config/cache.php') + ->registerConfig('mail', __DIR__ . '/config/mail.php') + ->registerConfig('queue', __DIR__ . '/config/queue.php'); +``` + +--- + +## With dependency injection + +Register in your DI container: + +```php +// Registration +$container->set(ConfigStrategy::class, fn() => new ArrayConfig()); + +$container->set(ConfigFileLoaderStrategy::class, fn() => new PhpConfigLoader()); + +$container->set(ConfigService::class, fn($c) => new ConfigService( + $c->get(ConfigStrategy::class), + $c->get(ConfigFileLoaderStrategy::class) +)); + +// Usage +$configService = $container->get(ConfigService::class); +$configService->registerConfig('app', '/config/app.php'); +``` + +--- + +## Environment-aware loading + +Load base configuration plus environment overrides: + +```php +class ConfigLoader +{ + public function __construct( + private ConfigService $service, + private string $configDir, + private string $environment + ) {} + + public function loadAll(): void + { + $files = ['app', 'database', 'cache', 'mail']; + + foreach ($files as $file) { + // Load base config + $basePath = "{$this->configDir}/{$file}.php"; + if (file_exists($basePath)) { + $this->service->registerConfig($file, $basePath); + } + + // Load environment override + $envPath = "{$this->configDir}/{$this->environment}/{$file}.php"; + if (file_exists($envPath)) { + $this->service->registerConfig($file, $envPath); + } + } + } +} + +// Usage +$loader = new ConfigLoader( + $configService, + __DIR__ . '/config', + $_ENV['APP_ENV'] ?? 'production' +); +$loader->loadAll(); +``` + +Directory structure: + +``` +config/ +├── app.php # Base configuration +├── database.php +├── development/ +│ ├── app.php # Development overrides +│ └── database.php +└── production/ + └── database.php # Production overrides +``` + +--- + +## Error handling + +```php +use PHPNomad\Config\Exceptions\ConfigException; + +try { + $service->registerConfig('app', '/path/to/app.php'); +} catch (ConfigException $e) { + // Log the error + error_log("Configuration error: " . $e->getMessage()); + + // Use fallback configuration + $strategy->register('app', [ + 'name' => 'Default App', + 'debug' => false, + ]); +} +``` + +--- + +## Conditional loading + +Load configuration based on conditions: + +```php +class ConditionalConfigLoader +{ + public function __construct( + private ConfigService $service, + private string $configDir + ) {} + + public function load(): void + { + // Always load core config + $this->service->registerConfig('app', "{$this->configDir}/app.php"); + + // Load database config only if needed + if ($this->needsDatabase()) { + $this->service->registerConfig('database', "{$this->configDir}/database.php"); + } + + // Load cache config only if cache is enabled + if (getenv('CACHE_ENABLED') === 'true') { + $this->service->registerConfig('cache', "{$this->configDir}/cache.php"); + } + } + + private function needsDatabase(): bool + { + return getenv('DATABASE_URL') !== false; + } +} +``` + +--- + +## Best practices + +### Load at bootstrap + +Load all configuration early in your application lifecycle: + +```php +// In bootstrap.php or Application::__construct() +$configService + ->registerConfig('app', $configDir . '/app.php') + ->registerConfig('database', $configDir . '/database.php'); + +// Configuration is now available throughout the application +``` + +### Use absolute paths + +```php +// Good: absolute path +$service->registerConfig('app', __DIR__ . '/config/app.php'); + +// Bad: relative path (depends on working directory) +$service->registerConfig('app', 'config/app.php'); +``` + +### Keep the service internal + +Applications should access configuration via `ConfigStrategy`, not `ConfigService`: + +```php +// Good: inject the strategy for reading +class MyService +{ + public function __construct(private ConfigStrategy $config) {} + + public function doSomething(): void + { + $timeout = $this->config->get('api.timeout', 30); + } +} + +// Bad: inject the service just to read config +class MyService +{ + public function __construct(private ConfigService $service) {} + // ConfigService is for loading, not reading +} +``` + +--- + +## Testing + +```php +class ConfigServiceTest extends TestCase +{ + public function test_loads_and_registers_config(): void + { + $strategy = $this->createMock(ConfigStrategy::class); + $loader = $this->createMock(ConfigFileLoaderStrategy::class); + + $loader->expects($this->once()) + ->method('loadFileConfigs') + ->with('/path/to/config.php') + ->willReturn(['key' => 'value']); + + $strategy->expects($this->once()) + ->method('register') + ->with('app', ['key' => 'value']); + + $service = new ConfigService($strategy, $loader); + $service->registerConfig('app', '/path/to/config.php'); + } + + public function test_returns_self_for_chaining(): void + { + $strategy = new ArrayConfig(); + $loader = $this->createMock(ConfigFileLoaderStrategy::class); + $loader->method('loadFileConfigs')->willReturn([]); + + $service = new ConfigService($strategy, $loader); + + $result = $service->registerConfig('app', '/path'); + + $this->assertSame($service, $result); + } +} +``` + +--- + +## See also + +- [ConfigStrategy](../interfaces/config-strategy) — The storage interface +- [ConfigFileLoaderStrategy](../interfaces/config-file-loader-strategy) — The file loader interface +- [ConfigException](../exceptions/config-exception) — Error handling diff --git a/public/docs/packages/config/services/introduction.md b/public/docs/packages/config/services/introduction.md new file mode 100644 index 0000000..4f3b37c --- /dev/null +++ b/public/docs/packages/config/services/introduction.md @@ -0,0 +1,98 @@ +--- +id: config-services-introduction +slug: docs/packages/config/services/introduction +title: Config Services Overview +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of the ConfigService class that orchestrates configuration file loading and registration. +llm_summary: > + The config package provides one service class: ConfigService. This service orchestrates the workflow + of loading configuration files via ConfigFileLoaderStrategy and registering them with ConfigStrategy. + It provides a fluent interface for loading multiple configuration files in sequence. +questions_answered: + - What services does the config package provide? + - How does ConfigService work? + - How do I load multiple configuration files? +audience: + - developers + - backend engineers +tags: + - services + - config + - orchestration +llm_tags: + - service-overview + - config-service +keywords: + - ConfigService + - configuration loading +related: + - ../introduction +see_also: + - config-service + - ../interfaces/config-strategy + - ../interfaces/config-file-loader-strategy +noindex: false +--- + +# Config Services + +The config package provides one service class that orchestrates configuration loading: + +| Service | Purpose | +|---------|---------| +| [ConfigService](config-service) | Coordinates file loading and strategy registration | + +--- + +## The orchestration pattern + +`ConfigService` connects the loader and strategy: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ConfigService │ +│ │ +│ registerConfig('database', '/path/to/database.php') │ +│ │ │ +│ ├──→ ConfigFileLoaderStrategy::loadFileConfigs() │ +│ │ (reads file, returns array) │ +│ │ │ +│ └──→ ConfigStrategy::register('database', $data) │ +│ (stores under namespace) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick example + +```php +use PHPNomad\Config\Services\ConfigService; + +$strategy = new ArrayConfig(); +$loader = new PhpConfigLoader(); +$service = new ConfigService($strategy, $loader); + +// Load multiple config files +$service + ->registerConfig('database', __DIR__ . '/config/database.php') + ->registerConfig('cache', __DIR__ . '/config/cache.php') + ->registerConfig('mail', __DIR__ . '/config/mail.php'); + +// Access via strategy +$dbHost = $strategy->get('database.host'); +``` + +--- + +## Next steps + +- [ConfigService](config-service) — Full service documentation +- [ConfigStrategy](../interfaces/config-strategy) — Where configuration is stored +- [ConfigFileLoaderStrategy](../interfaces/config-file-loader-strategy) — How files are loaded diff --git a/public/docs/packages/database/caching-and-events.md b/public/docs/packages/database/caching-and-events.md index 7e85a5c..9d5b242 100644 --- a/public/docs/packages/database/caching-and-events.md +++ b/public/docs/packages/database/caching-and-events.md @@ -562,6 +562,12 @@ public function save(Model $item): Model { --- +## Related Documentation + +* [Event Package](/packages/event/introduction) — Core event interfaces (`Event`, `EventStrategy`, `CanHandle`) +* [Event Listeners](/core-concepts/bootstrapping/initializers/event-listeners) — Setting up event listeners in initializers +* [Event Bindings](/core-concepts/bootstrapping/initializers/event-binding) — Binding platform events to application events + ## What's Next * [Database Handlers](/packages/database/handlers/introduction) — handlers that use caching and events diff --git a/public/docs/packages/database/database-service-provider.md b/public/docs/packages/database/database-service-provider.md index b8270d8..d1014d5 100644 --- a/public/docs/packages/database/database-service-provider.md +++ b/public/docs/packages/database/database-service-provider.md @@ -469,3 +469,5 @@ $serviceProvider->getQueryBuilder() // Doesn't exist * [Database Handlers](/packages/database/handlers/introduction) — handlers that use the provider * [Query Building](/packages/database/query-building) — using QueryBuilder and ClauseBuilder * [Caching and Events](/packages/database/caching-and-events) — using CacheableService and EventStrategy +* [Logger Package](/packages/logger/introduction) — LoggerStrategy interface documentation +* [Event Package](/packages/event/introduction) — EventStrategy interface documentation diff --git a/public/docs/packages/database/handlers/identifiable-database-datastore-handler.md b/public/docs/packages/database/handlers/identifiable-database-datastore-handler.md index a893668..f95f78f 100644 --- a/public/docs/packages/database/handlers/identifiable-database-datastore-handler.md +++ b/public/docs/packages/database/handlers/identifiable-database-datastore-handler.md @@ -438,3 +438,4 @@ Business logic belongs in services, not handlers. * [Database Handlers Introduction](/packages/database/handlers/introduction) — overview of handler architecture * [Query Building](/packages/database/query-building) — building custom queries * [Caching and Events](/packages/database/caching-and-events) — customizing cache and event behavior +* [Logger Package](/packages/logger/introduction) — LoggerStrategy interface for error logging diff --git a/public/docs/packages/database/introduction.md b/public/docs/packages/database/introduction.md index 7d7b909..7c8cca8 100644 --- a/public/docs/packages/database/introduction.md +++ b/public/docs/packages/database/introduction.md @@ -47,6 +47,7 @@ see_also: - handlers/introduction - tables/introduction - table-schema-definition + - ../logger/introduction noindex: false --- @@ -410,7 +411,8 @@ If your data comes from REST APIs, GraphQL, or other non-database sources, you d - **[phpnomad/datastore](../datastore/introduction)** — Defines interfaces that database handlers implement - **phpnomad/models** — Provides DataModel interface (covered in [Models and Identity](../../core-concepts/models-and-identity)) -- **phpnomad/events** — EventStrategy interface for broadcasting events +- **[phpnomad/event](../event/introduction)** — EventStrategy interface for broadcasting events +- **[phpnomad/logger](../logger/introduction)** — LoggerStrategy interface for operation logging --- diff --git a/public/docs/packages/datastore/traits/introduction.md b/public/docs/packages/datastore/traits/introduction.md index d39d7ce..6a4bf7d 100644 --- a/public/docs/packages/datastore/traits/introduction.md +++ b/public/docs/packages/datastore/traits/introduction.md @@ -264,3 +264,4 @@ To understand how handlers work and what they're responsible for, see: - [Core Implementation](/packages/datastore/core-implementation) — when to use traits vs manual implementation - [Database Handlers](/packages/database/handlers/introduction) — the handler side of the delegation contract - [Datastore Interfaces](/packages/datastore/interfaces/introduction) — the public contracts these traits implement +- [Logger Package](/packages/logger/introduction) — LoggerStrategy for logging in decorators diff --git a/public/docs/packages/datastore/traits/with-datastore-decorator.md b/public/docs/packages/datastore/traits/with-datastore-decorator.md index 79da848..f2c6dda 100644 --- a/public/docs/packages/datastore/traits/with-datastore-decorator.md +++ b/public/docs/packages/datastore/traits/with-datastore-decorator.md @@ -196,3 +196,4 @@ Most handlers extend this interface with additional capabilities (e.g., `Datasto * [Datastore Interface](/packages/datastore/interfaces/datastore) — the interface this trait implements * [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — adds `find()` method * [Core Implementation](/packages/datastore/core-implementation) — when to use traits vs manual implementation +* [Logger Package](/packages/logger/introduction) — LoggerStrategy interface used in examples above diff --git a/public/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md b/public/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md index 038d63c..7c4b7a9 100644 --- a/public/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md +++ b/public/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md @@ -201,3 +201,4 @@ This ensures the handler supports primary key lookups. * [DatastoreHasPrimaryKey Interface](/packages/datastore/interfaces/datastore-has-primary-key) — the interface this trait implements * [WithDatastoreWhereDecorator](/packages/datastore/traits/with-datastore-where-decorator) — adds query-builder methods * [Database Handlers](/packages/database/handlers/introduction) — the handler side of the contract +* [Logger Package](/packages/logger/introduction) — LoggerStrategy interface used in examples above diff --git a/public/docs/packages/datastore/traits/with-datastore-where-decorator.md b/public/docs/packages/datastore/traits/with-datastore-where-decorator.md index 4364570..0775857 100644 --- a/public/docs/packages/datastore/traits/with-datastore-where-decorator.md +++ b/public/docs/packages/datastore/traits/with-datastore-where-decorator.md @@ -227,3 +227,4 @@ This ensures the handler supports query-builder operations. * [DatastoreHasWhere Interface](/packages/datastore/interfaces/datastore-has-where) — the interface this trait implements * [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — adds `find()` method * [Query Building](/packages/database/query-building) — how handlers implement query builders +* [Logger Package](/packages/logger/introduction) — LoggerStrategy interface used in examples above diff --git a/public/docs/packages/enum-polyfill/introduction.md b/public/docs/packages/enum-polyfill/introduction.md new file mode 100644 index 0000000..db1ec82 --- /dev/null +++ b/public/docs/packages/enum-polyfill/introduction.md @@ -0,0 +1,295 @@ +--- +id: enum-polyfill-introduction +slug: docs/packages/enum-polyfill/introduction +title: Enum Polyfill Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The enum-polyfill package provides PHP 8.1-style enum methods to regular classes using constants, enabling backward-compatible enum functionality. +llm_summary: > + phpnomad/enum-polyfill provides the Enum trait that gives PHP classes enum-like behavior using + class constants. It provides methods matching PHP 8.1's native enum API: cases(), from(), + tryFrom(), getValues(), and isValid(). The trait uses singleton pattern (via WithInstance) to + cache reflection results for performance. Classes using this trait define constants as enum + values and get automatic validation, iteration, and safe value retrieval. Commonly used for + HTTP methods, CRUD action types, session contexts, and other fixed sets of values. Works on + PHP 7.4+ and provides forward-compatible syntax for projects targeting older PHP versions. +questions_answered: + - What is the enum-polyfill package? + - How do I create enums in PHPNomad for older PHP versions? + - When should I use enum-polyfill vs native PHP enums? +audience: + - developers + - backend engineers +tags: + - enum + - polyfill + - trait + - backward-compatibility +llm_tags: + - enum-trait + - cases-method + - from-method + - tryFrom-method + - php-compatibility +keywords: + - phpnomad enum + - php enum polyfill + - enum trait php + - backward compatible enum + - php 7.4 enum +related: + - ../singleton/introduction + - ../auth/introduction + - ../http/introduction +see_also: + - ../cache/introduction + - ../rest/introduction +noindex: false +--- + +# Enum Polyfill + +`phpnomad/enum-polyfill` provides **PHP 8.1-style enum functionality for older PHP versions**. It consists of a single trait—`Enum`—that can be added to any class with constants to gain enum-like behavior. + +At its core: + +* **API compatibility** — Methods match PHP 8.1's native enum API (`cases()`, `from()`, `tryFrom()`) +* **Validation** — Check if values are valid enum members with `isValid()` +* **Performance** — Reflection results cached via singleton pattern +* **Zero migration path** — When upgrading to PHP 8.1+, switch to native enums with minimal changes + +--- + +## Key ideas at a glance + +| Component | Purpose | +|-----------|---------| +| [Enum trait](./traits/enum.md) | Add to any class with constants to get enum behavior | +| `cases()` / `getValues()` | Returns all possible enum values | +| `from($value)` | Gets value or throws exception if invalid | +| `tryFrom($value)` | Gets value or returns null if invalid | +| `isValid($value)` | Check if a value is a valid enum member | + +--- + +## Why this package exists + +PHP 8.1 introduced native enums, but many projects still support older PHP versions. Without a polyfill, developers must: + +| Problem | Without enum-polyfill | With enum-polyfill | +|---------|----------------------|-------------------| +| Validation | Write custom validation per "enum" | `Status::isValid($value)` | +| Listing values | Manual array or reflection | `Status::cases()` | +| Safe retrieval | Custom try/catch everywhere | `Status::tryFrom($value)` | +| Migration path | Rewrite when upgrading PHP | Swap trait for native enum | + +This package provides a **forward-compatible API** that mirrors PHP 8.1 enums, making future migration straightforward. + +--- + +## Installation + +```bash +composer require phpnomad/enum-polyfill +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** `phpnomad/singleton` + +--- + +## Basic usage + +Define a class with constants and add the trait: + +```php +use PHPNomad\Enum\Traits\Enum; + +class Status +{ + use Enum; + + public const Active = 'active'; + public const Pending = 'pending'; + public const Inactive = 'inactive'; +} +``` + +Now use it like a PHP 8.1 enum: + +```php +// Get all possible values +$statuses = Status::cases(); +// ['active', 'pending', 'inactive'] + +// Validate a value +if (Status::isValid($userInput)) { + // Safe to use +} + +// Get value or null +$status = Status::tryFrom($userInput); +if ($status !== null) { + // Valid value +} + +// Get value or throw exception +try { + $status = Status::from($userInput); +} catch (UnexpectedValueException $e) { + // Invalid value +} +``` + +See [Enum trait](./traits/enum.md) for complete API documentation. + +--- + +## When to use this package + +| Scenario | Recommendation | +|----------|---------------| +| PHP 7.4 / 8.0 project | Use enum-polyfill | +| PHP 8.1+ project | Consider native enums | +| Library supporting PHP 7.4+ | Use enum-polyfill for compatibility | +| Need custom enum methods | Use enum-polyfill (more flexible than native) | +| Strict type safety needed | Native PHP 8.1 enums are stronger | + +### Advantages over native enums + +* Works on PHP 7.4+ +* Can add arbitrary methods to enum classes +* Constant values can be any type + +### Advantages of native enums + +* True type safety (function accepts `Status`, not `string`) +* Better IDE support +* Pattern matching with `match` expressions +* Built into the language + +--- + +## When NOT to use this package + +### You're on PHP 8.1+ exclusively + +If you don't need to support older PHP versions, native enums are cleaner: + +```php +// Native PHP 8.1 enum +enum Status: string +{ + case Active = 'active'; + case Pending = 'pending'; + case Inactive = 'inactive'; +} + +// Type-safe function +function setStatus(Status $status): void +{ + // $status is guaranteed to be a valid Status +} + +setStatus(Status::Active); // OK +setStatus('active'); // Error - type mismatch +``` + +### You need true type safety + +The polyfill returns the constant values (strings, integers, etc.), not typed objects: + +```php +// With polyfill - no type safety +function setStatus(string $status): void +{ + if (!Status::isValid($status)) { + throw new InvalidArgumentException(); + } +} + +setStatus('typo'); // Compiles fine, fails at runtime +``` + +--- + +## Package contents + +### Traits + +| Trait | Description | +|-------|-------------| +| [Enum](./traits/enum.md) | Provides enum-like behavior to classes with constants | + +See [Traits Overview](./traits/introduction.md) for details. + +--- + +## Migration to native enums + +When you upgrade to PHP 8.1+, converting is straightforward: + +```php +// Before (polyfill) +use PHPNomad\Enum\Traits\Enum; + +class Status +{ + use Enum; + + public const Active = 'active'; + public const Pending = 'pending'; + public const Inactive = 'inactive'; +} + +$status = Status::from('active'); // 'active' +``` + +```php +// After (native) +enum Status: string +{ + case Active = 'active'; + case Pending = 'pending'; + case Inactive = 'inactive'; +} + +$status = Status::from('active'); // Status::Active +``` + +The main difference: native enums return enum instances, while the polyfill returns the raw values. Update call sites accordingly. + +--- + +## Relationship to other packages + +### Dependencies + +| Package | Relationship | +|---------|-------------| +| [singleton](../singleton/introduction.md) | Uses `WithInstance` trait for caching | + +### Packages that use enum-polyfill + +| Package | How it uses enum-polyfill | +|---------|--------------------------| +| [auth](../auth/introduction.md) | `ActionTypes`, `SessionContexts` enums | +| [http](../http/introduction.md) | `Method` enum for HTTP verbs | +| [cache](../cache/introduction.md) | `Operation` enum for CRUD operations | +| [rest](../rest/introduction.md) | `BasicTypes` enum | +| wordpress-plugin | Various status and type enums | + +--- + +## Next steps + +* **[Enum Trait](./traits/enum.md)** — Complete API reference +* **[Singleton Package](../singleton/introduction.md)** — Understand the caching mechanism +* **[HTTP Package](../http/introduction.md)** — See Method enum in action +* **[Auth Package](../auth/introduction.md)** — See ActionTypes enum diff --git a/public/docs/packages/enum-polyfill/traits/enum.md b/public/docs/packages/enum-polyfill/traits/enum.md new file mode 100644 index 0000000..65a3ddc --- /dev/null +++ b/public/docs/packages/enum-polyfill/traits/enum.md @@ -0,0 +1,429 @@ +--- +id: enum-trait +slug: docs/packages/enum-polyfill/traits/enum +title: Enum Trait +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The Enum trait provides PHP 8.1-style enum methods to classes using constants. +llm_summary: > + The Enum trait from phpnomad/enum-polyfill gives PHP classes enum-like behavior using + class constants. It provides methods matching PHP 8.1's native enum API: cases(), from(), + tryFrom(), getValues(), and isValid(). The trait uses singleton pattern (via WithInstance) + to cache reflection results for performance. Classes using this trait define constants as + enum values and get automatic validation, iteration, and safe value retrieval. +questions_answered: + - How does the Enum trait work? + - What methods does the Enum trait provide? + - What is the difference between from() and tryFrom()? + - How do I validate a value against an enum? + - How do I get all enum values? + - Can I add custom methods to enum classes? +audience: + - developers + - backend engineers +tags: + - enum + - trait + - polyfill + - backward-compatibility +llm_tags: + - enum-trait + - cases-method + - from-method + - tryFrom-method +keywords: + - Enum trait + - php enum + - cases method + - from method + - tryFrom method +related: + - ../introduction + - ../../singleton/introduction +see_also: + - ../../auth/introduction + - ../../http/introduction +noindex: false +--- + +# Enum Trait + +**Namespace:** `PHPNomad\Enum\Traits` + +The `Enum` trait adds PHP 8.1-style enum functionality to any class with constants. Add this trait to gain `cases()`, `from()`, `tryFrom()`, and `isValid()` methods. + +--- + +## How It Works + +The trait uses reflection to read class constants and provides methods to work with them: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ class Status { use Enum; const Active = 'active'; ... } │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ First call to cases()/from() │ + └─────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Reflection reads constants │ + │ ['Active' => 'active', ...] │ + └─────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Values cached in singleton │ + │ (via WithInstance trait) │ + └─────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────┐ + │ Subsequent calls use cache │ + └─────────────────────────────────┘ +``` + +The singleton pattern ensures reflection only runs once per class, regardless of how many times you call enum methods. + +--- + +## Methods + +### cases() + +Returns all enum values as an array. + +```php +public static function cases(): array +``` + +**Returns:** Array of all constant values + +**Example:** + +```php +class Status +{ + use Enum; + + public const Active = 'active'; + public const Pending = 'pending'; + public const Inactive = 'inactive'; +} + +$statuses = Status::cases(); +// ['active', 'pending', 'inactive'] +``` + +--- + +### getValues() + +Alias for `cases()`. Returns all enum values. + +```php +public static function getValues(): array +``` + +**Returns:** Array of all constant values + +--- + +### isValid() + +Checks if a value is a valid enum member. + +```php +public static function isValid($value): bool +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$value` | `mixed` | The value to check | + +**Returns:** `true` if value matches a constant, `false` otherwise + +**Note:** Uses strict comparison (`===`) + +**Example:** + +```php +Status::isValid('active'); // true +Status::isValid('invalid'); // false +Status::isValid(null); // false +``` + +--- + +### from() + +Returns the value if valid, throws exception if not. + +```php +public static function from($value): mixed +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$value` | `mixed` | The value to validate and return | + +**Returns:** The value if it's a valid enum member + +**Throws:** `UnexpectedValueException` if value is not valid + +**Example:** + +```php +$status = Status::from('active'); // 'active' +$status = Status::from('invalid'); // throws UnexpectedValueException +``` + +**Use for:** Internal code where invalid values indicate bugs + +--- + +### tryFrom() + +Returns the value if valid, `null` if not. + +```php +public static function tryFrom($value): mixed|null +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$value` | `mixed` | The value to validate and return | + +**Returns:** The value if valid, `null` if not + +**Example:** + +```php +$status = Status::tryFrom('active'); // 'active' +$status = Status::tryFrom('invalid'); // null +``` + +**Use for:** User input where invalid values are expected + +--- + +## Basic Usage + +Define a class with constants and add the trait: + +```php +use PHPNomad\Enum\Traits\Enum; + +class Status +{ + use Enum; + + public const Active = 'active'; + public const Pending = 'pending'; + public const Inactive = 'inactive'; +} +``` + +Now use it like a PHP 8.1 enum: + +```php +// Get all possible values +$statuses = Status::cases(); +// ['active', 'pending', 'inactive'] + +// Validate a value +if (Status::isValid($userInput)) { + // Safe to use +} + +// Get value or null +$status = Status::tryFrom($userInput); +if ($status !== null) { + // Valid value +} + +// Get value or throw exception +try { + $status = Status::from($userInput); +} catch (UnexpectedValueException $e) { + // Invalid value +} +``` + +--- + +## Real-World Examples + +### HTTP Methods (from phpnomad/http) + +```php +use PHPNomad\Enum\Traits\Enum; + +class Method +{ + use Enum; + + public const Get = 'GET'; + public const Post = 'POST'; + public const Put = 'PUT'; + public const Delete = 'DELETE'; + public const Patch = 'PATCH'; + public const Options = 'OPTIONS'; +} + +// Usage in a router +function registerRoute(string $method, string $path, callable $handler): void +{ + if (!Method::isValid($method)) { + throw new InvalidArgumentException("Invalid HTTP method: {$method}"); + } + // Register route... +} + +registerRoute(Method::Get, '/users', $listUsers); +registerRoute(Method::Post, '/users', $createUser); +``` + +### CRUD Action Types (from phpnomad/auth) + +```php +use PHPNomad\Enum\Traits\Enum; + +class ActionTypes +{ + use Enum; + + public const Create = 'create'; + public const Read = 'read'; + public const Update = 'update'; + public const Delete = 'delete'; +} + +// Usage in permission checking +function canPerformAction(User $user, string $action, Resource $resource): bool +{ + $action = ActionTypes::from($action); // Validates the action + return $user->hasPermission($action, $resource); +} +``` + +--- + +## Adding Custom Methods + +Unlike native PHP enums (which have limitations), classes using the Enum trait are regular PHP classes—you can add any methods you need: + +```php +use PHPNomad\Enum\Traits\Enum; + +class Priority +{ + use Enum; + + public const Low = 1; + public const Medium = 2; + public const High = 3; + public const Critical = 4; + + /** + * Get human-readable label + */ + public static function getLabel(int $priority): string + { + return match ($priority) { + self::Low => 'Low Priority', + self::Medium => 'Medium Priority', + self::High => 'High Priority', + self::Critical => 'Critical', + default => 'Unknown', + }; + } + + /** + * Check if priority is urgent + */ + public static function isUrgent(int $priority): bool + { + return $priority >= self::High; + } +} +``` + +--- + +## Behavior Notes + +| Aspect | Behavior | +|--------|----------| +| Comparison | `isValid()` uses strict type checking (`===`) | +| Caching | Reflection results cached after first access | +| Return values | Returns constant values, not constant names | +| Dependencies | Uses `WithInstance` from singleton package | + +--- + +## Best Practices + +### Use from() for internal code, tryFrom() for user input + +```php +// Internal code - throw on invalid (indicates bug) +$method = Method::from($routeConfig['method']); + +// User input - handle gracefully +$status = Status::tryFrom($userInput); +if ($status === null) { + // Show error to user +} +``` + +### Validate at system boundaries + +```php +class UserController +{ + public function updateStatus(Request $request): Response + { + $status = Status::tryFrom($request->get('status')); + + if ($status === null) { + return Response::badRequest('Invalid status'); + } + + // Safe to use $status + $this->userService->setStatus($userId, $status); + } +} +``` + +### Use meaningful constant names and values + +```php +// Good - clear names, consistent values +class HttpStatus +{ + use Enum; + + public const Ok = 200; + public const Created = 201; + public const BadRequest = 400; + public const NotFound = 404; +} +``` + +--- + +## See Also + +- [Enum Polyfill Package Overview](../introduction.md) - High-level documentation +- [Singleton Package](../../singleton/introduction.md) - Provides caching mechanism +- [Auth Package](../../auth/introduction.md) - Uses ActionTypes enum +- [HTTP Package](../../http/introduction.md) - Uses Method enum diff --git a/public/docs/packages/enum-polyfill/traits/introduction.md b/public/docs/packages/enum-polyfill/traits/introduction.md new file mode 100644 index 0000000..945b197 --- /dev/null +++ b/public/docs/packages/enum-polyfill/traits/introduction.md @@ -0,0 +1,86 @@ +--- +id: enum-polyfill-traits-introduction +slug: docs/packages/enum-polyfill/traits/introduction +title: Enum Polyfill Traits Overview +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of traits provided by the enum-polyfill package. +llm_summary: > + The phpnomad/enum-polyfill package provides one trait: Enum, which gives any PHP class with + constants enum-like behavior including cases(), from(), tryFrom(), and isValid() methods. + This enables PHP 8.1-style enum functionality on older PHP versions. +questions_answered: + - What traits does the enum-polyfill package provide? + - What is the Enum trait? +audience: + - developers + - backend engineers +tags: + - enum + - traits + - reference +llm_tags: + - enum-trait + - php-compatibility +keywords: + - enum traits + - Enum trait +related: + - ../introduction + - ./enum +see_also: + - ../../singleton/introduction +noindex: false +--- + +# Enum Polyfill Traits + +The enum-polyfill package provides one trait that enables PHP 8.1-style enum functionality on older PHP versions. + +--- + +## Available Traits + +| Trait | Purpose | +|-------|---------| +| [Enum](./enum.md) | Adds enum-like behavior to classes with constants | + +--- + +## Quick Reference + +### Enum Trait + +Adds `cases()`, `from()`, `tryFrom()`, and `isValid()` methods to any class: + +```php +use PHPNomad\Enum\Traits\Enum; + +class Status +{ + use Enum; + + public const Active = 'active'; + public const Pending = 'pending'; + public const Inactive = 'inactive'; +} + +// Now use it like a PHP 8.1 enum +$statuses = Status::cases(); // ['active', 'pending', 'inactive'] +$isValid = Status::isValid('active'); // true +$status = Status::tryFrom('invalid'); // null +``` + +See [Enum](./enum.md) for complete documentation. + +--- + +## See Also + +- [Enum Polyfill Package Overview](../introduction.md) - High-level package documentation +- [Singleton Package](../../singleton/introduction.md) - The Enum trait uses singleton for caching diff --git a/public/docs/packages/event/interfaces/action-binding-strategy.md b/public/docs/packages/event/interfaces/action-binding-strategy.md new file mode 100644 index 0000000..e2f7c68 --- /dev/null +++ b/public/docs/packages/event/interfaces/action-binding-strategy.md @@ -0,0 +1,433 @@ +--- +id: event-interface-action-binding-strategy +slug: docs/packages/event/interfaces/action-binding-strategy +title: ActionBindingStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The ActionBindingStrategy interface binds external platform actions to internal application events. +llm_summary: > + ActionBindingStrategy bridges external platform actions (like WordPress hooks) to internal events + in phpnomad/event. The bindAction() method registers a listener on an external action that creates + and broadcasts an internal event when triggered. Accepts an event class, the external action name, + and an optional transformer to convert platform data into event constructor arguments. Used by + wordpress-integration to connect WordPress actions to PHPNomad events without coupling application + code to WordPress. +questions_answered: + - What is ActionBindingStrategy? + - How do I connect WordPress hooks to my events? + - What is the transformer parameter for? + - How does ActionBindingStrategy work with EventStrategy? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference + - platform-integration +llm_tags: + - action-binding-strategy + - platform-bridge + - wordpress-hooks +keywords: + - ActionBindingStrategy + - bindAction + - platform hooks + - WordPress integration +related: + - introduction + - has-event-bindings + - event-strategy +see_also: + - ../introduction + - ../../wordpress-integration/introduction +noindex: false +--- + +# ActionBindingStrategy Interface + +The `ActionBindingStrategy` interface connects external platform actions (like WordPress hooks) to your internal event system. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +interface ActionBindingStrategy +{ + /** + * Binds an external action to an event class. + * + * @param string $eventClass The event class to create + * @param string $actionToBind The external action to listen for + * @param callable|null $transformer Converts action args to event constructor args + */ + public function bindAction(string $eventClass, string $actionToBind, ?callable $transformer = null); +} +``` + +--- + +## Method Reference + +### `bindAction(string $eventClass, string $actionToBind, ?callable $transformer = null)` + +Registers a binding between an external action and an internal event. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$eventClass` | `string` | Fully-qualified class name of the Event to create | +| `$actionToBind` | `string` | Name of the external action/hook to listen for | +| `$transformer` | `?callable` | Converts action arguments to event constructor arguments | + +**Flow:** +``` +External Action fires + │ + ▼ +ActionBindingStrategy intercepts + │ + ▼ +Transformer converts arguments + │ + ▼ +Event instance created + │ + ▼ +EventStrategy broadcasts + │ + ▼ +Your handlers respond +``` + +--- + +## Basic Usage + +### Without Transformer + +When the external action provides arguments matching your event constructor: + +```php +// Event expects ($userId) +// WordPress 'user_register' hook provides ($userId) +$binding->bindAction( + UserCreatedEvent::class, + 'user_register' +); +``` + +### With Transformer + +When arguments need conversion: + +```php +$binding->bindAction( + OrderPlacedEvent::class, + 'woocommerce_new_order', + function($orderId) { + $order = wc_get_order($orderId); + return [ + $orderId, + $order->get_customer_id(), + (float) $order->get_total(), + ]; + } +); +``` + +The transformer returns an array of arguments for the event constructor. + +--- + +## How It Works + +### Conceptual Implementation + +```php +class WordPressActionBindingStrategy implements ActionBindingStrategy +{ + public function __construct(private EventStrategy $events) {} + + public function bindAction( + string $eventClass, + string $actionToBind, + ?callable $transformer = null + ) { + add_action($actionToBind, function(...$args) use ($eventClass, $transformer) { + // Transform arguments if transformer provided + $eventArgs = $transformer ? $transformer(...$args) : $args; + + // Create event instance + $event = new $eventClass(...$eventArgs); + + // Broadcast through internal event system + $this->events->broadcast($event); + }); + } +} +``` + +--- + +## Usage Patterns + +### Binding at Bootstrap + +```php +class WordPressBootstrapper +{ + public function __construct( + private ActionBindingStrategy $bindings + ) {} + + public function boot(): void + { + // User events + $this->bindings->bindAction( + UserCreatedEvent::class, + 'user_register', + fn($userId) => [$userId, get_userdata($userId)->user_email] + ); + + // Post events + $this->bindings->bindAction( + PostPublishedEvent::class, + 'publish_post', + fn($postId, $post) => [$postId, $post->post_title] + ); + } +} +``` + +### Processing HasEventBindings + +`ActionBindingStrategy` is often used to process `HasEventBindings` declarations: + +```php +class EventBindingProcessor +{ + public function __construct( + private ActionBindingStrategy $bindings + ) {} + + public function process(HasEventBindings $provider): void + { + foreach ($provider->getEventBindings() as $eventClass => $configs) { + foreach ($configs as $config) { + $this->bindings->bindAction( + $eventClass, + $config['action'], + $config['transformer'] ?? null + ); + } + } + } +} +``` + +--- + +## Transformer Patterns + +### Identity Transformer (Pass-through) + +When action args match event constructor: + +```php +// No transformer needed +$binding->bindAction(SimpleEvent::class, 'simple_action'); + +// Explicit pass-through +$binding->bindAction( + SimpleEvent::class, + 'simple_action', + fn(...$args) => $args +); +``` + +### Extracting Data + +```php +$binding->bindAction( + CustomerCreatedEvent::class, + 'woocommerce_created_customer', + function($customerId, $newCustomerData, $passwordGenerated) { + // Only pass what the event needs + return [ + $customerId, + $newCustomerData['user_email'], + ]; + } +); +``` + +### Enriching Data + +```php +$binding->bindAction( + PostPublishedEvent::class, + 'publish_post', + function($postId, $post) { + $author = get_userdata($post->post_author); + return [ + $postId, + $post->post_title, + $author->user_email, + new \DateTimeImmutable($post->post_date), + ]; + } +); +``` + +### Conditional Binding + +Return `null` from transformer to skip event creation: + +```php +$binding->bindAction( + ProductPublishedEvent::class, + 'save_post', + function($postId, $post, $update) { + // Only for new products, not updates + if ($update || $post->post_type !== 'product') { + return null; + } + return [$postId, $post->post_title]; + } +); +``` + +--- + +## Real-World Example + +### Complete WordPress Integration + +```php +class WordPressEventBridge +{ + public function __construct( + private ActionBindingStrategy $bindings + ) {} + + public function register(): void + { + $this->registerUserBindings(); + $this->registerPostBindings(); + $this->registerCommentBindings(); + } + + private function registerUserBindings(): void + { + // User registration + $this->bindings->bindAction( + UserRegisteredEvent::class, + 'user_register', + function($userId) { + $user = get_userdata($userId); + return [ + $userId, + $user->user_email, + $user->user_login, + ]; + } + ); + + // Profile update + $this->bindings->bindAction( + UserProfileUpdatedEvent::class, + 'profile_update', + fn($userId, $oldData) => [$userId, $oldData] + ); + + // User deletion + $this->bindings->bindAction( + UserDeletedEvent::class, + 'delete_user', + fn($userId, $reassignId) => [$userId] + ); + } + + private function registerPostBindings(): void + { + // New post published + $this->bindings->bindAction( + PostPublishedEvent::class, + 'publish_post', + fn($postId, $post) => [$postId, $post->post_title, $post->post_author] + ); + + // Post status changed + $this->bindings->bindAction( + PostStatusChangedEvent::class, + 'transition_post_status', + fn($new, $old, $post) => [$post->ID, $old, $new] + ); + } + + private function registerCommentBindings(): void + { + $this->bindings->bindAction( + CommentPostedEvent::class, + 'wp_insert_comment', + function($commentId, $comment) { + return [ + $commentId, + $comment->comment_post_ID, + $comment->comment_author_email, + ]; + } + ); + } +} +``` + +--- + +## Testing + +Mock `ActionBindingStrategy` to verify bindings are registered: + +```php +class WordPressEventBridgeTest extends TestCase +{ + public function test_registers_user_bindings(): void + { + $bindings = $this->createMock(ActionBindingStrategy::class); + + $bindings->expects($this->atLeastOnce()) + ->method('bindAction') + ->with( + $this->equalTo(UserRegisteredEvent::class), + $this->equalTo('user_register'), + $this->isType('callable') + ); + + $bridge = new WordPressEventBridge($bindings); + $bridge->register(); + } +} +``` + +--- + +## Related Interfaces + +- **[HasEventBindings](has-event-bindings)** — Declarative binding configuration +- **[EventStrategy](event-strategy)** — Receives broadcasted events +- **[Event](event)** — Events created from bindings + +--- + +## Further Reading + +- **[Event Bindings Guide](/core-concepts/bootstrapping/initializers/event-binding)** — Tutorial-style guide with WordPress examples +- **[Best Practices](../patterns/best-practices)** — Transformer patterns and testing strategies diff --git a/public/docs/packages/event/interfaces/can-handle.md b/public/docs/packages/event/interfaces/can-handle.md new file mode 100644 index 0000000..772036a --- /dev/null +++ b/public/docs/packages/event/interfaces/can-handle.md @@ -0,0 +1,372 @@ +--- +id: event-interface-can-handle +slug: docs/packages/event/interfaces/can-handle +title: CanHandle Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The CanHandle interface defines event handler classes that respond to specific events. +llm_summary: > + CanHandle is a generic interface for event handler classes in phpnomad/event. Handlers implement + handle(Event $event): void to respond when their associated event is broadcast. The interface + uses PHP generics (@template T of Event) for type safety. Handlers are typically registered via + HasListeners and instantiated through the DI container, enabling dependency injection of services + like email, logging, or database access. Keep handlers focused on a single responsibility. +questions_answered: + - What is the CanHandle interface? + - How do I create an event handler? + - How do handlers get dependencies? + - Can one handler handle multiple events? + - How do I access event data in a handler? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference + - handlers +llm_tags: + - can-handle + - event-handler + - handler-pattern +keywords: + - CanHandle + - event handler + - handle method +related: + - introduction + - event + - has-listeners +see_also: + - ../introduction + - ../patterns/best-practices +noindex: false +--- + +# CanHandle Interface + +The `CanHandle` interface defines event handler classes—dedicated objects that respond when specific events are broadcast. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +/** + * @template T of Event + */ +interface CanHandle +{ + /** + * Handle the event. + * + * @param T $event + */ + public function handle(Event $event): void; +} +``` + +--- + +## Method Reference + +### `handle(Event $event): void` + +Called when the associated event is broadcast. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$event` | `Event` | The event object containing data about what happened | +| **Returns** | `void` | | + +--- + +## Creating Handlers + +### Basic Handler + +```php +use PHPNomad\Events\Interfaces\CanHandle; +use PHPNomad\Events\Interfaces\Event; + +/** + * @implements CanHandle + */ +class LogUserCreationHandler implements CanHandle +{ + public function handle(Event $event): void + { + /** @var UserCreatedEvent $event */ + error_log("User created: {$event->userId}"); + } +} +``` + +### Handler with Dependencies + +Handlers are instantiated through the DI container, enabling dependency injection: + +```php +/** + * @implements CanHandle + */ +class SendWelcomeEmailHandler implements CanHandle +{ + public function __construct( + private EmailStrategy $email, + private TemplateRenderer $templates + ) {} + + public function handle(Event $event): void + { + /** @var UserCreatedEvent $event */ + $html = $this->templates->render('welcome', [ + 'userId' => $event->userId, + 'email' => $event->email, + ]); + + $this->email->send( + to: $event->email, + subject: 'Welcome!', + body: $html + ); + } +} +``` + +### Handler with Error Handling + +```php +/** + * @implements CanHandle + */ +class ProcessPaymentHandler implements CanHandle +{ + public function __construct( + private PaymentProcessor $processor, + private LoggerStrategy $logger + ) {} + + public function handle(Event $event): void + { + /** @var PaymentReceivedEvent $event */ + try { + $this->processor->confirm($event->transactionId); + } catch (PaymentException $e) { + $this->logger->error('Payment confirmation failed', [ + 'transaction_id' => $event->transactionId, + 'error' => $e->getMessage(), + ]); + // Decide: re-throw, queue for retry, or swallow + } + } +} +``` + +--- + +## Registering Handlers + +Handlers are typically registered via [HasListeners](has-listeners): + +```php +class MyModule implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreatedEvent::class => SendWelcomeEmailHandler::class, + OrderPlacedEvent::class => [ + SendOrderConfirmationHandler::class, + UpdateInventoryHandler::class, + NotifyWarehouseHandler::class, + ], + ]; + } +} +``` + +--- + +## Type Safety with Generics + +The `@template` annotation provides IDE support and static analysis: + +```php +/** + * @implements CanHandle + */ +class NotifyCustomerOfShipmentHandler implements CanHandle +{ + public function handle(Event $event): void + { + // IDE knows $event is OrderShippedEvent + $trackingNumber = $event->trackingNumber; + $carrier = $event->carrier; + } +} +``` + +**Without the annotation**, you need explicit type assertions: + +```php +public function handle(Event $event): void +{ + /** @var OrderShippedEvent $event */ + // or + assert($event instanceof OrderShippedEvent); +} +``` + +--- + +## Best Practices + +### 1. One Handler, One Responsibility + +Each handler should do one thing: + +```php +// Good: focused handlers +class SendWelcomeEmailHandler implements CanHandle { /* sends email */ } +class CreateUserProfileHandler implements CanHandle { /* creates profile */ } +class NotifyAdminOfNewUserHandler implements CanHandle { /* notifies admin */ } + +// Bad: handler does too much +class UserCreatedHandler implements CanHandle +{ + public function handle(Event $event): void + { + $this->sendWelcomeEmail($event); + $this->createUserProfile($event); + $this->notifyAdmin($event); + $this->updateStatistics($event); + } +} +``` + +### 2. Inject Dependencies + +Let the container manage dependencies: + +```php +// Good: dependencies injected +class NotifySlackHandler implements CanHandle +{ + public function __construct(private SlackClient $slack) {} +} + +// Bad: creates own dependencies +class NotifySlackHandler implements CanHandle +{ + public function handle(Event $event): void + { + $slack = new SlackClient(getenv('SLACK_TOKEN')); // Hard to test + } +} +``` + +### 3. Keep Handlers Fast + +Long-running operations should be queued: + +```php +// Good: queue heavy work +class GenerateReportHandler implements CanHandle +{ + public function __construct(private Queue $queue) {} + + public function handle(Event $event): void + { + $this->queue->push(new GenerateReportJob($event->reportId)); + } +} + +// Bad: blocks event dispatch +class GenerateReportHandler implements CanHandle +{ + public function handle(Event $event): void + { + $this->generateReport($event->reportId); // Takes 5 minutes! + } +} +``` + +### 4. Handle Errors Gracefully + +Don't let one handler break others: + +```php +public function handle(Event $event): void +{ + try { + $this->doWork($event); + } catch (\Exception $e) { + $this->logger->error('Handler failed', [ + 'handler' => self::class, + 'event' => $event->getId(), + 'error' => $e->getMessage(), + ]); + // Don't re-throw unless you want to stop other handlers + } +} +``` + +--- + +## Testing Handlers + +Handlers are easy to test because they're focused classes with injected dependencies: + +```php +class SendWelcomeEmailHandlerTest extends TestCase +{ + public function test_sends_welcome_email(): void + { + $email = $this->createMock(EmailStrategy::class); + $templates = $this->createMock(TemplateRenderer::class); + + $templates->method('render') + ->with('welcome', ['userId' => 123, 'email' => 'test@example.com']) + ->willReturn('Welcome!'); + + $email->expects($this->once()) + ->method('send') + ->with( + to: 'test@example.com', + subject: 'Welcome!', + body: 'Welcome!' + ); + + $handler = new SendWelcomeEmailHandler($email, $templates); + $handler->handle(new UserCreatedEvent( + userId: 123, + email: 'test@example.com', + createdAt: new \DateTimeImmutable() + )); + } +} +``` + +--- + +## Related Interfaces + +- **[Event](event)** — Events that handlers receive +- **[EventStrategy](event-strategy)** — Dispatcher that calls handlers +- **[HasListeners](has-listeners)** — Declares which handlers respond to which events + +--- + +## Further Reading + +- **[Event Listeners Guide](/core-concepts/bootstrapping/initializers/event-listeners)** — Tutorial-style guide with dependency injection examples +- **[Best Practices](../patterns/best-practices)** — Handler patterns, testing strategies, anti-patterns +- **[Logger Package](../../logger/introduction.md)** — LoggerStrategy for logging in handlers diff --git a/public/docs/packages/event/interfaces/event-strategy.md b/public/docs/packages/event/interfaces/event-strategy.md new file mode 100644 index 0000000..ff275cb --- /dev/null +++ b/public/docs/packages/event/interfaces/event-strategy.md @@ -0,0 +1,402 @@ +--- +id: event-interface-event-strategy +slug: docs/packages/event/interfaces/event-strategy +title: EventStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The EventStrategy interface defines the core event dispatcher for broadcasting events and managing listeners. +llm_summary: > + EventStrategy is the central interface for event management in phpnomad/event. It provides three + methods: broadcast() dispatches an Event to all registered listeners, attach() registers a callable + to respond to a specific event ID with optional priority, and detach() removes a previously attached + listener. Implementations include symfony-event-dispatcher-integration. Priority determines execution + order (higher priority runs first). This is the interface you inject when you need to dispatch events. +questions_answered: + - What is EventStrategy? + - How do I broadcast events? + - How do I attach listeners? + - How does priority work? + - How do I detach listeners? + - What implementations are available? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference + - dispatcher +llm_tags: + - event-strategy + - event-dispatcher + - broadcast +keywords: + - EventStrategy + - broadcast + - attach + - detach + - event dispatcher +related: + - introduction + - event + - can-handle +see_also: + - ../introduction + - ../../symfony-event-dispatcher-integration/introduction +noindex: false +--- + +# EventStrategy Interface + +The `EventStrategy` interface is the core dispatcher in PHPNomad's event system. It handles broadcasting events to listeners and managing listener registration. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +interface EventStrategy +{ + /** + * Broadcasts an event to all attached listeners. + */ + public function broadcast(Event $event): void; + + /** + * Attaches a listener to an event. + */ + public function attach(string $event, callable $action, ?int $priority = null): void; + + /** + * Detaches a listener from an event. + */ + public function detach(string $event, callable $action, ?int $priority = null): void; +} +``` + +--- + +## Method Reference + +### `broadcast(Event $event): void` + +Dispatches an event to all listeners registered for that event's ID. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$event` | `Event` | The event object to broadcast | +| **Returns** | `void` | | + +```php +$events->broadcast(new UserCreatedEvent($user)); +``` + +The event's `getId()` method determines which listeners are called. + +--- + +### `attach(string $event, callable $action, ?int $priority = null): void` + +Registers a listener for an event ID. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$event` | `string` | Event ID to listen for | +| `$action` | `callable` | Function to call when event fires | +| `$priority` | `?int` | Execution order (higher = earlier). Default varies by implementation | +| **Returns** | `void` | | + +```php +// Attach a closure +$events->attach('user.created', function(UserCreatedEvent $event) { + // Handle the event +}); + +// Attach a method +$events->attach('user.created', [$this, 'onUserCreated']); + +// Attach with priority +$events->attach('user.created', $handler, priority: 100); +``` + +--- + +### `detach(string $event, callable $action, ?int $priority = null): void` + +Removes a previously attached listener. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$event` | `string` | Event ID to stop listening for | +| `$action` | `callable` | The exact callable that was attached | +| `$priority` | `?int` | Priority it was attached with | +| **Returns** | `void` | | + +```php +// Remove a listener +$events->detach('user.created', $myHandler); +``` + +**Note:** You must pass the exact same callable reference that was used in `attach()`. + +--- + +## Priority System + +Listeners can specify a priority that determines execution order. + +```php +// High priority (runs first) +$events->attach('order.placed', $validateHandler, priority: 100); + +// Default priority +$events->attach('order.placed', $saveHandler, priority: 50); + +// Low priority (runs last) +$events->attach('order.placed', $notifyHandler, priority: 10); +``` + +**Execution order:** 100 → 50 → 10 (highest to lowest) + +### Priority Guidelines + +| Priority Range | Use Case | +|----------------|----------| +| 90-100 | Validation, security checks | +| 50-89 | Core business logic | +| 10-49 | Side effects, notifications | +| 1-9 | Logging, cleanup | + +--- + +## Usage Patterns + +### Injecting EventStrategy + +Use dependency injection to get an `EventStrategy` instance: + +```php +class OrderService +{ + public function __construct( + private EventStrategy $events, + private OrderRepository $orders + ) {} + + public function placeOrder(Cart $cart): Order + { + $order = Order::fromCart($cart); + $this->orders->save($order); + + $this->events->broadcast(new OrderPlacedEvent( + orderId: $order->getId(), + total: $order->getTotal() + )); + + return $order; + } +} +``` + +### Registering Listeners at Bootstrap + +```php +class AppServiceProvider +{ + public function __construct( + private EventStrategy $events, + private Container $container + ) {} + + public function boot(): void + { + // Closure listener + $this->events->attach('user.created', function($event) { + // Handle event + }); + + // Handler class (resolved through container) + $this->events->attach('user.created', function($event) { + $this->container->get(SendWelcomeEmailHandler::class)->handle($event); + }); + } +} +``` + +### Conditional Event Handling + +```php +$this->events->attach('post.published', function(PostPublishedEvent $event) { + // Only notify for featured posts + if ($event->isFeatured) { + $this->notifier->notifySubscribers($event->postId); + } +}); +``` + +--- + +## Implementations + +### Symfony EventDispatcher Integration + +The primary implementation uses Symfony's EventDispatcher: + +```bash +composer require phpnomad/symfony-event-dispatcher-integration +``` + +See [Symfony Event Dispatcher Integration](/packages/symfony-event-dispatcher-integration/introduction) for details. + +### Custom Implementation + +You can create your own implementation: + +```php +class SimpleEventStrategy implements EventStrategy +{ + private array $listeners = []; + + public function broadcast(Event $event): void + { + $id = $event->getId(); + + if (!isset($this->listeners[$id])) { + return; + } + + // Sort by priority (descending) + $listeners = $this->listeners[$id]; + usort($listeners, fn($a, $b) => $b['priority'] <=> $a['priority']); + + foreach ($listeners as $listener) { + ($listener['action'])($event); + } + } + + public function attach(string $event, callable $action, ?int $priority = null): void + { + $this->listeners[$event][] = [ + 'action' => $action, + 'priority' => $priority ?? 0, + ]; + } + + public function detach(string $event, callable $action, ?int $priority = null): void + { + if (!isset($this->listeners[$event])) { + return; + } + + $this->listeners[$event] = array_filter( + $this->listeners[$event], + fn($l) => $l['action'] !== $action + ); + } +} +``` + +--- + +## Testing with EventStrategy + +### Mocking in Tests + +```php +class OrderServiceTest extends TestCase +{ + public function test_broadcast_event_on_order_placed(): void + { + $events = $this->createMock(EventStrategy::class); + + $events->expects($this->once()) + ->method('broadcast') + ->with($this->callback(fn($e) => + $e instanceof OrderPlacedEvent && + $e->orderId === 123 + )); + + $service = new OrderService($events, $this->orders); + $service->placeOrder($this->cart); + } +} +``` + +### Capturing Broadcasted Events + +```php +class TestEventStrategy implements EventStrategy +{ + public array $broadcastedEvents = []; + + public function broadcast(Event $event): void + { + $this->broadcastedEvents[] = $event; + } + + // ... attach/detach implementations +} + +// In test +$events = new TestEventStrategy(); +$service = new OrderService($events, $orders); +$service->placeOrder($cart); + +$this->assertCount(1, $events->broadcastedEvents); +$this->assertInstanceOf(OrderPlacedEvent::class, $events->broadcastedEvents[0]); +``` + +--- + +## Common Mistakes + +### Forgetting to Broadcast + +```php +// Bad: event created but never broadcast +public function createUser(): User +{ + $user = new User(); + $this->users->save($user); + new UserCreatedEvent($user); // Lost! + return $user; +} + +// Good: actually broadcast +public function createUser(): User +{ + $user = new User(); + $this->users->save($user); + $this->events->broadcast(new UserCreatedEvent($user)); + return $user; +} +``` + +### Blocking in Listeners + +```php +// Bad: slow listener blocks the broadcast +$events->attach('order.placed', function($event) { + $this->sendEmailToAllSubscribers($event); // Takes 30 seconds! +}); + +// Good: queue heavy work +$events->attach('order.placed', function($event) { + $this->queue->push(new SendOrderEmailsJob($event->orderId)); +}); +``` + +--- + +## Related Interfaces + +- **[Event](event)** — Events that are broadcast +- **[CanHandle](can-handle)** — Handler classes for events +- **[HasListeners](has-listeners)** — Declarative listener registration diff --git a/public/docs/packages/event/interfaces/event.md b/public/docs/packages/event/interfaces/event.md new file mode 100644 index 0000000..16dde29 --- /dev/null +++ b/public/docs/packages/event/interfaces/event.md @@ -0,0 +1,357 @@ +--- +id: event-interface-event +slug: docs/packages/event/interfaces/event +title: Event Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The Event interface defines the contract for all event objects in PHPNomad's event system. +llm_summary: > + The Event interface is the base contract for all events in phpnomad/event. It requires a single + static method getId() that returns a unique string identifier for the event type. This ID is used + by EventStrategy to match events to their listeners. Events should be immutable value objects + that carry data about something that happened. Common patterns include using class constants or + fully-qualified class names as IDs. +questions_answered: + - What is the Event interface? + - How do I create an event class? + - What should getId() return? + - Should events be mutable or immutable? + - How do I include data in an event? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference +llm_tags: + - event-interface + - event-creation +keywords: + - Event interface + - getId + - event class +related: + - introduction + - event-strategy + - can-handle +see_also: + - ../introduction + - ../patterns/best-practices +noindex: false +--- + +# Event Interface + +The `Event` interface is the foundation of PHPNomad's event system. Every event class must implement this interface to be broadcastable through `EventStrategy`. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +interface Event +{ + /** + * Returns the unique identifier for this event type. + * + * @return string The event identifier + */ + public static function getId(): string; +} +``` + +--- + +## Method Reference + +### `getId(): string` + +Returns a unique string identifier for the event type. + +| Aspect | Details | +|--------|---------| +| Visibility | `public static` | +| Parameters | None | +| Returns | `string` — Unique event identifier | +| Called by | `EventStrategy` when matching events to listeners | + +**Important:** This is a static method. The ID identifies the *type* of event, not a specific instance. + +--- + +## Creating Event Classes + +### Basic Event + +The simplest event just implements the interface: + +```php +use PHPNomad\Events\Interfaces\Event; + +class UserLoggedInEvent implements Event +{ + public static function getId(): string + { + return 'user.logged_in'; + } +} +``` + +### Event with Data + +Events typically carry data about what happened: + +```php +use PHPNomad\Events\Interfaces\Event; + +class UserCreatedEvent implements Event +{ + public function __construct( + public readonly int $userId, + public readonly string $email, + public readonly \DateTimeImmutable $createdAt + ) {} + + public static function getId(): string + { + return 'user.created'; + } +} +``` + +### Using Class Name as ID + +A common pattern uses the fully-qualified class name: + +```php +class OrderPlacedEvent implements Event +{ + public static function getId(): string + { + return self::class; + } +} +``` + +This guarantees uniqueness but produces longer IDs like `App\Events\OrderPlacedEvent`. + +--- + +## Event ID Patterns + +### Dot-notation (Recommended) + +```php +public static function getId(): string +{ + return 'user.created'; +} +``` + +Benefits: +- Short, readable +- Natural grouping (`user.*`, `order.*`) +- Easy to type when attaching listeners + +### Class Name + +```php +public static function getId(): string +{ + return self::class; +} +``` + +Benefits: +- Guaranteed unique +- Refactoring-safe with IDE support + +### Constant + +```php +class UserCreatedEvent implements Event +{ + public const ID = 'user.created'; + + public static function getId(): string + { + return self::ID; + } +} + +// Listeners can reference the constant +$events->attach(UserCreatedEvent::ID, $handler); +``` + +--- + +## Best Practices + +### 1. Make Events Immutable + +Use `readonly` properties (PHP 8.1+) or private properties with getters: + +```php +// PHP 8.1+ with readonly +class PaymentReceivedEvent implements Event +{ + public function __construct( + public readonly string $transactionId, + public readonly float $amount, + public readonly \DateTimeImmutable $receivedAt + ) {} + + public static function getId(): string + { + return 'payment.received'; + } +} +``` + +### 2. Use Past Tense + +Events represent things that already happened: + +```php +// Good: past tense +class OrderShippedEvent {} +class UserDeletedEvent {} +class PaymentFailedEvent {} + +// Bad: present/future tense (sounds like commands) +class ShipOrderEvent {} +class DeleteUserEvent {} +``` + +### 3. Include Sufficient Context + +Events should carry all data handlers need: + +```php +// Good: handlers have everything they need +class OrderCompletedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly int $customerId, + public readonly float $total, + public readonly array $itemIds, + public readonly string $shippingMethod + ) {} +} + +// Bad: handlers must query for additional data +class OrderCompletedEvent implements Event +{ + public function __construct( + public readonly int $orderId + ) {} +} +``` + +### 4. Use Value Objects for Complex Data + +```php +class ShipmentDispatchedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly Address $shippingAddress, // Value object + public readonly Carrier $carrier, // Value object + public readonly \DateTimeImmutable $dispatchedAt + ) {} +} +``` + +--- + +## Usage Examples + +### Broadcasting an Event + +```php +$event = new UserCreatedEvent( + userId: 123, + email: 'user@example.com', + createdAt: new \DateTimeImmutable() +); + +$eventStrategy->broadcast($event); +``` + +### Accessing Event Data in Handlers + +```php +class SendWelcomeEmailHandler implements CanHandle +{ + public function handle(Event $event): void + { + /** @var UserCreatedEvent $event */ + $this->emailService->send( + to: $event->email, + subject: 'Welcome!', + template: 'welcome', + data: ['userId' => $event->userId] + ); + } +} +``` + +--- + +## Common Mistakes + +### Mutable Events + +```php +// Bad: mutable state +class UserUpdatedEvent implements Event +{ + public string $email; // Can be modified by handlers! +} + +// Good: immutable +class UserUpdatedEvent implements Event +{ + public function __construct( + public readonly string $email + ) {} +} +``` + +### Missing Data + +```php +// Bad: insufficient context +class InvoiceSentEvent implements Event +{ + public function __construct(public readonly int $invoiceId) {} +} + +// Handlers need customer email but must query for it +class NotifyCustomerHandler implements CanHandle +{ + public function handle(Event $event): void + { + $invoice = $this->invoices->find($event->invoiceId); + $customer = $this->customers->find($invoice->customerId); + // Now we can finally send the email + } +} +``` + +--- + +## Related Interfaces + +- **[EventStrategy](event-strategy)** — Broadcasts events to listeners +- **[CanHandle](can-handle)** — Handles events when they're broadcast +- **[HasListeners](has-listeners)** — Declares which events a module listens to diff --git a/public/docs/packages/event/interfaces/has-event-bindings.md b/public/docs/packages/event/interfaces/has-event-bindings.md new file mode 100644 index 0000000..16f131a --- /dev/null +++ b/public/docs/packages/event/interfaces/has-event-bindings.md @@ -0,0 +1,422 @@ +--- +id: event-interface-has-event-bindings +slug: docs/packages/event/interfaces/has-event-bindings +title: HasEventBindings Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The HasEventBindings interface provides flexible event binding configuration for platform integration. +llm_summary: > + HasEventBindings provides flexible event binding configuration in phpnomad/event. Unlike HasListeners + which maps events to handlers, HasEventBindings returns an array of binding configurations that can + include additional metadata like platform actions and transformers. Primary use case is bridging + platform-specific events (like WordPress hooks) to application events. Each binding specifies an + event class, an external action to listen for, and a transformer function that converts platform + data into the application event. +questions_answered: + - What is HasEventBindings? + - How is HasEventBindings different from HasListeners? + - How do I bind platform events to my application? + - What is an event transformer? + - When should I use HasEventBindings? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference + - platform-integration +llm_tags: + - has-event-bindings + - platform-bridge + - event-transformer +keywords: + - HasEventBindings + - getEventBindings + - event transformer + - platform integration +related: + - introduction + - has-listeners + - action-binding-strategy +see_also: + - ../introduction + - ../../../core-concepts/bootstrapping/initializers/event-binding +noindex: false +--- + +# HasEventBindings Interface + +The `HasEventBindings` interface provides flexible event binding configuration, primarily used for bridging platform-specific events to your application's event system. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +interface HasEventBindings +{ + /** + * @return array + */ + public function getEventBindings(): array; +} +``` + +--- + +## Method Reference + +### `getEventBindings(): array` + +Returns an array of event binding configurations. + +| Aspect | Details | +|--------|---------| +| Returns | `array` — Binding configurations | + +**Return format:** +```php +[ + EventClass::class => [ + [ + 'action' => 'platform_action_name', + 'transformer' => callable, + ], + ], +] +``` + +--- + +## HasEventBindings vs HasListeners + +| Interface | Purpose | Use When | +|-----------|---------|----------| +| `HasListeners` | Map events to handlers | Responding to internal events | +| `HasEventBindings` | Bridge external events | Connecting platform events to your app | + +``` +HasListeners: +Internal Event → Your Handler + +HasEventBindings: +Platform Action → Transformer → Your Event → Your Handlers +``` + +--- + +## Basic Usage + +### Simple Binding + +```php +use PHPNomad\Events\Interfaces\HasEventBindings; + +class WordPressIntegration implements HasEventBindings +{ + public function getEventBindings(): array + { + return [ + UserCreatedEvent::class => [ + [ + 'action' => 'user_register', + 'transformer' => function($userId) { + $user = get_userdata($userId); + return new UserCreatedEvent( + userId: $userId, + email: $user->user_email, + createdAt: new \DateTimeImmutable() + ); + }, + ], + ], + ]; + } +} +``` + +### Multiple Actions for One Event + +Different platform actions can trigger the same application event: + +```php +public function getEventBindings(): array +{ + return [ + OrderCreatedEvent::class => [ + // WooCommerce new order + [ + 'action' => 'woocommerce_new_order', + 'transformer' => [$this, 'fromWooCommerce'], + ], + // Easy Digital Downloads purchase + [ + 'action' => 'edd_complete_purchase', + 'transformer' => [$this, 'fromEdd'], + ], + ], + ]; +} + +private function fromWooCommerce($orderId): OrderCreatedEvent +{ + $order = wc_get_order($orderId); + return new OrderCreatedEvent( + orderId: $orderId, + customerId: $order->get_customer_id(), + total: $order->get_total() + ); +} + +private function fromEdd($paymentId): OrderCreatedEvent +{ + $payment = new EDD_Payment($paymentId); + return new OrderCreatedEvent( + orderId: $paymentId, + customerId: $payment->customer_id, + total: $payment->total + ); +} +``` + +--- + +## Transformers + +Transformers convert platform-specific data into your application events. + +### Closure Transformer + +```php +'transformer' => function($postId, $post) { + return new PostPublishedEvent( + postId: $postId, + title: $post->post_title, + authorId: $post->post_author + ); +} +``` + +### Method Transformer + +```php +'transformer' => [$this, 'transformPost'] + +// ... + +private function transformPost($postId, $post): PostPublishedEvent +{ + return new PostPublishedEvent( + postId: $postId, + title: $post->post_title, + authorId: $post->post_author + ); +} +``` + +### Service Transformer + +```php +'transformer' => [$this->postTransformer, 'toEvent'] +``` + +### Conditional Transformation + +Return `null` to skip event creation: + +```php +'transformer' => function($postId, $post, $update) { + // Only trigger for new posts, not updates + if ($update) { + return null; + } + + // Only trigger for published posts + if ($post->post_status !== 'publish') { + return null; + } + + return new PostPublishedEvent($postId); +} +``` + +--- + +## Real-World Example + +### WordPress + WooCommerce Integration + +```php +class WooCommerceEventBindings implements HasEventBindings +{ + public function __construct( + private OrderTransformer $orderTransformer, + private CustomerTransformer $customerTransformer + ) {} + + public function getEventBindings(): array + { + return [ + // Order lifecycle events + OrderPlacedEvent::class => [ + [ + 'action' => 'woocommerce_checkout_order_processed', + 'transformer' => [$this->orderTransformer, 'toOrderPlaced'], + ], + ], + + OrderCompletedEvent::class => [ + [ + 'action' => 'woocommerce_order_status_completed', + 'transformer' => [$this->orderTransformer, 'toOrderCompleted'], + ], + ], + + OrderCancelledEvent::class => [ + [ + 'action' => 'woocommerce_order_status_cancelled', + 'transformer' => [$this->orderTransformer, 'toOrderCancelled'], + ], + ], + + // Customer events + CustomerCreatedEvent::class => [ + [ + 'action' => 'woocommerce_created_customer', + 'transformer' => [$this->customerTransformer, 'toCustomerCreated'], + ], + ], + + // Product events + ProductPurchasedEvent::class => [ + [ + 'action' => 'woocommerce_order_item_added_to_order', + 'transformer' => function($itemId, $item, $orderId) { + return new ProductPurchasedEvent( + productId: $item->get_product_id(), + orderId: $orderId, + quantity: $item->get_quantity() + ); + }, + ], + ], + ]; + } +} +``` + +--- + +## Best Practices + +### 1. Use Service Classes for Complex Transformations + +```php +// Good: dedicated transformer service +class OrderTransformer +{ + public function toOrderPlaced($orderId): OrderPlacedEvent + { + $order = wc_get_order($orderId); + return new OrderPlacedEvent( + orderId: $orderId, + customerId: $order->get_customer_id(), + total: (float) $order->get_total(), + items: $this->extractItems($order), + placedAt: new \DateTimeImmutable($order->get_date_created()) + ); + } + + private function extractItems($order): array + { + // Complex item extraction logic + } +} +``` + +### 2. Handle Missing Data Gracefully + +```php +'transformer' => function($postId) { + $post = get_post($postId); + + if (!$post) { + return null; // Skip if post doesn't exist + } + + return new PostPublishedEvent($postId); +} +``` + +### 3. Document Platform Actions + +```php +public function getEventBindings(): array +{ + return [ + ReportCreatedEvent::class => [ + [ + // WordPress 'save_post' action + // @param int $post_id + // @param WP_Post $post + // @param bool $update - true if updating existing post + 'action' => 'save_post', + 'transformer' => function($postId, $post, $update) { + if ($update || $post->post_type !== 'report') { + return null; + } + return new ReportCreatedEvent($postId); + }, + ], + ], + ]; +} +``` + +--- + +## Testing + +```php +class WooCommerceEventBindingsTest extends TestCase +{ + public function test_binds_order_completed_event(): void + { + $bindings = new WooCommerceEventBindings( + new OrderTransformer(), + new CustomerTransformer() + ); + + $eventBindings = $bindings->getEventBindings(); + + $this->assertArrayHasKey(OrderCompletedEvent::class, $eventBindings); + $this->assertEquals( + 'woocommerce_order_status_completed', + $eventBindings[OrderCompletedEvent::class][0]['action'] + ); + } +} +``` + +--- + +## Related Interfaces + +- **[HasListeners](has-listeners)** — For internal event handling +- **[ActionBindingStrategy](action-binding-strategy)** — Executes the bindings +- **[Event](event)** — Events created by transformers + +--- + +## Further Reading + +- **[Event Bindings Guide](/core-concepts/bootstrapping/initializers/event-binding)** — Tutorial-style guide with WordPress examples +- **[Best Practices](../patterns/best-practices)** — Transformer patterns and testing strategies diff --git a/public/docs/packages/event/interfaces/has-listeners.md b/public/docs/packages/event/interfaces/has-listeners.md new file mode 100644 index 0000000..62e41fa --- /dev/null +++ b/public/docs/packages/event/interfaces/has-listeners.md @@ -0,0 +1,373 @@ +--- +id: event-interface-has-listeners +slug: docs/packages/event/interfaces/has-listeners +title: HasListeners Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The HasListeners interface declares which events a module listens to and their handlers. +llm_summary: > + HasListeners enables declarative event subscription in phpnomad/event. Classes implement + getListeners() to return an array mapping event class names to handler class names. Supports + single handlers or arrays of handlers per event. The loader/bootstrapper reads these mappings + and registers them with EventStrategy. Typically used in initializer classes to declare a + module's event subscriptions. Handlers are resolved through the DI container when events fire. +questions_answered: + - What is HasListeners? + - How do I declare event listeners? + - Can I have multiple handlers for one event? + - How does HasListeners work with the DI container? + - When should I use HasListeners vs attach()? +audience: + - developers + - backend engineers +tags: + - events + - interface + - reference + - configuration +llm_tags: + - has-listeners + - event-subscription + - declarative-config +keywords: + - HasListeners + - getListeners + - event subscription +related: + - introduction + - can-handle + - has-event-bindings +see_also: + - ../introduction + - ../../../core-concepts/bootstrapping/initializers/event-listeners +noindex: false +--- + +# HasListeners Interface + +The `HasListeners` interface provides declarative event subscription. Instead of manually calling `attach()`, you declare which events your module listens to and which handlers respond. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Events\Interfaces; + +interface HasListeners +{ + /** + * Gets the listeners and their handlers. + * + * @return array, class-string[]|class-string> + */ + public function getListeners(): array; +} +``` + +--- + +## Method Reference + +### `getListeners(): array` + +Returns a mapping of event classes to handler classes. + +| Aspect | Details | +|--------|---------| +| Returns | `array` — Event class names mapped to handler class names | + +**Return format:** +```php +[ + EventClass::class => HandlerClass::class, + // or + EventClass::class => [HandlerClass1::class, HandlerClass2::class], +] +``` + +--- + +## Basic Usage + +### Single Handler per Event + +```php +use PHPNomad\Events\Interfaces\HasListeners; + +class UserModule implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreatedEvent::class => SendWelcomeEmailHandler::class, + UserDeletedEvent::class => CleanupUserDataHandler::class, + ]; + } +} +``` + +### Multiple Handlers per Event + +```php +class OrderModule implements HasListeners +{ + public function getListeners(): array + { + return [ + OrderPlacedEvent::class => [ + SendOrderConfirmationHandler::class, + UpdateInventoryHandler::class, + NotifyWarehouseHandler::class, + ], + ]; + } +} +``` + +### Mixed Single and Multiple + +```php +class NotificationModule implements HasListeners +{ + public function getListeners(): array + { + return [ + // Single handler + UserLoggedInEvent::class => UpdateLastLoginHandler::class, + + // Multiple handlers + PaymentReceivedEvent::class => [ + SendReceiptHandler::class, + UpdateAccountBalanceHandler::class, + NotifyAccountingHandler::class, + ], + ]; + } +} +``` + +--- + +## How It Works + +The bootstrapper/loader reads `HasListeners` implementations and registers handlers: + +``` +┌─────────────────────┐ +│ Your Module │ +│ implements │ +│ HasListeners │ +└──────────┬──────────┘ + │ getListeners() + ▼ +┌─────────────────────┐ +│ Bootstrapper/ │ +│ Loader │ +└──────────┬──────────┘ + │ for each event → handler + ▼ +┌─────────────────────┐ +│ EventStrategy │ +│ attach(event, │ +│ container->get( │ +│ handler)) │ +└─────────────────────┘ +``` + +When an event fires: +1. `EventStrategy` calls the registered listener +2. The listener resolves the handler through the DI container +3. The handler's `handle()` method is called + +--- + +## Where to Use HasListeners + +### In Initializers + +The most common location is in initializer classes: + +```php +class ApplicationInitializer implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreatedEvent::class => [ + CreateUserProfileHandler::class, + SendWelcomeEmailHandler::class, + ], + ]; + } +} +``` + +### In Service Providers + +```php +class PaymentServiceProvider implements HasListeners +{ + public function getListeners(): array + { + return [ + PaymentReceivedEvent::class => ProcessPaymentHandler::class, + PaymentFailedEvent::class => HandlePaymentFailureHandler::class, + RefundRequestedEvent::class => ProcessRefundHandler::class, + ]; + } +} +``` + +### In Module Classes + +```php +class BlogModule implements HasListeners +{ + public function getListeners(): array + { + return [ + PostPublishedEvent::class => [ + NotifySubscribersHandler::class, + UpdateSearchIndexHandler::class, + InvalidateCacheHandler::class, + ], + ]; + } +} +``` + +--- + +## HasListeners vs Direct attach() + +| Approach | When to Use | +|----------|-------------| +| `HasListeners` | Standard case—declaring module's event subscriptions | +| Direct `attach()` | Dynamic listeners, conditional registration, closures | + +### HasListeners (Declarative) + +```php +// Clean, discoverable, testable +class MyModule implements HasListeners +{ + public function getListeners(): array + { + return [ + SomeEvent::class => SomeHandler::class, + ]; + } +} +``` + +### Direct attach() (Imperative) + +```php +// For dynamic or conditional listeners +$events->attach('some.event', function($event) use ($config) { + if ($config->isFeatureEnabled('notifications')) { + // handle + } +}); +``` + +--- + +## Best Practices + +### 1. Group Related Listeners + +Organize listeners by domain: + +```php +// Good: cohesive module +class InventoryModule implements HasListeners +{ + public function getListeners(): array + { + return [ + OrderPlacedEvent::class => ReserveInventoryHandler::class, + OrderCancelledEvent::class => ReleaseInventoryHandler::class, + ShipmentDispatchedEvent::class => DeductInventoryHandler::class, + ]; + } +} +``` + +### 2. Use Descriptive Handler Names + +Handler names should indicate what they do: + +```php +// Good: clear purpose +SendOrderConfirmationEmailHandler::class +UpdateCustomerLoyaltyPointsHandler::class +SyncInventoryWithWarehouseHandler::class + +// Bad: vague names +OrderHandler::class +ProcessHandler::class +HandleEvent::class +``` + +### 3. Order Handlers by Importance + +List critical handlers first: + +```php +public function getListeners(): array +{ + return [ + PaymentReceivedEvent::class => [ + ValidatePaymentHandler::class, // Critical: must run first + UpdateOrderStatusHandler::class, // Important: business logic + SendReceiptEmailHandler::class, // Nice-to-have: notification + UpdateAnalyticsHandler::class, // Optional: analytics + ], + ]; +} +``` + +--- + +## Testing + +Test that your module declares the expected listeners: + +```php +class OrderModuleTest extends TestCase +{ + public function test_declares_order_listeners(): void + { + $module = new OrderModule(); + $listeners = $module->getListeners(); + + $this->assertArrayHasKey(OrderPlacedEvent::class, $listeners); + $this->assertContains( + SendOrderConfirmationHandler::class, + $listeners[OrderPlacedEvent::class] + ); + } +} +``` + +--- + +## Related Interfaces + +- **[CanHandle](can-handle)** — The handlers that are registered +- **[Event](event)** — The events being listened for +- **[HasEventBindings](has-event-bindings)** — Alternative with more flexibility + +--- + +## Further Reading + +- **[Event Listeners Guide](/core-concepts/bootstrapping/initializers/event-listeners)** — Tutorial-style guide with examples +- **[Best Practices](../patterns/best-practices)** — Handler patterns and testing strategies diff --git a/public/docs/packages/event/interfaces/introduction.md b/public/docs/packages/event/interfaces/introduction.md new file mode 100644 index 0000000..e271806 --- /dev/null +++ b/public/docs/packages/event/interfaces/introduction.md @@ -0,0 +1,178 @@ +--- +id: event-interfaces-introduction +slug: docs/packages/event/interfaces/introduction +title: Event Interfaces Overview +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of all interfaces in the event package for implementing event-driven architecture. +llm_summary: > + The phpnomad/event package provides six interfaces for event-driven architecture: Event (identifiable + events), EventStrategy (broadcasting and listener management), CanHandle (event handlers), HasListeners + (declaring event subscriptions), HasEventBindings (flexible binding configuration), and ActionBindingStrategy + (bridging external systems to internal events). These interfaces enable decoupled, testable architectures + where components communicate through events rather than direct calls. +questions_answered: + - What interfaces are in the event package? + - How do the event interfaces relate to each other? + - Which interface should I implement for my use case? +audience: + - developers + - backend engineers +tags: + - events + - interfaces + - reference +llm_tags: + - event-interfaces + - api-reference +keywords: + - event interfaces + - EventStrategy + - CanHandle + - HasListeners +related: + - ../introduction +see_also: + - event + - event-strategy + - can-handle + - has-listeners + - has-event-bindings + - action-binding-strategy +noindex: false +--- + +# Event Interfaces + +The `phpnomad/event` package provides six interfaces that work together to enable event-driven architecture. Each interface has a specific role in the event system. + +--- + +## Interface Overview + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Event System │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ broadcasts ┌───────────────┐ │ +│ │ Event │ ──────────────────▶│ EventStrategy │ │ +│ └─────────┘ └───────┬───────┘ │ +│ ▲ │ │ +│ │ implements │ calls │ +│ │ ▼ │ +│ ┌─────────────┐ ┌───────────────┐ │ +│ │ Your Event │ │ CanHandle │ │ +│ │ Classes │ │ (handlers) │ │ +│ └─────────────┘ └───────────────┘ │ +│ ▲ │ +│ │ registered by │ +│ ┌──────────────┴──────────────┐ │ +│ │ │ │ +│ ┌───────────────┐ ┌──────────────────┐│ +│ │ HasListeners │ │ HasEventBindings ││ +│ │ (declarative) │ │ (flexible) ││ +│ └───────────────┘ └──────────────────┘│ +│ │ +│ ┌───────────────────────┐ │ +│ │ ActionBindingStrategy │ ← Bridges external systems │ +│ └───────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Quick Reference + +| Interface | Purpose | Key Method | +|-----------|---------|------------| +| [Event](event) | Identify events | `getId(): string` | +| [EventStrategy](event-strategy) | Broadcast and manage listeners | `broadcast()`, `attach()`, `detach()` | +| [CanHandle](can-handle) | Handle events | `handle(Event): void` | +| [HasListeners](has-listeners) | Declare event subscriptions | `getListeners(): array` | +| [HasEventBindings](has-event-bindings) | Flexible binding configuration | `getEventBindings(): array` | +| [ActionBindingStrategy](action-binding-strategy) | Bridge external systems | `bindAction()` | + +--- + +## Choosing the Right Interface + +### You want to create an event class +Implement **[Event](event)**. Your class represents something that happened. + +```php +class UserCreatedEvent implements Event +{ + public static function getId(): string + { + return 'user.created'; + } +} +``` + +### You want to handle events +Implement **[CanHandle](can-handle)**. Your class responds when specific events occur. + +```php +class SendWelcomeEmailHandler implements CanHandle +{ + public function handle(Event $event): void + { + // React to the event + } +} +``` + +### You want to broadcast events +Inject **[EventStrategy](event-strategy)**. Use it to dispatch events to all listeners. + +```php +class UserService +{ + public function __construct(private EventStrategy $events) {} + + public function createUser(): void + { + // ... create user + $this->events->broadcast(new UserCreatedEvent($user)); + } +} +``` + +### You want to declare what events a module listens to +Implement **[HasListeners](has-listeners)**. Map event classes to handler classes. + +```php +class MyModule implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreatedEvent::class => SendWelcomeEmailHandler::class, + ]; + } +} +``` + +### You want flexible event binding configuration +Implement **[HasEventBindings](has-event-bindings)**. Provides more configuration options than HasListeners. + +### You want to bridge platform events to your application +Use **[ActionBindingStrategy](action-binding-strategy)**. Connects external systems (like WordPress hooks) to your internal events. + +--- + +## Interface Details + +- **[Event](event)** — Base interface all events must implement +- **[EventStrategy](event-strategy)** — Core dispatcher for broadcasting and listener management +- **[CanHandle](can-handle)** — Handler interface for responding to events +- **[HasListeners](has-listeners)** — Declarative event-to-handler mapping +- **[HasEventBindings](has-event-bindings)** — Flexible event binding configuration +- **[ActionBindingStrategy](action-binding-strategy)** — Bridge to external event systems diff --git a/public/docs/packages/event/introduction.md b/public/docs/packages/event/introduction.md new file mode 100644 index 0000000..f72cc67 --- /dev/null +++ b/public/docs/packages/event/introduction.md @@ -0,0 +1,263 @@ +--- +id: event-introduction +slug: docs/packages/event/introduction +title: Event Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The event package provides interfaces for implementing event-driven architecture with broadcasting, listening, and handler patterns. +llm_summary: > + phpnomad/event provides a set of interfaces for implementing event-driven architecture in PHP. + The package defines Event (identifiable events), EventStrategy (broadcast/attach/detach operations), + CanHandle (event handlers), HasListeners (objects that provide listener mappings), HasEventBindings + (objects that provide event bindings), and ActionBindingStrategy (binding actions to events). + Zero dependencies. Used by auth, rest, update, core, database, wordpress-integration and many other + packages. Implementations include symfony-event-dispatcher-integration for Symfony EventDispatcher. +questions_answered: + - What is the event package? + - How do I implement event-driven architecture in PHPNomad? + - How do I broadcast events? + - How do I listen for events? + - What is an EventStrategy? + - How do I create event handlers? + - What packages use the event system? +audience: + - developers + - backend engineers + - architects +tags: + - events + - event-driven + - design-pattern + - pub-sub +llm_tags: + - event-pattern + - publish-subscribe + - observer-pattern + - event-broadcasting +keywords: + - phpnomad event + - event driven php + - EventStrategy + - event broadcasting + - event listeners +related: + - ../di/introduction + - ../database/introduction + - ../database/caching-and-events + - ../../core-concepts/bootstrapping/initializers/event-listeners + - ../../core-concepts/bootstrapping/initializers/event-binding +see_also: + - interfaces/introduction + - patterns/best-practices + - ../symfony-event-dispatcher-integration/introduction +noindex: false +--- + +# Event + +`phpnomad/event` provides **interfaces for event-driven architecture** in PHP applications. Instead of tightly coupling components, the event system lets you: + +* **Decouple publishers from subscribers** — Components communicate through events, not direct calls +* **React to state changes** — Listen for events like "record created" or "user logged in" +* **Extend behavior** — Add functionality without modifying existing code +* **Chain actions** — One event can trigger multiple handlers in sequence + +--- + +## Key Concepts + +| Concept | Description | +|---------|-------------| +| [Event](interfaces/event) | An object representing something that happened | +| [EventStrategy](interfaces/event-strategy) | The dispatcher that broadcasts events and manages listeners | +| [CanHandle](interfaces/can-handle) | Handler classes that respond to specific events | +| [HasListeners](interfaces/has-listeners) | Declares which events a module listens to | +| [HasEventBindings](interfaces/has-event-bindings) | Flexible binding configuration for platform integration | +| [ActionBindingStrategy](interfaces/action-binding-strategy) | Bridges external systems to internal events | + +See [Interfaces Overview](interfaces/introduction) for detailed documentation of each interface. + +--- + +## The Event Lifecycle + +``` +Something happens in your application + │ + ▼ +┌───────────────────────────┐ +│ Create Event object │ +│ implements Event │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ EventStrategy │ +│ broadcast($event) │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ Handlers are called │ +│ in priority order │ +└───────────────────────────┘ + │ + +────┼────+────+ + │ │ │ │ + ▼ ▼ ▼ ▼ + Send Update Log Notify + Email Cache Event Slack +``` + +--- + +## Installation + +```bash +composer require phpnomad/event +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** None (zero dependencies) + +This package provides **interfaces only**. For a working implementation, install an integration: + +```bash +composer require phpnomad/symfony-event-dispatcher-integration +``` + +--- + +## Quick Example + +### Creating an Event + +```php +use PHPNomad\Events\Interfaces\Event; + +class UserCreatedEvent implements Event +{ + public function __construct( + public readonly int $userId, + public readonly string $email + ) {} + + public static function getId(): string + { + return 'user.created'; + } +} +``` + +### Creating a Handler + +```php +use PHPNomad\Events\Interfaces\CanHandle; +use PHPNomad\Events\Interfaces\Event; + +class SendWelcomeEmailHandler implements CanHandle +{ + public function __construct(private EmailService $email) {} + + public function handle(Event $event): void + { + $this->email->send($event->email, 'Welcome!'); + } +} +``` + +### Declaring Listeners + +```php +use PHPNomad\Events\Interfaces\HasListeners; + +class UserModule implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreatedEvent::class => SendWelcomeEmailHandler::class, + ]; + } +} +``` + +### Broadcasting Events + +```php +class UserService +{ + public function __construct(private EventStrategy $events) {} + + public function createUser(string $email): User + { + $user = new User($email); + // save user... + + $this->events->broadcast(new UserCreatedEvent( + userId: $user->getId(), + email: $user->getEmail() + )); + + return $user; + } +} +``` + +--- + +## When to Use Events + +| Scenario | Why Events Help | +|----------|-----------------| +| Multiple reactions | One action triggers email, logging, cache update | +| Decoupled modules | Modules communicate without direct dependencies | +| Extension points | Add behavior without modifying existing code | +| Audit trails | Log all significant actions centrally | + +See [Best Practices](patterns/best-practices) for detailed guidance on when to use events and when not to. + +--- + +## Packages That Use Events + +| Package | How It Uses Events | +|---------|-------------------| +| [database](../database/introduction) | Broadcasts RecordCreated, RecordUpdated, RecordDeleted | +| [auth](../auth/introduction) | Authentication lifecycle events | +| [rest](../rest/introduction) | Request/response events via [EventInterceptor](../rest/interceptors/included-interceptors/event-interceptor) | +| [update](../update/introduction) | Update lifecycle events | +| [wordpress-integration](../wordpress-integration/introduction) | Bridges WordPress hooks to application events | + +--- + +## Further Reading + +### Package Documentation + +* [Interfaces Overview](interfaces/introduction) — All six interfaces explained +* [Best Practices](patterns/best-practices) — Event design, handler patterns, testing strategies + +### Related Core Concepts + +* [Event Listeners](/core-concepts/bootstrapping/initializers/event-listeners) — Setting up listeners in initializers +* [Event Bindings](/core-concepts/bootstrapping/initializers/event-binding) — Bridging platform events to application events +* [Caching and Events](/packages/database/caching-and-events) — Database CRUD events + +### Implementations + +* [Symfony Event Dispatcher Integration](../symfony-event-dispatcher-integration/introduction) — Production-ready EventStrategy implementation + +--- + +## Next Steps + +1. **Learn the interfaces** → [Interfaces Overview](interfaces/introduction) +2. **See best practices** → [Best Practices](patterns/best-practices) +3. **Get an implementation** → [Symfony Integration](../symfony-event-dispatcher-integration/introduction) diff --git a/public/docs/packages/event/patterns/best-practices.md b/public/docs/packages/event/patterns/best-practices.md new file mode 100644 index 0000000..f8c5a13 --- /dev/null +++ b/public/docs/packages/event/patterns/best-practices.md @@ -0,0 +1,621 @@ +--- +id: event-patterns-best-practices +slug: docs/packages/event/patterns/best-practices +title: Event System Best Practices +doc_type: how-to +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Best practices, common patterns, and testing strategies for PHPNomad's event system. +llm_summary: > + Comprehensive guide to event-driven architecture best practices in PHPNomad. Covers event design + (immutability, past tense naming, sufficient context), handler patterns (single responsibility, + dependency injection, error handling), testing strategies (mocking EventStrategy, testing handlers + in isolation), and common anti-patterns to avoid. Includes real-world e-commerce example with + OrderPlacedEvent, PaymentReceivedEvent, and associated handlers demonstrating proper structure. +questions_answered: + - What are best practices for events? + - How should I design event classes? + - How do I test event handlers? + - What are common event anti-patterns? + - How do I test that events are broadcast? + - When should I use events vs direct calls? +audience: + - developers + - backend engineers +tags: + - events + - best-practices + - patterns + - testing +llm_tags: + - event-best-practices + - event-testing + - event-patterns +keywords: + - event best practices + - event testing + - event patterns + - event anti-patterns +related: + - ../introduction + - ../interfaces/introduction +see_also: + - ../interfaces/event + - ../interfaces/can-handle +noindex: false +--- + +# Event System Best Practices + +This guide covers best practices for designing events, writing handlers, and testing event-driven code in PHPNomad. + +--- + +## Event Design + +### 1. Use Past Tense Names + +Events represent things that **have happened**: + +```php +// Good: past tense +class UserCreatedEvent {} +class OrderPlacedEvent {} +class PaymentReceivedEvent {} +class ShipmentDispatchedEvent {} + +// Bad: present/imperative (sounds like commands) +class CreateUserEvent {} +class PlaceOrderEvent {} +class ReceivePaymentEvent {} +``` + +### 2. Make Events Immutable + +Events are historical records—they shouldn't change after creation: + +```php +// Good: immutable with readonly properties (PHP 8.1+) +class OrderPlacedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly int $customerId, + public readonly float $total, + public readonly \DateTimeImmutable $placedAt + ) {} +} + +// Bad: mutable properties +class OrderPlacedEvent implements Event +{ + public int $orderId; // Can be modified! + public float $total; // Handlers could change this! +} +``` + +### 3. Include Sufficient Context + +Events should carry all data handlers need—avoid forcing handlers to query for more: + +```php +// Good: complete context +class InvoiceSentEvent implements Event +{ + public function __construct( + public readonly int $invoiceId, + public readonly int $customerId, + public readonly string $customerEmail, + public readonly float $amount, + public readonly \DateTimeImmutable $sentAt + ) {} +} + +// Bad: insufficient context +class InvoiceSentEvent implements Event +{ + public function __construct( + public readonly int $invoiceId // Handlers must query for customer, amount, etc. + ) {} +} +``` + +### 4. Use Value Objects for Complex Data + +```php +class ShipmentDispatchedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly Address $destination, // Value object + public readonly Carrier $carrier, // Value object + public readonly TrackingInfo $tracking, // Value object + public readonly \DateTimeImmutable $dispatchedAt + ) {} +} +``` + +--- + +## Handler Design + +### 1. Single Responsibility + +Each handler should do **one thing**: + +```php +// Good: focused handlers +class SendWelcomeEmailHandler implements CanHandle { /* sends email */ } +class CreateUserProfileHandler implements CanHandle { /* creates profile */ } +class AddToMailingListHandler implements CanHandle { /* adds to list */ } +class LogUserCreationHandler implements CanHandle { /* logs event */ } + +// Bad: handler does too much +class HandleUserCreatedHandler implements CanHandle +{ + public function handle(Event $event): void + { + $this->sendWelcomeEmail($event); + $this->createProfile($event); + $this->addToMailingList($event); + $this->logCreation($event); + $this->notifyAdmin($event); + } +} +``` + +### 2. Inject Dependencies + +Let the container provide what handlers need: + +```php +// Good: dependencies injected +class SendWelcomeEmailHandler implements CanHandle +{ + public function __construct( + private EmailStrategy $email, + private TemplateRenderer $templates, + private LoggerStrategy $logger + ) {} + + public function handle(Event $event): void + { + // Use injected dependencies + } +} + +// Bad: creating dependencies +class SendWelcomeEmailHandler implements CanHandle +{ + public function handle(Event $event): void + { + $email = new SmtpEmailService(/* config */); // Hard to test! + $email->send(/* ... */); + } +} +``` + +### 3. Handle Errors Gracefully + +Don't let one handler break others: + +```php +class NotifySlackHandler implements CanHandle +{ + public function __construct( + private SlackClient $slack, + private LoggerStrategy $logger + ) {} + + public function handle(Event $event): void + { + try { + $this->slack->notify($this->formatMessage($event)); + } catch (SlackException $e) { + // Log but don't re-throw—let other handlers run + $this->logger->warning('Slack notification failed', [ + 'event' => $event->getId(), + 'error' => $e->getMessage(), + ]); + } + } +} +``` + +### 4. Keep Handlers Fast + +Queue long-running operations: + +```php +// Good: queue heavy work +class GenerateInvoicePdfHandler implements CanHandle +{ + public function __construct(private Queue $queue) {} + + public function handle(Event $event): void + { + $this->queue->push(new GenerateInvoicePdfJob( + $event->orderId + )); + } +} + +// Bad: blocks event dispatch +class GenerateInvoicePdfHandler implements CanHandle +{ + public function handle(Event $event): void + { + $pdf = $this->generatePdf($event->orderId); // Takes 30 seconds! + $this->saveToDisk($pdf); + $this->uploadToS3($pdf); + } +} +``` + +--- + +## When to Use Events + +Events are appropriate when: + +| Scenario | Why Events | +|----------|------------| +| Multiple reactions | One action triggers email, logging, analytics | +| Decoupled modules | Modules communicate without direct dependencies | +| Extension points | Allow adding behavior without modifying code | +| Audit/compliance | Log all significant actions | +| Eventual consistency | Components update asynchronously | + +### Good Use Cases + +```php +// User lifecycle +$events->broadcast(new UserRegisteredEvent($user)); +$events->broadcast(new UserVerifiedEvent($user)); +$events->broadcast(new UserDeletedEvent($userId)); + +// Business events +$events->broadcast(new OrderPlacedEvent($order)); +$events->broadcast(new PaymentReceivedEvent($payment)); +$events->broadcast(new ShipmentDispatchedEvent($shipment)); +``` + +--- + +## When NOT to Use Events + +### Need a Return Value + +```php +// Bad: events don't return values +$event = new ValidateOrderEvent($order); +$events->broadcast($event); +$isValid = $event->isValid; // Awkward! + +// Good: direct call +$isValid = $this->validator->validate($order); +``` + +### Simple 1:1 Relationships + +```php +// Overkill: event for simple call +$events->broadcast(new CalculateTaxEvent($price)); + +// Just call the service +$tax = $this->taxCalculator->calculate($price); +``` + +### Performance-Critical Code + +```php +// Bad: event overhead in tight loop +foreach ($items as $item) { + $events->broadcast(new ItemProcessedEvent($item)); +} + +// Better: batch event +$events->broadcast(new ItemsProcessedEvent($items)); + +// Or: no event if just internal processing +foreach ($items as $item) { + $this->process($item); +} +``` + +--- + +## Testing Strategies + +### Testing Event Broadcasting + +Verify that services broadcast the right events: + +```php +class OrderServiceTest extends TestCase +{ + public function test_broadcasts_order_placed_event(): void + { + $events = $this->createMock(EventStrategy::class); + + $events->expects($this->once()) + ->method('broadcast') + ->with($this->callback(function(Event $event) { + return $event instanceof OrderPlacedEvent + && $event->customerId === 123 + && $event->total === 99.99; + })); + + $service = new OrderService($events, $this->orders); + $service->placeOrder($this->cart, $this->customer); + } +} +``` + +### Testing Handlers in Isolation + +```php +class SendOrderConfirmationHandlerTest extends TestCase +{ + public function test_sends_confirmation_email(): void + { + $email = $this->createMock(EmailStrategy::class); + + $email->expects($this->once()) + ->method('send') + ->with( + 'customer@example.com', + 'Order Confirmation', + $this->stringContains('Order #456') + ); + + $handler = new SendOrderConfirmationHandler($email); + $handler->handle(new OrderPlacedEvent( + orderId: 456, + customerId: 123, + customerEmail: 'customer@example.com', + total: 99.99, + placedAt: new \DateTimeImmutable() + )); + } +} +``` + +### Capturing Events for Assertions + +```php +class SpyEventStrategy implements EventStrategy +{ + public array $events = []; + + public function broadcast(Event $event): void + { + $this->events[] = $event; + } + + public function attach(string $event, callable $action, ?int $priority = null): void {} + public function detach(string $event, callable $action, ?int $priority = null): void {} +} + +// In test +public function test_order_flow_broadcasts_expected_events(): void +{ + $events = new SpyEventStrategy(); + $service = new OrderService($events, $this->orders, $this->payments); + + $service->placeOrder($this->cart); + $service->processPayment($this->order, $this->paymentMethod); + + $this->assertCount(2, $events->events); + $this->assertInstanceOf(OrderPlacedEvent::class, $events->events[0]); + $this->assertInstanceOf(PaymentReceivedEvent::class, $events->events[1]); +} +``` + +### Testing HasListeners Declarations + +```php +class OrderModuleTest extends TestCase +{ + public function test_registers_expected_listeners(): void + { + $module = new OrderModule(); + $listeners = $module->getListeners(); + + $this->assertArrayHasKey(OrderPlacedEvent::class, $listeners); + $this->assertContains( + SendOrderConfirmationHandler::class, + (array) $listeners[OrderPlacedEvent::class] + ); + } +} +``` + +--- + +## Common Anti-Patterns + +### Mutable Events + +```php +// Anti-pattern: event modified by handlers +class UserUpdatedEvent implements Event +{ + public array $changes = []; +} + +// Handler 1 adds to changes +// Handler 2 reads changes but sees Handler 1's modifications +// Order-dependent, hard to debug +``` + +### Event Chains + +```php +// Anti-pattern: handler broadcasts another event +class UpdateInventoryHandler implements CanHandle +{ + public function handle(Event $event): void + { + $this->inventory->reduce($event->items); + $this->events->broadcast(new InventoryUpdatedEvent()); // Cascades! + } +} + +// Can lead to infinite loops or hard-to-trace flows +``` + +### Using Events for Control Flow + +```php +// Anti-pattern: using events to control execution +class ProcessOrderHandler implements CanHandle +{ + public function handle(Event $event): void + { + if (!$event->validated) { // Checking state set by another handler + return; + } + // process... + } +} +``` + +### Over-Eventing + +```php +// Anti-pattern: event for every tiny thing +$events->broadcast(new DatabaseQueryExecutedEvent()); +$events->broadcast(new CacheHitEvent()); +$events->broadcast(new LogMessageWrittenEvent()); + +// Creates noise, performance overhead +``` + +--- + +## Real-World Example + +### E-commerce Order System + +```php +// Events +class OrderPlacedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly int $customerId, + public readonly string $customerEmail, + public readonly float $total, + public readonly array $items, + public readonly \DateTimeImmutable $placedAt + ) {} + + public static function getId(): string { return 'order.placed'; } +} + +class PaymentReceivedEvent implements Event +{ + public function __construct( + public readonly int $orderId, + public readonly string $transactionId, + public readonly float $amount, + public readonly \DateTimeImmutable $receivedAt + ) {} + + public static function getId(): string { return 'payment.received'; } +} + +// Handlers +class SendOrderConfirmationHandler implements CanHandle +{ + public function __construct(private EmailStrategy $email) {} + + public function handle(Event $event): void + { + $this->email->send( + $event->customerEmail, + 'Order Confirmation', + $this->formatEmail($event) + ); + } +} + +class ReserveInventoryHandler implements CanHandle +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(Event $event): void + { + foreach ($event->items as $item) { + $this->inventory->reserve($item['sku'], $item['quantity']); + } + } +} + +class NotifyWarehouseHandler implements CanHandle +{ + public function __construct(private WarehouseClient $warehouse) {} + + public function handle(Event $event): void + { + $this->warehouse->queueForFulfillment($event->orderId); + } +} + +// Module registration +class OrderModule implements HasListeners +{ + public function getListeners(): array + { + return [ + OrderPlacedEvent::class => [ + SendOrderConfirmationHandler::class, + ReserveInventoryHandler::class, + ], + PaymentReceivedEvent::class => [ + NotifyWarehouseHandler::class, + UpdateCustomerLoyaltyHandler::class, + ], + ]; + } +} + +// Service usage +class OrderService +{ + public function __construct( + private EventStrategy $events, + private OrderRepository $orders + ) {} + + public function placeOrder(Cart $cart, Customer $customer): Order + { + $order = Order::fromCart($cart, $customer); + $this->orders->save($order); + + $this->events->broadcast(new OrderPlacedEvent( + orderId: $order->getId(), + customerId: $customer->getId(), + customerEmail: $customer->getEmail(), + total: $order->getTotal(), + items: $order->getItems(), + placedAt: new \DateTimeImmutable() + )); + + return $order; + } +} +``` + +--- + +## Related Documentation + +- [Logger Package](../../logger/introduction.md) - LoggerStrategy used in handlers for logging +- [Event Interfaces](../interfaces/introduction.md) - Core event interfaces +- [Event Listeners Guide](/core-concepts/bootstrapping/initializers/event-listeners.md) - Event listener registration diff --git a/public/docs/packages/logger/interfaces/introduction.md b/public/docs/packages/logger/interfaces/introduction.md new file mode 100644 index 0000000..5d4b1ea --- /dev/null +++ b/public/docs/packages/logger/interfaces/introduction.md @@ -0,0 +1,82 @@ +--- +id: logger-interfaces-introduction +slug: docs/packages/logger/interfaces/introduction +title: Logger Interfaces Overview +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of interfaces provided by the logger package. +llm_summary: > + The phpnomad/logger package provides one interface: LoggerStrategy, which defines the + contract for PSR-3 compatible logging throughout PHPNomad applications. This interface + declares methods for all 8 standard log levels plus exception logging. +questions_answered: + - What interfaces does the logger package provide? + - What is the LoggerStrategy interface? +audience: + - developers + - backend engineers +tags: + - logging + - interfaces + - reference +llm_tags: + - logger-strategy + - psr-3 +keywords: + - logger interfaces + - LoggerStrategy +related: + - ../introduction + - ./logger-strategy +see_also: + - ../traits/introduction +noindex: false +--- + +# Logger Interfaces + +The logger package provides one core interface that defines the logging contract for PHPNomad applications. + +--- + +## Available Interfaces + +| Interface | Purpose | +|-----------|---------| +| [LoggerStrategy](./logger-strategy.md) | PSR-3 compatible logging contract with 8 log levels | + +--- + +## Quick Reference + +### LoggerStrategy + +The primary interface for all logging operations: + +```php +use PHPNomad\Logger\Interfaces\LoggerStrategy; + +class MyService +{ + public function __construct(private LoggerStrategy $logger) {} + + public function doSomething(): void + { + $this->logger->info('Operation started'); + } +} +``` + +See [LoggerStrategy](./logger-strategy.md) for complete documentation. + +--- + +## See Also + +- [Logger Package Overview](../introduction.md) - High-level package documentation +- [Logger Traits](../traits/introduction.md) - Default implementations diff --git a/public/docs/packages/logger/interfaces/logger-strategy.md b/public/docs/packages/logger/interfaces/logger-strategy.md new file mode 100644 index 0000000..eab1904 --- /dev/null +++ b/public/docs/packages/logger/interfaces/logger-strategy.md @@ -0,0 +1,407 @@ +--- +id: logger-strategy-interface +slug: docs/packages/logger/interfaces/logger-strategy +title: LoggerStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The LoggerStrategy interface defines a PSR-3 compatible logging contract for PHPNomad applications. +llm_summary: > + LoggerStrategy is the core logging interface in PHPNomad, providing PSR-3 compatible + logging methods for all 8 standard log levels (emergency, alert, critical, error, + warning, notice, info, debug) plus exception logging. Classes implementing this interface + can be swapped without changing application code, enabling flexible logging destinations + (files, databases, external services, null loggers for testing). Each method accepts + a message string and optional context array for structured logging. +questions_answered: + - What is the LoggerStrategy interface? + - What methods does LoggerStrategy define? + - How do I implement LoggerStrategy? + - What parameters does each logging method accept? + - How does logException work? +audience: + - developers + - backend engineers +tags: + - logging + - interface + - psr-3 + - strategy-pattern +llm_tags: + - logger-strategy + - log-levels + - exception-logging +keywords: + - LoggerStrategy + - logging interface + - PSR-3 + - log levels +related: + - ../introduction + - ../traits/can-log-exception +see_also: + - ../../database/introduction + - ../../rest/interceptors/introduction +noindex: false +--- + +# LoggerStrategy Interface + +**Namespace:** `PHPNomad\Logger\Interfaces` + +`LoggerStrategy` defines the logging contract for PHPNomad applications. It follows PSR-3 conventions with methods for all 8 standard log levels plus exception logging. + +--- + +## Interface Definition + +```php +namespace PHPNomad\Logger\Interfaces; + +use Exception; + +interface LoggerStrategy +{ + public function emergency(string $message, array $context = []): void; + public function alert(string $message, array $context = []): void; + public function critical(string $message, array $context = []): void; + public function error(string $message, array $context = []): void; + public function warning(string $message, array $context = []): void; + public function notice(string $message, array $context = []): void; + public function info(string $message, array $context = []): void; + public function debug(string $message, array $context = []): void; + public function logException( + Exception $e, + string $message = '', + array $context = [], + string $level = null + ): mixed; +} +``` + +--- + +## Methods + +### Log Level Methods + +All log level methods share the same signature: + +```php +public function {level}(string $message, array $context = []): void +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$message` | `string` | The log message | +| `$context` | `array` | Optional structured data to include with the message | + +#### emergency() + +Log when the system is completely unusable. + +```php +$logger->emergency('Database corruption detected', [ + 'table' => 'users', + 'corruption_type' => 'index_mismatch' +]); +``` + +**Use for:** Total system failure, data corruption, situations requiring immediate wake-up calls. + +#### alert() + +Log when immediate action is required. + +```php +$logger->alert('Primary database unreachable', [ + 'host' => $dbHost, + 'failover_active' => true +]); +``` + +**Use for:** Site down, database unavailable, disk full - situations requiring immediate human intervention. + +#### critical() + +Log critical conditions. + +```php +$logger->critical('Payment gateway connection failed', [ + 'gateway' => 'stripe', + 'error_code' => $errorCode +]); +``` + +**Use for:** Component unavailable, unexpected exceptions that affect core functionality. + +#### error() + +Log runtime errors that don't require immediate action. + +```php +$logger->error('Failed to send notification email', [ + 'user_id' => $userId, + 'email' => $email, + 'error' => $e->getMessage() +]); +``` + +**Use for:** Errors that should be investigated but don't halt the system. + +#### warning() + +Log exceptional occurrences that aren't errors. + +```php +$logger->warning('Deprecated API endpoint called', [ + 'endpoint' => '/api/v1/users', + 'replacement' => '/api/v2/users', + 'caller_ip' => $ip +]); +``` + +**Use for:** Deprecated API usage, poor practices, retry successes after failures. + +#### notice() + +Log normal but significant events. + +```php +$logger->notice('Application configuration reloaded', [ + 'config_file' => $configPath, + 'changes' => $changedKeys +]); +``` + +**Use for:** Service startup/shutdown, configuration changes, significant state changes. + +#### info() + +Log interesting events. + +```php +$logger->info('User logged in', [ + 'user_id' => $userId, + 'ip_address' => $ip, + 'user_agent' => $userAgent +]); +``` + +**Use for:** User actions, successful operations, routine events worth recording. + +#### debug() + +Log detailed debugging information. + +```php +$logger->debug('SQL query executed', [ + 'query' => $sql, + 'bindings' => $bindings, + 'duration_ms' => $duration +]); +``` + +**Use for:** Variable dumps, execution flow, detailed diagnostics. Typically disabled in production. + +--- + +### logException() + +Log an exception with configurable severity level. + +```php +public function logException( + Exception $e, + string $message = '', + array $context = [], + string $level = null +): mixed; +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `$e` | `Exception` | The exception to log | +| `$message` | `string` | Optional additional message | +| `$context` | `array` | Optional structured context data | +| `$level` | `string\|null` | Log level to use (defaults to `critical`) | + +**Example:** + +```php +try { + $this->processPayment($order); +} catch (PaymentException $e) { + $this->logger->logException( + $e, + 'Payment processing failed', + ['order_id' => $order->getId()], + LoggerLevel::Error + ); + throw $e; +} +``` + +The default implementation (via [CanLogException trait](../traits/can-log-exception.md)) combines your message with the exception message and calls the appropriate level method. + +--- + +## Log Level Severity + +From most to least severe: + +``` +EMERGENCY → System is unusable + ↓ + ALERT → Immediate action required + ↓ +CRITICAL → Critical conditions + ↓ + ERROR → Runtime errors + ↓ + WARNING → Exceptional but not errors + ↓ + NOTICE → Normal but significant + ↓ + INFO → Interesting events + ↓ + DEBUG → Detailed debug info +``` + +--- + +## Implementing LoggerStrategy + +### Basic Implementation + +```php +use PHPNomad\Logger\Interfaces\LoggerStrategy; +use PHPNomad\Logger\Traits\CanLogException; +use PHPNomad\Logger\Enums\LoggerLevel; + +class FileLogger implements LoggerStrategy +{ + use CanLogException; + + public function __construct(private string $logFile) {} + + public function emergency(string $message, array $context = []): void + { + $this->write(LoggerLevel::Emergency, $message, $context); + } + + public function alert(string $message, array $context = []): void + { + $this->write(LoggerLevel::Alert, $message, $context); + } + + public function critical(string $message, array $context = []): void + { + $this->write(LoggerLevel::Critical, $message, $context); + } + + public function error(string $message, array $context = []): void + { + $this->write(LoggerLevel::Error, $message, $context); + } + + public function warning(string $message, array $context = []): void + { + $this->write(LoggerLevel::Warning, $message, $context); + } + + public function notice(string $message, array $context = []): void + { + $this->write(LoggerLevel::Notice, $message, $context); + } + + public function info(string $message, array $context = []): void + { + $this->write(LoggerLevel::Info, $message, $context); + } + + public function debug(string $message, array $context = []): void + { + $this->write(LoggerLevel::Debug, $message, $context); + } + + private function write(string $level, string $message, array $context): void + { + $timestamp = date('Y-m-d H:i:s'); + $contextJson = empty($context) ? '' : ' ' . json_encode($context); + $line = "[{$timestamp}] [{$level}] {$message}{$contextJson}\n"; + + file_put_contents($this->logFile, $line, FILE_APPEND | LOCK_EX); + } +} +``` + +### Null Logger for Testing + +```php +class NullLogger implements LoggerStrategy +{ + use CanLogException; + + public function emergency(string $message, array $context = []): void {} + public function alert(string $message, array $context = []): void {} + public function critical(string $message, array $context = []): void {} + public function error(string $message, array $context = []): void {} + public function warning(string $message, array $context = []): void {} + public function notice(string $message, array $context = []): void {} + public function info(string $message, array $context = []): void {} + public function debug(string $message, array $context = []): void {} +} +``` + +--- + +## Usage Examples + +### Dependency Injection + +```php +class OrderService +{ + public function __construct(private LoggerStrategy $logger) {} + + public function processOrder(Order $order): void + { + $this->logger->info('Processing order', [ + 'order_id' => $order->getId(), + 'total' => $order->getTotal() + ]); + + // Process... + + $this->logger->info('Order completed', [ + 'order_id' => $order->getId() + ]); + } +} +``` + +### With Context Arrays + +```php +// Use structured context instead of string interpolation +$this->logger->info('User action', [ + 'user_id' => $userId, + 'action' => 'login', + 'ip_address' => $request->getClientIp(), + 'timestamp' => time() +]); +``` + +--- + +## See Also + +- [CanLogException Trait](../traits/can-log-exception.md) - Default `logException()` implementation +- [Logger Package Overview](../introduction.md) - High-level documentation +- [Database Package](../../database/introduction.md) - Uses LoggerStrategy for query logging +- [REST Interceptors](../../rest/interceptors/introduction.md) - Request/response logging diff --git a/public/docs/packages/logger/introduction.md b/public/docs/packages/logger/introduction.md new file mode 100644 index 0000000..c62ffd5 --- /dev/null +++ b/public/docs/packages/logger/introduction.md @@ -0,0 +1,278 @@ +--- +id: logger-introduction +slug: docs/packages/logger/introduction +title: Logger Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The logger package provides a PSR-3 compatible logging interface for consistent logging across PHPNomad applications. +llm_summary: > + phpnomad/logger provides the LoggerStrategy interface and CanLogException trait for consistent + logging throughout PHPNomad applications. The interface follows PSR-3 conventions with 8 log + levels (emergency, alert, critical, error, warning, notice, info, debug) plus exception logging. + The CanLogException trait provides a default implementation for logging exceptions with + configurable severity levels. This is an abstraction package - it defines the logging contract + but implementations come from integration packages or application code. Used extensively by + database, cache, REST, datastore packages and throughout the framework for error tracking, + debugging, and audit logging. +questions_answered: + - What is the logger package? + - How do I implement logging in PHPNomad? + - What log levels are available? + - How do I log exceptions? +audience: + - developers + - backend engineers + - devops +tags: + - logging + - psr-3 + - interface + - strategy-pattern +llm_tags: + - logger-strategy + - log-levels + - exception-logging + - can-log-exception +keywords: + - phpnomad logger + - logging interface + - psr-3 logging + - log levels php + - exception logging +related: + - ../database/introduction + - ../cache/introduction + - ../singleton/introduction +see_also: + - ../rest/interceptors/introduction + - ../datastore/introduction +noindex: false +--- + +# Logger + +`phpnomad/logger` provides a **PSR-3 compatible logging interface** for consistent logging across PHPNomad applications. It defines the logging contract—implementations are provided by integration packages or your application code. + +At its core: + +* **PSR-3 compatible** — Eight standard log levels matching the widely-adopted standard +* **Strategy pattern** — Swap logging implementations without changing application code +* **Exception logging** — Built-in support for logging exceptions with stack traces +* **Zero dependencies** — Pure abstraction with no external requirements + +--- + +## Key ideas at a glance + +| Component | Purpose | +|-----------|---------| +| [LoggerStrategy](./interfaces/logger-strategy.md) | Interface defining all logging methods | +| [CanLogException](./traits/can-log-exception.md) | Trait providing default exception logging | +| LoggerLevel | Constants for the 8 standard log levels | + +--- + +## Why this package exists + +Applications need consistent logging, but logging destinations vary: + +| Environment | Typical destination | +|-------------|-------------------| +| Development | Console/stdout | +| Production | File, database, or log service | +| WordPress | `error_log()` or WP debug.log | +| Testing | In-memory or null logger | + +Without a common interface, code becomes tied to specific loggers. With LoggerStrategy, you can swap implementations without changing application code. + +--- + +## Installation + +```bash +composer require phpnomad/logger +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** None (zero dependencies) + +--- + +## Log levels + +The eight standard log levels, from most to least severe: + +``` +SEVERITY HIERARCHY (highest to lowest) +══════════════════════════════════════ + + EMERGENCY System is unusable + │ (total failure, data corruption) + ▼ + ALERT Immediate action required + │ (site down, database unavailable) + ▼ + CRITICAL Critical conditions + │ (component unavailable, unexpected exception) + ▼ + ERROR Runtime errors + │ (errors that don't require immediate action) + ▼ + WARNING Exceptional occurrences that aren't errors + │ (deprecated APIs, poor API usage) + ▼ + NOTICE Normal but significant events + │ (startup, shutdown, config changes) + ▼ + INFO Interesting events + │ (user actions, SQL queries, API calls) + ▼ + DEBUG Detailed debug information + (variable dumps, execution flow) +``` + +--- + +## Basic usage + +Inject `LoggerStrategy` and call the appropriate level method: + +```php +use PHPNomad\Logger\Interfaces\LoggerStrategy; + +class OrderService +{ + public function __construct(private LoggerStrategy $logger) {} + + public function processOrder(Order $order): void + { + $this->logger->info('Processing order', [ + 'order_id' => $order->getId(), + 'customer' => $order->getCustomerId() + ]); + + try { + $this->chargePayment($order); + } catch (PaymentException $e) { + $this->logger->error('Payment failed', [ + 'order_id' => $order->getId(), + 'error' => $e->getMessage() + ]); + throw $e; + } + } +} +``` + +See [LoggerStrategy](./interfaces/logger-strategy.md) for complete API documentation. + +--- + +## When to use each level + +| Level | Use when... | Examples | +|-------|-------------|----------| +| Emergency | System is completely unusable | Data corruption, total failure | +| Alert | Immediate human action required | Database down, disk full | +| Critical | Critical component failed | Payment gateway unreachable | +| Error | Something failed but system continues | Failed API call, validation error | +| Warning | Something unexpected but not an error | Deprecated API used, slow query | +| Notice | Normal but notable events | Service started, config reloaded | +| Info | Routine operations worth recording | User login, order placed | +| Debug | Detailed debugging info | Variable dumps, SQL queries | + +--- + +## Best practices + +### Use structured context + +```php +// Bad - string interpolation +$this->logger->info("Order {$orderId} created by user {$userId}"); + +// Good - structured context +$this->logger->info('Order created', [ + 'order_id' => $orderId, + 'user_id' => $userId +]); +``` + +### Don't log sensitive data + +```php +// Bad - logging passwords +$this->logger->info('User login', ['password' => $password]); + +// Good - redact sensitive fields +$this->logger->info('User login', ['username' => $username]); +``` + +### Log at appropriate levels + +```php +$this->logger->error('User not found'); // Bad - not an error +$this->logger->info('User not found'); // Good - expected behavior +``` + +--- + +## Package contents + +### Interfaces + +| Interface | Description | +|-----------|-------------| +| [LoggerStrategy](./interfaces/logger-strategy.md) | PSR-3 compatible logging contract | + +See [Interfaces Overview](./interfaces/introduction.md) for details. + +### Traits + +| Trait | Description | +|-------|-------------| +| [CanLogException](./traits/can-log-exception.md) | Default `logException()` implementation | + +See [Traits Overview](./traits/introduction.md) for details. + +### Enums + +| Enum | Description | +|------|-------------| +| LoggerLevel | Constants for all 8 log levels | + +--- + +## Relationship to other packages + +### Packages that use logger + +| Package | How it uses LoggerStrategy | +|---------|---------------------------| +| [database](../database/introduction.md) | Logs query errors and operations | +| [cache](../cache/introduction.md) | Logs cache misses and errors | +| [rest](../rest/introduction.md) | Request/response logging via interceptors | +| [datastore](../datastore/introduction.md) | Operation logging in decorators | +| [facade](../facade/introduction.md) | LogService facade wraps LoggerStrategy | + +### Related packages + +| Package | Relationship | +|---------|-------------| +| [singleton](../singleton/introduction.md) | Logger instances often use singleton pattern | +| [di](../di/introduction.md) | Logger typically registered in container | + +--- + +## Next steps + +* **[LoggerStrategy Interface](./interfaces/logger-strategy.md)** — Complete interface documentation +* **[CanLogException Trait](./traits/can-log-exception.md)** — Default exception logging +* **[Database Package](../database/introduction.md)** — See query logging in action +* **[REST Interceptors](../rest/interceptors/introduction.md)** — Request/response logging diff --git a/public/docs/packages/logger/traits/can-log-exception.md b/public/docs/packages/logger/traits/can-log-exception.md new file mode 100644 index 0000000..8679be9 --- /dev/null +++ b/public/docs/packages/logger/traits/can-log-exception.md @@ -0,0 +1,238 @@ +--- +id: can-log-exception-trait +slug: docs/packages/logger/traits/can-log-exception +title: CanLogException Trait +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The CanLogException trait provides a default implementation for logging exceptions in LoggerStrategy implementations. +llm_summary: > + CanLogException is a trait that provides the default implementation of the logException() + method for classes implementing LoggerStrategy. It combines the provided message with + the exception message, defaults to critical severity level, and dynamically calls the + appropriate log level method. This reduces boilerplate when creating custom logger + implementations. +questions_answered: + - What is the CanLogException trait? + - How does CanLogException implement logException? + - What is the default log level for exceptions? + - How do I use CanLogException in my logger? +audience: + - developers + - backend engineers +tags: + - logging + - trait + - exception-handling +llm_tags: + - can-log-exception + - exception-logging + - default-implementation +keywords: + - CanLogException + - exception logging + - logger trait +related: + - ../introduction + - ../interfaces/logger-strategy +see_also: + - ../../database/introduction +noindex: false +--- + +# CanLogException Trait + +**Namespace:** `PHPNomad\Logger\Traits` + +`CanLogException` provides a default implementation of the `logException()` method for classes implementing `LoggerStrategy`. Use this trait to avoid writing boilerplate exception logging code. + +--- + +## Trait Definition + +```php +namespace PHPNomad\Logger\Traits; + +use Exception; +use PHPNomad\Logger\Enums\LoggerLevel; + +trait CanLogException +{ + public function logException( + Exception $e, + string $message = '', + array $context = [], + $level = null + ) { + if (!$level) { + $level = LoggerLevel::Critical; + } + + $this->$level( + implode(' - ', [$message, $e->getMessage()]), + $context + ); + } +} +``` + +--- + +## Behavior + +| Aspect | Behavior | +|--------|----------| +| Default level | `critical` if no level specified | +| Message format | Combines your message with exception message using ` - ` separator | +| Method dispatch | Dynamically calls the appropriate level method (`$this->$level(...)`) | + +### Output Format + +When you call: + +```php +$logger->logException( + $exception, + 'Payment failed', + ['order_id' => 123], + LoggerLevel::Error +); +``` + +The trait produces a log entry like: + +``` +[2026-01-25 10:30:45] [error] Payment failed - Card was declined {"order_id": 123} +``` + +--- + +## Usage + +### Basic Usage + +```php +use PHPNomad\Logger\Interfaces\LoggerStrategy; +use PHPNomad\Logger\Traits\CanLogException; + +class FileLogger implements LoggerStrategy +{ + use CanLogException; + + // Implement the 8 log level methods... + public function emergency(string $message, array $context = []): void + { + $this->write('emergency', $message, $context); + } + + // ... other level methods + + private function write(string $level, string $message, array $context): void + { + // Write to file + } +} +``` + +With the trait, `logException()` is automatically available: + +```php +$logger = new FileLogger('/var/log/app.log'); + +try { + riskyOperation(); +} catch (Exception $e) { + $logger->logException($e, 'Operation failed'); +} +``` + +### Specifying Log Level + +```php +use PHPNomad\Logger\Enums\LoggerLevel; + +// Default: critical +$logger->logException($e, 'Something went wrong'); + +// Explicit level +$logger->logException($e, 'User input error', [], LoggerLevel::Warning); +$logger->logException($e, 'Database error', [], LoggerLevel::Error); +$logger->logException($e, 'Fatal failure', [], LoggerLevel::Emergency); +``` + +### With Context + +```php +try { + $this->processOrder($order); +} catch (PaymentException $e) { + $this->logger->logException( + $e, + 'Payment processing failed', + [ + 'order_id' => $order->getId(), + 'customer_id' => $order->getCustomerId(), + 'amount' => $order->getTotal() + ], + LoggerLevel::Error + ); +} +``` + +--- + +## Overriding the Implementation + +If you need different behavior, you can override the method in your logger class: + +```php +class CustomLogger implements LoggerStrategy +{ + use CanLogException { + logException as protected defaultLogException; + } + + public function logException( + Exception $e, + string $message = '', + array $context = [], + $level = null + ) { + // Add stack trace to context + $context['stack_trace'] = $e->getTraceAsString(); + $context['exception_class'] = get_class($e); + $context['file'] = $e->getFile(); + $context['line'] = $e->getLine(); + + $this->defaultLogException($e, $message, $context, $level); + } +} +``` + +--- + +## Requirements + +The trait requires that your class implements all 8 log level methods from `LoggerStrategy`: + +- `emergency(string $message, array $context = []): void` +- `alert(string $message, array $context = []): void` +- `critical(string $message, array $context = []): void` +- `error(string $message, array $context = []): void` +- `warning(string $message, array $context = []): void` +- `notice(string $message, array $context = []): void` +- `info(string $message, array $context = []): void` +- `debug(string $message, array $context = []): void` + +The trait dynamically calls `$this->$level()`, so all level methods must exist. + +--- + +## See Also + +- [LoggerStrategy Interface](../interfaces/logger-strategy.md) - The interface this trait helps implement +- [Logger Package Overview](../introduction.md) - High-level documentation diff --git a/public/docs/packages/logger/traits/introduction.md b/public/docs/packages/logger/traits/introduction.md new file mode 100644 index 0000000..548f786 --- /dev/null +++ b/public/docs/packages/logger/traits/introduction.md @@ -0,0 +1,82 @@ +--- +id: logger-traits-introduction +slug: docs/packages/logger/traits/introduction +title: Logger Traits Overview +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of traits provided by the logger package. +llm_summary: > + The phpnomad/logger package provides one trait: CanLogException, which offers a default + implementation for the logException method defined in LoggerStrategy. This trait can be + used by any class implementing LoggerStrategy to get exception logging behavior without + writing it from scratch. +questions_answered: + - What traits does the logger package provide? + - What is the CanLogException trait? +audience: + - developers + - backend engineers +tags: + - logging + - traits + - reference +llm_tags: + - can-log-exception + - exception-logging +keywords: + - logger traits + - CanLogException +related: + - ../introduction + - ./can-log-exception +see_also: + - ../interfaces/introduction +noindex: false +--- + +# Logger Traits + +The logger package provides one trait that helps when implementing the LoggerStrategy interface. + +--- + +## Available Traits + +| Trait | Purpose | +|-------|---------| +| [CanLogException](./can-log-exception.md) | Default implementation for exception logging | + +--- + +## Quick Reference + +### CanLogException + +Provides a default `logException()` implementation: + +```php +use PHPNomad\Logger\Interfaces\LoggerStrategy; +use PHPNomad\Logger\Traits\CanLogException; + +class MyLogger implements LoggerStrategy +{ + use CanLogException; + + // Only need to implement the 8 level methods + // logException() is provided by the trait +} +``` + +See [CanLogException](./can-log-exception.md) for complete documentation. + +--- + +## See Also + +- [Logger Package Overview](../introduction.md) - High-level package documentation +- [Logger Interfaces](../interfaces/introduction.md) - Interface documentation diff --git a/public/docs/packages/mutator/interfaces/has-mutations.md b/public/docs/packages/mutator/interfaces/has-mutations.md new file mode 100644 index 0000000..d43701b --- /dev/null +++ b/public/docs/packages/mutator/interfaces/has-mutations.md @@ -0,0 +1,389 @@ +--- +id: mutator-interface-has-mutations +slug: docs/packages/mutator/interfaces/has-mutations +title: HasMutations Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The HasMutations interface allows objects to advertise their available transformations via getMutations(). +llm_summary: > + The HasMutations interface enables capability discovery by requiring objects to expose their available + mutations via getMutations(): array. The returned array maps mutation names to their implementations + (typically callables or handler classes). This makes transformation systems self-documenting and + enables dynamic UI generation, API documentation, and runtime introspection. +questions_answered: + - What is HasMutations? + - How do I expose available transformations? + - How does capability discovery work? + - When should I implement HasMutations? +audience: + - developers + - backend engineers +tags: + - interface + - mutator + - capability-discovery +llm_tags: + - has-mutations + - capability-discovery + - introspection +keywords: + - HasMutations interface + - capability discovery + - getMutations +related: + - ../introduction + - mutation-strategy +see_also: + - mutator + - mutator-handler +noindex: false +--- + +# HasMutations Interface + +The `HasMutations` interface enables **capability discovery** by allowing objects to advertise what transformations they support. This makes transformation systems self-documenting. + +## Interface definition + +```php +namespace PHPNomad\Mutator\Interfaces; + +interface HasMutations +{ + public function getMutations(): array; +} +``` + +## Methods + +### `getMutations(): array` + +Returns an array of available mutations. + +**Parameters:** None + +**Returns:** `array` — A map of mutation names to their implementations + +**Typical formats:** +- `['name' => callable]` — Name to closure/function +- `['name' => ClassName::class]` — Name to handler class +- `['name' => ['handler' => ..., 'description' => ...]]` — Rich metadata + +--- + +## Why use HasMutations? + +| Scenario | How HasMutations helps | +|----------|------------------------| +| Self-documenting APIs | Objects describe what they can do | +| Dynamic UIs | Generate forms/buttons from available mutations | +| Validation | Check if a mutation exists before calling | +| Documentation | Auto-generate docs from mutation lists | +| Plugin systems | Plugins advertise their capabilities | + +--- + +## Basic implementation + +```php +use PHPNomad\Mutator\Interfaces\HasMutations; + +class TextProcessor implements HasMutations +{ + public function getMutations(): array + { + return [ + 'uppercase' => fn($text) => strtoupper($text), + 'lowercase' => fn($text) => strtolower($text), + 'reverse' => fn($text) => strrev($text), + 'wordcount' => fn($text) => str_word_count($text), + ]; + } + + public function apply(string $mutation, string $text) + { + $mutations = $this->getMutations(); + + if (!isset($mutations[$mutation])) { + throw new InvalidArgumentException("Unknown mutation: $mutation"); + } + + return $mutations[$mutation]($text); + } +} + +// Usage +$processor = new TextProcessor(); + +// Discover available mutations +print_r(array_keys($processor->getMutations())); +// ['uppercase', 'lowercase', 'reverse', 'wordcount'] + +// Apply a mutation +echo $processor->apply('uppercase', 'hello'); // "HELLO" +``` + +--- + +## With metadata + +Return rich metadata for documentation or UI generation: + +```php +class FormValidator implements HasMutations +{ + public function getMutations(): array + { + return [ + 'required' => [ + 'handler' => fn($v) => !empty($v), + 'description' => 'Validates that the value is not empty', + 'errorMessage' => 'This field is required', + ], + 'email' => [ + 'handler' => fn($v) => filter_var($v, FILTER_VALIDATE_EMAIL) !== false, + 'description' => 'Validates email format', + 'errorMessage' => 'Please enter a valid email address', + ], + 'minLength' => [ + 'handler' => fn($v, $min) => strlen($v) >= $min, + 'description' => 'Validates minimum string length', + 'errorMessage' => 'Must be at least {min} characters', + 'params' => ['min' => 'integer'], + ], + ]; + } + + public function validate(string $mutation, $value, ...$params): bool + { + $mutations = $this->getMutations(); + + if (!isset($mutations[$mutation])) { + throw new InvalidArgumentException("Unknown validation: $mutation"); + } + + return $mutations[$mutation]['handler']($value, ...$params); + } + + public function getErrorMessage(string $mutation): string + { + return $this->getMutations()[$mutation]['errorMessage'] ?? 'Validation failed'; + } +} +``` + +--- + +## With handler classes + +Reference handler classes instead of closures: + +```php +class DataTransformer implements HasMutations +{ + public function getMutations(): array + { + return [ + 'slugify' => SlugifyHandler::class, + 'sanitize_html' => HtmlSanitizeHandler::class, + 'format_date' => DateFormatHandler::class, + ]; + } + + public function apply(string $mutation, ...$args) + { + $mutations = $this->getMutations(); + + if (!isset($mutations[$mutation])) { + throw new InvalidArgumentException("Unknown mutation: $mutation"); + } + + $handlerClass = $mutations[$mutation]; + $handler = new $handlerClass(); + + return $handler->mutate(...$args); + } +} +``` + +--- + +## Capability checking + +Check if a mutation exists before using it: + +```php +class SafeProcessor implements HasMutations +{ + // ... getMutations() implementation ... + + public function hasMutation(string $name): bool + { + return isset($this->getMutations()[$name]); + } + + public function apply(string $mutation, $value) + { + if (!$this->hasMutation($mutation)) { + return $value; // Pass through unchanged + } + + return $this->getMutations()[$mutation]($value); + } +} + +// Safe usage +if ($processor->hasMutation('uppercase')) { + $result = $processor->apply('uppercase', $input); +} +``` + +--- + +## Generating documentation + +Use `HasMutations` to auto-generate API documentation: + +```php +function generateDocs(HasMutations $object): string +{ + $docs = "Available mutations:\n\n"; + + foreach ($object->getMutations() as $name => $mutation) { + $docs .= "- **{$name}**"; + + if (is_array($mutation) && isset($mutation['description'])) { + $docs .= ": {$mutation['description']}"; + } + + $docs .= "\n"; + } + + return $docs; +} + +$processor = new FormValidator(); +echo generateDocs($processor); +// Available mutations: +// +// - **required**: Validates that the value is not empty +// - **email**: Validates email format +// - **minLength**: Validates minimum string length +``` + +--- + +## Best practices + +### Use descriptive mutation names + +```php +// Good: descriptive names +return [ + 'validate_email' => ..., + 'sanitize_html' => ..., + 'format_currency' => ..., +]; + +// Bad: vague names +return [ + 'validate' => ..., + 'clean' => ..., + 'format' => ..., +]; +``` + +### Keep mutations organized + +Group related mutations or use naming conventions: + +```php +return [ + // Validation mutations + 'validate.required' => ..., + 'validate.email' => ..., + 'validate.phone' => ..., + + // Transformation mutations + 'transform.uppercase' => ..., + 'transform.slug' => ..., +]; +``` + +### Make getMutations() deterministic + +The method should return the same mutations each call: + +```php +// Good: always returns same mutations +public function getMutations(): array +{ + return [ + 'uppercase' => fn($t) => strtoupper($t), + 'lowercase' => fn($t) => strtolower($t), + ]; +} + +// Bad: mutations change based on state +public function getMutations(): array +{ + if ($this->isAdmin) { + return ['delete' => ...]; // Confusing! + } + return []; +} +``` + +--- + +## Testing + +```php +class TextProcessorTest extends TestCase +{ + public function test_returns_expected_mutations(): void + { + $processor = new TextProcessor(); + + $mutations = $processor->getMutations(); + + $this->assertArrayHasKey('uppercase', $mutations); + $this->assertArrayHasKey('lowercase', $mutations); + $this->assertArrayHasKey('reverse', $mutations); + } + + public function test_mutations_are_callable(): void + { + $processor = new TextProcessor(); + + foreach ($processor->getMutations() as $name => $mutation) { + $this->assertTrue( + is_callable($mutation), + "Mutation '$name' should be callable" + ); + } + } + + public function test_applies_mutation(): void + { + $processor = new TextProcessor(); + + $result = $processor->apply('uppercase', 'hello'); + + $this->assertEquals('HELLO', $result); + } +} +``` + +--- + +## See also + +- [MutationStrategy](mutation-strategy) — Register mutations to named actions +- [MutatorHandler](mutator-handler) — Handler interface for mutations +- [Mutator](mutator) — Stateful transformation interface diff --git a/public/docs/packages/mutator/interfaces/introduction.md b/public/docs/packages/mutator/interfaces/introduction.md new file mode 100644 index 0000000..0ce7000 --- /dev/null +++ b/public/docs/packages/mutator/interfaces/introduction.md @@ -0,0 +1,127 @@ +--- +id: mutator-interfaces-introduction +slug: docs/packages/mutator/interfaces/introduction +title: Mutator Interfaces Overview +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of the five interfaces provided by the mutator package for implementing data transformation patterns. +llm_summary: > + The mutator package provides five interfaces that work together for structured data transformation: + Mutator (stateful transformation), MutatorHandler (functional transformation), MutationAdapter + (bidirectional data conversion), MutationStrategy (named action registration), and HasMutations + (capability discovery). Each interface serves a specific role in building composable, testable + transformation pipelines. +questions_answered: + - What interfaces does the mutator package provide? + - How do the mutator interfaces relate to each other? + - Which interface should I implement for my use case? +audience: + - developers + - backend engineers +tags: + - interfaces + - mutator + - transformation +llm_tags: + - interface-overview + - mutator-interfaces +keywords: + - mutator interfaces + - transformation interfaces + - MutationAdapter + - MutationStrategy +related: + - ../introduction +see_also: + - mutator + - mutator-handler + - mutation-adapter + - mutation-strategy + - has-mutations +noindex: false +--- + +# Mutator Interfaces + +The mutator package provides five interfaces that define contracts for structured data transformation. Each interface serves a specific role: + +| Interface | Purpose | When to Use | +|-----------|---------|-------------| +| [Mutator](mutator) | Stateful transformation | Complex multi-step transformations with internal state | +| [MutatorHandler](mutator-handler) | Functional transformation | Simple transformations without state management | +| [MutationAdapter](mutation-adapter) | Bidirectional conversion | Separating data marshaling from transformation logic | +| [MutationStrategy](mutation-strategy) | Named action dispatch | Dynamic pipelines with registered transformations | +| [HasMutations](has-mutations) | Capability discovery | Objects that advertise their transformation capabilities | + +--- + +## How the interfaces relate + +``` + ┌─────────────────────┐ + │ MutationStrategy │ + │ (registers named │ + │ transformations) │ + └─────────┬───────────┘ + │ attaches + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ HasMutations │ │ MutatorHandler │ +│ (advertises │ │ (functional │ +│ capabilities) │ │ transform) │ +└─────────────────┘ └─────────────────┘ + │ + │ or uses + ▼ + ┌─────────────────────┐ + │ MutationAdapter │ + │ (converts data │ + │ to/from Mutator) │ + └─────────┬───────────┘ + │ creates/reads + ▼ + ┌─────────────────────┐ + │ Mutator │ + │ (stateful │ + │ transformation) │ + └─────────────────────┘ +``` + +--- + +## Choosing the right interface + +**Start with [MutatorHandler](mutator-handler)** if: +- Your transformation is a simple input → output function +- No complex state management needed +- You want the simplest possible implementation + +**Use [Mutator](mutator) + [MutationAdapter](mutation-adapter)** if: +- Transformation has multiple steps or phases +- You need to separate conversion logic from transformation logic +- Testing isolation is important + +**Add [MutationStrategy](mutation-strategy)** if: +- You need to register transformations by name +- Building a plugin system or pipeline +- Runtime composition of transformations + +**Implement [HasMutations](has-mutations)** if: +- Objects should advertise their capabilities +- Building discoverable APIs +- Self-documenting transformation systems + +--- + +## Next steps + +- [Mutator](mutator) — Stateful transformation interface +- [MutatorHandler](mutator-handler) — Functional transformation interface +- [MutationAdapter](mutation-adapter) — Bidirectional conversion interface +- [MutationStrategy](mutation-strategy) — Named action registration +- [HasMutations](has-mutations) — Capability discovery interface diff --git a/public/docs/packages/mutator/interfaces/mutation-adapter.md b/public/docs/packages/mutator/interfaces/mutation-adapter.md new file mode 100644 index 0000000..b3470e2 --- /dev/null +++ b/public/docs/packages/mutator/interfaces/mutation-adapter.md @@ -0,0 +1,369 @@ +--- +id: mutator-interface-mutation-adapter +slug: docs/packages/mutator/interfaces/mutation-adapter +title: MutationAdapter Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The MutationAdapter interface provides bidirectional conversion between raw data and Mutator instances. +llm_summary: > + The MutationAdapter interface separates data marshaling from transformation logic. It defines two + methods: convertFromSource(...$args) creates a Mutator from input data, and convertToResult(Mutator) + extracts the output from a mutated Mutator. This enables clean separation of concerns where adapters + handle I/O format conversion and mutators contain pure transformation logic. Used with the + CanMutateFromAdapter trait for automatic workflow handling. +questions_answered: + - What is MutationAdapter? + - How do I convert data to and from Mutators? + - Why separate conversion from transformation? + - How does MutationAdapter work with CanMutateFromAdapter? +audience: + - developers + - backend engineers +tags: + - interface + - mutator + - adapter +llm_tags: + - mutation-adapter + - data-conversion + - adapter-pattern +keywords: + - MutationAdapter interface + - data conversion + - adapter pattern +related: + - ../introduction + - mutator +see_also: + - ../traits/can-mutate-from-adapter + - mutator-handler +noindex: false +--- + +# MutationAdapter Interface + +The `MutationAdapter` interface provides **bidirectional conversion** between raw data and `Mutator` instances. It separates data marshaling from transformation logic. + +## Interface definition + +```php +namespace PHPNomad\Mutator\Interfaces; + +interface MutationAdapter +{ + public function convertFromSource(...$args): Mutator; + public function convertToResult(Mutator $mutator); +} +``` + +## Methods + +### `convertFromSource(...$args): Mutator` + +Creates a Mutator instance from input data. + +**Parameters:** +- `...$args` — Variadic arguments representing the input data + +**Returns:** `Mutator` — A mutator instance ready to transform + +### `convertToResult(Mutator $mutator): mixed` + +Extracts the result from a mutated Mutator. + +**Parameters:** +- `$mutator` — The mutator after `mutate()` has been called + +**Returns:** `mixed` — The transformation result in the desired format + +--- + +## The adapter workflow + +``` +Input data + │ + ▼ +┌───────────────────────────────┐ +│ convertFromSource($args) │ +│ → creates Mutator instance │ +└───────────────────────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ Mutator::mutate() │ +│ → transforms internal state │ +└───────────────────────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ convertToResult($mutator) │ +│ → extracts output data │ +└───────────────────────────────┘ + │ + ▼ +Output data +``` + +--- + +## Why use adapters? + +Adapters provide separation of concerns: + +| Component | Responsibility | +|-----------|----------------| +| **Adapter** | Data format conversion (JSON, arrays, objects) | +| **Mutator** | Pure transformation logic | + +This separation enables: +- **Reusable mutators** — Same mutator with different input/output formats +- **Testable components** — Test conversion and logic independently +- **Flexible I/O** — Easily swap input/output formats + +--- + +## Basic implementation + +```php +use PHPNomad\Mutator\Interfaces\Mutator; +use PHPNomad\Mutator\Interfaces\MutationAdapter; + +// The mutator contains transformation logic +class SlugifyMutator implements Mutator +{ + private string $input; + private string $result = ''; + + public function __construct(string $input) + { + $this->input = $input; + } + + public function mutate(): void + { + $this->result = strtolower( + preg_replace('/[^a-zA-Z0-9]+/', '-', $this->input) + ); + } + + public function getResult(): string + { + return $this->result; + } +} + +// The adapter handles data conversion +class SlugAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + return new SlugifyMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + /** @var SlugifyMutator $mutator */ + return $mutator->getResult(); + } +} +``` + +--- + +## With validation results + +Adapters can shape the output format: + +```php +class ContactFormAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + // Expects array input + return new ContactFormMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + /** @var ContactFormMutator $mutator */ + if ($mutator->isValid()) { + return [ + 'success' => true, + 'data' => $mutator->getData(), + ]; + } + + return [ + 'success' => false, + 'errors' => $mutator->getErrors(), + ]; + } +} +``` + +--- + +## Different output formats + +The same mutator can have multiple adapters for different output needs: + +```php +// JSON API response adapter +class JsonSlugAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + $data = json_decode($args[0], true); + return new SlugifyMutator($data['text']); + } + + public function convertToResult(Mutator $mutator) + { + return json_encode(['slug' => $mutator->getResult()]); + } +} + +// Simple string adapter +class StringSlugAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + return new SlugifyMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + return $mutator->getResult(); + } +} +``` + +--- + +## Using with CanMutateFromAdapter + +The [CanMutateFromAdapter](../traits/can-mutate-from-adapter) trait automates the workflow: + +```php +use PHPNomad\Mutator\Traits\CanMutateFromAdapter; + +class SlugService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct() + { + $this->mutationAdapter = new SlugAdapter(); + } +} + +// The trait provides mutate() that handles the full workflow +$service = new SlugService(); +echo $service->mutate('Hello World!'); // "hello-world-" +``` + +--- + +## Best practices + +### Keep adapters focused on conversion + +Adapters should only convert data, not contain business logic: + +```php +// Good: adapter just converts +class UserAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + return new ValidateUserMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + return $mutator->getValidatedData(); + } +} + +// Bad: adapter contains validation logic +class UserAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + // Don't validate here! + if (empty($args[0]['email'])) { + throw new Exception('Email required'); + } + return new ValidateUserMutator($args[0]); + } +} +``` + +### Type-hint the mutator in convertToResult + +```php +public function convertToResult(Mutator $mutator) +{ + /** @var ContactFormMutator $mutator */ + return $mutator->getData(); +} +``` + +### Handle conversion errors gracefully + +```php +public function convertFromSource(...$args): Mutator +{ + if (!isset($args[0]) || !is_array($args[0])) { + return new ContactFormMutator([]); // Empty input, mutator handles validation + } + return new ContactFormMutator($args[0]); +} +``` + +--- + +## Testing + +Test adapters and mutators independently: + +```php +class SlugAdapterTest extends TestCase +{ + public function test_converts_from_source(): void + { + $adapter = new SlugAdapter(); + + $mutator = $adapter->convertFromSource('Test Input'); + + $this->assertInstanceOf(SlugifyMutator::class, $mutator); + } + + public function test_converts_to_result(): void + { + $adapter = new SlugAdapter(); + $mutator = new SlugifyMutator('Test Input'); + $mutator->mutate(); + + $result = $adapter->convertToResult($mutator); + + $this->assertEquals('test-input', $result); + } +} +``` + +--- + +## See also + +- [Mutator](mutator) — The transformation interface adapters work with +- [CanMutateFromAdapter](../traits/can-mutate-from-adapter) — Trait that automates the adapter workflow +- [MutatorHandler](mutator-handler) — Simpler alternative when adapters aren't needed diff --git a/public/docs/packages/mutator/interfaces/mutation-strategy.md b/public/docs/packages/mutator/interfaces/mutation-strategy.md new file mode 100644 index 0000000..a2f8759 --- /dev/null +++ b/public/docs/packages/mutator/interfaces/mutation-strategy.md @@ -0,0 +1,346 @@ +--- +id: mutator-interface-mutation-strategy +slug: docs/packages/mutator/interfaces/mutation-strategy +title: MutationStrategy Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The MutationStrategy interface registers mutator handlers to named actions for dynamic dispatch. +llm_summary: > + The MutationStrategy interface enables dynamic transformation pipelines by registering MutatorHandler + instances (via callable factories) to named action strings. The attach(callable, string) method + registers a handler getter to an action name. Implementations can then invoke handlers by action name. + Used for building plugin systems, hook-based transformations, and composable pipelines. +questions_answered: + - What is MutationStrategy? + - How do I register handlers to named actions? + - How do I build transformation pipelines? + - Why use callable factories instead of handlers directly? +audience: + - developers + - backend engineers +tags: + - interface + - mutator + - strategy + - pipeline +llm_tags: + - mutation-strategy + - named-actions + - pipeline-pattern +keywords: + - MutationStrategy interface + - named actions + - transformation pipeline +related: + - ../introduction + - mutator-handler +see_also: + - has-mutations + - mutator +noindex: false +--- + +# MutationStrategy Interface + +The `MutationStrategy` interface enables **dynamic transformation pipelines** by registering handlers to named actions. This supports plugin-style architectures where transformations are composed at runtime. + +## Interface definition + +```php +namespace PHPNomad\Mutator\Interfaces; + +interface MutationStrategy +{ + public function attach(callable $mutatorGetter, string $action): void; +} +``` + +## Methods + +### `attach(callable $mutatorGetter, string $action): void` + +Registers a handler factory to a named action. + +**Parameters:** +- `$mutatorGetter` — A callable that returns a `MutatorHandler` instance +- `$action` — The action name to attach the handler to + +**Returns:** `void` + +**Note:** The first parameter is a **callable factory**, not the handler itself. This enables lazy instantiation—handlers are only created when the action is invoked. + +--- + +## Why use MutationStrategy? + +| Scenario | How MutationStrategy helps | +|----------|---------------------------| +| Plugin systems | Plugins register handlers without knowing each other | +| Hook-based transformations | Named hooks trigger registered transformations | +| Composable pipelines | Chain multiple handlers on the same action | +| Runtime configuration | Register handlers based on configuration | + +--- + +## Basic implementation + +```php +use PHPNomad\Mutator\Interfaces\MutationStrategy; +use PHPNomad\Mutator\Interfaces\MutatorHandler; + +class SimpleMutationStrategy implements MutationStrategy +{ + private array $handlers = []; + + public function attach(callable $mutatorGetter, string $action): void + { + $this->handlers[$action][] = $mutatorGetter; + } + + public function apply(string $action, ...$args) + { + $value = $args[0] ?? null; + + foreach ($this->handlers[$action] ?? [] as $getter) { + /** @var MutatorHandler $handler */ + $handler = $getter(); + $value = $handler->mutate($value); + } + + return $value; + } +} +``` + +--- + +## Registering handlers + +```php +$strategy = new SimpleMutationStrategy(); + +// Register handlers using callable factories +$strategy->attach( + fn() => new TrimHandler(), + 'sanitize.string' +); + +$strategy->attach( + fn() => new LowercaseHandler(), + 'sanitize.string' +); + +$strategy->attach( + fn() => new HtmlEscapeHandler(), + 'sanitize.string' +); + +// Apply the pipeline +$result = $strategy->apply('sanitize.string', ' '); +// Result: "<script>hello</script>" +``` + +--- + +## Why callable factories? + +The interface takes a callable that **returns** a handler rather than the handler itself: + +```php +// This is what attach() expects +$strategy->attach(fn() => new ExpensiveHandler(), 'action'); + +// NOT this +$strategy->attach(new ExpensiveHandler(), 'action'); // Wrong! +``` + +Benefits of callable factories: + +| Benefit | Explanation | +|---------|-------------| +| **Lazy instantiation** | Handlers created only when action is invoked | +| **Fresh instances** | Each invocation can get a new handler instance | +| **Dependency injection** | Factory can pull from DI container | +| **Conditional creation** | Factory can include logic for which handler to create | + +--- + +## With dependency injection + +```php +class AppMutationStrategy implements MutationStrategy +{ + private Container $container; + private array $handlers = []; + + public function __construct(Container $container) + { + $this->container = $container; + } + + public function attach(callable $mutatorGetter, string $action): void + { + $this->handlers[$action][] = $mutatorGetter; + } + + public function apply(string $action, ...$args) + { + $value = $args[0] ?? null; + + foreach ($this->handlers[$action] ?? [] as $getter) { + $handler = $getter(); + $value = $handler->mutate($value); + } + + return $value; + } +} + +// Registration with DI +$strategy->attach( + fn() => $container->get(ValidateEmailHandler::class), + 'user.validate' +); +``` + +--- + +## Multiple actions + +Register handlers to different actions for organized pipelines: + +```php +// Validation pipeline +$strategy->attach(fn() => new RequiredFieldHandler(), 'user.validate'); +$strategy->attach(fn() => new EmailFormatHandler(), 'user.validate'); + +// Sanitization pipeline +$strategy->attach(fn() => new TrimHandler(), 'user.sanitize'); +$strategy->attach(fn() => new LowercaseEmailHandler(), 'user.sanitize'); + +// Transformation pipeline +$strategy->attach(fn() => new HashPasswordHandler(), 'user.transform'); + +// Apply in order +$data = $strategy->apply('user.sanitize', $input); +$data = $strategy->apply('user.validate', $data); +$data = $strategy->apply('user.transform', $data); +``` + +--- + +## Named action conventions + +Use namespaced action names for organization: + +```php +// Good: namespaced actions +'user.validate' +'user.sanitize' +'post.render.content' +'post.render.excerpt' + +// Bad: flat names that may collide +'validate' +'sanitize' +'render' +``` + +--- + +## Best practices + +### Use lazy factories + +```php +// Good: handler created only when needed +$strategy->attach(fn() => new ExpensiveHandler(), 'action'); + +// Bad: handler created immediately (if storing reference) +$handler = new ExpensiveHandler(); +$strategy->attach(fn() => $handler, 'action'); +``` + +### Order matters for pipelines + +Handlers are typically invoked in registration order: + +```php +// This order matters! +$strategy->attach(fn() => new TrimHandler(), 'sanitize'); // First +$strategy->attach(fn() => new LowercaseHandler(), 'sanitize'); // Second +$strategy->attach(fn() => new SlugifyHandler(), 'sanitize'); // Third +``` + +### Return values flow through the pipeline + +Each handler receives the previous handler's output: + +```php +// Input: " HELLO WORLD " +// After TrimHandler: "HELLO WORLD" +// After LowercaseHandler: "hello world" +// After SlugifyHandler: "hello-world" +``` + +--- + +## Testing + +```php +class MutationStrategyTest extends TestCase +{ + public function test_attaches_handler_to_action(): void + { + $strategy = new SimpleMutationStrategy(); + $called = false; + + $strategy->attach(function() use (&$called) { + $called = true; + return new class implements MutatorHandler { + public function mutate(...$args) { return $args[0]; } + }; + }, 'test.action'); + + $strategy->apply('test.action', 'value'); + + $this->assertTrue($called); + } + + public function test_applies_multiple_handlers_in_order(): void + { + $strategy = new SimpleMutationStrategy(); + + $strategy->attach( + fn() => new class implements MutatorHandler { + public function mutate(...$args) { return $args[0] . 'A'; } + }, + 'test' + ); + + $strategy->attach( + fn() => new class implements MutatorHandler { + public function mutate(...$args) { return $args[0] . 'B'; } + }, + 'test' + ); + + $result = $strategy->apply('test', ''); + + $this->assertEquals('AB', $result); + } +} +``` + +--- + +## See also + +- [MutatorHandler](mutator-handler) — The handlers attached to actions +- [HasMutations](has-mutations) — Objects that advertise their mutations +- [Loader Package](/packages/loader/introduction) — Uses MutationStrategy for module initialization diff --git a/public/docs/packages/mutator/interfaces/mutator-handler.md b/public/docs/packages/mutator/interfaces/mutator-handler.md new file mode 100644 index 0000000..5559a49 --- /dev/null +++ b/public/docs/packages/mutator/interfaces/mutator-handler.md @@ -0,0 +1,325 @@ +--- +id: mutator-interface-mutator-handler +slug: docs/packages/mutator/interfaces/mutator-handler +title: MutatorHandler Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The MutatorHandler interface defines functional-style transformations that take arguments and return results directly. +llm_summary: > + The MutatorHandler interface provides a functional alternative to Mutator. Instead of stateful + transformation, it takes variadic arguments and returns the result directly via mutate(...$args). + Simpler than Mutator when you don't need state management. Used with MutationStrategy for + building transformation pipelines with named actions. +questions_answered: + - What is MutatorHandler? + - How does MutatorHandler differ from Mutator? + - When should I use MutatorHandler? + - How do I build pipelines with MutatorHandler? +audience: + - developers + - backend engineers +tags: + - interface + - mutator + - transformation +llm_tags: + - mutator-handler + - functional-transformation +keywords: + - MutatorHandler interface + - functional transformation + - variadic arguments +related: + - ../introduction + - mutation-strategy +see_also: + - mutator + - mutation-adapter +noindex: false +--- + +# MutatorHandler Interface + +The `MutatorHandler` interface defines **functional-style transformations**. Unlike `Mutator`, it takes arguments directly and returns the result—no state management required. + +## Interface definition + +```php +namespace PHPNomad\Mutator\Interfaces; + +interface MutatorHandler +{ + public function mutate(...$args); +} +``` + +## Methods + +### `mutate(...$args): mixed` + +Performs a transformation on the provided arguments. + +**Parameters:** +- `...$args` — Variadic arguments passed to the transformation + +**Returns:** `mixed` — The transformed result + +**Behavior:** +- Should be stateless (same input always produces same output) +- Returns the result directly +- Can accept any number of arguments + +--- + +## When to use MutatorHandler + +Use `MutatorHandler` when: + +| Scenario | Why MutatorHandler fits | +|----------|-------------------------| +| Simple transformations | No state management overhead | +| Pure functions | Input → output with no side effects | +| Pipeline building | Easy to chain with MutationStrategy | +| Closures as handlers | Anonymous implementations work well | + +--- + +## Basic implementation + +```php +use PHPNomad\Mutator\Interfaces\MutatorHandler; + +class DiscountHandler implements MutatorHandler +{ + public function mutate(...$args) + { + [$price, $percentage] = $args; + return $price - ($price * $percentage / 100); + } +} + +// Usage +$handler = new DiscountHandler(); +echo $handler->mutate(100, 20); // 80 +echo $handler->mutate(50, 10); // 45 +``` + +--- + +## Multiple arguments + +```php +class FormatCurrencyHandler implements MutatorHandler +{ + public function mutate(...$args) + { + [$amount, $currency, $locale] = $args + [null, 'USD', 'en_US']; + + return number_format($amount, 2) . ' ' . $currency; + } +} + +$handler = new FormatCurrencyHandler(); +echo $handler->mutate(1234.5); // "1,234.50 USD" +echo $handler->mutate(1234.5, 'EUR'); // "1,234.50 EUR" +``` + +--- + +## Using with MutationStrategy + +`MutatorHandler` is designed to work with [MutationStrategy](mutation-strategy) for building pipelines: + +```php +class PipelineStrategy implements MutationStrategy +{ + private array $handlers = []; + + public function attach(callable $mutatorGetter, string $action): void + { + $this->handlers[$action][] = $mutatorGetter; + } + + public function apply(string $action, $value) + { + foreach ($this->handlers[$action] ?? [] as $getter) { + /** @var MutatorHandler $handler */ + $handler = $getter(); + $value = $handler->mutate($value); + } + return $value; + } +} + +// Register handlers +$pipeline = new PipelineStrategy(); + +$pipeline->attach( + fn() => new class implements MutatorHandler { + public function mutate(...$args) { return trim($args[0]); } + }, + 'sanitize.string' +); + +$pipeline->attach( + fn() => new class implements MutatorHandler { + public function mutate(...$args) { return strtolower($args[0]); } + }, + 'sanitize.string' +); + +// Apply pipeline +echo $pipeline->apply('sanitize.string', ' HELLO '); // "hello" +``` + +--- + +## Anonymous implementations + +For simple handlers, anonymous classes work well: + +```php +$trimHandler = new class implements MutatorHandler { + public function mutate(...$args) { + return trim($args[0]); + } +}; + +$upperHandler = new class implements MutatorHandler { + public function mutate(...$args) { + return strtoupper($args[0]); + } +}; + +echo $upperHandler->mutate($trimHandler->mutate(' hello ')); // "HELLO" +``` + +--- + +## Mutator vs MutatorHandler + +| Aspect | Mutator | MutatorHandler | +|--------|---------|----------------| +| State | Stateful (holds input/output) | Stateless | +| Method signature | `mutate(): void` | `mutate(...$args): mixed` | +| Results | Via getter methods | Direct return value | +| Complexity | More setup, more control | Simpler, less control | +| Testing | Test state after mutate() | Test return value directly | +| Use case | Validation, multi-step | Pure transformations | + +**Choose MutatorHandler** for simple, pure transformations. +**Choose Mutator** when you need state tracking or validation. + +--- + +## Best practices + +### Keep handlers pure + +Handlers should have no side effects: + +```php +// Good: pure function +class DoubleHandler implements MutatorHandler +{ + public function mutate(...$args) + { + return $args[0] * 2; + } +} + +// Bad: side effects +class DoubleHandler implements MutatorHandler +{ + public function mutate(...$args) + { + file_put_contents('log.txt', $args[0]); // Side effect! + return $args[0] * 2; + } +} +``` + +### Document expected arguments + +```php +/** + * Applies a percentage discount to a price. + * + * @param float $price The original price + * @param float $percentage The discount percentage (0-100) + * @return float The discounted price + */ +class DiscountHandler implements MutatorHandler +{ + public function mutate(...$args) + { + [$price, $percentage] = $args; + return $price - ($price * $percentage / 100); + } +} +``` + +### Handle missing arguments gracefully + +```php +class SafeDiscountHandler implements MutatorHandler +{ + public function mutate(...$args) + { + $price = $args[0] ?? 0; + $percentage = $args[1] ?? 0; + + return $price - ($price * $percentage / 100); + } +} +``` + +--- + +## Testing + +```php +class DiscountHandlerTest extends TestCase +{ + public function test_applies_discount(): void + { + $handler = new DiscountHandler(); + + $result = $handler->mutate(100, 20); + + $this->assertEquals(80, $result); + } + + public function test_handles_zero_discount(): void + { + $handler = new DiscountHandler(); + + $result = $handler->mutate(100, 0); + + $this->assertEquals(100, $result); + } + + public function test_is_stateless(): void + { + $handler = new DiscountHandler(); + + $first = $handler->mutate(100, 10); + $second = $handler->mutate(100, 10); + + $this->assertEquals($first, $second); + } +} +``` + +--- + +## See also + +- [Mutator](mutator) — Stateful alternative for complex transformations +- [MutationStrategy](mutation-strategy) — Register handlers to named actions +- [HasMutations](has-mutations) — Advertise available handlers diff --git a/public/docs/packages/mutator/interfaces/mutator.md b/public/docs/packages/mutator/interfaces/mutator.md new file mode 100644 index 0000000..dd1697f --- /dev/null +++ b/public/docs/packages/mutator/interfaces/mutator.md @@ -0,0 +1,293 @@ +--- +id: mutator-interface-mutator +slug: docs/packages/mutator/interfaces/mutator +title: Mutator Interface +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The Mutator interface defines stateful transformation objects that modify their internal state when mutate() is called. +llm_summary: > + The Mutator interface is the core transformation contract in phpnomad/mutator. It defines a single + mutate(): void method for stateful transformations where the object holds input data, transforms it + internally, and provides results via additional getter methods. Used with MutationAdapter for the + convert-mutate-convert pattern. Ideal for complex, multi-step transformations that need state tracking. +questions_answered: + - What is the Mutator interface? + - How do I implement Mutator? + - When should I use Mutator vs MutatorHandler? + - How does Mutator work with MutationAdapter? +audience: + - developers + - backend engineers +tags: + - interface + - mutator + - transformation +llm_tags: + - mutator-interface + - stateful-transformation +keywords: + - Mutator interface + - mutate method + - stateful transformation +related: + - ../introduction + - mutation-adapter +see_also: + - mutator-handler + - ../traits/can-mutate-from-adapter +noindex: false +--- + +# Mutator Interface + +The `Mutator` interface defines **stateful transformation objects**. When you call `mutate()`, the object transforms its internal state. You then retrieve results via additional methods on the implementing class. + +## Interface definition + +```php +namespace PHPNomad\Mutator\Interfaces; + +interface Mutator +{ + public function mutate(): void; +} +``` + +## Methods + +### `mutate(): void` + +Performs the transformation on the object's internal state. + +**Parameters:** None + +**Returns:** `void` — Results are accessed via additional methods on the implementing class + +**Behavior:** +- Should be idempotent when called multiple times (same result each time) +- May set internal error state rather than throwing exceptions +- Should not return a value; use getter methods for results + +--- + +## When to use Mutator + +Use `Mutator` when: + +| Scenario | Why Mutator fits | +|----------|------------------| +| Multi-step transformations | Internal state tracks progress through steps | +| Validation + transformation | Can accumulate errors and valid data separately | +| Complex input processing | Constructor receives input; mutate() processes it | +| Paired with MutationAdapter | Clean separation of conversion and logic | + +--- + +## Basic implementation + +```php +use PHPNomad\Mutator\Interfaces\Mutator; + +class SlugifyMutator implements Mutator +{ + private string $input; + private string $result = ''; + + public function __construct(string $input) + { + $this->input = $input; + } + + public function mutate(): void + { + $this->result = strtolower( + preg_replace('/[^a-zA-Z0-9]+/', '-', $this->input) + ); + } + + public function getResult(): string + { + return $this->result; + } +} + +// Usage +$mutator = new SlugifyMutator('Hello World!'); +$mutator->mutate(); +echo $mutator->getResult(); // "hello-world-" +``` + +--- + +## With validation and errors + +```php +class ContactFormMutator implements Mutator +{ + private array $input; + private array $data = []; + private array $errors = []; + + public function __construct(array $input) + { + $this->input = $input; + } + + public function mutate(): void + { + // Validate email + $email = trim($this->input['email'] ?? ''); + if (empty($email)) { + $this->errors['email'] = 'Email is required'; + } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->errors['email'] = 'Invalid email format'; + } else { + $this->data['email'] = strtolower($email); + } + + // Validate name + $name = trim($this->input['name'] ?? ''); + if (empty($name)) { + $this->errors['name'] = 'Name is required'; + } else { + $this->data['name'] = ucwords(strtolower($name)); + } + } + + public function isValid(): bool + { + return empty($this->errors); + } + + public function getData(): array + { + return $this->data; + } + + public function getErrors(): array + { + return $this->errors; + } +} +``` + +--- + +## Using with MutationAdapter + +The `Mutator` interface is designed to work with [MutationAdapter](mutation-adapter) for clean separation: + +```php +class ContactFormAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + return new ContactFormMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + /** @var ContactFormMutator $mutator */ + return $mutator->isValid() + ? ['success' => true, 'data' => $mutator->getData()] + : ['success' => false, 'errors' => $mutator->getErrors()]; + } +} +``` + +--- + +## Best practices + +### Keep mutators focused + +Each mutator should do one transformation well: + +```php +// Good: focused mutators +class TrimMutator implements Mutator { ... } +class ValidateEmailMutator implements Mutator { ... } +class SanitizeHtmlMutator implements Mutator { ... } + +// Bad: too many responsibilities +class StringMutator implements Mutator { + public function trim() { ... } + public function validateEmail() { ... } + public function sanitizeHtml() { ... } +} +``` + +### Make mutate() idempotent + +Calling `mutate()` multiple times should produce the same result: + +```php +$mutator = new SlugifyMutator('Hello'); +$mutator->mutate(); +$mutator->mutate(); // Same result +``` + +### Provide clear getter methods + +Name getters to indicate what they return: + +```php +// Good: clear names +public function getResult(): string { ... } +public function getErrors(): array { ... } +public function isValid(): bool { ... } + +// Bad: ambiguous +public function get() { ... } +public function status() { ... } +``` + +--- + +## Testing + +```php +class SlugifyMutatorTest extends TestCase +{ + public function test_mutates_string_to_slug(): void + { + $mutator = new SlugifyMutator('Hello World!'); + $mutator->mutate(); + + $this->assertEquals('hello-world-', $mutator->getResult()); + } + + public function test_handles_special_characters(): void + { + $mutator = new SlugifyMutator('Café & Résumé'); + $mutator->mutate(); + + $this->assertStringContainsString('-', $mutator->getResult()); + } + + public function test_is_idempotent(): void + { + $mutator = new SlugifyMutator('Test'); + $mutator->mutate(); + $first = $mutator->getResult(); + + $mutator->mutate(); + $second = $mutator->getResult(); + + $this->assertEquals($first, $second); + } +} +``` + +--- + +## See also + +- [MutatorHandler](mutator-handler) — Simpler functional alternative +- [MutationAdapter](mutation-adapter) — Pairs with Mutator for data conversion +- [CanMutateFromAdapter](../traits/can-mutate-from-adapter) — Trait that automates the adapter workflow diff --git a/public/docs/packages/mutator/introduction.md b/public/docs/packages/mutator/introduction.md new file mode 100644 index 0000000..225056a --- /dev/null +++ b/public/docs/packages/mutator/introduction.md @@ -0,0 +1,327 @@ +--- +id: mutator-introduction +slug: docs/packages/mutator/introduction +title: Mutator Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The mutator package provides interfaces and traits for implementing structured data transformation pipelines in PHP. +llm_summary: > + phpnomad/mutator provides a set of interfaces and one trait for implementing the mutation (transformation) + pattern in PHP. The package defines Mutator (stateful transformation), MutatorHandler (functional transformation + with arguments), MutationAdapter (bidirectional conversion between data and mutators), MutationStrategy + (attaching mutators to named actions), and HasMutations (exposing available mutations). The CanMutateFromAdapter + trait simplifies using adapters by handling the convert-mutate-convert workflow. Used by the loader package + for module initialization and by wordpress-plugin for data transformations. Zero dependencies. +questions_answered: + - What is the mutator package? + - How do I transform data using mutators? + - What is the difference between Mutator and MutatorHandler? + - How do MutationAdapters work? + - How do I attach mutators to named actions? + - When should I use mutators vs simple functions? + - What packages use the mutator interfaces? + - How do I create a data transformation pipeline? +audience: + - developers + - backend engineers +tags: + - mutator + - transformation + - design-pattern + - pipeline +llm_tags: + - mutation-pattern + - data-transformation + - adapter-pattern + - strategy-pattern +keywords: + - phpnomad mutator + - data transformation php + - mutation pattern + - MutationAdapter + - MutationStrategy +related: + - ../loader/introduction + - ../di/introduction +see_also: + - interfaces/introduction + - traits/introduction + - ../event/introduction +noindex: false +--- + +# Mutator + +`phpnomad/mutator` provides **interfaces and traits for structured data transformation** in PHP applications. Instead of ad-hoc transformation functions scattered throughout your codebase, the mutator pattern gives you: + +* **Consistent interfaces** — All transformations follow the same contract +* **Composable pipelines** — Chain mutations together via strategies +* **Separation of concerns** — Adapters handle conversion, mutators handle logic +* **Discoverability** — Objects can expose what mutations they support + +--- + +## Key ideas at a glance + +| Component | Purpose | Documentation | +|-----------|---------|---------------| +| **Mutator** | Stateful object that performs transformation via `mutate()` | [Interface docs](interfaces/mutator) | +| **MutatorHandler** | Functional interface: takes arguments, returns result directly | [Interface docs](interfaces/mutator-handler) | +| **MutationAdapter** | Converts between raw data and Mutator instances | [Interface docs](interfaces/mutation-adapter) | +| **MutationStrategy** | Attaches handlers to named actions for dynamic dispatch | [Interface docs](interfaces/mutation-strategy) | +| **HasMutations** | Interface for objects that expose their available mutations | [Interface docs](interfaces/has-mutations) | +| **CanMutateFromAdapter** | Trait that implements the adapter workflow | [Trait docs](traits/can-mutate-from-adapter) | + +--- + +## Why this package exists + +Data transformation is everywhere in PHP applications—sanitizing input, formatting output, validating data, converting between formats. Without a consistent pattern, you end up with: + +* **Scattered transformation logic** — Functions and methods spread across the codebase +* **Inconsistent signatures** — Some transform in place, some return new values +* **Hard-to-test pipelines** — Transformation steps are tightly coupled +* **No discoverability** — Finding what transformations exist requires reading code + +The mutator package provides **standardized contracts** that make transformations: + +| Problem | Solution | +|---------|----------| +| Inconsistent APIs | `Mutator` and `MutatorHandler` define clear contracts | +| Coupled conversions | `MutationAdapter` separates data conversion from logic | +| Static dispatch | `MutationStrategy` enables dynamic, named transformations | +| Hidden capabilities | `HasMutations` lets objects advertise what they can do | + +--- + +## Installation + +```bash +composer require phpnomad/mutator +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** None (zero dependencies) + +--- + +## The mutation workflow + +When using the `CanMutateFromAdapter` trait, the transformation follows this flow: + +``` +Input data + │ + ▼ +┌───────────────────────────────┐ +│ MutationAdapter │ +│ convertFromSource($args) │ +│ → creates Mutator instance │ +└───────────────────────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ Mutator │ +│ mutate() │ +│ → transforms internal state │ +└───────────────────────────────┘ + │ + ▼ +┌───────────────────────────────┐ +│ MutationAdapter │ +│ convertToResult($mutator) │ +│ → extracts output data │ +└───────────────────────────────┘ + │ + ▼ +Output data +``` + +This separation means: +* **Adapters** handle I/O format conversion (JSON, arrays, objects, etc.) +* **Mutators** contain pure transformation logic +* Each component is testable in isolation + +--- + +## Quick example + +```php +use PHPNomad\Mutator\Interfaces\Mutator; +use PHPNomad\Mutator\Interfaces\MutationAdapter; +use PHPNomad\Mutator\Traits\CanMutateFromAdapter; + +// 1. Define a mutator with transformation logic +class SlugifyMutator implements Mutator +{ + private string $input; + private string $result; + + public function __construct(string $input) + { + $this->input = $input; + } + + public function mutate(): void + { + $this->result = strtolower( + preg_replace('/[^a-zA-Z0-9]+/', '-', $this->input) + ); + } + + public function getResult(): string + { + return $this->result; + } +} + +// 2. Define an adapter for data conversion +class SlugAdapter implements MutationAdapter +{ + public function convertFromSource(...$args): Mutator + { + return new SlugifyMutator($args[0]); + } + + public function convertToResult(Mutator $mutator) + { + return $mutator->getResult(); + } +} + +// 3. Use the trait for automatic workflow +class SlugService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct() + { + $this->mutationAdapter = new SlugAdapter(); + } +} + +// Usage +$service = new SlugService(); +echo $service->mutate('Hello World!'); // "hello-world-" +``` + +--- + +## When to use mutators + +Mutators are appropriate when: + +| Scenario | Why mutators help | +|----------|-------------------| +| Complex transformations | Encapsulate multi-step logic in testable classes | +| Reusable transformations | Same mutator works across different contexts | +| Validation + transformation | Mutators can validate and transform in one pass | +| Dynamic pipelines | MutationStrategy enables runtime composition | +| Self-documenting code | HasMutations makes capabilities discoverable | + +### Common use cases + +* **Input validation and sanitization** — Clean user input before processing +* **Data format conversion** — Transform between DTOs, arrays, JSON +* **Business rule application** — Apply pricing rules, discounts, taxes +* **Content processing** — Markdown rendering, slug generation, text formatting +* **API response shaping** — Transform internal data for external consumption + +--- + +## When NOT to use mutators + +### Simple, one-off transformations + +If you're just uppercasing a string once, a function call is fine: + +```php +// Don't do this for trivial operations +$mutator = new UppercaseMutator($text); +$mutator->mutate(); +$result = $mutator->getResult(); + +// Just do this +$result = strtoupper($text); +``` + +### Pure functions suffice + +If your transformation has no state and no complex setup, a simple function or closure is cleaner. + +### No reuse needed + +If the transformation is used exactly once and is simple, inline it. + +--- + +## Best practices + +1. **Keep mutators focused** — Each mutator should do one thing well +2. **Make adapters responsible for conversion only** — Don't put business logic in adapters +3. **Use lazy initialization in strategies** — The `attach()` callable enables deferred instantiation +4. **Document what mutators do** — Especially for HasMutations, make capabilities clear + +See the individual interface docs for detailed best practices: +- [Mutator best practices](interfaces/mutator#best-practices) +- [MutatorHandler best practices](interfaces/mutator-handler#best-practices) +- [MutationAdapter best practices](interfaces/mutation-adapter#best-practices) + +--- + +## Relationship to other packages + +### Packages that depend on mutator + +| Package | How it uses mutator | +|---------|---------------------| +| [loader](/packages/loader/introduction) | Module initialization transformations | +| [wordpress-plugin](/packages/wordpress-plugin/introduction) | Data transformation in WordPress context | + +### Related patterns + +| Package | Relationship | +|---------|-------------| +| [event](/packages/event/introduction) | Events can trigger mutations; mutations can emit events | +| [di](/packages/di/introduction) | DI container can inject adapters and mutators | + +--- + +## Package contents + +### Interfaces + +| Interface | Purpose | +|-----------|---------| +| [Mutator](interfaces/mutator) | Stateful transformation (modifies internal state) | +| [MutatorHandler](interfaces/mutator-handler) | Functional transformation (args in, result out) | +| [MutationAdapter](interfaces/mutation-adapter) | Bidirectional data conversion | +| [MutationStrategy](interfaces/mutation-strategy) | Register handlers to named actions | +| [HasMutations](interfaces/has-mutations) | Expose available mutations | + +[View all interfaces →](interfaces/introduction) + +### Traits + +| Trait | Purpose | +|-------|---------| +| [CanMutateFromAdapter](traits/can-mutate-from-adapter) | Implements adapter workflow automatically | + +[View all traits →](traits/introduction) + +--- + +## Next steps + +* **New to mutators?** Read [Mutator interface](interfaces/mutator) and [MutatorHandler](interfaces/mutator-handler) to understand the two transformation styles +* **Building pipelines?** See [MutationStrategy](interfaces/mutation-strategy) for dynamic dispatch +* **Using adapters?** Check [CanMutateFromAdapter trait](traits/can-mutate-from-adapter) to simplify your code +* **Need loader integration?** See [Loader](/packages/loader/introduction) which uses mutators extensively diff --git a/public/docs/packages/mutator/traits/can-mutate-from-adapter.md b/public/docs/packages/mutator/traits/can-mutate-from-adapter.md new file mode 100644 index 0000000..fb89100 --- /dev/null +++ b/public/docs/packages/mutator/traits/can-mutate-from-adapter.md @@ -0,0 +1,366 @@ +--- +id: mutator-trait-can-mutate-from-adapter +slug: docs/packages/mutator/traits/can-mutate-from-adapter +title: CanMutateFromAdapter Trait +doc_type: reference +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The CanMutateFromAdapter trait automates the adapter-based mutation workflow with a single mutate() method. +llm_summary: > + The CanMutateFromAdapter trait provides a mutate(...$args) method that implements the full + adapter workflow: convertFromSource() creates a Mutator, mutate() transforms it, and + convertToResult() extracts the output. Classes using this trait must have a $mutationAdapter + property of type MutationAdapter. This eliminates boilerplate when using the adapter pattern. +questions_answered: + - What does CanMutateFromAdapter do? + - How do I use the CanMutateFromAdapter trait? + - What are the requirements for using this trait? + - How does the trait implement the adapter workflow? +audience: + - developers + - backend engineers +tags: + - trait + - mutator + - adapter +llm_tags: + - can-mutate-from-adapter + - adapter-workflow +keywords: + - CanMutateFromAdapter trait + - adapter workflow + - mutation trait +related: + - ../introduction + - ../interfaces/mutation-adapter +see_also: + - ../interfaces/mutator + - ../interfaces/mutator-handler +noindex: false +--- + +# CanMutateFromAdapter Trait + +The `CanMutateFromAdapter` trait implements the **adapter-based mutation workflow** automatically. Instead of manually calling `convertFromSource()`, `mutate()`, and `convertToResult()`, the trait provides a single `mutate()` method that handles everything. + +## Trait definition + +```php +namespace PHPNomad\Mutator\Traits; + +use PHPNomad\Mutator\Interfaces\MutationAdapter; + +trait CanMutateFromAdapter +{ + protected MutationAdapter $mutationAdapter; + + public function mutate(...$args) + { + $mutation = $this->mutationAdapter->convertFromSource(...$args); + $mutation->mutate(); + return $this->mutationAdapter->convertToResult($mutation); + } +} +``` + +## Requirements + +To use this trait, your class must: + +1. Have a `$mutationAdapter` property of type `MutationAdapter` +2. Initialize the adapter (typically in the constructor) + +--- + +## The workflow + +The trait automates this three-step process: + +``` +mutate($args) + │ + ▼ +┌───────────────────────────────────┐ +│ 1. convertFromSource(...$args) │ +│ → Creates a Mutator instance │ +└───────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────┐ +│ 2. $mutator->mutate() │ +│ → Transforms internal state │ +└───────────────────────────────────┘ + │ + ▼ +┌───────────────────────────────────┐ +│ 3. convertToResult($mutator) │ +│ → Extracts and returns output │ +└───────────────────────────────────┘ + │ + ▼ +return result +``` + +--- + +## Basic usage + +```php +use PHPNomad\Mutator\Traits\CanMutateFromAdapter; +use PHPNomad\Mutator\Interfaces\MutationAdapter; + +class SlugService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct() + { + $this->mutationAdapter = new SlugAdapter(); + } +} + +// The trait provides mutate() +$service = new SlugService(); +echo $service->mutate('Hello World!'); // "hello-world-" +``` + +--- + +## With dependency injection + +```php +class ContactFormService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct(ContactFormAdapter $adapter) + { + $this->mutationAdapter = $adapter; + } +} + +// In your DI container registration +$container->set(ContactFormService::class, function($c) { + return new ContactFormService( + $c->get(ContactFormAdapter::class) + ); +}); + +// Usage +$service = $container->get(ContactFormService::class); +$result = $service->mutate([ + 'email' => 'user@example.com', + 'name' => 'John Doe', + 'message' => 'Hello!' +]); +``` + +--- + +## Multiple adapters + +You can create services with different adapters for different use cases: + +```php +class UserService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct(MutationAdapter $adapter) + { + $this->mutationAdapter = $adapter; + } +} + +// Different adapters for different contexts +$registrationService = new UserService(new RegistrationAdapter()); +$profileService = new UserService(new ProfileUpdateAdapter()); + +// Same interface, different behavior +$newUser = $registrationService->mutate($registrationData); +$updatedProfile = $profileService->mutate($profileData); +``` + +--- + +## Extending the trait + +You can add methods that use the trait's `mutate()`: + +```php +class FormService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct() + { + $this->mutationAdapter = new FormAdapter(); + } + + // Add convenience methods + public function validateAndSubmit(array $data): array + { + $result = $this->mutate($data); + + if ($result['success']) { + $this->sendNotification($result['data']); + } + + return $result; + } + + private function sendNotification(array $data): void + { + // Send email, log, etc. + } +} +``` + +--- + +## Without the trait + +For comparison, here's what you'd write without the trait: + +```php +// Without trait - manual workflow +class SlugService +{ + private MutationAdapter $adapter; + + public function __construct() + { + $this->adapter = new SlugAdapter(); + } + + public function mutate(...$args) + { + // Must write this yourself + $mutator = $this->adapter->convertFromSource(...$args); + $mutator->mutate(); + return $this->adapter->convertToResult($mutator); + } +} +``` + +The trait eliminates this boilerplate. + +--- + +## Best practices + +### Initialize the adapter in the constructor + +```php +// Good: adapter initialized in constructor +public function __construct(MyAdapter $adapter) +{ + $this->mutationAdapter = $adapter; +} + +// Bad: adapter created lazily (may cause null errors) +public function mutate(...$args) +{ + $this->mutationAdapter ??= new MyAdapter(); // Risky + // ... +} +``` + +### Use interfaces for adapter type hints + +```php +// Good: accepts any MutationAdapter +public function __construct(MutationAdapter $adapter) +{ + $this->mutationAdapter = $adapter; +} + +// Less flexible: locked to specific adapter +public function __construct(SlugAdapter $adapter) +{ + $this->mutationAdapter = $adapter; +} +``` + +### Don't override mutate() unless necessary + +The trait's `mutate()` handles the standard workflow. Override only if you need custom behavior: + +```php +// Override only when needed +public function mutate(...$args) +{ + $this->logger->info('Starting mutation', ['args' => $args]); + + $result = parent::mutate(...$args); // Call trait method + + $this->logger->info('Mutation complete', ['result' => $result]); + + return $result; +} +``` + +--- + +## Testing + +Test the service with mock adapters: + +```php +class SlugServiceTest extends TestCase +{ + public function test_delegates_to_adapter(): void + { + $mockAdapter = $this->createMock(MutationAdapter::class); + $mockMutator = $this->createMock(Mutator::class); + + $mockAdapter->expects($this->once()) + ->method('convertFromSource') + ->with('input') + ->willReturn($mockMutator); + + $mockMutator->expects($this->once()) + ->method('mutate'); + + $mockAdapter->expects($this->once()) + ->method('convertToResult') + ->with($mockMutator) + ->willReturn('output'); + + $service = new class($mockAdapter) { + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct(MutationAdapter $adapter) + { + $this->mutationAdapter = $adapter; + } + }; + + $result = $service->mutate('input'); + + $this->assertEquals('output', $result); + } +} +``` + +--- + +## See also + +- [MutationAdapter](../interfaces/mutation-adapter) — The adapter interface this trait works with +- [Mutator](../interfaces/mutator) — The mutator interface adapters create +- [Package Introduction](../introduction) — Overview of the mutator package diff --git a/public/docs/packages/mutator/traits/introduction.md b/public/docs/packages/mutator/traits/introduction.md new file mode 100644 index 0000000..5b999fc --- /dev/null +++ b/public/docs/packages/mutator/traits/introduction.md @@ -0,0 +1,93 @@ +--- +id: mutator-traits-introduction +slug: docs/packages/mutator/traits/introduction +title: Mutator Traits Overview +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: Overview of the CanMutateFromAdapter trait that automates the adapter-based mutation workflow. +llm_summary: > + The mutator package provides one trait: CanMutateFromAdapter. This trait implements the standard + adapter workflow (convert from source, mutate, convert to result) automatically. Classes using + this trait must have a $mutationAdapter property. The trait provides a mutate(...$args) method + that handles the full transformation flow. +questions_answered: + - What traits does the mutator package provide? + - How does CanMutateFromAdapter work? + - What are the requirements for using the trait? +audience: + - developers + - backend engineers +tags: + - traits + - mutator + - transformation +llm_tags: + - trait-overview + - mutator-traits +keywords: + - mutator traits + - CanMutateFromAdapter +related: + - ../introduction +see_also: + - can-mutate-from-adapter + - ../interfaces/mutation-adapter +noindex: false +--- + +# Mutator Traits + +The mutator package provides one trait that simplifies the adapter-based mutation workflow. + +| Trait | Purpose | Requirements | +|-------|---------|--------------| +| [CanMutateFromAdapter](can-mutate-from-adapter) | Automates convert → mutate → convert workflow | `$mutationAdapter` property | + +--- + +## The adapter workflow + +When using `MutationAdapter` with `Mutator`, you typically follow this flow: + +``` +Input → convertFromSource() → Mutator → mutate() → convertToResult() → Output +``` + +The `CanMutateFromAdapter` trait implements this entire flow in a single `mutate()` method. + +--- + +## Quick example + +```php +use PHPNomad\Mutator\Traits\CanMutateFromAdapter; +use PHPNomad\Mutator\Interfaces\MutationAdapter; + +class SlugService +{ + use CanMutateFromAdapter; + + protected MutationAdapter $mutationAdapter; + + public function __construct() + { + $this->mutationAdapter = new SlugAdapter(); + } +} + +// Usage - the trait handles the full workflow +$service = new SlugService(); +$slug = $service->mutate('Hello World!'); // "hello-world-" +``` + +--- + +## Next steps + +- [CanMutateFromAdapter](can-mutate-from-adapter) — Full trait documentation +- [MutationAdapter](../interfaces/mutation-adapter) — The adapter interface the trait works with diff --git a/public/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md b/public/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md index bc8e678..fe93244 100644 --- a/public/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md +++ b/public/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md @@ -1,5 +1,7 @@ # EventInterceptor +> **See also:** [Event Package](/packages/event/introduction) for the core `Event` and `EventStrategy` interfaces. + The `EventInterceptor` is a built-in interceptor in PHPNomad designed for **publishing events** after a controller has completed its work. It lets you broadcast domain events in response to API calls, keeping controllers free of side-effect logic. diff --git a/public/docs/packages/rest/interceptors/introduction.md b/public/docs/packages/rest/interceptors/introduction.md index c7bd27b..91ba946 100644 --- a/public/docs/packages/rest/interceptors/introduction.md +++ b/public/docs/packages/rest/interceptors/introduction.md @@ -115,4 +115,11 @@ confusion and unintended side effects. * Use interceptors to **enforce cross-cutting policies** (envelopes, headers, serialization), not domain logic. * Be explicit about **which interceptors run where**—avoid magical or hidden behaviors. -Following these practices ensures interceptors stay predictable, reusable, and maintainable over time. \ No newline at end of file +Following these practices ensures interceptors stay predictable, reusable, and maintainable over time. + +--- + +## Related Documentation + +- [Logger Package](../../logger/introduction.md) - LoggerStrategy interface used for request logging +- [Included Interceptors](./included-interceptors/introduction.md) - Pre-built interceptors in PHPNomad \ No newline at end of file diff --git a/public/docs/packages/singleton/introduction.md b/public/docs/packages/singleton/introduction.md new file mode 100644 index 0000000..6830b65 --- /dev/null +++ b/public/docs/packages/singleton/introduction.md @@ -0,0 +1,544 @@ +--- +id: singleton-introduction +slug: docs/packages/singleton/introduction +title: Singleton Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2026-01-25 +applies_to: ["all"] +canonical: true +summary: The singleton package provides a reusable trait for implementing the singleton pattern in PHP classes. +llm_summary: > + phpnomad/singleton provides the WithInstance trait that gives any PHP class singleton behavior. + The trait maintains a static instance and provides lazy initialization via the instance() method. + Uses late static binding (static::) to support inheritance hierarchies where each subclass + maintains its own singleton. Commonly used for configuration managers, loggers, and service + locators. Consider using dependency injection containers for better testability in most cases. +questions_answered: + - What is the singleton package? + - How do I make a class a singleton in PHPNomad? + - How does the WithInstance trait work? + - Can singleton classes be extended? + - How do I test classes that use singletons? + - When should I use singletons vs dependency injection? + - What packages use the singleton trait? +audience: + - developers + - backend engineers +tags: + - singleton + - design-pattern + - trait +llm_tags: + - singleton-pattern + - with-instance + - lazy-initialization +keywords: + - phpnomad singleton + - singleton pattern php + - WithInstance trait + - static instance +related: + - ../di/introduction + - ../enum-polyfill/introduction +see_also: + - ../logger/introduction + - ../core/introduction +noindex: false +--- + +# Singleton + +`phpnomad/singleton` provides a **reusable implementation of the singleton pattern** for PHP applications. It consists of a single trait—`WithInstance`—that can be added to any class to ensure only one instance exists throughout your application's lifecycle. + +At its core: + +* **Lazy initialization** — The instance is created only when first requested +* **Late static binding** — Each class in an inheritance hierarchy maintains its own singleton +* **Zero configuration** — Just add the trait and call `instance()` + +--- + +## Key ideas at a glance + +* **WithInstance** — A trait that provides singleton behavior to any class +* **`instance()` method** — Returns the singleton instance, creating it on first call +* **`$instance` property** — Protected static property storing the singleton +* **Late static binding** — Uses `static::` so subclasses get their own instances + +--- + +## Why this package exists + +The singleton pattern solves a specific problem: ensuring a class has exactly one instance while providing global access to it. Without a standardized implementation, developers often: + +* Rewrite the same boilerplate in every singleton class +* Make mistakes with static binding (`self::` vs `static::`) +* Forget to handle inheritance correctly +* Create inconsistent implementations across a codebase + +This package provides a **tested, consistent implementation** that handles these edge cases correctly. + +--- + +## Installation + +```bash +composer require phpnomad/singleton +``` + +**Requirements:** PHP 7.4+ + +**Dependencies:** None (zero dependencies) + +--- + +## The singleton lifecycle + +When you call `instance()` on a class using the `WithInstance` trait: + +``` +First call to MyClass::instance() + │ + ▼ +┌─────────────────────────┐ +│ Check: is $instance set?│ +└─────────────────────────┘ + │ + No ──┴── Yes + │ │ + ▼ │ +┌─────────────┐ │ +│ Create new │ │ +│ instance │ │ +│ new static()│ │ +└─────────────┘ │ + │ │ + ▼ │ +┌─────────────┐ │ +│ Store in │ │ +│ $instance │ │ +└─────────────┘ │ + │ │ + └──────┬───────┘ + │ + ▼ + Return $instance +``` + +All subsequent calls skip instance creation and return the stored instance immediately. + +--- + +## Basic usage + +Add the trait to any class: + +```php +use PHPNomad\Singleton\Traits\WithInstance; + +class ConfigManager +{ + use WithInstance; + + private array $settings = []; + + public function set(string $key, mixed $value): void + { + $this->settings[$key] = $value; + } + + public function get(string $key, mixed $default = null): mixed + { + return $this->settings[$key] ?? $default; + } +} +``` + +Access the singleton from anywhere: + +```php +// In your bootstrap +ConfigManager::instance()->set('debug', true); +ConfigManager::instance()->set('timezone', 'UTC'); + +// Later, in a controller +$debug = ConfigManager::instance()->get('debug'); // true + +// In a service +$timezone = ConfigManager::instance()->get('timezone'); // 'UTC' +``` + +Every call to `instance()` returns the same object. + +--- + +## How it works + +The trait implementation is minimal: + +```php +trait WithInstance +{ + protected static $instance; + + public static function instance() + { + if (!isset(static::$instance)) { + static::$instance = new static; + } + + return static::$instance; + } +} +``` + +Key implementation details: + +| Aspect | Implementation | Why | +|--------|----------------|-----| +| Property visibility | `protected static` | Allows subclasses to access/reset | +| Static binding | `static::$instance` | Each class gets its own instance | +| Instantiation | `new static` | Creates the actual subclass, not the trait user | +| Initialization | Lazy (on first call) | No overhead if never used | + +--- + +## Inheritance behavior + +Because the trait uses `static::` (late static binding), each class in an inheritance hierarchy maintains its own singleton: + +```php +class BaseService +{ + use WithInstance; + + protected string $name = 'base'; + + public function getName(): string + { + return $this->name; + } +} + +class UserService extends BaseService +{ + protected string $name = 'users'; +} + +class OrderService extends BaseService +{ + protected string $name = 'orders'; +} + +// Each class has its own singleton instance +$base = BaseService::instance(); // BaseService object +$users = UserService::instance(); // UserService object +$orders = OrderService::instance(); // OrderService object + +$base->getName(); // 'base' +$users->getName(); // 'users' +$orders->getName(); // 'orders' + +// These are all different objects +$base !== $users; // true +$users !== $orders; // true +``` + +This is the correct behavior—if you had used `self::` instead of `static::`, all subclasses would share the parent's instance, which is almost never what you want. + +--- + +## When to use singletons + +Singletons are appropriate when: + +* **Exactly one instance must exist** — Configuration, logging, connection pools +* **Global access is genuinely needed** — The instance is used across many unrelated parts of the system +* **State must persist** — The instance maintains state that shouldn't reset +* **Resource management** — Controlling access to a shared resource like a file handle or connection + +### Common use cases + +| Use Case | Why Singleton | +|----------|---------------| +| Configuration manager | One source of truth for settings | +| Logger | Consistent logging across the application | +| Database connection pool | Manage limited connections | +| Cache manager | Single cache instance with shared state | +| Event dispatcher | Central hub for all events | + +--- + +## When NOT to use singletons + +Singletons are often overused. Avoid them when: + +### Testing is important + +Singletons carry state between tests, causing flaky tests: + +```php +// Test 1 sets state +ConfigManager::instance()->set('mode', 'test'); + +// Test 2 unexpectedly has that state +$mode = ConfigManager::instance()->get('mode'); // 'test' - leaked from Test 1! +``` + +### Dependency injection is available + +If you're using a DI container, prefer container-managed singletons: + +```php +// Instead of this (hard to test, hidden dependency) +class UserController +{ + public function index() + { + $users = Database::instance()->query('SELECT * FROM users'); + } +} + +// Do this (explicit dependency, testable) +class UserController +{ + public function __construct(private Database $db) {} + + public function index() + { + $users = $this->db->query('SELECT * FROM users'); + } +} +``` + +### The "single instance" requirement isn't real + +Ask yourself: does this *really* need to be a singleton, or is it just convenient? Often, passing instances through constructors (dependency injection) is cleaner. + +--- + +## Best practices + +### 1. Keep singleton classes focused + +Singletons should do one thing. If your singleton is managing config *and* logging *and* caching, split it up. + +### 2. Make constructors private or protected + +Prevent direct instantiation to enforce the singleton pattern: + +```php +class Logger +{ + use WithInstance; + + private function __construct() + { + // Initialize logger + } +} + +// This is now impossible: +$logger = new Logger(); // Error: private constructor +``` + +### 3. Consider immutability + +If possible, make singleton state immutable after initialization: + +```php +class Config +{ + use WithInstance; + + private array $settings; + private bool $locked = false; + + public function load(array $settings): void + { + if ($this->locked) { + throw new RuntimeException('Config is locked'); + } + $this->settings = $settings; + $this->locked = true; + } + + public function get(string $key): mixed + { + return $this->settings[$key] ?? null; + } +} +``` + +### 4. Document singleton usage + +Make it clear in your class docblock that it's a singleton: + +```php +/** + * Application configuration manager. + * + * This is a singleton - use Config::instance() to access. + */ +class Config +{ + use WithInstance; +} +``` + +--- + +## Testing with singletons + +Singletons persist across tests, which can cause issues. Here are strategies to handle this: + +### Strategy 1: Testable subclass + +Create a test-specific subclass that can reset the instance: + +```php +class TestableConfig extends Config +{ + public static function resetInstance(): void + { + static::$instance = null; + } +} + +// In your test +protected function setUp(): void +{ + TestableConfig::resetInstance(); +} +``` + +### Strategy 2: Reflection + +Reset any singleton using reflection: + +```php +function resetSingleton(string $class): void +{ + $reflection = new ReflectionClass($class); + $property = $reflection->getProperty('instance'); + $property->setAccessible(true); + $property->setValue(null, null); +} + +// In your test +protected function setUp(): void +{ + resetSingleton(Config::class); +} +``` + +### Strategy 3: Dependency injection + +The best solution is often to avoid calling `instance()` directly in the code you're testing: + +```php +// Instead of this (hard to test) +class UserService +{ + public function getUsers(): array + { + return Database::instance()->query('...'); + } +} + +// Do this (easy to test) +class UserService +{ + public function __construct(private Database $db) {} + + public function getUsers(): array + { + return $this->db->query('...'); + } +} + +// In production, inject the singleton +$service = new UserService(Database::instance()); + +// In tests, inject a mock +$service = new UserService($mockDatabase); +``` + +--- + +## Integration with dependency injection + +The PHPNomad [DI container](/packages/di/introduction) can manage singleton instances while maintaining testability: + +```php +use PHPNomad\Di\Container; + +$container = new Container(); + +// Register as a singleton in the container +$container->singleton(Logger::class, function() { + return new Logger(); +}); + +// The container ensures only one instance exists +$logger1 = $container->get(Logger::class); +$logger2 = $container->get(Logger::class); + +$logger1 === $logger2; // true +``` + +This approach gives you singleton behavior with: +* Explicit dependencies (visible in constructors) +* Easy mocking in tests +* Centralized configuration + +--- + +## Relationship to other packages + +### Packages that depend on singleton + +| Package | How it uses singleton | +|---------|----------------------| +| [enum-polyfill](/packages/enum-polyfill/introduction) | Enum instances use singleton pattern | +| [logger](/packages/logger/introduction) | Logger strategies can be singletons | +| [database](/packages/database/introduction) | Connection management | +| [core](/packages/core/introduction) | Core framework services | + +### Related packages + +| Package | Relationship | +|---------|-------------| +| [di](/packages/di/introduction) | Alternative approach via container-managed singletons | +| [facade](/packages/facade/introduction) | Facades often wrap singleton services | + +--- + +## API reference + +### WithInstance Trait + +**Namespace:** `PHPNomad\Singleton\Traits` + +#### Properties + +| Property | Type | Visibility | Description | +|----------|------|------------|-------------| +| `$instance` | `static` | `protected static` | Stores the singleton instance for the class | + +#### Methods + +| Method | Signature | Returns | Description | +|--------|-----------|---------|-------------| +| `instance` | `public static function instance()` | `static` | Returns the singleton instance, creating it on first call | + +--- + +## Next steps + +* **Need dependency injection?** See [DI Container](/packages/di/introduction) for container-managed singletons +* **Building enums?** See [Enum Polyfill](/packages/enum-polyfill/introduction) which uses this trait +* **Setting up logging?** See [Logger](/packages/logger/introduction) for logging strategies