From b2b2507f8444b54172063fbd473f1a1312104e2b Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 25 Jan 2026 11:12:52 -0500 Subject: [PATCH 1/2] progress --- .../bootstrapping/advanced-bootstrapping.md | 192 ++++ .../creating-and-managing-initializers.md | 292 +++++++ .../initializers/event-binding.md | 251 ++++++ .../initializers/event-listeners.md | 274 ++++++ .../bootstrapping/initializers/facades.md | 301 +++++++ .../initializers/rest-controllers.md | 90 ++ .../bootstrapping/introduction.md | 62 ++ .../bootstrapping/platform-integrations.md | 140 +++ .../datastores/getting-started-tutorial.md | 818 ++++++++++++++++++ .../core-concepts/datastores/introduction.md | 290 +++++++ .../datastores/models-and-identity.md | 677 +++++++++++++++ public/docs/docs/index.md | 67 ++ .../config/exceptions/config-exception.md | 341 ++++++++ .../interfaces/config-file-loader-strategy.md | 408 +++++++++ .../config/interfaces/config-strategy.md | 421 +++++++++ .../config/interfaces/introduction.md | 112 +++ .../docs/docs/packages/config/introduction.md | 327 +++++++ .../config/services/config-service.md | 367 ++++++++ .../packages/config/services/introduction.md | 98 +++ .../packages/database/caching-and-events.md | 575 ++++++++++++ .../database/database-service-provider.md | 473 ++++++++++ ...identifiable-database-datastore-handler.md | 441 ++++++++++ .../database/handlers/introduction.md | 218 +++++ .../with-datastore-handler-methods.md | 496 +++++++++++ .../date-created-factory.md | 171 ++++ .../date-modified-factory.md | 213 +++++ .../included-factories/foreign-key-factory.md | 266 ++++++ .../included-factories/introduction.md | 367 ++++++++ .../included-factories/primary-key-factory.md | 136 +++ .../docs/packages/database/introduction.md | 424 +++++++++ .../docs/packages/database/junction-tables.md | 550 ++++++++++++ .../docs/packages/database/query-building.md | 578 +++++++++++++ .../database/table-schema-definition.md | 626 ++++++++++++++ .../packages/database/tables/introduction.md | 269 ++++++ .../database/tables/junction-table-class.md | 73 ++ .../packages/database/tables/table-class.md | 166 ++++ .../packages/datastore/core-implementation.md | 604 +++++++++++++ .../packages/datastore/integration-guide.md | 485 +++++++++++ .../interfaces/datastore-has-counts.md | 289 +++++++ .../interfaces/datastore-has-primary-key.md | 254 ++++++ .../interfaces/datastore-has-where.md | 292 +++++++ .../datastore/interfaces/datastore.md | 266 ++++++ .../datastore/interfaces/introduction.md | 196 +++++ .../docs/packages/datastore/introduction.md | 309 +++++++ .../docs/packages/datastore/model-adapters.md | 540 ++++++++++++ .../packages/datastore/traits/introduction.md | 267 ++++++ .../traits/with-datastore-count-decorator.md | 217 +++++ .../traits/with-datastore-decorator.md | 199 +++++ .../with-datastore-primary-key-decorator.md | 204 +++++ .../traits/with-datastore-where-decorator.md | 230 +++++ .../packages/enum-polyfill/introduction.md | 295 +++++++ .../packages/enum-polyfill/traits/enum.md | 429 +++++++++ .../enum-polyfill/traits/introduction.md | 86 ++ .../interfaces/action-binding-strategy.md | 433 +++++++++ .../packages/event/interfaces/can-handle.md | 372 ++++++++ .../event/interfaces/event-strategy.md | 402 +++++++++ .../docs/packages/event/interfaces/event.md | 357 ++++++++ .../event/interfaces/has-event-bindings.md | 422 +++++++++ .../event/interfaces/has-listeners.md | 373 ++++++++ .../packages/event/interfaces/introduction.md | 178 ++++ .../docs/docs/packages/event/introduction.md | 263 ++++++ .../packages/event/patterns/best-practices.md | 621 +++++++++++++ .../logger/interfaces/introduction.md | 82 ++ .../logger/interfaces/logger-strategy.md | 407 +++++++++ .../docs/docs/packages/logger/introduction.md | 278 ++++++ .../logger/traits/can-log-exception.md | 238 +++++ .../packages/logger/traits/introduction.md | 82 ++ .../mutator/interfaces/has-mutations.md | 389 +++++++++ .../mutator/interfaces/introduction.md | 127 +++ .../mutator/interfaces/mutation-adapter.md | 369 ++++++++ .../mutator/interfaces/mutation-strategy.md | 346 ++++++++ .../mutator/interfaces/mutator-handler.md | 325 +++++++ .../packages/mutator/interfaces/mutator.md | 293 +++++++ .../docs/packages/mutator/introduction.md | 327 +++++++ .../mutator/traits/can-mutate-from-adapter.md | 366 ++++++++ .../packages/mutator/traits/introduction.md | 93 ++ public/docs/docs/packages/rest/controllers.md | 363 ++++++++ .../docs/packages/rest/integration-guide.md | 175 ++++ .../event-interceptor.md | 88 ++ .../rest/interceptors/introduction.md | 125 +++ .../docs/docs/packages/rest/introduction.md | 79 ++ .../callback-middleware.md | 83 ++ .../parse-jwt-middleware.md | 109 +++ .../set-type-middleware.md | 73 ++ .../validation-middleware.md | 136 +++ .../packages/rest/middleware/introduction.md | 223 +++++ .../included-validations/is-any.md | 201 +++++ .../included-validations/is-email.md | 96 ++ .../included-validations/is-greater-than.md | 112 +++ .../included-validations/is-numeric.md | 121 +++ .../included-validations/is-type.md | 271 ++++++ .../included-validations/keys-are-any.md | 129 +++ .../packages/rest/validations/introduction.md | 182 ++++ .../rest/validations/validation-set.md | 123 +++ .../docs/packages/singleton/introduction.md | 544 ++++++++++++ .../docs/docs/packages/utils/array-helper.md | 335 +++++++ .../utils/processors/array-processor.md | 76 ++ .../packages/utils/processors/list-filter.md | 235 +++++ .../packages/utils/processors/list-sorter.md | 93 ++ 99 files changed, 27847 insertions(+) create mode 100644 public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/facades.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/introduction.md create mode 100644 public/docs/docs/core-concepts/bootstrapping/platform-integrations.md create mode 100644 public/docs/docs/core-concepts/datastores/getting-started-tutorial.md create mode 100644 public/docs/docs/core-concepts/datastores/introduction.md create mode 100644 public/docs/docs/core-concepts/datastores/models-and-identity.md create mode 100644 public/docs/docs/index.md create mode 100644 public/docs/docs/packages/config/exceptions/config-exception.md create mode 100644 public/docs/docs/packages/config/interfaces/config-file-loader-strategy.md create mode 100644 public/docs/docs/packages/config/interfaces/config-strategy.md create mode 100644 public/docs/docs/packages/config/interfaces/introduction.md create mode 100644 public/docs/docs/packages/config/introduction.md create mode 100644 public/docs/docs/packages/config/services/config-service.md create mode 100644 public/docs/docs/packages/config/services/introduction.md create mode 100644 public/docs/docs/packages/database/caching-and-events.md create mode 100644 public/docs/docs/packages/database/database-service-provider.md create mode 100644 public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md create mode 100644 public/docs/docs/packages/database/handlers/introduction.md create mode 100644 public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md create mode 100644 public/docs/docs/packages/database/included-factories/date-created-factory.md create mode 100644 public/docs/docs/packages/database/included-factories/date-modified-factory.md create mode 100644 public/docs/docs/packages/database/included-factories/foreign-key-factory.md create mode 100644 public/docs/docs/packages/database/included-factories/introduction.md create mode 100644 public/docs/docs/packages/database/included-factories/primary-key-factory.md create mode 100644 public/docs/docs/packages/database/introduction.md create mode 100644 public/docs/docs/packages/database/junction-tables.md create mode 100644 public/docs/docs/packages/database/query-building.md create mode 100644 public/docs/docs/packages/database/table-schema-definition.md create mode 100644 public/docs/docs/packages/database/tables/introduction.md create mode 100644 public/docs/docs/packages/database/tables/junction-table-class.md create mode 100644 public/docs/docs/packages/database/tables/table-class.md create mode 100644 public/docs/docs/packages/datastore/core-implementation.md create mode 100644 public/docs/docs/packages/datastore/integration-guide.md create mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md create mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md create mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-where.md create mode 100644 public/docs/docs/packages/datastore/interfaces/datastore.md create mode 100644 public/docs/docs/packages/datastore/interfaces/introduction.md create mode 100644 public/docs/docs/packages/datastore/introduction.md create mode 100644 public/docs/docs/packages/datastore/model-adapters.md create mode 100644 public/docs/docs/packages/datastore/traits/introduction.md create mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md create mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-decorator.md create mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md create mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md create mode 100644 public/docs/docs/packages/enum-polyfill/introduction.md create mode 100644 public/docs/docs/packages/enum-polyfill/traits/enum.md create mode 100644 public/docs/docs/packages/enum-polyfill/traits/introduction.md create mode 100644 public/docs/docs/packages/event/interfaces/action-binding-strategy.md create mode 100644 public/docs/docs/packages/event/interfaces/can-handle.md create mode 100644 public/docs/docs/packages/event/interfaces/event-strategy.md create mode 100644 public/docs/docs/packages/event/interfaces/event.md create mode 100644 public/docs/docs/packages/event/interfaces/has-event-bindings.md create mode 100644 public/docs/docs/packages/event/interfaces/has-listeners.md create mode 100644 public/docs/docs/packages/event/interfaces/introduction.md create mode 100644 public/docs/docs/packages/event/introduction.md create mode 100644 public/docs/docs/packages/event/patterns/best-practices.md create mode 100644 public/docs/docs/packages/logger/interfaces/introduction.md create mode 100644 public/docs/docs/packages/logger/interfaces/logger-strategy.md create mode 100644 public/docs/docs/packages/logger/introduction.md create mode 100644 public/docs/docs/packages/logger/traits/can-log-exception.md create mode 100644 public/docs/docs/packages/logger/traits/introduction.md create mode 100644 public/docs/docs/packages/mutator/interfaces/has-mutations.md create mode 100644 public/docs/docs/packages/mutator/interfaces/introduction.md create mode 100644 public/docs/docs/packages/mutator/interfaces/mutation-adapter.md create mode 100644 public/docs/docs/packages/mutator/interfaces/mutation-strategy.md create mode 100644 public/docs/docs/packages/mutator/interfaces/mutator-handler.md create mode 100644 public/docs/docs/packages/mutator/interfaces/mutator.md create mode 100644 public/docs/docs/packages/mutator/introduction.md create mode 100644 public/docs/docs/packages/mutator/traits/can-mutate-from-adapter.md create mode 100644 public/docs/docs/packages/mutator/traits/introduction.md create mode 100644 public/docs/docs/packages/rest/controllers.md create mode 100644 public/docs/docs/packages/rest/integration-guide.md create mode 100644 public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md create mode 100644 public/docs/docs/packages/rest/interceptors/introduction.md create mode 100644 public/docs/docs/packages/rest/introduction.md create mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md create mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md create mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md create mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md create mode 100644 public/docs/docs/packages/rest/middleware/introduction.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-any.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-email.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-numeric.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-type.md create mode 100644 public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md create mode 100644 public/docs/docs/packages/rest/validations/introduction.md create mode 100644 public/docs/docs/packages/rest/validations/validation-set.md create mode 100644 public/docs/docs/packages/singleton/introduction.md create mode 100644 public/docs/docs/packages/utils/array-helper.md create mode 100644 public/docs/docs/packages/utils/processors/array-processor.md create mode 100644 public/docs/docs/packages/utils/processors/list-filter.md create mode 100644 public/docs/docs/packages/utils/processors/list-sorter.md diff --git a/public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md b/public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md new file mode 100644 index 0000000..4043147 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md @@ -0,0 +1,192 @@ +# Advanced Bootstrapping Patterns + +When your application grows, you might need more sophisticated ways to organize how it starts up. This guide will show +you how to handle more complex bootstrapping scenarios in PHPNomad. + +## Breaking Things into Groups + +Just like you might organize your clothes into different drawers, you can organize your application's startup code into +logical groups. This makes everything easier to manage and understand. + +Here's a simple example: + +```php +// Database-related initialization +$databaseBootstrapper = new Bootstrapper( + $container, + new MySqlInitializer(), + new TableCreateInitializer(), + new MigrationInitializer() +); + +// Authentication-related initialization +$authBootstrapper = new Bootstrapper( + $container, + new UserInitializer(), + new PermissionInitializer(), + new SessionInitializer() +); + +// Run them in sequence +$databaseBootstrapper->load(); +$authBootstrapper->load(); +``` + +## Using Factories for Reusability + +Sometimes you'll want to reuse the same group of initializers in different places. Factories are a great way to package +up these groups so they're easy to reuse: + +```php +class DatabaseBootstrapperFactory +{ + protected Container $container; + + public function __construct(Container $container) + { + $this->container = $container; + } + + public function create(): Bootstrapper + { + return new Bootstrapper( + $this->container, + new MySqlInitializer(), + new TableCreateInitializer(), + new MigrationInitializer() + ); + } +} + +// Using the factory +$factory = new DatabaseBootstrapperFactory($container); +$bootstrapper = $factory->create(); +$bootstrapper->load(); +``` + +## Conditional Bootstrapping + +Sometimes you need different initialization based on your environment or other conditions. Here's how you might handle +that: + +```php +class ConditionalBootstrapper +{ + public function load(string $environment) + { + $container = new Container(); + + // Core bootstrapping that always runs + $core = new Bootstrapper( + $container, + new CoreInitializer(), + new ConfigInitializer() + ); + $core->load(); + + // Environment-specific bootstrapping + if ($environment === 'development') { + $dev = new Bootstrapper( + $container, + new DebugInitializer(), + new MockDataInitializer() + ); + $dev->load(); + } + + if ($environment === 'production') { + $prod = new Bootstrapper( + $container, + new CacheInitializer(), + new MonitoringInitializer() + ); + $prod->load(); + } + } +} +``` + +## Real-World Example: Plugin System + +Here's a practical example showing how you might bootstrap a plugin system: + +```php +class PluginBootstrapperFactory +{ + protected Container $container; + + public function __construct(Container $container) + { + $this->container = $container; + } + + public function createForPlugin(string $pluginName): Bootstrapper + { + // Core plugin bootstrapping + $bootstrapper = new Bootstrapper( + $this->container, + new PluginCoreInitializer($pluginName), + new PluginAssetsInitializer($pluginName) + ); + + // Add optional features based on plugin configuration + if ($this->hasDatabase($pluginName)) { + $bootstrapper = new Bootstrapper( + $this->container, + $bootstrapper, + new PluginDatabaseInitializer($pluginName) + ); + } + + if ($this->hasRestApi($pluginName)) { + $bootstrapper = new Bootstrapper( + $this->container, + $bootstrapper, + new PluginRestInitializer($pluginName) + ); + } + + return $bootstrapper; + } + + protected function hasDatabase(string $pluginName): bool + { + // Check if plugin needs database features + return true; // Simplified for example + } + + protected function hasRestApi(string $pluginName): bool + { + // Check if plugin needs REST API features + return true; // Simplified for example + } +} +``` + +## Tips for Complex Bootstrapping + +1. **Keep It Organized**: Group related initializers together. This makes your code easier to understand and maintain. + +2. **Use Clear Names**: Give your bootstrapper groups and factories names that clearly explain what they do. + +3. **Stay Flexible**: Design your bootstrapping code so it's easy to add or remove features without breaking things. + +4. **Think About Order**: Sometimes the order of initialization matters. Group your bootstrappers accordingly and + document any important ordering requirements. + +## Common Patterns to Avoid + +1. **Don't Mix Concerns**: Keep platform-specific code separate from your core bootstrapping logic. + +2. **Avoid Global State**: Don't rely on global variables or static properties for bootstrapping configuration. + +3. **Don't Over-Engineer**: Start simple and add complexity only when you need it. + +## Summary + +Advanced bootstrapping patterns help you manage complex applications while keeping your code organized and maintainable. +By using groups, factories, and conditional loading, you can create a flexible system that grows with your needs while +staying clean and understandable. + +Remember: The goal is to make your initialization process clear and maintainable, not to make it clever or complex. When +in doubt, choose the simpler approach. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md b/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md new file mode 100644 index 0000000..1caf345 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md @@ -0,0 +1,292 @@ +# Creating and Managing Initializers + +Initializers are the building blocks of a PHPNomad application. Think of them as specialized workers, each with a +specific job to do when your application starts up. Each initializer handles one aspect of getting your application +ready to run - whether that's setting up database connections, loading configuration files, or connecting to external +services. + +## What Makes Up an Initializer? + +At its simplest, an initializer is a PHP class that implements one or more special interfaces. These interfaces tell +PHPNomad what the application can do: + +```php +class MyInitializer implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + // Define your class bindings here + MyConcreteClass::class => MyInterface::class + ]; + } +} +``` + +### Core Interfaces + +PHPNomad supports several interfaces that give initializers different capabilities: + +- `HasClassDefinitions`: For binding interfaces to concrete implementations +- `HasEventBindings`: For setting up event listeners and transformers +- `HasListeners`: For registering event listeners +- `Loadable`: For running code during initialization +- `CanSetContainer`: Gives your initializer access to the dependency container + +## Types of Initializers + +### Shared Initializers + +These initializers contain core business logic that works anywhere. They should never know about specific platforms - +their job is to set up the fundamental pieces of your application: + +```php +class EmailServiceInitializer implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + // Notice how this binds generic email interfaces + // with no knowledge of any specific platform + SmtpMailer::class => EmailStrategy::class, + EmailTemplateEngine::class => TemplateRenderer::class + ]; + } +} +``` + +### Platform Integration Initializers + +These initializers handle adapting your application for specific platforms. Instead of adding platform checks to our +shared initializers, we create separate initializers that focus solely on platform integration: + +```php +class WordPressEmailIntegration implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + // This initializer handles WordPress-specific bindings + WordPressMailer::class => EmailStrategy::class + ]; + } +} +``` + +### Event-Driven Initializers + +Many initializers work with events to set up listeners and bindings: + +```php +class UserEventsInitializer implements HasEventBindings +{ + public function getEventBindings(): array + { + return [ + UserCreated::class => [ + 'user_register', + ['transformer' => function($userId) { + return new UserCreated(new User($userId)); + }] + ] + ]; + } +} +``` + +### REST Initializers + +For applications exposing REST APIs, initializers can register controllers: + +```php +class ApiInitializer implements HasControllers +{ + public function getControllers(): array + { + return [ + UserController::class, + ProductController::class + ]; + } +} +``` + +## Best Practices + +### Keep It Focused + +Each initializer should do one thing and do it well. If you find your initializer handling multiple unrelated tasks, +it's time to split it up. + +Good: + +```php +class EmailServiceInitializer implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + SmtpMailer::class => EmailService::class + ]; + } +} +``` + +Not so good: + +```php +class ServiceInitializer implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + SmtpMailer::class => EmailService::class, + MySqlDatabase::class => Database::class, + RedisCache::class => CacheService::class + // Too many unrelated services! + ]; + } +} +``` + +### Handle Dependencies Wisely + +Let the container manage dependencies instead of creating them directly: + +```php +class UserServiceInitializer implements Loadable, CanSetContainer +{ + use HasSettableContainer; + + public function load(): void + { + // Let the container provide the dependency + $userService = $this->container->get(UserService::class); + $userService->initialize(); + } +} +``` + +## Common Patterns + +### Setting Up Services + +Register your services with the container: + +```php +class ServiceInitializer implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + // Single binding + ConcreteService::class => ServiceInterface::class, + + // Multiple interfaces + DatabaseService::class => [ + QueryInterface::class, + ConnectionInterface::class + ] + ]; + } +} +``` + +### Event Listeners + +Set up event listeners in your initializer: + +```php +class EventInitializer implements HasListeners +{ + public function getListeners(): array + { + return [ + UserCreated::class => UserCreatedHandler::class, + OrderPlaced::class => [ + SendOrderConfirmation::class, + UpdateInventory::class + ] + ]; + } +} +``` + +### Mutations + +Register mutation handlers for transforming data: + +```php +class DataMutationInitializer implements HasMutations +{ + public function getMutations(): array + { + return [ + UserData::class => [ + 'sanitize_user_input', + 'validate_user_data' + ] + ]; + } +} +``` + +## Things to Avoid + +1. **Don't Mix Platform-Specific Code**: Keep your shared initializers platform-agnostic. +2. **Avoid Direct Platform Dependencies**: Use interfaces instead of concrete platform classes. +3. **Don't Overuse Loadable::load**: Save it for truly necessary initialization tasks. +4. **Keep Initializers Small**: If an initializer is doing too much, split it up. + +## Real-World Example + +Here's a complete example of setting up authentication in a way that works across platforms: + +```php +// Core authentication setup +class AuthenticationInitializer implements HasClassDefinitions, Loadable, CanSetContainer +{ + use HasSettableContainer; + + public function getClassDefinitions(): array + { + return [ + // Core authentication services + AuthenticationService::class => AuthenticationInterface::class, + TokenGenerator::class => TokenGeneratorInterface::class, + + // Multiple interface bindings + UserRepository::class => [ + UserRepositoryInterface::class, + IdentityStoreInterface::class + ] + ]; + } + + public function load(): void + { + // One-time setup tasks + $authService = $this->container->get(AuthenticationInterface::class); + $authService->initialize(); + } +} + +// WordPress-specific authentication integration +class WordPressAuthIntegration implements HasClassDefinitions +{ + public function getClassDefinitions(): array + { + return [ + WordPressAuthProvider::class => AuthProviderInterface::class + ]; + } +} +``` + +Remember, good initializers are like good tools - they do one job well, work reliably, and make your life easier. By +following these patterns and practices, you'll build a foundation that's easy to maintain and adapt as your needs +change. + +The key to success with PHPNomad initializers is maintaining a clear separation between your core business logic and +platform-specific integrations. This separation allows your application to remain truly portable while still integrating +smoothly with any platform you need to support. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md b/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md new file mode 100644 index 0000000..4c67675 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md @@ -0,0 +1,251 @@ +# 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 +them as translators that help your code communicate with different platforms (like WordPress, Laravel, or your own +custom system) without needing to know their specific "language". + +## Why Use Event Bindings? + +The beauty of event bindings is that they keep your core application code clean and portable. Instead of embedding +platform-specific code throughout your application, you create a single place where your system's events connect to the +platform. + +For example, let's say you have an e-commerce application and want to track when someone uses a coupon code. Rather than +spreading WordPress-specific code everywhere, you can use event bindings to cleanly connect WordPress's coupon events to +your system. + +## How Event Bindings Work + +Event bindings consist of three main parts: + +1. Your application's event (like `CouponApplied` or `ReportCreated`) +2. The platform's action or event to listen for +3. A transformer that converts the platform's data into your application's event + +Here's a basic example: + +```php +class WooCommerceIntegration implements HasEventBindings +{ + public function getEventBindings(): array + { + return [ + // Your event => Platform event configuration + CouponApplied::class => [ + [ + 'action' => 'woocommerce_applied_coupon', + 'transformer' => function($couponCode) { + return new CouponApplied($couponCode); + } + ] + ] + ]; + } +} +``` + +In this example: + +- `CouponApplied` is your application's event +- `woocommerce_applied_coupon` is WooCommerce's action +- The transformer function converts WooCommerce's coupon code into your `CouponApplied` event + +## Advanced Usage: Multiple Bindings + +Sometimes you might want to listen for the same event from different sources. You can do this by returning an array of +bindings: + +```php +public function getEventBindings(): array +{ + return [ + OrderCreated::class => [ + // Listen for WooCommerce orders + [ + 'action' => 'woocommerce_order_status_completed', + 'transformer' => [$this->wooCommerceTransformer, 'toOrderCreated'] + ], + // Also listen for Easy Digital Downloads orders + [ + 'action' => 'edd_complete_purchase', + 'transformer' => [$this->eddTransformer, 'toOrderCreated'] + ] + ] + ]; +} +``` + +## Working with Transformers + +Transformers are functions that: + +1. Receive data from the platform's event +2. Convert that data into your application's format +3. Return either your event object or null + +If a transformer returns null, no event is triggered. This is useful for conditional events: + +```php +'transformer' => function($post_id, $post) { + // Only create event for published posts + if ($post->post_status !== 'publish') { + return null; + } + + return new PostPublished($post_id); +} +``` + +## Best Practices + +1. **Keep Transformers Clean**: Transformers should focus only on converting data. Heavy processing should happen + elsewhere. + +2. **Use Service Classes**: For complex transformations, create dedicated service classes instead of inline functions: + +```php +'transformer' => [$this->postTransformerService, 'toPostEvent'] +``` + +3. **Handle Errors Gracefully**: Transformers should handle missing or invalid data without crashing: + +```php +'transformer' => function($data) { + try { + return new MyEvent($data); + } catch (Exception $e) { + // Log error, return null to skip event + return null; + } +} +``` + +4. **Document Platform Events**: Always document which platform events you're binding to and what data they provide: + +```php +// Binds to 'save_post' WordPress action +// @param int $post_id The ID of the saved post +// @param WP_Post $post The post object +// @param bool $update Whether this is an update +``` + +## Real-World Example: Reports System + +Here's a complete example showing how to bind a reports system to WordPress: + +```php +class WordPressReportingIntegration implements HasEventBindings +{ + private ReportTransformer $transformer; + + public function __construct(ReportTransformer $transformer) + { + $this->transformer = $transformer; + } + + public function getEventBindings(): array + { + return [ + // Handle new reports + ReportCreated::class => [ + [ + 'action' => 'save_post', + 'transformer' => function($postId, $post, $update) { + // Only handle new reports + if ($update || $post->post_type !== 'report') { + return null; + } + + return $this->transformer->toReportCreated($post); + } + ] + ], + + // Handle report updates + ReportUpdated::class => [ + [ + 'action' => 'save_post', + 'transformer' => function($postId, $post, $update) { + // Only handle report updates + if (!$update || $post->post_type !== 'report') { + return null; + } + + return $this->transformer->toReportUpdated($post); + } + ] + ] + ]; + } +} +``` + +The beauty of this approach is that your internal application logic doesn't need to know about how events are emitted, +it just needs to listen for the nomadic events and it can do the actions from there. So if you needed to send an email +when a report is created, you could create an [event listener](event-listeners) in your a platform-agnostic +initializer that would fire when the report is published. + +In doing so, you've completely decoupled the platform from your application - as long as the platform knows how to emit +the right events, and can translate them appropriately, it's compatible with your system. + +Notice that the code below does not call any WordPress code whatsoever, and yet because of our event binding, we know +it will run when the report post is published. If we put this on a different platform, it's just a matter of sending +the `ReportCreated` event at the right time, and this would just work. + +```php +class SendReportToCustomer implements CanHandle +{ + public function __construct(protected EmailStrategy $emailer){} + + public function handle(Event $event): void + { + $this->emailer->send(/** Include email details here. */) + } +} + +class ApplicationInitializer implements HasListeners +{ + public function getListeners(): void + { + return [ + ReportCreated::class => SendReportToCustomer::class + ]; + } +} +``` + +## Summary + +Event bindings are a powerful way to keep your application's core logic clean while still integrating smoothly with any +platform. They act as a bridge between your application and the platform, translating events back and forth without +cluttering your main code with platform-specific details. + +Remember: + +- Keep transformers simple and focused +- Handle errors gracefully +- 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. + +--- + +## 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/docs/core-concepts/bootstrapping/initializers/event-listeners.md b/public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md new file mode 100644 index 0000000..ee5e20a --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md @@ -0,0 +1,274 @@ +# 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. + +Events are the bread and butter of PHPNomad, and it leans heavily on using them to keep your system decoupled from the +platform. In-fact, most of the time, you'll find that your entire system will boil down to a series of events that fire, +and then some logic that happens when those actions happen. If you can get very comfortable with thinking about +event-driven approaches, you'll come to love how PHPNomad works. + +## The Basics of Event Listeners + +At their core, event listeners are just special classes that "listen" for specific events in your application. When +those events happen, the listeners automatically run their code. Each listener is instantiated through PHPNomad's +container, which means you can easily inject any dependencies you need. + +Here's a simple example: + +```php +use PHPNomad\Email\Interfaces\EmailStrategy; +use PHPNomad\Events\Interfaces\Event; + +class UserRegistered implements Event +{ + public function __construct(public readonly string $email, public readonly string $name){} + + + public static function getId(): string + { + return 'user_registered'; + } +} + +/** + * @extends CanHandle + */ +class WelcomeEmailListener implements CanHandle +{ + public function __construct(private EmailStrategy $emailService) + { + // PHPNomad automatically injects the email service + } + + public function handle(Event $event): void + { + // Send a welcome email when a new user registers + $this->emailService->send( + [$event->email], + 'Welcome to Our Platform!', + 'welcome-email-template', + ['username' => $event->name] + ); + } +} +``` + +## Setting Up Listeners + +You can set up listeners in your initializer class. This is where you tell PHPNomad "when this happens, run this code": + +```php +class MyInitializer implements HasListeners +{ + public function getListeners(): array + { + return [ + // When a user gets registered, send a welcome email. + UserRegistered::class => WelcomeEmailListener::class, + + // When an order is placed, send the order confirmation and also update the inventory + OrderPlaced::class => [ + SendOrderConfirmation::class, + UpdateInventory::class + ] + ]; + } +} +``` + +In this example: + +- When a user registers, the container creates a WelcomeEmailListener (injecting the EmailStrategy) and runs its handle + method +- When an order is placed, multiple listeners are created and executed in sequence + +Now, when you broadcast the `UserRegistered` event, the `WelcomeEmailListener` will fire. + +```php +Event::broadcast(new UserRegistered('alex@fake.email','Alex')); +``` + +Alternatively, you might need to create an event binding that translates a platform event (such as a WordPress hook) to +the `UserRegistered` event. Check out [Event Binding](event-binding) for more context on that. + +## Why Use Event Listeners? + +Event listeners help keep your code organized and flexible. Instead of putting all your logic in one place, you can +spread it out into focused, manageable pieces. This brings several benefits: + +1. **Easier to Maintain**: Each listener handles one specific task, making the code simpler to understand and update +2. **More Flexible**: You can add or remove listeners without changing the rest of your code +3. **Better Organization**: Related code stays together, making it easier to find and modify +4. **Dependency Management**: PHPNomad's container handles all dependencies automatically + +## Real World Example + +Let's look at a practical example. Imagine you're running an online store and someone places an order: + +```php +use PHPNomad\Email\Interfaces\EmailStrategy; + +/** + * @extends CanHandle + */ +class SendOrderConfirmation implements CanHandle +{ + public function __construct( + private EmailStrategy $emailService + ) {} + + public function handle(Event $event): void + { + $order = $event->getOrder(); + + $this->emailService->send( + [$order->getCustomerEmail()], + 'Order Confirmation', + 'order-confirmation-template', + ['orderNumber' => $order->getId()] + ); + } +} + +/** + * @extends CanHandle + */ +class UpdateInventory implements CanHandle +{ + public function __construct( + private InventoryService $inventory + ) { + // Dependencies are injected automatically + } + + public function handle(Event $event): void + { + $order = $event->getOrder(); + + // Update inventory + $this->inventory->reduceStock($order->getItems()); + } +} +``` + +## Best Practices + +1. **Use Constructor Injection**: Let PHPNomad's container manage your dependencies by declaring them in your + constructor: + +```php +class WelcomeEmailListener implements CanHandle +{ + public function __construct( + private EmailStrategy $emailService, + private LoggerStrategy $logger + ) { + } + + public function handle(Event $event): void + { + try { + $this->emailService->send( + [$event->getUser()->getEmail()], + 'Welcome!', + 'welcome-template', + ['username' => $event->getUser()->getName()] + ); + } catch (Exception $e) { + $this->logger->error("Failed to send welcome email: " . $e->getMessage()); + } + } +} +``` + +2. **Keep Listeners Focused**: Each listener should do one job well. If you find a listener doing too many things, + consider splitting it into multiple listeners. + +3. **Use Interfaces**: Always type-hint interfaces rather than concrete classes in your constructor. This keeps your + code flexible and testable. + +## Common Scenarios + +Here are some typical situations where event listeners are particularly useful: + +1. **User Actions**: + +```php +class UserProfileCreationListener implements CanHandle +{ + public function __construct( + private ProfileService $profiles, + private LoggerStrategy $logger + ) { + } + + public function handle(Event $event): void + { + $this->profiles->createDefaultProfile($event->getUser()); + } +} +``` + +2. **Order Processing**: + +```php +class OrderPaymentListener implements CanHandle +{ + public function __construct( + private PaymentService $payments, + private LoggerStrategy $logger + ) { + } + + public function handle(Event $event): void + { + $this->payments->processPayment($event->getOrder()); + } +} +``` + +## Troubleshooting + +If your listeners aren't working as expected, check these common issues: + +1. **Is the Event Being Triggered?** + Make sure the event is actually being broadcast: + ```php + Event::broadcast(new UserRegistered($user)); + ``` + +2. **Is the Listener Registered?** + Verify your listener is properly registered in your initializer. + +## Conclusion + +Event listeners in PHPNomad provide a clean, maintainable way to handle application events while taking full advantage +of dependency injection. By letting the container manage your dependencies, you create code that's not only easier to +maintain but also easier to test and modify. Each listener can focus on its specific task while having easy access to +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. + +--- + +## 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/docs/core-concepts/bootstrapping/initializers/facades.md b/public/docs/docs/core-concepts/bootstrapping/initializers/facades.md new file mode 100644 index 0000000..2b5e8ef --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/initializers/facades.md @@ -0,0 +1,301 @@ +# Creating and Using Facades in PHPNomad + +While the Facade pattern was popularized by Laravel, PHPNomad's implementation takes a slightly different approach that +aligns with its platform-agnostic philosophy. Instead of being deeply integrated with a service container, PHPNomad's +Facades act as singleton wrappers that make your services easier to use across different platforms. + +## What is a Facade? + +Think of a Facade as a simple front door to a complex building. Instead of navigating through all the rooms and +hallways, you just walk through the front door to get where you need to go. In code terms, a Facade provides a clean, +static interface to access functionality that might otherwise require more complex setup or dependency management. + +## Creating Your First Facade + +Here's how to create a basic Facade: + +```php +use PHPNomad\Facade\Abstracts\Facade; +use PHPNomad\Singleton\Traits\WithInstance; + +/** + * @method static void info(string $message) + * @method static void error(string $message, array $context = []) + */ +class Logger extends Facade +{ + use WithInstance; // Important! This makes the Facade a singleton + + protected function abstractInstance(): string + { + return LoggerStrategy::class; + } +} +``` + +## Using Your Facade + +Once created, using your Facade is straightforward: + +```php +// No need to instantiate - just use it! +Logger::instance()->getContainedInstance()->info("Hello from PHPNomad!"); + +// Or better yet, add static methods to make it cleaner: +Logger::info("Hello from PHPNomad!"); +``` + +## Key Differences from Laravel + +While inspired by Laravel's Facades, PHPNomad's implementation has some key differences: + +1. **Singleton Pattern**: PHPNomad Facades use the `WithInstance` trait to ensure only one instance exists +2. **Platform Independence**: PHPNomad's Facades are designed to work across any PHP platform +3. **Explicit Methods**: Instead of magic methods, PHPNomad encourages explicitly defining the methods you want to + expose + +## Registering Facades + +Facades need to be registered with PHPNomad through an initializer: + +```php +use PHPNomad\Di\Interfaces\CanSetContainer; +use PHPNomad\Di\Traits\HasSettableContainer; +use PHPNomad\Facade\Interfaces\HasFacades; + +class CoreInitializer implements HasFacades, HasClassDefinitions, CanSetContainer +{ + use HasSettableContainer; + + public function getFacades(): array + { + return [ + Logger::instance(), // Note the instance() call + Cache::instance(), + Event::instance() + ]; + } + + public function getClassDefinitions(): array + { + return [ + LogService::class => LoggerStrategy::class, + RedisCache::class => CacheStrategy::class, + EventDispatcher::class => EventStrategy::class + ]; + } +} +``` + +## A Complete Example + +Here's a complete example showing how to create and use a Cache Facade: + +```php +/** + * 1. Create the Facade + */ +class Cache extends Facade +{ + use WithInstance; + + protected function abstractInstance(): string + { + return CacheStrategy::class; + } + + public static function get(string $key): mixed + { + return static::instance()->getContainedInstance()->get($key); + } + + public static function set(string $key, mixed $value, ?int $ttl = null): void + { + static::instance()->getContainedInstance()->set($key, $value, $ttl); + } + + public static function has(string $key): bool + { + return static::instance()->getContainedInstance()->has($key); + } +} + +/** + * 2. Create the Initializer + */ +class CacheInitializer implements HasFacades, HasClassDefinitions, CanSetContainer +{ + use HasSettableContainer; + + public function getFacades(): array + { + return [ + Cache::instance() // Single instance + ]; + } + + public function getClassDefinitions(): array + { + return [ + RedisCache::class => CacheStrategy::class + ]; + } +} + +/** + * 3. Bootstrap your application + */ +$container = new Container(); +$bootstrapper = new Bootstrapper( + $container, + new CacheInitializer() +); +$bootstrapper->load(); + +/** + * 4. Use the Facade anywhere in your code + */ +Cache::set('my-key', 'my-value', 3600); +$value = Cache::get('my-key'); +``` + +## ⚠️ Important Warning About Facades + +Before diving into how to create and use Facades, it's crucial to understand their intended purpose and limitations: +Facades in PHPNomad are primarily designed for: + +* Creating public APIs that third-party developers can easily consume +* Providing simple access points for developers integrating with your application from outside the PHPNomad context +* Situations where dependency injection isn't practical (like static WordPress hooks or template files) + +They are not intended for: + +* Regular application development within your PHPNomad codebase +* Replacing proper dependency injection +* Avoiding proper service architecture + +If you find yourself frequently using Facades within your own application code, this is usually a sign that you should +reconsider your approach. Instead, use dependency injection and proper service architecture for better maintainability, +testability, and cleaner code design. + +Example of Proper vs. Improper Use +```php +// 🚫 Don't do this in your application code +class UserService { + public function createUser(array $data) { + Logger::info("Creating user..."); // Directly using Facade + // ... rest of the code + } +} + +// ✅ Do this instead +class UserService { + private LoggerStrategy $logger; + + public function __construct(LoggerStrategy $logger) { + $this->logger = $logger; // Proper dependency injection + } + + public function createUser(array $data) { + $this->logger->info("Creating user..."); + // ... rest of the code + } +} + +// ✅ Facades are great for third-party integration points +add_action('init', function() { + Logger::info("WordPress integration point"); +}); +``` + +## Best Practices + +### 1. Always Use WithInstance + +Every Facade must use the `WithInstance` trait to ensure the singleton pattern: + +```php +class AnyFacade extends Facade +{ + use WithInstance; // Don't forget this! +} +``` + +### 2. Keep Facades Focused + +Each Facade should represent a single service: + +```php +// Good - focused on caching +class Cache extends Facade +{ + use WithInstance; + + protected function abstractInstance(): string + { + return CacheStrategy::class; + } +} + +// Not good - mixing concerns +class Utilities extends Facade +{ + use WithInstance; + + protected function abstractInstance(): string + { + return UtilityService::class; // Too many responsibilities + } +} +``` + +### 3. Group Related Facades in Initializers + +Keep related Facades together in their initializers: + +```php +class DatabaseInitializer implements HasFacades +{ + public function getFacades(): array + { + return [ + Query::instance(), + Schema::instance(), + Migration::instance() + ]; + } +} +``` + +## When to Use Facades + +Facades are great for: + +- Services that need global access +- Core functionality used throughout your application +- Cross-cutting concerns like logging, caching, and events + +Consider alternatives when: + +- The service requires complex configuration +- You're writing unit tests (use dependency injection) +- The functionality is specific to a single module + +## Common Pitfalls to Avoid + +1. **Forgetting WithInstance**: Always include the `WithInstance` trait in your Facades +2. **Overusing Facades**: Not everything needs to be a Facade +3. **Complex Logic in Facades**: Keep Facade methods simple and delegate complex logic to the service +4. **Bypassing Dependency Injection**: Use Facades for convenience, not to avoid proper dependency management + +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. + +--- + +## 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/docs/core-concepts/bootstrapping/initializers/rest-controllers.md b/public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md new file mode 100644 index 0000000..1bfb5c3 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md @@ -0,0 +1,90 @@ +# REST Controllers in PHPNomad + +REST controllers define an **endpoint**, an **HTTP method**, and **how to produce a response**—and they stay portable +across hosts. + +Instead of registering routes inline, PHPNomad discovers them through **Initializers** that implement **`HasControllers` +**. This keeps your API definitions decoupled from the host and mirrors how you already register event listeners +with `HasListeners`. + +## The Basics: How registration works + +* **Controller**: a small class implementing, at minimum, `Controller` +* **Initializer** implementing `HasControllers`: returns a list of controllers to load. +* **Boostrapper** configured to utilize the initializer. + See [Creating and Managing Initializers](/core-concepts/bootstrapping/creating-and-managing-initializers) for more + information + +The container will construct these controllers (resolving dependencies), and the runtime will register their routes from +the controller contracts. + +## Minimal Controller + +This is a very basic example of a controller, and does not include middleware or validations. In your production +controllers, you’ll often also implement `HasMiddleware` / `HasValidations`. See real-world examples +where `getEndpoint()`, `getMethod()`, and `getValidations()` are used together. + +See the [Rest](/packages/rest/introduction) package documentation for fuller examples. + +```php +response + ->setStatus(200) + ->setJson(['message' => 'Hello from PHPNomad REST']); + } +} +``` + +## Registering Controllers via an Initializer + +Your initializer simply **implements `HasControllers`** and returns the controllers to load. +The container instantiates them; the runtime reads `getEndpoint()` / `getMethod()` to wire routes. + +```php +|Controller> + */ + public function getControllers(): array + { + return [ + HelloController::class, + // CreateWidget::class, + // GetWidget::class, + // ListWidgets::class, + ]; + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/introduction.md b/public/docs/docs/core-concepts/bootstrapping/introduction.md new file mode 100644 index 0000000..1eb4697 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/introduction.md @@ -0,0 +1,62 @@ +# Introduction to PHPNomad Bootstrapping + +The bootstrapper is where everything begins in a PHPNomad application. It's responsible for: + +- Setting up your application +- Loading all the pieces in the right order +- Making sure everything can talk to each other + +Here's what it looks like in its simplest form: + +```php +$container = new Container(); +$bootstrapper = new Bootstrapper( + $container, + new MyAppSetup(), // A custom initializer created for your application + new DatabaseSetup(), // An initializer that ensures the system knows how to query data + // Other initializers loaded here as needed, such as a WordPress integration, or various Symfony implementations. +); +$bootstrapper->load(); +``` + +## Key Ideas Behind PHPNomad + +### Your Code Comes First + +Instead of building your code to fit into a specific platform or system, PHPNomad lets you build your application the +way you want. Then, the platform adapts to work with your code. This makes it much easier if you ever need to move your +code to a different system. + +### Breaking Things into Pieces + +Rather than putting all your setup code in one place, PHPNomad encourages you to break it into smaller, focused pieces +called initializers. Each initializer has one job, making your code easier to understand and change. + +### Keeping Things Separate + +Your application's core logic stays separate from platform-specific code. This means you can change how your application +works with different platforms without having to rewrite your main application code. + +## How Setup Works + +When you start your application: + +1. First, PHPNomad creates a central hub (we call it a container) that helps different parts of your code find each + other +2. Then, it runs through your setup steps one by one +3. Finally, it locks everything down so nothing can accidentally change how things are connected + +This process helps keep your application stable and predictable. + +## Built for Change + +PHPNomad is designed to make your code flexible: + +- Your core application code doesn't need to know about the platform it's running on +- Platform-specific code is kept separate and can be easily changed +- You can move your application between different systems without rewriting everything + +This means you can start building for one platform today, but keep your options open for the future. Most of your code +will work just fine if you decide to move it somewhere else later. + +You're not locked into one way of doing things - and that's the whole point of PHPNomad. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md b/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md new file mode 100644 index 0000000..f044bf5 --- /dev/null +++ b/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md @@ -0,0 +1,140 @@ +# Introduction to Platform-Specific Integrations + +When building applications with PHPNomad, you might need to integrate with platforms like WordPress, Laravel, or +Symfony. These platforms often come with unique requirements, but PHPNomad’s philosophy ensures your core logic remains +flexible and portable. The goal isn’t to lock your application into a specific platform but to allow your application to +adapt seamlessly, as if it’s just visiting. + +PHPNomad takes a "nomadic" approach: instead of the platform dictating how your application is built, your application +integrates with the platform through lightweight, modular adapters. This perspective keeps your application’s core logic +clean and reusable, whether you’re bootstrapping a WordPress plugin or setting up a Laravel service provider, or maybe +even building your own homegrown MVC system that uses PHPNomad. + +## Core Principles for Platform-Specific Integrations + +To make integrations easy and maintainable, PHPNomad emphasizes separating shared application logic from +platform-specific details. A shared initializer handles logic that works across platforms, while a platform-specific +initializer adapts to the unique needs of a given environment. + +## Common Use Cases + +A typical integration involves adapting the application’s bootstrapping process to a platform’s specific entry points. +For WordPress, this might include setting up actions and filters, or binding WordPress actions with events that are +inside your application. + +```php + +class WordPressSystemInitializer implements HasEventBindings, HasListeners +{ + /** + * Bind WordPress hooks to trigger Ready event. + * + * @return array + */ + public function getEventBindings(): array + { + return [ + // Bind WordPress 'init' hook to trigger the Ready event. + Ready::class => [ + ['action' => 'init', 'transformer' => function () { + $ready = null; + + if (!self::$initRan) { + $ready = new Ready(); + self::$initRan = true; + } + + return $ready; + }] + ], + ]; + } + + /** + * Register the listener for the Ready event. + * + * @return array + */ + public function getListeners(): array + { + return [ + // Instruct the system to register custom post types using a custom RegisterCustomPostTypes::class handler. + Ready::class => [RegisterCustomPostTypes::class] + ]; + } +} +``` + +The plugin might look like: + +```php +/** + * Plugin Name: Demo Plugin + */ + +require_once plugin_dir_url(__FILE__, 'vendor/autoload.php'); + +$bootstrapper = new Bootstrapper($container, [ + new WordPressInitializer(), // Provided by PHPNomad, making most functionality to work in WordPress context. + new ConfigInitializer(['config.json']), + new WordPressSystemInitializer(), + new ApplicationInitializer() // Shared application logic that's shared between platforms. +]); + +// Run the bootstrapper to load all initializers. +$bootstrapper->load(); +``` + +In a homegrown setup, you might not need the binding since you have control of the entire request, and can probably just +emit the event after the system is set up. + +```php +// index.php + +require_once 'vendor/autoload.php'; + +// Set up the container and initialize the bootstrapper. +$container = new Container(); +$bootstrapper = new Bootstrapper($container, [ + new ConfigInitializer(['config.json']), + new MySqlDatabaseInitializer(), + new RestApiInitializer(), + new SymfonyEventInitializer(), + new HomegrownSystemInitializer(), + new ApplicationInitializer() // Business logic that's shared between platforms. +]); + +// Run the bootstrapper to load all initializers. +$bootstrapper->load(); + +// Broadcast the Ready event directly. +Event::broadcast(new Ready()); +``` + +These integrations allow the application to interface with the platform without embedding platform-specific details +directly into the shared logic. + +## Challenges and Best Practices + +One common pitfall in platform-specific integrations is allowing platform logic to creep into shared initializers. For +instance, a shared initializer should never directly reference platform-specific declarations. Keeping +these concerns separate ensures that shared initializers remain reusable across different contexts. + +If you’re debugging a platform-specific initializer, make sure the problem isn’t due to the platform’s lifecycle or API. +For example, in WordPress, actions and filters may need to be registered at a specific point during initialization. + +For more information on this, check out [Event Bindings](/core-concepts/bootstrapping/initializers/event-binding) + +## Expanding Integrations + +PHPNomad encourages you to think modularly when creating platform-specific initializers. By keeping your logic +well-organized, you’ll make it easy to reuse these initializers across projects. For example, if you’ve written a +WordPress initializer for handling custom post types, consider extracting it into a standalone class or package that can +be dropped into any future WordPress project. + +Moreover, by sticking to PHPNomad’s philosophy of platform-agnostic design, your integrations will naturally be +future-proof. As platforms evolve, you can update your initializers without rewriting your application’s core logic. + +Integrating with platforms doesn’t have to mean sacrificing flexibility. With PHPNomad, you can build applications that +are adaptable, maintainable, and ready to travel wherever your project leads. By keeping platform logic modular and +separate from your core logic, you ensure your applications remain as free-spirited as the PHPNomad philosophy itself. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md b/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md new file mode 100644 index 0000000..61589c6 --- /dev/null +++ b/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md @@ -0,0 +1,818 @@ +# Getting Started: Your First Datastore + +This tutorial guides you through creating a complete database-backed datastore for a `Post` entity in PHPNomad. +You will build all the components required by the datastore pattern: the model, adapter, Core interfaces and +implementation, database table definition, database handler, and dependency injection registration. + +By the end, you will have a working datastore that can create, read, update, delete, and query blog posts stored in a database. + +--- + +## Prerequisites + +Before starting, ensure you have: + +- PHPNomad framework installed and configured +- A database configured and accessible (MySQL, MariaDB, or compatible) +- Basic understanding of PHP interfaces and dependency injection +- Familiarity with the [datastore architecture concepts](overview-and-architecture) + +--- + +## Directory structure + +Create the following directory structure for your Post datastore: + +``` +Blog/ +├── Core/ +│ ├── Models/ +│ │ ├── Post.php +│ │ └── Adapters/ +│ │ └── PostAdapter.php +│ └── Datastores/ +│ └── Post/ +│ ├── Interfaces/ +│ │ ├── PostDatastore.php +│ │ └── PostDatastoreHandler.php +│ └── PostDatastore.php +└── Service/ + └── Datastores/ + └── Post/ + ├── PostDatabaseDatastoreHandler.php + └── PostsTable.php +``` + +This structure separates Core (business logic) from Service (implementation details), which is fundamental to the +datastore pattern. + +--- + +## Step 1: Define your model + +Models represent domain entities as immutable value objects. They contain data and behavior but no persistence logic. + +Create `Blog/Core/Models/Post.php`: + +```php +id = $id; + $this->createdDate = $createdDate; + } +} +``` + +**Key points**: +- `HasSingleIntIdentity` provides the `getId()` method and identity tracking +- `WithSingleIntIdentity` trait implements the identity interface +- `WithCreatedDate` provides automatic timestamp tracking +- Properties use `public readonly` for immutability and direct access +- `id` and `createdDate` are managed by traits, other properties use constructor promotion + +For detailed information about model identity patterns and traits, see [Models and Identity](models-and-identity). + +--- + +## Step 2: Create the model adapter + +Adapters convert between models and storage representations (arrays). The database handler uses adapters to transform +database rows into models and vice versa. + +Create `Blog/Core/Models/Adapters/PostAdapter.php`: + +```php +dateFormatterService->getDateTime( + Arr::get($array, 'publishedDate') + ), + createdDate: $this->dateFormatterService->getDateTimeOrNull( + Arr::get($array, 'createdDate') + ) + ); + } + + public function toArray(DataModel $model): array + { + /** @var Post $model */ + return [ + 'id' => $model->getId(), + 'title' => $model->title, + 'content' => $model->content, + 'authorId' => $model->authorId, + 'publishedDate' => $this->dateFormatterService->getDateString( + $model->publishedDate + ), + 'createdDate' => $this->dateFormatterService->getDateStringOrNull( + $model->getCreatedDate() + ), + ]; + } +} +``` + +**Key points**: + +- `DateFormatterService` handles DateTime conversion to/from database format +- `Arr::get()` safely retrieves values from arrays +- `toModel()` converts database rows (arrays) to Post objects +- `toArray()` converts Post objects to database-compatible arrays +- Type casting ensures data integrity + +--- + +## Step 3: Define Core datastore interfaces + +Core interfaces declare what operations are possible without specifying how they work. + +### PostDatastore interface + +Create `Blog/Core/Datastores/Post/Interfaces/PostDatastore.php`: + +```php +datastoreHandler = $datastoreHandler; + } + + public function getPublishedPosts(): array + { + return $this->datastoreHandler->andWhere([ + ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] + ]); + } + + public function getByAuthor(int $authorId): array + { + return $this->datastoreHandler->andWhere([ + ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] + ]); + } +} +``` + +**Why decorator traits?** + +The decorator traits (`WithDatastoreDecorator`, `WithDatastorePrimaryKeyDecorator`, etc.) automatically delegate +standard operations like `create()`, `find()`, `update()`, and `delete()` to the `$datastoreHandler`. This eliminates +boilerplate code and lets you focus only on custom business methods like `getPublishedPosts()`. + +Without these traits, you would need to manually write delegation methods: + +```php +public function find(int $id): Post +{ + return $this->datastoreHandler->find($id); +} + +public function create(array $attributes): Post +{ + return $this->datastoreHandler->create($attributes); +} +// ... and many more +``` + +The traits handle all of this automatically, keeping your datastore implementation clean and focused on business logic. + +For detailed information about decorator patterns, see [Core Implementation](../packages/datastore/core-implementation). + +--- + +## Step 5: Define the database table schema + +Now that your datastore interfaces and Core implementation are complete, you need to define how data will be stored. Since we're building a database-backed datastore, we need to create a table schema that defines the database structure for storing posts. + +Table classes define the database schema for your entity, including columns, indices, and versioning. + +Create `Blog/Service/Datastores/Post/PostsTable.php`: + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('authorId', 'BIGINT', null, 'NOT NULL'), + new Column('publishedDate', 'DATETIME', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + new Index(['authorId'], 'idx_posts_author'), + new Index(['publishedDate'], 'idx_posts_published'), + ]; + } +} +``` + +**Key points**: + +- `PrimaryKeyFactory` creates standard auto-incrementing primary key +- `DateCreatedFactory` creates timestamp column with automatic default +- `getTableVersion()` enables schema migrations +- Indices improve query performance for common lookups +- Column factories provide consistent definitions across your application + +**About Table dependencies**: + +The `Table` base class constructor requires several dependencies for database configuration: + +```php +public function __construct( + HasLocalDatabasePrefix $localPrefixProvider, + HasGlobalDatabasePrefix $globalPrefixProvider, + HasCharsetProvider $charsetProvider, + HasCollateProvider $collateProvider, + TableSchemaService $tableSchemaService, + LoggerStrategy $loggerStrategy +) {} +``` + +These dependencies are automatically injected by PHPNomad's dependency injection container when you register the table. +You don't need to manually provide them—the framework handles this through auto-wiring. + +For complete table schema reference, see [Table Schema Definition](../packages/database/table-schema-definition). + +--- + +## Step 6: Implement the database handler + +With your table schema defined, you now need to implement the handler that connects your datastore to the database. The database handler uses the table definition to perform actual database operations like querying, inserting, updating, and deleting records. + +The database handler extends PHPNomad's base handler and uses traits that provide the implementation of all standard datastore operations. + +Create `Blog/Service/Datastores/Post/PostDatabaseDatastoreHandler.php`: + +```php +serviceProvider = $serviceProvider; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->tableSchemaService = $tableSchemaService; + $this->model = Post::class; + } +} +``` + +**Why extend IdentifiableDatabaseDatastoreHandler?** + +`IdentifiableDatabaseDatastoreHandler` is a base class that provides single-ID convenience methods by delegating to +compound-ID operations. For example, it implements: + +```php +public function find(int $id): DataModel +{ + return $this->findCompound(['id' => $id]); +} +``` + +This saves you from writing boilerplate delegation code for every entity with a single integer primary key. + +**Why use WithDatastoreHandlerMethods?** + +The `WithDatastoreHandlerMethods` trait provides the actual implementation of all standard datastore operations: + +- `create()` - Inserts records and broadcasts events +- `find()` / `where()` - Queries with caching +- `update()` / `delete()` - Modifications with event broadcasting +- Query building and condition handling +- Cache management for retrieved models + +Without this trait, you would need to implement dozens of methods manually. The trait encapsulates all the database +interaction logic, caching strategies, and event broadcasting, allowing you to focus on entity-specific concerns. + +**Constructor dependencies explained**: + +- `DatabaseServiceProvider` - Provides query builder, cache service, event broadcasting +- `PostsTable` - Your table schema definition +- `PostAdapter` - Converts between Post models and arrays +- `TableSchemaService` - Manages table creation and migrations + +For detailed information about database handlers, see [Database Handlers](../packages/database/database-handlers). + +--- + +## Step 7: Create table installer + +Your table schema is defined, but it won't create itself. You need an **installer** that creates the table when your application is activated or installed. + +Create `Blog/Service/Installer.php`: + +```php +createTables(); + } + + protected function getTablesToInstall(): array + { + return [ + PostsTable::class, + ]; + } +} +``` + +**How installers work:** + +- `CanInstallTables` trait provides the table installation logic +- `getTablesToInstall()` returns an array of table classes to create +- `createTables()` checks if tables exist and creates/updates them +- Installers are idempotent—safe to run multiple times + +**When installers run:** + +Installers run during specific installation events, not on every page load: + +- **WordPress plugins**: On plugin activation via `register_activation_hook()` +- **CLI applications**: During install commands +- **Manual triggers**: When deploying schema changes + +The installer checks the current database state and only creates/updates tables when needed. If your table already exists and matches the schema version, the installer does nothing. + +**Why this matters:** + +Without an installer, your table definitions exist in code but never get executed. The installer is the bridge between your schema definition and the actual database structure. + +--- + +## Step 8: Register with dependency injection + +Register your datastore components with PHPNomad's dependency injection container so they can be auto-wired. + +Create `Blog/Service/Initializer.php`: + +```php + PostDatastoreHandler::class, + PostDatastore::class => PostDatastoreInterface::class, + ]; + } +} +``` + +**Key points**: +- `HasClassDefinitions` tells PHPNomad this initializer registers class bindings +- Maps concrete implementations to their interfaces +- The container auto-wires all constructor dependencies +- Format is: `Implementation::class => Interface::class` + +### Create a Loader + +Now create a loader that combines your initializers: + +Create `Blog/Loader.php`: + +```php +initializers = [ + new ServiceInitializer(), + ]; + } + + public function load(): void + { + $this->loadInitializers(); + } +} +``` + +The loader collects all your initializers and loads them in order during bootstrap. + +For complete initialization patterns, see [Creating and Managing Initializers](/core-concepts/bootstrapping/creating-and-managing-initializers). + +--- + +## Step 9: Bootstrap your application + +Finally, create an Application class that ties everything together: + +Create `Blog/Application.php`: + +```php +container = new Container(); + } + + /** + * Normal application initialization + */ + public function init(): void + { + (new Bootstrapper( + $this->container, + new Loader() // Loads all initializers + ))->load(); + } + + /** + * Run during plugin activation or installation + */ + public function install(): void + { + (new Bootstrapper( + $this->container, + new Installer() // Creates database tables + ))->load(); + } +} +``` + +**How to use:** + +For WordPress plugins, hook into activation and init: + +```php +install(); +}); + +// Normal app initialization +add_action('plugins_loaded', function() { + $app = new Application(); + $app->init(); +}); +``` + +**Key insights:** + +- `install()` runs the installer (creates tables) - only on activation +- `init()` loads initializers (registers classes) - runs on every page load +- The bootstrapper handles dependency resolution and initialization order + +For complete bootstrapping documentation, see [Bootstrapping Introduction](/core-concepts/bootstrapping/introduction). + +--- + +## Step 10: Use your datastore + +Once registered, you can inject and use your datastore anywhere in your application: + +```php +postDatastore->create([ + 'title' => 'My First Post', + 'content' => 'This is the content of my first blog post.', + 'authorId' => 1, + 'publishedDate' => date('Y-m-d H:i:s'), + ]); + + echo "Created post with ID: " . $post->getId(); + } + + public function listPublishedPosts(): void + { + $posts = $this->postDatastore->getPublishedPosts(); + + foreach ($posts as $post) { + echo $post->title . "\n"; + } + } + + public function findPost(int $id): void + { + $post = $this->postDatastore->find($id); + echo $post->title; + } + + public function updatePost(int $id): void + { + $this->postDatastore->update($id, [ + 'title' => 'Updated Title' + ]); + } + + public function deletePost(int $id): void + { + $this->postDatastore->delete($id); + } +} +``` + +The container automatically injects the `PostDatastore` implementation, which uses the database handler under the hood. + +--- + +## What you have accomplished + +You have built a complete database-backed datastore with all components: + +- ✅ **Model** - Immutable value object representing a Post +- ✅ **Adapter** - Converts between Post models and database arrays +- ✅ **Core Interfaces** - Define what operations are possible +- ✅ **Core Implementation** - Business logic with decorator pattern +- ✅ **Table Schema** - Database structure with columns and indices +- ✅ **Database Handler** - Actual database operations +- ✅ **Installer** - Creates and manages database tables +- ✅ **Initializer** - Registers components with DI +- ✅ **Loader** - Combines initializers for bootstrapping +- ✅ **Application** - Orchestrates init and install workflows + +Your datastore now supports: + +- Creating, reading, updating, and deleting posts +- Custom business queries (published posts, posts by author) +- Automatic caching and event broadcasting +- Swappable implementations (could replace database with REST API) +- Proper table installation and schema versioning +- Clean bootstrapping and initialization patterns + +--- + +## Next steps + +Now that you have built your first datastore, explore these topics to deepen your understanding: + +- **[Models and Identity](models-and-identity)** — Learn about compound keys and different identity patterns +- **[Core Datastore Layer](../packages/datastore/core-implementation)** — Master decorator patterns and custom business methods +- **[Database Handlers](../packages/database/database-handlers)** — Understand caching, events, and query building +- **[Table Schema Definition](../packages/database/table-schema-definition)** — Learn all column types, indices, and foreign keys +- **[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 + +--- + +## Summary + +Building a PHPNomad datastore involves creating models, adapters, Core interfaces and implementations, database schemas, +database handlers, and dependency injection registration. The pattern separates business logic (Core) from +implementation details (Service), enabling flexible, testable, and maintainable code. Decorator traits eliminate +boilerplate, while base classes and handler traits provide robust database operations with caching and event support. diff --git a/public/docs/docs/core-concepts/datastores/introduction.md b/public/docs/docs/core-concepts/datastores/introduction.md new file mode 100644 index 0000000..b57a5d0 --- /dev/null +++ b/public/docs/docs/core-concepts/datastores/introduction.md @@ -0,0 +1,290 @@ +# Overview and Architecture + +## What is the datastore pattern? + +The datastore pattern is a two-level abstraction system for data persistence that keeps your code independent of specific storage implementations. Instead of writing database queries directly in your business logic, you define how data operations should work through interfaces, then provide concrete implementations that talk to actual data sources. + +This pattern ensures your domain code remains portable. Whether data comes from a local database, a REST API, GraphQL endpoint, or in-memory cache, your application logic stays the same. You can swap implementations without rewriting the code that uses them. + +The pattern consists of two primary layers: **Core** for business interfaces and domain logic, and **Service** for concrete storage implementations. This separation is the foundation of implementation-agnostic design. + +--- + +## Why this separation matters + +Separation of concerns keeps your codebase flexible and maintainable. When data access is tightly coupled to a specific storage mechanism, changing that mechanism requires touching every piece of code that reads or writes data. This creates risk, increases testing burden, and makes your code harder to move between environments. + +The datastore pattern solves this by treating storage as a swappable dependency. Your business logic depends only on interfaces that describe what operations are possible. The actual implementation—whether it queries MySQL, calls a REST API, or reads from Redis—is provided at runtime through dependency injection. + +This approach provides several concrete benefits: + +- **Portability**: Move between storage systems without refactoring domain code +- **Testability**: Swap real implementations for test doubles during testing +- **Flexibility**: Support multiple data sources simultaneously for different entities +- **Future-proofing**: Adapt to changing requirements without rewriting core logic + +Consider a scenario where your application initially stores collaborator data in a local database. Later, you need to fetch collaborators from a remote API to integrate with a partner system. With the datastore pattern, you implement a new handler that calls the API instead of the database. Your domain code—the code that creates, updates, and queries collaborators—requires no changes. + +--- + +## The two-level architecture + +The pattern uses two distinct layers, each with a specific responsibility. + +### Core layer: Business interfaces + +The Core layer defines **what** operations are possible, without specifying **how** they work. It contains: + +- **Interfaces** that declare data operations (create, find, update, delete, query) +- **Models** that represent domain entities as value objects +- **Datastore implementations** that delegate to handler interfaces using the decorator pattern + +Core never depends on Service. It knows nothing about databases, HTTP clients, or any concrete storage technology. This ignorance is intentional and ensures portability. + +Example Core interface: + +```php +interface PostDatastore { + public function create(array $attributes): Post; + public function find(int $id): Post; + public function update(int $id, array $attributes): void; + public function delete(int $id): void; + public function where(array $conditions): array; +} +``` + +### Service layer: Concrete implementations + +The Service layer provides **how** operations are executed. It contains: + +- **Handler implementations** that interact with actual data sources +- **Table definitions** (for database implementations) that define schema +- **Adapters** that convert between models and storage formats + +Service depends on Core interfaces and implements them. Multiple Service implementations can exist for the same Core interface, enabling you to choose implementations based on environment, performance needs, or integration requirements. + +Example Service implementations: + +```php +// Database implementation +class PostDatabaseDatastoreHandler implements PostDatastoreHandler { + public function find(int $id): Post { + // Query database, convert row to Post model + } +} + +// GraphQL implementation +class PostGraphQLDatastoreHandler implements PostDatastoreHandler { + public function find(int $id): Post { + // Query GraphQL endpoint, convert response to Post model + } +} +``` + +--- + +## Communication flow + +Understanding how data flows through the layers helps clarify the architecture. The pattern uses two types of flow: conceptual (design-time) and runtime (execution). + +### Conceptual flow: Layers and abstractions + +At design time, the architecture looks like this: + +```mermaid +graph TB + A[Application Code] --> B[Datastore Interface
Core Layer] + B --> C[DatastoreHandler Interface
Core Layer] + C -.implements.-> D[Database Handler
Service Layer] + C -.implements.-> E[GraphQL Handler
Service Layer] + C -.implements.-> F[REST Handler
Service Layer] +``` + +The Core layer defines interfaces. The Service layer provides implementations. Application code depends only on Core interfaces, never on Service implementations directly. + +### Runtime flow: Concrete execution + +At runtime, when your application executes a data operation, the flow looks like this: + +```mermaid +graph LR + A[Application Code] --> B[PostDatastore
Core Implementation] + B --> C[PostDatabaseDatastoreHandler
Service Implementation] + C --> D[MySQL QueryBuilder] + D --> E[Database] +``` + +1. Application code calls a method on the Datastore +2. Datastore delegates to its injected Handler +3. Handler (database implementation) builds a query +4. Query executes against the database +5. Result is converted to a Model and returned + +Swap the handler for a GraphQL implementation, and steps 3-4 change to API calls instead of SQL queries. The application code and Datastore remain identical. + +--- + +## Key abstractions + +Several abstractions work together to enable the pattern. + +### Datastore + +The **Datastore** is the primary interface your application code interacts with. It defines business-level operations and may include domain-specific query methods beyond basic CRUD. + +```php +interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere { + public function getPublishedPosts(): array; + public function getByAuthor(int $authorId): array; +} +``` + +The Datastore implementation uses decorator traits to delegate standard operations to a handler, while implementing custom business methods directly. + +### DatastoreHandler + +The **DatastoreHandler** is the low-level interface that concrete implementations fulfill. It extends the same base interfaces as the Datastore but typically includes only standard operations, not business-specific methods. + +```php +interface PostDatastoreHandler extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere { + // Only standard interface methods, no custom business logic +} +``` + +Handlers are the swap points. When you want to change storage mechanisms, you implement a new handler without touching the Datastore interface or its consumers. + +### Model + +The **Model** represents a domain entity as an immutable value object. Models know nothing about persistence—they have no save methods, no database awareness. They are pure data with behavior. + +```php +class Post implements DataModel, HasSingleIntIdentity { + public function __construct( + private int $id, + private string $title, + private string $content, + private DateTime $publishedDate + ) {} + + public function getId(): int { return $this->id; } + public function getTitle(): string { return $this->title; } +} +``` + +### ModelAdapter + +The **ModelAdapter** converts between Models and storage representations (typically arrays). Handlers use adapters to transform raw data into Models on read, and Models into storable formats on write. + +```php +class PostAdapter implements ModelAdapter { + public function toModel(array $data): Post { + return new Post( + $data['id'], + $data['title'], + $data['content'], + new DateTime($data['published_date']) + ); + } + + public function toArray(Post $model): array { + return [ + 'id' => $model->getId(), + 'title' => $model->getTitle(), + 'content' => $model->getContent(), + 'published_date' => $model->getPublishedDate()->format('Y-m-d H:i:s') + ]; + } +} +``` + +--- + +## Example: Swapping implementations + +The core value of this architecture is implementation independence. Here is how it works in practice. + +### Starting with a database + +Initially, your blog stores posts in a MySQL database: + +```php +class PostDatabaseDatastoreHandler implements PostDatastoreHandler { + public function find(int $id): Post { + $row = $this->queryBuilder + ->select() + ->from($this->table) + ->where('id', '=', $id) + ->execute() + ->fetch(); + + return $this->adapter->toModel($row); + } +} +``` + +Your application code uses the Datastore: + +```php +$post = $postDatastore->find(123); +echo $post->getTitle(); +``` + +### Switching to GraphQL + +Your requirements change. Posts now come from a remote GraphQL API. You implement a new handler: + +```php +class PostGraphQLDatastoreHandler implements PostDatastoreHandler { + public function find(int $id): Post { + $query = "query { post(id: $id) { id title content publishedDate } }"; + $response = $this->graphqlClient->execute($query); + + return $this->adapter->toModel($response['data']['post']); + } +} +``` + +You update your dependency injection configuration to bind `PostDatastoreHandler` to the GraphQL implementation instead of the database implementation. Your application code remains unchanged: + +```php +$post = $postDatastore->find(123); // Now fetches from GraphQL +echo $post->getTitle(); +``` + +No business logic was modified. No tests for application code need updates. Only the handler implementation and its tests changed. + +--- + +## When to use this pattern + +PHPNomad is designed around the datastore pattern. If you are building applications with PHPNomad, especially when working with databases, this pattern is the expected approach. + +The pattern is required when: + +- You are using PHPNomad's database layer for local data storage +- You need to integrate data from multiple sources with a unified interface +- Your application design follows nomadic principles of portability and swappability + +The architecture is most beneficial when: + +- You anticipate changing storage implementations in the future +- Testing domain logic independently of storage is important +- Multiple environments require different storage strategies + +PHPNomad's database abstractions and table schema system are built to work with this pattern. Using the datastore pattern with PHPNomad ensures your code remains portable and consistent with the framework's design philosophy. + +--- + +## Related topics + +- **[Getting Started: Your First Datastore](getting-started-tutorial)** — Build a complete datastore implementation from scratch +- **[Core Datastore Layer](../packages/datastore/core-implementation)** — Deep dive into Core interfaces and implementations +- **[Database Service Layer](../packages/database/database-handlers)** — Detailed guide to database-backed handlers +- **[Models and Identity](models-and-identity)** — How to design models and identity systems +- **[Dependency Injection and Initialization](dependency-injection)** — Register and bootstrap datastores + +--- + +## Summary + +The datastore pattern provides implementation-agnostic data access through a two-level architecture. The Core layer defines business interfaces and domain models. The Service layer provides concrete storage implementations. This separation enables you to swap data sources without refactoring application logic, keeping your code portable, testable, and adaptable to changing requirements. diff --git a/public/docs/docs/core-concepts/datastores/models-and-identity.md b/public/docs/docs/core-concepts/datastores/models-and-identity.md new file mode 100644 index 0000000..4c06d9f --- /dev/null +++ b/public/docs/docs/core-concepts/datastores/models-and-identity.md @@ -0,0 +1,677 @@ +# Models and Identity + +## What are models? + +Models are immutable value objects that represent domain entities in your application. They contain data and domain +logic but have no knowledge of how they are persisted. A model never saves itself, queries a database, or makes API +calls—it is purely a container for data with behavior. + +Models are independent of storage. Whether data comes from a database, REST API, or cache, the model remains the same. +This separation keeps domain logic clean and portable. + +--- + +## The DataModel interface + +All models must implement the `DataModel` interface, which marks them as domain entities that can be stored and +retrieved through datastores: + +```php +interface DataModel +{ + public function getIdentity(): array; +} +``` + +The `getIdentity()` method returns an associative array representing how the entity is uniquely identified. For a Post +with ID 123, this might return `['id' => 123]`. For a UserSession identified by both user ID and session token, it +returns `['userId' => 456, 'sessionToken' => 'abc123']`. + +Datastores use this identity array to look up, update, and delete specific entities. + +--- + +## Understanding identity + +Identity determines how entities are uniquely identified. There are two primary patterns: + +### Single integer identity + +Most entities use a single auto-incrementing integer as their primary identifier. Examples include posts, users, +products, and orders. + +For these entities, implement the `HasSingleIntIdentity` interface: + +```php +interface HasSingleIntIdentity +{ + public function getId(): int; + public function getIdentity(): array; +} +``` + +This interface requires both a `getId()` method that returns the integer ID, and a `getIdentity()` method that returns +`['id' => $this->getId()]`. + +### Compound identity + +Some entities require multiple values to be uniquely identified. This happens when entities use composite keys in the +database. Common examples: + +- **User sessions** - identified by `userId` + `sessionToken` +- **Translations** - identified by `entityId` + `locale` +- **Time-series data** - identified by `deviceId` + `timestamp` +- **Versioned content** - identified by `contentId` + `version` + +For these entities, `getIdentity()` returns an array with multiple keys: + +```php +public function getIdentity(): array +{ + return [ + 'userId' => $this->userId, + 'sessionToken' => $this->sessionToken + ]; +} +``` + +The keys in this array must match the columns used to uniquely identify records in storage. + +--- + +## Single integer identity pattern + +For entities with a single integer ID, use the `WithSingleIntIdentity` trait to reduce boilerplate. + +### Using WithSingleIntIdentity trait + +The `WithSingleIntIdentity` trait provides: + +- A protected `$id` property +- Implementation of `getId()` that returns the ID +- Implementation of `getIdentity()` that returns `['id' => $this->getId()]` + +Example: + +```php +id = $id; + } +} + +// Usage: +$post = new Post(123, 'My Post', 'Content...', 1, new DateTime()); +echo $post->getId(); // 123 +print_r($post->getIdentity()); // ['id' => 123] +``` + +### Manual implementation + +If you prefer not to use the trait, implement the interface manually: + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + public function __construct( + private int $id, + public readonly string $title, + public readonly string $content + ) {} + + public function getId(): int + { + return $this->id; + } + + public function getIdentity(): array + { + return ['id' => $this->id]; + } +} +``` + +The trait is recommended to keep implementations consistent across your codebase. + +--- + +## Compound identity pattern + +For entities with compound keys, implement `getIdentity()` to return all identifying values. + +Example with UserSession: + +```php + $this->userId, + 'sessionToken' => $this->sessionToken + ]; + } +} +``` + +When the datastore performs operations on this entity, it uses both `userId` and `sessionToken` to identify the record: + +```php +// Datastore uses compound identity for lookups +$session = $sessionDatastore->findCompound([ + 'userId' => 456, + 'sessionToken' => 'abc123' +]); + +// Update uses compound identity +$sessionDatastore->updateCompound( + ['userId' => 456, 'sessionToken' => 'abc123'], + ['ipAddress' => '192.168.1.1'] +); +``` + +The keys in the identity array must exactly match the column names used in your storage implementation. + +--- + +## Timestamp traits + +PHPNomad provides traits for automatic timestamp tracking. + +### WithCreatedDate trait + +The `WithCreatedDate` trait provides: + +- A protected `$createdDate` property of type `?DateTime` +- Implementation of `getCreatedDate()` that returns the creation timestamp +- Automatic handling of null values for new entities + +Example: + +```php +id = $id; + $this->createdDate = $createdDate; + } +} + +// Usage: +$post = new Post(123, 'Title', 'Content', new DateTime('2025-01-08 10:00:00')); +echo $post->getCreatedDate()->format('Y-m-d H:i:s'); // 2025-01-08 10:00:00 +``` + +When creating new entities, pass `null` for `createdDate`. The database will set it automatically via +`DEFAULT CURRENT_TIMESTAMP`. + +### WithModifiedDate trait + +The `WithModifiedDate` trait works similarly for tracking last modification time: + +```php +use Nomad\Datastore\Traits\WithModifiedDate; + +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + use WithCreatedDate; + use WithModifiedDate; + + public function __construct( + int $id, + public readonly string $title, + ?DateTime $createdDate = null, + ?DateTime $modifiedDate = null + ) { + $this->id = $id; + $this->createdDate = $createdDate; + $this->modifiedDate = $modifiedDate; + } +} + +// Usage: +echo $post->getModifiedDate()?->format('Y-m-d H:i:s'); +``` + +The database automatically updates `modifiedDate` via `ON UPDATE CURRENT_TIMESTAMP` when using the corresponding table +column factory. + +### When to use traits vs manual implementation + +**Use traits when:** + +- You want consistent timestamp handling across entities +- The standard implementation (nullable DateTime) fits your needs +- You want to reduce boilerplate code + +**Implement manually when:** + +- You need custom timestamp logic +- You require non-nullable timestamps with specific defaults +- You want to calculate timestamps based on other model data + +--- + +## DateTime handling in models + +Models use PHP `DateTime` objects for all date and time values. Adapters handle conversion between `DateTime` objects +and database string formats. + +### Model with DateTime properties: + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly DateTime $publishedDate, + public readonly ?DateTime $scheduledDate = null + ) { + $this->id = $id; + } +} +``` + +### Adapter converts DateTime to/from strings: + +```php +class PostAdapter implements ModelAdapter +{ + public function __construct( + private DateFormatterService $dateFormatterService + ) {} + + public function toModel(array $data): Post + { + return new Post( + id: $data['id'], + publishedDate: $this->dateFormatterService->getDateTime( + $data['publishedDate'] + ), + scheduledDate: $this->dateFormatterService->getDateTimeOrNull( + $data['scheduledDate'] + ) + ); + } + + public function toArray(Post $model): array + { + return [ + 'id' => $model->getId(), + 'publishedDate' => $this->dateFormatterService->getDateString( + $model->publishedDate + ), + 'scheduledDate' => $this->dateFormatterService->getDateStringOrNull( + $model->scheduledDate + ), + ]; + } +} +``` + +**Key points:** + +- Models always use `DateTime` objects, never strings +- `DateFormatterService` handles conversion to database format (usually `Y-m-d H:i:s`) +- Use nullable `?DateTime` for optional dates +- Adapters use `getDateTime()` for required dates, `getDateTimeOrNull()` for optional dates + +--- + +## Model immutability + +Models must be immutable—their state cannot change after construction. This prevents bugs, simplifies reasoning about +code, and enables safe caching. + +### Correct immutable model: + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly string $title, + public readonly string $content, + public readonly bool $published + ) { + $this->id = $id; + } +} +``` + +### Common mistakes - DO NOT DO THIS: + +```php +// WRONG: Mutable properties +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public string $title // Not readonly - can be changed! + ) { + $this->id = $id; + } +} + +// WRONG: Setter methods +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly string $title + ) { + $this->id = $id; + } + + // NEVER add setters! + public function setTitle(string $title): void + { + $this->title = $title; // Breaks immutability! + } +} +``` + +### Why immutability matters: + +**Prevents bugs:** + +```php +$post = $datastore->find(123); +$cachedPost = $cache->get('post:123'); + +// If models were mutable, changing one affects the other +$post->title = 'New Title'; // Would corrupt cache! +``` + +**Enables safe caching:** + +```php +// Datastore can safely cache immutable models +$post = $datastore->find(123); // Caches result +$samePost = $datastore->find(123); // Returns cached instance +// Both references point to same object, but it can't be changed +``` + +**Simplifies concurrency:** + +```php +// Multiple threads can safely read the same model +// No locks or synchronization needed +``` + +### How to "update" immutable models: + +You don't modify existing models—you create new ones with changed values: + +```php +// Get existing post +$post = $datastore->find(123); + +// Create updated post by creating new instance +$updatedPost = new Post( + id: $post->getId(), + title: 'New Title', // Changed + content: $post->content, // Same + published: $post->published // Same +); + +// Or use datastore update, which creates a new instance internally +$datastore->update(123, ['title' => 'New Title']); +``` + +The datastore handles updates by: + +1. Loading the current model +2. Merging changes from the array +3. Creating a new model instance +4. Persisting the new state +5. Returning the new model + +--- + +## Model best practices + +### Use public readonly properties + +Constructor property promotion with `readonly` provides immutability and clean syntax: + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly string $title, + public readonly string $content, + public readonly int $authorId + ) { + $this->id = $id; + } +} + +// Access directly, no getters needed +echo $post->title; +echo $post->authorId; +``` + +### Models are for data access only + +**Models should never contain business logic.** They are purely data containers with no behavior beyond providing access to their properties. + +**DO NOT add methods like:** +- `isExpired()` - belongs in a service +- `isPublished()` - belongs in a service +- `calculateTotal()` - belongs in a service +- `validate()` - belongs in a service or validator +- `canBeEditedBy()` - belongs in an authorization service + +```php +// WRONG - business logic in model +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly DateTime $publishedDate + ) { + $this->id = $id; + } + + // DON'T DO THIS + public function isPublished(): bool + { + return $this->publishedDate <= new DateTime(); + } +} + +// CORRECT - model is data only +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly DateTime $publishedDate + ) { + $this->id = $id; + } +} + +// Business logic belongs in services +class PostService +{ + public function isPublished(Post $post): bool + { + return $post->publishedDate <= new DateTime(); + } +} +``` + +Models are designed to be serializable, cacheable, and transferable. Business logic in models creates coupling and makes them harder to test and maintain. Keep models as simple data structures and put all logic in services. + +### Handle relationships with IDs + +If your entity relates to other entities, store IDs rather than embedding objects: + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly string $title, + public readonly int $authorId // Store ID, not Author object + ) { + $this->id = $id; + } +} + +// Fetch related entities separately through their datastores +$post = $postDatastore->find(123); +$author = $authorDatastore->find($post->authorId); +``` + +This keeps models simple and storage-agnostic. + +--- + +## Complete examples + +### Simple entity with single ID: + +```php +id = $id; + $this->createdDate = $createdDate; + } +} +``` + +### Entity with compound identity: + +```php +createdDate = $createdDate; + } + + public function getIdentity(): array + { + return [ + 'userId' => $this->userId, + 'sessionToken' => $this->sessionToken + ]; + } +} +``` + +--- + +## Summary + +Models are immutable value objects that represent domain entities. They implement the `DataModel` interface and provide +identity through `getIdentity()`. Use `WithSingleIntIdentity` for entities with single integer IDs, or implement +compound identity for entities requiring multiple identifying values. Models use `DateTime` for dates, with adapters +handling string conversion. Traits like `WithCreatedDate` and `WithModifiedDate` provide automatic timestamp tracking. +Always use `public readonly` properties to enforce immutability. Models must never contain business logic—they are purely data containers. All business logic belongs in services. diff --git a/public/docs/docs/index.md b/public/docs/docs/index.md new file mode 100644 index 0000000..9d7f23d --- /dev/null +++ b/public/docs/docs/index.md @@ -0,0 +1,67 @@ +# PHPNomad + +PHPNomad's primary purpose is to create code that's both easy to read and simple to use, while remaining +platform-agnostic. By focusing on clear, modular design, PHPNomad allows developers to build code that works +consistently across various environments, making it adaptable without added complexity. + +## What is PHPNomad? + +Think of PHPNomad as a way to write code that can easily move between different PHP systems. Whether you're building a +WordPress plugin, a Laravel application, or a homegrown MVC service, PHPNomad helps make your code portable and adaptable. + +The name comes from the idea that your code should be able to travel and adapt, just like a digital nomad who can +work from anywhere. + +## Why PHPNomad? + +### Eliminate Context Switching + +The biggest advantage of PHPNomad is eliminating mental overhead when working across different platforms. Developers use +the same patterns, tools, and approaches whether building a WordPress plugin, Laravel application, or standalone PHP +service. This consistency dramatically reduces cognitive load and increases productivity. + +### True Platform Independence + +Rather than being tied to platform-specific implementations (like WordPress's `wp_remote_request` or Laravel's Guzzle), +PHPNomad introduces a buffer layer between your business logic and platform-specific code. This means your core +functionality remains clean and portable, while platform-specific details are handled through clean interfaces. + +### A Framework for Modern Development + +PHPNomad's consistent patterns and clear separation of concerns make it ideal for modern development practices, +including: + +- AI-assisted development through standardized patterns +- Microservices architecture through modular design +- Future-proofing through platform agnosticism + +## Core Principles + +### Platform-Agnostic Design + +At the heart of PHPNomad is a commitment to platform-agnostic design. Components are crafted to function seamlessly +across different systems, including WordPress, Laravel, Symfony, and standalone PHP applications. This design allows +your codebase to "travel" effortlessly between environments. + +### Separation of Concerns + +PHPNomad separates business logic from platform-specific integrations through dependency injection and the strategy +pattern. This creates a clean buffer between your core logic and platform-specific code, letting each system "plug in" +without deep coupling. + +### Inversion of Control + +PHPNomad shifts control from platform-specific code to your core system. This means platforms integrate into your +application rather than your application integrating into platforms. This fundamental shift makes your codebase truly +portable. + +### Modularity + +Start small and scale as needed. Each feature exists as an independent module, allowing you to begin with a simple +WordPress plugin and later extract parts into microservices or add new functionality. This modularity empowers teams to +scale at their own pace. + +### Event-Driven Architecture + +Built around events, PHPNomad facilitates both synchronous and asynchronous operations without binding to specific +platforms. This approach enhances scalability and makes your codebase responsive in any environment. diff --git a/public/docs/docs/packages/config/exceptions/config-exception.md b/public/docs/docs/packages/config/exceptions/config-exception.md new file mode 100644 index 0000000..3482921 --- /dev/null +++ b/public/docs/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/docs/packages/config/interfaces/config-file-loader-strategy.md b/public/docs/docs/packages/config/interfaces/config-file-loader-strategy.md new file mode 100644 index 0000000..c7b01c7 --- /dev/null +++ b/public/docs/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/docs/packages/config/interfaces/config-strategy.md b/public/docs/docs/packages/config/interfaces/config-strategy.md new file mode 100644 index 0000000..7d45b62 --- /dev/null +++ b/public/docs/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/docs/packages/config/interfaces/introduction.md b/public/docs/docs/packages/config/interfaces/introduction.md new file mode 100644 index 0000000..48aef37 --- /dev/null +++ b/public/docs/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/docs/packages/config/introduction.md b/public/docs/docs/packages/config/introduction.md new file mode 100644 index 0000000..3289db2 --- /dev/null +++ b/public/docs/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/docs/packages/config/services/config-service.md b/public/docs/docs/packages/config/services/config-service.md new file mode 100644 index 0000000..0c4d9a2 --- /dev/null +++ b/public/docs/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/docs/packages/config/services/introduction.md b/public/docs/docs/packages/config/services/introduction.md new file mode 100644 index 0000000..4f3b37c --- /dev/null +++ b/public/docs/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/docs/packages/database/caching-and-events.md b/public/docs/docs/packages/database/caching-and-events.md new file mode 100644 index 0000000..9d5b242 --- /dev/null +++ b/public/docs/docs/packages/database/caching-and-events.md @@ -0,0 +1,575 @@ +# Caching and Events + +PHPNomad's database handlers include built-in support for **automatic caching** and **event broadcasting**. These features are provided by the `CacheableService` and `EventStrategy` components, which are injected into handlers via the `DatabaseServiceProvider`. + +Caching improves performance by storing frequently accessed data, while events enable reactive patterns where other parts of your system can respond to data changes without tight coupling. + +## Overview + +When a handler extends `IdentifiableDatabaseDatastoreHandler`, it automatically gets: + +* **Caching** — Query results are cached based on configurable policies +* **Cache invalidation** — Mutations (save, delete) automatically invalidate affected cache entries +* **Event broadcasting** — Mutations trigger events that other services can listen to + +This happens transparently—you don't need to write caching or event code in your handlers. + +--- + +## Caching Strategy + +### How Caching Works + +The `CacheableService` wraps query operations with cache checks: + +1. **Cache hit** — If data exists in cache and policy allows, return cached data +2. **Cache miss** — Execute the query, store result in cache, return data +3. **Invalidation** — Mutations (save, delete) clear relevant cache entries + +### CacheableService API + +```php +class CacheableService +{ + /** + * Get data with caching + * + * @param string $operation - Operation name (e.g., 'find', 'get') + * @param array $context - Context data (e.g., ['id' => 123]) + * @param callable $callback - Function to execute on cache miss + */ + public function getWithCache(string $operation, array $context, callable $callback); + + /** + * Get cached data directly (throws if not found) + */ + public function get(array $context); + + /** + * Clear cache for specific context + */ + public function forget(array $context): void; + + /** + * Clear all cache entries matching a pattern + */ + public function forgetMatching(string $pattern): void; +} +``` + +### Example: Handler with Caching + +```php +cache = $serviceProvider->cacheableService; + $this->table = $table; + $this->adapter = $adapter; + } + + public function find(int $id): Post + { + return $this->cache->getWithCache( + operation: 'find', + context: ['id' => $id], + callback: function() use ($id) { + // This only runs on cache miss + $row = $this->executeQuery("SELECT * FROM {$this->table->getTableName()} WHERE id = {$id}"); + return $this->adapter->toModel($row); + } + ); + } +} +``` + +**On first call:** +1. Cache miss → executes query +2. Stores result in cache +3. Returns post + +**On subsequent calls:** +1. Cache hit → returns cached post +2. Query is never executed + +--- + +### Cache Invalidation + +When you save or delete a record, the handler automatically invalidates relevant cache entries: + +```php +public function save(Model $item): Model +{ + $result = parent::save($item); + + // Automatically clears cache for this record + $this->cache->forget(['id' => $item->getId()]); + + // Also clears list caches that might include this record + $this->cache->forgetMatching('posts:list:*'); + + return $result; +} + +public function delete(Model $item): void +{ + parent::delete($item); + + // Automatically clears cache for this record + $this->cache->forget(['id' => $item->getId()]); + $this->cache->forgetMatching('posts:list:*'); +} +``` + +**Note:** `IdentifiableDatabaseDatastoreHandler` handles this automatically. You only need custom invalidation for complex cache patterns. + +--- + +### Cache Policies + +Cache behavior is controlled by a `CachePolicy`: + +```php +interface CachePolicy +{ + /** + * Determine if this operation should use cache + */ + public function shouldCache(string $operation, array $context): bool; + + /** + * Generate cache key from context + */ + public function getCacheKey(array $context): string; + + /** + * Get cache TTL (time-to-live) in seconds + */ + public function getTtl(array $context): int; +} +``` + +### Example: Custom Cache Policy + +```php + 123, 'status' => 'published'])); +// posts:list:a3f2e1d... +``` + +**Count queries:** +```php +$key = "posts:count:" . md5(serialize(['status' => 'published'])); +// posts:count:b4c3d2e... +``` + +**Wildcard invalidation:** +```php +// Clear all list caches when any post changes +$this->cache->forgetMatching('posts:list:*'); + +// Clear all post caches (lists and single records) +$this->cache->forgetMatching('posts:*'); +``` + +--- + +## Event Broadcasting + +### How Events Work + +Handlers broadcast events after mutations, allowing other parts of your system to react: + +* **RecordCreated** — Fired after `save()` creates a new record +* **RecordUpdated** — Fired after `save()` updates an existing record +* **RecordDeleted** — Fired after `delete()` removes a record + +Events are **asynchronous** by default—listeners don't block the handler. + +--- + +### EventStrategy API + +```php +interface EventStrategy +{ + /** + * Broadcast an event to all registered listeners + * + * @param object $event The event object + */ + public function broadcast(object $event): void; + + /** + * Register a listener for an event type + * + * @param string $eventClass The event class name + * @param callable $listener The listener callback + */ + public function listen(string $eventClass, callable $listener): void; +} +``` + +--- + +### Example: Handler with Events + +```php +events = $serviceProvider->eventStrategy; + $this->table = $table; + $this->adapter = $adapter; + } + + public function save(Model $item): Model + { + $isNew = !$item->getId(); + + $result = parent::save($item); + + // Broadcast appropriate event + if ($isNew) { + $this->events->broadcast(new RecordCreated('posts', $result)); + } else { + $this->events->broadcast(new RecordUpdated('posts', $result)); + } + + return $result; + } + + public function delete(Model $item): void + { + parent::delete($item); + + $this->events->broadcast(new RecordDeleted('posts', $item)); + } +} +``` + +**Note:** `IdentifiableDatabaseDatastoreHandler` broadcasts these events automatically. + +--- + +### Listening to Events + +Register listeners in your service provider: + +```php +events->listen(RecordCreated::class, function(RecordCreated $event) { + if ($event->table === 'posts') { + $post = $event->model; + $this->notifications->sendNewPostNotification($post); + } + }); + + // Listen for post updates + $this->events->listen(RecordUpdated::class, function(RecordUpdated $event) { + if ($event->table === 'posts') { + $post = $event->model; + $this->notifications->sendPostUpdatedNotification($post); + } + }); + + // Listen for post deletion + $this->events->listen(RecordDeleted::class, function(RecordDeleted $event) { + if ($event->table === 'posts') { + // Clean up related data + $this->cleanupPostRelations($event->model->getId()); + } + }); + } +} +``` + +--- + +### Custom Events + +You can broadcast domain-specific events: + +```php +posts->find($postId); + + $published = new Post( + id: $post->id, + title: $post->title, + content: $post->content, + authorId: $post->authorId, + publishedDate: new DateTime() + ); + + $this->posts->save($published); + + // Broadcast custom event + $this->events->broadcast(new PostPublished($published, new DateTime())); + } +} +``` + +**Listen for it:** +```php +$this->events->listen(PostPublished::class, function(PostPublished $event) { + $this->emailService->notifySubscribers($event->post); + $this->searchIndex->updatePost($event->post); +}); +``` + +--- + +## Combining Caching and Events + +Caching and events work together seamlessly: + +```php +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + public function save(Model $item): Model + { + $isNew = !$item->getId(); + + // Save to database + $result = parent::save($item); + + // Clear cache + $this->cache->forget(['id' => $result->getId()]); + $this->cache->forgetMatching('posts:list:*'); + + // Broadcast event + if ($isNew) { + $this->events->broadcast(new RecordCreated('posts', $result)); + } else { + $this->events->broadcast(new RecordUpdated('posts', $result)); + } + + return $result; + } +} +``` + +**Flow:** +1. **Write** — Save to database +2. **Invalidate** — Clear affected caches +3. **Notify** — Broadcast event to listeners +4. **React** — Listeners update derived data, send notifications, etc. + +--- + +## Event-Driven Cache Warming + +Use events to proactively warm caches: + +```php +$this->events->listen(RecordUpdated::class, function(RecordUpdated $event) { + if ($event->table === 'posts') { + // Warm cache for commonly accessed queries + $this->postDatastore->get(['status' => 'published']); + $this->postDatastore->get(['featured' => true]); + } +}); +``` + +--- + +## Cache Miss Events + +`CacheableService` broadcasts a `CacheMissed` event you can track: + +```php +use PHPNomad\Cache\Events\CacheMissed; + +$this->events->listen(CacheMissed::class, function(CacheMissed $event) { + // Log cache misses for monitoring + $this->logger->info("Cache miss: {$event->operation}", $event->context); +}); +``` + +--- + +## Best Practices + +### Cache Strategically + +```php +// ✅ GOOD: cache expensive queries +$posts = $this->cache->getWithCache('list', ['author_id' => 123], fn() => + $this->queryBuilder->select('*')->from($this->table)->where(...)->build() +); + +// ❌ BAD: caching single writes +$this->cache->getWithCache('save', [], fn() => $this->save($post)); +``` + +### Use Descriptive Cache Keys + +```php +// ✅ GOOD: clear, structured keys +"posts:123" +"posts:list:author:456" +"posts:count:published" + +// ❌ BAD: opaque keys +"p123" +"query_result" +``` + +### Invalidate Broadly on Writes + +```php +// ✅ GOOD: clear related caches +$this->cache->forget(['id' => $id]); +$this->cache->forgetMatching('posts:list:*'); +$this->cache->forgetMatching('posts:count:*'); + +// ❌ BAD: only clear one entry +$this->cache->forget(['id' => $id]); +``` + +### Keep Events Lightweight + +```php +// ✅ GOOD: quick event listener +$this->events->listen(RecordCreated::class, fn($e) => + $this->queue->push(new SendNotificationJob($e->model)) +); + +// ❌ BAD: slow event listener blocks handler +$this->events->listen(RecordCreated::class, function($e) { + $this->emailService->sendToAllSubscribers($e->model); // Slow! +}); +``` + +### Use Events for Side Effects + +```php +// ✅ GOOD: side effects in event listeners +$this->events->listen(PostPublished::class, fn($e) => + $this->searchIndex->update($e->post) +); + +// ❌ BAD: side effects in handler +public function save(Model $item): Model { + $result = parent::save($item); + $this->searchIndex->update($result); // Couples handler to search + return $result; +} +``` + +--- + +## 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 +* [Query Building](/packages/database/query-building) — building cacheable queries +* [Database Service Provider](/packages/database/database-service-provider) — configuring caching and events diff --git a/public/docs/docs/packages/database/database-service-provider.md b/public/docs/docs/packages/database/database-service-provider.md new file mode 100644 index 0000000..d1014d5 --- /dev/null +++ b/public/docs/docs/packages/database/database-service-provider.md @@ -0,0 +1,473 @@ +# DatabaseServiceProvider + +The `DatabaseServiceProvider` is a **dependency container** that provides all the services database handlers need to function. It bundles query builders, cache services, event broadcasting, and logging into a single injectable dependency, simplifying handler construction. + +Instead of injecting 5-6 separate dependencies into every handler, you inject one `DatabaseServiceProvider` and access its public properties. + +## What It Provides + +The `DatabaseServiceProvider` class exposes six services: + +```php +class DatabaseServiceProvider +{ + public LoggerStrategy $loggerStrategy; + public QueryStrategy $queryStrategy; + public CacheableService $cacheableService; + public QueryBuilder $queryBuilder; + public ClauseBuilder $clauseBuilder; + public EventStrategy $eventStrategy; +} +``` + +These services are injected into the provider via its constructor and made available as public properties. + +--- + +## Services Overview + +### 1. QueryBuilder + +Builds safe, escaped SQL SELECT queries. + +**Usage:** +```php +$sql = $serviceProvider->queryBuilder + ->select('*') + ->from($table) + ->where($clause) + ->limit(10) + ->build(); +``` + +**See:** [Query Building](/packages/database/query-building) + +--- + +### 2. ClauseBuilder + +Constructs WHERE clauses for queries. + +**Usage:** +```php +$clause = $serviceProvider->clauseBuilder + ->useTable($table) + ->where('author_id', '=', 123) + ->andWhere('status', '=', 'published'); +``` + +**See:** [Query Building](/packages/database/query-building) + +--- + +### 3. QueryStrategy + +Executes SQL queries against the database. + +**Usage:** +```php +// Execute query and return results +$rows = $serviceProvider->queryStrategy->query($sql); + +// Execute query and return single row +$row = $serviceProvider->queryStrategy->querySingle($sql); + +// Execute mutation (INSERT, UPDATE, DELETE) +$affected = $serviceProvider->queryStrategy->execute($sql); +``` + +--- + +### 4. CacheableService + +Provides automatic caching for query results. + +**Usage:** +```php +$post = $serviceProvider->cacheableService->getWithCache( + operation: 'find', + context: ['id' => 123], + callback: fn() => $this->executeQuery("SELECT * FROM posts WHERE id = 123") +); +``` + +**See:** [Caching and Events](/packages/database/caching-and-events) + +--- + +### 5. EventStrategy + +Broadcasts events to registered listeners. + +**Usage:** +```php +$serviceProvider->eventStrategy->broadcast( + new RecordCreated('posts', $post) +); +``` + +**See:** [Caching and Events](/packages/database/caching-and-events) + +--- + +### 6. LoggerStrategy + +Logs errors, warnings, and debug information. + +**Usage:** +```php +$serviceProvider->loggerStrategy->error('Database query failed', [ + 'query' => $sql, + 'error' => $exception->getMessage() +]); + +$serviceProvider->loggerStrategy->debug('Query executed', [ + 'query' => $sql, + 'duration' => $duration +]); +``` + +--- + +## Using DatabaseServiceProvider in Handlers + +Handlers receive the provider via constructor injection: + +```php +queryBuilder = $serviceProvider->queryBuilder; + $this->clauseBuilder = $serviceProvider->clauseBuilder; + $this->cache = $serviceProvider->cacheableService; + $this->events = $serviceProvider->eventStrategy; + $this->logger = $serviceProvider->loggerStrategy; + + // Set handler properties + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; + } + + public function findPublished(): array + { + try { + return $this->cache->getWithCache( + 'list:published', + [], + function() { + $clause = $this->clauseBuilder + ->useTable($this->table) + ->where('status', '=', 'published'); + + $sql = $this->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause) + ->build(); + + $rows = $this->serviceProvider->queryStrategy->query($sql); + + return array_map( + fn($row) => $this->modelAdapter->toModel($row), + $rows + ); + } + ); + } catch (\Exception $e) { + $this->logger->error('Failed to fetch published posts', [ + 'error' => $e->getMessage() + ]); + throw $e; + } + } +} +``` + +--- + +## Why Use a Service Provider? + +### Without Service Provider + +Every handler would need 6+ constructor parameters: + +```php +public function __construct( + QueryBuilder $queryBuilder, + ClauseBuilder $clauseBuilder, + QueryStrategy $queryStrategy, + CacheableService $cacheableService, + EventStrategy $eventStrategy, + LoggerStrategy $loggerStrategy, + PostsTable $table, + PostAdapter $adapter, + TableSchemaService $tableSchemaService +) { + // 9 dependencies! +} +``` + +### With Service Provider + +Only 4 constructor parameters: + +```php +public function __construct( + DatabaseServiceProvider $serviceProvider, + PostsTable $table, + PostAdapter $adapter, + TableSchemaService $tableSchemaService +) { + // 4 dependencies - much cleaner +} +``` + +--- + +## Registering DatabaseServiceProvider + +The provider is registered once in your DI container: + +```php +set(QueryBuilder::class, fn() => new QueryBuilder()); + $container->set(ClauseBuilder::class, fn() => new ClauseBuilder()); + $container->set(QueryStrategy::class, fn() => new MysqlQueryStrategy()); + $container->set(CacheableService::class, fn($c) => + new CacheableService( + $c->get(EventStrategy::class), + $c->get(CacheStrategy::class), + $c->get(CachePolicy::class) + ) + ); + $container->set(EventStrategy::class, fn() => new EventStrategy()); + $container->set(LoggerStrategy::class, fn() => new FileLogger()); + + // Register provider that bundles them all + $container->set(DatabaseServiceProvider::class, function($c) { + return new DatabaseServiceProvider( + loggerStrategy: $c->get(LoggerStrategy::class), + queryStrategy: $c->get(QueryStrategy::class), + queryBuilder: $c->get(QueryBuilder::class), + clauseBuilder: $c->get(ClauseBuilder::class), + cacheableService: $c->get(CacheableService::class), + eventStrategy: $c->get(EventStrategy::class) + ); + }); + } +} +``` + +Now every handler can inject `DatabaseServiceProvider` and access all services. + +--- + +## Accessing Services + +### Direct Access + +```php +$queryBuilder = $serviceProvider->queryBuilder; +$cache = $serviceProvider->cacheableService; +``` + +### In Base Class (IdentifiableDatabaseDatastoreHandler) + +The base handler stores the provider for internal use: + +```php +abstract class IdentifiableDatabaseDatastoreHandler +{ + protected DatabaseServiceProvider $serviceProvider; + + protected function executeQuery(string $sql): array + { + return $this->serviceProvider->queryStrategy->query($sql); + } + + protected function log(string $message, array $context = []): void + { + $this->serviceProvider->loggerStrategy->info($message, $context); + } +} +``` + +Your handlers inherit these helper methods. + +--- + +## Example: Complete Handler with Provider + +```php +model = Post::class; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; + } + + // All standard methods (find, get, save, delete) are provided by base class + // Base class uses $this->serviceProvider internally + + public function findBySlug(string $slug): ?Post + { + $clause = $this->serviceProvider->clauseBuilder + ->useTable($this->table) + ->where('slug', '=', $slug); + + $sql = $this->serviceProvider->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause) + ->build(); + + try { + $row = $this->serviceProvider->queryStrategy->querySingle($sql); + return $row ? $this->modelAdapter->toModel($row) : null; + } catch (\Exception $e) { + $this->serviceProvider->loggerStrategy->error('Failed to find post by slug', [ + 'slug' => $slug, + 'error' => $e->getMessage() + ]); + throw $e; + } + } +} +``` + +--- + +## Benefits + +### 1. Simplified Constructor + +Reduces constructor complexity from 9+ parameters to 4. + +### 2. Consistent Service Access + +All handlers access the same service instances, ensuring consistency. + +### 3. Easy Mocking in Tests + +Mock one provider instead of 6 individual services: + +```php +$mockProvider = $this->createMock(DatabaseServiceProvider::class); +$mockProvider->queryBuilder = $this->createMock(QueryBuilder::class); +$mockProvider->cacheableService = $this->createMock(CacheableService::class); +// etc. + +$handler = new PostHandler($mockProvider, $table, $adapter, $schemaService); +``` + +### 4. Centralized Configuration + +Change implementations (e.g., swap MySQL for PostgreSQL) in one place: + +```php +$container->set(QueryStrategy::class, fn() => new PostgresQueryStrategy()); +// All handlers automatically use PostgreSQL +``` + +--- + +## Best Practices + +### Extract Services in Constructor + +```php +// ✅ GOOD: extract to properties +public function __construct(DatabaseServiceProvider $serviceProvider, ...) +{ + $this->queryBuilder = $serviceProvider->queryBuilder; + $this->cache = $serviceProvider->cacheableService; +} + +// ❌ BAD: access provider repeatedly +public function find($id) { + $this->serviceProvider->queryBuilder->select(...); // Verbose +} +``` + +### Don't Create Provider Manually + +```php +// ❌ BAD: manual instantiation +$provider = new DatabaseServiceProvider(...); + +// ✅ GOOD: inject from container +public function __construct(DatabaseServiceProvider $serviceProvider) +``` + +### Use Provider Properties, Not Methods + +The provider exposes services as **public properties**, not methods: + +```php +// ✅ GOOD: property access +$serviceProvider->queryBuilder + +// ❌ BAD: no getter methods +$serviceProvider->getQueryBuilder() // Doesn't exist +``` + +--- + +## What's Next + +* [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/docs/packages/database/handlers/identifiable-database-datastore-handler.md b/public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md new file mode 100644 index 0000000..f95f78f --- /dev/null +++ b/public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md @@ -0,0 +1,441 @@ +# IdentifiableDatabaseDatastoreHandler + +The `IdentifiableDatabaseDatastoreHandler` is the **base class** for implementing database-backed datastores in PHPNomad. It provides complete implementations of all standard handler interfaces (find, get, save, delete, where, count) with built-in caching, event broadcasting, and query building. + +When you extend this class, you get a fully functional database handler with minimal code—just set a few properties in your constructor. + +## What It Provides + +By extending `IdentifiableDatabaseDatastoreHandler`, your handler automatically implements: + +* **DatastoreHandler** — `get()`, `save()`, `delete()` +* **DatastoreHandlerHasPrimaryKey** — `find(int $id)` +* **DatastoreHandlerHasWhere** — `where()` returning a query builder +* **DatastoreHandlerHasCounts** — `count(array $args)` + +Plus automatic: +* Query building with escaping +* Result caching with invalidation +* Event broadcasting on mutations +* Table schema management + +--- + +## Basic Usage + +```php +model = Post::class; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; + } +} +``` + +That's it! This handler now supports: +- `find($id)` - Find by primary key +- `get($args)` - Get multiple records +- `save($model)` - Create or update +- `delete($model)` - Remove record +- `where()` - Query builder +- `count($args)` - Count records + +--- + +## Required Properties + +You must set these five properties in your constructor: + +### `$model` +The model class name this handler works with. + +```php +$this->model = Post::class; +``` + +### `$table` +The table definition for database schema. + +```php +$this->table = $table; +``` + +### `$modelAdapter` +The adapter for converting between models and arrays. + +```php +$this->modelAdapter = $adapter; +``` + +### `$serviceProvider` +The database service provider with query builders, cache, events. + +```php +$this->serviceProvider = $serviceProvider; +``` + +### `$tableSchemaService` +Service for managing table creation and updates. + +```php +$this->tableSchemaService = $tableSchemaService; +``` + +--- + +## Provided Methods + +### `find(int $id): Model` + +Finds a single record by primary key. + +**Implementation:** +- Checks cache first +- On cache miss, executes SELECT query +- Converts result to model via adapter +- Stores in cache +- Returns model + +**Throws:** `RecordNotFoundException` if not found. + +**Example:** +```php +$post = $handler->find(42); +``` + +--- + +### `get(array $args = []): iterable` + +Retrieves multiple records matching criteria. + +**Implementation:** +- Builds WHERE clause from `$args` +- Executes SELECT query +- Converts each row to model +- Returns iterable collection + +**Example:** +```php +$posts = $handler->get(['author_id' => 123, 'status' => 'published']); +``` + +--- + +### `save(Model $item): Model` + +Creates or updates a record. + +**Implementation:** +- Converts model to array via adapter +- Determines INSERT or UPDATE based on primary key +- Executes query +- Invalidates cache +- Broadcasts `RecordCreated` or `RecordUpdated` event +- Returns saved model with generated ID (if new) + +**Example:** +```php +$newPost = new Post(null, 'Title', 'Content', 123, new DateTime()); +$savedPost = $handler->save($newPost); +echo $savedPost->id; // Now has an ID +``` + +--- + +### `delete(Model $item): void` + +Removes a record from the database. + +**Implementation:** +- Extracts primary key from model +- Executes DELETE query +- Invalidates cache +- Broadcasts `RecordDeleted` event + +**Example:** +```php +$post = $handler->find(42); +$handler->delete($post); +``` + +--- + +### `where(): DatastoreWhereQuery` + +Returns a query builder for complex filtering. + +**Returns:** Query interface with methods like `equals()`, `greaterThan()`, `orderBy()`, `limit()`. + +**Example:** +```php +$posts = $handler + ->where() + ->equals('author_id', 123) + ->greaterThan('view_count', 100) + ->orderBy('published_date', 'DESC') + ->limit(10) + ->getResults(); +``` + +--- + +### `count(array $args = []): int` + +Counts records matching criteria. + +**Implementation:** +- Builds WHERE clause from `$args` +- Executes SELECT COUNT(*) query +- Returns integer count + +**Example:** +```php +$publishedCount = $handler->count(['status' => 'published']); +``` + +--- + +## Custom Methods + +You can add custom business methods alongside the standard ones: + +```php +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + // ... standard setup ... + + /** + * Custom method: find posts by slug + */ + public function findBySlug(string $slug): ?Post + { + $clause = $this->serviceProvider->clauseBuilder + ->useTable($this->table) + ->where('slug', '=', $slug); + + $sql = $this->serviceProvider->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause) + ->build(); + + $row = $this->serviceProvider->queryStrategy->querySingle($sql); + + return $row ? $this->modelAdapter->toModel($row) : null; + } + + /** + * Custom method: get top posts by view count + */ + public function getTopPosts(int $limit = 10): iterable + { + $sql = $this->serviceProvider->queryBuilder + ->select('*') + ->from($this->table) + ->orderBy('view_count', 'DESC') + ->limit($limit) + ->build(); + + $rows = $this->serviceProvider->queryStrategy->query($sql); + + return array_map( + fn($row) => $this->modelAdapter->toModel($row), + $rows + ); + } +} +``` + +--- + +## Caching Behavior + +All read operations are automatically cached: + +**Cached operations:** +- `find()` — cached by ID +- `get()` — cached by args hash +- `where()` results — cached by query hash + +**Cache invalidation:** +- `save()` — clears cache for that record +- `delete()` — clears cache for that record +- Both also clear list caches + +**Customize caching:** +```php +// Override to customize cache behavior +protected function getCacheKey(array $context): string +{ + return 'posts:' . md5(serialize($context)); +} + +protected function shouldCache(string $operation, array $context): bool +{ + // Don't cache queries with LIMIT > 100 + return !isset($context['limit']) || $context['limit'] <= 100; +} +``` + +--- + +## Event Broadcasting + +Mutations automatically broadcast events: + +**Events:** +- `RecordCreated` — after successful INSERT +- `RecordUpdated` — after successful UPDATE +- `RecordDeleted` — after successful DELETE + +**Customize events:** +```php +// Override to add custom events +public function save(Model $item): Model +{ + $isNew = !$item->getId(); + $result = parent::save($item); + + // Custom event + if ($isNew && $item->status === 'published') { + $this->serviceProvider->eventStrategy->broadcast( + new PostPublished($result) + ); + } + + return $result; +} +``` + +--- + +## Table Schema Management + +The handler ensures the table exists on first use: + +**Automatic table creation:** +- Checks if table exists +- Creates table if missing +- Updates table if schema version changed +- All transparent to your code + +**Table versioning:** +When you increment `$table->getTableVersion()`, the handler detects changes and updates the schema. + +--- + +## Error Handling + +The base handler includes error handling: + +**Exceptions thrown:** +- `RecordNotFoundException` — `find()` with invalid ID +- `QueryBuilderException` — malformed queries +- `DatabaseException` — connection/query errors + +**Logging:** +All errors are logged via `LoggerStrategy`: + +```php +try { + $post = $handler->find($id); +} catch (RecordNotFoundException $e) { + // Logged automatically: "Record not found: posts:123" +} +``` + +--- + +## Best Practices + +### Use WithDatastoreHandlerMethods Trait + +```php +// ✅ GOOD: use trait for standard implementations +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + use WithDatastoreHandlerMethods; +} + +// ❌ BAD: implement methods manually +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + public function find($id) { /* manual implementation */ } +} +``` + +### Set All Required Properties + +```php +// ✅ GOOD: all properties set +public function __construct(...) +{ + $this->model = Post::class; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; +} + +// ❌ BAD: missing properties +public function __construct(...) +{ + $this->table = $table; + // Missing model, adapter, etc. +} +``` + +### Keep Handlers Focused on Persistence + +```php +// ✅ GOOD: handler does storage only +public function save(Model $item): Model +{ + return parent::save($item); +} + +// ❌ BAD: handler contains business logic +public function save(Model $item): Model +{ + if ($item->publishedDate < new DateTime()) { + throw new ValidationException("Cannot publish in the past"); + } + return parent::save($item); +} +``` + +Business logic belongs in services, not handlers. + +--- + +## What's Next + +* [WithDatastoreHandlerMethods](/packages/database/handlers/with-datastore-handler-methods) — the trait that powers this base class +* [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/docs/packages/database/handlers/introduction.md b/public/docs/docs/packages/database/handlers/introduction.md new file mode 100644 index 0000000..6bc4e7f --- /dev/null +++ b/public/docs/docs/packages/database/handlers/introduction.md @@ -0,0 +1,218 @@ +# Database Handlers + +Database handlers are the **storage implementation layer** of PHPNomad's datastore architecture. They implement the `DatastoreHandler` interface contracts and are responsible for translating high-level datastore operations (like `save()`, `find()`, or `where()`) into concrete database queries, cache interactions, and event broadcasts. + +While [datastore interfaces](/packages/datastore/interfaces/introduction) define the **public API** your application depends on, handler interfaces define the **storage contract** that backends must implement. Handlers live in the Service layer and are specific to a storage technology—in this case, SQL databases. + +## What Handlers Do + +Handlers are where **persistence logic** lives. A database handler: + +* Converts models to storage arrays via [ModelAdapter](/packages/datastore/model-adapters). +* Executes SQL queries using [QueryBuilder](/packages/database/query-building). +* Manages table schema via [Table](/packages/database/tables/introduction) definitions. +* Optionally caches results using [CacheableService](/packages/database/caching-and-events). +* Broadcasts events after mutations using [EventStrategy](/packages/database/caching-and-events). + +Your [Core datastore implementation](/packages/datastore/core-implementation) delegates to a handler. The handler does the actual work of talking to the database, while the Core class provides the public interface your application uses. + +## Handler Interfaces + +Just like datastore interfaces, handler interfaces are **composable**. The base `DatastoreHandler` interface provides minimal operations, and you can extend with additional capabilities as needed. + +### `DatastoreHandler` + +The minimal contract every handler must implement. + +```php +interface DatastoreHandler +{ + public function get(array $args = []): iterable; + public function save(Model $item): Model; + public function delete(Model $item): void; +} +``` + +### Extension Interfaces + +Handlers can implement additional interfaces to support more operations: + +* **`DatastoreHandlerHasPrimaryKey`** — adds `find(int $id): Model` for primary key lookups. +* **`DatastoreHandlerHasWhere`** — adds `where(): DatastoreWhereQuery` for query-builder filtering. +* **`DatastoreHandlerHasCounts`** — adds `count(array $args = []): int` for counting records. + +These mirror the [datastore interfaces](/packages/datastore/interfaces/introduction) but live on the storage side. + +## Base Handler Implementation: `IdentifiableDatabaseDatastoreHandler` + +PHPNomad provides a **base handler class** that implements all the standard handler interfaces and includes built-in support for caching, events, and query building. + +**`IdentifiableDatabaseDatastoreHandler`** is the recommended starting point for most database-backed datastores. It implements: + +* `DatastoreHandler` +* `DatastoreHandlerHasPrimaryKey` +* `DatastoreHandlerHasWhere` +* `DatastoreHandlerHasCounts` + +This means you get `get()`, `save()`, `delete()`, `find()`, `where()`, and `count()` out of the box. + +### What you provide + +To use `IdentifiableDatabaseDatastoreHandler`, you extend it and provide: + +1. **Table definition** — a [Table](/packages/database/tables/introduction) instance that defines your schema. +2. **ModelAdapter** — converts between models and database arrays. +3. **Dependencies** — QueryBuilder, CacheableService, EventStrategy (injected via constructor). + +### Example: basic handler + +```php +queryBuilder + ->select('posts.*, authors.name as author_name') + ->from('posts') + ->join('authors', 'posts.author_id', 'authors.id') + ->where('posts.id', '=', $id) + ->first(); + + if (!$row) { + throw new RecordNotFoundException("Post {$id} not found"); + } + + return $this->adapter->toModel($row); + } +} +``` + +Here we override `find()` to add a join. The base handler's version would work, but this gives us author data in a single query. + +## Boilerplate Reduction with `WithDatastoreHandlerMethods` + +If you're not extending `IdentifiableDatabaseDatastoreHandler` (e.g., building a REST handler or custom storage backend), you can use the **`WithDatastoreHandlerMethods`** trait to generate standard implementations. + +This trait is analogous to the [decorator traits](/packages/datastore/traits/introduction) in the datastore package. It provides default implementations for common handler patterns. + +**Example:** + +```php +final class CustomPostHandler implements + DatastoreHandler, + DatastoreHandlerHasPrimaryKey +{ + use WithDatastoreHandlerMethods; + + // Trait provides get(), save(), delete(), find() based on + // abstract methods you define (like getTable(), getAdapter(), etc.) +} +``` + +This is useful when you need more control than `IdentifiableDatabaseDatastoreHandler` provides but don't want to write everything from scratch. + +## Handler Dependencies + +Handlers typically depend on several collaborators: + +### Required + +* **[Table](/packages/database/tables/introduction)** — schema definition (columns, indexes, primary key). +* **[ModelAdapter](/packages/datastore/model-adapters)** — converts between models and storage arrays. +* **[QueryBuilder](/packages/database/query-building)** — builds and executes SQL queries. + +### Optional (but recommended) + +* **[CacheableService](/packages/database/caching-and-events)** — automatic result caching with invalidation. +* **[EventStrategy](/packages/database/caching-and-events)** — broadcasts events after mutations. + +These are injected via the constructor and provided by your initializer. + +## Best Practices + +When working with database handlers: + +* **Extend `IdentifiableDatabaseDatastoreHandler` by default** — it handles the common cases correctly. +* **Override only when necessary** — if the base handler's behavior works, don't replace it. +* **Keep handlers storage-focused** — business logic belongs in services, not handlers. +* **Use caching and events** — they're built in and cost almost nothing to enable. +* **Match handler interfaces to datastore interfaces** — if your datastore implements `DatastoreHasPrimaryKey`, your handler should implement `DatastoreHandlerHasPrimaryKey`. + +## What's Next + +To understand how handlers fit into the larger architecture, see: + +* [Table Definitions](/packages/database/tables/introduction) — define schemas for handlers to use +* [Query Building](/packages/database/query-building) — how handlers execute SQL +* [Caching and Events](/packages/database/caching-and-events) — automatic caching and event broadcasting +* [Datastore Interfaces](/packages/datastore/interfaces/introduction) — the public contracts handlers support diff --git a/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md b/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md new file mode 100644 index 0000000..416388e --- /dev/null +++ b/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md @@ -0,0 +1,496 @@ +# WithDatastoreHandlerMethods Trait + +The `WithDatastoreHandlerMethods` trait provides the **actual implementation** of all standard datastore handler methods. When you extend `IdentifiableDatabaseDatastoreHandler` and use this trait, you get complete CRUD functionality with zero boilerplate. + +This trait is the workhorse that powers database handlers—it contains all the query building, caching, event broadcasting, and data conversion logic. + +## What It Provides + +The trait implements: + +* **CRUD operations** — `find()`, `get()`, `save()`, `delete()` +* **Query building** — constructs SQL queries with proper escaping +* **WHERE clause support** — `where()` method returning query builder +* **Counting** — `count()` method for efficient record counting +* **Automatic caching** — caches reads, invalidates on writes +* **Event broadcasting** — fires events on mutations +* **Error handling** — catches and logs database errors + +## Basic Usage + +```php +model = Post::class; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; + } +} +``` + +That's all you need—the trait provides all CRUD methods automatically. + +--- + +## Implemented Methods + +### `find(int $id): Model` + +**What it does:** +1. Generates cache key from ID +2. Checks cache for existing record +3. On cache miss: + - Builds SELECT query with WHERE id = ? + - Executes query via QueryStrategy + - Converts result row to model via adapter + - Stores in cache +4. Returns model + +**Throws:** `RecordNotFoundException` if ID doesn't exist + +**Generated SQL:** +```sql +SELECT * FROM wp_posts WHERE id = 123 +``` + +--- + +### `get(array $args = []): iterable` + +**What it does:** +1. Builds WHERE clause from `$args` array +2. Constructs SELECT query +3. Executes query +4. Converts each row to model via adapter +5. Returns iterable collection + +**Example args:** +```php +$args = [ + 'author_id' => 123, + 'status' => 'published', + 'limit' => 10, + 'offset' => 20 +]; +``` + +**Generated SQL:** +```sql +SELECT * FROM wp_posts +WHERE author_id = 123 AND status = 'published' +LIMIT 10 OFFSET 20 +``` + +--- + +### `save(Model $item): Model` + +**What it does:** +1. Converts model to array via adapter +2. Checks if model has primary key: + - **If NO key** → INSERT new record + - **If HAS key** → UPDATE existing record +3. Executes query via QueryStrategy +4. Invalidates cache for this record +5. Broadcasts event: + - `RecordCreated` for INSERT + - `RecordUpdated` for UPDATE +6. Returns model with ID populated + +**INSERT SQL:** +```sql +INSERT INTO wp_posts (title, content, author_id, published_date) +VALUES ('Title', 'Content', 123, '2024-01-01 12:00:00') +``` + +**UPDATE SQL:** +```sql +UPDATE wp_posts +SET title = 'New Title', content = 'New Content' +WHERE id = 123 +``` + +--- + +### `delete(Model $item): void` + +**What it does:** +1. Extracts primary key from model +2. Builds DELETE query +3. Executes query +4. Invalidates cache for this record +5. Broadcasts `RecordDeleted` event + +**Generated SQL:** +```sql +DELETE FROM wp_posts WHERE id = 123 +``` + +--- + +### `where(): DatastoreWhereQuery` + +**What it does:** +Returns a query builder instance configured for the handler's table. + +**Returns:** Object with fluent API: +- `equals(field, value)` +- `greaterThan(field, value)` +- `lessThan(field, value)` +- `in(field, ...values)` +- `like(field, pattern)` +- `orderBy(field, direction)` +- `limit(count)` +- `offset(count)` +- `getResults()` + +**Example:** +```php +$posts = $handler + ->where() + ->equals('status', 'published') + ->greaterThan('view_count', 100) + ->orderBy('published_date', 'DESC') + ->limit(10) + ->getResults(); +``` + +--- + +### `count(array $args = []): int` + +**What it does:** +1. Builds WHERE clause from `$args` +2. Constructs SELECT COUNT(*) query +3. Executes query +4. Returns integer count + +**Generated SQL:** +```sql +SELECT COUNT(*) FROM wp_posts +WHERE status = 'published' +``` + +--- + +## Caching Implementation + +The trait integrates with `CacheableService`: + +### Cache Keys + +**Single record:** +```php +// Cache key: posts:123 +$cacheKey = $this->table->getTableName() . ':' . $id; +``` + +**List queries:** +```php +// Cache key: posts:list:md5(serialize($args)) +$cacheKey = $this->table->getTableName() . ':list:' . md5(serialize($args)); +``` + +### Cache Invalidation + +On `save()` or `delete()`: +```php +// Clear single record cache +$this->serviceProvider->cacheableService->forget(['id' => $id]); + +// Clear all list caches for this table +$this->serviceProvider->cacheableService->forgetMatching( + $this->table->getTableName() . ':list:*' +); +``` + +--- + +## Event Broadcasting + +The trait broadcasts standard events: + +### RecordCreated + +Fired after successful INSERT: + +```php +$this->serviceProvider->eventStrategy->broadcast( + new RecordCreated( + table: $this->table->getTableName(), + model: $savedModel + ) +); +``` + +### RecordUpdated + +Fired after successful UPDATE: + +```php +$this->serviceProvider->eventStrategy->broadcast( + new RecordUpdated( + table: $this->table->getTableName(), + model: $savedModel + ) +); +``` + +### RecordDeleted + +Fired after successful DELETE: + +```php +$this->serviceProvider->eventStrategy->broadcast( + new RecordDeleted( + table: $this->table->getTableName(), + model: $deletedModel + ) +); +``` + +--- + +## Query Building Implementation + +The trait uses `QueryBuilder` and `ClauseBuilder` from the service provider: + +### Building SELECT Queries + +```php +protected function buildSelectQuery(array $args): string +{ + $clause = $this->serviceProvider->clauseBuilder + ->useTable($this->table); + + // Add WHERE conditions from args + foreach ($args as $field => $value) { + if ($field === 'limit' || $field === 'offset') { + continue; // Handle separately + } + $clause->where($field, '=', $value); + } + + $query = $this->serviceProvider->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause); + + // Handle pagination + if (isset($args['limit'])) { + $query->limit($args['limit']); + } + if (isset($args['offset'])) { + $query->offset($args['offset']); + } + + return $query->build(); +} +``` + +--- + +## Error Handling + +The trait includes comprehensive error handling: + +### RecordNotFoundException + +Thrown when `find()` doesn't locate a record: + +```php +if (!$row) { + throw new RecordNotFoundException( + "Record not found: {$this->table->getTableName()}:{$id}" + ); +} +``` + +### Database Errors + +Caught and logged: + +```php +try { + $result = $this->serviceProvider->queryStrategy->execute($sql); +} catch (DatabaseException $e) { + $this->serviceProvider->loggerStrategy->error( + 'Database query failed', + [ + 'table' => $this->table->getTableName(), + 'query' => $sql, + 'error' => $e->getMessage() + ] + ); + throw $e; +} +``` + +--- + +## Overriding Trait Methods + +You can override any trait method to customize behavior: + +### Override `save()` with Custom Logic + +```php +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + use WithDatastoreHandlerMethods { + save as private traitSave; // Rename trait method + } + + public function save(Model $item): Model + { + // Custom pre-save logic + $this->validatePost($item); + + // Call trait's save + $result = $this->traitSave($item); + + // Custom post-save logic + $this->updateSearchIndex($result); + + return $result; + } + + private function validatePost(Model $post): void + { + if (empty($post->title)) { + throw new ValidationException('Title required'); + } + } +} +``` + +### Override `find()` with Custom Caching + +```php +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + use WithDatastoreHandlerMethods; + + public function find(int $id): Model + { + // Custom cache key + $cacheKey = "posts:full:{$id}"; + + return $this->serviceProvider->cacheableService->getWithCache( + operation: 'find', + context: ['key' => $cacheKey], + callback: function() use ($id) { + // Execute query + $sql = $this->buildFindQuery($id); + $row = $this->serviceProvider->queryStrategy->querySingle($sql); + + if (!$row) { + throw new RecordNotFoundException("Post {$id} not found"); + } + + return $this->modelAdapter->toModel($row); + } + ); + } +} +``` + +--- + +## Required Properties + +The trait expects these properties to be set by your handler: + +```php +protected string $model; // Model class name +protected Table $table; // Table definition +protected ModelAdapter $modelAdapter; // Model adapter +protected DatabaseServiceProvider $serviceProvider; // Service provider +protected TableSchemaService $tableSchemaService; // Schema service +``` + +If any are missing, trait methods will fail with errors. + +--- + +## Best Practices + +### Always Use the Trait + +```php +// ✅ GOOD: use trait +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + use WithDatastoreHandlerMethods; +} + +// ❌ BAD: manual implementation +class PostHandler extends IdentifiableDatabaseDatastoreHandler +{ + public function find(int $id): Model + { + // Reimplementing what the trait already does + } +} +``` + +### Override Only When Necessary + +```php +// ✅ GOOD: override for specific needs +public function save(Model $item): Model +{ + $this->logSaveAttempt($item); + return $this->traitSave($item); +} + +// ❌ BAD: override without adding value +public function save(Model $item): Model +{ + return $this->traitSave($item); // No customization +} +``` + +### Use Proper Method Renaming + +```php +// ✅ GOOD: rename trait method to avoid conflicts +use WithDatastoreHandlerMethods { + save as private traitSave; +} + +public function save(Model $item): Model +{ + return $this->traitSave($item); +} + +// ❌ BAD: call parent (doesn't work with traits) +public function save(Model $item): Model +{ + return parent::save($item); // Error! +} +``` + +--- + +## What's Next + +* [IdentifiableDatabaseDatastoreHandler](/packages/database/handlers/identifiable-database-datastore-handler) — the base class that uses this trait +* [Database Handlers Introduction](/packages/database/handlers/introduction) — handler architecture overview +* [Query Building](/packages/database/query-building) — understanding query construction +* [Caching and Events](/packages/database/caching-and-events) — how caching and events work diff --git a/public/docs/docs/packages/database/included-factories/date-created-factory.md b/public/docs/docs/packages/database/included-factories/date-created-factory.md new file mode 100644 index 0000000..62bb7e2 --- /dev/null +++ b/public/docs/docs/packages/database/included-factories/date-created-factory.md @@ -0,0 +1,171 @@ +# DateCreatedFactory + +The `DateCreatedFactory` creates a standardized **timestamp column** that stores when a record was created. This factory provides consistent creation timestamp tracking across all entity tables. + +## Basic Usage + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } +} +``` + +## Generated Column Definition + +The factory creates: + +**Column name:** `date_created` +**Column type:** `DATETIME` +**Properties:** `NOT NULL DEFAULT CURRENT_TIMESTAMP` + +**Generated SQL:** +```sql +CREATE TABLE wp_posts ( + id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + date_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +## Automatic Timestamp Behavior + +When you insert a new record, the database automatically sets `date_created`: + +```php +// Create and save new post +$newPost = new Post(null, 'My Title', 'Content', 123, null); +$savedPost = $handler->save($newPost); + +// Database automatically set date_created +echo $savedPost->dateCreated->format('Y-m-d H:i:s'); +// Output: 2024-01-15 14:23:45 +``` + +**Generated INSERT:** +```sql +INSERT INTO wp_posts (title, content, author_id) +VALUES ('My Title', 'Content', 123); +-- date_created is automatically set to current timestamp +``` + +## Why Use This Factory? + +**Consistency and auditability:** +```php +// ✅ GOOD: all tables track creation time the same way +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } +} + +class UsersTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('username', 'VARCHAR', [100], 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } +} + +// ❌ BAD: inconsistent timestamp columns +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('created_at', 'TIMESTAMP', null, 'NOT NULL'), + ]; + } +} + +class UsersTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('creation_date', 'DATETIME', null, 'NULL'), + // Different name, nullable! + ]; + } +} +``` + +## Common Usage Pattern + +```php +toColumn(), + + // Business columns + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('author_id', 'BIGINT', null, 'NOT NULL'), + + // Timestamp columns at the end + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } +} +``` + +## Querying by Creation Date + +```php +// Find posts created in the last 7 days +$recentPosts = $handler + ->where() + ->greaterThan('date_created', (new DateTime('-7 days'))->format('Y-m-d H:i:s')) + ->orderBy('date_created', 'DESC') + ->getResults(); + +// Count posts created today +$todayCount = $handler->count([ + 'date_created >=' => (new DateTime('today'))->format('Y-m-d H:i:s') +]); +``` + +## What's Next + +* [DateModifiedFactory](/packages/database/included-factories/date-modified-factory) — automatic timestamp on record updates +* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column +* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/included-factories/date-modified-factory.md b/public/docs/docs/packages/database/included-factories/date-modified-factory.md new file mode 100644 index 0000000..46cfd87 --- /dev/null +++ b/public/docs/docs/packages/database/included-factories/date-modified-factory.md @@ -0,0 +1,213 @@ +# DateModifiedFactory + +The `DateModifiedFactory` creates a standardized **timestamp column** that automatically updates whenever a record is modified. This factory provides consistent update timestamp tracking across all entity tables. + +## Basic Usage + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } +} +``` + +## Generated Column Definition + +The factory creates: + +**Column name:** `date_modified` +**Column type:** `DATETIME` +**Properties:** `NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` + +**Generated SQL:** +```sql +CREATE TABLE wp_posts ( + id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + date_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + date_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); +``` + +## Automatic Timestamp Behavior + +The `date_modified` column automatically updates on every UPDATE: + +```php +// Create new post +$newPost = new Post(null, 'Title', 'Content', 123, null); +$savedPost = $handler->save($newPost); + +// Initial timestamps are the same +echo $savedPost->dateCreated->format('Y-m-d H:i:s'); +// Output: 2024-01-15 14:23:45 +echo $savedPost->dateModified->format('Y-m-d H:i:s'); +// Output: 2024-01-15 14:23:45 + +// Update the post later +sleep(5); +$savedPost->title = 'New Title'; +$updatedPost = $handler->save($savedPost); + +// date_created unchanged, date_modified updated +echo $updatedPost->dateCreated->format('Y-m-d H:i:s'); +// Output: 2024-01-15 14:23:45 (unchanged) +echo $updatedPost->dateModified->format('Y-m-d H:i:s'); +// Output: 2024-01-15 14:23:50 (updated) +``` + +**Generated UPDATE:** +```sql +UPDATE wp_posts +SET title = 'New Title', content = 'Content' +WHERE id = 123; +-- date_modified is automatically set to current timestamp +``` + +## Why Use This Factory? + +**Automatic change tracking:** +```php +// ✅ GOOD: all tables track modifications the same way +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } +} + +// ❌ BAD: manual timestamp management +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('updated_at', 'DATETIME', null, 'NULL'), + // Not automatic - requires manual updates in code + ]; + } +} + +// ❌ BAD: application code managing timestamps +public function save(Model $item): Model +{ + $item->dateModified = new DateTime(); // Manual! + return parent::save($item); +} +``` + +## Common Usage Pattern + +```php +toColumn(), + + // Business columns + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('author_id', 'BIGINT', null, 'NOT NULL'), + new Column('status', 'VARCHAR', [20], 'NOT NULL'), + + // Timestamp columns at the end + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } +} +``` + +## Querying by Modification Date + +```php +// Find posts modified in the last hour +$recentlyModified = $handler + ->where() + ->greaterThan('date_modified', (new DateTime('-1 hour'))->format('Y-m-d H:i:s')) + ->orderBy('date_modified', 'DESC') + ->getResults(); + +// Find stale posts (not modified in 90 days) +$stalePosts = $handler + ->where() + ->lessThan('date_modified', (new DateTime('-90 days'))->format('Y-m-d H:i:s')) + ->getResults(); + +// Check if post was modified after creation +foreach ($posts as $post) { + if ($post->dateModified > $post->dateCreated) { + echo "Post {$post->id} has been edited\n"; + } +} +``` + +## Change Detection + +Use `date_modified` to detect and react to changes: + +```php +// Cache invalidation based on modification time +public function getCachedPost(int $id): Post +{ + $cacheKey = "post:{$id}"; + $cached = $this->cache->get($cacheKey); + + if ($cached) { + $current = $this->handler->find($id); + + // Invalidate if modified since cache + if ($current->dateModified > $cached->dateModified) { + $this->cache->forget($cacheKey); + $this->cache->set($cacheKey, $current); + return $current; + } + + return $cached; + } + + $post = $this->handler->find($id); + $this->cache->set($cacheKey, $post); + return $post; +} +``` + +## What's Next + +* [DateCreatedFactory](/packages/database/included-factories/date-created-factory) — automatic timestamp on record creation +* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column +* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/included-factories/foreign-key-factory.md b/public/docs/docs/packages/database/included-factories/foreign-key-factory.md new file mode 100644 index 0000000..ea45e04 --- /dev/null +++ b/public/docs/docs/packages/database/included-factories/foreign-key-factory.md @@ -0,0 +1,266 @@ +# ForeignKeyFactory + +The `ForeignKeyFactory` creates a standardized **foreign key column** that references another table's primary key. This factory provides consistent relationship column definitions across all entity tables. + +## Basic Usage + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new ForeignKeyFactory('author'))->toColumn(), + ]; + } +} +``` + +## Generated Column Definition + +**Constructor:** `new ForeignKeyFactory('author')` + +**Column name:** `author_id` +**Column type:** `BIGINT` +**Properties:** `NOT NULL` + +**Generated SQL:** +```sql +CREATE TABLE wp_posts ( + id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + author_id BIGINT NOT NULL +); +``` + +## Naming Convention + +The factory automatically appends `_id` to your entity name: + +```php +// Input: 'author' → Output: 'author_id' +(new ForeignKeyFactory('author'))->toColumn() + +// Input: 'category' → Output: 'category_id' +(new ForeignKeyFactory('category'))->toColumn() + +// Input: 'parent_post' → Output: 'parent_post_id' +(new ForeignKeyFactory('parent_post'))->toColumn() +``` + +## Why Use This Factory? + +**Consistency across relationships:** +```php +// ✅ GOOD: all foreign keys follow same pattern +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + (new ForeignKeyFactory('author'))->toColumn(), + (new ForeignKeyFactory('category'))->toColumn(), + ]; + } +} + +class CommentsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new ForeignKeyFactory('post'))->toColumn(), + (new ForeignKeyFactory('author'))->toColumn(), + ]; + } +} + +// ❌ BAD: inconsistent foreign key definitions +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('author', 'INT', null, 'NOT NULL'), // Wrong type + new Column('categoryId', 'BIGINT', null, 'NOT NULL'), // Wrong naming + ]; + } +} + +class CommentsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('post_id', 'VARCHAR', [50], 'NULL'), // Wrong type, nullable + ]; + } +} +``` + +## Common Usage Pattern + +```php +toColumn(), + + // Business columns + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('slug', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('status', 'VARCHAR', [20], 'NOT NULL'), + + // Foreign keys + (new ForeignKeyFactory('author'))->toColumn(), + (new ForeignKeyFactory('category'))->toColumn(), + + // Timestamps + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + // Index foreign keys for join performance + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['category_id'], 'category_idx', 'INDEX'), + new Index(['slug'], 'slug_unique', 'UNIQUE'), + ]; + } +} +``` + +## Self-Referential Foreign Keys + +Use descriptive names for self-referential relationships: + +```php +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + + // Self-referential foreign key + (new ForeignKeyFactory('parent_post'))->toColumn(), + // Creates column: parent_post_id + ]; + } + + public function getIndices(): array + { + return [ + new Index(['parent_post_id'], 'parent_post_idx', 'INDEX'), + ]; + } +} +``` + +## Nullable Foreign Keys + +For optional relationships, make the column nullable: + +```php +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + + // Required relationship + (new ForeignKeyFactory('author'))->toColumn(), + + // Optional relationship - override constraint + new Column('featured_image_id', 'BIGINT', null, 'NULL'), + ]; + } +} +``` + +## Junction Table Usage + +Foreign key factories work perfectly in junction tables: + +```php +class PostTagsTable extends Table +{ + public function getColumns(): array + { + return [ + (new ForeignKeyFactory('post'))->toColumn(), + (new ForeignKeyFactory('tag'))->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + // Compound primary key + new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), + + // Individual indexes for joins + new Index(['post_id'], 'post_idx', 'INDEX'), + new Index(['tag_id'], 'tag_idx', 'INDEX'), + ]; + } +} +``` + +## Querying with Foreign Keys + +```php +// Find posts by author +$authorPosts = $handler->get(['author_id' => 123]); + +// Count posts per category +$categoryCount = $handler->count(['category_id' => 5]); + +// Find posts using query builder +$posts = $handler + ->where() + ->equals('author_id', 123) + ->equals('status', 'published') + ->orderBy('date_created', 'DESC') + ->getResults(); +``` + +## What's Next + +* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many relationships +* [Table Class](/packages/database/tables/table-class) — complete table definition reference +* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column diff --git a/public/docs/docs/packages/database/included-factories/introduction.md b/public/docs/docs/packages/database/included-factories/introduction.md new file mode 100644 index 0000000..98e0ee6 --- /dev/null +++ b/public/docs/docs/packages/database/included-factories/introduction.md @@ -0,0 +1,367 @@ +# Column and Index Factories + +PHPNomad's database package provides **pre-built factories** for common column and index patterns. These factories eliminate repetitive schema definitions and ensure consistency across tables. Instead of manually defining columns with `Column` every time, you use specialized factories that encode best practices and standard patterns. + +Factories are **building blocks** you compose in your [table definitions](/packages/database/tables/introduction). Each factory produces properly configured column or index objects with sensible defaults, while still allowing customization when needed. + +## Why Factories Exist + +Without factories, every table would repeat the same patterns: + +```php +// Repetitive: defining a primary key in every table +$this->columnFactory->int('id', 11)->autoIncrement()->notNull(); + +// Repetitive: defining timestamps in every table +$this->columnFactory->datetime('created_at')->default('CURRENT_TIMESTAMP')->notNull(); +$this->columnFactory->datetime('updated_at') + ->default('CURRENT_TIMESTAMP') + ->onUpdate('CURRENT_TIMESTAMP') + ->notNull(); +``` + +With factories, these become: + +```php +$this->primaryKeyFactory->create('id'); +$this->dateCreatedFactory->create('created_at'); +$this->dateModifiedFactory->create('updated_at'); +``` + +This reduces duplication, prevents typos, and makes table definitions scannable. + +## Column Factories + +PHPNomad provides four specialized column factories for the most common patterns. + +### `PrimaryKeyFactory` + +Creates **auto-increment integer primary keys**—the standard pattern for identifying rows. + +**What it creates:** +* `INT(11)` column (or `BIGINT` if specified) +* `NOT NULL` constraint +* `AUTO_INCREMENT` attribute +* Primary key designation + +**Usage:** + +```php +final class PostTable implements Table +{ + public function __construct( + private Column $columnFactory, + private PrimaryKeyFactory $primaryKeyFactory + ) {} + + public function getColumns(): array + { + return [ + $this->primaryKeyFactory->create('id'), + // other columns... + ]; + } + + public function getPrimaryKey(): PrimaryKey + { + return $this->primaryKeyFactory->create('id'); + } +} +``` + +**Customization:** + +```php +// Use BIGINT for very large tables +$this->primaryKeyFactory->create('id', size: 'big'); +``` + +--- + +### `DateCreatedFactory` + +Creates **timestamp columns** that automatically capture when a row was created. + +**What it creates:** +* `DATETIME` column +* `DEFAULT CURRENT_TIMESTAMP` +* `NOT NULL` constraint + +**Usage:** + +```php +public function getColumns(): array +{ + return [ + $this->primaryKeyFactory->create('id'), + $this->columnFactory->string('title', 255)->notNull(), + $this->dateCreatedFactory->create('created_at'), + ]; +} +``` + +This column is set once when the row is inserted and never changes. + +**When to use:** +* Audit trails (knowing when records were added) +* Sorting by creation time +* Tracking data freshness + +--- + +### `DateModifiedFactory` + +Creates **timestamp columns** that automatically update whenever a row changes. + +**What it creates:** +* `DATETIME` column +* `DEFAULT CURRENT_TIMESTAMP` +* `ON UPDATE CURRENT_TIMESTAMP` +* `NOT NULL` constraint + +**Usage:** + +```php +public function getColumns(): array +{ + return [ + $this->primaryKeyFactory->create('id'), + $this->columnFactory->string('title', 255)->notNull(), + $this->dateCreatedFactory->create('created_at'), + $this->dateModifiedFactory->create('updated_at'), + ]; +} +``` + +This column is updated automatically by the database every time the row is modified. + +**When to use:** +* Tracking when records were last changed +* Cache invalidation (e.g., "invalidate if `updated_at` is newer than cached timestamp") +* Detecting stale data + +--- + +### `ForeignKeyFactory` + +Creates **foreign key columns** that reference primary keys in other tables. + +**What it creates:** +* `INT` column matching the referenced table's primary key type +* `NOT NULL` constraint (by default) +* Foreign key constraint pointing to the target table +* Optional `ON DELETE` and `ON UPDATE` rules + +**Usage:** + +```php +public function __construct( + private Column $columnFactory, + private ForeignKeyFactory $foreignKeyFactory +) {} + +public function getColumns(): array +{ + return [ + $this->primaryKeyFactory->create('id'), + $this->columnFactory->string('title', 255)->notNull(), + + // Reference the 'id' column in the 'authors' table + $this->foreignKeyFactory->create('author_id', 'authors', 'id'), + + $this->dateCreatedFactory->create('created_at'), + ]; +} +``` + +**Customization:** + +```php +// Allow NULL (optional foreign key) +$this->foreignKeyFactory->create('author_id', 'authors', 'id', nullable: true); + +// Cascade deletes (delete posts when author is deleted) +$this->foreignKeyFactory->create('author_id', 'authors', 'id', onDelete: 'CASCADE'); + +// Set NULL on delete (orphan posts when author is deleted) +$this->foreignKeyFactory->create('author_id', 'authors', 'id', onDelete: 'SET NULL', nullable: true); +``` + +**When to use:** +* Relationships between tables (posts → authors, orders → customers) +* Enforcing referential integrity at the database level +* Junction tables (many-to-many relationships) + +--- + +## Index Factory + +Indexes improve query performance by allowing the database to find rows faster. The `IndexFactory` creates single-column and composite indexes. + +### `IndexFactory::create()` + +Creates a **single-column index**. + +**Usage:** + +```php +public function __construct( + private IndexFactory $indexFactory +) {} + +public function getIndexes(): array +{ + return [ + // Index on author_id for "find all posts by author" queries + $this->indexFactory->create('idx_author', ['author_id']), + + // Index on published_date for sorting and range queries + $this->indexFactory->create('idx_published', ['published_date']), + ]; +} +``` + +### `IndexFactory::composite()` + +Creates a **composite index** that spans multiple columns. These are useful for queries that filter on multiple fields at once. + +**Usage:** + +```php +public function getIndexes(): array +{ + return [ + // Composite index for "posts by author, sorted by publish date" + $this->indexFactory->composite('idx_author_date', ['author_id', 'published_date']), + ]; +} +``` + +**When to use composite indexes:** +* Queries with multiple `WHERE` conditions +* Queries that filter and sort on different columns +* Covering indexes (include all columns needed by a query) + +**Note:** Column order matters. The index `['author_id', 'published_date']` can serve: +* `WHERE author_id = 123` +* `WHERE author_id = 123 ORDER BY published_date` + +But not: +* `WHERE published_date > '2024-01-01'` (doesn't start with `author_id`) + +--- + +## Real-World Example: Full Table with Factories + +Here's a complete table that uses all the factories: + +```php +primaryKeyFactory->create('id'), + + // Regular columns + $this->columnFactory->string('title', 255)->notNull(), + $this->columnFactory->text('content')->notNull(), + $this->columnFactory->string('slug', 255)->notNull()->unique(), + $this->columnFactory->datetime('published_date')->nullable(), + + // Foreign key to authors table + $this->foreignKeyFactory->create('author_id', 'authors', 'id'), + + // Timestamps + $this->dateCreatedFactory->create('created_at'), + $this->dateModifiedFactory->create('updated_at'), + ]; + } + + public function getPrimaryKey(): PrimaryKey + { + return $this->primaryKeyFactory->create('id'); + } + + public function getIndexes(): array + { + return [ + // Single-column indexes + $this->indexFactory->create('idx_author', ['author_id']), + $this->indexFactory->create('idx_published', ['published_date']), + $this->indexFactory->create('idx_slug', ['slug']), // unique already indexed, but explicit + + // Composite index for "author's published posts, sorted by date" + $this->indexFactory->composite('idx_author_published', [ + 'author_id', + 'published_date' + ]), + ]; + } +} +``` + +This table uses factories for: +* Primary key (`id`) +* Foreign key (`author_id`) +* Timestamps (`created_at`, `updated_at`) +* Indexes (single and composite) + +The result is a clean, readable schema definition with minimal boilerplate. + +## Best Practices + +When using factories: + +* **Inject factories via constructor** — let the DI container provide them. +* **Use factories for standard patterns** — don't manually define primary keys or timestamps. +* **Customize when needed** — factories accept parameters for common variations (nullable, cascade, etc.). +* **Name indexes descriptively** — use `idx_` prefix and column names (e.g., `idx_author_date`). +* **Add indexes on foreign keys** — always index columns used in joins. +* **Consider composite indexes** — they're more efficient than multiple single-column indexes for multi-condition queries. + +## When NOT to Use Factories + +Factories are for **common patterns**. For unique or domain-specific columns, use the base `Column` factory: + +```php +// Custom columns that don't fit factory patterns +$this->columnFactory->decimal('price', 10, 2)->notNull(), +$this->columnFactory->json('metadata')->nullable(), +$this->columnFactory->enum('status', ['draft', 'published', 'archived'])->default('draft'), +``` + +Factories reduce boilerplate for **90% of columns**. The remaining 10% are domain-specific and should use `Column` directly. + +## What's Next + +To see how these factories are used in context, see: + +* [Table Definitions](/packages/database/tables/introduction) — how factories compose into full table schemas +* [Individual Factory Docs](/packages/database/included-factories/primary-key-factory) — detailed API reference for each factory +* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/included-factories/primary-key-factory.md b/public/docs/docs/packages/database/included-factories/primary-key-factory.md new file mode 100644 index 0000000..be5ed16 --- /dev/null +++ b/public/docs/docs/packages/database/included-factories/primary-key-factory.md @@ -0,0 +1,136 @@ +# PrimaryKeyFactory + +The `PrimaryKeyFactory` creates a standardized **auto-incrementing primary key column** named `id`. This factory provides a consistent primary key definition across all entity tables. + +## Basic Usage + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + ]; + } +} +``` + +## Generated Column Definition + +The factory creates: + +**Column name:** `id` +**Column type:** `BIGINT` +**Properties:** `AUTO_INCREMENT NOT NULL PRIMARY KEY` + +**Generated SQL:** +```sql +CREATE TABLE wp_posts ( + id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL +); +``` + +## Why Use This Factory? + +**Consistency across tables:** +```php +// ✅ GOOD: all tables have same primary key definition +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + // ... + ]; + } +} + +class UsersTable extends Table +{ + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + // ... + ]; + } +} + +// ❌ BAD: manual definitions can vary +class PostsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('id', 'BIGINT', null, 'AUTO_INCREMENT NOT NULL PRIMARY KEY'), + // ... + ]; + } +} + +class UsersTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('user_id', 'INT', null, 'AUTO_INCREMENT NOT NULL PRIMARY KEY'), + // Different name and type! + ]; + } +} +``` + +## Common Usage Pattern + +```php +toColumn(), + + // Business columns + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('slug', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('author_id', 'BIGINT', null, 'NOT NULL'), + ]; + } + + public function getIndices(): array + { + return [ + // No need to define primary key index - it's automatic + new Index(['slug'], 'slug_unique', 'UNIQUE'), + new Index(['author_id'], 'author_idx', 'INDEX'), + ]; + } +} +``` + +## What's Next + +* [DateCreatedFactory](/packages/database/included-factories/date-created-factory) — automatic timestamp on record creation +* [DateModifiedFactory](/packages/database/included-factories/date-modified-factory) — automatic timestamp on record updates +* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/introduction.md b/public/docs/docs/packages/database/introduction.md new file mode 100644 index 0000000..7c8cca8 --- /dev/null +++ b/public/docs/docs/packages/database/introduction.md @@ -0,0 +1,424 @@ +--- +id: database-introduction +slug: docs/packages/database/introduction +title: Database Package +doc_type: explanation +status: active +language: en +owner: docs-team +last_reviewed: 2025-01-08 +applies_to: ["all"] +canonical: true +summary: The database package provides concrete database implementations of the datastore pattern with table schemas, query building, caching, and event broadcasting. +llm_summary: > + phpnomad/database implements the datastore interfaces for SQL databases. Define table schemas, create handlers that + query databases, leverage automatic caching and event broadcasting, and use query builders for complex conditions. + Works with MySQL, MariaDB, and compatible databases. +questions_answered: + - What is the database package? + - What does phpnomad/database provide? + - How does database persistence work? + - What are the key concepts in database datastores? + - When should I use the database package? + - How does caching work in database handlers? + - What events are broadcast? +audience: + - developers + - backend engineers + - database developers +tags: + - database + - package-overview + - persistence +llm_tags: + - database-package + - sql-persistence + - database-handlers +keywords: + - phpnomad database + - database package + - database persistence + - SQL datastores +related: + - ../../core-concepts/overview-and-architecture + - ../../core-concepts/getting-started-tutorial + - ../datastore/introduction +see_also: + - handlers/introduction + - tables/introduction + - table-schema-definition + - ../logger/introduction +noindex: false +--- + +# Database + +`phpnomad/database` provides **concrete database implementations** of the datastore pattern. It's designed to let you define **table schemas, execute queries, and persist models** in SQL databases while maintaining the storage-agnostic abstractions from `phpnomad/datastore`. + +At its core: + +* **Table classes** define database schemas including columns, indices, and versioning. +* **Database handlers** implement datastore interfaces with actual SQL queries. +* **Query builders** construct SQL from condition arrays without writing raw queries. +* **Caching** automatically stores retrieved models to reduce database hits. +* **Event broadcasting** emits events when records are created, updated, or deleted. + +By implementing the datastore interfaces with database-backed handlers, you get full CRUD operations, complex querying, caching, and event notifications—all while keeping your domain logic portable. + +--- + +## Key ideas at a glance + +* **DatabaseDatastoreHandler** — Base class for database-backed handlers that query tables. +* **Table** — Schema definition including columns, indices, and versioning for migrations. +* **QueryBuilder** — Constructs SQL queries from condition arrays and parameters. +* **CacheableService** — Automatic caching layer that stores retrieved models by identity. +* **EventStrategy** — Broadcasts RecordCreated, RecordUpdated, RecordDeleted events. +* **WithDatastoreHandlerMethods** — Trait providing complete CRUD implementation. + +--- + +## The database persistence lifecycle + +When your application performs a data operation through a database-backed datastore, the request flows through these layers: + +``` +Application → Datastore → DatabaseHandler → QueryBuilder → Database → ModelAdapter → Model + ↓ ↑ + Cache Check Cache Store + ↓ + Event Broadcast +``` + +### Application layer + +Your application calls methods on the Datastore interface: + +```php +$post = $postDatastore->find(123); +$posts = $postDatastore->where([ + ['column' => 'status', 'operator' => '=', 'value' => 'published'] +]); +``` + +### Datastore layer + +The Datastore delegates to its database handler: + +```php +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastorePrimaryKeyDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatabaseDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } +} +``` + +### Database handler layer + +The **Database Handler** extends `IdentifiableDatabaseDatastoreHandler` and uses the `WithDatastoreHandlerMethods` trait to implement all standard operations: + +```php +class PostDatabaseDatastoreHandler extends IdentifiableDatabaseDatastoreHandler + implements PostDatastoreHandler +{ + use WithDatastoreHandlerMethods; + + public function __construct( + DatabaseServiceProvider $serviceProvider, + PostsTable $table, + PostAdapter $adapter, + TableSchemaService $tableSchemaService + ) { + $this->serviceProvider = $serviceProvider; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->tableSchemaService = $tableSchemaService; + $this->model = Post::class; + } +} +``` + +### Cache check + +Before querying the database, the handler checks the cache: + +```php +$cacheKey = ['identities' => ['id' => 123], 'type' => Post::class]; +if ($cached = $this->serviceProvider->cacheableService->get($cacheKey)) { + return $cached; // Cache hit, skip database +} +``` + +### Query building + +The handler uses `QueryBuilder` to construct SQL: + +```php +$query = $this->serviceProvider->queryBuilder + ->select() + ->from($this->table) + ->where('id', '=', 123) + ->build(); +``` + +The `QueryBuilder` generates parameterized SQL with placeholders to prevent injection. + +### Database execution + +The query executes against the database and returns raw rows: + +```php +$row = $this->serviceProvider->queryStrategy->execute($query); +``` + +### Model conversion + +The `ModelAdapter` converts the raw row to a model: + +```php +$post = $this->modelAdapter->toModel($row); +``` + +### Cache storage + +The model is stored in cache for future requests: + +```php +$this->serviceProvider->cacheableService->set($cacheKey, $post); +``` + +### Event broadcasting + +Events are broadcast after successful operations: + +```php +// After create +$this->serviceProvider->eventStrategy->dispatch(new RecordCreated($post)); + +// After update +$this->serviceProvider->eventStrategy->dispatch(new RecordUpdated($post)); + +// After delete +$this->serviceProvider->eventStrategy->dispatch(new RecordDeleted($post)); +``` + +--- + +## Why use the database package + +### Automatic caching + +Every find operation checks cache first. Subsequent requests for the same record return instantly without database queries. Cache invalidates automatically on updates and deletes. + +### Event-driven architecture + +Database operations broadcast events that other systems can listen to. Create audit logs, send notifications, update search indices, or trigger workflows—all decoupled from the handler. + +### Query abstraction + +No raw SQL in your handlers. Build queries with arrays and let `QueryBuilder` handle SQL generation, parameterization, and escaping. + +### Schema versioning + +Table definitions include version numbers. When schemas change, migrations can detect version differences and update tables accordingly. + +### Standardized patterns + +All database handlers follow the same pattern: extend the base, inject dependencies, implement interfaces. This consistency makes codebases predictable and maintainable. + +--- + +## Core components + +### Database handlers + +Handlers extend `IdentifiableDatabaseDatastoreHandler` and use `WithDatastoreHandlerMethods` to implement CRUD operations. They connect table schemas to datastore interfaces. + +See [Database Handlers](handlers/introduction) for complete documentation. + +### Table schemas + +Table classes extend `Table` and define columns, indices, and versioning. They specify how entities are stored in the database without writing DDL. + +```php +class PostsTable extends Table +{ + public function getUnprefixedName(): string + { + return 'posts'; + } + + public function getColumns(): array + { + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + new Index(['title'], 'idx_posts_title'), + ]; + } +} +``` + +See [Table Schema Definition](table-schema-definition) and [Tables](tables/introduction) for complete documentation. + +### Query building + +The `QueryBuilder` converts condition arrays and parameters into SQL queries. Conditions use a structured format that supports AND/OR logic, operators, and nested groups. + +```php +$posts = $handler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'status', 'operator' => '=', 'value' => 'published'], + ['column' => 'views', 'operator' => '>', 'value' => 1000] + ] + ] +], limit: 10); +``` + +See [Query Building](query-building) for complete documentation. + +### Caching and events + +The database package includes automatic caching and event broadcasting. Models are cached by identity and invalidated on mutations. Events broadcast after successful operations. + +See [Caching and Events](caching-and-events) for complete documentation. + +### Database service provider + +The `DatabaseServiceProvider` is injected into handlers and provides access to: + +- `QueryBuilder` — Constructs SQL queries +- `CacheableService` — Caches models +- `EventStrategy` — Broadcasts events +- `ClauseBuilder` — Builds WHERE clauses +- `LoggerStrategy` — Logs operations +- `QueryStrategy` — Executes queries + +See [DatabaseServiceProvider](database-service-provider) for complete documentation. + +--- + +## Column and index factories + +The database package provides factories for common column patterns: + +- **PrimaryKeyFactory** — Auto-incrementing integer primary key +- **DateCreatedFactory** — Timestamp with `DEFAULT CURRENT_TIMESTAMP` +- **DateModifiedFactory** — Timestamp with `ON UPDATE CURRENT_TIMESTAMP` +- **ForeignKeyFactory** — Foreign key columns with constraints + +```php +public function getColumns(): array +{ + return [ + (new PrimaryKeyFactory())->toColumn(), + new Column('authorId', 'BIGINT', null, 'NOT NULL'), + (new ForeignKeyFactory('author', 'authors', 'id'))->toColumn(), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; +} +``` + +See [Column and Index Factories](included-factories/introduction) for complete documentation. + +--- + +## Junction tables + +Many-to-many relationships use junction tables. The `JunctionTable` class automatically creates compound primary keys, foreign keys, and standard indices from two related tables. + +```php +class PostsTagsTable extends JunctionTable +{ + public function __construct( + // Base dependencies... + PostsTable $leftTable, + TagsTable $rightTable + ) { + parent::__construct(...func_get_args()); + } +} +``` + +See [Junction Tables](junction-tables) for complete documentation. + +--- + +## Supported databases + +The database package works with: + +- MySQL 5.7+ +- MariaDB 10.2+ +- Other MySQL-compatible databases + +The query builder generates standard SQL that should work across these systems. Platform-specific features (stored procedures, triggers, full-text search) are not abstracted. + +--- + +## When to use this package + +Use `phpnomad/database` when: + +- You're storing data in a SQL database +- You want automatic caching and event broadcasting +- Query building and schema versioning are valuable +- You're using the datastore pattern with database persistence + +If your data comes from REST APIs, GraphQL, or other non-database sources, you don't need this package. Use `phpnomad/datastore` and implement custom handlers. + +--- + +## Package components + +### Required reading + +- **[Database Handlers](handlers/introduction)** — Creating database-backed handlers +- **[Table Schema Definition](table-schema-definition)** — Defining database tables +- **[Tables](tables/introduction)** — Table base classes and patterns + +### Deep dives + +- **[Query Building](query-building)** — Condition arrays, operators, QueryBuilder +- **[Caching and Events](caching-and-events)** — How caching and event broadcasting work +- **[DatabaseServiceProvider](database-service-provider)** — Services available to handlers + +### Reference + +- **[Column and Index Factories](included-factories/introduction)** — Pre-built column factories +- **[Junction Tables](junction-tables)** — Many-to-many relationships + +--- + +## Relationship to other packages + +- **[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/event](../event/introduction)** — EventStrategy interface for broadcasting events +- **[phpnomad/logger](../logger/introduction)** — LoggerStrategy interface for operation logging + +--- + +## Next steps + +- **New to database datastores?** Start with [Getting Started Tutorial](../../core-concepts/getting-started-tutorial) +- **Ready to implement?** See [Database Handlers](handlers/introduction) +- **Need table schemas?** Check [Table Schema Definition](table-schema-definition) +- **Building complex queries?** Read [Query Building](query-building) diff --git a/public/docs/docs/packages/database/junction-tables.md b/public/docs/docs/packages/database/junction-tables.md new file mode 100644 index 0000000..a6b405e --- /dev/null +++ b/public/docs/docs/packages/database/junction-tables.md @@ -0,0 +1,550 @@ +# Junction Tables and Many-to-Many Relationships + +Junction tables (also called join tables or pivot tables) are used to represent **many-to-many relationships** between two entities. For example, posts can have many tags, and tags can be applied to many posts. A junction table stores these associations without duplicating data. + +PHPNomad provides patterns for defining junction table schemas and working with many-to-many relationships through datastores. + +## What is a Junction Table? + +A junction table has: + +* **Two foreign key columns** — one for each side of the relationship +* **Compound primary key** — both foreign keys together form the primary key +* **No additional data** — it only stores associations (for pure many-to-many) +* **Indexes on both columns** — for efficient lookups in both directions + +### Example: Posts and Tags + +**Scenario:** Posts can have multiple tags, and tags can be applied to multiple posts. + +**Tables:** +* `posts` — stores post data (`id`, `title`, `content`, etc.) +* `tags` — stores tag data (`id`, `name`, `slug`, etc.) +* `post_tags` — junction table storing associations + +**Relationship:** +``` +posts (1) ←→ (many) post_tags (many) ←→ (1) tags +``` + +--- + +## Defining a Junction Table + +Junction tables extend `Table` and follow a specific pattern: + +```php + $model->postId, + 'tag_id' => $model->tagId, + ]; + } + + public function toModel(array $array): DataModel + { + return new PostTag( + postId: Arr::get($array, 'post_id'), + tagId: Arr::get($array, 'tag_id') + ); + } +} +``` + +--- + +## Junction Table Datastore + +Define the datastore interface and implementation: + +### Interface + +```php +handler->get(['post_id' => $postId]); + } + + public function getPostsForTag(int $tagId): iterable + { + return $this->handler->get(['tag_id' => $tagId]); + } + + public function addTagToPost(int $postId, int $tagId): void + { + $association = new PostTag($postId, $tagId); + $this->handler->save($association); + } + + public function removeTagFromPost(int $postId, int $tagId): void + { + $association = new PostTag($postId, $tagId); + $this->handler->delete($association); + } + + public function postHasTag(int $postId, int $tagId): bool + { + $results = $this->handler->get([ + 'post_id' => $postId, + 'tag_id' => $tagId, + ]); + + return !empty($results); + } +} +``` + +--- + +## Working with Junction Tables + +### Adding Associations + +```php +// Add multiple tags to a post +$postTags = $container->get(PostTagDatastore::class); + +$tagIds = [1, 5, 12, 23]; +foreach ($tagIds as $tagId) { + $postTags->addTagToPost(postId: 42, tagId: $tagId); +} +``` + +### Querying Associations + +```php +// Get all tags for a post +$associations = $postTags->getTagsForPost(42); + +foreach ($associations as $association) { + echo "Post 42 has tag {$association->tagId}\n"; +} + +// Get all posts for a tag +$associations = $postTags->getPostsForTag(5); + +foreach ($associations as $association) { + echo "Tag 5 is on post {$association->postId}\n"; +} +``` + +### Removing Associations + +```php +// Remove a specific tag from a post +$postTags->removeTagFromPost(postId: 42, tagId: 5); + +// Remove all tags from a post +$associations = $postTags->getTagsForPost(42); +foreach ($associations as $association) { + $postTags->delete($association); +} +``` + +### Checking Association Existence + +```php +if ($postTags->postHasTag(postId: 42, tagId: 5)) { + echo "Post 42 has tag 5"; +} +``` + +--- + +## Loading Related Data + +Junction tables are often used with joins to load related entities: + +### Service Layer Pattern + +```php +posts->find($postId); + + // Get tag associations for this post + $associations = $this->postTags->getTagsForPost($postId); + + // Load actual tag models + $tags = []; + foreach ($associations as $association) { + $tags[] = $this->tags->find($association->tagId); + } + + return [ + 'post' => $post, + 'tags' => $tags, + ]; + } + + /** + * Get all posts for a tag + */ + public function getPostsForTag(int $tagId): array + { + $tag = $this->tags->find($tagId); + + // Get post associations for this tag + $associations = $this->postTags->getPostsForTag($tagId); + + // Load actual post models + $posts = []; + foreach ($associations as $association) { + $posts[] = $this->posts->find($association->postId); + } + + return [ + 'tag' => $tag, + 'posts' => $posts, + ]; + } +} +``` + +--- + +## Junction Tables with Additional Data + +Sometimes you need to store **metadata** about the relationship itself. For example, storing when a tag was added to a post, or who added it. + +### Extended Junction Table + +```php +class PostTagsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('post_id', 'BIGINT', null, 'NOT NULL'), + new Column('tag_id', 'BIGINT', null, 'NOT NULL'), + + // Additional metadata + new Column('added_by_user_id', 'BIGINT', null, 'NULL'), + (new DateCreatedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), + new Index(['post_id'], 'post_idx', 'INDEX'), + new Index(['tag_id'], 'tag_idx', 'INDEX'), + new Index(['added_by_user_id'], 'user_idx', 'INDEX'), + ]; + } +} +``` + +### Extended Model + +```php +class PostTag implements DataModel +{ + public function __construct( + public readonly int $postId, + public readonly int $tagId, + public readonly ?int $addedByUserId = null, + public readonly ?\DateTime $createdAt = null + ) {} +} +``` + +--- + +## Multiple Junction Tables + +Complex systems often have multiple many-to-many relationships: + +**Example: E-commerce system** + +```php +// Products and categories +class ProductCategoriesTable extends Table { ... } + +// Orders and products (with quantity, price at time of order) +class OrderProductsTable extends Table +{ + public function getColumns(): array + { + return [ + new Column('order_id', 'BIGINT', null, 'NOT NULL'), + new Column('product_id', 'BIGINT', null, 'NOT NULL'), + new Column('quantity', 'INT', null, 'NOT NULL'), + new Column('price_at_purchase', 'DECIMAL', [10, 2], 'NOT NULL'), + ]; + } +} + +// Users and roles +class UserRolesTable extends Table { ... } +``` + +--- + +## Best Practices + +### Always Use Compound Primary Keys + +```php +// ✅ GOOD: compound primary key prevents duplicates +new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY') + +// ❌ BAD: allows duplicate associations +new Column('id', 'INT', null, 'NOT NULL AUTO_INCREMENT PRIMARY KEY') +new Column('post_id', 'BIGINT', null, 'NOT NULL') +new Column('tag_id', 'BIGINT', null, 'NOT NULL') +``` + +### Index Both Directions + +```php +// ✅ GOOD: supports queries in both directions +new Index(['post_id'], 'post_idx', 'INDEX'), // "tags for post" +new Index(['tag_id'], 'tag_idx', 'INDEX'), // "posts for tag" + +// ❌ BAD: only one direction is fast +new Index(['post_id'], 'post_idx', 'INDEX'), +``` + +### Name Consistently + +```php +// ✅ GOOD: consistent naming +post_tags table, post_id and tag_id columns + +// ❌ BAD: inconsistent +posts_to_tags table, postId and tagId columns +``` + +### Keep Models Simple + +```php +// ✅ GOOD: minimal junction model +class PostTag +{ + public function __construct( + public readonly int $postId, + public readonly int $tagId + ) {} +} + +// ❌ BAD: junction model with business logic +class PostTag +{ + public function isActive(): bool { ... } + public function validate(): void { ... } +} +``` + +### Use Batch Operations + +```php +// ✅ GOOD: batch insert +foreach ($tagIds as $tagId) { + $postTags->addTagToPost($postId, $tagId); +} + +// ✅ EVEN BETTER: if your datastore supports bulk operations +$postTags->bulkAddTags($postId, $tagIds); +``` + +--- + +## What's Next + +* [Table Schema Definition](/packages/database/table-schema-definition) — detailed table definition reference +* [Database Handlers](/packages/database/handlers/introduction) — implementing handlers for junction tables +* [Model Adapters](/packages/datastore/model-adapters) — creating adapters for junction models diff --git a/public/docs/docs/packages/database/query-building.md b/public/docs/docs/packages/database/query-building.md new file mode 100644 index 0000000..1e0933e --- /dev/null +++ b/public/docs/docs/packages/database/query-building.md @@ -0,0 +1,578 @@ +# Query Building + +PHPNomad's database package provides a **fluent query builder** for constructing safe, escaped SQL queries. The +`QueryBuilder` interface offers a chainable API for building SELECT queries with WHERE clauses, joins, grouping, +ordering, and pagination—without writing raw SQL. + +Query building is used primarily in [database handlers](/packages/database/handlers/introduction) to execute queries +against tables defined by [table schemas](/packages/database/table-schema-definition). + +## Core Components + +### QueryBuilder + +The main interface for building SELECT queries. Provides methods for: + +* Selecting fields +* Setting FROM clause +* Adding WHERE conditions via `ClauseBuilder` +* JOINs (LEFT, RIGHT) +* Grouping and aggregations (SUM, COUNT) +* Ordering and pagination (ORDER BY, LIMIT, OFFSET) + +### ClauseBuilder + +A specialized builder for constructing WHERE clauses with: + +* Multiple conditions (AND, OR) +* Comparison operators (=, <, >, IN, LIKE, BETWEEN, etc.) +* Grouped conditions (parentheses) +* Proper escaping and sanitization + +--- + +## Basic Query Building + +### Simple SELECT Query + +```php +queryBuilder + ->select('id', 'title', 'content') + ->from($this->table) + ->build(); + + // Execute query and return results + return $this->executeQuery($sql); + } +} +``` + +**Generated SQL:** + +```sql +SELECT id, title, content FROM wp_posts +``` + +--- + +### SELECT with WHERE Clause + +```php +public function getPostsByAuthor(int $authorId): array +{ + $clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', $authorId); + + $sql = $this->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause) + ->build(); + + return $this->executeQuery($sql); +} +``` + +**Generated SQL:** + +```sql +SELECT * FROM wp_posts WHERE author_id = 123 +``` + +--- + +## ClauseBuilder API + +The `ClauseBuilder` constructs WHERE clauses with proper escaping. + +### Comparison Operators + +**Equality:** + +```php +$clause->where('status', '=', 'published'); +// WHERE status = 'published' +``` + +**Inequality:** + +```php +$clause->where('view_count', '>', 100); +// WHERE view_count > 100 + +$clause->where('view_count', '>=', 50); +// WHERE view_count >= 50 + +$clause->where('view_count', '<', 1000); +// WHERE view_count < 1000 +``` + +**IN operator:** + +```php +$clause->where('status', 'IN', 'published', 'featured', 'archived'); +// WHERE status IN ('published', 'featured', 'archived') +``` + +**NOT IN:** + +```php +$clause->where('status', 'NOT IN', 'draft', 'pending'); +// WHERE status NOT IN ('draft', 'pending') +``` + +**LIKE operator:** + +```php +$clause->where('title', 'LIKE', '%wordpress%'); +// WHERE title LIKE '%wordpress%' +``` + +**BETWEEN:** + +```php +$clause->where('created_at', 'BETWEEN', '2024-01-01', '2024-12-31'); +// WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31' +``` + +**IS NULL / IS NOT NULL:** + +```php +$clause->where('published_date', 'IS NULL'); +// WHERE published_date IS NULL + +$clause->where('published_date', 'IS NOT NULL'); +// WHERE published_date IS NOT NULL +``` + +--- + +### Chaining Conditions + +**AND conditions:** + +```php +$clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', 123) + ->andWhere('status', '=', 'published') + ->andWhere('view_count', '>', 100); + +// WHERE author_id = 123 AND status = 'published' AND view_count > 100 +``` + +**OR conditions:** + +```php +$clause = $this->clauseBuilder + ->useTable($this->table) + ->where('status', '=', 'published') + ->orWhere('status', '=', 'featured'); + +// WHERE status = 'published' OR status = 'featured' +``` + +**Mixed AND/OR:** + +```php +$clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', 123) + ->andWhere('status', '=', 'published') + ->orWhere('status', '=', 'featured'); + +// WHERE author_id = 123 AND status = 'published' OR status = 'featured' +// Note: Operator precedence applies (AND before OR) +``` + +--- + +### Grouped Conditions + +For complex logic with parentheses, use `group()`: + +```php +// (status = 'published' OR status = 'featured') AND author_id = 123 + +$statusClause = $this->clauseBuilder + ->useTable($this->table) + ->where('status', '=', 'published') + ->orWhere('status', '=', 'featured'); + +$clause = $this->clauseBuilder + ->useTable($this->table) + ->group('AND', $statusClause) + ->andWhere('author_id', '=', 123); +``` + +**More complex grouping:** + +```php +// (author_id = 123 OR author_id = 456) AND (status = 'published' OR status = 'featured') + +$authorClause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', 123) + ->orWhere('author_id', '=', 456); + +$statusClause = $this->clauseBuilder + ->useTable($this->table) + ->where('status', '=', 'published') + ->orWhere('status', '=', 'featured'); + +$clause = $this->clauseBuilder + ->useTable($this->table) + ->group('AND', $authorClause) + ->andGroup('AND', $statusClause); +``` + +--- + +## QueryBuilder Methods + +### select() + +Specify columns to retrieve: + +```php +$queryBuilder->select('id', 'title', 'content'); +// SELECT id, title, content + +$queryBuilder->select('*'); +// SELECT * +``` + +--- + +### from() + +Set the table for the query: + +```php +$queryBuilder->from($this->table); +// FROM wp_posts (using table's prefixed name) +``` + +--- + +### where() + +Add a WHERE clause using a `ClauseBuilder`: + +```php +$clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', 123); + +$queryBuilder->where($clause); +// WHERE author_id = 123 +``` + +To remove a WHERE clause: + +```php +$queryBuilder->where(null); +``` + +--- + +### leftJoin() / rightJoin() + +Join tables: + +```php +$queryBuilder + ->select('posts.id', 'posts.title', 'users.name as author_name') + ->from($this->postsTable) + ->leftJoin($this->usersTable, 'posts.author_id', 'users.id'); + +// SELECT posts.id, posts.title, users.name as author_name +// FROM wp_posts +// LEFT JOIN wp_users ON posts.author_id = users.id +``` + +--- + +### groupBy() + +Group results: + +```php +$queryBuilder + ->select('author_id') + ->from($this->table) + ->groupBy('author_id'); + +// SELECT author_id FROM wp_posts GROUP BY author_id +``` + +Multiple columns: + +```php +$queryBuilder->groupBy('author_id', 'status'); +// GROUP BY author_id, status +``` + +--- + +### Aggregations: sum() and count() + +**COUNT:** + +```php +$queryBuilder + ->count('id', 'total_posts') + ->from($this->table); + +// SELECT COUNT(id) as total_posts FROM wp_posts +``` + +**SUM:** + +```php +$queryBuilder + ->sum('view_count', 'total_views') + ->from($this->table); + +// SELECT SUM(view_count) as total_views FROM wp_posts +``` + +**With GROUP BY:** + +```php +$queryBuilder + ->select('author_id') + ->count('id', 'post_count') + ->from($this->table) + ->groupBy('author_id'); + +// SELECT author_id, COUNT(id) as post_count FROM wp_posts GROUP BY author_id +``` + +--- + +### orderBy() + +Sort results: + +```php +$queryBuilder->orderBy('published_date', 'DESC'); +// ORDER BY published_date DESC + +$queryBuilder->orderBy('title', 'ASC'); +// ORDER BY title ASC +``` + +--- + +### limit() and offset() + +Pagination: + +```php +$queryBuilder + ->select('*') + ->from($this->table) + ->limit(10) + ->offset(20); + +// SELECT * FROM wp_posts LIMIT 10 OFFSET 20 +``` + +--- + +## Complete Query Example + +Here's a complex query demonstrating multiple features: + +```php +public function getPublishedPostsByAuthorsWithHighViews( + array $authorIds, + int $minViews, + int $page = 1, + int $perPage = 10 +): array { + // Build WHERE clause + $clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', 'IN', ...$authorIds) + ->andWhere('status', '=', 'published') + ->andWhere('view_count', '>=', $minViews) + ->andWhere('published_date', 'IS NOT NULL'); + + // Build full query + $sql = $this->queryBuilder + ->select('id', 'title', 'author_id', 'view_count', 'published_date') + ->from($this->table) + ->where($clause) + ->orderBy('view_count', 'DESC') + ->limit($perPage) + ->offset(($page - 1) * $perPage) + ->build(); + + return $this->executeQuery($sql); +} +``` + +**Generated SQL:** + +```sql +SELECT id, title, author_id, view_count, published_date +FROM wp_posts +WHERE author_id IN (123, 456, 789) + AND status = 'published' + AND view_count >= 100 + AND published_date IS NOT NULL +ORDER BY view_count DESC +LIMIT 10 OFFSET 20 +``` + +--- + +## Query Builder Reset + +Reuse a query builder instance by resetting it: + +```php +$queryBuilder->reset(); +// Clears all clauses and returns to default state + +$queryBuilder->resetClauses('where', 'limit', 'offset'); +// Clears specific clauses only +``` + +--- + +## Using QueryBuilder in Handlers + +Handlers receive `QueryBuilder` and `ClauseBuilder` from the `DatabaseServiceProvider`: + +```php +queryBuilder = $serviceProvider->queryBuilder; + $this->clauseBuilder = $serviceProvider->clauseBuilder; + $this->table = $table; + $this->adapter = $adapter; + } + + public function findPublished(): array + { + $clause = $this->clauseBuilder + ->useTable($this->table) + ->where('status', '=', 'published'); + + $sql = $this->queryBuilder + ->select('*') + ->from($this->table) + ->where($clause) + ->build(); + + $rows = $this->executeQuery($sql); + + return array_map( + fn($row) => $this->adapter->toModel($row), + $rows + ); + } +} +``` + +--- + +## Best Practices + +### Always Use ClauseBuilder for WHERE Clauses + +```php +// ✅ GOOD: proper escaping via ClauseBuilder +$clause = $this->clauseBuilder + ->useTable($this->table) + ->where('author_id', '=', $userInput); + +$queryBuilder->where($clause); + +// ❌ BAD: manual string concatenation (SQL injection risk!) +$sql = "WHERE author_id = " . $userInput; +``` + +### Build Queries, Don't Execute Raw SQL + +```php +// ✅ GOOD: use query builder +$sql = $this->queryBuilder + ->select('*') + ->from($this->table) + ->build(); + +// ❌ BAD: raw SQL strings +$sql = "SELECT * FROM wp_posts WHERE author_id = " . $id; +``` + +### Use Table Objects for FROM and JOIN + +```php +// ✅ GOOD: table object handles prefixes +$queryBuilder->from($this->postsTable); + +// ❌ BAD: hardcoded table name +$queryBuilder->from('wp_posts'); +``` + +### Reset Builders Between Queries + +```php +// ✅ GOOD: reset before reusing +$queryBuilder->reset(); +$queryBuilder->select('*')->from($this->table); + +// ❌ BAD: reusing without reset (accumulates clauses) +$queryBuilder->select('id'); // First query +$queryBuilder->select('*'); // Adds to first query! +``` + +### Validate User Input Before Queries + +```php +// ✅ GOOD: validate before building query +if (!in_array($status, ['draft', 'published', 'archived'])) { + throw new ValidationException("Invalid status"); +} + +$clause->where('status', '=', $status); + +// ClauseBuilder handles escaping, but validation prevents logic errors +``` + +--- + +## What's Next + +* [Database Handlers](/packages/database/handlers/introduction) — how handlers use query builders +* [Table Schema Definition](/packages/database/table-schema-definition) — defining tables for queries +* [Caching and Events](/packages/database/caching-and-events) — query result caching diff --git a/public/docs/docs/packages/database/table-schema-definition.md b/public/docs/docs/packages/database/table-schema-definition.md new file mode 100644 index 0000000..db6f316 --- /dev/null +++ b/public/docs/docs/packages/database/table-schema-definition.md @@ -0,0 +1,626 @@ +# Table Schema Definition + +Table schema definitions describe the structure of your database tables in a database-agnostic way. They're PHP classes that extend `Table` and define columns, indexes, primary keys, and versioning information. Handlers use these definitions to create tables and generate queries. + +This document provides a complete reference for defining table schemas in PHPNomad. + +## Table Base Class + +All tables extend `PHPNomad\Database\Abstracts\Table` and must implement six methods: + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; +} +``` + +See **Column Definitions** section below for details. + +--- + +### `getIndices(): array` + +Returns an array of `Index` objects defining database indexes. + +```php +public function getIndices(): array +{ + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['slug'], 'slug_idx', 'UNIQUE'), + ]; +} +``` + +See **Index Definitions** section below for details. + +--- + +## Column Definitions + +Columns are defined using the `Column` class or specialized factories. + +### Using the Column Class + +```php +new Column( + string $name, // Column name + string $type, // SQL type (VARCHAR, INT, TEXT, etc.) + array|null $params, // Type parameters (e.g., [255] for VARCHAR) + string $constraint // Constraints (NOT NULL, NULL, DEFAULT, etc.) +) +``` + +### Column Types + +**String types:** +```php +new Column('title', 'VARCHAR', [255], 'NOT NULL') +new Column('content', 'TEXT', null, 'NOT NULL') +new Column('slug', 'VARCHAR', [255], 'NOT NULL') +``` + +**Integer types:** +```php +new Column('id', 'INT', [11], 'NOT NULL AUTO_INCREMENT') +new Column('author_id', 'BIGINT', null, 'NOT NULL') +new Column('view_count', 'INT', [11], 'DEFAULT 0') +new Column('is_active', 'TINYINT', [1], 'DEFAULT 1') // Boolean +``` + +**Date/Time types:** +```php +new Column('published_date', 'DATETIME', null, 'NULL') +new Column('created_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP') +new Column('updated_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP') +``` + +**Other types:** +```php +new Column('price', 'DECIMAL', [10, 2], 'NOT NULL') // 10 digits, 2 decimals +new Column('metadata', 'JSON', null, 'NULL') // MySQL 5.7.8+ +new Column('file_data', 'BLOB', null, 'NULL') +new Column('status', 'ENUM', ['draft', 'published', 'archived'], 'DEFAULT "draft"') +``` + +### Column Constraints + +**NOT NULL / NULL:** +```php +'NOT NULL' // Required field +'NULL' // Optional field +``` + +**DEFAULT values:** +```php +'DEFAULT 0' +'DEFAULT "draft"' +'DEFAULT CURRENT_TIMESTAMP' +``` + +**AUTO_INCREMENT:** +```php +'NOT NULL AUTO_INCREMENT' // For primary keys +``` + +**ON UPDATE:** +```php +'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' // Auto-update timestamp +``` + +**UNIQUE:** +```php +'NOT NULL UNIQUE' // Unique constraint +``` + +--- + +## Column Factories + +PHPNomad provides specialized factories for common column patterns. See [Included Factories](/packages/database/included-factories/introduction) for full details. + +### PrimaryKeyFactory + +Creates standard auto-increment integer primary keys: + +```php +use PHPNomad\Database\Factories\Columns\PrimaryKeyFactory; + +public function getColumns(): array +{ + return [ + (new PrimaryKeyFactory())->toColumn(), // Creates 'id' INT AUTO_INCREMENT + // other columns... + ]; +} +``` + +**Generates:** +```sql +id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY +``` + +--- + +### DateCreatedFactory + +Creates timestamp columns with automatic creation date: + +```php +use PHPNomad\Database\Factories\Columns\DateCreatedFactory; + +public function getColumns(): array +{ + return [ + (new DateCreatedFactory())->toColumn(), // Creates 'created_at' + ]; +} +``` + +**Generates:** +```sql +created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL +``` + +--- + +### DateModifiedFactory + +Creates timestamp columns that auto-update on record modification: + +```php +use PHPNomad\Database\Factories\Columns\DateModifiedFactory; + +public function getColumns(): array +{ + return [ + (new DateModifiedFactory())->toColumn(), // Creates 'updated_at' + ]; +} +``` + +**Generates:** +```sql +updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL +``` + +--- + +### ForeignKeyFactory + +Creates foreign key columns with optional constraints: + +```php +use PHPNomad\Database\Factories\Columns\ForeignKeyFactory; + +public function getColumns(): array +{ + return [ + (new ForeignKeyFactory( + 'author_id', // Column name + 'users', // Referenced table + 'id', // Referenced column + 'CASCADE', // ON DELETE action + 'CASCADE' // ON UPDATE action + ))->toColumn(), + ]; +} +``` + +**Common foreign key patterns:** +```php +// Required foreign key +(new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn() + +// Optional foreign key (NULL allowed) +(new ForeignKeyFactory('author_id', 'users', 'id', 'SET NULL'))->toColumn() + +// Cascade deletes +(new ForeignKeyFactory('post_id', 'posts', 'id', 'CASCADE'))->toColumn() +``` + +--- + +## Index Definitions + +Indexes improve query performance. Define them using the `Index` class: + +```php +new Index( + array $columns, // Column(s) to index + string $name, // Index name + string $type // Index type: 'INDEX', 'UNIQUE', 'PRIMARY KEY' +) +``` + +### Single-Column Indexes + +```php +public function getIndices(): array +{ + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['published_date'], 'published_idx', 'INDEX'), + ]; +} +``` + +**When to add:** +* Columns used in WHERE clauses +* Foreign key columns +* Columns used for sorting (ORDER BY) + +--- + +### Unique Indexes + +```php +public function getIndices(): array +{ + return [ + new Index(['email'], 'email_unique', 'UNIQUE'), + new Index(['slug'], 'slug_unique', 'UNIQUE'), + ]; +} +``` + +**When to add:** +* Fields that must be unique across records +* Natural keys (email, username, slug) + +--- + +### Composite Indexes + +Indexes spanning multiple columns for complex queries: + +```php +public function getIndices(): array +{ + return [ + new Index(['author_id', 'published_date'], 'author_published_idx', 'INDEX'), + new Index(['user_id', 'session_token'], 'user_session_idx', 'PRIMARY KEY'), + ]; +} +``` + +**When to add:** +* Queries filtering on multiple columns +* Compound primary keys (junction tables) +* Queries with sorting on filtered data + +**Column order matters:** +```php +new Index(['author_id', 'published_date'], 'idx', 'INDEX') +// Supports: WHERE author_id = 123 +// Supports: WHERE author_id = 123 ORDER BY published_date +// Does NOT support: WHERE published_date > '2024-01-01' (doesn't start with author_id) +``` + +--- + +### Primary Key Indexes + +For compound primary keys (typically junction tables): + +```php +public function getIndices(): array +{ + return [ + new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), + ]; +} +``` + +--- + +## Complete Example + +Here's a full table definition with all features: + +```php +toColumn(), + + // Regular columns + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('slug', 'VARCHAR', [255], 'NOT NULL UNIQUE'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('excerpt', 'VARCHAR', [500], 'NULL'), + + // Enum for status + new Column('status', 'ENUM', ['draft', 'published', 'archived'], 'DEFAULT "draft"'), + + // Numeric fields + new Column('view_count', 'INT', [11], 'DEFAULT 0'), + new Column('comment_count', 'INT', [11], 'DEFAULT 0'), + + // Foreign keys + (new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn(), + (new ForeignKeyFactory('category_id', 'categories', 'id', 'SET NULL'))->toColumn(), + + // Dates + new Column('published_date', 'DATETIME', null, 'NULL'), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + // Single column indexes + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['category_id'], 'category_idx', 'INDEX'), + new Index(['status'], 'status_idx', 'INDEX'), + new Index(['published_date'], 'published_idx', 'INDEX'), + new Index(['slug'], 'slug_unique', 'UNIQUE'), + + // Composite indexes + new Index(['author_id', 'published_date'], 'author_published_idx', 'INDEX'), + new Index(['status', 'published_date'], 'status_published_idx', 'INDEX'), + ]; + } +} +``` + +--- + +## Best Practices + +### Column Naming + +Use `snake_case` for column names to match database conventions: + +```php +// ✅ GOOD +new Column('author_id', 'BIGINT', null, 'NOT NULL') +new Column('published_date', 'DATETIME', null, 'NULL') + +// ❌ BAD +new Column('authorId', 'BIGINT', null, 'NOT NULL') +new Column('publishedDate', 'DATETIME', null, 'NULL') +``` + +### Index Foreign Keys + +Always index foreign key columns for join performance: + +```php +public function getColumns(): array +{ + return [ + (new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn(), + ]; +} + +public function getIndices(): array +{ + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), // ✅ Always add + ]; +} +``` + +### Use Factories for Standard Patterns + +Don't manually define primary keys or timestamps: + +```php +// ✅ GOOD +(new PrimaryKeyFactory())->toColumn() +(new DateCreatedFactory())->toColumn() + +// ❌ BAD +new Column('id', 'INT', [11], 'NOT NULL AUTO_INCREMENT') +new Column('created_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP') +``` + +### Version Your Schema + +Increment `getTableVersion()` whenever you change the schema: + +```php +// Initial version +public function getTableVersion(): string { return '1'; } + +// After adding a column +public function getTableVersion(): string { return '2'; } + +// After modifying an index +public function getTableVersion(): string { return '3'; } +``` + +### Nullable vs Required + +Be explicit about nullability: + +```php +// Required fields +new Column('title', 'VARCHAR', [255], 'NOT NULL') + +// Optional fields +new Column('published_date', 'DATETIME', null, 'NULL') +``` + +### Index Strategy + +Follow these guidelines: +1. **Always index:** Primary keys, foreign keys, unique fields +2. **Often index:** Columns in WHERE clauses, ORDER BY columns +3. **Consider composite indexes:** For multi-column queries +4. **Don't over-index:** Every index adds write overhead + +--- + +## What's Next + +* [Tables Introduction](/packages/database/tables/introduction) — overview of table definitions +* [Included Factories](/packages/database/included-factories/introduction) — column factory reference +* [Junction Tables](/packages/database/junction-tables) — many-to-many relationship tables +* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/tables/introduction.md b/public/docs/docs/packages/database/tables/introduction.md new file mode 100644 index 0000000..9e854b0 --- /dev/null +++ b/public/docs/docs/packages/database/tables/introduction.md @@ -0,0 +1,269 @@ +# Tables + +Tables in PHPNomad are **schema definitions** that describe the structure of your database tables. They define columns, indexes, primary keys, and constraints in a **database-agnostic** way, allowing handlers and query builders to generate the correct SQL for your target database. + +A table object is not a query builder or active record. It's a **metadata container** that describes what a table looks like, which [handlers](/packages/database/handlers/introduction) use to create schemas and [QueryBuilder](/packages/database/query-building) uses to generate queries. + +## What Tables Define + +A table definition specifies: + +* **Table name** — the name of the table in the database. +* **Columns** — each field's name, type, and constraints (nullable, default value, etc.). +* **Primary key** — which column(s) uniquely identify rows. +* **Indexes** — additional indexes for query performance. +* **Foreign keys** — relationships to other tables (optional). + +These definitions are created using **factory classes** from the `phpnomad/database` package, which provide a fluent API for building schema definitions. + +## Why Table Objects Exist + +In PHPNomad, **schema lives in code**, not in migration scripts or raw SQL. This has several benefits: + +* **Portability** — the same table definition works across MySQL, MariaDB, and other supported databases. +* **Versioning** — schema changes are tracked in version control alongside code. +* **Testability** — tables can be created in test databases programmatically. +* **Type safety** — column definitions are strongly typed and validated at runtime. + +Handlers use table definitions to: +* Create tables on first use (or during migrations). +* Validate that models match the schema. +* Generate queries that reference the correct columns. + +## The Base Table Class + +The `Table` class is the **standard base** for defining entity tables. You extend it and provide column definitions, a primary key, and optional indexes. + +### Basic example + +```php +columnFactory->int('id')->autoIncrement(), + $this->columnFactory->string('title', 255)->notNull(), + $this->columnFactory->text('content')->notNull(), + $this->columnFactory->int('author_id')->notNull(), + $this->columnFactory->datetime('published_date')->nullable(), + $this->columnFactory->datetime('created_at')->default('CURRENT_TIMESTAMP'), + $this->columnFactory->datetime('updated_at')->default('CURRENT_TIMESTAMP')->onUpdate('CURRENT_TIMESTAMP'), + ]; + } + + public function getPrimaryKey(): PrimaryKey + { + return $this->primaryKeyFactory->create('id'); + } + + public function getIndexes(): array + { + return [ + // Add index on author_id for faster lookups + $this->indexFactory->create('idx_author', ['author_id']), + ]; + } +} +``` + +This defines a `posts` table with: +* An auto-increment `id` primary key +* Required `title`, `content`, and `author_id` columns +* Optional `published_date` column +* Auto-managed `created_at` and `updated_at` timestamps + +## Column Factories + +Column definitions are created using factory methods that return `Column` objects. PHPNomad provides [several included factories](/packages/database/included-factories/introduction) for common patterns: + +* **`PrimaryKeyFactory`** — creates auto-increment integer primary keys +* **`DateCreatedFactory`** — creates `created_at` timestamp columns +* **`DateModifiedFactory`** — creates `updated_at` columns with auto-update +* **`ForeignKeyFactory`** — creates foreign key columns that reference other tables + +You can also use the base `Column` factory to define custom columns with full control over type, size, nullability, defaults, and constraints. + +## Junction Tables + +PHPNomad provides a specialized `JunctionTable` class for **many-to-many relationships**. Junction tables store associations between two entities (e.g., posts and tags) without additional data. + +A junction table: +* Has a **compound primary key** (both foreign keys together). +* Stores only the foreign keys (no additional columns). +* Uses composite indexes for efficient lookups in both directions. + +### Example: PostTag junction table + +```php +foreignKeyFactory->create('post_id', 'posts', 'id'), + $this->foreignKeyFactory->create('tag_id', 'tags', 'id'), + ]; + } + + public function getPrimaryKey(): array + { + return ['post_id', 'tag_id']; // Compound key + } + + public function getIndexes(): array + { + return [ + // Index for "which tags are on this post?" + $this->indexFactory->create('idx_post', ['post_id']), + // Index for "which posts have this tag?" + $this->indexFactory->create('idx_tag', ['tag_id']), + ]; + } +} +``` + +Junction tables are used with the [JunctionTable class](/packages/database/tables/junction-table-class) to manage many-to-many relationships efficiently. + +## Table Lifecycle + +Tables are: + +1. **Defined** — you create a class that implements `Table` and describes the schema. +2. **Injected** — your handler receives the table instance via constructor DI. +3. **Used** — handlers call `getTableName()`, `getColumns()`, etc. to generate queries. +4. **Created** — on first use (or during migrations), the handler ensures the table exists in the database. + +You don't "run" a table or call methods on it directly. It's a **passive descriptor** that other components consume. + +## Column Types and Constraints + +The `Column` factory supports these types: + +* `int(name, size)` — integers (various sizes: TINYINT, INT, BIGINT) +* `string(name, length)` — VARCHAR columns +* `text(name)` — TEXT columns (arbitrary length) +* `datetime(name)` — DATETIME columns +* `boolean(name)` — BOOLEAN columns +* `json(name)` — JSON columns (database-dependent) +* `decimal(name, precision, scale)` — DECIMAL columns + +And these constraints: + +* `notNull()` — column cannot be NULL +* `nullable()` — column can be NULL (default) +* `default(value)` — default value when not specified +* `autoIncrement()` — auto-incrementing integer (usually on primary keys) +* `onUpdate(value)` — value to set on UPDATE (e.g., `CURRENT_TIMESTAMP`) +* `unique()` — enforce uniqueness constraint + +Chaining these methods produces expressive column definitions: + +```php +$this->columnFactory + ->string('email', 255) + ->notNull() + ->unique(); +``` + +## Primary Keys + +Every table must define a primary key. Most tables use a **single auto-increment integer**: + +```php +public function getPrimaryKey(): PrimaryKey +{ + return $this->primaryKeyFactory->create('id'); +} +``` + +Tables with **compound primary keys** (like junction tables) return an array: + +```php +public function getPrimaryKey(): array +{ + return ['user_id', 'session_token']; +} +``` + +## Indexes + +Indexes improve query performance by allowing the database to find rows faster. Add indexes on: + +* Foreign keys (for joins) +* Columns used in WHERE clauses +* Columns used for sorting + +**Example: adding indexes** + +```php +public function getIndexes(): array +{ + return [ + $this->indexFactory->create('idx_author', ['author_id']), + $this->indexFactory->create('idx_published', ['published_date']), + $this->indexFactory->composite('idx_author_date', ['author_id', 'published_date']), + ]; +} +``` + +Composite indexes support queries that filter on multiple columns. + +## Best Practices + +When defining tables: + +* **Use factories** — don't construct `Column` objects manually; use the provided factories. +* **Name consistently** — use snake_case for column names to match database conventions. +* **Index foreign keys** — always add indexes on columns used in joins. +* **Use timestamps** — include `created_at` and `updated_at` for audit trails. +* **Keep tables focused** — each table should represent one entity or one relationship (junction tables). +* **Declare constraints** — use `notNull()`, `unique()`, etc. to enforce data integrity at the database level. + +## Schema Evolution + +When your schema changes (adding columns, indexes, etc.), update the table definition. The handler will detect changes and can update the database schema, though this depends on your migration strategy. + +For production systems, consider: +* **Versioned migrations** — track schema changes explicitly. +* **Backwards compatibility** — add columns as nullable first, backfill data, then mark as not-null. +* **Index creation** — add indexes separately from table creation if tables are large. + +## What's Next + +To understand how tables fit into the larger system, see: + +* [Table Class](/packages/database/tables/table-class) — detailed API reference for entity tables +* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many relationship tables +* [Included Factories](/packages/database/included-factories/introduction) — pre-built column factories +* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/tables/junction-table-class.md b/public/docs/docs/packages/database/tables/junction-table-class.md new file mode 100644 index 0000000..58b7054 --- /dev/null +++ b/public/docs/docs/packages/database/tables/junction-table-class.md @@ -0,0 +1,73 @@ +# JunctionTable Class + +The `JunctionTable` class extends `Table` for defining **many-to-many relationship tables**. Junction tables store associations between two entities using foreign keys and compound primary keys. + +For conceptual overview and usage patterns, see [Junction Tables](/packages/database/junction-tables). + +## Key Differences from Table + +Junction tables differ from regular entity tables in that they: + +* Have **compound primary keys** (multiple columns) +* Store only **foreign keys** (no additional data by default) +* Use **composite indexes** for bidirectional lookups + +## Example + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + (new DateCreatedFactory())->toColumn(), + ]; +} +``` + +### `getIndices(): array` + +Returns array of Index definitions. + +**Example:** +```php +public function getIndices(): array +{ + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['slug'], 'slug_unique', 'UNIQUE'), + ]; +} +``` + +## Complete Example + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('author_id', 'BIGINT', null, 'NOT NULL'), + new Column('published_date', 'DATETIME', null, 'NULL'), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['published_date'], 'published_idx', 'INDEX'), + ]; + } +} +``` + +## What's Next + +* [Table Schema Definition](/packages/database/table-schema-definition) — complete schema reference +* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many tables +* [Tables Introduction](/packages/database/tables/introduction) — overview diff --git a/public/docs/docs/packages/datastore/core-implementation.md b/public/docs/docs/packages/datastore/core-implementation.md new file mode 100644 index 0000000..41f893c --- /dev/null +++ b/public/docs/docs/packages/datastore/core-implementation.md @@ -0,0 +1,604 @@ +# Core Datastore Layer + +## What is the Core layer? + +The Core layer is where you define business-level data operations without any knowledge of how data is actually stored or retrieved. It contains interfaces that declare what operations are possible, and implementations that delegate standard operations while adding custom business logic. + +Core never depends on Service. It knows nothing about databases, REST APIs, GraphQL, or any concrete storage technology. This separation ensures your domain logic remains portable and independent of infrastructure. + +The Core layer contains: +- **Datastore interfaces** - Public API for your application code +- **DatastoreHandler interfaces** - Contract for storage implementations +- **Datastore implementations** - Delegation layer using decorator pattern +- **Models** - Domain entities (covered in [Models and Identity](models-and-identity)) + +--- + +## Directory structure + +The standard directory structure for Core datastores: + +``` +YourModule/ +└── Core/ + ├── Models/ + │ ├── Post.php + │ └── Adapters/ + │ └── PostAdapter.php + └── Datastores/ + └── Post/ + ├── Interfaces/ + │ ├── PostDatastore.php + │ └── PostDatastoreHandler.php + └── PostDatastore.php +``` + +**Key points:** +- Each entity gets its own directory under `Datastores/` +- Interfaces live in `Interfaces/` subdirectory +- Implementation lives at the entity directory level +- Models and adapters are separate from datastores + +--- + +## Naming conventions + +Consistent naming makes codebases predictable and maintainable: + +| Component | Pattern | Example | +|-----------|---------|---------| +| Datastore interface | `{Entity}Datastore` | `PostDatastore` | +| DatastoreHandler interface | `{Entity}DatastoreHandler` | `PostDatastoreHandler` | +| Datastore implementation | `{Entity}Datastore` | `PostDatastore` | +| Model | `{Entity}` | `Post` | +| Adapter | `{Entity}Adapter` | `PostAdapter` | + +**Important:** The Datastore interface and implementation share the same name. They are distinguished by namespace and the interface suffix in the interface file. + +--- + +## Datastore vs DatastoreHandler: The critical distinction + +This is the most confusing aspect of the datastore pattern. Understanding why both interfaces exist is essential to using the pattern effectively. + +### PostDatastore: Your public API + +The `Datastore` interface defines your **public API**—what operations your application code can perform. This interface includes: + +- Standard operations (if you choose to extend base interfaces) +- Custom business methods specific to this entity + +```php +datastoreHandler = $datastoreHandler; + } + + // Custom business method - implemented here, not in handler + public function getPublishedPosts(): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] + ] + ] + ]); + } + + public function getByAuthor(int $authorId): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] + ] + ] + ]); + } +} +``` + +The custom methods (`getPublishedPosts`, `getByAuthor`) are implemented in the Core datastore using the handler's `where()` method. The handler doesn't need to know about these business-specific queries—it just provides the building blocks. + +**This means:** +- Database handlers, REST handlers, GraphQL handlers only need to implement standard operations +- Business logic lives in the Core datastore, composed from handler primitives +- You can swap storage implementations without changing business methods +- Each handler implementation doesn't need to understand your specific business domain + +--- + +## When to extend base interfaces (and when not to) + +The base datastore interfaces (`DatastoreHasPrimaryKey`, `DatastoreHasWhere`, etc.) provide standard operations. **You are not required to extend them.** + +### Full standard interface (database-friendly) + +If your storage supports queries, filtering, and standard CRUD, extend the base interfaces: + +```php +interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere, DatastoreHasCounts +{ + public function getPublishedPosts(): array; +} + +interface PostDatastoreHandler extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere, DatastoreHasCounts +{ + // Standard operations +} +``` + +**Use when:** +- Storage is a database with full query support +- You want standard CRUD operations available +- Consumers benefit from generic query methods like `where()` + + +## The decorator pattern with traits + +When your `Datastore` and `DatastoreHandler` both extend the same base interfaces, use decorator traits to eliminate boilerplate delegation code. + +### Without decorator traits (manual delegation) + +Without traits, you'd write delegation methods for every standard operation: + +```php +class PostDatastore implements PostDatastoreInterface +{ + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + // Manual delegation for Datastore methods + public function create(array $attributes): Post + { + return $this->datastoreHandler->create($attributes); + } + + public function updateCompound(array $ids, array $attributes): void + { + $this->datastoreHandler->updateCompound($ids, $attributes); + } + + // Manual delegation for DatastoreHasPrimaryKey methods + public function find(int $id): Post + { + return $this->datastoreHandler->find($id); + } + + public function findMultiple(array $ids): array + { + return $this->datastoreHandler->findMultiple($ids); + } + + public function update(int $id, array $attributes): void + { + $this->datastoreHandler->update($id, $attributes); + } + + public function delete(int $id): void + { + $this->datastoreHandler->delete($id); + } + + // Manual delegation for DatastoreHasWhere methods + public function where(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array + { + return $this->datastoreHandler->where($conditions, $limit, $offset, $orderBy, $order); + } + + public function andWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array + { + return $this->datastoreHandler->andWhere($conditions, $limit, $offset, $orderBy, $order); + } + + public function orWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array + { + return $this->datastoreHandler->orWhere($conditions, $limit, $offset, $orderBy, $order); + } + + public function deleteWhere(array $conditions): void + { + $this->datastoreHandler->deleteWhere($conditions); + } + + public function findBy(string $field, $value): Post + { + return $this->datastoreHandler->findBy($field, $value); + } + + // Plus count methods, plus custom methods... +} +``` + +That's dozens of lines of boilerplate for a simple datastore. + +### With decorator traits (automatic delegation) + +Decorator traits handle all standard delegation automatically: + +```php +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; // Delegates: create, updateCompound + use WithDatastorePrimaryKeyDecorator; // Delegates: find, findMultiple, update, delete + use WithDatastoreWhereDecorator; // Delegates: where, andWhere, orWhere, deleteWhere, findBy + use WithDatastoreCountDecorator; // Delegates: count methods + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + // Only implement custom business methods + public function getPublishedPosts(): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] + ] + ] + ]); + } + + public function getByAuthor(int $authorId): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] + ] + ] + ]); + } +} +``` + +All standard operations automatically delegate to `$this->datastoreHandler`. You only write custom business methods. + +### Available decorator traits + +| Trait | Delegates Methods | +|-------|-------------------| +| `WithDatastoreDecorator` | `create()`, `updateCompound()` | +| `WithDatastorePrimaryKeyDecorator` | `find()`, `findMultiple()`, `update()`, `delete()` | +| `WithDatastoreWhereDecorator` | `where()`, `andWhere()`, `orWhere()`, `deleteWhere()`, `findBy()` | +| `WithDatastoreCountDecorator` | Count-related methods | + +Use the traits that match the interfaces your Datastore extends. If your `PostDatastore` extends `DatastoreHasPrimaryKey`, use `WithDatastorePrimaryKeyDecorator`. + +### When NOT to use decorator traits + +**Don't use decorator traits when:** + +1. **Your interfaces don't match** - If `PostDatastore` extends `DatastoreHasWhere` but `PostDatastoreHandler` doesn't, you can't delegate +2. **You want a minimal API** - If you're not extending base interfaces, don't use delegation traits +3. **You need custom behavior** - If standard operations need special handling, implement them manually + +```php +// Example: Minimal API, no delegation +interface PostDatastore extends Datastore +{ + public function getPublishedPosts(): array; +} + +interface PostDatastoreHandler extends Datastore +{ + // Minimal +} + +class PostDatastore implements PostDatastoreInterface +{ + // NO decorator traits + + public function __construct( + private PostDatastoreHandler $datastoreHandler + ) {} + + // Implement everything explicitly + public function create(array $attributes): Post + { + return $this->datastoreHandler->create($attributes); + } + + public function updateCompound(array $ids, array $attributes): void + { + $this->datastoreHandler->updateCompound($ids, $attributes); + } + + public function getPublishedPosts(): array + { + // Custom implementation + } +} +``` + +--- + +## Custom business methods + +Custom methods define domain-specific operations. They use handler primitives to implement business logic. + +### Pattern 1: Simple filtering + +```php +interface PostDatastore extends Datastore, DatastoreHasWhere +{ + public function getPublishedPosts(): array; + public function getDraftPosts(): array; +} + +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; + use WithDatastoreWhereDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + public function getPublishedPosts(): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'status', 'operator' => '=', 'value' => 'published'] + ] + ] + ]); + } + + public function getDraftPosts(): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'status', 'operator' => '=', 'value' => 'draft'] + ] + ] + ]); + } +} +``` + +### Pattern 2: Lookup by specific field + +```php +interface PostDatastore extends Datastore, DatastoreHasWhere +{ + public function getBySlug(string $slug): Post; + public function getByAuthor(int $authorId): array; +} + +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; + use WithDatastoreWhereDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + public function getBySlug(string $slug): Post + { + return $this->datastoreHandler->findBy('slug', $slug); + } + + public function getByAuthor(int $authorId): array + { + return $this->datastoreHandler->where([ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] + ] + ] + ]); + } +} +``` + +### Pattern 3: Complex queries + +```php +interface PostDatastore extends Datastore, DatastoreHasWhere +{ + public function getRecentPublishedByAuthor(int $authorId, int $limit = 10): array; +} + +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; + use WithDatastoreWhereDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + public function getRecentPublishedByAuthor(int $authorId, int $limit = 10): array + { + return $this->datastoreHandler->where( + conditions: [ + [ + 'type' => 'AND', + 'clauses' => [ + ['column' => 'authorId', 'operator' => '=', 'value' => $authorId], + ['column' => 'status', 'operator' => '=', 'value' => 'published'] + ] + ] + ], + limit: $limit, + orderBy: 'publishedDate', + order: 'DESC' + ); + } +} +``` + +### Pattern 4: Combining multiple operations + +```php +interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere +{ + public function publishPost(int $postId): Post; +} + +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; + use WithDatastorePrimaryKeyDecorator; + use WithDatastoreWhereDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + public function publishPost(int $postId): Post + { + $this->datastoreHandler->update($postId, [ + 'status' => 'published', + 'publishedDate' => date('Y-m-d H:i:s') + ]); + + return $this->datastoreHandler->find($postId); + } +} +``` + +--- + +## Design principles for Core datastores + +### Keep business logic in the Core implementation + +Custom methods implement business logic by composing handler primitives. The handler doesn't know about "published posts" or "recent posts"—it just provides query capabilities. The Core datastore interprets what "published" means. + +### Be intentional about your public API + +Every method you add to `PostDatastore` is a promise to consumers. If you add `where()` to your interface, consumers will use it. If you later switch to a REST API that doesn't support generic queries, you'll break consumers. + +Ask yourself: +- Will this storage always support this operation? +- Do I want consumers calling this directly? +- Is this operation stable long-term? + +If unsure, keep your interface minimal and add methods as needed. + +### Handler interfaces should be generic + +The `DatastoreHandler` interface should contain only operations that **any storage implementation** can reasonably provide. Don't add business-specific methods to the handler—those belong in the `Datastore` implementation. + +--- + +## Summary + +The Core datastore layer defines business-level data operations through interfaces and implementations. The critical distinction is between `Datastore` (public API for consumers) and `DatastoreHandler` (contract for storage implementations). Decorator traits eliminate boilerplate delegation when both interfaces extend the same base interfaces. For tighter control or limited storage capabilities, opt out of base interfaces and define only the operations you need. Custom business methods compose handler primitives to implement domain logic. Keep your public API intentional and your handler interfaces generic. diff --git a/public/docs/docs/packages/datastore/integration-guide.md b/public/docs/docs/packages/datastore/integration-guide.md new file mode 100644 index 0000000..e8bed98 --- /dev/null +++ b/public/docs/docs/packages/datastore/integration-guide.md @@ -0,0 +1,485 @@ +# Datastore Integration Guide + +This guide shows you how to integrate the datastore package into your application by creating a complete datastore implementation from scratch. While the [Getting Started Tutorial](/core-concepts/getting-started-tutorial) walks through the basics, this guide covers **production patterns**, **dependency injection setup**, and **custom implementations** for different storage backends. + +## Integration Overview + +Integrating a datastore involves four steps: + +1. **Define your Core contracts** — interfaces for your datastore and handler +2. **Implement the Core datastore** — the public API layer +3. **Implement the Service handler** — the storage backend layer +4. **Register with DI** — wire everything together + +This guide demonstrates each step using a `Post` entity as an example. + +--- + +## Step 1: Define Core Contracts + +Start by defining **interfaces** for your datastore and handler in the Core layer. These are the contracts your application depends on. + +### Datastore Interface + +```php +handler + ->where() + ->equals('author_id', $authorId) + ->lessThanOrEqual('published_date', new \DateTime()) + ->orderBy('published_date', 'DESC') + ->getResults(); + } +} +``` + +**Key points:** +- Traits provide standard method implementations +- Trait conflicts are resolved with `insteadof` +- Custom methods are implemented manually +- Handler is injected via constructor + +--- + +## Step 3: Implement Service Handler + +The Service handler connects your datastore to actual storage. For database-backed datastores, extend `IdentifiableDatabaseDatastoreHandler`. + +### Database Handler + +```php +model = Post::class; + $this->table = $table; + $this->modelAdapter = $adapter; + $this->serviceProvider = $serviceProvider; + $this->tableSchemaService = $tableSchemaService; + } +} +``` + +**Required components:** +- `DatabaseServiceProvider` — provides query builder, cache, events +- `Table` — schema definition for the database table +- `ModelAdapter` — converts between models and arrays +- `TableSchemaService` — handles schema creation/updates + +### Table Definition + +```php +toColumn(), + new Column('title', 'VARCHAR', [255], 'NOT NULL'), + new Column('content', 'TEXT', null, 'NOT NULL'), + new Column('author_id', 'BIGINT', null, 'NOT NULL'), + new Column('published_date', 'DATETIME', null, 'NULL'), + (new DateCreatedFactory())->toColumn(), + (new DateModifiedFactory())->toColumn(), + ]; + } + + public function getIndices(): array + { + return [ + new Index(['author_id'], 'author_idx', 'INDEX'), + new Index(['published_date'], 'published_idx', 'INDEX'), + ]; + } + + public function getUnprefixedName(): string + { + return 'posts'; + } + + public function getSingularUnprefixedName(): string + { + return 'post'; + } +} +``` + +--- + +## Step 4: Register with Dependency Injection + +Wire everything together in your service provider: + +```php +set(PostAdapter::class, fn() => new PostAdapter()); + + // Register the table + $container->set(PostsTable::class, fn() => new PostsTable()); + + // Register the handler (Service layer) + $container->set(PostDatastoreHandler::class, function($c) { + return new PostDatabaseHandler( + $c->get(DatabaseServiceProvider::class), + $c->get(PostsTable::class), + $c->get(PostAdapter::class), + $c->get(TableSchemaService::class) + ); + }); + + // Register the datastore (Core layer) + $container->set(PostDatastore::class, function($c) { + return new CorePostDatastore( + $c->get(PostDatastoreHandler::class) + ); + }); + } +} +``` + +Now your application can inject `PostDatastore` anywhere it needs data access: + +```php +class PublishPostService +{ + public function __construct( + private PostDatastore $posts + ) {} + + public function publish(int $postId): void + { + $post = $this->posts->find($postId); + // ... business logic + } +} +``` + +--- + +## Alternative Backend: REST API Handler + +You can implement handlers for different storage backends. Here's a REST API example: + +```php +httpClient->get("{$this->apiBaseUrl}/posts/{$id}"); + + if ($response->getStatusCode() === 404) { + throw new RecordNotFoundException("Post {$id} not found"); + } + + $data = json_decode($response->getBody(), true); + return $this->adapter->toModel($data); + } + + public function get(array $args = []): iterable + { + $queryString = http_build_query($args); + $response = $this->httpClient->get("{$this->apiBaseUrl}/posts?{$queryString}"); + $data = json_decode($response->getBody(), true); + + return array_map( + fn($item) => $this->adapter->toModel($item), + $data['posts'] ?? [] + ); + } + + public function save(Post $item): Post + { + $data = $this->adapter->toArray($item); + + if ($item->getId()) { + // UPDATE + $response = $this->httpClient->put( + "{$this->apiBaseUrl}/posts/{$item->getId()}", + $data + ); + } else { + // CREATE + $response = $this->httpClient->post( + "{$this->apiBaseUrl}/posts", + $data + ); + } + + $responseData = json_decode($response->getBody(), true); + return $this->adapter->toModel($responseData); + } + + public function delete(Post $item): void + { + $this->httpClient->delete("{$this->apiBaseUrl}/posts/{$item->getId()}"); + } + + public function where(): DatastoreWhereQuery + { + // Return a REST-compatible query builder + return new RestWhereQuery($this->httpClient, $this->apiBaseUrl, $this->adapter); + } + + public function count(array $args = []): int + { + $queryString = http_build_query(array_merge($args, ['count_only' => true])); + $response = $this->httpClient->get("{$this->apiBaseUrl}/posts?{$queryString}"); + $data = json_decode($response->getBody(), true); + return $data['count'] ?? 0; + } +} +``` + +**Register the REST handler instead:** + +```php +$container->set(PostDatastoreHandler::class, function($c) { + return new PostRestHandler( + $c->get(Client::class), + $c->get(PostAdapter::class), + 'https://api.example.com/v1' + ); +}); +``` + +Your Core datastore and application code **don't change**—only the handler implementation. + +--- + +## Best Practices + +### Keep Core and Service Separate + +``` +Core/ + Datastores/ + Post/ + Interfaces/ + PostDatastore.php # Public interface + PostDatastoreHandler.php # Handler contract + PostDatastore.php # Implementation (delegates to handler) + Models/ + Post.php + Adapters/ + PostAdapter.php + +Service/ + Datastores/ + Post/ + PostDatabaseHandler.php # Database implementation + PostsTable.php # Schema definition +``` + +**Core** = business logic, storage-agnostic +**Service** = concrete storage implementations + +### Use Traits for Standard Implementations + +Don't write boilerplate delegation code: + +```php +// ❌ BAD: manual delegation +class PostDatastore implements IPostDatastore +{ + public function get(array $args = []): iterable + { + return $this->handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + // ... etc +} + +// ✅ GOOD: use traits +class PostDatastore implements IPostDatastore +{ + use WithDatastorePrimaryKeyDecorator; + use WithDatastoreWhereDecorator; + use WithDatastoreCountDecorator; +} +``` + +### Inject Interfaces, Not Implementations + +```php +// ✅ GOOD: depend on interface +class PostService +{ + public function __construct( + private PostDatastore $posts // Interface + ) {} +} + +// ❌ BAD: depend on implementation +class PostService +{ + public function __construct( + private CorePostDatastore $posts // Concrete class + ) {} +} +``` + +This allows swapping implementations (database → REST) without touching consumers. + +--- + +## What's Next + +* [Model Adapters](/packages/datastore/model-adapters) — converting between models and storage arrays +* [Database Handlers](/packages/database/handlers/introduction) — database-specific handler details +* [Table Definitions](/packages/database/tables/introduction) — defining database schemas +* [Core Implementation](/packages/datastore/core-implementation) — advanced Core datastore patterns diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md new file mode 100644 index 0000000..8ba120c --- /dev/null +++ b/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md @@ -0,0 +1,289 @@ +# DatastoreHasCounts Interface + +The `DatastoreHasCounts` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **efficient counting operations**. It provides the `count()` method for determining how many records match given criteria without fetching and loading all the data. + +This interface is useful for **pagination** (knowing total pages), **dashboard metrics** (e.g., "23 unread messages"), and **existence checks** (e.g., "are there any drafts?"). + +## Interface Definition + +```php +interface DatastoreHasCounts extends Datastore +{ + /** + * Returns the total number of records matching the criteria. + * + * @param array $args Filtering criteria (same format as get()) + * @return int The count of matching records + */ + public function count(array $args = []): int; +} +``` + +## Method + +### `count(array $args = []): int` + +Counts records matching the provided criteria without fetching them. + +**Parameters:** +* `$args` — Filtering criteria as an associative array (same format as `get()`). + +**Returns:** +* An integer representing the number of matching records. + +**When to use:** +* Calculating pagination totals +* Dashboard metrics and statistics +* Checking if records exist (`count() > 0`) +* Avoiding memory overhead of loading large result sets + +**Example: total records** + +```php +$totalPosts = $postDatastore->count(); +echo "Total posts: {$totalPosts}"; +``` + +**Example: filtered count** + +```php +$publishedCount = $postDatastore->count(['status' => 'published']); +$draftCount = $postDatastore->count(['status' => 'draft']); + +echo "Published: {$publishedCount}, Drafts: {$draftCount}"; +``` + +**Example: existence check** + +```php +$hasDrafts = $postDatastore->count(['status' => 'draft']) > 0; + +if ($hasDrafts) { + echo "You have unpublished drafts"; +} +``` + +--- + +## Why This Interface Exists + +Without `count()`, you'd have to fetch all records and count them: + +```php +// ❌ BAD: loads all records into memory +$posts = $datastore->get(['author_id' => 123]); +$count = count($posts); // Expensive! +``` + +With `count()`, the operation happens at the storage layer: + +```php +// ✅ GOOD: efficient database COUNT query +$count = $datastore->count(['author_id' => 123]); +``` + +For databases, this translates to `SELECT COUNT(*) FROM ...`, which is far more efficient than fetching rows. + +--- + +## Usage Patterns + +### Pagination + +Counting is essential for calculating total pages: + +```php +final class PostPaginationService +{ + public function __construct( + private PostDatastore $posts + ) {} + + public function getPaginationInfo(array $filters, int $perPage): array + { + $total = $this->posts->count($filters); + $totalPages = (int) ceil($total / $perPage); + + return [ + 'total' => $total, + 'per_page' => $perPage, + 'total_pages' => $totalPages, + ]; + } + + public function getPage(array $filters, int $page, int $perPage): iterable + { + return $this->posts->get(array_merge($filters, [ + 'limit' => $perPage, + 'offset' => ($page - 1) * $perPage, + ])); + } +} +``` + +**Usage:** + +```php +$filters = ['author_id' => 123, 'status' => 'published']; + +$info = $service->getPaginationInfo($filters, perPage: 10); +// ['total' => 47, 'per_page' => 10, 'total_pages' => 5] + +$posts = $service->getPage($filters, page: 2, perPage: 10); +// Returns posts 11-20 +``` + +### Dashboard Metrics + +Counting is ideal for statistics dashboards: + +```php +final class DashboardService +{ + public function __construct( + private PostDatastore $posts + ) {} + + public function getStats(int $authorId): array + { + return [ + 'total' => $this->posts->count(['author_id' => $authorId]), + 'published' => $this->posts->count([ + 'author_id' => $authorId, + 'status' => 'published' + ]), + 'drafts' => $this->posts->count([ + 'author_id' => $authorId, + 'status' => 'draft' + ]), + ]; + } +} +``` + +**Returns:** + +```php +[ + 'total' => 52, + 'published' => 48, + 'drafts' => 4, +] +``` + +### Conditional Logic + +Use `count()` for existence checks or thresholds: + +```php +// Check if user has any posts before allowing account deletion +$postCount = $postDatastore->count(['author_id' => $userId]); + +if ($postCount > 0) { + throw new ValidationException("Cannot delete user with existing posts"); +} + +// Enforce post limits +$userPostCount = $postDatastore->count(['author_id' => $userId]); + +if ($userPostCount >= 100) { + throw new LimitExceededException("Post limit reached"); +} +``` + +--- + +## Combining with WHERE Queries + +If your datastore implements both `DatastoreHasCounts` and [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where), you can count complex queries: + +```php +$query = $postDatastore + ->where() + ->equals('author_id', 123) + ->greaterThan('published_date', '2024-01-01') + ->lessThan('view_count', 100); + +$count = $query->count(); // How many match? +$posts = $query->getResults(); // Fetch them if needed +``` + +This is more powerful than `count($args)` because you get the full query-builder API. + +--- + +## Relationship to Other Interfaces + +### vs. `get()` + `count()` + +| Method | Efficiency | Use Case | +|--------|-----------|----------| +| `count($args)` | High (storage-layer count) | Pagination, metrics, existence checks | +| `count(get($args))` | Low (loads all data) | Never do this | + +**Always use `count()` instead of loading and counting.** + +### Combining with Other Extensions + +```php +interface PostDatastore extends + Datastore, + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete() + // find() + // where() + // count() +} +``` + +This provides a complete query API. + +--- + +## Implementation with Decorator Traits + +Use [`WithDatastoreCountDecorator`](/packages/datastore/traits/with-datastore-count-decorator) to auto-implement: + +```php +final class PostDatastoreImpl implements DatastoreHasCounts +{ + use WithDatastoreCountDecorator; + + public function __construct( + private DatastoreHandlerHasCounts $handler + ) {} +} +``` + +The trait delegates `count()` to the handler. + +--- + +## Implementation Notes + +When implementing this interface: + +* **`count()` should be efficient** — execute a storage-layer count (e.g., `SELECT COUNT(*)`), not fetch-and-count. +* **Return 0 for no matches** — don't return `null` or throw exceptions. +* **`$args` format matches `get()`** — use the same filtering conventions. +* **Support empty args** — `count()` with no args returns the total record count. + +--- + +## When NOT to Implement This Interface + +Skip `DatastoreHasCounts` if: +* Your storage can't count efficiently (e.g., some APIs don't expose count endpoints). +* Counting isn't needed for your use case. +* You're prototyping and can add it later. + +--- + +## What's Next + +* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface +* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — query-builder counting +* [WithDatastoreCountDecorator](/packages/datastore/traits/with-datastore-count-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md new file mode 100644 index 0000000..e4d98ae --- /dev/null +++ b/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md @@ -0,0 +1,254 @@ +# DatastoreHasPrimaryKey Interface + +The `DatastoreHasPrimaryKey` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **primary key-based operations**. It provides the `find()` method for fast single-record lookups by ID—one of the most common operations in data access. + +This interface assumes your storage uses a **single integer primary key** (typically named `id`). If your model uses compound keys or non-integer identifiers, this interface may not apply. + +## Interface Definition + +```php +interface DatastoreHasPrimaryKey extends Datastore +{ + /** + * Finds a single record by its primary key. + * + * @param int $id The primary key value + * @return Model The matching model + * @throws RecordNotFoundException if no record exists with the given ID + */ + public function find(int $id): Model; +} +``` + +## Method + +### `find(int $id): Model` + +Retrieves a single model by its primary key value. + +**Parameters:** +* `$id` — The primary key (typically an auto-increment integer). + +**Returns:** +* A single `Model` instance. + +**Throws:** +* `RecordNotFoundException` — if no record exists with the given ID. + +**When to use:** +* Fetching a known record by ID +* Loading related entities (e.g., "get the author for this post") +* REST endpoints like `GET /posts/42` + +**Example: basic lookup** + +```php +try { + $post = $postDatastore->find(42); + echo $post->title; +} catch (RecordNotFoundException $e) { + echo "Post not found"; +} +``` + +**Example: loading related entity** + +```php +$post = $postDatastore->find(123); + +// Load the author using the foreign key +$author = $authorDatastore->find($post->authorId); + +echo "Post '{$post->title}' by {$author->name}"; +``` + +--- + +## Why This Interface Exists + +Primary key lookups are: +* **Fast** — indexed lookups are O(log n) or O(1) in most databases. +* **Common** — most business logic operates on single entities. +* **Predictable** — you know you'll get exactly one result (or an exception). + +By separating `find()` into its own interface, PHPNomad allows datastores to opt in or out based on their storage model. For example: +* **Database-backed datastores** → implement this (they have primary keys). +* **Log aggregators or event streams** → don't implement this (they don't have primary keys). + +## Usage Patterns + +### Service Layer Integration + +Services typically depend on `DatastoreHasPrimaryKey` when they need ID-based lookups: + +```php +final class PublishPostService +{ + public function __construct( + private PostDatastore $posts // Assumes DatastoreHasPrimaryKey + ) {} + + public function publish(int $postId): void + { + $post = $this->posts->find($postId); + + // Business logic: create new model with updated date + $publishedPost = new Post( + id: $post->id, + title: $post->title, + content: $post->content, + authorId: $post->authorId, + publishedDate: new DateTime() // Set publish date + ); + + $this->posts->save($publishedPost); + } +} +``` + +### REST Controller Example + +REST endpoints often map directly to `find()`: + +```php +final class GetPostController implements Controller +{ + public function __construct( + private PostDatastore $posts, + private Response $response + ) {} + + public function getEndpoint(): string + { + return '/posts/{id}'; + } + + public function getMethod(): string + { + return Method::Get; + } + + public function getResponse(Request $request): Response + { + $id = (int) $request->getParam('id'); + + try { + $post = $this->posts->find($id); + + return $this->response + ->setStatus(200) + ->setJson(['post' => $post]); + } catch (RecordNotFoundException $e) { + return $this->response + ->setStatus(404) + ->setJson(['error' => 'Post not found']); + } + } +} +``` + +### Error Handling + +Always handle `RecordNotFoundException` when calling `find()`: + +```php +// ✅ GOOD: explicit error handling +try { + $post = $postDatastore->find($id); + // ... use post +} catch (RecordNotFoundException $e) { + // Handle gracefully +} + +// ❌ BAD: unhandled exception crashes the application +$post = $postDatastore->find($id); // May throw! +``` + +## Relationship to Other Interfaces + +### vs. `get()` + +Both `find()` and `get()` can fetch records, but they serve different purposes: + +| Method | Returns | When Not Found | Use Case | +|--------|---------|----------------|----------| +| `find($id)` | Single model | Throws exception | Known ID, expect one result | +| `get(['id' => $id])` | Iterable (0 or 1 item) | Empty iterable | Query by criteria, may return none | + +**Example comparison:** + +```php +// Using find() - throws if not found +try { + $post = $datastore->find(42); +} catch (RecordNotFoundException $e) { + // Handle not found +} + +// Using get() - returns empty if not found +$posts = $datastore->get(['id' => 42]); +if (empty($posts)) { + // Handle not found +} +$post = $posts[0] ?? null; +``` + +Use `find()` when you **expect** the record to exist. Use `get()` when existence is uncertain. + +### Combining with Other Extensions + +Most datastores implement multiple interfaces: + +```php +interface PostDatastore extends + Datastore, + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete() from Datastore + // find() from DatastoreHasPrimaryKey + // where() from DatastoreHasWhere + // count() from DatastoreHasCounts +} +``` + +This gives consumers a full set of operations. + +## Implementation with Decorator Traits + +If your Core implementation just delegates to a handler, use [`WithDatastorePrimaryKeyDecorator`](/packages/datastore/traits/with-datastore-primary-key-decorator): + +```php +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; + + public function __construct( + private DatastoreHandlerHasPrimaryKey $handler + ) {} +} +``` + +The trait provides `find()`, `get()`, `save()`, and `delete()` automatically. + +## Implementation Notes + +When implementing this interface: + +* **`find()` must throw `RecordNotFoundException`** if the ID doesn't exist—do not return `null`. +* **Primary key should be indexed** in your storage layer for performance. +* **Thread safety** — `find()` should always return the latest data (no stale reads unless caching is explicit). + +## When NOT to Implement This Interface + +Skip `DatastoreHasPrimaryKey` if: +* Your storage doesn't have primary keys (e.g., logs, events). +* You use compound keys (use custom methods instead). +* You use non-integer IDs (e.g., UUIDs—extend the interface with `findByUuid()` or similar). + +## What's Next + +* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface this extends +* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — query-builder filtering +* [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md new file mode 100644 index 0000000..8c36584 --- /dev/null +++ b/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md @@ -0,0 +1,292 @@ +# DatastoreHasWhere Interface + +The `DatastoreHasWhere` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **query-builder-style filtering**. It provides the `where()` method, which returns a fluent query interface for building complex queries with multiple conditions, comparisons, and sorting. + +This interface is for datastores that support **rich querying** beyond simple key-value filtering. If your storage supports SQL, this interface maps naturally to `WHERE` clauses. + +## Interface Definition + +```php +interface DatastoreHasWhere extends Datastore +{ + /** + * Returns a query interface for building WHERE clauses. + * + * @return DatastoreWhereQuery A fluent query builder + */ + public function where(): DatastoreWhereQuery; +} +``` + +## Method + +### `where(): DatastoreWhereQuery` + +Returns a query builder instance for constructing filtered queries. + +**Returns:** +* A `DatastoreWhereQuery` object that provides methods like `equals()`, `greaterThan()`, `lessThan()`, `in()`, `like()`, `orderBy()`, `limit()`, and `getResults()`. + +**When to use:** +* Complex filtering (multiple conditions, OR logic, comparisons) +* Sorting results +* Pagination with complex criteria +* Queries that don't map cleanly to `get(['key' => 'value'])` + +**Example: basic filtering** + +```php +$posts = $postDatastore + ->where() + ->equals('author_id', 123) + ->getResults(); +``` + +**Example: multiple conditions** + +```php +$posts = $postDatastore + ->where() + ->equals('author_id', 123) + ->greaterThan('published_date', '2024-01-01') + ->lessThanOrEqual('published_date', '2024-12-31') + ->getResults(); +``` + +**Example: OR conditions** + +```php +$posts = $postDatastore + ->where() + ->equals('status', 'published') + ->or() + ->equals('status', 'featured') + ->getResults(); +``` + +**Example: sorting and pagination** + +```php +$posts = $postDatastore + ->where() + ->equals('author_id', 123) + ->orderBy('published_date', 'DESC') + ->limit(10) + ->offset(20) + ->getResults(); +``` + +--- + +## DatastoreWhereQuery API + +The `DatastoreWhereQuery` interface provides a fluent API for building queries. Implementations typically support: + +### Comparison Methods + +* `equals(string $field, mixed $value)` — `field = value` +* `notEquals(string $field, mixed $value)` — `field != value` +* `greaterThan(string $field, mixed $value)` — `field > value` +* `greaterThanOrEqual(string $field, mixed $value)` — `field >= value` +* `lessThan(string $field, mixed $value)` — `field < value` +* `lessThanOrEqual(string $field, mixed $value)` — `field <= value` +* `in(string $field, array $values)` — `field IN (values)` +* `notIn(string $field, array $values)` — `field NOT IN (values)` +* `like(string $field, string $pattern)` — `field LIKE pattern` +* `isNull(string $field)` — `field IS NULL` +* `isNotNull(string $field)` — `field IS NOT NULL` + +### Logical Operators + +* `and()` — AND condition (default between chained methods) +* `or()` — OR condition + +### Ordering and Pagination + +* `orderBy(string $field, string $direction = 'ASC')` — Sort results +* `limit(int $count)` — Limit number of results +* `offset(int $count)` — Skip N results (for pagination) + +### Execution + +* `getResults(): iterable` — Execute the query and return matching models +* `count(): int` — Count matching records (if combined with `DatastoreHasCounts`) +* `delete(): void` — Delete matching records (if supported) + +--- + +## Usage Patterns + +### Service Layer Queries + +Services use `where()` for business queries: + +```php +final class PostService +{ + public function __construct( + private PostDatastore $posts + ) {} + + public function getRecentPublishedPosts(int $authorId, int $limit = 10): iterable + { + return $this->posts + ->where() + ->equals('author_id', $authorId) + ->lessThanOrEqual('published_date', new DateTime()) + ->orderBy('published_date', 'DESC') + ->limit($limit) + ->getResults(); + } + + public function getPostsByTag(string $tag): iterable + { + return $this->posts + ->where() + ->like('tags', "%{$tag}%") + ->getResults(); + } +} +``` + +### Complex Filtering Example + +```php +// Find posts that are: +// - By author 123 OR author 456 +// - Published in 2024 +// - Status is "published" or "featured" +// - Sorted by views (descending) + +$posts = $postDatastore + ->where() + ->in('author_id', [123, 456]) + ->greaterThanOrEqual('published_date', '2024-01-01') + ->lessThan('published_date', '2025-01-01') + ->in('status', ['published', 'featured']) + ->orderBy('view_count', 'DESC') + ->limit(20) + ->getResults(); +``` + +### Counting with Queries + +If your datastore also implements `DatastoreHasCounts`, you can count query results: + +```php +$query = $postDatastore + ->where() + ->equals('author_id', 123) + ->greaterThan('published_date', '2024-01-01'); + +$count = $query->count(); // How many match? +$posts = $query->getResults(); // Fetch them +``` + +### Deleting with Queries + +Some implementations support `delete()` on queries: + +```php +// Delete all draft posts older than 30 days +$postDatastore + ->where() + ->equals('status', 'draft') + ->lessThan('created_at', new DateTime('-30 days')) + ->delete(); +``` + +**Note:** Not all datastores support query-based deletion. Check your implementation. + +--- + +## Why This Interface Exists + +The base [`get()`](/packages/datastore/interfaces/datastore) method works for simple queries: + +```php +$posts = $datastore->get(['author_id' => 123]); +``` + +But it breaks down for: +* **Comparisons** — `get(['views >' => 1000])` is awkward and non-standard. +* **OR logic** — `get(['status' => 'published OR featured'])` doesn't work. +* **Sorting** — `get()` doesn't provide ordering control. + +`DatastoreHasWhere` solves this with a fluent, expressive API that maps cleanly to SQL and other query languages. + +## Relationship to Other Interfaces + +### vs. `get()` + +| Method | Use Case | Query Complexity | +|--------|----------|------------------| +| `get(['key' => 'value'])` | Simple key-value filtering | Low | +| `where()->equals()->getResults()` | Complex queries with comparisons, OR logic, sorting | High | + +**Rule of thumb:** If you can express it as `['key' => 'value']`, use `get()`. Otherwise, use `where()`. + +### Combining with Other Extensions + +```php +interface PostDatastore extends + Datastore, + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete() + // find() + // where() + // count() +} +``` + +This gives you all query capabilities. + +--- + +## Implementation with Decorator Traits + +Use [`WithDatastoreWhereDecorator`](/packages/datastore/traits/with-datastore-where-decorator) to auto-implement: + +```php +final class PostDatastoreImpl implements DatastoreHasWhere +{ + use WithDatastoreWhereDecorator; + + public function __construct( + private DatastoreHandlerHasWhere $handler + ) {} +} +``` + +The trait delegates `where()` to the handler, which returns its query builder. + +--- + +## Implementation Notes + +When implementing this interface: + +* **`where()` returns a new query instance** — don't modify shared state. +* **Queries are immutable** — each method call returns a new query object (or mutates and returns `$this` for chaining). +* **`getResults()` executes the query** — it's the only method that hits storage. +* **Support standard operators** — at minimum: `equals`, `in`, `greaterThan`, `lessThan`, `orderBy`, `limit`, `offset`. + +--- + +## When NOT to Implement This Interface + +Skip `DatastoreHasWhere` if: +* Your storage doesn't support filtering (e.g., simple key-value stores). +* Queries are always simple and `get()` suffices. +* You're wrapping an API that doesn't support complex queries. + +--- + +## What's Next + +* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface +* [DatastoreHasCounts](/packages/datastore/interfaces/datastore-has-counts) — counting query results +* [WithDatastoreWhereDecorator](/packages/datastore/traits/with-datastore-where-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore.md b/public/docs/docs/packages/datastore/interfaces/datastore.md new file mode 100644 index 0000000..d2ac433 --- /dev/null +++ b/public/docs/docs/packages/datastore/interfaces/datastore.md @@ -0,0 +1,266 @@ +# Datastore Interface + +The `Datastore` interface is the **foundational contract** for all data access in PHPNomad. It defines three core operations—`get()`, `save()`, and `delete()`—that form the basis of every datastore implementation. Every other datastore interface extends from this one. + +## Interface Definition + +```php +interface Datastore +{ + /** + * Retrieves a collection of models based on the provided criteria. + * + * @param array $args Filtering criteria (implementation-defined) + * @return iterable Collection of models matching the criteria + */ + public function get(array $args = []): iterable; + + /** + * Persists a model to storage (create or update). + * + * @param Model $item The model to save + * @return Model The saved model (may include generated IDs or timestamps) + */ + public function save(Model $item): Model; + + /** + * Removes a model from storage. + * + * @param Model $item The model to delete + * @return void + */ + public function delete(Model $item): void; +} +``` + +## Methods + +### `get(array $args = []): iterable` + +Retrieves a collection of models matching the provided criteria. + +**Parameters:** +* `$args` — Filtering criteria as an associative array. The structure is **implementation-defined**, meaning each datastore decides what keys are valid. + +**Returns:** +* An iterable collection of `Model` objects (typically an array or generator). + +**Common `$args` patterns:** + +```php +// Filter by a single field +$posts = $datastore->get(['author_id' => 123]); + +// Multiple conditions (AND logic, typically) +$posts = $datastore->get([ + 'author_id' => 123, + 'status' => 'published' +]); + +// Limit results +$posts = $datastore->get(['limit' => 10]); + +// Pagination +$posts = $datastore->get(['limit' => 10, 'offset' => 20]); + +// Empty args = all records +$posts = $datastore->get(); +``` + +**When to use:** +* Fetching multiple records with simple filtering +* When you don't need advanced query building (use `where()` for that) +* Quick lookups by known fields + +**Example:** + +```php +// Get all published posts by a specific author +$posts = $postDatastore->get([ + 'author_id' => 123, + 'status' => 'published' +]); + +foreach ($posts as $post) { + echo $post->title . "\n"; +} +``` + +--- + +### `save(Model $item): Model` + +Persists a model to storage. This method handles both **create** and **update** operations—implementations typically determine which based on whether the model has a primary key. + +**Parameters:** +* `$item` — The model to persist. + +**Returns:** +* The saved model, potentially with updated fields (e.g., auto-generated IDs, timestamps). + +**Behavior:** +* **Create:** If the model lacks a primary key (or it's `null`), a new record is created. +* **Update:** If the model has a primary key, the existing record is updated. + +**Example: Creating a new record** + +```php +$newPost = new Post( + id: null, // No ID yet + title: 'My First Post', + content: 'Hello world!', + authorId: 123, + publishedDate: new DateTime() +); + +$savedPost = $postDatastore->save($newPost); + +echo $savedPost->id; // Now has an auto-generated ID +``` + +**Example: Updating an existing record** + +```php +$existingPost = $postDatastore->find(42); + +// Models are immutable, so we create a new instance with updated fields +$updatedPost = new Post( + id: $existingPost->id, + title: 'Updated Title', + content: $existingPost->content, + authorId: $existingPost->authorId, + publishedDate: $existingPost->publishedDate +); + +$postDatastore->save($updatedPost); +``` + +**When to use:** +* Creating new records +* Updating existing records +* Persisting changes after business logic + +--- + +### `delete(Model $item): void` + +Removes a model from storage. + +**Parameters:** +* `$item` — The model to delete. Implementations typically extract the primary key from the model to perform the deletion. + +**Returns:** +* `void` — no return value. + +**Behavior:** +* The model is removed from storage. +* If the model doesn't exist, behavior is implementation-defined (may throw an exception or silently succeed). + +**Example:** + +```php +$post = $postDatastore->find(42); + +$postDatastore->delete($post); + +// Post 42 is now removed from storage +``` + +**When to use:** +* Removing records after business logic determines they should be deleted +* Cleanup operations +* Cascading deletes (if not handled by database constraints) + +--- + +## Usage Patterns + +### Basic CRUD Operations + +The `Datastore` interface provides everything needed for simple create-read-update-delete operations: + +```php +// CREATE +$newPost = new Post(null, 'Title', 'Content', 123, new DateTime()); +$savedPost = $datastore->save($newPost); + +// READ +$posts = $datastore->get(['author_id' => 123]); + +// UPDATE +$updatedPost = new Post( + $savedPost->id, + 'New Title', + $savedPost->content, + $savedPost->authorId, + $savedPost->publishedDate +); +$datastore->save($updatedPost); + +// DELETE +$datastore->delete($updatedPost); +``` + +### Working with Immutable Models + +PHPNomad models are **immutable**—once created, their properties cannot change. To "update" a model, you create a new instance with the changed values: + +```php +$post = $datastore->find(42); + +// Create new instance with updated title +$updatedPost = new Post( + id: $post->id, + title: 'New Title', // Changed + content: $post->content, + authorId: $post->authorId, + publishedDate: $post->publishedDate +); + +$datastore->save($updatedPost); +``` + +This ensures data consistency and makes debugging easier (you always know where state changes). + +### Filtering Semantics + +The `$args` parameter in `get()` is **implementation-defined**, but most implementations follow these conventions: + +* **Keys are column names** — `['author_id' => 123]` filters by `author_id`. +* **Multiple keys use AND logic** — `['author_id' => 123, 'status' => 'published']` means "author is 123 AND status is published". +* **Special keys control behavior** — `limit`, `offset`, `order_by` are common. + +For complex queries (OR conditions, comparisons like `>` or `LIKE`), use [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where) instead. + +## Extending the Base Interface + +Most datastores extend `Datastore` with additional capabilities: + +```php +interface PostDatastore extends Datastore, DatastoreHasPrimaryKey +{ + // Inherits get(), save(), delete() + // Adds find() from DatastoreHasPrimaryKey + + // Custom business methods can be added + public function findPublishedPosts(int $authorId): iterable; +} +``` + +See [Datastore Interfaces Overview](/packages/datastore/interfaces/introduction) for extension patterns. + +## Implementation Notes + +When implementing this interface: + +* **`get()` should return an empty iterable** if no matches are found (not `null`, not an exception). +* **`save()` should be idempotent** — calling it multiple times with the same model should produce the same result. +* **`delete()` should not throw** if the model doesn't exist (graceful degradation). +* **Models should be validated** before persistence (use validation services, not handler code). + +## What's Next + +* [DatastoreHasPrimaryKey](/packages/datastore/interfaces/datastore-has-primary-key) — adds `find(int $id)` for ID-based lookups +* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — adds query-builder-style filtering +* [DatastoreHasCounts](/packages/datastore/interfaces/datastore-has-counts) — adds `count()` for efficient counting +* [Core Implementation](/packages/datastore/core-implementation) — how to implement these interfaces diff --git a/public/docs/docs/packages/datastore/interfaces/introduction.md b/public/docs/docs/packages/datastore/interfaces/introduction.md new file mode 100644 index 0000000..d884e08 --- /dev/null +++ b/public/docs/docs/packages/datastore/interfaces/introduction.md @@ -0,0 +1,196 @@ +# Datastore Interfaces + +Datastore interfaces define the **public API** for data access in PHPNomad. They describe what operations consumers can perform without tying them to any specific storage implementation. This separation is what makes datastores portable: the same interface works whether your data lives in a database, a REST API, in-memory cache, or a flat file. + +At the core, every datastore interface extends from **`Datastore`**, which provides basic operations like `get()`, `save()`, and `delete()`. From there, you can layer on additional capabilities through **extension interfaces** that add primary key lookups, querying, counting, and more. + +## Why Interfaces Matter + +In PHPNomad, **interfaces are contracts** that your application code depends on. By coding against `Datastore` or `DatastoreHasPrimaryKey`, you're expressing *what you need* without caring *how it's implemented*. + +This matters because: + +* **Portability** — swap implementations without touching application code (e.g., move from database to REST). +* **Testability** — mock or stub the interface in tests without spinning up real storage. +* **Clarity** — each interface declares exactly what operations it supports, making API boundaries obvious. + +When you write a service or controller that depends on a datastore, you inject the **interface**, not a concrete class. The DI container handles the rest. + +## The Base Interface: `Datastore` + +The `Datastore` interface is the **minimal contract** every datastore must implement. It provides three core operations: + +```php +interface Datastore +{ + /** + * Retrieves a collection of models based on the provided criteria. + */ + public function get(array $args = []): iterable; + + /** + * Persists a model to storage. + */ + public function save(Model $item): Model; + + /** + * Removes a model from storage. + */ + public function delete(Model $item): void; +} +``` + +### What this enables + +* **Fetch** — `get($args)` returns an iterable collection of models filtered by arbitrary criteria. +* **Persist** — `save($model)` writes a model to storage (create or update). +* **Remove** — `delete($model)` deletes a model from storage. + +This is enough to build most CRUD operations. When you need more specific operations (like fetching by ID or running WHERE clauses), you extend this base with additional interfaces. + +## Extension Interfaces + +PHPNomad provides several **extension interfaces** that add specific capabilities to the base `Datastore` contract. Each one is focused on a single concern, and you compose them as needed. + +### `DatastoreHasPrimaryKey` + +Adds the ability to **fetch by primary key** — a common pattern for single-record lookups. + +```php +interface DatastoreHasPrimaryKey extends Datastore +{ + /** + * Finds a single record by its primary key. + * + * @throws RecordNotFoundException if not found + */ + public function find(int $id): Model; +} +``` + +**When to use:** Your datastore has a single integer primary key (e.g., `id`), and you need fast lookups by ID. + +**Example:** +```php +$post = $postDatastore->find(42); +``` + +--- + +### `DatastoreHasWhere` + +Adds **query-builder-style filtering** with a `where()` method that returns a scoped query interface. + +```php +interface DatastoreHasWhere extends Datastore +{ + /** + * Returns a query interface for building WHERE clauses. + */ + public function where(): DatastoreWhereQuery; +} +``` + +**When to use:** You need to filter records by multiple criteria, and `get($args)` isn't expressive enough. + +**Example:** +```php +$posts = $postDatastore + ->where() + ->equals('authorId', 123) + ->greaterThan('publishedDate', '2024-01-01') + ->getResults(); +``` + +--- + +### `DatastoreHasCounts` + +Adds **counting operations** without fetching all records. + +```php +interface DatastoreHasCounts extends Datastore +{ + /** + * Returns the total number of records matching the criteria. + */ + public function count(array $args = []): int; +} +``` + +**When to use:** You need to know *how many* records exist without loading them all into memory (e.g., pagination totals). + +**Example:** +```php +$totalPosts = $postDatastore->count(['authorId' => 123]); +``` + +--- + +## Composing Interfaces + +In practice, most datastores implement **multiple interfaces** to provide a rich API. For example: + +```php +interface PostDatastore extends + Datastore, + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // Custom business methods can also be added here + public function findPublishedPosts(int $authorId): iterable; +} +``` + +This gives consumers: +* Basic operations via `Datastore` +* ID lookups via `DatastoreHasPrimaryKey` +* Complex queries via `DatastoreHasWhere` +* Efficient counting via `DatastoreHasCounts` +* Domain-specific methods like `findPublishedPosts()` + +## The Minimal API Approach + +Not every datastore needs all these capabilities. If your storage layer doesn't support primary keys (e.g., a log aggregator or event stream), you might only implement `Datastore`. + +**Example: minimal datastore** + +```php +interface AuditLogDatastore extends Datastore +{ + // Only needs get(), save(), delete() + // No primary keys, no WHERE queries +} +``` + +This is valid and often preferable. **Only add interfaces when you actually need the capability**, not because other datastores have them. + +## Working with DatastoreHandlers + +While datastore interfaces define the **public API**, `DatastoreHandler` interfaces define the **implementation contract** for storage backends. + +For example: +* `Datastore` is what your application depends on. +* `DatastoreHandler` is what the database/REST/file adapter implements. + +The [Core implementation](/packages/datastore/core-implementation) sits between these two, delegating calls from the public interface to the handler. This separation keeps business logic independent of storage details. + +See [DatastoreHandler interfaces](/packages/database/handlers/introduction) for the storage-side contracts. + +## Best Practices + +When designing or using datastore interfaces: + +* **Depend on interfaces, not implementations** — inject `PostDatastore`, not `DatabasePostDatastore`. +* **Only extend what you need** — don't add `DatastoreHasPrimaryKey` if you don't have primary keys. +* **Keep interfaces focused** — each extension adds one capability. Don't create bloated "god interfaces." +* **Use custom methods sparingly** — add domain-specific methods to your interface, but prefer composing standard operations when possible. + +## What's Next + +To understand how these interfaces are implemented, see: + +* [Core Implementation](/packages/datastore/core-implementation) — how to build the layer that implements these interfaces +* [Decorator Traits](/packages/datastore/traits/introduction) — eliminate boilerplate when delegating to handlers +* [Database Handlers](/packages/database/handlers/introduction) — how storage backends implement the handler contract diff --git a/public/docs/docs/packages/datastore/introduction.md b/public/docs/docs/packages/datastore/introduction.md new file mode 100644 index 0000000..ec8bf7b --- /dev/null +++ b/public/docs/docs/packages/datastore/introduction.md @@ -0,0 +1,309 @@ +# Datastore + +`phpnomad/datastore` is a **storage-agnostic data access layer** that separates **what data operations you need** from **how they're implemented**. It's designed to let you describe **models, interfaces, and operations** in a way that's **independent of the persistence backend** you plug into. + +At its core: + +* **Models** represent domain entities as immutable value objects with no persistence awareness. +* **Datastores** define business-level data operations through interfaces. +* **DatastoreHandlers** provide the contract for concrete implementations. +* **ModelAdapters** convert between models and storage representations. +* **Decorator traits** eliminate boilerplate delegation code. + +By separating data access **definition** (what operations exist, what they require, what they return) from **implementation** (database queries, API calls, cache lookups), you get datastores that can move between storage backends without rewriting business logic. + +--- + +## Key ideas at a glance + +* **Datastore** — Your public API defining business-level data operations for an entity. +* **DatastoreHandler** — The contract that concrete storage implementations fulfill. +* **Model** — An immutable value object representing a domain entity, independent of persistence. +* **ModelAdapter** — Converts between models and storage representations (arrays, JSON, etc.). +* **Decorator traits** — Automatically delegate standard operations to handlers, keeping code lean. + +--- + +## The data access lifecycle + +When your application performs a data operation through a datastore, it moves through a consistent sequence: + +``` +Application → Datastore → Handler → Storage (Database/API/Cache) → Adapter → Model +``` + +### Application layer + +Your controllers, services, or other application code depend on the `Datastore` interface. They call methods like `find()`, `create()`, `where()`, or custom business methods like `getPublishedPosts()`. + +```php +$post = $postDatastore->find(123); +$published = $postDatastore->getPublishedPosts(); +``` + +The application never knows whether posts come from a database, REST API, or cache. It only knows the operations available on the `PostDatastore` interface. + +### Datastore layer + +The **Datastore** is your public API. It extends base interfaces (optionally) and adds custom business methods. Standard operations are delegated to the handler using decorator traits. Custom methods compose handler primitives to implement business logic. + +```php +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; + use WithDatastorePrimaryKeyDecorator; + + protected Datastore $datastoreHandler; + + public function __construct(PostDatastoreHandler $datastoreHandler) + { + $this->datastoreHandler = $datastoreHandler; + } + + // Custom business method + public function getPublishedPosts(): array + { + return $this->datastoreHandler->where([ + ['column' => 'status', 'operator' => '=', 'value' => 'published'] + ]); + } +} +``` + +### Handler layer + +The **DatastoreHandler** is the contract for storage implementations. It extends the same base interfaces as the Datastore but typically contains no custom business methods. Handlers focus on the primitives: create, find, update, delete, query. + +Different implementations exist for different storage backends: +- `PostDatabaseDatastoreHandler` — queries a database +- `PostGraphQLDatastoreHandler` — calls a GraphQL API +- `PostRESTDatastoreHandler` — makes HTTP requests +- `PostCacheDatastoreHandler` — reads from cache + +### Storage layer + +The handler interacts with the actual storage mechanism. For databases, this means SQL queries. For APIs, this means HTTP requests. For caches, this means key-value lookups. + +The storage layer knows nothing about models or business logic. It works with raw data representations (arrays, JSON objects, database rows). + +### Adapter layer + +The **ModelAdapter** converts between storage representations and domain models. When reading, it takes raw data (arrays) and constructs model objects. When writing, it takes model objects and produces storable data. + +```php +class PostAdapter implements ModelAdapter +{ + public function toModel(array $data): Post + { + return new Post( + id: $data['id'], + title: $data['title'], + content: $data['content'] + ); + } + + public function toArray(Post $model): array + { + return [ + 'id' => $model->getId(), + 'title' => $model->title, + 'content' => $model->content + ]; + } +} +``` + +### Model layer + +The **Model** is the final result — a domain entity your application can use. Models are immutable value objects with public readonly properties. They contain no persistence logic and don't know where they came from. + +```php +class Post implements DataModel, HasSingleIntIdentity +{ + use WithSingleIntIdentity; + + public function __construct( + int $id, + public readonly string $title, + public readonly string $content + ) { + $this->id = $id; + } +} +``` + +--- + +## Why separation matters + +### Storage independence + +By depending only on the `Datastore` interface, your application code remains portable. If posts initially come from a database but later need to come from a CMS API, you swap the handler implementation. Application code doesn't change. + +```php +// Day 1: Database implementation +$container->bind(PostDatastoreHandler::class, PostDatabaseDatastoreHandler::class); + +// Day 90: Switch to REST API +$container->bind(PostDatastoreHandler::class, PostRESTDatastoreHandler::class); + +// Application code unchanged +$post = $postDatastore->find(123); +``` + +### Testability + +Datastores are easy to test. Mock the handler, inject it into the datastore, and verify business methods work correctly without touching a real database or API. + +### Clear contracts + +The separation between `Datastore` (what consumers need) and `DatastoreHandler` (what implementations provide) makes contracts explicit. Consumers depend on business operations. Implementations provide storage primitives. + +--- + +## Core interfaces + +The datastore package provides several base interfaces you can extend: + +### Datastore + +The foundational interface. All datastores extend `Datastore`, which defines basic create and update operations. + +```php +interface Datastore +{ + public function create(array $attributes): DataModel; + public function updateCompound(array $ids, array $attributes): void; +} +``` + +### DatastoreHasPrimaryKey + +Adds operations for entities with single integer IDs: find, update, delete by ID. + +```php +interface DatastoreHasPrimaryKey +{ + public function find(int $id): DataModel; + public function findMultiple(array $ids): array; + public function update(int $id, array $attributes): void; + public function delete(int $id): void; +} +``` + +### DatastoreHasWhere + +Adds query operations with conditions: where, andWhere, orWhere, deleteWhere, findBy. + +```php +interface DatastoreHasWhere +{ + public function where(array $conditions, ?int $limit = null, ...): array; + public function andWhere(array $conditions, ?int $limit = null, ...): array; + public function orWhere(array $conditions, ?int $limit = null, ...): array; + public function deleteWhere(array $conditions): void; + public function findBy(string $field, $value): DataModel; +} +``` + +### DatastoreHasCounts + +Adds counting operations for query results. + +You're not required to extend these interfaces. For APIs with limited operations, define only what you need. + +See [Datastore Interfaces](interfaces/introduction) for complete documentation. + +--- + +## Decorator traits + +When your `Datastore` and `DatastoreHandler` extend the same base interfaces, decorator traits eliminate boilerplate delegation code. + +```php +class PostDatastore implements PostDatastoreInterface +{ + use WithDatastoreDecorator; // Delegates: create, updateCompound + use WithDatastorePrimaryKeyDecorator; // Delegates: find, update, delete + use WithDatastoreWhereDecorator; // Delegates: where, andWhere, orWhere + + protected Datastore $datastoreHandler; + + // Only implement custom business methods + public function getPublishedPosts(): array + { + return $this->datastoreHandler->where([...]); + } +} +``` + +Without traits, you'd manually write dozens of delegation methods. Traits handle standard operations automatically. + +See [Decorator Traits](traits/introduction) for complete documentation. + +--- + +## When to use this package + +Use `phpnomad/datastore` when: + +- You need storage-agnostic data access +- Portability between storage backends is important +- You want strong separation between domain and infrastructure +- Multiple implementations of the same entity are anticipated (database today, API tomorrow) +- Testing domain logic independently of storage is critical + +For simple applications with a single, stable storage mechanism and no portability requirements, this abstraction may be overkill. The datastore pattern shines when flexibility and future adaptability matter. + +--- + +## Working with databases + +While the datastore package is storage-agnostic, most applications use databases. The `phpnomad/database` package provides concrete database implementations of the datastore interfaces, including table schema definitions, query builders, caching, and event broadcasting. + +See [Database Package](../database/introduction) for database-specific documentation. + +--- + +## Working with other backends + +The datastore package isn't limited to databases. You can implement handlers for: + +- **REST APIs** — Make HTTP requests, convert JSON to models +- **GraphQL APIs** — Execute GraphQL queries, map responses to models +- **Cache layers** — Read from Redis/Memcached with fallback to primary storage +- **In-memory stores** — Arrays or collections for testing +- **File systems** — JSON/XML files as simple persistence + +See [Integration Guide](integration-guide) for implementing custom handlers. + +--- + +## Package components + +### Required reading + +- **[Core Implementation](core-implementation)** — Directory structure, naming conventions, Datastore vs DatastoreHandler distinction, decorator pattern usage +- **[Datastore Interfaces](interfaces/introduction)** — Complete interface reference +- **[Model Adapters](model-adapters)** — How to create adapters + +### Reference + +- **[Decorator Traits](traits/introduction)** — All available traits and their delegated methods +- **[Integration Guide](integration-guide)** — Implementing custom storage backends + +--- + +## Relationship to other packages + +- **[phpnomad/database](../database/introduction)** — Concrete database implementations of datastore interfaces +- **phpnomad/models** — Provides `DataModel` interface and identity traits (covered in [Models and Identity](../../core-concepts/models-and-identity)) + +--- + +## Next steps + +- **New to datastores?** Start with [Getting Started Tutorial](../../core-concepts/getting-started-tutorial) +- **Understanding the architecture?** Read [Overview and Architecture](../../core-concepts/overview-and-architecture) +- **Ready to implement?** See [Core Implementation](core-implementation) +- **Need database persistence?** Check [Database Package](../database/introduction) diff --git a/public/docs/docs/packages/datastore/model-adapters.md b/public/docs/docs/packages/datastore/model-adapters.md new file mode 100644 index 0000000..15b2a2e --- /dev/null +++ b/public/docs/docs/packages/datastore/model-adapters.md @@ -0,0 +1,540 @@ +# Model Adapters + +Model adapters are **bidirectional transformers** that convert between your immutable domain models and storage-friendly associative arrays. They sit at the boundary between your business logic (which works with strongly-typed models) and your storage layer (which works with raw data). + +Every handler needs an adapter to function. Without adapters, handlers wouldn't know how to convert database rows into models or how to extract data from models for persistence. + +## The Adapter Contract + +All adapters implement the `ModelAdapter` interface: + +```php +interface ModelAdapter +{ + /** + * Converts a model to an associative array for storage. + * + * @param DataModel $model The model to convert + * @return array Associative array with storage-friendly keys + */ + public function toArray(DataModel $model): array; + + /** + * Converts an associative array from storage to a model. + * + * @param array $array Data from storage + * @return DataModel The constructed model instance + */ + public function toModel(array $array): DataModel; +} +``` + +This contract defines two operations: + +* **`toArray()`** — Model → Array (for writes: `save()`, `update()`) +* **`toModel()`** — Array → Model (for reads: `get()`, `find()`) + +--- + +## Basic Adapter Example + +Here's a complete adapter for a `Post` model: + +```php + $model->id, + 'title' => $model->title, + 'content' => $model->content, + 'author_id' => $model->authorId, + 'published_date' => $model->publishedDate?->format('Y-m-d H:i:s'), + 'created_at' => $model->createdAt?->format('Y-m-d H:i:s'), + 'updated_at' => $model->updatedAt?->format('Y-m-d H:i:s'), + ]; + } + + /** + * Convert array from storage to Post model + */ + public function toModel(array $array): DataModel + { + return new Post( + id: Arr::get($array, 'id'), + title: Arr::get($array, 'title', ''), + content: Arr::get($array, 'content', ''), + authorId: Arr::get($array, 'author_id'), + publishedDate: Arr::get($array, 'published_date') + ? new \DateTime(Arr::get($array, 'published_date')) + : null, + createdAt: Arr::get($array, 'created_at') + ? new \DateTime(Arr::get($array, 'created_at')) + : null, + updatedAt: Arr::get($array, 'updated_at') + ? new \DateTime(Arr::get($array, 'updated_at')) + : null + ); + } +} +``` + +**Key responsibilities:** + +1. **Field mapping** — Convert property names (camelCase) to column names (snake_case) +2. **Type conversion** — Transform `DateTime` objects to strings and back +3. **Default values** — Provide fallbacks for missing data using `Arr::get()` +4. **Null handling** — Handle nullable fields gracefully + +--- + +## Why Adapters Exist + +Without adapters, your handler would need to know how to construct your models: + +```php +// ❌ BAD: handler knows model internals +class PostHandler +{ + public function find(int $id): Post + { + $row = $this->queryBuilder->select('*')->where('id', $id)->first(); + + // Handler is tightly coupled to Post constructor + return new Post( + $row['id'], + $row['title'] ?? '', + $row['content'] ?? '', + $row['author_id'], + $row['published_date'] ? new DateTime($row['published_date']) : null + ); + } +} +``` + +With adapters, handlers are **decoupled** from model details: + +```php +// ✅ GOOD: handler delegates to adapter +class PostHandler +{ + public function __construct( + private PostAdapter $adapter + ) {} + + public function find(int $id): Post + { + $row = $this->queryBuilder->select('*')->where('id', $id)->first(); + return $this->adapter->toModel($row); // Adapter handles construction + } +} +``` + +Now if the `Post` constructor changes, only the adapter needs updating. + +--- + +## Adapter Usage in Handlers + +Handlers use adapters in both directions: + +### Reading: Array → Model + +When fetching data from storage: + +```php +public function get(array $args = []): iterable +{ + $rows = $this->queryBuilder + ->select('*') + ->from($this->table->getTableName()) + ->where($args) + ->getResults(); + + // Convert each row to a model + return array_map( + fn($row) => $this->adapter->toModel($row), + $rows + ); +} +``` + +### Writing: Model → Array + +When persisting data to storage: + +```php +public function save(DataModel $model): DataModel +{ + // Convert model to array + $data = $this->adapter->toArray($model); + + if ($model->getId()) { + // UPDATE + $this->queryBuilder + ->update($this->table->getTableName()) + ->set($data) + ->where('id', $model->getId()) + ->execute(); + } else { + // INSERT + $id = $this->queryBuilder + ->insert($this->table->getTableName()) + ->values($data) + ->execute(); + + // Return model with new ID + $data['id'] = $id; + } + + return $this->adapter->toModel($data); +} +``` + +--- + +## Handling Complex Types + +Adapters often need to transform between domain types and storage primitives. + +### DateTime Conversion + +```php +public function toArray(DataModel $model): array +{ + return [ + 'published_date' => $model->publishedDate?->format('Y-m-d H:i:s'), + ]; +} + +public function toModel(array $array): DataModel +{ + return new Post( + publishedDate: Arr::get($array, 'published_date') + ? new \DateTime(Arr::get($array, 'published_date')) + : null + ); +} +``` + +### JSON Fields + +```php +public function toArray(DataModel $model): array +{ + return [ + 'metadata' => json_encode($model->metadata), + ]; +} + +public function toModel(array $array): DataModel +{ + return new Post( + metadata: json_decode(Arr::get($array, 'metadata', '{}'), true) + ); +} +``` + +### Enums (PHP 8.1+) + +```php +public function toArray(DataModel $model): array +{ + return [ + 'status' => $model->status->value, // Enum to string + ]; +} + +public function toModel(array $array): DataModel +{ + return new Post( + status: PostStatus::from(Arr::get($array, 'status', 'draft')) + ); +} +``` + +--- + +## Compound Identity Adapters + +For models with compound primary keys, adapters handle multiple identifying fields: + +```php + $model->userId, + 'session_token' => $model->sessionToken, + 'ip_address' => $model->ipAddress, + 'expires_at' => $model->expiresAt->format('Y-m-d H:i:s'), + 'created_at' => $model->createdAt->format('Y-m-d H:i:s'), + ]; + } + + public function toModel(array $array): DataModel + { + return new UserSession( + userId: Arr::get($array, 'user_id'), + sessionToken: Arr::get($array, 'session_token'), + ipAddress: Arr::get($array, 'ip_address', ''), + expiresAt: new \DateTime(Arr::get($array, 'expires_at')), + createdAt: new \DateTime(Arr::get($array, 'created_at')) + ); + } +} +``` + +--- + +## Field Name Mapping + +Adapters bridge naming conventions between your domain (camelCase) and storage (snake_case): + +**Domain model:** +```php +class Post +{ + public readonly int $authorId; + public readonly DateTime $publishedDate; +} +``` + +**Database columns:** +```sql +CREATE TABLE posts ( + author_id BIGINT NOT NULL, + published_date DATETIME +); +``` + +**Adapter mapping:** +```php +public function toArray(DataModel $model): array +{ + return [ + 'author_id' => $model->authorId, // camelCase → snake_case + 'published_date' => $model->publishedDate->format('Y-m-d H:i:s'), + ]; +} + +public function toModel(array $array): DataModel +{ + return new Post( + authorId: Arr::get($array, 'author_id'), // snake_case → camelCase + publishedDate: new DateTime(Arr::get($array, 'published_date')) + ); +} +``` + +--- + +## Default Values and Safety + +Use `Arr::get()` with defaults to handle missing or null data gracefully: + +```php +public function toModel(array $array): DataModel +{ + return new Post( + id: Arr::get($array, 'id'), // Required + title: Arr::get($array, 'title', ''), // Default to empty string + content: Arr::get($array, 'content', ''), + authorId: Arr::get($array, 'author_id', 0), + publishedDate: Arr::get($array, 'published_date') + ? new DateTime(Arr::get($array, 'published_date')) + : null, // Nullable field + ); +} +``` + +This prevents crashes when data is incomplete or malformed. + +--- + +## Adapters for Junction Tables + +Junction table adapters are simpler—they only handle foreign keys: + +```php + $model->postId, + 'tag_id' => $model->tagId, + ]; + } + + public function toModel(array $array): DataModel + { + return new PostTag( + postId: Arr::get($array, 'post_id'), + tagId: Arr::get($array, 'tag_id') + ); + } +} +``` + +--- + +## Testing Adapters + +Adapters should be tested independently to ensure correct transformations: + +```php +class PostAdapterTest extends TestCase +{ + private PostAdapter $adapter; + + protected function setUp(): void + { + $this->adapter = new PostAdapter(); + } + + public function test_toArray_converts_model_to_array(): void + { + $post = new Post( + id: 1, + title: 'Test Post', + content: 'Content', + authorId: 123, + publishedDate: new DateTime('2024-01-01 12:00:00') + ); + + $array = $this->adapter->toArray($post); + + $this->assertEquals(1, $array['id']); + $this->assertEquals('Test Post', $array['title']); + $this->assertEquals('2024-01-01 12:00:00', $array['published_date']); + } + + public function test_toModel_converts_array_to_model(): void + { + $array = [ + 'id' => 1, + 'title' => 'Test Post', + 'content' => 'Content', + 'author_id' => 123, + 'published_date' => '2024-01-01 12:00:00', + ]; + + $post = $this->adapter->toModel($array); + + $this->assertEquals(1, $post->id); + $this->assertEquals('Test Post', $post->title); + $this->assertEquals(123, $post->authorId); + $this->assertEquals('2024-01-01', $post->publishedDate->format('Y-m-d')); + } + + public function test_roundtrip_preserves_data(): void + { + $original = new Post( + id: 1, + title: 'Test', + content: 'Content', + authorId: 123, + publishedDate: new DateTime('2024-01-01') + ); + + $array = $this->adapter->toArray($original); + $restored = $this->adapter->toModel($array); + + $this->assertEquals($original->id, $restored->id); + $this->assertEquals($original->title, $restored->title); + } +} +``` + +--- + +## Best Practices + +### Use Arr::get() for Safe Access + +```php +// ✅ GOOD: safe with defaults +$title = Arr::get($array, 'title', ''); + +// ❌ BAD: crashes if key missing +$title = $array['title']; +``` + +### Handle Null Appropriately + +```php +// ✅ GOOD: null-safe +'published_date' => $model->publishedDate?->format('Y-m-d H:i:s') + +// ❌ BAD: crashes if null +'published_date' => $model->publishedDate->format('Y-m-d H:i:s') +``` + +### Keep Adapters Pure + +Adapters should only transform data—no business logic, no validation, no side effects: + +```php +// ❌ BAD: adapter has business logic +public function toModel(array $array): DataModel +{ + $post = new Post(...); + + if ($post->publishedDate < new DateTime()) { + throw new ValidationException("Cannot create past-dated post"); + } + + return $post; +} + +// ✅ GOOD: adapter only transforms +public function toModel(array $array): DataModel +{ + return new Post(...); +} +``` + +### One Adapter Per Model + +Each model should have exactly one adapter. Don't create multiple adapters for different serialization formats—use separate transformation services for that. + +--- + +## What's Next + +* [Integration Guide](/packages/datastore/integration-guide) — complete datastore setup with adapters +* [Models and Identity](/core-concepts/models-and-identity) — designing models for use with adapters +* [Database Handlers](/packages/database/handlers/introduction) — how handlers use adapters diff --git a/public/docs/docs/packages/datastore/traits/introduction.md b/public/docs/docs/packages/datastore/traits/introduction.md new file mode 100644 index 0000000..6a4bf7d --- /dev/null +++ b/public/docs/docs/packages/datastore/traits/introduction.md @@ -0,0 +1,267 @@ +# Decorator Traits + +Decorator traits in PHPNomad are **code generators** that eliminate boilerplate when building datastore implementations. They automatically delegate method calls from your datastore class to the underlying handler, so you don't have to write repetitive pass-through methods by hand. + +When you implement a datastore interface that extends something like `DatastoreHasPrimaryKey`, you need to provide implementations for `get()`, `save()`, `delete()`, *and* `find()`. If your class is just delegating all those calls to a handler, that's a lot of mechanical code. Decorator traits collapse that down to a single `use` statement. + +## Why Decorator Traits Exist + +In the two-level datastore architecture, your **Core implementation** sits between the public `Datastore` interface and the `DatastoreHandler` that talks to storage. Most of the time, your Core class doesn't add logic—it just forwards calls: + +```php +final class PostDatastore implements DatastoreHasPrimaryKey +{ + public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} + + public function get(array $args = []): iterable + { + return $this->handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + public function delete(Model $item): void + { + $this->handler->delete($item); + } + + public function find(int $id): Model + { + return $this->handler->find($id); + } +} +``` + +Every method is identical: `return $this->handler->methodName(...)`. That's tedious to write and maintain. Decorator traits replace all of that with: + +```php +final class PostDatastore implements DatastoreHasPrimaryKey +{ + use WithDatastorePrimaryKeyDecorator; + + public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} +} +``` + +**That's it.** The trait provides all four methods automatically. + +## How They Work + +Each decorator trait corresponds to one of the [datastore interfaces](/packages/datastore/interfaces/introduction). The trait provides method implementations that delegate to a `$handler` property. + +When you `use` the trait, you must: +1. Store the handler in a property named `$handler`. +2. Ensure the handler implements the matching handler interface. + +The trait will generate the delegation code for every method in that interface. + +## Available Decorator Traits + +PHPNomad provides four decorator traits, one for each standard interface: + +### `WithDatastoreDecorator` + +Decorates the base **`Datastore`** interface. + +**Provides:** +- `get(array $args = []): iterable` +- `save(Model $item): Model` +- `delete(Model $item): void` + +**Requires handler type:** `DatastoreHandler` + +**Usage:** +```php +final class PostDatastore implements Datastore +{ + use WithDatastoreDecorator; + + public function __construct(private DatastoreHandler $handler) {} +} +``` + +--- + +### `WithDatastorePrimaryKeyDecorator` + +Decorates **`DatastoreHasPrimaryKey`** (which extends `Datastore`). + +**Provides:** +- All methods from `WithDatastoreDecorator` +- `find(int $id): Model` + +**Requires handler type:** `DatastoreHandlerHasPrimaryKey` + +**Usage:** +```php +final class PostDatastore implements DatastoreHasPrimaryKey +{ + use WithDatastorePrimaryKeyDecorator; + + public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} +} +``` + +--- + +### `WithDatastoreWhereDecorator` + +Decorates **`DatastoreHasWhere`** (which extends `Datastore`). + +**Provides:** +- All methods from `WithDatastoreDecorator` +- `where(): DatastoreWhereQuery` + +**Requires handler type:** `DatastoreHandlerHasWhere` + +**Usage:** +```php +final class PostDatastore implements DatastoreHasWhere +{ + use WithDatastoreWhereDecorator; + + public function __construct(private DatastoreHandlerHasWhere $handler) {} +} +``` + +--- + +### `WithDatastoreCountDecorator` + +Decorates **`DatastoreHasCounts`** (which extends `Datastore`). + +**Provides:** +- All methods from `WithDatastoreDecorator` +- `count(array $args = []): int` + +**Requires handler type:** `DatastoreHandlerHasCounts` + +**Usage:** +```php +final class PostDatastore implements DatastoreHasCounts +{ + use WithDatastoreCountDecorator; + + public function __construct(private DatastoreHandlerHasCounts $handler) {} +} +``` + +--- + +## Composing Multiple Traits + +If your datastore interface extends multiple capabilities, you can use multiple traits together. PHP allows this as long as there are no method name conflicts (and PHPNomad's traits are designed to compose cleanly). + +**Example: combining primary key and counting** + +```php +interface PostDatastore extends DatastoreHasPrimaryKey, DatastoreHasCounts +{ + // find(), get(), save(), delete(), count() +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; + use WithDatastoreCountDecorator; + + public function __construct( + private DatastoreHandlerHasPrimaryKey & DatastoreHandlerHasCounts $handler + ) {} +} +``` + +Both traits delegate to `$this->handler`, and the handler implements both interfaces. + +## When NOT to Use Decorator Traits + +Decorator traits are perfect for **pass-through implementations** where you don't need to add logic. But if you need to: + +- Transform data before or after handler calls +- Add caching, logging, or authorization checks +- Override specific methods with custom behavior + +Then you should **implement the methods manually** instead of using the trait. + +**Example: custom logic in `find()`** + +```php +final class PostDatastore implements DatastoreHasPrimaryKey +{ + use WithDatastoreDecorator; // Only for get/save/delete + + public function __construct( + private DatastoreHandlerHasPrimaryKey $handler, + private LoggerStrategy $logger + ) {} + + // Custom implementation with logging + public function find(int $id): Model + { + $this->logger->info("Fetching post {$id}"); + return $this->handler->find($id); + } +} +``` + +Here we use `WithDatastoreDecorator` for the basic methods, but implement `find()` ourselves to add logging. + +## Real-World Example: Full Composition + +Here's a realistic example showing how traits simplify a datastore with multiple capabilities and one custom method: + +```php +interface PostDatastore extends + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + public function findPublishedPosts(int $authorId): iterable; +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; + use WithDatastoreWhereDecorator; + use WithDatastoreCountDecorator; + + public function __construct( + private DatastoreHandlerHasPrimaryKey & + DatastoreHandlerHasWhere & + DatastoreHandlerHasCounts $handler + ) {} + + // Custom business method - not auto-generated + public function findPublishedPosts(int $authorId): iterable + { + return $this->where() + ->equals('authorId', $authorId) + ->lessThanOrEqual('publishedDate', new DateTime()) + ->getResults(); + } +} +``` + +The traits provide `get()`, `save()`, `delete()`, `find()`, `where()`, and `count()` automatically. You only write the custom `findPublishedPosts()` method by hand. + +## Best Practices + +When working with decorator traits: + +- **Use traits for delegation only** — if you need logic, implement methods manually. +- **Name the handler property `$handler`** — traits expect this name. +- **Match handler types to interfaces** — if you implement `DatastoreHasPrimaryKey`, use `DatastoreHandlerHasPrimaryKey`. +- **Compose traits freely** — multiple traits work together as long as interfaces align. +- **Override when needed** — you can always implement specific methods yourself instead of using the trait's version. + +## What's Next + +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/docs/packages/datastore/traits/with-datastore-count-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md new file mode 100644 index 0000000..4a23c88 --- /dev/null +++ b/public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md @@ -0,0 +1,217 @@ +# WithDatastoreCountDecorator Trait + +The `WithDatastoreCountDecorator` trait provides automatic implementations of the [`DatastoreHasCounts`](/packages/datastore/interfaces/datastore-has-counts) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `count()` method for efficient record counting. + +## What It Provides + +This trait implements four methods: + +* `get(array $args = []): iterable` — from `Datastore` +* `save(Model $item): Model` — from `Datastore` +* `delete(Model $item): void` — from `Datastore` +* `count(array $args = []): int` — from `DatastoreHasCounts` + +All methods delegate to `$this->handler`. + +## Requirements + +To use this trait, your class must: + +1. **Implement `DatastoreHasCounts`** — the trait provides the method bodies. +2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasCounts`. +3. **Initialize the handler** — typically via constructor injection. + +## Basic Usage + +```php +handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + public function delete(Model $item): void + { + $this->handler->delete($item); + } + + public function count(array $args = []): int + { + return $this->handler->count($args); + } +} +``` + +## When to Use This Trait + +Use `WithDatastoreCountDecorator` when: + +* Your datastore needs efficient counting operations. +* Your Core implementation doesn't add logic—it just delegates to the handler. +* You want to minimize boilerplate in standard implementations. + +## When NOT to Use This Trait + +Don't use this trait if you need to: + +* Add caching around count operations. +* Log or track count queries. +* Transform count criteria before delegating. + +In these cases, implement the methods manually. + +## Example: Custom Logic in `count()` + +If you need custom behavior in `count()`, implement it manually: + +```php +final class PostDatastore implements DatastoreHasCounts +{ + use WithDatastoreCountDecorator; + + public function __construct( + private DatastoreHandlerHasCounts $handler, + private CacheService $cache + ) {} + + // Override count() with caching + public function count(array $args = []): int + { + $cacheKey = 'post_count_' . md5(serialize($args)); + + return $this->cache->remember($cacheKey, 300, function() use ($args) { + return $this->handler->count($args); + }); + } + + // get(), save(), delete() provided by trait +} +``` + +## Combining with Other Decorator Traits + +Most datastores implement multiple interfaces. You can combine traits: + +```php +interface PostDatastore extends + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete(), find(), where(), count() +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() + use WithDatastoreCountDecorator { + // Resolve conflict: both traits provide get(), save(), delete() + WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreCountDecorator; + WithDatastorePrimaryKeyDecorator::save insteadof WithDatastoreCountDecorator; + WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreCountDecorator; + } + + public function __construct( + private DatastoreHandlerHasPrimaryKey & + DatastoreHandlerHasCounts $handler + ) {} + + // Manually implement where() + public function where(): DatastoreWhereQuery + { + return $this->handler->where(); + } +} +``` + +## Adding Custom Count Methods + +You can add domain-specific count methods alongside trait-provided ones: + +```php +interface PostDatastore extends DatastoreHasCounts +{ + public function countByAuthor(int $authorId): int; + public function countPublished(): int; +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastoreCountDecorator; + + public function __construct( + private DatastoreHandlerHasCounts $handler + ) {} + + // Custom count method + public function countByAuthor(int $authorId): int + { + return $this->count(['author_id' => $authorId]); + } + + // Another custom count method + public function countPublished(): int + { + return $this->count(['status' => 'published']); + } + + // get(), save(), delete(), count() provided by trait +} +``` + +## Handler Type Requirements + +The `$handler` property must implement `DatastoreHandlerHasCounts`: + +```php +interface DatastoreHandlerHasCounts extends DatastoreHandler +{ + public function count(array $args = []): int; +} +``` + +This ensures the handler supports efficient counting operations. + +## Best Practices + +* **Use for pure delegation** — if you're adding logic, implement manually. +* **Name the handler `$handler`** — the trait expects this property name. +* **Match handler and interface types** — if you implement `DatastoreHasCounts`, use `DatastoreHandlerHasCounts`. +* **Combine traits carefully** — resolve conflicts when multiple traits provide the same methods. + +## What's Next + +* [DatastoreHasCounts Interface](/packages/datastore/interfaces/datastore-has-counts) — the interface this trait implements +* [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — adds `find()` method +* [Database Handlers](/packages/database/handlers/introduction) — the handler side of the contract diff --git a/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md new file mode 100644 index 0000000..f2c6dda --- /dev/null +++ b/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md @@ -0,0 +1,199 @@ +# WithDatastoreDecorator Trait + +The `WithDatastoreDecorator` trait provides automatic implementations of the base [`Datastore`](/packages/datastore/interfaces/datastore) interface methods by delegating to a `$handler` property. It eliminates boilerplate code when your Core datastore implementation is a pure pass-through to a handler. + +## What It Provides + +This trait implements three methods: + +* `get(array $args = []): iterable` +* `save(Model $item): Model` +* `delete(Model $item): void` + +Each method simply forwards the call to `$this->handler` with the same parameters. + +## Requirements + +To use this trait, your class must: + +1. **Implement `Datastore`** — the trait provides the method bodies. +2. **Have a `$handler` property** — must be of type `DatastoreHandler`. +3. **Initialize the handler** — typically via constructor injection. + +## Basic Usage + +```php +handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + public function delete(Model $item): void + { + $this->handler->delete($item); + } +} +``` + +By using the trait, you avoid writing this repetitive delegation code. + +## When to Use This Trait + +Use `WithDatastoreDecorator` when: + +* Your Core datastore doesn't add logic—it just passes calls to the handler. +* You want to reduce boilerplate in simple implementations. +* You're building a standard database-backed datastore. + +## When NOT to Use This Trait + +Don't use `WithDatastoreDecorator` if you need to: + +* Add logging, caching, or validation before delegating. +* Transform data between the public API and handler. +* Implement custom behavior in `get()`, `save()`, or `delete()`. + +In these cases, implement the methods manually. + +## Example: Custom Logic in `save()` + +If you need custom behavior in one method, implement it manually and use the trait for the others: + +```php +final class PostDatastore implements Datastore +{ + use WithDatastoreDecorator { + save as private traitSave; // Rename trait's save method + } + + public function __construct( + private DatastoreHandler $handler, + private LoggerStrategy $logger + ) {} + + // Custom save with logging + public function save(Model $item): Model + { + $this->logger->info("Saving post: {$item->getId()}"); + return $this->traitSave($item); // Delegate to trait + } + + // get() and delete() provided by trait +} +``` + +Alternatively, just implement `save()` yourself and let the trait handle `get()` and `delete()`: + +```php +final class PostDatastore implements Datastore +{ + use WithDatastoreDecorator; + + public function __construct( + private DatastoreHandler $handler, + private LoggerStrategy $logger + ) {} + + // Custom save with logging + public function save(Model $item): Model + { + $this->logger->info("Saving post: {$item->getId()}"); + return $this->handler->save($item); + } + + // get() and delete() provided by trait +} +``` + +## Combining with Other Decorator Traits + +You can use multiple decorator traits together when your interface extends multiple capabilities: + +```php +interface PostDatastore extends + Datastore, + DatastoreHasPrimaryKey, + DatastoreHasCounts +{ + // get(), save(), delete(), find(), count() +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastoreDecorator; // get(), save(), delete() + use WithDatastorePrimaryKeyDecorator { + // Resolve conflict: both traits provide get(), save(), delete() + WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreDecorator; + WithDatastorePrimaryKeyDecorator::save insteadof WithDatastoreDecorator; + WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreDecorator; + } + use WithDatastoreCountDecorator; // count() + + public function __construct( + private DatastoreHandlerHasPrimaryKey & DatastoreHandlerHasCounts $handler + ) {} +} +``` + +**Note:** In practice, you'd typically use **only** `WithDatastorePrimaryKeyDecorator` since it extends `WithDatastoreDecorator` and includes all base methods. The example above shows how to resolve conflicts if needed. + +## Handler Type Requirements + +The `$handler` property must implement `DatastoreHandler`: + +```php +interface DatastoreHandler +{ + public function get(array $args = []): iterable; + public function save(Model $item): Model; + public function delete(Model $item): void; +} +``` + +Most handlers extend this interface with additional capabilities (e.g., `DatastoreHandlerHasPrimaryKey`), which is fine—the trait only calls the base methods. + +## Best Practices + +* **Use traits for pure delegation** — if you're adding logic, implement manually. +* **Name the handler `$handler`** — the trait expects this property name. +* **Inject via constructor** — don't create handlers inside the datastore. +* **Combine with extension traits** — use `WithDatastorePrimaryKeyDecorator` for extended interfaces. + +## What's Next + +* [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/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md new file mode 100644 index 0000000..7c4b7a9 --- /dev/null +++ b/public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md @@ -0,0 +1,204 @@ +# WithDatastorePrimaryKeyDecorator Trait + +The `WithDatastorePrimaryKeyDecorator` trait provides automatic implementations of the [`DatastoreHasPrimaryKey`](/packages/datastore/interfaces/datastore-has-primary-key) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `find()` method for primary key lookups. + +## What It Provides + +This trait implements four methods: + +* `get(array $args = []): iterable` — from `Datastore` +* `save(Model $item): Model` — from `Datastore` +* `delete(Model $item): void` — from `Datastore` +* `find(int $id): Model` — from `DatastoreHasPrimaryKey` + +All methods delegate to `$this->handler`. + +## Requirements + +To use this trait, your class must: + +1. **Implement `DatastoreHasPrimaryKey`** — the trait provides the method bodies. +2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasPrimaryKey`. +3. **Initialize the handler** — typically via constructor injection. + +## Basic Usage + +```php +handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + public function delete(Model $item): void + { + $this->handler->delete($item); + } + + public function find(int $id): Model + { + return $this->handler->find($id); + } +} +``` + +## When to Use This Trait + +Use `WithDatastorePrimaryKeyDecorator` when: + +* Your datastore has a single integer primary key. +* Your Core implementation doesn't add logic—it just delegates to the handler. +* You want to minimize boilerplate in standard implementations. + +## When NOT to Use This Trait + +Don't use this trait if you need to: + +* Add caching, logging, or validation before delegating. +* Transform data between the public API and handler. +* Implement custom behavior in `find()` or other methods. + +In these cases, implement the methods manually. + +## Example: Custom Logic in `find()` + +If you need custom behavior in `find()`, implement it manually and let the trait handle the rest: + +```php +final class PostDatastore implements DatastoreHasPrimaryKey +{ + use WithDatastorePrimaryKeyDecorator; + + public function __construct( + private DatastoreHandlerHasPrimaryKey $handler, + private LoggerStrategy $logger + ) {} + + // Override find() with logging + public function find(int $id): Model + { + $this->logger->info("Finding post: {$id}"); + return $this->handler->find($id); + } + + // get(), save(), delete() provided by trait +} +``` + +## Combining with Other Decorator Traits + +Most datastores implement multiple interfaces. You can combine traits: + +```php +interface PostDatastore extends + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete(), find(), where(), count() +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() + use WithDatastoreWhereDecorator; // where() + use WithDatastoreCountDecorator; // count() + + public function __construct( + private DatastoreHandlerHasPrimaryKey & + DatastoreHandlerHasWhere & + DatastoreHandlerHasCounts $handler + ) {} +} +``` + +All six methods are now auto-implemented via traits. + +## Adding Custom Business Methods + +You can add custom methods alongside trait-provided ones: + +```php +interface PostDatastore extends DatastoreHasPrimaryKey +{ + public function findPublishedPosts(int $authorId): iterable; +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; + + public function __construct( + private DatastoreHandlerHasPrimaryKey $handler + ) {} + + // Custom business method + public function findPublishedPosts(int $authorId): iterable + { + return $this->handler->get([ + 'author_id' => $authorId, + 'status' => 'published', + ]); + } + + // get(), save(), delete(), find() provided by trait +} +``` + +## Handler Type Requirements + +The `$handler` property must implement `DatastoreHandlerHasPrimaryKey`: + +```php +interface DatastoreHandlerHasPrimaryKey extends DatastoreHandler +{ + public function find(int $id): Model; +} +``` + +This ensures the handler supports primary key lookups. + +## Best Practices + +* **Use for pure delegation** — if you're adding logic, implement manually. +* **Name the handler `$handler`** — the trait expects this property name. +* **Match handler and interface types** — if you implement `DatastoreHasPrimaryKey`, use `DatastoreHandlerHasPrimaryKey`. +* **Combine traits freely** — traits compose cleanly for multiple capabilities. + +## What's Next + +* [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/docs/packages/datastore/traits/with-datastore-where-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md new file mode 100644 index 0000000..0775857 --- /dev/null +++ b/public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md @@ -0,0 +1,230 @@ +# WithDatastoreWhereDecorator Trait + +The `WithDatastoreWhereDecorator` trait provides automatic implementations of the [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `where()` method for query-builder-style filtering. + +## What It Provides + +This trait implements four methods: + +* `get(array $args = []): iterable` — from `Datastore` +* `save(Model $item): Model` — from `Datastore` +* `delete(Model $item): void` — from `Datastore` +* `where(): DatastoreWhereQuery` — from `DatastoreHasWhere` + +All methods delegate to `$this->handler`. + +## Requirements + +To use this trait, your class must: + +1. **Implement `DatastoreHasWhere`** — the trait provides the method bodies. +2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasWhere`. +3. **Initialize the handler** — typically via constructor injection. + +## Basic Usage + +```php +handler->get($args); + } + + public function save(Model $item): Model + { + return $this->handler->save($item); + } + + public function delete(Model $item): void + { + $this->handler->delete($item); + } + + public function where(): DatastoreWhereQuery + { + return $this->handler->where(); + } +} +``` + +## When to Use This Trait + +Use `WithDatastoreWhereDecorator` when: + +* Your datastore supports complex querying. +* Your Core implementation doesn't add logic—it just delegates to the handler. +* You want to minimize boilerplate in standard implementations. + +## When NOT to Use This Trait + +Don't use this trait if you need to: + +* Wrap the query builder with additional logic. +* Add caching or logging around query execution. +* Transform queries before delegating to the handler. + +In these cases, implement the methods manually. + +## Example: Custom Logic in `where()` + +If you need to wrap the query builder, implement `where()` manually: + +```php +final class PostDatastore implements DatastoreHasWhere +{ + use WithDatastoreWhereDecorator; + + public function __construct( + private DatastoreHandlerHasWhere $handler, + private LoggerStrategy $logger + ) {} + + // Override where() to log query construction + public function where(): DatastoreWhereQuery + { + $this->logger->info("Building query for PostDatastore"); + return $this->handler->where(); + } + + // get(), save(), delete() provided by trait +} +``` + +## Combining with Other Decorator Traits + +Most datastores implement multiple interfaces. You can combine traits: + +```php +interface PostDatastore extends + DatastoreHasPrimaryKey, + DatastoreHasWhere, + DatastoreHasCounts +{ + // get(), save(), delete(), find(), where(), count() +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() + use WithDatastoreWhereDecorator { + // Resolve conflict: both traits provide get(), save(), delete() + WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreWhereDecorator; + WithDatastorePrimaryKeyDecorator::save insteadof WithDatastorePrimaryKeyDecorator; + WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreWhereDecorator; + } + use WithDatastoreCountDecorator; // count() + + public function __construct( + private DatastoreHandlerHasPrimaryKey & + DatastoreHandlerHasWhere & + DatastoreHandlerHasCounts $handler + ) {} +} +``` + +In practice, you'd typically choose **one** trait that provides the base methods (`get`, `save`, `delete`) and add others that don't conflict. For example: + +```php +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastorePrimaryKeyDecorator; // Provides all base + find() + // Manually implement where() if needed, or use trait + + public function where(): DatastoreWhereQuery + { + return $this->handler->where(); + } + + // count() - implement if needed + public function count(array $args = []): int + { + return $this->handler->count($args); + } +} +``` + +## Adding Custom Query Methods + +You can add domain-specific query methods alongside trait-provided ones: + +```php +interface PostDatastore extends DatastoreHasWhere +{ + public function findRecentPublished(int $limit = 10): iterable; +} + +final class PostDatastoreImpl implements PostDatastore +{ + use WithDatastoreWhereDecorator; + + public function __construct( + private DatastoreHandlerHasWhere $handler + ) {} + + // Custom query method + public function findRecentPublished(int $limit = 10): iterable + { + return $this->where() + ->equals('status', 'published') + ->lessThanOrEqual('published_date', new DateTime()) + ->orderBy('published_date', 'DESC') + ->limit($limit) + ->getResults(); + } + + // get(), save(), delete(), where() provided by trait +} +``` + +## Handler Type Requirements + +The `$handler` property must implement `DatastoreHandlerHasWhere`: + +```php +interface DatastoreHandlerHasWhere extends DatastoreHandler +{ + public function where(): DatastoreWhereQuery; +} +``` + +This ensures the handler supports query-builder operations. + +## Best Practices + +* **Use for pure delegation** — if you're adding logic, implement manually. +* **Name the handler `$handler`** — the trait expects this property name. +* **Match handler and interface types** — if you implement `DatastoreHasWhere`, use `DatastoreHandlerHasWhere`. +* **Combine traits carefully** — resolve conflicts when multiple traits provide the same methods. + +## What's Next + +* [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/docs/packages/enum-polyfill/introduction.md b/public/docs/docs/packages/enum-polyfill/introduction.md new file mode 100644 index 0000000..db1ec82 --- /dev/null +++ b/public/docs/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/docs/packages/enum-polyfill/traits/enum.md b/public/docs/docs/packages/enum-polyfill/traits/enum.md new file mode 100644 index 0000000..65a3ddc --- /dev/null +++ b/public/docs/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/docs/packages/enum-polyfill/traits/introduction.md b/public/docs/docs/packages/enum-polyfill/traits/introduction.md new file mode 100644 index 0000000..945b197 --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/action-binding-strategy.md b/public/docs/docs/packages/event/interfaces/action-binding-strategy.md new file mode 100644 index 0000000..e2f7c68 --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/can-handle.md b/public/docs/docs/packages/event/interfaces/can-handle.md new file mode 100644 index 0000000..772036a --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/event-strategy.md b/public/docs/docs/packages/event/interfaces/event-strategy.md new file mode 100644 index 0000000..ff275cb --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/event.md b/public/docs/docs/packages/event/interfaces/event.md new file mode 100644 index 0000000..16dde29 --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/has-event-bindings.md b/public/docs/docs/packages/event/interfaces/has-event-bindings.md new file mode 100644 index 0000000..16f131a --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/has-listeners.md b/public/docs/docs/packages/event/interfaces/has-listeners.md new file mode 100644 index 0000000..62e41fa --- /dev/null +++ b/public/docs/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/docs/packages/event/interfaces/introduction.md b/public/docs/docs/packages/event/interfaces/introduction.md new file mode 100644 index 0000000..e271806 --- /dev/null +++ b/public/docs/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/docs/packages/event/introduction.md b/public/docs/docs/packages/event/introduction.md new file mode 100644 index 0000000..f72cc67 --- /dev/null +++ b/public/docs/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/docs/packages/event/patterns/best-practices.md b/public/docs/docs/packages/event/patterns/best-practices.md new file mode 100644 index 0000000..f8c5a13 --- /dev/null +++ b/public/docs/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/docs/packages/logger/interfaces/introduction.md b/public/docs/docs/packages/logger/interfaces/introduction.md new file mode 100644 index 0000000..5d4b1ea --- /dev/null +++ b/public/docs/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/docs/packages/logger/interfaces/logger-strategy.md b/public/docs/docs/packages/logger/interfaces/logger-strategy.md new file mode 100644 index 0000000..eab1904 --- /dev/null +++ b/public/docs/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/docs/packages/logger/introduction.md b/public/docs/docs/packages/logger/introduction.md new file mode 100644 index 0000000..c62ffd5 --- /dev/null +++ b/public/docs/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/docs/packages/logger/traits/can-log-exception.md b/public/docs/docs/packages/logger/traits/can-log-exception.md new file mode 100644 index 0000000..8679be9 --- /dev/null +++ b/public/docs/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/docs/packages/logger/traits/introduction.md b/public/docs/docs/packages/logger/traits/introduction.md new file mode 100644 index 0000000..548f786 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/has-mutations.md b/public/docs/docs/packages/mutator/interfaces/has-mutations.md new file mode 100644 index 0000000..d43701b --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/introduction.md b/public/docs/docs/packages/mutator/interfaces/introduction.md new file mode 100644 index 0000000..0ce7000 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/mutation-adapter.md b/public/docs/docs/packages/mutator/interfaces/mutation-adapter.md new file mode 100644 index 0000000..b3470e2 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/mutation-strategy.md b/public/docs/docs/packages/mutator/interfaces/mutation-strategy.md new file mode 100644 index 0000000..a2f8759 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/mutator-handler.md b/public/docs/docs/packages/mutator/interfaces/mutator-handler.md new file mode 100644 index 0000000..5559a49 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/interfaces/mutator.md b/public/docs/docs/packages/mutator/interfaces/mutator.md new file mode 100644 index 0000000..dd1697f --- /dev/null +++ b/public/docs/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/docs/packages/mutator/introduction.md b/public/docs/docs/packages/mutator/introduction.md new file mode 100644 index 0000000..225056a --- /dev/null +++ b/public/docs/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/docs/packages/mutator/traits/can-mutate-from-adapter.md b/public/docs/docs/packages/mutator/traits/can-mutate-from-adapter.md new file mode 100644 index 0000000..fb89100 --- /dev/null +++ b/public/docs/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/docs/packages/mutator/traits/introduction.md b/public/docs/docs/packages/mutator/traits/introduction.md new file mode 100644 index 0000000..5b999fc --- /dev/null +++ b/public/docs/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/docs/packages/rest/controllers.md b/public/docs/docs/packages/rest/controllers.md new file mode 100644 index 0000000..dfd6d34 --- /dev/null +++ b/public/docs/docs/packages/rest/controllers.md @@ -0,0 +1,363 @@ +# Controllers + +Controllers are the **heart of a REST endpoint** in PHPNomad. They are where normalized, validated input is turned into +a response. By the time a controller runs, upstream middleware and validations have already shaped and checked the +request, so the controller can focus entirely on business logic. + +## Purpose of Controllers + +A controller should be **deterministic**: given a request and any injected dependencies, it computes a result and +returns a response with an explicit status. It doesn’t worry about enforcing defaults, validating input, or logging +side effects. Those belong to other phases of the lifecycle (middleware, validations, interceptors). Keeping controllers +focused in this way ensures predictability and portability across different integrations. + +## Controller Contract + +Every controller implements the `Controller` interface, which requires three methods: + +* `getEndpoint()` — returns the path where this controller is mounted (e.g., `/widgets`). +* `getMethod()` — returns the HTTP method (e.g., `Method::Get`, `Method::Post`). +* `getResponse(Request $request)` — the core logic: receives a normalized request, produces a response. + +The `Response` contract provides helpers like `setStatus()`, `setJson()`, and `setError()` to clearly shape the output. + +## Constructor Injection + +Controllers rarely work alone; they almost always call into services. In PHPNomad, you can declare those needs directly +in the constructor. Dependencies like repositories, domain services, or loggers are provided by +the [initializer](/core-concepts/bootstrapping/creating-and-managing-initializers) and +injected automatically. + +This makes controllers **testable and explicit**: they declare what they need, and the DI container provides it. No +service location, no global state. + +## Example: Basic Controller + +Here’s a simple controller that lists widgets with pagination: + +```php +widgets->list( + limit: (int) $request->getParam('number'), + offset: (int) $request->getParam('offset'), + ); + + return $this->response + ->setStatus(200) + ->setJson([ + 'data' => $items, + 'number' => $request->getParam('number'), + 'offset' => $request->getParam('offset'), + ]); + } +} +```` + +In this example, the constructor pulls in two dependencies: the response object and a repository. The controller +declares that it responds to `GET /widgets`, and its `getResponse` method simply queries the repository and returns +JSON. The +response is explicit — a `200 OK` with both the data and the paging parameters echoed back — and is shaped through the +`Response` contract’s helpers. + +## Signaling Errors + +Not every request succeeds. Controllers can throw a `RestException` when they need to signal an error condition. Systems +that implement the REST lifecycle know how to catch these exceptions and turn them into proper HTTP responses: + +* The exception’s **code** becomes the HTTP status. +* The **message** becomes the error message returned to the client. +* The **context** array is included in the error payload, allowing structured details about what went wrong. + +This means you don’t need to manually build an error `Response` — throwing a `RestException` is enough. + +### Example: Throwing a RestException + +```php +getParam('id'); + $widget = $this->widgets->find($id); + + if (!$widget) { + // 404 Not Found with structured error payload + throw new RestException( + code: 404, + message: "Widget $id was not found", + context: ['id' => $id] + ); + } + + return $this->response + ->setStatus(200) + ->setJson($widget); + } +} +```` + +In this example, if the repository returns nothing, the controller throws a `RestException`. The runtime will catch it +and return a **404 Not Found** with a body like: + +```json +{ + "error": { + "message": "Widget 42 was not found", + "context": { + "id": 42 + } + } +} +``` + +This keeps your controller code clean: you describe the error, and the framework guarantees consistent error responses +with a predictable structure. + +## Full Controller Example + +In phpnomad/rest, a controller can be as simple as returning a payload — but in most real systems you’ll need +validations, middleware, and interceptors. + +The following example (CreateWidget) demonstrates a fully composed controller that uses all the moving parts: + +* Validations define input contracts, ensuring required fields are present and correctly typed. +* Middleware transforms and enforces rules before logic runs (e.g., coercing types, checking authorization, validating). +* Interceptors handle side effects after the response is ready, like broadcasting events. +* Controller logic itself focuses only on the business operation (creating a widget). + +This pattern is typical for production endpoints: request → middleware → validations → controller → response → +interceptors. + +```php +widgets->create( + name: (string) $request->getParam('name'), + price: (float) $request->getParam('price'), + tags: (array) ($request->getParam('tags') ?? []) + ); + + // For creates, 201 is standard (with a body that includes the new resource ID). + return $this->response + ->setStatus(201) + ->setBody(['id' => $id, 'status' => 'created']); + } + + /** + * Validations define the *input contract* for this endpoint. + * Each field maps to a ValidationSet, which can: + * - be required or optional + * - enforce type, format, or custom rules + */ + public function getValidations(): array + { + return [ + // "name" is required and must be a string + 'name' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::String)), + + // "price" is required and must be a float + 'price' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Float)), + + // "tags" is optional, but if present must be an array + 'tags' => (new ValidationSet()) + ->addValidation(fn() => new IsType(BasicTypes::Array)), + ]; + } + + /** + * Middleware runs *before* the controller logic. + * Common uses: + * - Type coercion (force "price" into a float) + * - Authorization (policies based on user/session) + * - Running the validations defined above + */ + public function getMiddleware(Request $request): array + { + return [ + // Ensure "price" param is treated as a float before validation + new SetTypeMiddleware('price', BasicTypes::Float), + + // AuthorizationMiddleware checks if the current session + // is allowed to perform the "create widget" action. + new AuthorizationMiddleware( + evaluator: $this->auth, + context: SessionContexts::Web, + action: new Action(ActionTypes::Create, 'widget'), + policies: [ + // Example policies: require a valid web session + new SessionTypePolicy(SessionContexts::Web), + // and ensure the user can perform this action + new UserCanDoActionPolicy(), + ], + ), + + // ValidationMiddleware runs the ValidationSets defined in getValidations(). + new ValidationMiddleware($this), + ]; + } + + /** + * Interceptors run *after* the response has been created. + * They never modify the response, only handle side-effects. + * Here we broadcast a "WidgetCreatedEvent" for other systems to consume. + */ + public function getInterceptors(Request $request, Response $response): array + { + return [ + new EventInterceptor( + eventGetter: function () use ($request, $response) { + return new WidgetCreatedEvent( + name: (string) $request->getParam('name'), + price: (float) $request->getParam('price'), + id: (int) ($response->getBody()['id'] ?? 0), + ); + }, + eventStrategy: $this->events + ), + ]; + } +} +``` + +## Best Practices + +When in doubt, lean towards simplicity. These practices help controllers stay predictable: + +* Keep controllers lean: orchestrate the request/response, don’t embed business rules. +* Inject services via the constructor so controllers are easy to test and extend. +* Always set a status: don’t rely on defaults, be explicit about success and failure codes. +* Don’t validate or authorize here. Trust [middleware]/packages/rest/middleware/introduction) + and [validations](/packages/rest/validations/introduction) to handle that + before the controller runs. + +## When to Add Complexity + +In real applications, controllers often participate in a richer lifecycle. Middleware handles cross-cutting concerns +like authorization or pagination defaults. Validations define the input contract. Interceptors perform post-response +work like logging or publishing events. + +These are declared by implementing additional interfaces on the controller, but their logic lives outside it. This +separation keeps controllers focused on shaping the response while making each piece reusable across endpoints. For a +broader picture of how these phases work together, see +the [request lifecycle](/packages/rest/introduction#the-request-lifecycle). \ No newline at end of file diff --git a/public/docs/docs/packages/rest/integration-guide.md b/public/docs/docs/packages/rest/integration-guide.md new file mode 100644 index 0000000..7b0cd08 --- /dev/null +++ b/public/docs/docs/packages/rest/integration-guide.md @@ -0,0 +1,175 @@ +# REST Platform Integration Guide + +This guide is for engineers wiring PHPNomad controllers into a specific PHP platform with an HTTP runtime. If you’ve +never extended PHPNomad before, start here. + +By the end, you’ll have a thin adapter that lets controllers run unchanged on your platform. It will register +controllers with your router, translate your platform’s request into PHPNomad’s `Request`, convert controller `Response` +objects back into your platform’s response, and keep error handling consistent. + +You are responsible for a small contract: + +* RestStrategy implementation - to map a controller’s `(method, endpoint)` into your router and drive the request + lifecycle. +* Request implementation - to expose method, path, headers, params, body, and a safe attribute bag without leaking + platform + types. +* Response implementation - to set status, headers, and body while leaving serialization to the strategy. + *(Authentication can be added via middleware, but it’s optional.)* + +This is in great shape—clear, concrete, and the two integration styles (platform-led vs strategy-led) come through +nicely. What’s still missing or underspecified are a few “contract” rules that first-time extenders won’t intuit: + +* the lifecycle contract (stated explicitly, not just implied), +* the portable endpoint schema and how params get injected, +* deterministic param resolution (route → query → body), +* the error-mapping guarantee and envelope shape, +* who serializes responses (strategy vs adapter) and body-parsing rules, +* what “Auth via middleware” means when the platform already has permission hooks. + +Below are short, drop-in blocks you can add to the intro section (kept skimmable, with bullets only where they carry +weight). + +## Integration contract + +To integrate with PHPNomad, your adapter must follow these rules. + +### Lifecycle (required order) + +The request must flow in this order. Don’t skip or reorder steps. + +``` +Route → Middleware → Controller → Response → Interceptors +``` + +### Endpoint schema (portable) + +Controllers declare endpoints like `/widgets/{id}` using only literals and named params. Your adapter translates to the +platform’s DSL **and** injects captured values back into `Request` under the same keys. + +* Example: `/widgets/{id}` → WordPress `(?P[\d]+)` or FastRoute `{id:\w+}` +* Inside controllers, `$request->getParam('id')` works the same on every platform. + +## Examples + +See these existing adapters for reference implementations. + +* [FastRoute Integration](https://github.com/phpnomad/fastroute-integration) +* [WordPress Integration](https://github.com/phpnomad/wordpress-integration) + +## Approach + +The key to setting up PHPNomad's REST implementation with a platform is that you have to implement the Request, +Response, and RestStrategy, and usually Auth as well. + +These are all baked into existing platforms, so usually implementing these feels more like adapting the existing +platform into PHPNomad's syntax. + +For example in +the [WordPress integration's RestStrategy](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Strategies/RestStrategy.php), +it registers the route like this: + +```php +public function registerRoute(callable $controllerGetter) +{ + // Register the route with WordPress. + add_action('rest_api_init', function () use ($controllerGetter) { + /** @var Controller $controller */ + $controller = $controllerGetter(); + // Use WordPress's register_rest_route function to register the route. + register_rest_route( + $this->restNamespaceProvider->getRestNamespace(), + $this->convertEndpointFormat($controller->getEndpoint()), + [ + 'methods' => $controller->getMethod(), + 'callback' => fn(WP_REST_Request $request) => $this->handleRequest($controller, new WordPressRequest($request, $this->currentUserResolver->getCurrentUser())), + 'permission_callback' => '__return_true' + ] + ); + }); +} +``` + +The `handleRequest` method that actually does the work of adapting the PHPNomad request into a WordPress request. +It does this by passing +a [WordPressRequest](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Rest/Request.php) object, which +implements PHPNomad's `Request` interface. `handleRequest` then runs the controller and converts the response back +into a WordPress response. + +[You can see it in action here.](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Strategies/RestStrategy.php#L77-L102), +but the guts of it are in this method, which runs middleware, gets the response, and runs interceptors: + +```php +private function wrapCallback(Controller $controller, Request $request): Response +{ + // Maybe process middleware. + if ($controller instanceof HasMiddleware) { + Arr::each($controller->getMiddleware($request), fn(Middleware $middleware) => $middleware->process($request)); + } + + + /** @var \PHPNomad\Integrations\WordPress\Rest\Response $response */ + $response = $controller->getResponse($request); + + + // Maybe process interceptors. + if ($controller instanceof HasInterceptors) { + Arr::each($controller->getInterceptors($request, $response), fn(Interceptor $interceptor) => $interceptor->process($request, $response)); + } + + + return $response; +} +``` + +In some cases, the integration is more-loosely built. For example, the fastroute integration specifically targets +making fastroute natively use PHPNomad to handle building a minimalistic REST API that uses PHPNomad. + +This requires a bit more code to accomplish, since Fastroute doesn't handle a lot of the necessary aspects for us. In +the case of Fastroute, we created our own registry to store the routes so that we can reference that and handle routing +ourselves. As shown above, other platforms like WordPress or Laravel would handle most of this for us. + +Fastroute's `registerRoute` method looks like this: + +```php +public function registerRoute(callable $controllerGetter) +{ + $this->registry->set(function () use ($controllerGetter) { + /** @var Controller $controller */ + $controller = $controllerGetter(); + + + return [ + $controller->getMethod(), + $controller->getEndpoint(), + function ($request) use ($controller) { + if ($controller instanceof HasMiddleware) { + $this->runMiddleware($controller, $request); + } + $response = $controller->getResponse($request); + $this->setRestHeaders($response); + + + if ($controller instanceof HasInterceptors) { + $this->runInterceptors($controller, $request, $response); + } + + + return $response; + } + ]; + + + }); +} +``` + +The main difference here is that we needed to provide our own routing registry, and we also needed to handle +setting the response headers, since Fastroute doesn't do that for us, however the rest of the logic is very similar to +the WordPress example above. + +* Run middleware if the controller has any. +* Get the response from the controller. +* Set the response headers. +* Run interceptors if the controller has any. +* Return the response. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md b/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md new file mode 100644 index 0000000..fe93244 --- /dev/null +++ b/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md @@ -0,0 +1,88 @@ +# 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. + +## Purpose + +Controllers should remain deterministic: given valid input, return a response. But many actions in a system also trigger +**side effects**—for example, creating a resource may need to emit a `UserRegistered` or `OrderPlaced` event. + +Placing this responsibility inside controllers creates tight coupling and scattered event code. The `EventInterceptor` +moves this logic into the lifecycle boundary, where it can consistently fire after the response is prepared. + +## How It Works + +The interceptor accepts two things at construction: + +* **`$eventGetter`** — a callable that produces an `Event` instance, usually using data from the request or response. +* **`$eventStrategy`** — the broadcasting mechanism that knows how to publish events to your system (e.g., sync + dispatch, queue, message bus). + +When the interceptor runs, it calls the getter, builds the event, and uses the strategy to broadcast it. + +## Usage Example + +Here’s how you could use `EventInterceptor` to broadcast an event whenever a new widget is created: + +```php +widgets->create( + name: (string) $request->getParam('name') + ); + + return $this->response + ->setStatus(201) + ->setJson(['id' => $id, 'status' => 'created']); + } + + public function getInterceptors(Request $req, Response $res): array + { + return [ + new EventInterceptor( + eventGetter: fn () => new WidgetCreatedEvent( + id: $res->getBody()['id'], + name: $req->getParam('name') + ), + eventStrategy: $this->events + ), + ]; + } +} +``` + +In this example: + +* The controller only creates the widget and returns a response. +* The interceptor handles broadcasting a `WidgetCreatedEvent` after the response is finalized. +* Event emission is kept portable, reusable, and out of controller code. + +The `EventInterceptor` provides a clean way to emit domain events at the edge of the request lifecycle. By using a +simple getter and a broadcasting strategy, you keep controllers lean, avoid duplicated event logic, and ensure side +effects happen reliably after the main response is prepared. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/interceptors/introduction.md b/public/docs/docs/packages/rest/interceptors/introduction.md new file mode 100644 index 0000000..91ba946 --- /dev/null +++ b/public/docs/docs/packages/rest/interceptors/introduction.md @@ -0,0 +1,125 @@ +# Interceptors + +Interceptors allow you to extend and customize the final stage of the request lifecycle in PHPNomad. They run after the +controller has returned its response but before that response is sent back to the client. This makes them ideal for +adapting outputs and handling cross-cutting concerns without bloating controller logic. + +Unlike middleware, which runs before a controller executes, interceptors operate with full knowledge of the request and +response. This timing makes them uniquely suited for last-minute adjustments and side effects. + +Interceptors give you the power to modify responses or trigger side effects at the very edge of the lifecycle. They see +the full picture—the request, the controller result, and the prepared response—allowing them to: + +* Adapt responses into consistent formats +* Enforce output policies centrally +* Trigger events, logging, or metrics safely + +By keeping controllers lean and letting interceptors handle boundary concerns, your codebase remains portable, +maintainable, and easier to reason about. + +## Adapting Responses + +One common use case for interceptors is adapting a response into a format that is safe, portable, or consistent across +your API. Instead of forcing every controller to handle response shaping, you can offload that work to an interceptor +that runs at the boundary. + +```php +use PHPNomad\Rest\Interfaces\Interceptor; +use PHPNomad\Http\Interfaces\Request; +use PHPNomad\Http\Interfaces\Response; + +final class ModelAdapterInterceptor implements Interceptor +{ + public function process(Request $request, Response $response): void + { + $body = $response->getBody(); + + if (is_object($body) && method_exists($body, 'toArray')) { + $response->setJson($body->toArray()); + } + } +} +``` + +This interceptor ensures that domain models are consistently converted into JSON arrays without controllers needing to +repeat that logic. + +**Conclusion:** By using interceptors for response adaptation, you centralize formatting rules and keep controllers +focused only on core business logic. + +## Handling Side Effects + +Interceptors are also the right place for side effects that depend on the final result of a request. Since they execute +after the response has been prepared, they can safely trigger actions that should not interfere with the controller’s +outcome. + +Typical examples include: + +* Publishing domain events once a resource is created or updated +* Logging details of completed requests +* Recording metrics for observability + +```php +logger->info('API Request Completed', [ + 'path' => $request->getPath(), + 'method' => $request->getMethod(), + 'status' => $response->getStatus(), + ]); + } +} +``` + +This interceptor adds to the logger after a request succeeds, without polluting controller code. + +By isolating side effects inside interceptors, you keep your controllers deterministic and ensure cross-cutting actions +happen reliably at the right time. + +### Ordering and Execution + +When you define multiple interceptors for a controller, their order matters. They execute sequentially, and later +interceptors see the modifications made by earlier ones. + +This allows you to stack concerns: for example, one interceptor could adapt models into arrays, and another could wrap +all responses into a common envelope. + +* Interceptors run in the order returned from `getInterceptors()`. +* Each interceptor receives the final `Request` and `Response`. +* They may mutate the response body, headers, or status. + +By controlling interceptor ordering, you can compose response pipelines cleanly without entangling unrelated concerns. + +### Best Practices + +Interceptors are powerful, but like middleware, they work best when kept focused and predictable. + +Because they can mutate responses or trigger external systems, it’s important to apply consistent patterns to avoid +confusion and unintended side effects. + +* Keep interceptors **single-purpose** (e.g., one for adaptation, one for events). +* **Don’t hide failures**: catch exceptions from side effects, but log them for observability. +* 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. + +--- + +## 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/docs/packages/rest/introduction.md b/public/docs/docs/packages/rest/introduction.md new file mode 100644 index 0000000..fc23c30 --- /dev/null +++ b/public/docs/docs/packages/rest/introduction.md @@ -0,0 +1,79 @@ +# Rest + +`phpnomad/rest` is an **MVC-driven methodology for defining REST APIs**. +It’s designed to let you describe **controllers, routes, validations, and policies** in a way that’s **agnostic to the +framework or runtime** you plug into. + +At its core: + +* **Controllers** express your business logic in a consistent contract. +* **RestStrategies** adapt those controllers to different environments (FastRoute, WordPress, custom). +* **Middleware** and **Validations** give you predictable, portable contracts around input handling and authorization. +* **Interceptors** capture side effects like events or logs after responses are sent. + +By separating API **definition** (what the endpoint is, what it requires, what it returns) from **integration** (how it +runs inside a host), you get REST endpoints that can move between stacks without rewrites. + +## Key ideas at a glance + +* **Controller** — your endpoint’s logic, returning the payload + status intent. +* **RestStrategy** — wires routes to controllers in your host framework. +* **Middleware** — pre-controller cross-cuts (auth, pagination defaults, projections). +* **Validations** — input contracts with a consistent error shape. +* **Interceptors** — post-response side effects (events, logs, metrics). + +--- + +## The Request Lifecycle + +When a request enters a system wired with `phpnomad/rest`, it moves through a consistent sequence of steps: + +``` +Route → Middleware → Controller → Response → Interceptors +``` + +### Route + +The **RestStrategy** matches an incoming request to a registered controller based on the HTTP method and path. + +* Integration-specific (FastRoute, WordPress, custom). +* Responsible only for dispatching into the portable REST flow. + +### Middleware + +[Middleware](/packages/rest/middleware/introduction) runs **before** your controller logic. + +* Can short-circuit (e.g., fail auth, block a bad request). +* Can enrich context (e.g., inject a current user, parse query filters). +* Runs in defined order, producing a clean setup for the controller. + +### Validations + +[Validation](/packages/rest/validations/introduction) sets define **input contracts** for the request. These are set using a +middleware, so you can control when +they run. + +* Ensure required fields are present. +* Enforce types and formats (e.g., integer IDs, valid emails). +* Failures are collected and returned in a predictable error payload. + +### Controller Handle Method + +The [controller](/packages/rest/controllers) is the **core of the endpoint**. + +* Business logic goes here: read, mutate, return. +* Sees a request context already shaped by middleware and validated inputs. +* Returns a response object, setting the status and payload. + +### Interceptors + +[Interceptors](/packages/rest/interceptors/introduction) run **after the controller has produced a response** and **before the +response leaves the pipeline**. + +They're usually used for two main purposes: + +1. **Adapt the response** — reshape or enrich the response object without touching controller code. +2. **Perform side effects** — emit events, write audit logs, push metrics, etc. + +Because interceptors sit at the boundary, they’re an ideal place to keep controllers lean while still achieving +consistent output formats and cross-cutting behavior. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md new file mode 100644 index 0000000..3011f94 --- /dev/null +++ b/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md @@ -0,0 +1,83 @@ +# CallbackMiddleware + +The `CallbackMiddleware` is the most minimal middleware provided in PHPNomad. It exists as a utility for injecting +arbitrary logic into the request lifecycle without creating a dedicated middleware class. + +It’s particularly useful for **simple one-off behaviors**, rapid prototyping, or cases where full-blown middleware is +unnecessary. + +## Purpose + +Middleware normally provides **reusable, named behaviors** that can be applied across multiple controllers. For example, +pagination defaults or record existence checks. + +However, sometimes you need lightweight logic that doesn’t justify creating and registering a new class. +The `CallbackMiddleware` makes this possible by letting you pass in any callable and have it run against the request. + +This trades **formality for flexibility**. Use it sparingly, but it can be a good tool for fast iteration. + +## Contract + +`CallbackMiddleware` implements the standard `Middleware` interface: + +```php +interface Middleware +{ + public function process(Request $request): void; +} +``` + +Instead of having its own logic, it simply wraps a user-supplied `callable` and invokes it during `process()`: + +```php +final class CallbackMiddleware implements Middleware +{ + public function __construct(callable $callback) { /* ... */ } + + public function process(Request $request): void + { + ($this->callback)($request); + } +} +``` + +The callback receives the **normalized request** object, allowing you to inspect or modify it before the controller +executes. + +## Example: Adding a Default Parameter + +Here’s how you could use `CallbackMiddleware` to inject a default `locale` if the request does not already have one: + +```php +use PHPNomad\Rest\Middleware\CallbackMiddleware; +use PHPNomad\Http\Interfaces\Request; + +$middleware = new CallbackMiddleware(function (Request $request) { + if (!$request->hasParam('locale')) { + $request->setParam('locale', 'en_US'); + } +}); +``` + +When included in a controller’s middleware chain, this ensures every request has a `locale` parameter available. + +## Example: Simple Audit Logging + +You could also log requests inline without creating a full logger middleware: + +```php +use PHPNomad\Rest\Middleware\CallbackMiddleware; +use PHPNomad\Http\Interfaces\Request; + +$middleware = new CallbackMiddleware(function (Request $request) { + error_log("Incoming request to: " . $request->getParam('endpoint')); +}); +``` + +This is useful for debugging or quick metrics collection. + +## Best Practices + +* **Prefer explicit middleware classes** for reusable or complex behaviors. +* **Use CallbackMiddleware only for simple, localized logic** where creating a full middleware class would be overkill. +* **Keep callbacks short and focused** — they should not contain business logic or validation rules. diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md new file mode 100644 index 0000000..5aa6b67 --- /dev/null +++ b/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md @@ -0,0 +1,109 @@ +# ParseJwtMiddleware + +The `ParseJwtMiddleware` is a built-in middleware in PHPNomad designed to handle **JSON Web Tokens (JWTs)**. +Its job is to read a JWT from the request, validate and decode it, and make the decoded token available +to downstream parts of the lifecycle (controllers, other middleware, etc.). + +## Purpose + +Authentication and authorization flows often require a token that represents the identity and claims of the current user. +The `ParseJwtMiddleware` ensures: + +- The token is present in the request (under a configurable key). +- It is decoded and validated using the configured `JwtService`. +- If valid, the decoded token is re-attached to the request for later use. +- If invalid, a `RestException` is thrown so the request ends early with a clear error response. + +This keeps your controllers and other components free from manual JWT parsing and error handling. + +## Usage + +In practice, you don’t call middleware directly — you declare it on a controller. +Here’s how to attach `ParseJwtMiddleware` to an endpoint that requires a valid token: + +```php +getParam('jwt'); + + return $this->response + ->setStatus(200) + ->setJson([ + 'userId' => $token['sub'], + 'roles' => $token['roles'] ?? [], + ]); + } + + public function getMiddleware(Request $request): array + { + return [ + $this->jwtMiddleware, + ]; + } +} +``` + +### Example request + +``` +GET /profile?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +``` + +### Example response + +```json +{ + "userId": 123, + "roles": ["editor", "admin"] +} +``` + +If the token is invalid, the response would instead be: + +```json +{ + "error": { + "message": "Invalid Token", + "context": {} + } +} +``` + +with status **400 Bad Request**. + +--- + +## Best Practices + +* **Chain this early**: Place `ParseJwtMiddleware` before any logic that relies on user identity. +* **Keep controllers lean**: Once decoded, controllers should just consume `$request->getParam('jwt')`. +* **Consistent key**: If your API passes the token under a different request key, configure `ParseJwtMiddleware` with that key. +* **Fail fast**: By throwing a `RestException` early, you prevent controllers from ever running with bad state. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md new file mode 100644 index 0000000..a756b51 --- /dev/null +++ b/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md @@ -0,0 +1,73 @@ +# SetTypeMiddleware + +The `SetTypeMiddleware` is a built-in middleware in PHPNomad for **type coercion**. +It ensures that request parameters have the expected PHP type before the controller sees them, so business logic can +trust values are already in the right form. + +HTTP parameters arrive as strings by default, regardless of whether they represent numbers, booleans, or arrays. +`SetTypeMiddleware` lets you declare that a specific parameter should always be treated as a certain type. For example, +if your endpoint expects a `float price` or an `int userId`, you can coerce it automatically rather than repeating +casts inside your controllers. + +## Usage Example + +Suppose you have an endpoint that accepts a `price` parameter, which should always be a float. +By attaching `SetTypeMiddleware`, you guarantee that `$request->getParam('price')` is already a float when the +controller runs. + +## Usage Example + +Suppose you have an endpoint that accepts a `userId` parameter, which should always be treated as an integer. +By attaching `SetTypeMiddleware`, you guarantee that `$request->getParam('userId')` is already an integer when the +controller runs. + +```php +users->findById($request->getParam('userId')); + + if (!$user) { + return $this->response + ->setStatus(404) + ->setJson(['error' => 'User not found']); + } + + return $this->response + ->setStatus(200) + ->setJson($user); + } + + public function getMiddleware(Request $request): array + { + return [ + new SetTypeMiddleware('userId', BasicTypes::Integer), + ]; + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md new file mode 100644 index 0000000..aaed21e --- /dev/null +++ b/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md @@ -0,0 +1,136 @@ +# ValidationMiddleware + +The `ValidationMiddleware` is the bridge between declared **input contracts** and incoming requests. +It runs before the controller to ensure that required parameters are present, correctly typed, and pass any +custom validation rules you’ve defined. + +If any validations fail, it throws a `ValidationException`, which the framework converts into a consistent HTTP error +response. + +## Purpose + +Controllers should never need to perform defensive checks like “is this field missing?” or “is this string actually an +email?”. That belongs in the validations phase. + +`ValidationMiddleware` ensures: + +- Every declared `ValidationSet` is checked against the request. +- Failures are collected into a structured error payload. +- Controllers only run if input passes validation. + +This keeps your controller code clean, predictable, and focused on business logic. + +## Usage Example + +Here’s a controller that requires `name` to be a non-empty string and `age` to be an integer ≥ 18. +It attaches `ValidationMiddleware` with its own validation rules. + +```php +getParam('name'); + $age = (int) $request->getParam('age'); + + return $this->response + ->setStatus(201) + ->setJson(['message' => "User $name registered."]); + } + + public function getMiddleware(Request $request): array + { + return [ + // Attach validation middleware to enforce rules. This allows you to choose when to validate. + new ValidationMiddleware($this), + ]; + } + + public function getValidations(): array + { + return [ + 'name' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::String)), + + 'age' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Integer)) + ->addValidation(fn() => new IsGreaterThan(17)), + ]; + } +} +``` + +### Example request + +``` +POST /users/register +Content-Type: application/json + +{ + "name": "Alice", + "age": 22 +} +``` + +### Example success response + +```json +{ + "message": "User Alice registered." +} +``` + +### Example failure response + +If `age` was `15`: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "age": [ + "Must be at least 18." + ] + } + } +} +``` + +--- + +## Best Practices + +* **Always attach ValidationMiddleware** to endpoints that require structured inputs. +* **Combine with type coercion** (e.g., `SetTypeMiddleware`) so values are in the right type before being validated. +* **Keep validations declarative**: don’t bury conditional logic in controllers—express it in `ValidationSet`s. +* **Make error messages user-friendly**: clients should be able to act on them without guesswork. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/introduction.md b/public/docs/docs/packages/rest/middleware/introduction.md new file mode 100644 index 0000000..3b04cba --- /dev/null +++ b/public/docs/docs/packages/rest/middleware/introduction.md @@ -0,0 +1,223 @@ +# Middleware + +Middleware is the **pre-controller** layer of `phpnomad/rest`. It runs before your controller’s business logic, shaping +the request into something predictable and safe to operate on. Typical uses include setting sane defaults, coercing +types, enriching context, and—when necessary—stopping a request early. + +By the time a request reaches your controller, well-behaved middleware should have already done the boring work: +normalize parameters, apply limits, gate obvious failures, and authorize/authenticate the request. + +## What middleware is responsible for + +Middleware has two clear responsibilities: + +1) Prepare the request Normalize or enrich input so controllers can keep their focus. This might be pagination defaults, + converting a CSV + into an array, or attaching derived context for later phases. + +2) Short-circuit when appropriate If something is clearly wrong (unauthorized, missing resource, exceeded limit), + middleware can stop the request + *before* controller code runs. The recommended way to do this is to **throw a `RestException`** with an HTTP status + code, message, and structured context—the integration will catch it and format the HTTP response consistently. + +## What middleware is not + +1. It is not where you perform post-response side effects, such as triggering events. That's the job + of [interceptors](/packages/rest/middleware/interceptors/introduction) +2. It is not where you encode your domain’s validation rules. That's the job + of [validations](/packages/rest/middleware/validations/introduction). +3. It is not where you write business logic. That's the job of the controller. + +## The middleware contract + +A middleware class implements the `PHPNomad\Rest\Interfaces\Middleware` interface and defines a single method: + +```php +public function process(\PHPNomad\Http\Interfaces\Request $request): void; +```` + +* **Input:** a normalized `Request` object you can read and mutate + via `getParam`, `hasParam`, `setParam`, `removeParam`, and friends. +* **Output:** no return value. Either mutate the request in place and return, or throw a `RestException` to + short-circuit with an error. + +Like controllers, middleware can use **constructor injection**. Because instances are created via your initializer and +container, you can request collaborators (repositories, services, strategies) in the constructor without manual wiring. + +## Example: pagination defaults + +This middleware ensures that all list endpoints have sensible pagination. Controllers don’t need to know anything about +defaults or caps; they simply read `number` and `offset`. + +```php +hasParam('number')) { + $request->setParam('number', $this->defaultNumber); + } + + // Cap page size + if ((int) $request->getParam('number') > $this->maxNumber) { + $request->setParam('number', $this->maxNumber); + } + + // Default offset + if (!$request->hasParam('offset')) { + $request->setParam('offset', 0); + } + } +} +``` + +**Why this works well:** controllers can rely on `number` and `offset` existing and living within bounds, without +duplicating that code in every endpoint. + +## Example: resolving a user from the request + +This middleware looks up a record by ID and attaches the full record to the request. If the user doesn’t exist, it +throws a `RestException` with a `404` status code. + +This is a common pattern for endpoints that operate on a specific resource. By the time the controller runs, it can +assume the user exists and focus on the business logic. + +The power of this approach is that the logic to fetch the user and handle the "not found" case is isolated in one place, +and can be reused across multiple controllers, regardless of the datastore implementation. + +```php +getParam('id'); + + if(!$id) { + throw new RestException( + code: 400, + message: "Missing required 'id' parameter", + context: [] + ); + } + + try{ + // Fetch the record and attach it to the request. + $request->setParam('record', $this->datastore->find($id)); + } catch(RecordNotFoundException $e) { + // If the record doesn't exist, stop the request with a 404. + throw new RestException( + code: 404, + message: "User {$id} was not found", + context: ['id' => $id] + ); + } catch(DatastoreErrorException $e) { + // Log the error for internal tracking. + $this->logger->logException($e); + + // For other errors, throw a 500 with context. + // Note that the original exception is not exposed to the client. + throw new RestException( + code: 500, + message: "Error fetching user {$id}", + context: ['id' => $id] + ); + } + } +} +``` + +## Using middleware in your controllers + +To attach middleware to a controller, implement the `PHPNomad\Rest\Interfaces\HasMiddleware` interface and define +`getMiddleware()`. + +The example below shows a `GetUser` controller that uses the `GetRecordFromRequest` middleware to fetch a user by ID. +This uses the GetRecordFromRequest middleware defined above. + +Note that before it passes the request to the middleware, it also +uses [SetTypeMiddleware](/packages/rest/middleware/included-middleware/set-type-middleware) to ensure the `id` parameter is always an integer. + +```php +/** + * Example controller showing how to get a user + * - Middleware + * + * Each piece is isolated but works together in the request lifecycle. + */ +final class GetUser implements Controller, HasMiddleware +{ + public function __construct( + private Response $response, // Response object (DI-provided) + private UserDatastore $userDatastore, // User datastore for middleware + private UserAdapter $userAdapter, // User adapter for controller + private GetRecordFromRequest $getRecordMiddleware // Middleware instance + ) {} + + /** + * The HTTP endpoint path where this controller is mounted. + */ + public function getEndpoint(): string + { + return '/user/{id}'; + } + + /** + * The HTTP method used for this endpoint. + */ + public function getMethod(): string + { + return Method::Get; + } + + /** + * The core controller logic. This only runs if: + * - Middleware passed (auth, type coercion, etc.) + * - Validations succeeded + */ + public function getResponse(Request $request): Response + { + // The "record" param is set by GetRecordFromRequest middleware. + // Adapter converts it to a response-friendly format. + $user = $this->userAdapter->toResponse( + $request->getParam('record') // Set by middleware + ); + + // For gets, 200 is standard (with a body that includes the resource). + return $this->response + ->setStatus(200) + ->setBody($user); + } + + /** + * Middleware runs *before* the controller logic. + */ + public function getMiddleware(Request $request): array + { + return [ + new SetTypeMiddleware('id', BasicTypes::Integer), // Coerce 'id' to int + $this->getRecordMiddleware + ]; + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-any.md b/public/docs/docs/packages/rest/validations/included-validations/is-any.md new file mode 100644 index 0000000..6458b7c --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/is-any.md @@ -0,0 +1,201 @@ +# IsAny + +The `IsAny` validation ensures that a request parameter matches **one of a predefined list of acceptable values**. This +is particularly useful when restricting input to an enumeration of allowed options. + +## Usage + +```php +use PHPNomad\Rest\Validations\IsAny; + +$validation = new IsAny(['draft', 'published', 'archived']); +``` + +The above will only pass validation if the request parameter matches **exactly one** of `draft`, `published`, +or `archived`. + +## Constructor + +```php +public function __construct($validItems, $errorMessage = null) +``` + +* **`$validItems`** (`array`) – A list of allowed values. +* **`$errorMessage`** (`string|callable|null`) – An optional custom error message. If omitted, a default error message + is generated automatically. + +## Using IsAny in a Controller + +Below, the endpoint accepts a `status` parameter that must be one of +`draft`, `published`, or `archived`. The controller declares this rule +declaratively; `ValidationMiddleware` runs it before the handler. + +```php +getParam('id'); + $status = (string) $request->getParam('status'); + + $this->posts->setStatus($id, $status); + + return $this->response + ->setStatus(200) + ->setJson(['id' => $id, 'status' => $status]); + } + + // Declare validations for this endpoint. + public function getValidations(): array + { + return [ + 'status' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsAny(['draft', 'published', 'archived'])), + ]; + } + + // Attach the middleware that executes validations. + public function getMiddleware(Request $request): array + { + return [ new ValidationMiddleware($this) ]; + } +} +```` + +### Example (success) + +``` +POST /posts/42/status +Content-Type: application/json + +{ "status": "published" } +``` + +```json +{ + "id": 42, + "status": "published" +} +``` + +### Example (failure) + +When `status` is missing **or** not in the allowed set, the middleware throws a `ValidationException` and the framework +returns a structured error: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "status": [ + { + "field": "status", + "message": "status must be draft, published, or archived, but was given deleted", + "type": "REQUIRES_ANY", + "context": { + "validValues": [ + "draft", + "published", + "archived" + ] + } + } + ] + } + } +} +``` + +### Optional: custom message + +`IsAny` accepts an optional custom message (string or callable). For example: + +```php +'status' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsAny( + ['draft', 'published', 'archived'], + fn (string $key, Request $req) => sprintf( + 'Invalid %s: must be %s.', + $key, + implode(', ', ['draft', 'published', 'archived']) + ) + )), +``` + +## Behavior + +* Calls `$request->getParam($key)` and checks if the value exists in `$validItems`. +* If the value is missing or not in the list, validation fails. +* Returns a contextual error message, e.g.: + +``` +status must be draft, published, or archived, but was given deleted +``` + +If no value was provided: + +``` +status must be draft, published, or archived, but no value was given +``` + +## Error Type + +* **Type:** `REQUIRES_ANY` +* **Context:** + + ```json + { + "validValues": ["draft", "published", "archived"] + } + ``` + +## Example Failure Response + +For a missing or invalid `status`, the system may produce: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "status": [ + { + "field": "status", + "message": "status must be draft, published, or archived, but was given deleted", + "type": "REQUIRES_ANY", + "context": { + "validValues": [ + "draft", + "published", + "archived" + ] + } + } + ] + } + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-email.md b/public/docs/docs/packages/rest/validations/included-validations/is-email.md new file mode 100644 index 0000000..237d3ab --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/is-email.md @@ -0,0 +1,96 @@ +# IsEmail + +The `IsEmail` validation ensures that a request parameter is a **validly formatted email address**. It uses PHP’s +built-in functions under the hood, making it a lightweight but reliable validator for user input. + +## Usage in a Controller + +A typical scenario is validating that a `userEmail` field contains a valid email before processing the request. Here’s +an example controller that creates a user: + +```php +users->create( + email: (string) $request->getParam('userEmail') + ); + + return $this->response + ->setStatus(201) + ->setJson(['id' => $userId, 'status' => 'created']); + } + + public function getValidations(): array + { + return [ + 'userEmail' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsEmail()), + ]; + } +} +``` + +In this example: + +* The `userEmail` parameter is required. +* Validation ensures the value is a properly formatted email. +* If the validation fails, the request never reaches the `getResponse` method. + +## Error Type + +* **Type:** `INVALID_EMAIL` +* **Error Message:** + + ``` + userEmail must be a valid email address. + ``` +* **Context:** empty (this validator doesn’t provide extra context). + +## Example Failure Response + +If a request is made without a valid `userEmail`, `ValidationMiddleware` throws a `ValidationException`. The system +produces: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "userEmail": [ + { + "field": "userEmail", + "message": "userEmail must be a valid email address.", + "type": "INVALID_EMAIL" + } + ] + } + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md b/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md new file mode 100644 index 0000000..ba5af2f --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md @@ -0,0 +1,112 @@ +# IsGreaterThan + +The `IsGreaterThan` validation ensures that a request parameter is **strictly greater than a specified numeric value**. +This is useful for enforcing minimum thresholds, such as “age must be greater than 18” or “quantity must be greater than +0.” + + +## Usage in a Controller + +Below, the endpoint accepts an `age` parameter that must be greater than **18**. The controller declares this rule +using `ValidationSet`, and `ValidationMiddleware` enforces it before the handler runs. + +```php + 18. + return $this->response + ->setStatus(201) + ->setJson([ + 'name' => $request->getParam('name'), + 'age' => $request->getParam('age'), + 'status' => 'registered' + ]); + } + + // Declare validations for this endpoint. + public function getValidations(): array + { + return [ + 'age' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsGreaterThan(17)), + ]; + } + + // Attach the middleware that executes validations. + public function getMiddleware(Request $request): array + { + return [ new ValidationMiddleware($this) ]; + } +} +``` + +### Example (success) + +``` +POST /adults/register +Content-Type: application/json + +{ "name": "Alice", "age": 25 } +``` + +```json +{ + "name": "Alice", + "age": 25, + "status": "registered" +} +``` + +### Example (failure) + +When `age` is `15`, the middleware throws a `ValidationException` and the framework returns: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "age": [ + { + "field": "age", + "message": "age must be greater than 18. Was given 15", + "type": "VALUE_TOO_SMALL", + "context": { + "minimumValue": 18 + } + } + ] + } + } +} +``` + +## Notes + +* If the field is **missing**, the default error message clarifies that a value was expected: + `"age must be greater than 18, but no value was given."` +* You can compose `IsGreaterThan` with other validations in the same `ValidationSet` (e.g., `IsType(Integer)`) for + stricter guarantees. +* The `context` always includes the required minimum value so clients can adjust their input programmatically. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md b/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md new file mode 100644 index 0000000..2b2b9ea --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md @@ -0,0 +1,121 @@ +# IsNumeric + +The `IsNumeric` validation ensures that a request parameter is **numeric**. This includes integers, floats, and numeric +strings (anything PHP’s `is_numeric()` would accept). + +This validation is especially useful for parameters that arrive as strings via HTTP but need to represent numbers, such +as IDs, quantities, or counts. + +## Usage in a Controller + +Below, the endpoint accepts a `count` parameter that must be numeric. The controller declares this rule in +a `ValidationSet`, and `ValidationMiddleware` enforces it before the handler runs. + +```php +getParam('count'); + + $report = $this->reports->generate($count); + + return $this->response + ->setStatus(200) + ->setJson([ + 'count' => $count, + 'report' => $report + ]); + } + + // Declare validations for this endpoint. + public function getValidations(): array + { + return [ + 'count' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsNumeric()), + ]; + } + + // Attach the middleware that executes validations. + public function getMiddleware(Request $request): array + { + return [ new ValidationMiddleware($this) ]; + } +} +``` + +### Example (success) + +``` +POST /reports/generate +Content-Type: application/json + +{ "count": "10" } +``` + +```json +{ + "count": 10, + "report": { + /* report contents */ + } +} +``` + +### Example (failure) + +When `count` is not numeric: + +``` +POST /reports/generate +Content-Type: application/json + +{ "count": "ten" } +``` + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "count": [ + { + "field": "count", + "message": "The key count must be numeric.", + "type": "REQUIRES_NUMERIC", + "context": {} + } + ] + } + } +} +``` + +## Notes + +* `IsNumeric` does **not** coerce values — it only checks. Use it alongside `SetTypeMiddleware` if you want to ensure + the parameter is converted to an integer or float before controller code. +* For stricter cases (e.g., only integers allowed), combine `IsNumeric` with an `IsType(Integer)` validation. +* This validation pairs well with others, like `IsGreaterThan`, to enforce both type and range. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-type.md b/public/docs/docs/packages/rest/validations/included-validations/is-type.md new file mode 100644 index 0000000..429de81 --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/is-type.md @@ -0,0 +1,271 @@ +# IsType + +The `IsType` validation ensures that a request parameter matches a specific **basic type**. +It supports the built-in `BasicTypes` enumeration, covering common cases +like `Integer`, `Float`, `Boolean`, `String`, `Array`, `Object`, and `Null`. + +This validation is particularly useful for APIs that need to guarantee type safety before controller logic executes. + +## Class Definition + +```php +namespace PHPNomad\Rest\Validations; + +use PHPNomad\Rest\Interfaces\Validation; +use PHPNomad\Rest\Traits\WithProvidedErrorMessage; +use PHPNomad\Http\Interfaces\Request; +use PHPNomad\Rest\Enums\BasicTypes; + +class IsType implements Validation +``` + +## Usage in a Controller + +Here’s an example endpoint that requires an integer `userId` and a boolean `isActive` flag. Both fields are validated +before the controller runs: + +```php +getParam('userId'); + $isActive = (bool) $request->getParam('isActive'); + + $this->users->setActiveStatus($id, $isActive); + + return $this->response + ->setStatus(200) + ->setJson(['id' => $id, 'isActive' => $isActive]); + } + + /** Declare validations for each field */ + public function getValidations(): array + { + return [ + 'userId' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Integer)), + + // IMPORTANT: Do not coerce first; let IsType(Boolean) validate the literal input. + 'isActive' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Boolean)), + ]; + } + + /** Compose middleware: coerce userId to int, then run validations */ + public function getMiddleware(Request $request): array + { + return [ + new SetTypeMiddleware('userId', BasicTypes::Integer), + // Validation must come after any coercion middleware. + new ValidationMiddleware($this), + ]; + } +} +``` + +## Error Type and Context + +* **Type:** `INVALID_TYPE` +* **Error Message:** + + ``` + userId must be an Integer, was given abc + ``` +* **Context:** + + ```json + { + "requiredType": "Integer" + } + ``` + +## Example Failure Response + +If the client passes `"userId": "abc"` and `"isActive": "yes"`, the response might look like this: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "userId": [ + { + "field": "userId", + "message": "userId must be a Integer, was given abc", + "type": "INVALID_TYPE", + "context": { + "requiredType": "Integer" + } + } + ], + "isActive": [ + { + "field": "isActive", + "message": "isActive must be a Boolean, was given yes", + "type": "INVALID_TYPE", + "context": { + "requiredType": "Boolean" + } + } + ] + } + } +} +``` + +Absolutely — great idea to show **type coercion + validation together**. One nuance: coercing **booleans** +with `settype()` can accidentally turn any non-empty string into `true`. So in the example below we **coerce the integer +** (`userId`) with `SetTypeMiddleware`, but we **do not** coerce the boolean (`isActive`) before validation; we +let `IsType(Boolean)` validate strings like `"true"`, `"false"`, `"1"`, `"0"` strictly. + +Here’s an updated section you can drop into the **`IsType`** docs. + +--- + +## Using `IsType` with Middleware (coercion + validation) + +Below, the endpoint requires: + +* `userId`: an **integer** (coerced by middleware, then validated) +* `isActive`: a **boolean** (validated strictly, then cast in the controller) + +```php +getParam('userId'); + $isActive = (bool) $request->getParam('isActive'); + + $this->users->setActiveStatus($id, $isActive); + + return $this->response + ->setStatus(200) + ->setJson(['id' => $id, 'isActive' => $isActive]); + } + + /** Declare validations for each field */ + public function getValidations(): array + { + return [ + 'userId' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Integer)), + + // IMPORTANT: Do not coerce first; let IsType(Boolean) validate the literal input. + 'isActive' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsType(BasicTypes::Boolean)), + ]; + } + + /** Compose middleware: coerce userId to int, then run validations */ + public function getMiddleware(Request $request): array + { + return [ + new SetTypeMiddleware('userId', BasicTypes::Integer), + new ValidationMiddleware($this), + ]; + } +} +``` + +* `userId`: coercion is safe (`"42"` → `42`) and avoids repeating casts in your controller. +* `isActive`: coercing **before** validating would turn *any* non-empty string into `true`. By **validating first** + with `IsType(Boolean)`, values like `"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"` are accepted; nonsense + strings (e.g., `"perhaps"`) are rejected. After validation passes, casting to `(bool)` in the controller is safe and + intentional. + +### Example request (success) + +``` +POST /users/42/status +Content-Type: application/json + +{ "isActive": "true" } +``` + +```json +{ + "id": 42, + "isActive": true +} +``` + +### Example request (failure) + +``` +POST /users/42/status +Content-Type: application/json + +{ "isActive": "perhaps" } +``` + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "isActive": [ + { + "field": "isActive", + "message": "isActive must be a Boolean, was given perhaps", + "type": "INVALID_TYPE", + "context": { + "requiredType": "Boolean" + } + } + ] + } + } +} +``` diff --git a/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md b/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md new file mode 100644 index 0000000..76e089e --- /dev/null +++ b/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md @@ -0,0 +1,129 @@ +# KeysAreAny + +The `KeysAreAny` validation ensures that **all keys of an input array parameter** are part of a predefined set of +allowed values. + +This is useful for cases where clients may send dynamic filters, attributes, or options, but you only want to allow a +specific whitelist of keys. + +## Usage in a Controller + +Below, the endpoint accepts a `filters` parameter, which must be an object (associative array) where the **keys** are +limited to `status` and `category`. + +If the request contains any other keys, validation fails. + +```php +getParam('filters'); + + $results = $this->posts->search($filters); + + return $this->response + ->setStatus(200) + ->setJson(['results' => $results]); + } + + public function getValidations(): array + { + return [ + 'filters' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new KeysAreAny(['status', 'category'])), + ]; + } + + public function getMiddleware(Request $request): array + { + return [ new ValidationMiddleware($this) ]; + } +} +``` + +--- + +### Example (success) + +``` +POST /posts/search +Content-Type: application/json + +{ + "filters": { + "status": "published", + "category": "tech" + } +} +``` + +```json +{ + "results": [ + { + "id": 1, + "title": "Scaling PHPNomad APIs", + "status": "published", + "category": "tech" + } + ] +} +``` + +### Example (failure) + +When the client sends a `filters` object with disallowed keys (e.g., `author`), the middleware throws +a `ValidationException`: + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "filters": [ + { + "field": "filters", + "message": "keys for filters must be status or category, but was given status,author", + "type": "REQUIRES_ANY", + "context": { + "validValues": [ + "status", + "category" + ] + } + } + ] + } + } +} +``` + +## Notes + +* **Checks keys only**: The values of the array are not validated here — use other validations for that. +* **Good for filters/attributes**: Useful when accepting flexible filter or metadata objects from clients. +* **Composability**: Combine `KeysAreAny` with other validations like `IsType(Array)` or custom rules to validate both + structure and content. +* **Custom error message**: You can override the default message by providing a string or callable to the constructor. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/introduction.md b/public/docs/docs/packages/rest/validations/introduction.md new file mode 100644 index 0000000..c84a634 --- /dev/null +++ b/public/docs/docs/packages/rest/validations/introduction.md @@ -0,0 +1,182 @@ +# Validations + +Validations in PHPNomad are designed to make **input expectations explicit and declarative**. Instead of scattering +checks throughout your controller code, you can attach a set of rules that describe what makes a request valid. This +makes endpoints easier to reason about and more portable across different contexts. + +## Declarative by Design + +A validation is a small class that implements the `Validation` interface. It defines three things: + +* **What to check** — via `isValid()`, given the current request. +* **What message to return** — via `getErrorMessage()`. +* **How to describe the failure** — via `getType()` and `getContext()` for machine-readable error handling. + +This means you can build reusable validation rules like “IDs must exist,” “this field must be unique,” or “value must +match a regex,” and apply them consistently wherever needed. + +## How Validations Run + +You don’t call validations directly. Instead, PHPNomad provides the `ValidationMiddleware`, a built-in middleware that +automatically runs the validations you’ve declared for a controller or another provider. + +This middleware iterates over each field’s [Validation Set](/packages/rest/validations/validation-set), checking if the field is required and +applying each validation rule in turn. + +If any rules fail, the middleware throws a `ValidationException`, and the system generates a structured error response. +This keeps controllers focused on business logic, while still ensuring strong input guarantees. + +## Why It Matters + +By keeping validations declarative: + +* **Controllers stay clean** — no inline `if` checks scattered around. +* **Errors are consistent** — all validation failures return the same error format. +* **Rules are reusable** — you can apply the same validation logic across multiple endpoints. + +## Example: Custom Validation with a Datastore + +Suppose you want to ensure that a record **does not already exist** before creating it. For example, you might prevent +creating a user with an `id` that’s already taken. You can implement this as a reusable validation that queries a +datastore. + +```php +getParam($key); + + try { + $this->datastore->find($id); + // If a record is found, this is a failure. + return false; + } catch (RecordNotFound $e) { + // Not found means it’s valid. + return true; + } + } + + public function getErrorMessage(): string + { + return 'A record with this identifier already exists.'; + } + + public function getType(): string + { + return 'record_exists'; + } + + public function getContext(string $key, Request $request): array + { + return [ + 'field' => $key, + 'value' => $request->getParam($key), + ]; + } +} +``` + +This validation: + +* Looks up the parameter value in a datastore. +* If the record exists, the validation fails. +* If the datastore throws `RecordNotFound`, the validation passes. +* Produces a structured error message and type when it fails. + +--- + +## Attaching the Validation + +Here’s how a controller might use it when creating a new record: + +```php +getParam('id'); + + $this->users->create(['id' => $id]); + + return $this->response + ->setStatus(201) + ->setJson(['id' => $id, 'status' => 'created']); + } + + public function getValidations(): array + { + return [ + 'id' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new DoesNotExist($this->users)), + ]; + } + + public function getMiddleware(Request $request): array + { + return [ new ValidationMiddleware($this) ]; + } +} +``` + +--- + +### Example request + +``` +POST /users +Content-Type: application/json + +{ + "id": "abc123" +} +``` + +### Example error response (if a record with `id=abc123` already exists) + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "id": [ + "A record with this identifier already exists." + ] + } + } +} +``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/validation-set.md b/public/docs/docs/packages/rest/validations/validation-set.md new file mode 100644 index 0000000..ddc2d80 --- /dev/null +++ b/public/docs/docs/packages/rest/validations/validation-set.md @@ -0,0 +1,123 @@ +# ValidationSet + +A `ValidationSet` is the way you declare validations for a single field in PHPNomad. +It acts as a container for one or more validation rules and knows whether the field is required. + +When `ValidationMiddleware` runs, it asks each `ValidationSet` to evaluate the incoming request and collect any +failures. This keeps validations **declarative and composable**: controllers don’t run checks inline, they simply return +an array of validation sets. + +## Purpose + +Instead of scattering `if` statements around your controller code, a `ValidationSet` lets you declare: + +- Whether the field is **required**. +- What rules must be applied to the field (via `addValidation`). +- How failures should be collected and described. + +This makes the input contract explicit, testable, and reusable. + +## API + +### `addValidation(Closure $validationGetter)` + +Adds a validation to the set. +The closure should return a `Validation` instance when called. + +```php +$set = (new ValidationSet()) + ->addValidation(fn() => new IsInteger()) + ->addValidation(fn() => new MinValue(1)); +```` + +### `setRequired(bool $isRequired = true)` + +Marks the field as required. +If the field is missing, the set automatically produces a “required” failure, without needing a separate validation. + +```php +$set = (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsString()); +``` + +## Example + +Here’s how you might declare validations for a `username` field in a controller: + +```php +use PHPNomad\Rest\Factories\ValidationSet; +use App\Validations\IsNotReservedUsername; + +public function getValidations(): array +{ + return [ + 'username' => (new ValidationSet()) + ->setRequired() + ->addValidation(fn() => new IsString()) + ->addValidation(fn() => new MinLength(3)) + ->addValidation(fn() => new IsNotReservedUsername()), + ]; +} +``` + +If the request payload is missing `username`, the set will automatically produce a “required” failure. +If it’s present but invalid, all failing rules will be collected and returned. + +## Example failure response + +When `ValidationMiddleware` runs and finds validation failures, it throws a `ValidationException`. The framework catches +this and generates a structured error response. Errors are grouped by field, with each failure including a message, +type, and context. + +This means that clients can see all problems at once and handle them intelligently. + +```json +{ + "error": { + "message": "Validations failed.", + "context": { + "username": [ + { + "field": "username", + "message": "username is required.", + "type": "REQUIRED" + } + ], + "email": [ + { + "field": "email", + "message": "Must be a valid email address.", + "type": "invalid_email", + "context": { + "value": "not-an-email" + } + } + ], + "password": [ + { + "field": "password", + "message": "Must be at least 12 characters.", + "type": "min_length", + "context": { + "min": 12, + "actual": 7 + } + }, + { + "field": "password", + "message": "Must include at least one number.", + "type": "pattern_missing_digit" + } + ] + } + } +} +``` + +## Best Practices + +* **Always use `setRequired()` for required fields** — don’t reinvent the “is required” check in a custom validation. +* **Compose multiple rules** in a single set; don’t build monolithic validators. +* **Favor closures over instances** when adding validations — they’re cached automatically and won’t bloat memory. +* **Keep error messages clear** — client developers should be able to act on them without guesswork. \ No newline at end of file diff --git a/public/docs/docs/packages/singleton/introduction.md b/public/docs/docs/packages/singleton/introduction.md new file mode 100644 index 0000000..6830b65 --- /dev/null +++ b/public/docs/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 diff --git a/public/docs/docs/packages/utils/array-helper.md b/public/docs/docs/packages/utils/array-helper.md new file mode 100644 index 0000000..31902c9 --- /dev/null +++ b/public/docs/docs/packages/utils/array-helper.md @@ -0,0 +1,335 @@ +# Array Helper + +The `ArrayHelper` class contains several helper methods that make working with native PHP arrays a little easier. These +methods are all statically accessible, and can be chained together using +the [Array Processor](/packages/utils/processors/array-processor). + +## Process + +Instantiates an [Array Processor](/packages/utils/processors/array-processor), which allows an array to have any method +in `ArrayHelper` used in a chain. + +```php +// Outputs "1, 3, bar, baz, foo" +echo ArrayHelper::process(['foo', 'bar', 'baz', null, 1, 3]) + ->whereNotNull() + ->sort(fn ($a, $b) => $a <=> $b) + ->setSeparator(', '); +``` + +## Where Not Null + +Helper method to filter out values that are `null` + +```php +//Returns ['foo','bar','baz'] +ArrayHelper::whereNotNull(['foo','bar','baz',null]); +``` + +## Each + +Calls the specified callback on each item in a foreach loop. If the array is associative, the key is retained. +Functional methods like this are particularly useful because they can require type safety in your callbacks. + +The example below converts an array of tag names keyed by their URL-friendly slug into hashtags. + +```php +//Returns ['outer-banks' => '#OuterBanks', 'north-carolina' => '#NorthCarolina', 'travel' => '#Travel'] +$hashtags = ArrayHelper::each([ + 'outer-banks' => 'Outer Banks', + 'north-carolina' => 'North Carolina', + 'travel' => 'Travel', +], fn(string $value, string $key) => '#' . StringHelper::pascalCase($value)); +``` + +## After + +Fetches items after the specified array position. + +```php +use PHPNomad\Helpers\ArrayHelper; + +//['bar','baz'] +ArrayHelper::after(['foo','bar','baz'],1); +``` + +## Before + +The opposite of `ArrayHelper::after`. Fetches items Before_ the specified array position. + +```php +use PHPNomad\Helpers\ArrayHelper; + +//['foo'] +ArrayHelper::before(['foo','bar','baz'],1); +``` + +## Dot + +Fetches an item from an array using a dot notation. Throws an `ItemNotFound` if the item provided could not be located +in the array. + +```php +use PHPNomad\Helpers\ArrayHelper; + +try{ + // baz + ArrayHelper::dot(['foo' => ['bar' => 'baz']], 'foo.bar') +}catch(ItemNotFound $e){ + // Handle cases where the item was not found. +} +``` + +## Remove + +Removes an item from the array, and returns the transformed array. + +```php +// ['peanut butter' => 'JIF', 'jelly' => 'Smucker\'s'] +ArrayHelper::remove(['milk' => 'Goshen Dairy','peanut butter' => 'JIF', 'jelly' => 'Smucker\'s'], 'milk'); +``` + +## Wrap + +Forces an item to be an array, even if it isn't an array. + +```php +// [123] +ArrayHelper::wrap(123); +``` + +## Hydrate + +Creates an array of new instances given the arguments to pass into the instance constructor. + +```php + +class Tag{ + + public function _Construct(public readonly string $slug, public readonly string $name){ + + } +} + +// [(Tag),(Tag),(Tag) +ArrayHelper::hydrate([ + ['rv-life', 'RVLife'], + ['travel','Travel'], + ['wordpress','WordPress'] + ],Tag::class) +``` + +## Flatten + +Flatten arrays of arrays into a single array where the parent array is embedded as an item keyed by the `$key`. + +```php +/** + * [ + * ['id' => 'group-1', 'key' => 'value', 'another' => 'value'], + * ['id' => 'group-1', 'key' => 'another-value', 'another' => 'value'], + * ['id' => 'group-2', 'key' => 'value', 'another' => 'value'], + * ['id' => 'group-2', 'key' => 'another-value', 'another' => 'value'] + * ] + */ +ArrayHelper::flatten([ + 'group-1' => [['key' => 'value', 'another' => 'value'], ['key' => 'another-value', 'another' => 'value']], + 'group-2' => [['key' => 'value', 'another' => 'value'], ['key' => 'another-value', 'another' => 'value']], +], 'id') +``` + +## To Indexed + +Updates the array to contain a key equal to the array's key value. + +```php +/** + * [ + * ['slug' => 'travel','name' => 'Travel'], + * ['slug' => 'rv-life','name' => 'RV Life'], + * ['slug' => 'wordpress','name' => 'WordPress'] + * ] + */ +ArrayHelper::toIndexed(['travel' => 'Travel','rv-life' => 'RVLife','wordpress' => 'Wordpress'], 'slug', 'name'); +``` + +## Sort + +Sorts array using the specified subject, sorting method, and direction. Transforms the array directly. + +```php +$items = ['bar','foo','baz']; + +// ['foo', 'baz', 'bar'] +ArrayHelper::sort($items,SORTREGULAR,Direction::Descending); +``` + +This also supports providing a callback for the sort, instead: + +```php +class Tag{ + + public function _Construct(public readonly string $slug, public readonly string $name){ + + } +} + +$items = [ + new Tag('rv-life','RV Life'), + new Tag('travel','Travel'), + new Tag('outer-banks', 'Outer Banks'), + new Tag('taos', 'Taos') +]; + +// [Tag(outer-banks), Tag(rv-life), Tag(taos), Tag(travel)] +ArrayHelper::sort($items,fn(Tag $a, Tag $b) => $a->slug <=> $b->slug); +``` + +## Pluck + +Plucks a single item from an array, given a key. Falls back to a default value if it is not set. + +```php +// 'bar' +ArrayHelper::pluck(['foo' => 'bar'],'foo','baz'); + +// 'baz' +ArrayHelper::pluck(['foo' => 'bar'],'invalid','baz'); +``` + +If the item is not an array, it will also provide the default value. + +```php +// 'baz' +ArrayHelper::pluck('This is clearly not an array...and yet.','invalid','baz'); +``` + +## Pluck Recursive + +Plucks a specific value from an array of items. + +```php +$items = [ + ['slug' => 'rv-life', 'name' => 'RVLife'], + ['slug' => 'travel', 'name' => 'Travel'], + ['slug' => 'wordpress', 'name' => 'WordPress'], + ['name' => 'Invalid'] +]; + +// ['rv-life','travel','outer-banks','taos', null] +ArrayHelper::PluckRecursive($items,'slug', null); +``` + +This also works with objects: + +```php +class Tag{ + + public function _Construct(public readonly string $slug, public readonly string $name){ + + } +} + +$items = [ + new Tag('rv-life','RV Life'), + new Tag('travel','Travel'), + new Tag('outer-banks', 'Outer Banks'), + new Tag('taos', 'Taos') +]; + +// ['rv-life','travel','outer-banks','taos'] +ArrayHelper::pluckRecursive($items, 'slug', null); +``` + +## Cast + +Cast all items in the array to the specified type. + +```php +// [1, 234,12,123,0,0] +ArrayHelper::cast(['1','234','12.34',123,'alex',false], 'int'); +``` + +## Append + +Adds the specified item(s) to the end of an array. + +```php +// ['foo','bar','baz'] +ArrayHelper::append(['foo'],'bar','baz'); +``` + +## Is Associative + +Returns true if this array is an associative array. + +```php +// true +ArrayHelper::isAssociative(['foo' => 'bar']); + +// false +ArrayHelper::isAssociative(['foo', 'bar', 'baz']); +``` + +## Normalize + +Recursively sorts, and optionally mutates an array of arrays. Useful when preparing for caching purposes because it +ensures that any array that is technically identical, although in a different order, is the same. This can also convert +a closure into a format that can be safely converted into a hash. + +Generally, this is used to prepare an array to be converted into a consistent hash, regardless of what order the items +in the array are stored. + +```php +$cachedQuery = [ + 'postType' => 'post', + 'postsPerPage' => -1, + 'metaQuery' => [ + 'relation' => 'OR', + [ + 'key' => 'likes', + 'value' => 50, + 'compare' => '>', + 'type' => 'numeric', + ], + ], +]; + +/** +* [ +* 'metaQuery' => [ +* 'relation' => 'OR' +* [ +* 'compare' => '>' +* 'key' => 'likes' +* 'type' => 'numeric' +* 'value' => 50 +* ] +* ] +* 'postType' => 'post' +* 'postsPerPage' => int -1 + * ] + */ +ArrayHelper::normalize($cachedQuery) +``` + +## Proxies + +There are also several methods that serve as direct proxies for `array_*` functions, with the only difference being that +the order of the arguments always put the input array as the first argument (haystack comes first). + +* map => arrayMap +* reduce => arrayReduce +* filter => arrayFilter +* values => arrayValues +* keys => arrayKeys +* unique => arrayUnique +* keySort => ksort +* merge => arrayMerge +* reverse => arrayReverse +* prepend => arrayUnshift +* intersect => arrayIntersect +* intersectKeys => arrayIntersectKeys +* diff => arrayDiff +* replaceRecursive => arrayReplaceRecursive +* replace => arrayReplace \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/array-processor.md b/public/docs/docs/packages/utils/processors/array-processor.md new file mode 100644 index 0000000..f51c130 --- /dev/null +++ b/public/docs/docs/packages/utils/processors/array-processor.md @@ -0,0 +1,76 @@ +# Array Processor + +The Array Processor makes it possible to pass a single array through multiple chained mutation methods, and then output the result as either the transformed array, or a string. It supports all [Array Helper](/packages/utils/array-helper) methods in a chained form. + +## Extracting The Array + +When you've done all the processing necessary, you can get the array back by calling `toArray()`. + +```php +//['BAR','BAZ','FOO'] +(new ArrayProcessor(['foo','bar','baz'])) + ->sort() + ->map(fn(string $item) => strtoupper($item)) + ->toArray(); +``` + +## Converting a processed array into a string + +`ArrayProcessor` implements `CanConvertToString`, which means that it can be typecast into a string directly, or passed into any method or function that typehints a string, and it will automatically be converted into a string when passed through. + +By default, the array processor will convert the array into a comma-separated value, like so: + +```php +// 'foo,bar,baz' +(new ArrayProcessor(['foo','bar','baz']))->toString(); +``` + +However, if you provide a separator, it will use that instead of `,`: + +```php +// 'foo & bar & baz' +(new ArrayProcessor(['foo','bar','baz']))->setSeparator(' & ')->toString(); +``` + +You can directly echo the processor, or generally treat it like a string, too. + +```php +// "The following items are set in the array: foo,bar,baz +$result = "The following items are set in the array: " . (new ArrayProcessor(['foo','bar','baz'])); + +// Echos "foo and also bar and also baz" +echo (new ArrayProcessor(['foo','bar','baz']))->setSeparator('and also'); +``` + +## Gotchas + +The Array Processor assumes that you will always start, and finish with an array. This means that some implementations, such as `reduce` can cause unexpected errors when the accumulator is something other than an array: + +```php +class Tag implements CanConvertToString { + + public function _Construct(public readonly string $slug, public readonly string $name){ + + } +} + +// This won't work. +(new ArrayProcessor([new Tag('rv-life','RV Life'), new Tag('travel','Travel')])) + ->reduce(function(string $acc, Tag $value){ + $acc .= $value->name . ' ' . $value->slug; + + return $acc; + },'') + ->toString(); + +// But this would! And it would return the same result as what the reducer above would return. +//Note the accumulator is an array, which gets converted to a string. +(new ArrayProcessor([new Tag('rv-life','RV Life'), new Tag('travel','Travel')])) + ->reduce(function(array $acc, Tag $value){ + $acc[] = $value->name . ' ' . $value->slug; + + return $acc; + },[]) + ->setSeparator('') + ->toString(); +``` \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/list-filter.md b/public/docs/docs/packages/utils/processors/list-filter.md new file mode 100644 index 0000000..6210825 --- /dev/null +++ b/public/docs/docs/packages/utils/processors/list-filter.md @@ -0,0 +1,235 @@ +# List Filter + +A list filter makes it possible to filter items from an array of objects using a chain-able query syntax. This feature +is built-into [Object Registries](/reference/registries/object-registries) via the `ObjectRegistry::query` method, +however it can also be used on raw arrays, as long as each item in the array has the same getters and setters you're +filtering by. This can be done using fully-qualified objects, or by simply converting arrays to objects, as shown below. + +```php +use \PHPNomad\Helpers\Processors\ListFilter; + +$iceCreamOrderItems = [ + 'item_1' => (object) [ + 'customer' => (object)['name' => 'Alex', 'id' => 1], + 'scoops' => ['strawberry','chocolate'], + 'cone' => 'waffle', + 'price' => 629, + 'toppings' => ['cheese-crackers'] + ], + + 'item_2' => (object) [ + 'customer' => (object)['name' => 'Devin', 'id' => 2], + 'scoops' => ['chocolate'], + 'cone' => 'chocolateWaffle', + 'price' => 429, + 'toppings' => ['sprinkles'] + ], + + 'item_3' => (object) [ + 'customer' => (object)['name' => 'Kate', 'id' => 3], + 'scoops' => ['vanilla'], + 'cone' => 'basic', + 'price' => 429, + 'toppings' => [] + ], + + 'item_4' => (object) [ + 'customer' => (object)['name' => 'Ben', 'id' => 4], + 'scoops' => ['strawberry','chocolate', 'vanilla'], + 'cone' => 'bowl', + 'price' => 899, + 'toppings' => ['sprinkles'] + ], +]; +``` + +## Action + +An action applies the set of operations against the array, and provides a result in different ways based on the type of +action made. + +### Filter Action + +The filter action will return a filtered array of items, filtering the results based on the provided criteria in the +operations chained before the call. + +```php +// [1,2] +$filtered = (new ListFilter($iceCreamOrderItems))->lessThan('price', 600)->filter(); +``` + +### Find Action + +The find action will return the first item found based on the provided criteria in the operations chained before the +call. + +```php +// Returns item 1 in the array above +$filtered = (new ListFilter($iceCreamOrderItems))->equals('customer.id', 2)->find(); +``` + +## Operations + +An operation is a single specification on how to filter the items in the array. Operations can be chained together, and +will set multiple operations against the filter. + +Operations are not applied until either `filter` or `find` is called, and the operations run in the order they're +declared. + +```php +// [1] +$filtered = (new ListFilter($iceCreamOrderItems)) + ->greaterThan('price',629) + ->lessThan('price', 899) + ->in('toppings','sprinkles') + ->filter(); +``` + +### Numeric Operations + +It's possible to filter numbers based on their value using `lessThan`, `greaterThan`, `lessThanOrEqual`, +and `greaterThanOrEqual`. + +```php +// [1,2] +$filtered = (new ListFilter($iceCreamOrderItems))->lessThan('price', 600)->filter(); +// [1,2,3] +$filtered = (new ListFilter($iceCreamOrderItems))->greaterThan('price', 400)->filter(); +// [1,2,3] +$filtered = (new ListFilter($iceCreamOrderItems))->greaterThanOrEqual('price', 429)->filter(); +// [0,1,2] +$filtered = (new ListFilter($iceCreamOrderItems))->lessThanOrEqual('price', 429)->filter(); +``` + +### Instance Operations + +It's possible to filter values based on their instance type. Naturally, this requires that the items in-question are an +actual instance. These filters work with the class, as well as any class that they inherit. + +```php +interface Content{} + +interface Article{} + +class BlogPost implements Content, Article{ + /*..*/ +} + +class MicroPost implements Content, Article{ + /*..*/ +} + +class Comment implements Content{ + /*..*/ +} + +$posts = [new BlogPost(),new BlogPost(), new MicroPost(), new Comment()]; + +// [0,1] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->instanceOf(BlogPost::class); +// [0,1,2] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->instanceOf(Article::class); +// [0,1,3] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->notInstanceOf(MicroPost::class); +// [3] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->notInstanceOf(Article::class); +// [0,1,2] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->hasAllInstances(Content::class, Article::class); +// [2,3] +$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->hasAnyInstances(MicroPost::class, Comment::class); +``` + +### Key Operations + +These filters work against the array key instead of the array value. + +```php +// [1,2] +$filtered = (new ListFilter($iceCreamOrderItems))->keyIn('item_2', 'item_3')->filter(); +// [0,3] +$filtered = (new ListFilter($iceCreamOrderItems))->keyNotIn('item_2', 'item_3')->filter(); +``` + +### Value Operations + +Value operations work directly against the various property values on the objects inside the array. In order for any +value operation to work, the property must either be `public` (`readonly` is okay!), or has an associated getter method +called `get_${property}` where `${property}` is the name of the property that must be fetched. The getter method takes +priority over the property value, so if you have a getter and a public property, it will use the getter method. + +All value operations support dot notation for fetching values nested in objects, and can work with both arrays of +values, and single values. + +#### In, Not-In + +`in` will set the query to filter out items whose field any of the provided values. `notIn` does the exact opposite. + +```php +// [0,2] +$filtered = (new ListFilter($iceCreamOrderItems))->in('cone', 'basic','waffle')->filter(); +// [1,3] +$filtered = (new ListFilter($iceCreamOrderItems))->in('toppings', 'sprinkles')->filter(); +// [0,1,3] +$filtered = (new ListFilter($iceCreamOrderItems))->in('toppings', 'sprinkles', 'cheese-crackers')->filter(); +// [0,2] +$filtered = (new ListFilter($iceCreamOrderItems))->notIn('toppings', 'sprinkles')->filter(); +// [2] +$filtered = (new ListFilter($iceCreamOrderItems))->notIn('toppings', 'sprinkles', 'cheese-crackers')->filter(); +// [3] +$filtered = (new ListFilter($iceCreamOrderItems))->in('customer.name','Ben')->filter(); +``` + +#### And + +Sets the query to filter out items whose field has all the provided values. + +```php +// [0,3] +$filtered = (new ListFilter($iceCreamOrderItems))->and('scoops', 'strawberry','chocolate')->filter(); +``` + +#### Equals + +Sets the query to filter out items whose value is not identical to the provided value. + +```php +// [1,2] +$filtered = (new ListFilter($iceCreamOrderItems))->and('price', 429)->filter(); +// [0] +$filtered = (new ListFilter($iceCreamOrderItems))->and('scoops', ['strawberry','chocolate'])->filter(); +``` + +### Callback + +If all-else fails, you can chain in a callback to filter items. The example below would filter out any item whose cone +type does not begin with the letter 'b': + +```php +// [2,3] +$filtered = (new ListFilter($iceCreamOrderItems)) + ->filterFromCallback('cone', fn(string $cone) => 0 === strpos($cone,'b')) + ->filter(); +``` + +## Seeding + +The concrete `ListFilter` class can be seeded directly, using an array. This can be done both with the enums, and +without. The enum is the preferred method, but if you're confident that the input is accurate, you can technically use +an array directly. This can be useful in scenarios such as directly querying a registry using a REST endpoint, or +something like that. + +```php +use PHPNomad\Enums\Filter; + +// Using Enums +ListFilter::seed($iceCreamOrderItems, [ + Filter::in->field('cone') => ['waffle', 'chocolateWaffle'], + Filter::greaterThan->field('price') => 429, +])->filter() + +// Raw query +ListFilter::seed($iceCreamOrderItems, [ + 'cone_In' => ['waffle', 'chocolateWaffle'], + 'price_GreaterThan' => 429, +])->filter() +``` \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/list-sorter.md b/public/docs/docs/packages/utils/processors/list-sorter.md new file mode 100644 index 0000000..dd636e9 --- /dev/null +++ b/public/docs/docs/packages/utils/processors/list-sorter.md @@ -0,0 +1,93 @@ +# List Sorter + +A list sorter makes it possible to sort items from an array of objects using a chain-able query syntax. This feature +is built-into [Object Registries](/reference/registries/object-registries) via the `ObjectRegistry::query` method, +however it can also be used on raw arrays, as long as each item in the array has the same getters and setters you're +sorting by. This can be done using fully-qualified objects, or by simply converting arrays to objects, as shown below. + +```php +use \PHPNomad\Helpers\Processors\ListFilter; + +$iceCreamOrderItems = [ + (object) [ + 'customer' => (object)['name' => 'Alex', 'id' => 1], + 'scoops' => ['strawberry','chocolate'], + 'cone' => 'waffle', + 'price' => 629, + 'toppings' => ['cheese-crackers'] + ], + + (object) [ + 'customer' => (object)['name' => 'Devin', 'id' => 2], + 'scoops' => ['chocolate'], + 'cone' => 'chocolateWaffle', + 'price' => 429, + 'toppings' => ['sprinkles'] + ], + + (object) [ + 'customer' => (object)['name' => 'Kate', 'id' => 3], + 'scoops' => ['vanilla'], + 'cone' => 'basic', + 'price' => 429, + 'toppings' => [] + ], + + (object) [ + 'customer' => (object)['name' => 'Ben', 'id' => 4], + 'scoops' => ['strawberry','chocolate', 'vanilla'], + 'cone' => 'bowl', + 'price' => 899, + 'toppings' => ['sprinkles'] + ], +]; +``` + +## Usage + +Sort items by price +```php +$sorted = (new ListSorter($iceCreamOrderItems))->sortBy('price')->sort() +$sortedReverse = (new ListSorter($iceCreamOrderItems))->sortBy('price', Direction::Descending)->sort() +``` + +Nested object values are supported. Sort items by customer name. +```php +$sorted = (new ListSorter($iceCreamOrderItems))->sortBy('customer.name')->sort() +$sortedReverse = (new ListSorter($iceCreamOrderItems))->sortBy('customer.name', Direction::Descending)->sort() +``` + +## Custom Sorting Method + +The default sorting method provided in `ListSorter` is sufficient for most cases, however, if you need to create a custom sorting algorithm for it, this can be done by extending the `SortMethod` class. + +The example below creates a custom sorting method that makes it possible to sort items based on the number of items in the array. This allows us to sort the ice cream orders by the number of scoops. + +```php +use PHPNomad\Abstracts\SortMethod; +use \PHPNomad\Helpers\ObjectHelper; + +// First create the sorter. +class ArrayCountSorter extends SortMethod{ + + // The sort method is called on each item, and works much like usort, except it also includes the field name and the direction. + public function sort( object $a, object $b, string $field, Direction $direction ): int + { + // The spaceship operator will return -1, 0, or 1 based on the result. See PHP docs. + $result = count(ObjectHelper::pluck($a, $field)) <=> count(ObjectHelper::pluck($b, $field)); + + // Invert the result if it's descending, otherwise simply return the result as-is. + return $direction === Direction::Descending ? $result * -1 : $result; + } +} + +// Use the custom sorter method with scoops. +$sorted = (new ListSorter($iceCreamOrderItems)) + ->sortBy(field: 'scoops', method: ArrayCountSorter::class) + ->sort(); + +// Use the custom sorter method with scoops, only this time reverse it, Missy Elliott style. +$sortedReverse = (new ListSorter($iceCreamOrderItems)) + ->sortBy(field: 'scoops', direction: Direction::Descending, method: ArrayCountSorter::class) + ->sort(); +``` \ No newline at end of file From 05ddb9e69b9e3bc06597c82b62d87736e1c79588 Mon Sep 17 00:00:00 2001 From: Alex Standiford Date: Sun, 25 Jan 2026 11:15:59 -0500 Subject: [PATCH 2/2] progress --- .../initializers/event-binding.md | 21 +- .../initializers/event-listeners.md | 23 +- .../bootstrapping/initializers/facades.md | 10 +- .../datastores/getting-started-tutorial.md | 1 + .../bootstrapping/advanced-bootstrapping.md | 192 ---- .../creating-and-managing-initializers.md | 292 ------- .../initializers/event-binding.md | 251 ------ .../initializers/event-listeners.md | 274 ------ .../bootstrapping/initializers/facades.md | 301 ------- .../initializers/rest-controllers.md | 90 -- .../bootstrapping/introduction.md | 62 -- .../bootstrapping/platform-integrations.md | 140 --- .../datastores/getting-started-tutorial.md | 818 ------------------ .../core-concepts/datastores/introduction.md | 290 ------- .../datastores/models-and-identity.md | 677 --------------- public/docs/docs/index.md | 67 -- .../packages/database/caching-and-events.md | 575 ------------ .../database/database-service-provider.md | 473 ---------- ...identifiable-database-datastore-handler.md | 441 ---------- .../database/handlers/introduction.md | 218 ----- .../with-datastore-handler-methods.md | 496 ----------- .../date-created-factory.md | 171 ---- .../date-modified-factory.md | 213 ----- .../included-factories/foreign-key-factory.md | 266 ------ .../included-factories/introduction.md | 367 -------- .../included-factories/primary-key-factory.md | 136 --- .../docs/packages/database/introduction.md | 424 --------- .../docs/packages/database/junction-tables.md | 550 ------------ .../docs/packages/database/query-building.md | 578 ------------- .../database/table-schema-definition.md | 626 -------------- .../packages/database/tables/introduction.md | 269 ------ .../database/tables/junction-table-class.md | 73 -- .../packages/database/tables/table-class.md | 166 ---- .../packages/datastore/core-implementation.md | 604 ------------- .../packages/datastore/integration-guide.md | 485 ----------- .../interfaces/datastore-has-counts.md | 289 ------- .../interfaces/datastore-has-primary-key.md | 254 ------ .../interfaces/datastore-has-where.md | 292 ------- .../datastore/interfaces/datastore.md | 266 ------ .../datastore/interfaces/introduction.md | 196 ----- .../docs/packages/datastore/introduction.md | 309 ------- .../docs/packages/datastore/model-adapters.md | 540 ------------ .../packages/datastore/traits/introduction.md | 267 ------ .../traits/with-datastore-count-decorator.md | 217 ----- .../traits/with-datastore-decorator.md | 199 ----- .../with-datastore-primary-key-decorator.md | 204 ----- .../traits/with-datastore-where-decorator.md | 230 ----- public/docs/docs/packages/rest/controllers.md | 363 -------- .../docs/packages/rest/integration-guide.md | 175 ---- .../event-interceptor.md | 88 -- .../rest/interceptors/introduction.md | 125 --- .../docs/docs/packages/rest/introduction.md | 79 -- .../callback-middleware.md | 83 -- .../parse-jwt-middleware.md | 109 --- .../set-type-middleware.md | 73 -- .../validation-middleware.md | 136 --- .../packages/rest/middleware/introduction.md | 223 ----- .../included-validations/is-any.md | 201 ----- .../included-validations/is-email.md | 96 -- .../included-validations/is-greater-than.md | 112 --- .../included-validations/is-numeric.md | 121 --- .../included-validations/is-type.md | 271 ------ .../included-validations/keys-are-any.md | 129 --- .../packages/rest/validations/introduction.md | 182 ---- .../rest/validations/validation-set.md | 123 --- .../docs/docs/packages/utils/array-helper.md | 335 ------- .../utils/processors/array-processor.md | 76 -- .../packages/utils/processors/list-filter.md | 235 ----- .../packages/utils/processors/list-sorter.md | 93 -- .../config/exceptions/config-exception.md | 0 .../interfaces/config-file-loader-strategy.md | 0 .../config/interfaces/config-strategy.md | 0 .../config/interfaces/introduction.md | 0 .../packages/config/introduction.md | 0 .../config/services/config-service.md | 0 .../packages/config/services/introduction.md | 0 .../packages/database/caching-and-events.md | 6 + .../database/database-service-provider.md | 2 + ...identifiable-database-datastore-handler.md | 1 + public/docs/packages/database/introduction.md | 4 +- .../packages/datastore/traits/introduction.md | 1 + .../traits/with-datastore-decorator.md | 1 + .../with-datastore-primary-key-decorator.md | 1 + .../traits/with-datastore-where-decorator.md | 1 + .../packages/enum-polyfill/introduction.md | 0 .../packages/enum-polyfill/traits/enum.md | 0 .../enum-polyfill/traits/introduction.md | 0 .../interfaces/action-binding-strategy.md | 0 .../packages/event/interfaces/can-handle.md | 0 .../event/interfaces/event-strategy.md | 0 .../packages/event/interfaces/event.md | 0 .../event/interfaces/has-event-bindings.md | 0 .../event/interfaces/has-listeners.md | 0 .../packages/event/interfaces/introduction.md | 0 .../{docs => }/packages/event/introduction.md | 0 .../packages/event/patterns/best-practices.md | 0 .../logger/interfaces/introduction.md | 0 .../logger/interfaces/logger-strategy.md | 0 .../packages/logger/introduction.md | 0 .../logger/traits/can-log-exception.md | 0 .../packages/logger/traits/introduction.md | 0 .../mutator/interfaces/has-mutations.md | 0 .../mutator/interfaces/introduction.md | 0 .../mutator/interfaces/mutation-adapter.md | 0 .../mutator/interfaces/mutation-strategy.md | 0 .../mutator/interfaces/mutator-handler.md | 0 .../packages/mutator/interfaces/mutator.md | 0 .../packages/mutator/introduction.md | 0 .../mutator/traits/can-mutate-from-adapter.md | 0 .../packages/mutator/traits/introduction.md | 0 .../event-interceptor.md | 2 + .../rest/interceptors/introduction.md | 9 +- .../packages/singleton/introduction.md | 0 113 files changed, 78 insertions(+), 17281 deletions(-) delete mode 100644 public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/facades.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/introduction.md delete mode 100644 public/docs/docs/core-concepts/bootstrapping/platform-integrations.md delete mode 100644 public/docs/docs/core-concepts/datastores/getting-started-tutorial.md delete mode 100644 public/docs/docs/core-concepts/datastores/introduction.md delete mode 100644 public/docs/docs/core-concepts/datastores/models-and-identity.md delete mode 100644 public/docs/docs/index.md delete mode 100644 public/docs/docs/packages/database/caching-and-events.md delete mode 100644 public/docs/docs/packages/database/database-service-provider.md delete mode 100644 public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md delete mode 100644 public/docs/docs/packages/database/handlers/introduction.md delete mode 100644 public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md delete mode 100644 public/docs/docs/packages/database/included-factories/date-created-factory.md delete mode 100644 public/docs/docs/packages/database/included-factories/date-modified-factory.md delete mode 100644 public/docs/docs/packages/database/included-factories/foreign-key-factory.md delete mode 100644 public/docs/docs/packages/database/included-factories/introduction.md delete mode 100644 public/docs/docs/packages/database/included-factories/primary-key-factory.md delete mode 100644 public/docs/docs/packages/database/introduction.md delete mode 100644 public/docs/docs/packages/database/junction-tables.md delete mode 100644 public/docs/docs/packages/database/query-building.md delete mode 100644 public/docs/docs/packages/database/table-schema-definition.md delete mode 100644 public/docs/docs/packages/database/tables/introduction.md delete mode 100644 public/docs/docs/packages/database/tables/junction-table-class.md delete mode 100644 public/docs/docs/packages/database/tables/table-class.md delete mode 100644 public/docs/docs/packages/datastore/core-implementation.md delete mode 100644 public/docs/docs/packages/datastore/integration-guide.md delete mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md delete mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md delete mode 100644 public/docs/docs/packages/datastore/interfaces/datastore-has-where.md delete mode 100644 public/docs/docs/packages/datastore/interfaces/datastore.md delete mode 100644 public/docs/docs/packages/datastore/interfaces/introduction.md delete mode 100644 public/docs/docs/packages/datastore/introduction.md delete mode 100644 public/docs/docs/packages/datastore/model-adapters.md delete mode 100644 public/docs/docs/packages/datastore/traits/introduction.md delete mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md delete mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-decorator.md delete mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md delete mode 100644 public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md delete mode 100644 public/docs/docs/packages/rest/controllers.md delete mode 100644 public/docs/docs/packages/rest/integration-guide.md delete mode 100644 public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md delete mode 100644 public/docs/docs/packages/rest/interceptors/introduction.md delete mode 100644 public/docs/docs/packages/rest/introduction.md delete mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md delete mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md delete mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md delete mode 100644 public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md delete mode 100644 public/docs/docs/packages/rest/middleware/introduction.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-any.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-email.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-numeric.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/is-type.md delete mode 100644 public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md delete mode 100644 public/docs/docs/packages/rest/validations/introduction.md delete mode 100644 public/docs/docs/packages/rest/validations/validation-set.md delete mode 100644 public/docs/docs/packages/utils/array-helper.md delete mode 100644 public/docs/docs/packages/utils/processors/array-processor.md delete mode 100644 public/docs/docs/packages/utils/processors/list-filter.md delete mode 100644 public/docs/docs/packages/utils/processors/list-sorter.md rename public/docs/{docs => }/packages/config/exceptions/config-exception.md (100%) rename public/docs/{docs => }/packages/config/interfaces/config-file-loader-strategy.md (100%) rename public/docs/{docs => }/packages/config/interfaces/config-strategy.md (100%) rename public/docs/{docs => }/packages/config/interfaces/introduction.md (100%) rename public/docs/{docs => }/packages/config/introduction.md (100%) rename public/docs/{docs => }/packages/config/services/config-service.md (100%) rename public/docs/{docs => }/packages/config/services/introduction.md (100%) rename public/docs/{docs => }/packages/enum-polyfill/introduction.md (100%) rename public/docs/{docs => }/packages/enum-polyfill/traits/enum.md (100%) rename public/docs/{docs => }/packages/enum-polyfill/traits/introduction.md (100%) rename public/docs/{docs => }/packages/event/interfaces/action-binding-strategy.md (100%) rename public/docs/{docs => }/packages/event/interfaces/can-handle.md (100%) rename public/docs/{docs => }/packages/event/interfaces/event-strategy.md (100%) rename public/docs/{docs => }/packages/event/interfaces/event.md (100%) rename public/docs/{docs => }/packages/event/interfaces/has-event-bindings.md (100%) rename public/docs/{docs => }/packages/event/interfaces/has-listeners.md (100%) rename public/docs/{docs => }/packages/event/interfaces/introduction.md (100%) rename public/docs/{docs => }/packages/event/introduction.md (100%) rename public/docs/{docs => }/packages/event/patterns/best-practices.md (100%) rename public/docs/{docs => }/packages/logger/interfaces/introduction.md (100%) rename public/docs/{docs => }/packages/logger/interfaces/logger-strategy.md (100%) rename public/docs/{docs => }/packages/logger/introduction.md (100%) rename public/docs/{docs => }/packages/logger/traits/can-log-exception.md (100%) rename public/docs/{docs => }/packages/logger/traits/introduction.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/has-mutations.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/introduction.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/mutation-adapter.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/mutation-strategy.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/mutator-handler.md (100%) rename public/docs/{docs => }/packages/mutator/interfaces/mutator.md (100%) rename public/docs/{docs => }/packages/mutator/introduction.md (100%) rename public/docs/{docs => }/packages/mutator/traits/can-mutate-from-adapter.md (100%) rename public/docs/{docs => }/packages/mutator/traits/introduction.md (100%) rename public/docs/{docs => }/packages/singleton/introduction.md (100%) 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/docs/core-concepts/bootstrapping/advanced-bootstrapping.md b/public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md deleted file mode 100644 index 4043147..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/advanced-bootstrapping.md +++ /dev/null @@ -1,192 +0,0 @@ -# Advanced Bootstrapping Patterns - -When your application grows, you might need more sophisticated ways to organize how it starts up. This guide will show -you how to handle more complex bootstrapping scenarios in PHPNomad. - -## Breaking Things into Groups - -Just like you might organize your clothes into different drawers, you can organize your application's startup code into -logical groups. This makes everything easier to manage and understand. - -Here's a simple example: - -```php -// Database-related initialization -$databaseBootstrapper = new Bootstrapper( - $container, - new MySqlInitializer(), - new TableCreateInitializer(), - new MigrationInitializer() -); - -// Authentication-related initialization -$authBootstrapper = new Bootstrapper( - $container, - new UserInitializer(), - new PermissionInitializer(), - new SessionInitializer() -); - -// Run them in sequence -$databaseBootstrapper->load(); -$authBootstrapper->load(); -``` - -## Using Factories for Reusability - -Sometimes you'll want to reuse the same group of initializers in different places. Factories are a great way to package -up these groups so they're easy to reuse: - -```php -class DatabaseBootstrapperFactory -{ - protected Container $container; - - public function __construct(Container $container) - { - $this->container = $container; - } - - public function create(): Bootstrapper - { - return new Bootstrapper( - $this->container, - new MySqlInitializer(), - new TableCreateInitializer(), - new MigrationInitializer() - ); - } -} - -// Using the factory -$factory = new DatabaseBootstrapperFactory($container); -$bootstrapper = $factory->create(); -$bootstrapper->load(); -``` - -## Conditional Bootstrapping - -Sometimes you need different initialization based on your environment or other conditions. Here's how you might handle -that: - -```php -class ConditionalBootstrapper -{ - public function load(string $environment) - { - $container = new Container(); - - // Core bootstrapping that always runs - $core = new Bootstrapper( - $container, - new CoreInitializer(), - new ConfigInitializer() - ); - $core->load(); - - // Environment-specific bootstrapping - if ($environment === 'development') { - $dev = new Bootstrapper( - $container, - new DebugInitializer(), - new MockDataInitializer() - ); - $dev->load(); - } - - if ($environment === 'production') { - $prod = new Bootstrapper( - $container, - new CacheInitializer(), - new MonitoringInitializer() - ); - $prod->load(); - } - } -} -``` - -## Real-World Example: Plugin System - -Here's a practical example showing how you might bootstrap a plugin system: - -```php -class PluginBootstrapperFactory -{ - protected Container $container; - - public function __construct(Container $container) - { - $this->container = $container; - } - - public function createForPlugin(string $pluginName): Bootstrapper - { - // Core plugin bootstrapping - $bootstrapper = new Bootstrapper( - $this->container, - new PluginCoreInitializer($pluginName), - new PluginAssetsInitializer($pluginName) - ); - - // Add optional features based on plugin configuration - if ($this->hasDatabase($pluginName)) { - $bootstrapper = new Bootstrapper( - $this->container, - $bootstrapper, - new PluginDatabaseInitializer($pluginName) - ); - } - - if ($this->hasRestApi($pluginName)) { - $bootstrapper = new Bootstrapper( - $this->container, - $bootstrapper, - new PluginRestInitializer($pluginName) - ); - } - - return $bootstrapper; - } - - protected function hasDatabase(string $pluginName): bool - { - // Check if plugin needs database features - return true; // Simplified for example - } - - protected function hasRestApi(string $pluginName): bool - { - // Check if plugin needs REST API features - return true; // Simplified for example - } -} -``` - -## Tips for Complex Bootstrapping - -1. **Keep It Organized**: Group related initializers together. This makes your code easier to understand and maintain. - -2. **Use Clear Names**: Give your bootstrapper groups and factories names that clearly explain what they do. - -3. **Stay Flexible**: Design your bootstrapping code so it's easy to add or remove features without breaking things. - -4. **Think About Order**: Sometimes the order of initialization matters. Group your bootstrappers accordingly and - document any important ordering requirements. - -## Common Patterns to Avoid - -1. **Don't Mix Concerns**: Keep platform-specific code separate from your core bootstrapping logic. - -2. **Avoid Global State**: Don't rely on global variables or static properties for bootstrapping configuration. - -3. **Don't Over-Engineer**: Start simple and add complexity only when you need it. - -## Summary - -Advanced bootstrapping patterns help you manage complex applications while keeping your code organized and maintainable. -By using groups, factories, and conditional loading, you can create a flexible system that grows with your needs while -staying clean and understandable. - -Remember: The goal is to make your initialization process clear and maintainable, not to make it clever or complex. When -in doubt, choose the simpler approach. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md b/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md deleted file mode 100644 index 1caf345..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/creating-and-managing-initializers.md +++ /dev/null @@ -1,292 +0,0 @@ -# Creating and Managing Initializers - -Initializers are the building blocks of a PHPNomad application. Think of them as specialized workers, each with a -specific job to do when your application starts up. Each initializer handles one aspect of getting your application -ready to run - whether that's setting up database connections, loading configuration files, or connecting to external -services. - -## What Makes Up an Initializer? - -At its simplest, an initializer is a PHP class that implements one or more special interfaces. These interfaces tell -PHPNomad what the application can do: - -```php -class MyInitializer implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - // Define your class bindings here - MyConcreteClass::class => MyInterface::class - ]; - } -} -``` - -### Core Interfaces - -PHPNomad supports several interfaces that give initializers different capabilities: - -- `HasClassDefinitions`: For binding interfaces to concrete implementations -- `HasEventBindings`: For setting up event listeners and transformers -- `HasListeners`: For registering event listeners -- `Loadable`: For running code during initialization -- `CanSetContainer`: Gives your initializer access to the dependency container - -## Types of Initializers - -### Shared Initializers - -These initializers contain core business logic that works anywhere. They should never know about specific platforms - -their job is to set up the fundamental pieces of your application: - -```php -class EmailServiceInitializer implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - // Notice how this binds generic email interfaces - // with no knowledge of any specific platform - SmtpMailer::class => EmailStrategy::class, - EmailTemplateEngine::class => TemplateRenderer::class - ]; - } -} -``` - -### Platform Integration Initializers - -These initializers handle adapting your application for specific platforms. Instead of adding platform checks to our -shared initializers, we create separate initializers that focus solely on platform integration: - -```php -class WordPressEmailIntegration implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - // This initializer handles WordPress-specific bindings - WordPressMailer::class => EmailStrategy::class - ]; - } -} -``` - -### Event-Driven Initializers - -Many initializers work with events to set up listeners and bindings: - -```php -class UserEventsInitializer implements HasEventBindings -{ - public function getEventBindings(): array - { - return [ - UserCreated::class => [ - 'user_register', - ['transformer' => function($userId) { - return new UserCreated(new User($userId)); - }] - ] - ]; - } -} -``` - -### REST Initializers - -For applications exposing REST APIs, initializers can register controllers: - -```php -class ApiInitializer implements HasControllers -{ - public function getControllers(): array - { - return [ - UserController::class, - ProductController::class - ]; - } -} -``` - -## Best Practices - -### Keep It Focused - -Each initializer should do one thing and do it well. If you find your initializer handling multiple unrelated tasks, -it's time to split it up. - -Good: - -```php -class EmailServiceInitializer implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - SmtpMailer::class => EmailService::class - ]; - } -} -``` - -Not so good: - -```php -class ServiceInitializer implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - SmtpMailer::class => EmailService::class, - MySqlDatabase::class => Database::class, - RedisCache::class => CacheService::class - // Too many unrelated services! - ]; - } -} -``` - -### Handle Dependencies Wisely - -Let the container manage dependencies instead of creating them directly: - -```php -class UserServiceInitializer implements Loadable, CanSetContainer -{ - use HasSettableContainer; - - public function load(): void - { - // Let the container provide the dependency - $userService = $this->container->get(UserService::class); - $userService->initialize(); - } -} -``` - -## Common Patterns - -### Setting Up Services - -Register your services with the container: - -```php -class ServiceInitializer implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - // Single binding - ConcreteService::class => ServiceInterface::class, - - // Multiple interfaces - DatabaseService::class => [ - QueryInterface::class, - ConnectionInterface::class - ] - ]; - } -} -``` - -### Event Listeners - -Set up event listeners in your initializer: - -```php -class EventInitializer implements HasListeners -{ - public function getListeners(): array - { - return [ - UserCreated::class => UserCreatedHandler::class, - OrderPlaced::class => [ - SendOrderConfirmation::class, - UpdateInventory::class - ] - ]; - } -} -``` - -### Mutations - -Register mutation handlers for transforming data: - -```php -class DataMutationInitializer implements HasMutations -{ - public function getMutations(): array - { - return [ - UserData::class => [ - 'sanitize_user_input', - 'validate_user_data' - ] - ]; - } -} -``` - -## Things to Avoid - -1. **Don't Mix Platform-Specific Code**: Keep your shared initializers platform-agnostic. -2. **Avoid Direct Platform Dependencies**: Use interfaces instead of concrete platform classes. -3. **Don't Overuse Loadable::load**: Save it for truly necessary initialization tasks. -4. **Keep Initializers Small**: If an initializer is doing too much, split it up. - -## Real-World Example - -Here's a complete example of setting up authentication in a way that works across platforms: - -```php -// Core authentication setup -class AuthenticationInitializer implements HasClassDefinitions, Loadable, CanSetContainer -{ - use HasSettableContainer; - - public function getClassDefinitions(): array - { - return [ - // Core authentication services - AuthenticationService::class => AuthenticationInterface::class, - TokenGenerator::class => TokenGeneratorInterface::class, - - // Multiple interface bindings - UserRepository::class => [ - UserRepositoryInterface::class, - IdentityStoreInterface::class - ] - ]; - } - - public function load(): void - { - // One-time setup tasks - $authService = $this->container->get(AuthenticationInterface::class); - $authService->initialize(); - } -} - -// WordPress-specific authentication integration -class WordPressAuthIntegration implements HasClassDefinitions -{ - public function getClassDefinitions(): array - { - return [ - WordPressAuthProvider::class => AuthProviderInterface::class - ]; - } -} -``` - -Remember, good initializers are like good tools - they do one job well, work reliably, and make your life easier. By -following these patterns and practices, you'll build a foundation that's easy to maintain and adapt as your needs -change. - -The key to success with PHPNomad initializers is maintaining a clear separation between your core business logic and -platform-specific integrations. This separation allows your application to remain truly portable while still integrating -smoothly with any platform you need to support. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md b/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md deleted file mode 100644 index 4c67675..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/initializers/event-binding.md +++ /dev/null @@ -1,251 +0,0 @@ -# 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 -them as translators that help your code communicate with different platforms (like WordPress, Laravel, or your own -custom system) without needing to know their specific "language". - -## Why Use Event Bindings? - -The beauty of event bindings is that they keep your core application code clean and portable. Instead of embedding -platform-specific code throughout your application, you create a single place where your system's events connect to the -platform. - -For example, let's say you have an e-commerce application and want to track when someone uses a coupon code. Rather than -spreading WordPress-specific code everywhere, you can use event bindings to cleanly connect WordPress's coupon events to -your system. - -## How Event Bindings Work - -Event bindings consist of three main parts: - -1. Your application's event (like `CouponApplied` or `ReportCreated`) -2. The platform's action or event to listen for -3. A transformer that converts the platform's data into your application's event - -Here's a basic example: - -```php -class WooCommerceIntegration implements HasEventBindings -{ - public function getEventBindings(): array - { - return [ - // Your event => Platform event configuration - CouponApplied::class => [ - [ - 'action' => 'woocommerce_applied_coupon', - 'transformer' => function($couponCode) { - return new CouponApplied($couponCode); - } - ] - ] - ]; - } -} -``` - -In this example: - -- `CouponApplied` is your application's event -- `woocommerce_applied_coupon` is WooCommerce's action -- The transformer function converts WooCommerce's coupon code into your `CouponApplied` event - -## Advanced Usage: Multiple Bindings - -Sometimes you might want to listen for the same event from different sources. You can do this by returning an array of -bindings: - -```php -public function getEventBindings(): array -{ - return [ - OrderCreated::class => [ - // Listen for WooCommerce orders - [ - 'action' => 'woocommerce_order_status_completed', - 'transformer' => [$this->wooCommerceTransformer, 'toOrderCreated'] - ], - // Also listen for Easy Digital Downloads orders - [ - 'action' => 'edd_complete_purchase', - 'transformer' => [$this->eddTransformer, 'toOrderCreated'] - ] - ] - ]; -} -``` - -## Working with Transformers - -Transformers are functions that: - -1. Receive data from the platform's event -2. Convert that data into your application's format -3. Return either your event object or null - -If a transformer returns null, no event is triggered. This is useful for conditional events: - -```php -'transformer' => function($post_id, $post) { - // Only create event for published posts - if ($post->post_status !== 'publish') { - return null; - } - - return new PostPublished($post_id); -} -``` - -## Best Practices - -1. **Keep Transformers Clean**: Transformers should focus only on converting data. Heavy processing should happen - elsewhere. - -2. **Use Service Classes**: For complex transformations, create dedicated service classes instead of inline functions: - -```php -'transformer' => [$this->postTransformerService, 'toPostEvent'] -``` - -3. **Handle Errors Gracefully**: Transformers should handle missing or invalid data without crashing: - -```php -'transformer' => function($data) { - try { - return new MyEvent($data); - } catch (Exception $e) { - // Log error, return null to skip event - return null; - } -} -``` - -4. **Document Platform Events**: Always document which platform events you're binding to and what data they provide: - -```php -// Binds to 'save_post' WordPress action -// @param int $post_id The ID of the saved post -// @param WP_Post $post The post object -// @param bool $update Whether this is an update -``` - -## Real-World Example: Reports System - -Here's a complete example showing how to bind a reports system to WordPress: - -```php -class WordPressReportingIntegration implements HasEventBindings -{ - private ReportTransformer $transformer; - - public function __construct(ReportTransformer $transformer) - { - $this->transformer = $transformer; - } - - public function getEventBindings(): array - { - return [ - // Handle new reports - ReportCreated::class => [ - [ - 'action' => 'save_post', - 'transformer' => function($postId, $post, $update) { - // Only handle new reports - if ($update || $post->post_type !== 'report') { - return null; - } - - return $this->transformer->toReportCreated($post); - } - ] - ], - - // Handle report updates - ReportUpdated::class => [ - [ - 'action' => 'save_post', - 'transformer' => function($postId, $post, $update) { - // Only handle report updates - if (!$update || $post->post_type !== 'report') { - return null; - } - - return $this->transformer->toReportUpdated($post); - } - ] - ] - ]; - } -} -``` - -The beauty of this approach is that your internal application logic doesn't need to know about how events are emitted, -it just needs to listen for the nomadic events and it can do the actions from there. So if you needed to send an email -when a report is created, you could create an [event listener](event-listeners) in your a platform-agnostic -initializer that would fire when the report is published. - -In doing so, you've completely decoupled the platform from your application - as long as the platform knows how to emit -the right events, and can translate them appropriately, it's compatible with your system. - -Notice that the code below does not call any WordPress code whatsoever, and yet because of our event binding, we know -it will run when the report post is published. If we put this on a different platform, it's just a matter of sending -the `ReportCreated` event at the right time, and this would just work. - -```php -class SendReportToCustomer implements CanHandle -{ - public function __construct(protected EmailStrategy $emailer){} - - public function handle(Event $event): void - { - $this->emailer->send(/** Include email details here. */) - } -} - -class ApplicationInitializer implements HasListeners -{ - public function getListeners(): void - { - return [ - ReportCreated::class => SendReportToCustomer::class - ]; - } -} -``` - -## Summary - -Event bindings are a powerful way to keep your application's core logic clean while still integrating smoothly with any -platform. They act as a bridge between your application and the platform, translating events back and forth without -cluttering your main code with platform-specific details. - -Remember: - -- Keep transformers simple and focused -- Handle errors gracefully -- 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. - ---- - -## 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/docs/core-concepts/bootstrapping/initializers/event-listeners.md b/public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md deleted file mode 100644 index ee5e20a..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/initializers/event-listeners.md +++ /dev/null @@ -1,274 +0,0 @@ -# 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. - -Events are the bread and butter of PHPNomad, and it leans heavily on using them to keep your system decoupled from the -platform. In-fact, most of the time, you'll find that your entire system will boil down to a series of events that fire, -and then some logic that happens when those actions happen. If you can get very comfortable with thinking about -event-driven approaches, you'll come to love how PHPNomad works. - -## The Basics of Event Listeners - -At their core, event listeners are just special classes that "listen" for specific events in your application. When -those events happen, the listeners automatically run their code. Each listener is instantiated through PHPNomad's -container, which means you can easily inject any dependencies you need. - -Here's a simple example: - -```php -use PHPNomad\Email\Interfaces\EmailStrategy; -use PHPNomad\Events\Interfaces\Event; - -class UserRegistered implements Event -{ - public function __construct(public readonly string $email, public readonly string $name){} - - - public static function getId(): string - { - return 'user_registered'; - } -} - -/** - * @extends CanHandle - */ -class WelcomeEmailListener implements CanHandle -{ - public function __construct(private EmailStrategy $emailService) - { - // PHPNomad automatically injects the email service - } - - public function handle(Event $event): void - { - // Send a welcome email when a new user registers - $this->emailService->send( - [$event->email], - 'Welcome to Our Platform!', - 'welcome-email-template', - ['username' => $event->name] - ); - } -} -``` - -## Setting Up Listeners - -You can set up listeners in your initializer class. This is where you tell PHPNomad "when this happens, run this code": - -```php -class MyInitializer implements HasListeners -{ - public function getListeners(): array - { - return [ - // When a user gets registered, send a welcome email. - UserRegistered::class => WelcomeEmailListener::class, - - // When an order is placed, send the order confirmation and also update the inventory - OrderPlaced::class => [ - SendOrderConfirmation::class, - UpdateInventory::class - ] - ]; - } -} -``` - -In this example: - -- When a user registers, the container creates a WelcomeEmailListener (injecting the EmailStrategy) and runs its handle - method -- When an order is placed, multiple listeners are created and executed in sequence - -Now, when you broadcast the `UserRegistered` event, the `WelcomeEmailListener` will fire. - -```php -Event::broadcast(new UserRegistered('alex@fake.email','Alex')); -``` - -Alternatively, you might need to create an event binding that translates a platform event (such as a WordPress hook) to -the `UserRegistered` event. Check out [Event Binding](event-binding) for more context on that. - -## Why Use Event Listeners? - -Event listeners help keep your code organized and flexible. Instead of putting all your logic in one place, you can -spread it out into focused, manageable pieces. This brings several benefits: - -1. **Easier to Maintain**: Each listener handles one specific task, making the code simpler to understand and update -2. **More Flexible**: You can add or remove listeners without changing the rest of your code -3. **Better Organization**: Related code stays together, making it easier to find and modify -4. **Dependency Management**: PHPNomad's container handles all dependencies automatically - -## Real World Example - -Let's look at a practical example. Imagine you're running an online store and someone places an order: - -```php -use PHPNomad\Email\Interfaces\EmailStrategy; - -/** - * @extends CanHandle - */ -class SendOrderConfirmation implements CanHandle -{ - public function __construct( - private EmailStrategy $emailService - ) {} - - public function handle(Event $event): void - { - $order = $event->getOrder(); - - $this->emailService->send( - [$order->getCustomerEmail()], - 'Order Confirmation', - 'order-confirmation-template', - ['orderNumber' => $order->getId()] - ); - } -} - -/** - * @extends CanHandle - */ -class UpdateInventory implements CanHandle -{ - public function __construct( - private InventoryService $inventory - ) { - // Dependencies are injected automatically - } - - public function handle(Event $event): void - { - $order = $event->getOrder(); - - // Update inventory - $this->inventory->reduceStock($order->getItems()); - } -} -``` - -## Best Practices - -1. **Use Constructor Injection**: Let PHPNomad's container manage your dependencies by declaring them in your - constructor: - -```php -class WelcomeEmailListener implements CanHandle -{ - public function __construct( - private EmailStrategy $emailService, - private LoggerStrategy $logger - ) { - } - - public function handle(Event $event): void - { - try { - $this->emailService->send( - [$event->getUser()->getEmail()], - 'Welcome!', - 'welcome-template', - ['username' => $event->getUser()->getName()] - ); - } catch (Exception $e) { - $this->logger->error("Failed to send welcome email: " . $e->getMessage()); - } - } -} -``` - -2. **Keep Listeners Focused**: Each listener should do one job well. If you find a listener doing too many things, - consider splitting it into multiple listeners. - -3. **Use Interfaces**: Always type-hint interfaces rather than concrete classes in your constructor. This keeps your - code flexible and testable. - -## Common Scenarios - -Here are some typical situations where event listeners are particularly useful: - -1. **User Actions**: - -```php -class UserProfileCreationListener implements CanHandle -{ - public function __construct( - private ProfileService $profiles, - private LoggerStrategy $logger - ) { - } - - public function handle(Event $event): void - { - $this->profiles->createDefaultProfile($event->getUser()); - } -} -``` - -2. **Order Processing**: - -```php -class OrderPaymentListener implements CanHandle -{ - public function __construct( - private PaymentService $payments, - private LoggerStrategy $logger - ) { - } - - public function handle(Event $event): void - { - $this->payments->processPayment($event->getOrder()); - } -} -``` - -## Troubleshooting - -If your listeners aren't working as expected, check these common issues: - -1. **Is the Event Being Triggered?** - Make sure the event is actually being broadcast: - ```php - Event::broadcast(new UserRegistered($user)); - ``` - -2. **Is the Listener Registered?** - Verify your listener is properly registered in your initializer. - -## Conclusion - -Event listeners in PHPNomad provide a clean, maintainable way to handle application events while taking full advantage -of dependency injection. By letting the container manage your dependencies, you create code that's not only easier to -maintain but also easier to test and modify. Each listener can focus on its specific task while having easy access to -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. - ---- - -## 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/docs/core-concepts/bootstrapping/initializers/facades.md b/public/docs/docs/core-concepts/bootstrapping/initializers/facades.md deleted file mode 100644 index 2b5e8ef..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/initializers/facades.md +++ /dev/null @@ -1,301 +0,0 @@ -# Creating and Using Facades in PHPNomad - -While the Facade pattern was popularized by Laravel, PHPNomad's implementation takes a slightly different approach that -aligns with its platform-agnostic philosophy. Instead of being deeply integrated with a service container, PHPNomad's -Facades act as singleton wrappers that make your services easier to use across different platforms. - -## What is a Facade? - -Think of a Facade as a simple front door to a complex building. Instead of navigating through all the rooms and -hallways, you just walk through the front door to get where you need to go. In code terms, a Facade provides a clean, -static interface to access functionality that might otherwise require more complex setup or dependency management. - -## Creating Your First Facade - -Here's how to create a basic Facade: - -```php -use PHPNomad\Facade\Abstracts\Facade; -use PHPNomad\Singleton\Traits\WithInstance; - -/** - * @method static void info(string $message) - * @method static void error(string $message, array $context = []) - */ -class Logger extends Facade -{ - use WithInstance; // Important! This makes the Facade a singleton - - protected function abstractInstance(): string - { - return LoggerStrategy::class; - } -} -``` - -## Using Your Facade - -Once created, using your Facade is straightforward: - -```php -// No need to instantiate - just use it! -Logger::instance()->getContainedInstance()->info("Hello from PHPNomad!"); - -// Or better yet, add static methods to make it cleaner: -Logger::info("Hello from PHPNomad!"); -``` - -## Key Differences from Laravel - -While inspired by Laravel's Facades, PHPNomad's implementation has some key differences: - -1. **Singleton Pattern**: PHPNomad Facades use the `WithInstance` trait to ensure only one instance exists -2. **Platform Independence**: PHPNomad's Facades are designed to work across any PHP platform -3. **Explicit Methods**: Instead of magic methods, PHPNomad encourages explicitly defining the methods you want to - expose - -## Registering Facades - -Facades need to be registered with PHPNomad through an initializer: - -```php -use PHPNomad\Di\Interfaces\CanSetContainer; -use PHPNomad\Di\Traits\HasSettableContainer; -use PHPNomad\Facade\Interfaces\HasFacades; - -class CoreInitializer implements HasFacades, HasClassDefinitions, CanSetContainer -{ - use HasSettableContainer; - - public function getFacades(): array - { - return [ - Logger::instance(), // Note the instance() call - Cache::instance(), - Event::instance() - ]; - } - - public function getClassDefinitions(): array - { - return [ - LogService::class => LoggerStrategy::class, - RedisCache::class => CacheStrategy::class, - EventDispatcher::class => EventStrategy::class - ]; - } -} -``` - -## A Complete Example - -Here's a complete example showing how to create and use a Cache Facade: - -```php -/** - * 1. Create the Facade - */ -class Cache extends Facade -{ - use WithInstance; - - protected function abstractInstance(): string - { - return CacheStrategy::class; - } - - public static function get(string $key): mixed - { - return static::instance()->getContainedInstance()->get($key); - } - - public static function set(string $key, mixed $value, ?int $ttl = null): void - { - static::instance()->getContainedInstance()->set($key, $value, $ttl); - } - - public static function has(string $key): bool - { - return static::instance()->getContainedInstance()->has($key); - } -} - -/** - * 2. Create the Initializer - */ -class CacheInitializer implements HasFacades, HasClassDefinitions, CanSetContainer -{ - use HasSettableContainer; - - public function getFacades(): array - { - return [ - Cache::instance() // Single instance - ]; - } - - public function getClassDefinitions(): array - { - return [ - RedisCache::class => CacheStrategy::class - ]; - } -} - -/** - * 3. Bootstrap your application - */ -$container = new Container(); -$bootstrapper = new Bootstrapper( - $container, - new CacheInitializer() -); -$bootstrapper->load(); - -/** - * 4. Use the Facade anywhere in your code - */ -Cache::set('my-key', 'my-value', 3600); -$value = Cache::get('my-key'); -``` - -## ⚠️ Important Warning About Facades - -Before diving into how to create and use Facades, it's crucial to understand their intended purpose and limitations: -Facades in PHPNomad are primarily designed for: - -* Creating public APIs that third-party developers can easily consume -* Providing simple access points for developers integrating with your application from outside the PHPNomad context -* Situations where dependency injection isn't practical (like static WordPress hooks or template files) - -They are not intended for: - -* Regular application development within your PHPNomad codebase -* Replacing proper dependency injection -* Avoiding proper service architecture - -If you find yourself frequently using Facades within your own application code, this is usually a sign that you should -reconsider your approach. Instead, use dependency injection and proper service architecture for better maintainability, -testability, and cleaner code design. - -Example of Proper vs. Improper Use -```php -// 🚫 Don't do this in your application code -class UserService { - public function createUser(array $data) { - Logger::info("Creating user..."); // Directly using Facade - // ... rest of the code - } -} - -// ✅ Do this instead -class UserService { - private LoggerStrategy $logger; - - public function __construct(LoggerStrategy $logger) { - $this->logger = $logger; // Proper dependency injection - } - - public function createUser(array $data) { - $this->logger->info("Creating user..."); - // ... rest of the code - } -} - -// ✅ Facades are great for third-party integration points -add_action('init', function() { - Logger::info("WordPress integration point"); -}); -``` - -## Best Practices - -### 1. Always Use WithInstance - -Every Facade must use the `WithInstance` trait to ensure the singleton pattern: - -```php -class AnyFacade extends Facade -{ - use WithInstance; // Don't forget this! -} -``` - -### 2. Keep Facades Focused - -Each Facade should represent a single service: - -```php -// Good - focused on caching -class Cache extends Facade -{ - use WithInstance; - - protected function abstractInstance(): string - { - return CacheStrategy::class; - } -} - -// Not good - mixing concerns -class Utilities extends Facade -{ - use WithInstance; - - protected function abstractInstance(): string - { - return UtilityService::class; // Too many responsibilities - } -} -``` - -### 3. Group Related Facades in Initializers - -Keep related Facades together in their initializers: - -```php -class DatabaseInitializer implements HasFacades -{ - public function getFacades(): array - { - return [ - Query::instance(), - Schema::instance(), - Migration::instance() - ]; - } -} -``` - -## When to Use Facades - -Facades are great for: - -- Services that need global access -- Core functionality used throughout your application -- Cross-cutting concerns like logging, caching, and events - -Consider alternatives when: - -- The service requires complex configuration -- You're writing unit tests (use dependency injection) -- The functionality is specific to a single module - -## Common Pitfalls to Avoid - -1. **Forgetting WithInstance**: Always include the `WithInstance` trait in your Facades -2. **Overusing Facades**: Not everything needs to be a Facade -3. **Complex Logic in Facades**: Keep Facade methods simple and delegate complex logic to the service -4. **Bypassing Dependency Injection**: Use Facades for convenience, not to avoid proper dependency management - -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. - ---- - -## 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/docs/core-concepts/bootstrapping/initializers/rest-controllers.md b/public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md deleted file mode 100644 index 1bfb5c3..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/initializers/rest-controllers.md +++ /dev/null @@ -1,90 +0,0 @@ -# REST Controllers in PHPNomad - -REST controllers define an **endpoint**, an **HTTP method**, and **how to produce a response**—and they stay portable -across hosts. - -Instead of registering routes inline, PHPNomad discovers them through **Initializers** that implement **`HasControllers` -**. This keeps your API definitions decoupled from the host and mirrors how you already register event listeners -with `HasListeners`. - -## The Basics: How registration works - -* **Controller**: a small class implementing, at minimum, `Controller` -* **Initializer** implementing `HasControllers`: returns a list of controllers to load. -* **Boostrapper** configured to utilize the initializer. - See [Creating and Managing Initializers](/core-concepts/bootstrapping/creating-and-managing-initializers) for more - information - -The container will construct these controllers (resolving dependencies), and the runtime will register their routes from -the controller contracts. - -## Minimal Controller - -This is a very basic example of a controller, and does not include middleware or validations. In your production -controllers, you’ll often also implement `HasMiddleware` / `HasValidations`. See real-world examples -where `getEndpoint()`, `getMethod()`, and `getValidations()` are used together. - -See the [Rest](/packages/rest/introduction) package documentation for fuller examples. - -```php -response - ->setStatus(200) - ->setJson(['message' => 'Hello from PHPNomad REST']); - } -} -``` - -## Registering Controllers via an Initializer - -Your initializer simply **implements `HasControllers`** and returns the controllers to load. -The container instantiates them; the runtime reads `getEndpoint()` / `getMethod()` to wire routes. - -```php -|Controller> - */ - public function getControllers(): array - { - return [ - HelloController::class, - // CreateWidget::class, - // GetWidget::class, - // ListWidgets::class, - ]; - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/introduction.md b/public/docs/docs/core-concepts/bootstrapping/introduction.md deleted file mode 100644 index 1eb4697..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/introduction.md +++ /dev/null @@ -1,62 +0,0 @@ -# Introduction to PHPNomad Bootstrapping - -The bootstrapper is where everything begins in a PHPNomad application. It's responsible for: - -- Setting up your application -- Loading all the pieces in the right order -- Making sure everything can talk to each other - -Here's what it looks like in its simplest form: - -```php -$container = new Container(); -$bootstrapper = new Bootstrapper( - $container, - new MyAppSetup(), // A custom initializer created for your application - new DatabaseSetup(), // An initializer that ensures the system knows how to query data - // Other initializers loaded here as needed, such as a WordPress integration, or various Symfony implementations. -); -$bootstrapper->load(); -``` - -## Key Ideas Behind PHPNomad - -### Your Code Comes First - -Instead of building your code to fit into a specific platform or system, PHPNomad lets you build your application the -way you want. Then, the platform adapts to work with your code. This makes it much easier if you ever need to move your -code to a different system. - -### Breaking Things into Pieces - -Rather than putting all your setup code in one place, PHPNomad encourages you to break it into smaller, focused pieces -called initializers. Each initializer has one job, making your code easier to understand and change. - -### Keeping Things Separate - -Your application's core logic stays separate from platform-specific code. This means you can change how your application -works with different platforms without having to rewrite your main application code. - -## How Setup Works - -When you start your application: - -1. First, PHPNomad creates a central hub (we call it a container) that helps different parts of your code find each - other -2. Then, it runs through your setup steps one by one -3. Finally, it locks everything down so nothing can accidentally change how things are connected - -This process helps keep your application stable and predictable. - -## Built for Change - -PHPNomad is designed to make your code flexible: - -- Your core application code doesn't need to know about the platform it's running on -- Platform-specific code is kept separate and can be easily changed -- You can move your application between different systems without rewriting everything - -This means you can start building for one platform today, but keep your options open for the future. Most of your code -will work just fine if you decide to move it somewhere else later. - -You're not locked into one way of doing things - and that's the whole point of PHPNomad. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md b/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md deleted file mode 100644 index f044bf5..0000000 --- a/public/docs/docs/core-concepts/bootstrapping/platform-integrations.md +++ /dev/null @@ -1,140 +0,0 @@ -# Introduction to Platform-Specific Integrations - -When building applications with PHPNomad, you might need to integrate with platforms like WordPress, Laravel, or -Symfony. These platforms often come with unique requirements, but PHPNomad’s philosophy ensures your core logic remains -flexible and portable. The goal isn’t to lock your application into a specific platform but to allow your application to -adapt seamlessly, as if it’s just visiting. - -PHPNomad takes a "nomadic" approach: instead of the platform dictating how your application is built, your application -integrates with the platform through lightweight, modular adapters. This perspective keeps your application’s core logic -clean and reusable, whether you’re bootstrapping a WordPress plugin or setting up a Laravel service provider, or maybe -even building your own homegrown MVC system that uses PHPNomad. - -## Core Principles for Platform-Specific Integrations - -To make integrations easy and maintainable, PHPNomad emphasizes separating shared application logic from -platform-specific details. A shared initializer handles logic that works across platforms, while a platform-specific -initializer adapts to the unique needs of a given environment. - -## Common Use Cases - -A typical integration involves adapting the application’s bootstrapping process to a platform’s specific entry points. -For WordPress, this might include setting up actions and filters, or binding WordPress actions with events that are -inside your application. - -```php - -class WordPressSystemInitializer implements HasEventBindings, HasListeners -{ - /** - * Bind WordPress hooks to trigger Ready event. - * - * @return array - */ - public function getEventBindings(): array - { - return [ - // Bind WordPress 'init' hook to trigger the Ready event. - Ready::class => [ - ['action' => 'init', 'transformer' => function () { - $ready = null; - - if (!self::$initRan) { - $ready = new Ready(); - self::$initRan = true; - } - - return $ready; - }] - ], - ]; - } - - /** - * Register the listener for the Ready event. - * - * @return array - */ - public function getListeners(): array - { - return [ - // Instruct the system to register custom post types using a custom RegisterCustomPostTypes::class handler. - Ready::class => [RegisterCustomPostTypes::class] - ]; - } -} -``` - -The plugin might look like: - -```php -/** - * Plugin Name: Demo Plugin - */ - -require_once plugin_dir_url(__FILE__, 'vendor/autoload.php'); - -$bootstrapper = new Bootstrapper($container, [ - new WordPressInitializer(), // Provided by PHPNomad, making most functionality to work in WordPress context. - new ConfigInitializer(['config.json']), - new WordPressSystemInitializer(), - new ApplicationInitializer() // Shared application logic that's shared between platforms. -]); - -// Run the bootstrapper to load all initializers. -$bootstrapper->load(); -``` - -In a homegrown setup, you might not need the binding since you have control of the entire request, and can probably just -emit the event after the system is set up. - -```php -// index.php - -require_once 'vendor/autoload.php'; - -// Set up the container and initialize the bootstrapper. -$container = new Container(); -$bootstrapper = new Bootstrapper($container, [ - new ConfigInitializer(['config.json']), - new MySqlDatabaseInitializer(), - new RestApiInitializer(), - new SymfonyEventInitializer(), - new HomegrownSystemInitializer(), - new ApplicationInitializer() // Business logic that's shared between platforms. -]); - -// Run the bootstrapper to load all initializers. -$bootstrapper->load(); - -// Broadcast the Ready event directly. -Event::broadcast(new Ready()); -``` - -These integrations allow the application to interface with the platform without embedding platform-specific details -directly into the shared logic. - -## Challenges and Best Practices - -One common pitfall in platform-specific integrations is allowing platform logic to creep into shared initializers. For -instance, a shared initializer should never directly reference platform-specific declarations. Keeping -these concerns separate ensures that shared initializers remain reusable across different contexts. - -If you’re debugging a platform-specific initializer, make sure the problem isn’t due to the platform’s lifecycle or API. -For example, in WordPress, actions and filters may need to be registered at a specific point during initialization. - -For more information on this, check out [Event Bindings](/core-concepts/bootstrapping/initializers/event-binding) - -## Expanding Integrations - -PHPNomad encourages you to think modularly when creating platform-specific initializers. By keeping your logic -well-organized, you’ll make it easy to reuse these initializers across projects. For example, if you’ve written a -WordPress initializer for handling custom post types, consider extracting it into a standalone class or package that can -be dropped into any future WordPress project. - -Moreover, by sticking to PHPNomad’s philosophy of platform-agnostic design, your integrations will naturally be -future-proof. As platforms evolve, you can update your initializers without rewriting your application’s core logic. - -Integrating with platforms doesn’t have to mean sacrificing flexibility. With PHPNomad, you can build applications that -are adaptable, maintainable, and ready to travel wherever your project leads. By keeping platform logic modular and -separate from your core logic, you ensure your applications remain as free-spirited as the PHPNomad philosophy itself. \ No newline at end of file diff --git a/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md b/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md deleted file mode 100644 index 61589c6..0000000 --- a/public/docs/docs/core-concepts/datastores/getting-started-tutorial.md +++ /dev/null @@ -1,818 +0,0 @@ -# Getting Started: Your First Datastore - -This tutorial guides you through creating a complete database-backed datastore for a `Post` entity in PHPNomad. -You will build all the components required by the datastore pattern: the model, adapter, Core interfaces and -implementation, database table definition, database handler, and dependency injection registration. - -By the end, you will have a working datastore that can create, read, update, delete, and query blog posts stored in a database. - ---- - -## Prerequisites - -Before starting, ensure you have: - -- PHPNomad framework installed and configured -- A database configured and accessible (MySQL, MariaDB, or compatible) -- Basic understanding of PHP interfaces and dependency injection -- Familiarity with the [datastore architecture concepts](overview-and-architecture) - ---- - -## Directory structure - -Create the following directory structure for your Post datastore: - -``` -Blog/ -├── Core/ -│ ├── Models/ -│ │ ├── Post.php -│ │ └── Adapters/ -│ │ └── PostAdapter.php -│ └── Datastores/ -│ └── Post/ -│ ├── Interfaces/ -│ │ ├── PostDatastore.php -│ │ └── PostDatastoreHandler.php -│ └── PostDatastore.php -└── Service/ - └── Datastores/ - └── Post/ - ├── PostDatabaseDatastoreHandler.php - └── PostsTable.php -``` - -This structure separates Core (business logic) from Service (implementation details), which is fundamental to the -datastore pattern. - ---- - -## Step 1: Define your model - -Models represent domain entities as immutable value objects. They contain data and behavior but no persistence logic. - -Create `Blog/Core/Models/Post.php`: - -```php -id = $id; - $this->createdDate = $createdDate; - } -} -``` - -**Key points**: -- `HasSingleIntIdentity` provides the `getId()` method and identity tracking -- `WithSingleIntIdentity` trait implements the identity interface -- `WithCreatedDate` provides automatic timestamp tracking -- Properties use `public readonly` for immutability and direct access -- `id` and `createdDate` are managed by traits, other properties use constructor promotion - -For detailed information about model identity patterns and traits, see [Models and Identity](models-and-identity). - ---- - -## Step 2: Create the model adapter - -Adapters convert between models and storage representations (arrays). The database handler uses adapters to transform -database rows into models and vice versa. - -Create `Blog/Core/Models/Adapters/PostAdapter.php`: - -```php -dateFormatterService->getDateTime( - Arr::get($array, 'publishedDate') - ), - createdDate: $this->dateFormatterService->getDateTimeOrNull( - Arr::get($array, 'createdDate') - ) - ); - } - - public function toArray(DataModel $model): array - { - /** @var Post $model */ - return [ - 'id' => $model->getId(), - 'title' => $model->title, - 'content' => $model->content, - 'authorId' => $model->authorId, - 'publishedDate' => $this->dateFormatterService->getDateString( - $model->publishedDate - ), - 'createdDate' => $this->dateFormatterService->getDateStringOrNull( - $model->getCreatedDate() - ), - ]; - } -} -``` - -**Key points**: - -- `DateFormatterService` handles DateTime conversion to/from database format -- `Arr::get()` safely retrieves values from arrays -- `toModel()` converts database rows (arrays) to Post objects -- `toArray()` converts Post objects to database-compatible arrays -- Type casting ensures data integrity - ---- - -## Step 3: Define Core datastore interfaces - -Core interfaces declare what operations are possible without specifying how they work. - -### PostDatastore interface - -Create `Blog/Core/Datastores/Post/Interfaces/PostDatastore.php`: - -```php -datastoreHandler = $datastoreHandler; - } - - public function getPublishedPosts(): array - { - return $this->datastoreHandler->andWhere([ - ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] - ]); - } - - public function getByAuthor(int $authorId): array - { - return $this->datastoreHandler->andWhere([ - ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] - ]); - } -} -``` - -**Why decorator traits?** - -The decorator traits (`WithDatastoreDecorator`, `WithDatastorePrimaryKeyDecorator`, etc.) automatically delegate -standard operations like `create()`, `find()`, `update()`, and `delete()` to the `$datastoreHandler`. This eliminates -boilerplate code and lets you focus only on custom business methods like `getPublishedPosts()`. - -Without these traits, you would need to manually write delegation methods: - -```php -public function find(int $id): Post -{ - return $this->datastoreHandler->find($id); -} - -public function create(array $attributes): Post -{ - return $this->datastoreHandler->create($attributes); -} -// ... and many more -``` - -The traits handle all of this automatically, keeping your datastore implementation clean and focused on business logic. - -For detailed information about decorator patterns, see [Core Implementation](../packages/datastore/core-implementation). - ---- - -## Step 5: Define the database table schema - -Now that your datastore interfaces and Core implementation are complete, you need to define how data will be stored. Since we're building a database-backed datastore, we need to create a table schema that defines the database structure for storing posts. - -Table classes define the database schema for your entity, including columns, indices, and versioning. - -Create `Blog/Service/Datastores/Post/PostsTable.php`: - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('authorId', 'BIGINT', null, 'NOT NULL'), - new Column('publishedDate', 'DATETIME', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - new Index(['authorId'], 'idx_posts_author'), - new Index(['publishedDate'], 'idx_posts_published'), - ]; - } -} -``` - -**Key points**: - -- `PrimaryKeyFactory` creates standard auto-incrementing primary key -- `DateCreatedFactory` creates timestamp column with automatic default -- `getTableVersion()` enables schema migrations -- Indices improve query performance for common lookups -- Column factories provide consistent definitions across your application - -**About Table dependencies**: - -The `Table` base class constructor requires several dependencies for database configuration: - -```php -public function __construct( - HasLocalDatabasePrefix $localPrefixProvider, - HasGlobalDatabasePrefix $globalPrefixProvider, - HasCharsetProvider $charsetProvider, - HasCollateProvider $collateProvider, - TableSchemaService $tableSchemaService, - LoggerStrategy $loggerStrategy -) {} -``` - -These dependencies are automatically injected by PHPNomad's dependency injection container when you register the table. -You don't need to manually provide them—the framework handles this through auto-wiring. - -For complete table schema reference, see [Table Schema Definition](../packages/database/table-schema-definition). - ---- - -## Step 6: Implement the database handler - -With your table schema defined, you now need to implement the handler that connects your datastore to the database. The database handler uses the table definition to perform actual database operations like querying, inserting, updating, and deleting records. - -The database handler extends PHPNomad's base handler and uses traits that provide the implementation of all standard datastore operations. - -Create `Blog/Service/Datastores/Post/PostDatabaseDatastoreHandler.php`: - -```php -serviceProvider = $serviceProvider; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->tableSchemaService = $tableSchemaService; - $this->model = Post::class; - } -} -``` - -**Why extend IdentifiableDatabaseDatastoreHandler?** - -`IdentifiableDatabaseDatastoreHandler` is a base class that provides single-ID convenience methods by delegating to -compound-ID operations. For example, it implements: - -```php -public function find(int $id): DataModel -{ - return $this->findCompound(['id' => $id]); -} -``` - -This saves you from writing boilerplate delegation code for every entity with a single integer primary key. - -**Why use WithDatastoreHandlerMethods?** - -The `WithDatastoreHandlerMethods` trait provides the actual implementation of all standard datastore operations: - -- `create()` - Inserts records and broadcasts events -- `find()` / `where()` - Queries with caching -- `update()` / `delete()` - Modifications with event broadcasting -- Query building and condition handling -- Cache management for retrieved models - -Without this trait, you would need to implement dozens of methods manually. The trait encapsulates all the database -interaction logic, caching strategies, and event broadcasting, allowing you to focus on entity-specific concerns. - -**Constructor dependencies explained**: - -- `DatabaseServiceProvider` - Provides query builder, cache service, event broadcasting -- `PostsTable` - Your table schema definition -- `PostAdapter` - Converts between Post models and arrays -- `TableSchemaService` - Manages table creation and migrations - -For detailed information about database handlers, see [Database Handlers](../packages/database/database-handlers). - ---- - -## Step 7: Create table installer - -Your table schema is defined, but it won't create itself. You need an **installer** that creates the table when your application is activated or installed. - -Create `Blog/Service/Installer.php`: - -```php -createTables(); - } - - protected function getTablesToInstall(): array - { - return [ - PostsTable::class, - ]; - } -} -``` - -**How installers work:** - -- `CanInstallTables` trait provides the table installation logic -- `getTablesToInstall()` returns an array of table classes to create -- `createTables()` checks if tables exist and creates/updates them -- Installers are idempotent—safe to run multiple times - -**When installers run:** - -Installers run during specific installation events, not on every page load: - -- **WordPress plugins**: On plugin activation via `register_activation_hook()` -- **CLI applications**: During install commands -- **Manual triggers**: When deploying schema changes - -The installer checks the current database state and only creates/updates tables when needed. If your table already exists and matches the schema version, the installer does nothing. - -**Why this matters:** - -Without an installer, your table definitions exist in code but never get executed. The installer is the bridge between your schema definition and the actual database structure. - ---- - -## Step 8: Register with dependency injection - -Register your datastore components with PHPNomad's dependency injection container so they can be auto-wired. - -Create `Blog/Service/Initializer.php`: - -```php - PostDatastoreHandler::class, - PostDatastore::class => PostDatastoreInterface::class, - ]; - } -} -``` - -**Key points**: -- `HasClassDefinitions` tells PHPNomad this initializer registers class bindings -- Maps concrete implementations to their interfaces -- The container auto-wires all constructor dependencies -- Format is: `Implementation::class => Interface::class` - -### Create a Loader - -Now create a loader that combines your initializers: - -Create `Blog/Loader.php`: - -```php -initializers = [ - new ServiceInitializer(), - ]; - } - - public function load(): void - { - $this->loadInitializers(); - } -} -``` - -The loader collects all your initializers and loads them in order during bootstrap. - -For complete initialization patterns, see [Creating and Managing Initializers](/core-concepts/bootstrapping/creating-and-managing-initializers). - ---- - -## Step 9: Bootstrap your application - -Finally, create an Application class that ties everything together: - -Create `Blog/Application.php`: - -```php -container = new Container(); - } - - /** - * Normal application initialization - */ - public function init(): void - { - (new Bootstrapper( - $this->container, - new Loader() // Loads all initializers - ))->load(); - } - - /** - * Run during plugin activation or installation - */ - public function install(): void - { - (new Bootstrapper( - $this->container, - new Installer() // Creates database tables - ))->load(); - } -} -``` - -**How to use:** - -For WordPress plugins, hook into activation and init: - -```php -install(); -}); - -// Normal app initialization -add_action('plugins_loaded', function() { - $app = new Application(); - $app->init(); -}); -``` - -**Key insights:** - -- `install()` runs the installer (creates tables) - only on activation -- `init()` loads initializers (registers classes) - runs on every page load -- The bootstrapper handles dependency resolution and initialization order - -For complete bootstrapping documentation, see [Bootstrapping Introduction](/core-concepts/bootstrapping/introduction). - ---- - -## Step 10: Use your datastore - -Once registered, you can inject and use your datastore anywhere in your application: - -```php -postDatastore->create([ - 'title' => 'My First Post', - 'content' => 'This is the content of my first blog post.', - 'authorId' => 1, - 'publishedDate' => date('Y-m-d H:i:s'), - ]); - - echo "Created post with ID: " . $post->getId(); - } - - public function listPublishedPosts(): void - { - $posts = $this->postDatastore->getPublishedPosts(); - - foreach ($posts as $post) { - echo $post->title . "\n"; - } - } - - public function findPost(int $id): void - { - $post = $this->postDatastore->find($id); - echo $post->title; - } - - public function updatePost(int $id): void - { - $this->postDatastore->update($id, [ - 'title' => 'Updated Title' - ]); - } - - public function deletePost(int $id): void - { - $this->postDatastore->delete($id); - } -} -``` - -The container automatically injects the `PostDatastore` implementation, which uses the database handler under the hood. - ---- - -## What you have accomplished - -You have built a complete database-backed datastore with all components: - -- ✅ **Model** - Immutable value object representing a Post -- ✅ **Adapter** - Converts between Post models and database arrays -- ✅ **Core Interfaces** - Define what operations are possible -- ✅ **Core Implementation** - Business logic with decorator pattern -- ✅ **Table Schema** - Database structure with columns and indices -- ✅ **Database Handler** - Actual database operations -- ✅ **Installer** - Creates and manages database tables -- ✅ **Initializer** - Registers components with DI -- ✅ **Loader** - Combines initializers for bootstrapping -- ✅ **Application** - Orchestrates init and install workflows - -Your datastore now supports: - -- Creating, reading, updating, and deleting posts -- Custom business queries (published posts, posts by author) -- Automatic caching and event broadcasting -- Swappable implementations (could replace database with REST API) -- Proper table installation and schema versioning -- Clean bootstrapping and initialization patterns - ---- - -## Next steps - -Now that you have built your first datastore, explore these topics to deepen your understanding: - -- **[Models and Identity](models-and-identity)** — Learn about compound keys and different identity patterns -- **[Core Datastore Layer](../packages/datastore/core-implementation)** — Master decorator patterns and custom business methods -- **[Database Handlers](../packages/database/database-handlers)** — Understand caching, events, and query building -- **[Table Schema Definition](../packages/database/table-schema-definition)** — Learn all column types, indices, and foreign keys -- **[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 - ---- - -## Summary - -Building a PHPNomad datastore involves creating models, adapters, Core interfaces and implementations, database schemas, -database handlers, and dependency injection registration. The pattern separates business logic (Core) from -implementation details (Service), enabling flexible, testable, and maintainable code. Decorator traits eliminate -boilerplate, while base classes and handler traits provide robust database operations with caching and event support. diff --git a/public/docs/docs/core-concepts/datastores/introduction.md b/public/docs/docs/core-concepts/datastores/introduction.md deleted file mode 100644 index b57a5d0..0000000 --- a/public/docs/docs/core-concepts/datastores/introduction.md +++ /dev/null @@ -1,290 +0,0 @@ -# Overview and Architecture - -## What is the datastore pattern? - -The datastore pattern is a two-level abstraction system for data persistence that keeps your code independent of specific storage implementations. Instead of writing database queries directly in your business logic, you define how data operations should work through interfaces, then provide concrete implementations that talk to actual data sources. - -This pattern ensures your domain code remains portable. Whether data comes from a local database, a REST API, GraphQL endpoint, or in-memory cache, your application logic stays the same. You can swap implementations without rewriting the code that uses them. - -The pattern consists of two primary layers: **Core** for business interfaces and domain logic, and **Service** for concrete storage implementations. This separation is the foundation of implementation-agnostic design. - ---- - -## Why this separation matters - -Separation of concerns keeps your codebase flexible and maintainable. When data access is tightly coupled to a specific storage mechanism, changing that mechanism requires touching every piece of code that reads or writes data. This creates risk, increases testing burden, and makes your code harder to move between environments. - -The datastore pattern solves this by treating storage as a swappable dependency. Your business logic depends only on interfaces that describe what operations are possible. The actual implementation—whether it queries MySQL, calls a REST API, or reads from Redis—is provided at runtime through dependency injection. - -This approach provides several concrete benefits: - -- **Portability**: Move between storage systems without refactoring domain code -- **Testability**: Swap real implementations for test doubles during testing -- **Flexibility**: Support multiple data sources simultaneously for different entities -- **Future-proofing**: Adapt to changing requirements without rewriting core logic - -Consider a scenario where your application initially stores collaborator data in a local database. Later, you need to fetch collaborators from a remote API to integrate with a partner system. With the datastore pattern, you implement a new handler that calls the API instead of the database. Your domain code—the code that creates, updates, and queries collaborators—requires no changes. - ---- - -## The two-level architecture - -The pattern uses two distinct layers, each with a specific responsibility. - -### Core layer: Business interfaces - -The Core layer defines **what** operations are possible, without specifying **how** they work. It contains: - -- **Interfaces** that declare data operations (create, find, update, delete, query) -- **Models** that represent domain entities as value objects -- **Datastore implementations** that delegate to handler interfaces using the decorator pattern - -Core never depends on Service. It knows nothing about databases, HTTP clients, or any concrete storage technology. This ignorance is intentional and ensures portability. - -Example Core interface: - -```php -interface PostDatastore { - public function create(array $attributes): Post; - public function find(int $id): Post; - public function update(int $id, array $attributes): void; - public function delete(int $id): void; - public function where(array $conditions): array; -} -``` - -### Service layer: Concrete implementations - -The Service layer provides **how** operations are executed. It contains: - -- **Handler implementations** that interact with actual data sources -- **Table definitions** (for database implementations) that define schema -- **Adapters** that convert between models and storage formats - -Service depends on Core interfaces and implements them. Multiple Service implementations can exist for the same Core interface, enabling you to choose implementations based on environment, performance needs, or integration requirements. - -Example Service implementations: - -```php -// Database implementation -class PostDatabaseDatastoreHandler implements PostDatastoreHandler { - public function find(int $id): Post { - // Query database, convert row to Post model - } -} - -// GraphQL implementation -class PostGraphQLDatastoreHandler implements PostDatastoreHandler { - public function find(int $id): Post { - // Query GraphQL endpoint, convert response to Post model - } -} -``` - ---- - -## Communication flow - -Understanding how data flows through the layers helps clarify the architecture. The pattern uses two types of flow: conceptual (design-time) and runtime (execution). - -### Conceptual flow: Layers and abstractions - -At design time, the architecture looks like this: - -```mermaid -graph TB - A[Application Code] --> B[Datastore Interface
Core Layer] - B --> C[DatastoreHandler Interface
Core Layer] - C -.implements.-> D[Database Handler
Service Layer] - C -.implements.-> E[GraphQL Handler
Service Layer] - C -.implements.-> F[REST Handler
Service Layer] -``` - -The Core layer defines interfaces. The Service layer provides implementations. Application code depends only on Core interfaces, never on Service implementations directly. - -### Runtime flow: Concrete execution - -At runtime, when your application executes a data operation, the flow looks like this: - -```mermaid -graph LR - A[Application Code] --> B[PostDatastore
Core Implementation] - B --> C[PostDatabaseDatastoreHandler
Service Implementation] - C --> D[MySQL QueryBuilder] - D --> E[Database] -``` - -1. Application code calls a method on the Datastore -2. Datastore delegates to its injected Handler -3. Handler (database implementation) builds a query -4. Query executes against the database -5. Result is converted to a Model and returned - -Swap the handler for a GraphQL implementation, and steps 3-4 change to API calls instead of SQL queries. The application code and Datastore remain identical. - ---- - -## Key abstractions - -Several abstractions work together to enable the pattern. - -### Datastore - -The **Datastore** is the primary interface your application code interacts with. It defines business-level operations and may include domain-specific query methods beyond basic CRUD. - -```php -interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere { - public function getPublishedPosts(): array; - public function getByAuthor(int $authorId): array; -} -``` - -The Datastore implementation uses decorator traits to delegate standard operations to a handler, while implementing custom business methods directly. - -### DatastoreHandler - -The **DatastoreHandler** is the low-level interface that concrete implementations fulfill. It extends the same base interfaces as the Datastore but typically includes only standard operations, not business-specific methods. - -```php -interface PostDatastoreHandler extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere { - // Only standard interface methods, no custom business logic -} -``` - -Handlers are the swap points. When you want to change storage mechanisms, you implement a new handler without touching the Datastore interface or its consumers. - -### Model - -The **Model** represents a domain entity as an immutable value object. Models know nothing about persistence—they have no save methods, no database awareness. They are pure data with behavior. - -```php -class Post implements DataModel, HasSingleIntIdentity { - public function __construct( - private int $id, - private string $title, - private string $content, - private DateTime $publishedDate - ) {} - - public function getId(): int { return $this->id; } - public function getTitle(): string { return $this->title; } -} -``` - -### ModelAdapter - -The **ModelAdapter** converts between Models and storage representations (typically arrays). Handlers use adapters to transform raw data into Models on read, and Models into storable formats on write. - -```php -class PostAdapter implements ModelAdapter { - public function toModel(array $data): Post { - return new Post( - $data['id'], - $data['title'], - $data['content'], - new DateTime($data['published_date']) - ); - } - - public function toArray(Post $model): array { - return [ - 'id' => $model->getId(), - 'title' => $model->getTitle(), - 'content' => $model->getContent(), - 'published_date' => $model->getPublishedDate()->format('Y-m-d H:i:s') - ]; - } -} -``` - ---- - -## Example: Swapping implementations - -The core value of this architecture is implementation independence. Here is how it works in practice. - -### Starting with a database - -Initially, your blog stores posts in a MySQL database: - -```php -class PostDatabaseDatastoreHandler implements PostDatastoreHandler { - public function find(int $id): Post { - $row = $this->queryBuilder - ->select() - ->from($this->table) - ->where('id', '=', $id) - ->execute() - ->fetch(); - - return $this->adapter->toModel($row); - } -} -``` - -Your application code uses the Datastore: - -```php -$post = $postDatastore->find(123); -echo $post->getTitle(); -``` - -### Switching to GraphQL - -Your requirements change. Posts now come from a remote GraphQL API. You implement a new handler: - -```php -class PostGraphQLDatastoreHandler implements PostDatastoreHandler { - public function find(int $id): Post { - $query = "query { post(id: $id) { id title content publishedDate } }"; - $response = $this->graphqlClient->execute($query); - - return $this->adapter->toModel($response['data']['post']); - } -} -``` - -You update your dependency injection configuration to bind `PostDatastoreHandler` to the GraphQL implementation instead of the database implementation. Your application code remains unchanged: - -```php -$post = $postDatastore->find(123); // Now fetches from GraphQL -echo $post->getTitle(); -``` - -No business logic was modified. No tests for application code need updates. Only the handler implementation and its tests changed. - ---- - -## When to use this pattern - -PHPNomad is designed around the datastore pattern. If you are building applications with PHPNomad, especially when working with databases, this pattern is the expected approach. - -The pattern is required when: - -- You are using PHPNomad's database layer for local data storage -- You need to integrate data from multiple sources with a unified interface -- Your application design follows nomadic principles of portability and swappability - -The architecture is most beneficial when: - -- You anticipate changing storage implementations in the future -- Testing domain logic independently of storage is important -- Multiple environments require different storage strategies - -PHPNomad's database abstractions and table schema system are built to work with this pattern. Using the datastore pattern with PHPNomad ensures your code remains portable and consistent with the framework's design philosophy. - ---- - -## Related topics - -- **[Getting Started: Your First Datastore](getting-started-tutorial)** — Build a complete datastore implementation from scratch -- **[Core Datastore Layer](../packages/datastore/core-implementation)** — Deep dive into Core interfaces and implementations -- **[Database Service Layer](../packages/database/database-handlers)** — Detailed guide to database-backed handlers -- **[Models and Identity](models-and-identity)** — How to design models and identity systems -- **[Dependency Injection and Initialization](dependency-injection)** — Register and bootstrap datastores - ---- - -## Summary - -The datastore pattern provides implementation-agnostic data access through a two-level architecture. The Core layer defines business interfaces and domain models. The Service layer provides concrete storage implementations. This separation enables you to swap data sources without refactoring application logic, keeping your code portable, testable, and adaptable to changing requirements. diff --git a/public/docs/docs/core-concepts/datastores/models-and-identity.md b/public/docs/docs/core-concepts/datastores/models-and-identity.md deleted file mode 100644 index 4c06d9f..0000000 --- a/public/docs/docs/core-concepts/datastores/models-and-identity.md +++ /dev/null @@ -1,677 +0,0 @@ -# Models and Identity - -## What are models? - -Models are immutable value objects that represent domain entities in your application. They contain data and domain -logic but have no knowledge of how they are persisted. A model never saves itself, queries a database, or makes API -calls—it is purely a container for data with behavior. - -Models are independent of storage. Whether data comes from a database, REST API, or cache, the model remains the same. -This separation keeps domain logic clean and portable. - ---- - -## The DataModel interface - -All models must implement the `DataModel` interface, which marks them as domain entities that can be stored and -retrieved through datastores: - -```php -interface DataModel -{ - public function getIdentity(): array; -} -``` - -The `getIdentity()` method returns an associative array representing how the entity is uniquely identified. For a Post -with ID 123, this might return `['id' => 123]`. For a UserSession identified by both user ID and session token, it -returns `['userId' => 456, 'sessionToken' => 'abc123']`. - -Datastores use this identity array to look up, update, and delete specific entities. - ---- - -## Understanding identity - -Identity determines how entities are uniquely identified. There are two primary patterns: - -### Single integer identity - -Most entities use a single auto-incrementing integer as their primary identifier. Examples include posts, users, -products, and orders. - -For these entities, implement the `HasSingleIntIdentity` interface: - -```php -interface HasSingleIntIdentity -{ - public function getId(): int; - public function getIdentity(): array; -} -``` - -This interface requires both a `getId()` method that returns the integer ID, and a `getIdentity()` method that returns -`['id' => $this->getId()]`. - -### Compound identity - -Some entities require multiple values to be uniquely identified. This happens when entities use composite keys in the -database. Common examples: - -- **User sessions** - identified by `userId` + `sessionToken` -- **Translations** - identified by `entityId` + `locale` -- **Time-series data** - identified by `deviceId` + `timestamp` -- **Versioned content** - identified by `contentId` + `version` - -For these entities, `getIdentity()` returns an array with multiple keys: - -```php -public function getIdentity(): array -{ - return [ - 'userId' => $this->userId, - 'sessionToken' => $this->sessionToken - ]; -} -``` - -The keys in this array must match the columns used to uniquely identify records in storage. - ---- - -## Single integer identity pattern - -For entities with a single integer ID, use the `WithSingleIntIdentity` trait to reduce boilerplate. - -### Using WithSingleIntIdentity trait - -The `WithSingleIntIdentity` trait provides: - -- A protected `$id` property -- Implementation of `getId()` that returns the ID -- Implementation of `getIdentity()` that returns `['id' => $this->getId()]` - -Example: - -```php -id = $id; - } -} - -// Usage: -$post = new Post(123, 'My Post', 'Content...', 1, new DateTime()); -echo $post->getId(); // 123 -print_r($post->getIdentity()); // ['id' => 123] -``` - -### Manual implementation - -If you prefer not to use the trait, implement the interface manually: - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - public function __construct( - private int $id, - public readonly string $title, - public readonly string $content - ) {} - - public function getId(): int - { - return $this->id; - } - - public function getIdentity(): array - { - return ['id' => $this->id]; - } -} -``` - -The trait is recommended to keep implementations consistent across your codebase. - ---- - -## Compound identity pattern - -For entities with compound keys, implement `getIdentity()` to return all identifying values. - -Example with UserSession: - -```php - $this->userId, - 'sessionToken' => $this->sessionToken - ]; - } -} -``` - -When the datastore performs operations on this entity, it uses both `userId` and `sessionToken` to identify the record: - -```php -// Datastore uses compound identity for lookups -$session = $sessionDatastore->findCompound([ - 'userId' => 456, - 'sessionToken' => 'abc123' -]); - -// Update uses compound identity -$sessionDatastore->updateCompound( - ['userId' => 456, 'sessionToken' => 'abc123'], - ['ipAddress' => '192.168.1.1'] -); -``` - -The keys in the identity array must exactly match the column names used in your storage implementation. - ---- - -## Timestamp traits - -PHPNomad provides traits for automatic timestamp tracking. - -### WithCreatedDate trait - -The `WithCreatedDate` trait provides: - -- A protected `$createdDate` property of type `?DateTime` -- Implementation of `getCreatedDate()` that returns the creation timestamp -- Automatic handling of null values for new entities - -Example: - -```php -id = $id; - $this->createdDate = $createdDate; - } -} - -// Usage: -$post = new Post(123, 'Title', 'Content', new DateTime('2025-01-08 10:00:00')); -echo $post->getCreatedDate()->format('Y-m-d H:i:s'); // 2025-01-08 10:00:00 -``` - -When creating new entities, pass `null` for `createdDate`. The database will set it automatically via -`DEFAULT CURRENT_TIMESTAMP`. - -### WithModifiedDate trait - -The `WithModifiedDate` trait works similarly for tracking last modification time: - -```php -use Nomad\Datastore\Traits\WithModifiedDate; - -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - use WithCreatedDate; - use WithModifiedDate; - - public function __construct( - int $id, - public readonly string $title, - ?DateTime $createdDate = null, - ?DateTime $modifiedDate = null - ) { - $this->id = $id; - $this->createdDate = $createdDate; - $this->modifiedDate = $modifiedDate; - } -} - -// Usage: -echo $post->getModifiedDate()?->format('Y-m-d H:i:s'); -``` - -The database automatically updates `modifiedDate` via `ON UPDATE CURRENT_TIMESTAMP` when using the corresponding table -column factory. - -### When to use traits vs manual implementation - -**Use traits when:** - -- You want consistent timestamp handling across entities -- The standard implementation (nullable DateTime) fits your needs -- You want to reduce boilerplate code - -**Implement manually when:** - -- You need custom timestamp logic -- You require non-nullable timestamps with specific defaults -- You want to calculate timestamps based on other model data - ---- - -## DateTime handling in models - -Models use PHP `DateTime` objects for all date and time values. Adapters handle conversion between `DateTime` objects -and database string formats. - -### Model with DateTime properties: - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly DateTime $publishedDate, - public readonly ?DateTime $scheduledDate = null - ) { - $this->id = $id; - } -} -``` - -### Adapter converts DateTime to/from strings: - -```php -class PostAdapter implements ModelAdapter -{ - public function __construct( - private DateFormatterService $dateFormatterService - ) {} - - public function toModel(array $data): Post - { - return new Post( - id: $data['id'], - publishedDate: $this->dateFormatterService->getDateTime( - $data['publishedDate'] - ), - scheduledDate: $this->dateFormatterService->getDateTimeOrNull( - $data['scheduledDate'] - ) - ); - } - - public function toArray(Post $model): array - { - return [ - 'id' => $model->getId(), - 'publishedDate' => $this->dateFormatterService->getDateString( - $model->publishedDate - ), - 'scheduledDate' => $this->dateFormatterService->getDateStringOrNull( - $model->scheduledDate - ), - ]; - } -} -``` - -**Key points:** - -- Models always use `DateTime` objects, never strings -- `DateFormatterService` handles conversion to database format (usually `Y-m-d H:i:s`) -- Use nullable `?DateTime` for optional dates -- Adapters use `getDateTime()` for required dates, `getDateTimeOrNull()` for optional dates - ---- - -## Model immutability - -Models must be immutable—their state cannot change after construction. This prevents bugs, simplifies reasoning about -code, and enables safe caching. - -### Correct immutable model: - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly string $title, - public readonly string $content, - public readonly bool $published - ) { - $this->id = $id; - } -} -``` - -### Common mistakes - DO NOT DO THIS: - -```php -// WRONG: Mutable properties -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public string $title // Not readonly - can be changed! - ) { - $this->id = $id; - } -} - -// WRONG: Setter methods -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly string $title - ) { - $this->id = $id; - } - - // NEVER add setters! - public function setTitle(string $title): void - { - $this->title = $title; // Breaks immutability! - } -} -``` - -### Why immutability matters: - -**Prevents bugs:** - -```php -$post = $datastore->find(123); -$cachedPost = $cache->get('post:123'); - -// If models were mutable, changing one affects the other -$post->title = 'New Title'; // Would corrupt cache! -``` - -**Enables safe caching:** - -```php -// Datastore can safely cache immutable models -$post = $datastore->find(123); // Caches result -$samePost = $datastore->find(123); // Returns cached instance -// Both references point to same object, but it can't be changed -``` - -**Simplifies concurrency:** - -```php -// Multiple threads can safely read the same model -// No locks or synchronization needed -``` - -### How to "update" immutable models: - -You don't modify existing models—you create new ones with changed values: - -```php -// Get existing post -$post = $datastore->find(123); - -// Create updated post by creating new instance -$updatedPost = new Post( - id: $post->getId(), - title: 'New Title', // Changed - content: $post->content, // Same - published: $post->published // Same -); - -// Or use datastore update, which creates a new instance internally -$datastore->update(123, ['title' => 'New Title']); -``` - -The datastore handles updates by: - -1. Loading the current model -2. Merging changes from the array -3. Creating a new model instance -4. Persisting the new state -5. Returning the new model - ---- - -## Model best practices - -### Use public readonly properties - -Constructor property promotion with `readonly` provides immutability and clean syntax: - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly string $title, - public readonly string $content, - public readonly int $authorId - ) { - $this->id = $id; - } -} - -// Access directly, no getters needed -echo $post->title; -echo $post->authorId; -``` - -### Models are for data access only - -**Models should never contain business logic.** They are purely data containers with no behavior beyond providing access to their properties. - -**DO NOT add methods like:** -- `isExpired()` - belongs in a service -- `isPublished()` - belongs in a service -- `calculateTotal()` - belongs in a service -- `validate()` - belongs in a service or validator -- `canBeEditedBy()` - belongs in an authorization service - -```php -// WRONG - business logic in model -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly DateTime $publishedDate - ) { - $this->id = $id; - } - - // DON'T DO THIS - public function isPublished(): bool - { - return $this->publishedDate <= new DateTime(); - } -} - -// CORRECT - model is data only -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly DateTime $publishedDate - ) { - $this->id = $id; - } -} - -// Business logic belongs in services -class PostService -{ - public function isPublished(Post $post): bool - { - return $post->publishedDate <= new DateTime(); - } -} -``` - -Models are designed to be serializable, cacheable, and transferable. Business logic in models creates coupling and makes them harder to test and maintain. Keep models as simple data structures and put all logic in services. - -### Handle relationships with IDs - -If your entity relates to other entities, store IDs rather than embedding objects: - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly string $title, - public readonly int $authorId // Store ID, not Author object - ) { - $this->id = $id; - } -} - -// Fetch related entities separately through their datastores -$post = $postDatastore->find(123); -$author = $authorDatastore->find($post->authorId); -``` - -This keeps models simple and storage-agnostic. - ---- - -## Complete examples - -### Simple entity with single ID: - -```php -id = $id; - $this->createdDate = $createdDate; - } -} -``` - -### Entity with compound identity: - -```php -createdDate = $createdDate; - } - - public function getIdentity(): array - { - return [ - 'userId' => $this->userId, - 'sessionToken' => $this->sessionToken - ]; - } -} -``` - ---- - -## Summary - -Models are immutable value objects that represent domain entities. They implement the `DataModel` interface and provide -identity through `getIdentity()`. Use `WithSingleIntIdentity` for entities with single integer IDs, or implement -compound identity for entities requiring multiple identifying values. Models use `DateTime` for dates, with adapters -handling string conversion. Traits like `WithCreatedDate` and `WithModifiedDate` provide automatic timestamp tracking. -Always use `public readonly` properties to enforce immutability. Models must never contain business logic—they are purely data containers. All business logic belongs in services. diff --git a/public/docs/docs/index.md b/public/docs/docs/index.md deleted file mode 100644 index 9d7f23d..0000000 --- a/public/docs/docs/index.md +++ /dev/null @@ -1,67 +0,0 @@ -# PHPNomad - -PHPNomad's primary purpose is to create code that's both easy to read and simple to use, while remaining -platform-agnostic. By focusing on clear, modular design, PHPNomad allows developers to build code that works -consistently across various environments, making it adaptable without added complexity. - -## What is PHPNomad? - -Think of PHPNomad as a way to write code that can easily move between different PHP systems. Whether you're building a -WordPress plugin, a Laravel application, or a homegrown MVC service, PHPNomad helps make your code portable and adaptable. - -The name comes from the idea that your code should be able to travel and adapt, just like a digital nomad who can -work from anywhere. - -## Why PHPNomad? - -### Eliminate Context Switching - -The biggest advantage of PHPNomad is eliminating mental overhead when working across different platforms. Developers use -the same patterns, tools, and approaches whether building a WordPress plugin, Laravel application, or standalone PHP -service. This consistency dramatically reduces cognitive load and increases productivity. - -### True Platform Independence - -Rather than being tied to platform-specific implementations (like WordPress's `wp_remote_request` or Laravel's Guzzle), -PHPNomad introduces a buffer layer between your business logic and platform-specific code. This means your core -functionality remains clean and portable, while platform-specific details are handled through clean interfaces. - -### A Framework for Modern Development - -PHPNomad's consistent patterns and clear separation of concerns make it ideal for modern development practices, -including: - -- AI-assisted development through standardized patterns -- Microservices architecture through modular design -- Future-proofing through platform agnosticism - -## Core Principles - -### Platform-Agnostic Design - -At the heart of PHPNomad is a commitment to platform-agnostic design. Components are crafted to function seamlessly -across different systems, including WordPress, Laravel, Symfony, and standalone PHP applications. This design allows -your codebase to "travel" effortlessly between environments. - -### Separation of Concerns - -PHPNomad separates business logic from platform-specific integrations through dependency injection and the strategy -pattern. This creates a clean buffer between your core logic and platform-specific code, letting each system "plug in" -without deep coupling. - -### Inversion of Control - -PHPNomad shifts control from platform-specific code to your core system. This means platforms integrate into your -application rather than your application integrating into platforms. This fundamental shift makes your codebase truly -portable. - -### Modularity - -Start small and scale as needed. Each feature exists as an independent module, allowing you to begin with a simple -WordPress plugin and later extract parts into microservices or add new functionality. This modularity empowers teams to -scale at their own pace. - -### Event-Driven Architecture - -Built around events, PHPNomad facilitates both synchronous and asynchronous operations without binding to specific -platforms. This approach enhances scalability and makes your codebase responsive in any environment. diff --git a/public/docs/docs/packages/database/caching-and-events.md b/public/docs/docs/packages/database/caching-and-events.md deleted file mode 100644 index 9d5b242..0000000 --- a/public/docs/docs/packages/database/caching-and-events.md +++ /dev/null @@ -1,575 +0,0 @@ -# Caching and Events - -PHPNomad's database handlers include built-in support for **automatic caching** and **event broadcasting**. These features are provided by the `CacheableService` and `EventStrategy` components, which are injected into handlers via the `DatabaseServiceProvider`. - -Caching improves performance by storing frequently accessed data, while events enable reactive patterns where other parts of your system can respond to data changes without tight coupling. - -## Overview - -When a handler extends `IdentifiableDatabaseDatastoreHandler`, it automatically gets: - -* **Caching** — Query results are cached based on configurable policies -* **Cache invalidation** — Mutations (save, delete) automatically invalidate affected cache entries -* **Event broadcasting** — Mutations trigger events that other services can listen to - -This happens transparently—you don't need to write caching or event code in your handlers. - ---- - -## Caching Strategy - -### How Caching Works - -The `CacheableService` wraps query operations with cache checks: - -1. **Cache hit** — If data exists in cache and policy allows, return cached data -2. **Cache miss** — Execute the query, store result in cache, return data -3. **Invalidation** — Mutations (save, delete) clear relevant cache entries - -### CacheableService API - -```php -class CacheableService -{ - /** - * Get data with caching - * - * @param string $operation - Operation name (e.g., 'find', 'get') - * @param array $context - Context data (e.g., ['id' => 123]) - * @param callable $callback - Function to execute on cache miss - */ - public function getWithCache(string $operation, array $context, callable $callback); - - /** - * Get cached data directly (throws if not found) - */ - public function get(array $context); - - /** - * Clear cache for specific context - */ - public function forget(array $context): void; - - /** - * Clear all cache entries matching a pattern - */ - public function forgetMatching(string $pattern): void; -} -``` - -### Example: Handler with Caching - -```php -cache = $serviceProvider->cacheableService; - $this->table = $table; - $this->adapter = $adapter; - } - - public function find(int $id): Post - { - return $this->cache->getWithCache( - operation: 'find', - context: ['id' => $id], - callback: function() use ($id) { - // This only runs on cache miss - $row = $this->executeQuery("SELECT * FROM {$this->table->getTableName()} WHERE id = {$id}"); - return $this->adapter->toModel($row); - } - ); - } -} -``` - -**On first call:** -1. Cache miss → executes query -2. Stores result in cache -3. Returns post - -**On subsequent calls:** -1. Cache hit → returns cached post -2. Query is never executed - ---- - -### Cache Invalidation - -When you save or delete a record, the handler automatically invalidates relevant cache entries: - -```php -public function save(Model $item): Model -{ - $result = parent::save($item); - - // Automatically clears cache for this record - $this->cache->forget(['id' => $item->getId()]); - - // Also clears list caches that might include this record - $this->cache->forgetMatching('posts:list:*'); - - return $result; -} - -public function delete(Model $item): void -{ - parent::delete($item); - - // Automatically clears cache for this record - $this->cache->forget(['id' => $item->getId()]); - $this->cache->forgetMatching('posts:list:*'); -} -``` - -**Note:** `IdentifiableDatabaseDatastoreHandler` handles this automatically. You only need custom invalidation for complex cache patterns. - ---- - -### Cache Policies - -Cache behavior is controlled by a `CachePolicy`: - -```php -interface CachePolicy -{ - /** - * Determine if this operation should use cache - */ - public function shouldCache(string $operation, array $context): bool; - - /** - * Generate cache key from context - */ - public function getCacheKey(array $context): string; - - /** - * Get cache TTL (time-to-live) in seconds - */ - public function getTtl(array $context): int; -} -``` - -### Example: Custom Cache Policy - -```php - 123, 'status' => 'published'])); -// posts:list:a3f2e1d... -``` - -**Count queries:** -```php -$key = "posts:count:" . md5(serialize(['status' => 'published'])); -// posts:count:b4c3d2e... -``` - -**Wildcard invalidation:** -```php -// Clear all list caches when any post changes -$this->cache->forgetMatching('posts:list:*'); - -// Clear all post caches (lists and single records) -$this->cache->forgetMatching('posts:*'); -``` - ---- - -## Event Broadcasting - -### How Events Work - -Handlers broadcast events after mutations, allowing other parts of your system to react: - -* **RecordCreated** — Fired after `save()` creates a new record -* **RecordUpdated** — Fired after `save()` updates an existing record -* **RecordDeleted** — Fired after `delete()` removes a record - -Events are **asynchronous** by default—listeners don't block the handler. - ---- - -### EventStrategy API - -```php -interface EventStrategy -{ - /** - * Broadcast an event to all registered listeners - * - * @param object $event The event object - */ - public function broadcast(object $event): void; - - /** - * Register a listener for an event type - * - * @param string $eventClass The event class name - * @param callable $listener The listener callback - */ - public function listen(string $eventClass, callable $listener): void; -} -``` - ---- - -### Example: Handler with Events - -```php -events = $serviceProvider->eventStrategy; - $this->table = $table; - $this->adapter = $adapter; - } - - public function save(Model $item): Model - { - $isNew = !$item->getId(); - - $result = parent::save($item); - - // Broadcast appropriate event - if ($isNew) { - $this->events->broadcast(new RecordCreated('posts', $result)); - } else { - $this->events->broadcast(new RecordUpdated('posts', $result)); - } - - return $result; - } - - public function delete(Model $item): void - { - parent::delete($item); - - $this->events->broadcast(new RecordDeleted('posts', $item)); - } -} -``` - -**Note:** `IdentifiableDatabaseDatastoreHandler` broadcasts these events automatically. - ---- - -### Listening to Events - -Register listeners in your service provider: - -```php -events->listen(RecordCreated::class, function(RecordCreated $event) { - if ($event->table === 'posts') { - $post = $event->model; - $this->notifications->sendNewPostNotification($post); - } - }); - - // Listen for post updates - $this->events->listen(RecordUpdated::class, function(RecordUpdated $event) { - if ($event->table === 'posts') { - $post = $event->model; - $this->notifications->sendPostUpdatedNotification($post); - } - }); - - // Listen for post deletion - $this->events->listen(RecordDeleted::class, function(RecordDeleted $event) { - if ($event->table === 'posts') { - // Clean up related data - $this->cleanupPostRelations($event->model->getId()); - } - }); - } -} -``` - ---- - -### Custom Events - -You can broadcast domain-specific events: - -```php -posts->find($postId); - - $published = new Post( - id: $post->id, - title: $post->title, - content: $post->content, - authorId: $post->authorId, - publishedDate: new DateTime() - ); - - $this->posts->save($published); - - // Broadcast custom event - $this->events->broadcast(new PostPublished($published, new DateTime())); - } -} -``` - -**Listen for it:** -```php -$this->events->listen(PostPublished::class, function(PostPublished $event) { - $this->emailService->notifySubscribers($event->post); - $this->searchIndex->updatePost($event->post); -}); -``` - ---- - -## Combining Caching and Events - -Caching and events work together seamlessly: - -```php -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - public function save(Model $item): Model - { - $isNew = !$item->getId(); - - // Save to database - $result = parent::save($item); - - // Clear cache - $this->cache->forget(['id' => $result->getId()]); - $this->cache->forgetMatching('posts:list:*'); - - // Broadcast event - if ($isNew) { - $this->events->broadcast(new RecordCreated('posts', $result)); - } else { - $this->events->broadcast(new RecordUpdated('posts', $result)); - } - - return $result; - } -} -``` - -**Flow:** -1. **Write** — Save to database -2. **Invalidate** — Clear affected caches -3. **Notify** — Broadcast event to listeners -4. **React** — Listeners update derived data, send notifications, etc. - ---- - -## Event-Driven Cache Warming - -Use events to proactively warm caches: - -```php -$this->events->listen(RecordUpdated::class, function(RecordUpdated $event) { - if ($event->table === 'posts') { - // Warm cache for commonly accessed queries - $this->postDatastore->get(['status' => 'published']); - $this->postDatastore->get(['featured' => true]); - } -}); -``` - ---- - -## Cache Miss Events - -`CacheableService` broadcasts a `CacheMissed` event you can track: - -```php -use PHPNomad\Cache\Events\CacheMissed; - -$this->events->listen(CacheMissed::class, function(CacheMissed $event) { - // Log cache misses for monitoring - $this->logger->info("Cache miss: {$event->operation}", $event->context); -}); -``` - ---- - -## Best Practices - -### Cache Strategically - -```php -// ✅ GOOD: cache expensive queries -$posts = $this->cache->getWithCache('list', ['author_id' => 123], fn() => - $this->queryBuilder->select('*')->from($this->table)->where(...)->build() -); - -// ❌ BAD: caching single writes -$this->cache->getWithCache('save', [], fn() => $this->save($post)); -``` - -### Use Descriptive Cache Keys - -```php -// ✅ GOOD: clear, structured keys -"posts:123" -"posts:list:author:456" -"posts:count:published" - -// ❌ BAD: opaque keys -"p123" -"query_result" -``` - -### Invalidate Broadly on Writes - -```php -// ✅ GOOD: clear related caches -$this->cache->forget(['id' => $id]); -$this->cache->forgetMatching('posts:list:*'); -$this->cache->forgetMatching('posts:count:*'); - -// ❌ BAD: only clear one entry -$this->cache->forget(['id' => $id]); -``` - -### Keep Events Lightweight - -```php -// ✅ GOOD: quick event listener -$this->events->listen(RecordCreated::class, fn($e) => - $this->queue->push(new SendNotificationJob($e->model)) -); - -// ❌ BAD: slow event listener blocks handler -$this->events->listen(RecordCreated::class, function($e) { - $this->emailService->sendToAllSubscribers($e->model); // Slow! -}); -``` - -### Use Events for Side Effects - -```php -// ✅ GOOD: side effects in event listeners -$this->events->listen(PostPublished::class, fn($e) => - $this->searchIndex->update($e->post) -); - -// ❌ BAD: side effects in handler -public function save(Model $item): Model { - $result = parent::save($item); - $this->searchIndex->update($result); // Couples handler to search - return $result; -} -``` - ---- - -## 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 -* [Query Building](/packages/database/query-building) — building cacheable queries -* [Database Service Provider](/packages/database/database-service-provider) — configuring caching and events diff --git a/public/docs/docs/packages/database/database-service-provider.md b/public/docs/docs/packages/database/database-service-provider.md deleted file mode 100644 index d1014d5..0000000 --- a/public/docs/docs/packages/database/database-service-provider.md +++ /dev/null @@ -1,473 +0,0 @@ -# DatabaseServiceProvider - -The `DatabaseServiceProvider` is a **dependency container** that provides all the services database handlers need to function. It bundles query builders, cache services, event broadcasting, and logging into a single injectable dependency, simplifying handler construction. - -Instead of injecting 5-6 separate dependencies into every handler, you inject one `DatabaseServiceProvider` and access its public properties. - -## What It Provides - -The `DatabaseServiceProvider` class exposes six services: - -```php -class DatabaseServiceProvider -{ - public LoggerStrategy $loggerStrategy; - public QueryStrategy $queryStrategy; - public CacheableService $cacheableService; - public QueryBuilder $queryBuilder; - public ClauseBuilder $clauseBuilder; - public EventStrategy $eventStrategy; -} -``` - -These services are injected into the provider via its constructor and made available as public properties. - ---- - -## Services Overview - -### 1. QueryBuilder - -Builds safe, escaped SQL SELECT queries. - -**Usage:** -```php -$sql = $serviceProvider->queryBuilder - ->select('*') - ->from($table) - ->where($clause) - ->limit(10) - ->build(); -``` - -**See:** [Query Building](/packages/database/query-building) - ---- - -### 2. ClauseBuilder - -Constructs WHERE clauses for queries. - -**Usage:** -```php -$clause = $serviceProvider->clauseBuilder - ->useTable($table) - ->where('author_id', '=', 123) - ->andWhere('status', '=', 'published'); -``` - -**See:** [Query Building](/packages/database/query-building) - ---- - -### 3. QueryStrategy - -Executes SQL queries against the database. - -**Usage:** -```php -// Execute query and return results -$rows = $serviceProvider->queryStrategy->query($sql); - -// Execute query and return single row -$row = $serviceProvider->queryStrategy->querySingle($sql); - -// Execute mutation (INSERT, UPDATE, DELETE) -$affected = $serviceProvider->queryStrategy->execute($sql); -``` - ---- - -### 4. CacheableService - -Provides automatic caching for query results. - -**Usage:** -```php -$post = $serviceProvider->cacheableService->getWithCache( - operation: 'find', - context: ['id' => 123], - callback: fn() => $this->executeQuery("SELECT * FROM posts WHERE id = 123") -); -``` - -**See:** [Caching and Events](/packages/database/caching-and-events) - ---- - -### 5. EventStrategy - -Broadcasts events to registered listeners. - -**Usage:** -```php -$serviceProvider->eventStrategy->broadcast( - new RecordCreated('posts', $post) -); -``` - -**See:** [Caching and Events](/packages/database/caching-and-events) - ---- - -### 6. LoggerStrategy - -Logs errors, warnings, and debug information. - -**Usage:** -```php -$serviceProvider->loggerStrategy->error('Database query failed', [ - 'query' => $sql, - 'error' => $exception->getMessage() -]); - -$serviceProvider->loggerStrategy->debug('Query executed', [ - 'query' => $sql, - 'duration' => $duration -]); -``` - ---- - -## Using DatabaseServiceProvider in Handlers - -Handlers receive the provider via constructor injection: - -```php -queryBuilder = $serviceProvider->queryBuilder; - $this->clauseBuilder = $serviceProvider->clauseBuilder; - $this->cache = $serviceProvider->cacheableService; - $this->events = $serviceProvider->eventStrategy; - $this->logger = $serviceProvider->loggerStrategy; - - // Set handler properties - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; - } - - public function findPublished(): array - { - try { - return $this->cache->getWithCache( - 'list:published', - [], - function() { - $clause = $this->clauseBuilder - ->useTable($this->table) - ->where('status', '=', 'published'); - - $sql = $this->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause) - ->build(); - - $rows = $this->serviceProvider->queryStrategy->query($sql); - - return array_map( - fn($row) => $this->modelAdapter->toModel($row), - $rows - ); - } - ); - } catch (\Exception $e) { - $this->logger->error('Failed to fetch published posts', [ - 'error' => $e->getMessage() - ]); - throw $e; - } - } -} -``` - ---- - -## Why Use a Service Provider? - -### Without Service Provider - -Every handler would need 6+ constructor parameters: - -```php -public function __construct( - QueryBuilder $queryBuilder, - ClauseBuilder $clauseBuilder, - QueryStrategy $queryStrategy, - CacheableService $cacheableService, - EventStrategy $eventStrategy, - LoggerStrategy $loggerStrategy, - PostsTable $table, - PostAdapter $adapter, - TableSchemaService $tableSchemaService -) { - // 9 dependencies! -} -``` - -### With Service Provider - -Only 4 constructor parameters: - -```php -public function __construct( - DatabaseServiceProvider $serviceProvider, - PostsTable $table, - PostAdapter $adapter, - TableSchemaService $tableSchemaService -) { - // 4 dependencies - much cleaner -} -``` - ---- - -## Registering DatabaseServiceProvider - -The provider is registered once in your DI container: - -```php -set(QueryBuilder::class, fn() => new QueryBuilder()); - $container->set(ClauseBuilder::class, fn() => new ClauseBuilder()); - $container->set(QueryStrategy::class, fn() => new MysqlQueryStrategy()); - $container->set(CacheableService::class, fn($c) => - new CacheableService( - $c->get(EventStrategy::class), - $c->get(CacheStrategy::class), - $c->get(CachePolicy::class) - ) - ); - $container->set(EventStrategy::class, fn() => new EventStrategy()); - $container->set(LoggerStrategy::class, fn() => new FileLogger()); - - // Register provider that bundles them all - $container->set(DatabaseServiceProvider::class, function($c) { - return new DatabaseServiceProvider( - loggerStrategy: $c->get(LoggerStrategy::class), - queryStrategy: $c->get(QueryStrategy::class), - queryBuilder: $c->get(QueryBuilder::class), - clauseBuilder: $c->get(ClauseBuilder::class), - cacheableService: $c->get(CacheableService::class), - eventStrategy: $c->get(EventStrategy::class) - ); - }); - } -} -``` - -Now every handler can inject `DatabaseServiceProvider` and access all services. - ---- - -## Accessing Services - -### Direct Access - -```php -$queryBuilder = $serviceProvider->queryBuilder; -$cache = $serviceProvider->cacheableService; -``` - -### In Base Class (IdentifiableDatabaseDatastoreHandler) - -The base handler stores the provider for internal use: - -```php -abstract class IdentifiableDatabaseDatastoreHandler -{ - protected DatabaseServiceProvider $serviceProvider; - - protected function executeQuery(string $sql): array - { - return $this->serviceProvider->queryStrategy->query($sql); - } - - protected function log(string $message, array $context = []): void - { - $this->serviceProvider->loggerStrategy->info($message, $context); - } -} -``` - -Your handlers inherit these helper methods. - ---- - -## Example: Complete Handler with Provider - -```php -model = Post::class; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; - } - - // All standard methods (find, get, save, delete) are provided by base class - // Base class uses $this->serviceProvider internally - - public function findBySlug(string $slug): ?Post - { - $clause = $this->serviceProvider->clauseBuilder - ->useTable($this->table) - ->where('slug', '=', $slug); - - $sql = $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause) - ->build(); - - try { - $row = $this->serviceProvider->queryStrategy->querySingle($sql); - return $row ? $this->modelAdapter->toModel($row) : null; - } catch (\Exception $e) { - $this->serviceProvider->loggerStrategy->error('Failed to find post by slug', [ - 'slug' => $slug, - 'error' => $e->getMessage() - ]); - throw $e; - } - } -} -``` - ---- - -## Benefits - -### 1. Simplified Constructor - -Reduces constructor complexity from 9+ parameters to 4. - -### 2. Consistent Service Access - -All handlers access the same service instances, ensuring consistency. - -### 3. Easy Mocking in Tests - -Mock one provider instead of 6 individual services: - -```php -$mockProvider = $this->createMock(DatabaseServiceProvider::class); -$mockProvider->queryBuilder = $this->createMock(QueryBuilder::class); -$mockProvider->cacheableService = $this->createMock(CacheableService::class); -// etc. - -$handler = new PostHandler($mockProvider, $table, $adapter, $schemaService); -``` - -### 4. Centralized Configuration - -Change implementations (e.g., swap MySQL for PostgreSQL) in one place: - -```php -$container->set(QueryStrategy::class, fn() => new PostgresQueryStrategy()); -// All handlers automatically use PostgreSQL -``` - ---- - -## Best Practices - -### Extract Services in Constructor - -```php -// ✅ GOOD: extract to properties -public function __construct(DatabaseServiceProvider $serviceProvider, ...) -{ - $this->queryBuilder = $serviceProvider->queryBuilder; - $this->cache = $serviceProvider->cacheableService; -} - -// ❌ BAD: access provider repeatedly -public function find($id) { - $this->serviceProvider->queryBuilder->select(...); // Verbose -} -``` - -### Don't Create Provider Manually - -```php -// ❌ BAD: manual instantiation -$provider = new DatabaseServiceProvider(...); - -// ✅ GOOD: inject from container -public function __construct(DatabaseServiceProvider $serviceProvider) -``` - -### Use Provider Properties, Not Methods - -The provider exposes services as **public properties**, not methods: - -```php -// ✅ GOOD: property access -$serviceProvider->queryBuilder - -// ❌ BAD: no getter methods -$serviceProvider->getQueryBuilder() // Doesn't exist -``` - ---- - -## What's Next - -* [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/docs/packages/database/handlers/identifiable-database-datastore-handler.md b/public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md deleted file mode 100644 index f95f78f..0000000 --- a/public/docs/docs/packages/database/handlers/identifiable-database-datastore-handler.md +++ /dev/null @@ -1,441 +0,0 @@ -# IdentifiableDatabaseDatastoreHandler - -The `IdentifiableDatabaseDatastoreHandler` is the **base class** for implementing database-backed datastores in PHPNomad. It provides complete implementations of all standard handler interfaces (find, get, save, delete, where, count) with built-in caching, event broadcasting, and query building. - -When you extend this class, you get a fully functional database handler with minimal code—just set a few properties in your constructor. - -## What It Provides - -By extending `IdentifiableDatabaseDatastoreHandler`, your handler automatically implements: - -* **DatastoreHandler** — `get()`, `save()`, `delete()` -* **DatastoreHandlerHasPrimaryKey** — `find(int $id)` -* **DatastoreHandlerHasWhere** — `where()` returning a query builder -* **DatastoreHandlerHasCounts** — `count(array $args)` - -Plus automatic: -* Query building with escaping -* Result caching with invalidation -* Event broadcasting on mutations -* Table schema management - ---- - -## Basic Usage - -```php -model = Post::class; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; - } -} -``` - -That's it! This handler now supports: -- `find($id)` - Find by primary key -- `get($args)` - Get multiple records -- `save($model)` - Create or update -- `delete($model)` - Remove record -- `where()` - Query builder -- `count($args)` - Count records - ---- - -## Required Properties - -You must set these five properties in your constructor: - -### `$model` -The model class name this handler works with. - -```php -$this->model = Post::class; -``` - -### `$table` -The table definition for database schema. - -```php -$this->table = $table; -``` - -### `$modelAdapter` -The adapter for converting between models and arrays. - -```php -$this->modelAdapter = $adapter; -``` - -### `$serviceProvider` -The database service provider with query builders, cache, events. - -```php -$this->serviceProvider = $serviceProvider; -``` - -### `$tableSchemaService` -Service for managing table creation and updates. - -```php -$this->tableSchemaService = $tableSchemaService; -``` - ---- - -## Provided Methods - -### `find(int $id): Model` - -Finds a single record by primary key. - -**Implementation:** -- Checks cache first -- On cache miss, executes SELECT query -- Converts result to model via adapter -- Stores in cache -- Returns model - -**Throws:** `RecordNotFoundException` if not found. - -**Example:** -```php -$post = $handler->find(42); -``` - ---- - -### `get(array $args = []): iterable` - -Retrieves multiple records matching criteria. - -**Implementation:** -- Builds WHERE clause from `$args` -- Executes SELECT query -- Converts each row to model -- Returns iterable collection - -**Example:** -```php -$posts = $handler->get(['author_id' => 123, 'status' => 'published']); -``` - ---- - -### `save(Model $item): Model` - -Creates or updates a record. - -**Implementation:** -- Converts model to array via adapter -- Determines INSERT or UPDATE based on primary key -- Executes query -- Invalidates cache -- Broadcasts `RecordCreated` or `RecordUpdated` event -- Returns saved model with generated ID (if new) - -**Example:** -```php -$newPost = new Post(null, 'Title', 'Content', 123, new DateTime()); -$savedPost = $handler->save($newPost); -echo $savedPost->id; // Now has an ID -``` - ---- - -### `delete(Model $item): void` - -Removes a record from the database. - -**Implementation:** -- Extracts primary key from model -- Executes DELETE query -- Invalidates cache -- Broadcasts `RecordDeleted` event - -**Example:** -```php -$post = $handler->find(42); -$handler->delete($post); -``` - ---- - -### `where(): DatastoreWhereQuery` - -Returns a query builder for complex filtering. - -**Returns:** Query interface with methods like `equals()`, `greaterThan()`, `orderBy()`, `limit()`. - -**Example:** -```php -$posts = $handler - ->where() - ->equals('author_id', 123) - ->greaterThan('view_count', 100) - ->orderBy('published_date', 'DESC') - ->limit(10) - ->getResults(); -``` - ---- - -### `count(array $args = []): int` - -Counts records matching criteria. - -**Implementation:** -- Builds WHERE clause from `$args` -- Executes SELECT COUNT(*) query -- Returns integer count - -**Example:** -```php -$publishedCount = $handler->count(['status' => 'published']); -``` - ---- - -## Custom Methods - -You can add custom business methods alongside the standard ones: - -```php -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - // ... standard setup ... - - /** - * Custom method: find posts by slug - */ - public function findBySlug(string $slug): ?Post - { - $clause = $this->serviceProvider->clauseBuilder - ->useTable($this->table) - ->where('slug', '=', $slug); - - $sql = $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause) - ->build(); - - $row = $this->serviceProvider->queryStrategy->querySingle($sql); - - return $row ? $this->modelAdapter->toModel($row) : null; - } - - /** - * Custom method: get top posts by view count - */ - public function getTopPosts(int $limit = 10): iterable - { - $sql = $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->orderBy('view_count', 'DESC') - ->limit($limit) - ->build(); - - $rows = $this->serviceProvider->queryStrategy->query($sql); - - return array_map( - fn($row) => $this->modelAdapter->toModel($row), - $rows - ); - } -} -``` - ---- - -## Caching Behavior - -All read operations are automatically cached: - -**Cached operations:** -- `find()` — cached by ID -- `get()` — cached by args hash -- `where()` results — cached by query hash - -**Cache invalidation:** -- `save()` — clears cache for that record -- `delete()` — clears cache for that record -- Both also clear list caches - -**Customize caching:** -```php -// Override to customize cache behavior -protected function getCacheKey(array $context): string -{ - return 'posts:' . md5(serialize($context)); -} - -protected function shouldCache(string $operation, array $context): bool -{ - // Don't cache queries with LIMIT > 100 - return !isset($context['limit']) || $context['limit'] <= 100; -} -``` - ---- - -## Event Broadcasting - -Mutations automatically broadcast events: - -**Events:** -- `RecordCreated` — after successful INSERT -- `RecordUpdated` — after successful UPDATE -- `RecordDeleted` — after successful DELETE - -**Customize events:** -```php -// Override to add custom events -public function save(Model $item): Model -{ - $isNew = !$item->getId(); - $result = parent::save($item); - - // Custom event - if ($isNew && $item->status === 'published') { - $this->serviceProvider->eventStrategy->broadcast( - new PostPublished($result) - ); - } - - return $result; -} -``` - ---- - -## Table Schema Management - -The handler ensures the table exists on first use: - -**Automatic table creation:** -- Checks if table exists -- Creates table if missing -- Updates table if schema version changed -- All transparent to your code - -**Table versioning:** -When you increment `$table->getTableVersion()`, the handler detects changes and updates the schema. - ---- - -## Error Handling - -The base handler includes error handling: - -**Exceptions thrown:** -- `RecordNotFoundException` — `find()` with invalid ID -- `QueryBuilderException` — malformed queries -- `DatabaseException` — connection/query errors - -**Logging:** -All errors are logged via `LoggerStrategy`: - -```php -try { - $post = $handler->find($id); -} catch (RecordNotFoundException $e) { - // Logged automatically: "Record not found: posts:123" -} -``` - ---- - -## Best Practices - -### Use WithDatastoreHandlerMethods Trait - -```php -// ✅ GOOD: use trait for standard implementations -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - use WithDatastoreHandlerMethods; -} - -// ❌ BAD: implement methods manually -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - public function find($id) { /* manual implementation */ } -} -``` - -### Set All Required Properties - -```php -// ✅ GOOD: all properties set -public function __construct(...) -{ - $this->model = Post::class; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; -} - -// ❌ BAD: missing properties -public function __construct(...) -{ - $this->table = $table; - // Missing model, adapter, etc. -} -``` - -### Keep Handlers Focused on Persistence - -```php -// ✅ GOOD: handler does storage only -public function save(Model $item): Model -{ - return parent::save($item); -} - -// ❌ BAD: handler contains business logic -public function save(Model $item): Model -{ - if ($item->publishedDate < new DateTime()) { - throw new ValidationException("Cannot publish in the past"); - } - return parent::save($item); -} -``` - -Business logic belongs in services, not handlers. - ---- - -## What's Next - -* [WithDatastoreHandlerMethods](/packages/database/handlers/with-datastore-handler-methods) — the trait that powers this base class -* [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/docs/packages/database/handlers/introduction.md b/public/docs/docs/packages/database/handlers/introduction.md deleted file mode 100644 index 6bc4e7f..0000000 --- a/public/docs/docs/packages/database/handlers/introduction.md +++ /dev/null @@ -1,218 +0,0 @@ -# Database Handlers - -Database handlers are the **storage implementation layer** of PHPNomad's datastore architecture. They implement the `DatastoreHandler` interface contracts and are responsible for translating high-level datastore operations (like `save()`, `find()`, or `where()`) into concrete database queries, cache interactions, and event broadcasts. - -While [datastore interfaces](/packages/datastore/interfaces/introduction) define the **public API** your application depends on, handler interfaces define the **storage contract** that backends must implement. Handlers live in the Service layer and are specific to a storage technology—in this case, SQL databases. - -## What Handlers Do - -Handlers are where **persistence logic** lives. A database handler: - -* Converts models to storage arrays via [ModelAdapter](/packages/datastore/model-adapters). -* Executes SQL queries using [QueryBuilder](/packages/database/query-building). -* Manages table schema via [Table](/packages/database/tables/introduction) definitions. -* Optionally caches results using [CacheableService](/packages/database/caching-and-events). -* Broadcasts events after mutations using [EventStrategy](/packages/database/caching-and-events). - -Your [Core datastore implementation](/packages/datastore/core-implementation) delegates to a handler. The handler does the actual work of talking to the database, while the Core class provides the public interface your application uses. - -## Handler Interfaces - -Just like datastore interfaces, handler interfaces are **composable**. The base `DatastoreHandler` interface provides minimal operations, and you can extend with additional capabilities as needed. - -### `DatastoreHandler` - -The minimal contract every handler must implement. - -```php -interface DatastoreHandler -{ - public function get(array $args = []): iterable; - public function save(Model $item): Model; - public function delete(Model $item): void; -} -``` - -### Extension Interfaces - -Handlers can implement additional interfaces to support more operations: - -* **`DatastoreHandlerHasPrimaryKey`** — adds `find(int $id): Model` for primary key lookups. -* **`DatastoreHandlerHasWhere`** — adds `where(): DatastoreWhereQuery` for query-builder filtering. -* **`DatastoreHandlerHasCounts`** — adds `count(array $args = []): int` for counting records. - -These mirror the [datastore interfaces](/packages/datastore/interfaces/introduction) but live on the storage side. - -## Base Handler Implementation: `IdentifiableDatabaseDatastoreHandler` - -PHPNomad provides a **base handler class** that implements all the standard handler interfaces and includes built-in support for caching, events, and query building. - -**`IdentifiableDatabaseDatastoreHandler`** is the recommended starting point for most database-backed datastores. It implements: - -* `DatastoreHandler` -* `DatastoreHandlerHasPrimaryKey` -* `DatastoreHandlerHasWhere` -* `DatastoreHandlerHasCounts` - -This means you get `get()`, `save()`, `delete()`, `find()`, `where()`, and `count()` out of the box. - -### What you provide - -To use `IdentifiableDatabaseDatastoreHandler`, you extend it and provide: - -1. **Table definition** — a [Table](/packages/database/tables/introduction) instance that defines your schema. -2. **ModelAdapter** — converts between models and database arrays. -3. **Dependencies** — QueryBuilder, CacheableService, EventStrategy (injected via constructor). - -### Example: basic handler - -```php -queryBuilder - ->select('posts.*, authors.name as author_name') - ->from('posts') - ->join('authors', 'posts.author_id', 'authors.id') - ->where('posts.id', '=', $id) - ->first(); - - if (!$row) { - throw new RecordNotFoundException("Post {$id} not found"); - } - - return $this->adapter->toModel($row); - } -} -``` - -Here we override `find()` to add a join. The base handler's version would work, but this gives us author data in a single query. - -## Boilerplate Reduction with `WithDatastoreHandlerMethods` - -If you're not extending `IdentifiableDatabaseDatastoreHandler` (e.g., building a REST handler or custom storage backend), you can use the **`WithDatastoreHandlerMethods`** trait to generate standard implementations. - -This trait is analogous to the [decorator traits](/packages/datastore/traits/introduction) in the datastore package. It provides default implementations for common handler patterns. - -**Example:** - -```php -final class CustomPostHandler implements - DatastoreHandler, - DatastoreHandlerHasPrimaryKey -{ - use WithDatastoreHandlerMethods; - - // Trait provides get(), save(), delete(), find() based on - // abstract methods you define (like getTable(), getAdapter(), etc.) -} -``` - -This is useful when you need more control than `IdentifiableDatabaseDatastoreHandler` provides but don't want to write everything from scratch. - -## Handler Dependencies - -Handlers typically depend on several collaborators: - -### Required - -* **[Table](/packages/database/tables/introduction)** — schema definition (columns, indexes, primary key). -* **[ModelAdapter](/packages/datastore/model-adapters)** — converts between models and storage arrays. -* **[QueryBuilder](/packages/database/query-building)** — builds and executes SQL queries. - -### Optional (but recommended) - -* **[CacheableService](/packages/database/caching-and-events)** — automatic result caching with invalidation. -* **[EventStrategy](/packages/database/caching-and-events)** — broadcasts events after mutations. - -These are injected via the constructor and provided by your initializer. - -## Best Practices - -When working with database handlers: - -* **Extend `IdentifiableDatabaseDatastoreHandler` by default** — it handles the common cases correctly. -* **Override only when necessary** — if the base handler's behavior works, don't replace it. -* **Keep handlers storage-focused** — business logic belongs in services, not handlers. -* **Use caching and events** — they're built in and cost almost nothing to enable. -* **Match handler interfaces to datastore interfaces** — if your datastore implements `DatastoreHasPrimaryKey`, your handler should implement `DatastoreHandlerHasPrimaryKey`. - -## What's Next - -To understand how handlers fit into the larger architecture, see: - -* [Table Definitions](/packages/database/tables/introduction) — define schemas for handlers to use -* [Query Building](/packages/database/query-building) — how handlers execute SQL -* [Caching and Events](/packages/database/caching-and-events) — automatic caching and event broadcasting -* [Datastore Interfaces](/packages/datastore/interfaces/introduction) — the public contracts handlers support diff --git a/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md b/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md deleted file mode 100644 index 416388e..0000000 --- a/public/docs/docs/packages/database/handlers/with-datastore-handler-methods.md +++ /dev/null @@ -1,496 +0,0 @@ -# WithDatastoreHandlerMethods Trait - -The `WithDatastoreHandlerMethods` trait provides the **actual implementation** of all standard datastore handler methods. When you extend `IdentifiableDatabaseDatastoreHandler` and use this trait, you get complete CRUD functionality with zero boilerplate. - -This trait is the workhorse that powers database handlers—it contains all the query building, caching, event broadcasting, and data conversion logic. - -## What It Provides - -The trait implements: - -* **CRUD operations** — `find()`, `get()`, `save()`, `delete()` -* **Query building** — constructs SQL queries with proper escaping -* **WHERE clause support** — `where()` method returning query builder -* **Counting** — `count()` method for efficient record counting -* **Automatic caching** — caches reads, invalidates on writes -* **Event broadcasting** — fires events on mutations -* **Error handling** — catches and logs database errors - -## Basic Usage - -```php -model = Post::class; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; - } -} -``` - -That's all you need—the trait provides all CRUD methods automatically. - ---- - -## Implemented Methods - -### `find(int $id): Model` - -**What it does:** -1. Generates cache key from ID -2. Checks cache for existing record -3. On cache miss: - - Builds SELECT query with WHERE id = ? - - Executes query via QueryStrategy - - Converts result row to model via adapter - - Stores in cache -4. Returns model - -**Throws:** `RecordNotFoundException` if ID doesn't exist - -**Generated SQL:** -```sql -SELECT * FROM wp_posts WHERE id = 123 -``` - ---- - -### `get(array $args = []): iterable` - -**What it does:** -1. Builds WHERE clause from `$args` array -2. Constructs SELECT query -3. Executes query -4. Converts each row to model via adapter -5. Returns iterable collection - -**Example args:** -```php -$args = [ - 'author_id' => 123, - 'status' => 'published', - 'limit' => 10, - 'offset' => 20 -]; -``` - -**Generated SQL:** -```sql -SELECT * FROM wp_posts -WHERE author_id = 123 AND status = 'published' -LIMIT 10 OFFSET 20 -``` - ---- - -### `save(Model $item): Model` - -**What it does:** -1. Converts model to array via adapter -2. Checks if model has primary key: - - **If NO key** → INSERT new record - - **If HAS key** → UPDATE existing record -3. Executes query via QueryStrategy -4. Invalidates cache for this record -5. Broadcasts event: - - `RecordCreated` for INSERT - - `RecordUpdated` for UPDATE -6. Returns model with ID populated - -**INSERT SQL:** -```sql -INSERT INTO wp_posts (title, content, author_id, published_date) -VALUES ('Title', 'Content', 123, '2024-01-01 12:00:00') -``` - -**UPDATE SQL:** -```sql -UPDATE wp_posts -SET title = 'New Title', content = 'New Content' -WHERE id = 123 -``` - ---- - -### `delete(Model $item): void` - -**What it does:** -1. Extracts primary key from model -2. Builds DELETE query -3. Executes query -4. Invalidates cache for this record -5. Broadcasts `RecordDeleted` event - -**Generated SQL:** -```sql -DELETE FROM wp_posts WHERE id = 123 -``` - ---- - -### `where(): DatastoreWhereQuery` - -**What it does:** -Returns a query builder instance configured for the handler's table. - -**Returns:** Object with fluent API: -- `equals(field, value)` -- `greaterThan(field, value)` -- `lessThan(field, value)` -- `in(field, ...values)` -- `like(field, pattern)` -- `orderBy(field, direction)` -- `limit(count)` -- `offset(count)` -- `getResults()` - -**Example:** -```php -$posts = $handler - ->where() - ->equals('status', 'published') - ->greaterThan('view_count', 100) - ->orderBy('published_date', 'DESC') - ->limit(10) - ->getResults(); -``` - ---- - -### `count(array $args = []): int` - -**What it does:** -1. Builds WHERE clause from `$args` -2. Constructs SELECT COUNT(*) query -3. Executes query -4. Returns integer count - -**Generated SQL:** -```sql -SELECT COUNT(*) FROM wp_posts -WHERE status = 'published' -``` - ---- - -## Caching Implementation - -The trait integrates with `CacheableService`: - -### Cache Keys - -**Single record:** -```php -// Cache key: posts:123 -$cacheKey = $this->table->getTableName() . ':' . $id; -``` - -**List queries:** -```php -// Cache key: posts:list:md5(serialize($args)) -$cacheKey = $this->table->getTableName() . ':list:' . md5(serialize($args)); -``` - -### Cache Invalidation - -On `save()` or `delete()`: -```php -// Clear single record cache -$this->serviceProvider->cacheableService->forget(['id' => $id]); - -// Clear all list caches for this table -$this->serviceProvider->cacheableService->forgetMatching( - $this->table->getTableName() . ':list:*' -); -``` - ---- - -## Event Broadcasting - -The trait broadcasts standard events: - -### RecordCreated - -Fired after successful INSERT: - -```php -$this->serviceProvider->eventStrategy->broadcast( - new RecordCreated( - table: $this->table->getTableName(), - model: $savedModel - ) -); -``` - -### RecordUpdated - -Fired after successful UPDATE: - -```php -$this->serviceProvider->eventStrategy->broadcast( - new RecordUpdated( - table: $this->table->getTableName(), - model: $savedModel - ) -); -``` - -### RecordDeleted - -Fired after successful DELETE: - -```php -$this->serviceProvider->eventStrategy->broadcast( - new RecordDeleted( - table: $this->table->getTableName(), - model: $deletedModel - ) -); -``` - ---- - -## Query Building Implementation - -The trait uses `QueryBuilder` and `ClauseBuilder` from the service provider: - -### Building SELECT Queries - -```php -protected function buildSelectQuery(array $args): string -{ - $clause = $this->serviceProvider->clauseBuilder - ->useTable($this->table); - - // Add WHERE conditions from args - foreach ($args as $field => $value) { - if ($field === 'limit' || $field === 'offset') { - continue; // Handle separately - } - $clause->where($field, '=', $value); - } - - $query = $this->serviceProvider->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause); - - // Handle pagination - if (isset($args['limit'])) { - $query->limit($args['limit']); - } - if (isset($args['offset'])) { - $query->offset($args['offset']); - } - - return $query->build(); -} -``` - ---- - -## Error Handling - -The trait includes comprehensive error handling: - -### RecordNotFoundException - -Thrown when `find()` doesn't locate a record: - -```php -if (!$row) { - throw new RecordNotFoundException( - "Record not found: {$this->table->getTableName()}:{$id}" - ); -} -``` - -### Database Errors - -Caught and logged: - -```php -try { - $result = $this->serviceProvider->queryStrategy->execute($sql); -} catch (DatabaseException $e) { - $this->serviceProvider->loggerStrategy->error( - 'Database query failed', - [ - 'table' => $this->table->getTableName(), - 'query' => $sql, - 'error' => $e->getMessage() - ] - ); - throw $e; -} -``` - ---- - -## Overriding Trait Methods - -You can override any trait method to customize behavior: - -### Override `save()` with Custom Logic - -```php -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - use WithDatastoreHandlerMethods { - save as private traitSave; // Rename trait method - } - - public function save(Model $item): Model - { - // Custom pre-save logic - $this->validatePost($item); - - // Call trait's save - $result = $this->traitSave($item); - - // Custom post-save logic - $this->updateSearchIndex($result); - - return $result; - } - - private function validatePost(Model $post): void - { - if (empty($post->title)) { - throw new ValidationException('Title required'); - } - } -} -``` - -### Override `find()` with Custom Caching - -```php -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - use WithDatastoreHandlerMethods; - - public function find(int $id): Model - { - // Custom cache key - $cacheKey = "posts:full:{$id}"; - - return $this->serviceProvider->cacheableService->getWithCache( - operation: 'find', - context: ['key' => $cacheKey], - callback: function() use ($id) { - // Execute query - $sql = $this->buildFindQuery($id); - $row = $this->serviceProvider->queryStrategy->querySingle($sql); - - if (!$row) { - throw new RecordNotFoundException("Post {$id} not found"); - } - - return $this->modelAdapter->toModel($row); - } - ); - } -} -``` - ---- - -## Required Properties - -The trait expects these properties to be set by your handler: - -```php -protected string $model; // Model class name -protected Table $table; // Table definition -protected ModelAdapter $modelAdapter; // Model adapter -protected DatabaseServiceProvider $serviceProvider; // Service provider -protected TableSchemaService $tableSchemaService; // Schema service -``` - -If any are missing, trait methods will fail with errors. - ---- - -## Best Practices - -### Always Use the Trait - -```php -// ✅ GOOD: use trait -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - use WithDatastoreHandlerMethods; -} - -// ❌ BAD: manual implementation -class PostHandler extends IdentifiableDatabaseDatastoreHandler -{ - public function find(int $id): Model - { - // Reimplementing what the trait already does - } -} -``` - -### Override Only When Necessary - -```php -// ✅ GOOD: override for specific needs -public function save(Model $item): Model -{ - $this->logSaveAttempt($item); - return $this->traitSave($item); -} - -// ❌ BAD: override without adding value -public function save(Model $item): Model -{ - return $this->traitSave($item); // No customization -} -``` - -### Use Proper Method Renaming - -```php -// ✅ GOOD: rename trait method to avoid conflicts -use WithDatastoreHandlerMethods { - save as private traitSave; -} - -public function save(Model $item): Model -{ - return $this->traitSave($item); -} - -// ❌ BAD: call parent (doesn't work with traits) -public function save(Model $item): Model -{ - return parent::save($item); // Error! -} -``` - ---- - -## What's Next - -* [IdentifiableDatabaseDatastoreHandler](/packages/database/handlers/identifiable-database-datastore-handler) — the base class that uses this trait -* [Database Handlers Introduction](/packages/database/handlers/introduction) — handler architecture overview -* [Query Building](/packages/database/query-building) — understanding query construction -* [Caching and Events](/packages/database/caching-and-events) — how caching and events work diff --git a/public/docs/docs/packages/database/included-factories/date-created-factory.md b/public/docs/docs/packages/database/included-factories/date-created-factory.md deleted file mode 100644 index 62bb7e2..0000000 --- a/public/docs/docs/packages/database/included-factories/date-created-factory.md +++ /dev/null @@ -1,171 +0,0 @@ -# DateCreatedFactory - -The `DateCreatedFactory` creates a standardized **timestamp column** that stores when a record was created. This factory provides consistent creation timestamp tracking across all entity tables. - -## Basic Usage - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } -} -``` - -## Generated Column Definition - -The factory creates: - -**Column name:** `date_created` -**Column type:** `DATETIME` -**Properties:** `NOT NULL DEFAULT CURRENT_TIMESTAMP` - -**Generated SQL:** -```sql -CREATE TABLE wp_posts ( - id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - date_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); -``` - -## Automatic Timestamp Behavior - -When you insert a new record, the database automatically sets `date_created`: - -```php -// Create and save new post -$newPost = new Post(null, 'My Title', 'Content', 123, null); -$savedPost = $handler->save($newPost); - -// Database automatically set date_created -echo $savedPost->dateCreated->format('Y-m-d H:i:s'); -// Output: 2024-01-15 14:23:45 -``` - -**Generated INSERT:** -```sql -INSERT INTO wp_posts (title, content, author_id) -VALUES ('My Title', 'Content', 123); --- date_created is automatically set to current timestamp -``` - -## Why Use This Factory? - -**Consistency and auditability:** -```php -// ✅ GOOD: all tables track creation time the same way -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } -} - -class UsersTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('username', 'VARCHAR', [100], 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } -} - -// ❌ BAD: inconsistent timestamp columns -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('created_at', 'TIMESTAMP', null, 'NOT NULL'), - ]; - } -} - -class UsersTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('creation_date', 'DATETIME', null, 'NULL'), - // Different name, nullable! - ]; - } -} -``` - -## Common Usage Pattern - -```php -toColumn(), - - // Business columns - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('author_id', 'BIGINT', null, 'NOT NULL'), - - // Timestamp columns at the end - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } -} -``` - -## Querying by Creation Date - -```php -// Find posts created in the last 7 days -$recentPosts = $handler - ->where() - ->greaterThan('date_created', (new DateTime('-7 days'))->format('Y-m-d H:i:s')) - ->orderBy('date_created', 'DESC') - ->getResults(); - -// Count posts created today -$todayCount = $handler->count([ - 'date_created >=' => (new DateTime('today'))->format('Y-m-d H:i:s') -]); -``` - -## What's Next - -* [DateModifiedFactory](/packages/database/included-factories/date-modified-factory) — automatic timestamp on record updates -* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column -* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/included-factories/date-modified-factory.md b/public/docs/docs/packages/database/included-factories/date-modified-factory.md deleted file mode 100644 index 46cfd87..0000000 --- a/public/docs/docs/packages/database/included-factories/date-modified-factory.md +++ /dev/null @@ -1,213 +0,0 @@ -# DateModifiedFactory - -The `DateModifiedFactory` creates a standardized **timestamp column** that automatically updates whenever a record is modified. This factory provides consistent update timestamp tracking across all entity tables. - -## Basic Usage - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } -} -``` - -## Generated Column Definition - -The factory creates: - -**Column name:** `date_modified` -**Column type:** `DATETIME` -**Properties:** `NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP` - -**Generated SQL:** -```sql -CREATE TABLE wp_posts ( - id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - date_created DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - date_modified DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP -); -``` - -## Automatic Timestamp Behavior - -The `date_modified` column automatically updates on every UPDATE: - -```php -// Create new post -$newPost = new Post(null, 'Title', 'Content', 123, null); -$savedPost = $handler->save($newPost); - -// Initial timestamps are the same -echo $savedPost->dateCreated->format('Y-m-d H:i:s'); -// Output: 2024-01-15 14:23:45 -echo $savedPost->dateModified->format('Y-m-d H:i:s'); -// Output: 2024-01-15 14:23:45 - -// Update the post later -sleep(5); -$savedPost->title = 'New Title'; -$updatedPost = $handler->save($savedPost); - -// date_created unchanged, date_modified updated -echo $updatedPost->dateCreated->format('Y-m-d H:i:s'); -// Output: 2024-01-15 14:23:45 (unchanged) -echo $updatedPost->dateModified->format('Y-m-d H:i:s'); -// Output: 2024-01-15 14:23:50 (updated) -``` - -**Generated UPDATE:** -```sql -UPDATE wp_posts -SET title = 'New Title', content = 'Content' -WHERE id = 123; --- date_modified is automatically set to current timestamp -``` - -## Why Use This Factory? - -**Automatic change tracking:** -```php -// ✅ GOOD: all tables track modifications the same way -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } -} - -// ❌ BAD: manual timestamp management -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('updated_at', 'DATETIME', null, 'NULL'), - // Not automatic - requires manual updates in code - ]; - } -} - -// ❌ BAD: application code managing timestamps -public function save(Model $item): Model -{ - $item->dateModified = new DateTime(); // Manual! - return parent::save($item); -} -``` - -## Common Usage Pattern - -```php -toColumn(), - - // Business columns - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('author_id', 'BIGINT', null, 'NOT NULL'), - new Column('status', 'VARCHAR', [20], 'NOT NULL'), - - // Timestamp columns at the end - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } -} -``` - -## Querying by Modification Date - -```php -// Find posts modified in the last hour -$recentlyModified = $handler - ->where() - ->greaterThan('date_modified', (new DateTime('-1 hour'))->format('Y-m-d H:i:s')) - ->orderBy('date_modified', 'DESC') - ->getResults(); - -// Find stale posts (not modified in 90 days) -$stalePosts = $handler - ->where() - ->lessThan('date_modified', (new DateTime('-90 days'))->format('Y-m-d H:i:s')) - ->getResults(); - -// Check if post was modified after creation -foreach ($posts as $post) { - if ($post->dateModified > $post->dateCreated) { - echo "Post {$post->id} has been edited\n"; - } -} -``` - -## Change Detection - -Use `date_modified` to detect and react to changes: - -```php -// Cache invalidation based on modification time -public function getCachedPost(int $id): Post -{ - $cacheKey = "post:{$id}"; - $cached = $this->cache->get($cacheKey); - - if ($cached) { - $current = $this->handler->find($id); - - // Invalidate if modified since cache - if ($current->dateModified > $cached->dateModified) { - $this->cache->forget($cacheKey); - $this->cache->set($cacheKey, $current); - return $current; - } - - return $cached; - } - - $post = $this->handler->find($id); - $this->cache->set($cacheKey, $post); - return $post; -} -``` - -## What's Next - -* [DateCreatedFactory](/packages/database/included-factories/date-created-factory) — automatic timestamp on record creation -* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column -* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/included-factories/foreign-key-factory.md b/public/docs/docs/packages/database/included-factories/foreign-key-factory.md deleted file mode 100644 index ea45e04..0000000 --- a/public/docs/docs/packages/database/included-factories/foreign-key-factory.md +++ /dev/null @@ -1,266 +0,0 @@ -# ForeignKeyFactory - -The `ForeignKeyFactory` creates a standardized **foreign key column** that references another table's primary key. This factory provides consistent relationship column definitions across all entity tables. - -## Basic Usage - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new ForeignKeyFactory('author'))->toColumn(), - ]; - } -} -``` - -## Generated Column Definition - -**Constructor:** `new ForeignKeyFactory('author')` - -**Column name:** `author_id` -**Column type:** `BIGINT` -**Properties:** `NOT NULL` - -**Generated SQL:** -```sql -CREATE TABLE wp_posts ( - id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL, - author_id BIGINT NOT NULL -); -``` - -## Naming Convention - -The factory automatically appends `_id` to your entity name: - -```php -// Input: 'author' → Output: 'author_id' -(new ForeignKeyFactory('author'))->toColumn() - -// Input: 'category' → Output: 'category_id' -(new ForeignKeyFactory('category'))->toColumn() - -// Input: 'parent_post' → Output: 'parent_post_id' -(new ForeignKeyFactory('parent_post'))->toColumn() -``` - -## Why Use This Factory? - -**Consistency across relationships:** -```php -// ✅ GOOD: all foreign keys follow same pattern -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - (new ForeignKeyFactory('author'))->toColumn(), - (new ForeignKeyFactory('category'))->toColumn(), - ]; - } -} - -class CommentsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new ForeignKeyFactory('post'))->toColumn(), - (new ForeignKeyFactory('author'))->toColumn(), - ]; - } -} - -// ❌ BAD: inconsistent foreign key definitions -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('author', 'INT', null, 'NOT NULL'), // Wrong type - new Column('categoryId', 'BIGINT', null, 'NOT NULL'), // Wrong naming - ]; - } -} - -class CommentsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('post_id', 'VARCHAR', [50], 'NULL'), // Wrong type, nullable - ]; - } -} -``` - -## Common Usage Pattern - -```php -toColumn(), - - // Business columns - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('slug', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('status', 'VARCHAR', [20], 'NOT NULL'), - - // Foreign keys - (new ForeignKeyFactory('author'))->toColumn(), - (new ForeignKeyFactory('category'))->toColumn(), - - // Timestamps - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - // Index foreign keys for join performance - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['category_id'], 'category_idx', 'INDEX'), - new Index(['slug'], 'slug_unique', 'UNIQUE'), - ]; - } -} -``` - -## Self-Referential Foreign Keys - -Use descriptive names for self-referential relationships: - -```php -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - - // Self-referential foreign key - (new ForeignKeyFactory('parent_post'))->toColumn(), - // Creates column: parent_post_id - ]; - } - - public function getIndices(): array - { - return [ - new Index(['parent_post_id'], 'parent_post_idx', 'INDEX'), - ]; - } -} -``` - -## Nullable Foreign Keys - -For optional relationships, make the column nullable: - -```php -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - - // Required relationship - (new ForeignKeyFactory('author'))->toColumn(), - - // Optional relationship - override constraint - new Column('featured_image_id', 'BIGINT', null, 'NULL'), - ]; - } -} -``` - -## Junction Table Usage - -Foreign key factories work perfectly in junction tables: - -```php -class PostTagsTable extends Table -{ - public function getColumns(): array - { - return [ - (new ForeignKeyFactory('post'))->toColumn(), - (new ForeignKeyFactory('tag'))->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - // Compound primary key - new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), - - // Individual indexes for joins - new Index(['post_id'], 'post_idx', 'INDEX'), - new Index(['tag_id'], 'tag_idx', 'INDEX'), - ]; - } -} -``` - -## Querying with Foreign Keys - -```php -// Find posts by author -$authorPosts = $handler->get(['author_id' => 123]); - -// Count posts per category -$categoryCount = $handler->count(['category_id' => 5]); - -// Find posts using query builder -$posts = $handler - ->where() - ->equals('author_id', 123) - ->equals('status', 'published') - ->orderBy('date_created', 'DESC') - ->getResults(); -``` - -## What's Next - -* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many relationships -* [Table Class](/packages/database/tables/table-class) — complete table definition reference -* [PrimaryKeyFactory](/packages/database/included-factories/primary-key-factory) — standardized primary key column diff --git a/public/docs/docs/packages/database/included-factories/introduction.md b/public/docs/docs/packages/database/included-factories/introduction.md deleted file mode 100644 index 98e0ee6..0000000 --- a/public/docs/docs/packages/database/included-factories/introduction.md +++ /dev/null @@ -1,367 +0,0 @@ -# Column and Index Factories - -PHPNomad's database package provides **pre-built factories** for common column and index patterns. These factories eliminate repetitive schema definitions and ensure consistency across tables. Instead of manually defining columns with `Column` every time, you use specialized factories that encode best practices and standard patterns. - -Factories are **building blocks** you compose in your [table definitions](/packages/database/tables/introduction). Each factory produces properly configured column or index objects with sensible defaults, while still allowing customization when needed. - -## Why Factories Exist - -Without factories, every table would repeat the same patterns: - -```php -// Repetitive: defining a primary key in every table -$this->columnFactory->int('id', 11)->autoIncrement()->notNull(); - -// Repetitive: defining timestamps in every table -$this->columnFactory->datetime('created_at')->default('CURRENT_TIMESTAMP')->notNull(); -$this->columnFactory->datetime('updated_at') - ->default('CURRENT_TIMESTAMP') - ->onUpdate('CURRENT_TIMESTAMP') - ->notNull(); -``` - -With factories, these become: - -```php -$this->primaryKeyFactory->create('id'); -$this->dateCreatedFactory->create('created_at'); -$this->dateModifiedFactory->create('updated_at'); -``` - -This reduces duplication, prevents typos, and makes table definitions scannable. - -## Column Factories - -PHPNomad provides four specialized column factories for the most common patterns. - -### `PrimaryKeyFactory` - -Creates **auto-increment integer primary keys**—the standard pattern for identifying rows. - -**What it creates:** -* `INT(11)` column (or `BIGINT` if specified) -* `NOT NULL` constraint -* `AUTO_INCREMENT` attribute -* Primary key designation - -**Usage:** - -```php -final class PostTable implements Table -{ - public function __construct( - private Column $columnFactory, - private PrimaryKeyFactory $primaryKeyFactory - ) {} - - public function getColumns(): array - { - return [ - $this->primaryKeyFactory->create('id'), - // other columns... - ]; - } - - public function getPrimaryKey(): PrimaryKey - { - return $this->primaryKeyFactory->create('id'); - } -} -``` - -**Customization:** - -```php -// Use BIGINT for very large tables -$this->primaryKeyFactory->create('id', size: 'big'); -``` - ---- - -### `DateCreatedFactory` - -Creates **timestamp columns** that automatically capture when a row was created. - -**What it creates:** -* `DATETIME` column -* `DEFAULT CURRENT_TIMESTAMP` -* `NOT NULL` constraint - -**Usage:** - -```php -public function getColumns(): array -{ - return [ - $this->primaryKeyFactory->create('id'), - $this->columnFactory->string('title', 255)->notNull(), - $this->dateCreatedFactory->create('created_at'), - ]; -} -``` - -This column is set once when the row is inserted and never changes. - -**When to use:** -* Audit trails (knowing when records were added) -* Sorting by creation time -* Tracking data freshness - ---- - -### `DateModifiedFactory` - -Creates **timestamp columns** that automatically update whenever a row changes. - -**What it creates:** -* `DATETIME` column -* `DEFAULT CURRENT_TIMESTAMP` -* `ON UPDATE CURRENT_TIMESTAMP` -* `NOT NULL` constraint - -**Usage:** - -```php -public function getColumns(): array -{ - return [ - $this->primaryKeyFactory->create('id'), - $this->columnFactory->string('title', 255)->notNull(), - $this->dateCreatedFactory->create('created_at'), - $this->dateModifiedFactory->create('updated_at'), - ]; -} -``` - -This column is updated automatically by the database every time the row is modified. - -**When to use:** -* Tracking when records were last changed -* Cache invalidation (e.g., "invalidate if `updated_at` is newer than cached timestamp") -* Detecting stale data - ---- - -### `ForeignKeyFactory` - -Creates **foreign key columns** that reference primary keys in other tables. - -**What it creates:** -* `INT` column matching the referenced table's primary key type -* `NOT NULL` constraint (by default) -* Foreign key constraint pointing to the target table -* Optional `ON DELETE` and `ON UPDATE` rules - -**Usage:** - -```php -public function __construct( - private Column $columnFactory, - private ForeignKeyFactory $foreignKeyFactory -) {} - -public function getColumns(): array -{ - return [ - $this->primaryKeyFactory->create('id'), - $this->columnFactory->string('title', 255)->notNull(), - - // Reference the 'id' column in the 'authors' table - $this->foreignKeyFactory->create('author_id', 'authors', 'id'), - - $this->dateCreatedFactory->create('created_at'), - ]; -} -``` - -**Customization:** - -```php -// Allow NULL (optional foreign key) -$this->foreignKeyFactory->create('author_id', 'authors', 'id', nullable: true); - -// Cascade deletes (delete posts when author is deleted) -$this->foreignKeyFactory->create('author_id', 'authors', 'id', onDelete: 'CASCADE'); - -// Set NULL on delete (orphan posts when author is deleted) -$this->foreignKeyFactory->create('author_id', 'authors', 'id', onDelete: 'SET NULL', nullable: true); -``` - -**When to use:** -* Relationships between tables (posts → authors, orders → customers) -* Enforcing referential integrity at the database level -* Junction tables (many-to-many relationships) - ---- - -## Index Factory - -Indexes improve query performance by allowing the database to find rows faster. The `IndexFactory` creates single-column and composite indexes. - -### `IndexFactory::create()` - -Creates a **single-column index**. - -**Usage:** - -```php -public function __construct( - private IndexFactory $indexFactory -) {} - -public function getIndexes(): array -{ - return [ - // Index on author_id for "find all posts by author" queries - $this->indexFactory->create('idx_author', ['author_id']), - - // Index on published_date for sorting and range queries - $this->indexFactory->create('idx_published', ['published_date']), - ]; -} -``` - -### `IndexFactory::composite()` - -Creates a **composite index** that spans multiple columns. These are useful for queries that filter on multiple fields at once. - -**Usage:** - -```php -public function getIndexes(): array -{ - return [ - // Composite index for "posts by author, sorted by publish date" - $this->indexFactory->composite('idx_author_date', ['author_id', 'published_date']), - ]; -} -``` - -**When to use composite indexes:** -* Queries with multiple `WHERE` conditions -* Queries that filter and sort on different columns -* Covering indexes (include all columns needed by a query) - -**Note:** Column order matters. The index `['author_id', 'published_date']` can serve: -* `WHERE author_id = 123` -* `WHERE author_id = 123 ORDER BY published_date` - -But not: -* `WHERE published_date > '2024-01-01'` (doesn't start with `author_id`) - ---- - -## Real-World Example: Full Table with Factories - -Here's a complete table that uses all the factories: - -```php -primaryKeyFactory->create('id'), - - // Regular columns - $this->columnFactory->string('title', 255)->notNull(), - $this->columnFactory->text('content')->notNull(), - $this->columnFactory->string('slug', 255)->notNull()->unique(), - $this->columnFactory->datetime('published_date')->nullable(), - - // Foreign key to authors table - $this->foreignKeyFactory->create('author_id', 'authors', 'id'), - - // Timestamps - $this->dateCreatedFactory->create('created_at'), - $this->dateModifiedFactory->create('updated_at'), - ]; - } - - public function getPrimaryKey(): PrimaryKey - { - return $this->primaryKeyFactory->create('id'); - } - - public function getIndexes(): array - { - return [ - // Single-column indexes - $this->indexFactory->create('idx_author', ['author_id']), - $this->indexFactory->create('idx_published', ['published_date']), - $this->indexFactory->create('idx_slug', ['slug']), // unique already indexed, but explicit - - // Composite index for "author's published posts, sorted by date" - $this->indexFactory->composite('idx_author_published', [ - 'author_id', - 'published_date' - ]), - ]; - } -} -``` - -This table uses factories for: -* Primary key (`id`) -* Foreign key (`author_id`) -* Timestamps (`created_at`, `updated_at`) -* Indexes (single and composite) - -The result is a clean, readable schema definition with minimal boilerplate. - -## Best Practices - -When using factories: - -* **Inject factories via constructor** — let the DI container provide them. -* **Use factories for standard patterns** — don't manually define primary keys or timestamps. -* **Customize when needed** — factories accept parameters for common variations (nullable, cascade, etc.). -* **Name indexes descriptively** — use `idx_` prefix and column names (e.g., `idx_author_date`). -* **Add indexes on foreign keys** — always index columns used in joins. -* **Consider composite indexes** — they're more efficient than multiple single-column indexes for multi-condition queries. - -## When NOT to Use Factories - -Factories are for **common patterns**. For unique or domain-specific columns, use the base `Column` factory: - -```php -// Custom columns that don't fit factory patterns -$this->columnFactory->decimal('price', 10, 2)->notNull(), -$this->columnFactory->json('metadata')->nullable(), -$this->columnFactory->enum('status', ['draft', 'published', 'archived'])->default('draft'), -``` - -Factories reduce boilerplate for **90% of columns**. The remaining 10% are domain-specific and should use `Column` directly. - -## What's Next - -To see how these factories are used in context, see: - -* [Table Definitions](/packages/database/tables/introduction) — how factories compose into full table schemas -* [Individual Factory Docs](/packages/database/included-factories/primary-key-factory) — detailed API reference for each factory -* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/included-factories/primary-key-factory.md b/public/docs/docs/packages/database/included-factories/primary-key-factory.md deleted file mode 100644 index be5ed16..0000000 --- a/public/docs/docs/packages/database/included-factories/primary-key-factory.md +++ /dev/null @@ -1,136 +0,0 @@ -# PrimaryKeyFactory - -The `PrimaryKeyFactory` creates a standardized **auto-incrementing primary key column** named `id`. This factory provides a consistent primary key definition across all entity tables. - -## Basic Usage - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - ]; - } -} -``` - -## Generated Column Definition - -The factory creates: - -**Column name:** `id` -**Column type:** `BIGINT` -**Properties:** `AUTO_INCREMENT NOT NULL PRIMARY KEY` - -**Generated SQL:** -```sql -CREATE TABLE wp_posts ( - id BIGINT AUTO_INCREMENT NOT NULL PRIMARY KEY, - title VARCHAR(255) NOT NULL, - content TEXT NOT NULL -); -``` - -## Why Use This Factory? - -**Consistency across tables:** -```php -// ✅ GOOD: all tables have same primary key definition -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - // ... - ]; - } -} - -class UsersTable extends Table -{ - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - // ... - ]; - } -} - -// ❌ BAD: manual definitions can vary -class PostsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('id', 'BIGINT', null, 'AUTO_INCREMENT NOT NULL PRIMARY KEY'), - // ... - ]; - } -} - -class UsersTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('user_id', 'INT', null, 'AUTO_INCREMENT NOT NULL PRIMARY KEY'), - // Different name and type! - ]; - } -} -``` - -## Common Usage Pattern - -```php -toColumn(), - - // Business columns - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('slug', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('author_id', 'BIGINT', null, 'NOT NULL'), - ]; - } - - public function getIndices(): array - { - return [ - // No need to define primary key index - it's automatic - new Index(['slug'], 'slug_unique', 'UNIQUE'), - new Index(['author_id'], 'author_idx', 'INDEX'), - ]; - } -} -``` - -## What's Next - -* [DateCreatedFactory](/packages/database/included-factories/date-created-factory) — automatic timestamp on record creation -* [DateModifiedFactory](/packages/database/included-factories/date-modified-factory) — automatic timestamp on record updates -* [Table Class](/packages/database/tables/table-class) — complete table definition reference diff --git a/public/docs/docs/packages/database/introduction.md b/public/docs/docs/packages/database/introduction.md deleted file mode 100644 index 7c8cca8..0000000 --- a/public/docs/docs/packages/database/introduction.md +++ /dev/null @@ -1,424 +0,0 @@ ---- -id: database-introduction -slug: docs/packages/database/introduction -title: Database Package -doc_type: explanation -status: active -language: en -owner: docs-team -last_reviewed: 2025-01-08 -applies_to: ["all"] -canonical: true -summary: The database package provides concrete database implementations of the datastore pattern with table schemas, query building, caching, and event broadcasting. -llm_summary: > - phpnomad/database implements the datastore interfaces for SQL databases. Define table schemas, create handlers that - query databases, leverage automatic caching and event broadcasting, and use query builders for complex conditions. - Works with MySQL, MariaDB, and compatible databases. -questions_answered: - - What is the database package? - - What does phpnomad/database provide? - - How does database persistence work? - - What are the key concepts in database datastores? - - When should I use the database package? - - How does caching work in database handlers? - - What events are broadcast? -audience: - - developers - - backend engineers - - database developers -tags: - - database - - package-overview - - persistence -llm_tags: - - database-package - - sql-persistence - - database-handlers -keywords: - - phpnomad database - - database package - - database persistence - - SQL datastores -related: - - ../../core-concepts/overview-and-architecture - - ../../core-concepts/getting-started-tutorial - - ../datastore/introduction -see_also: - - handlers/introduction - - tables/introduction - - table-schema-definition - - ../logger/introduction -noindex: false ---- - -# Database - -`phpnomad/database` provides **concrete database implementations** of the datastore pattern. It's designed to let you define **table schemas, execute queries, and persist models** in SQL databases while maintaining the storage-agnostic abstractions from `phpnomad/datastore`. - -At its core: - -* **Table classes** define database schemas including columns, indices, and versioning. -* **Database handlers** implement datastore interfaces with actual SQL queries. -* **Query builders** construct SQL from condition arrays without writing raw queries. -* **Caching** automatically stores retrieved models to reduce database hits. -* **Event broadcasting** emits events when records are created, updated, or deleted. - -By implementing the datastore interfaces with database-backed handlers, you get full CRUD operations, complex querying, caching, and event notifications—all while keeping your domain logic portable. - ---- - -## Key ideas at a glance - -* **DatabaseDatastoreHandler** — Base class for database-backed handlers that query tables. -* **Table** — Schema definition including columns, indices, and versioning for migrations. -* **QueryBuilder** — Constructs SQL queries from condition arrays and parameters. -* **CacheableService** — Automatic caching layer that stores retrieved models by identity. -* **EventStrategy** — Broadcasts RecordCreated, RecordUpdated, RecordDeleted events. -* **WithDatastoreHandlerMethods** — Trait providing complete CRUD implementation. - ---- - -## The database persistence lifecycle - -When your application performs a data operation through a database-backed datastore, the request flows through these layers: - -``` -Application → Datastore → DatabaseHandler → QueryBuilder → Database → ModelAdapter → Model - ↓ ↑ - Cache Check Cache Store - ↓ - Event Broadcast -``` - -### Application layer - -Your application calls methods on the Datastore interface: - -```php -$post = $postDatastore->find(123); -$posts = $postDatastore->where([ - ['column' => 'status', 'operator' => '=', 'value' => 'published'] -]); -``` - -### Datastore layer - -The Datastore delegates to its database handler: - -```php -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastorePrimaryKeyDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatabaseDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } -} -``` - -### Database handler layer - -The **Database Handler** extends `IdentifiableDatabaseDatastoreHandler` and uses the `WithDatastoreHandlerMethods` trait to implement all standard operations: - -```php -class PostDatabaseDatastoreHandler extends IdentifiableDatabaseDatastoreHandler - implements PostDatastoreHandler -{ - use WithDatastoreHandlerMethods; - - public function __construct( - DatabaseServiceProvider $serviceProvider, - PostsTable $table, - PostAdapter $adapter, - TableSchemaService $tableSchemaService - ) { - $this->serviceProvider = $serviceProvider; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->tableSchemaService = $tableSchemaService; - $this->model = Post::class; - } -} -``` - -### Cache check - -Before querying the database, the handler checks the cache: - -```php -$cacheKey = ['identities' => ['id' => 123], 'type' => Post::class]; -if ($cached = $this->serviceProvider->cacheableService->get($cacheKey)) { - return $cached; // Cache hit, skip database -} -``` - -### Query building - -The handler uses `QueryBuilder` to construct SQL: - -```php -$query = $this->serviceProvider->queryBuilder - ->select() - ->from($this->table) - ->where('id', '=', 123) - ->build(); -``` - -The `QueryBuilder` generates parameterized SQL with placeholders to prevent injection. - -### Database execution - -The query executes against the database and returns raw rows: - -```php -$row = $this->serviceProvider->queryStrategy->execute($query); -``` - -### Model conversion - -The `ModelAdapter` converts the raw row to a model: - -```php -$post = $this->modelAdapter->toModel($row); -``` - -### Cache storage - -The model is stored in cache for future requests: - -```php -$this->serviceProvider->cacheableService->set($cacheKey, $post); -``` - -### Event broadcasting - -Events are broadcast after successful operations: - -```php -// After create -$this->serviceProvider->eventStrategy->dispatch(new RecordCreated($post)); - -// After update -$this->serviceProvider->eventStrategy->dispatch(new RecordUpdated($post)); - -// After delete -$this->serviceProvider->eventStrategy->dispatch(new RecordDeleted($post)); -``` - ---- - -## Why use the database package - -### Automatic caching - -Every find operation checks cache first. Subsequent requests for the same record return instantly without database queries. Cache invalidates automatically on updates and deletes. - -### Event-driven architecture - -Database operations broadcast events that other systems can listen to. Create audit logs, send notifications, update search indices, or trigger workflows—all decoupled from the handler. - -### Query abstraction - -No raw SQL in your handlers. Build queries with arrays and let `QueryBuilder` handle SQL generation, parameterization, and escaping. - -### Schema versioning - -Table definitions include version numbers. When schemas change, migrations can detect version differences and update tables accordingly. - -### Standardized patterns - -All database handlers follow the same pattern: extend the base, inject dependencies, implement interfaces. This consistency makes codebases predictable and maintainable. - ---- - -## Core components - -### Database handlers - -Handlers extend `IdentifiableDatabaseDatastoreHandler` and use `WithDatastoreHandlerMethods` to implement CRUD operations. They connect table schemas to datastore interfaces. - -See [Database Handlers](handlers/introduction) for complete documentation. - -### Table schemas - -Table classes extend `Table` and define columns, indices, and versioning. They specify how entities are stored in the database without writing DDL. - -```php -class PostsTable extends Table -{ - public function getUnprefixedName(): string - { - return 'posts'; - } - - public function getColumns(): array - { - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - new Index(['title'], 'idx_posts_title'), - ]; - } -} -``` - -See [Table Schema Definition](table-schema-definition) and [Tables](tables/introduction) for complete documentation. - -### Query building - -The `QueryBuilder` converts condition arrays and parameters into SQL queries. Conditions use a structured format that supports AND/OR logic, operators, and nested groups. - -```php -$posts = $handler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'status', 'operator' => '=', 'value' => 'published'], - ['column' => 'views', 'operator' => '>', 'value' => 1000] - ] - ] -], limit: 10); -``` - -See [Query Building](query-building) for complete documentation. - -### Caching and events - -The database package includes automatic caching and event broadcasting. Models are cached by identity and invalidated on mutations. Events broadcast after successful operations. - -See [Caching and Events](caching-and-events) for complete documentation. - -### Database service provider - -The `DatabaseServiceProvider` is injected into handlers and provides access to: - -- `QueryBuilder` — Constructs SQL queries -- `CacheableService` — Caches models -- `EventStrategy` — Broadcasts events -- `ClauseBuilder` — Builds WHERE clauses -- `LoggerStrategy` — Logs operations -- `QueryStrategy` — Executes queries - -See [DatabaseServiceProvider](database-service-provider) for complete documentation. - ---- - -## Column and index factories - -The database package provides factories for common column patterns: - -- **PrimaryKeyFactory** — Auto-incrementing integer primary key -- **DateCreatedFactory** — Timestamp with `DEFAULT CURRENT_TIMESTAMP` -- **DateModifiedFactory** — Timestamp with `ON UPDATE CURRENT_TIMESTAMP` -- **ForeignKeyFactory** — Foreign key columns with constraints - -```php -public function getColumns(): array -{ - return [ - (new PrimaryKeyFactory())->toColumn(), - new Column('authorId', 'BIGINT', null, 'NOT NULL'), - (new ForeignKeyFactory('author', 'authors', 'id'))->toColumn(), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; -} -``` - -See [Column and Index Factories](included-factories/introduction) for complete documentation. - ---- - -## Junction tables - -Many-to-many relationships use junction tables. The `JunctionTable` class automatically creates compound primary keys, foreign keys, and standard indices from two related tables. - -```php -class PostsTagsTable extends JunctionTable -{ - public function __construct( - // Base dependencies... - PostsTable $leftTable, - TagsTable $rightTable - ) { - parent::__construct(...func_get_args()); - } -} -``` - -See [Junction Tables](junction-tables) for complete documentation. - ---- - -## Supported databases - -The database package works with: - -- MySQL 5.7+ -- MariaDB 10.2+ -- Other MySQL-compatible databases - -The query builder generates standard SQL that should work across these systems. Platform-specific features (stored procedures, triggers, full-text search) are not abstracted. - ---- - -## When to use this package - -Use `phpnomad/database` when: - -- You're storing data in a SQL database -- You want automatic caching and event broadcasting -- Query building and schema versioning are valuable -- You're using the datastore pattern with database persistence - -If your data comes from REST APIs, GraphQL, or other non-database sources, you don't need this package. Use `phpnomad/datastore` and implement custom handlers. - ---- - -## Package components - -### Required reading - -- **[Database Handlers](handlers/introduction)** — Creating database-backed handlers -- **[Table Schema Definition](table-schema-definition)** — Defining database tables -- **[Tables](tables/introduction)** — Table base classes and patterns - -### Deep dives - -- **[Query Building](query-building)** — Condition arrays, operators, QueryBuilder -- **[Caching and Events](caching-and-events)** — How caching and event broadcasting work -- **[DatabaseServiceProvider](database-service-provider)** — Services available to handlers - -### Reference - -- **[Column and Index Factories](included-factories/introduction)** — Pre-built column factories -- **[Junction Tables](junction-tables)** — Many-to-many relationships - ---- - -## Relationship to other packages - -- **[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/event](../event/introduction)** — EventStrategy interface for broadcasting events -- **[phpnomad/logger](../logger/introduction)** — LoggerStrategy interface for operation logging - ---- - -## Next steps - -- **New to database datastores?** Start with [Getting Started Tutorial](../../core-concepts/getting-started-tutorial) -- **Ready to implement?** See [Database Handlers](handlers/introduction) -- **Need table schemas?** Check [Table Schema Definition](table-schema-definition) -- **Building complex queries?** Read [Query Building](query-building) diff --git a/public/docs/docs/packages/database/junction-tables.md b/public/docs/docs/packages/database/junction-tables.md deleted file mode 100644 index a6b405e..0000000 --- a/public/docs/docs/packages/database/junction-tables.md +++ /dev/null @@ -1,550 +0,0 @@ -# Junction Tables and Many-to-Many Relationships - -Junction tables (also called join tables or pivot tables) are used to represent **many-to-many relationships** between two entities. For example, posts can have many tags, and tags can be applied to many posts. A junction table stores these associations without duplicating data. - -PHPNomad provides patterns for defining junction table schemas and working with many-to-many relationships through datastores. - -## What is a Junction Table? - -A junction table has: - -* **Two foreign key columns** — one for each side of the relationship -* **Compound primary key** — both foreign keys together form the primary key -* **No additional data** — it only stores associations (for pure many-to-many) -* **Indexes on both columns** — for efficient lookups in both directions - -### Example: Posts and Tags - -**Scenario:** Posts can have multiple tags, and tags can be applied to multiple posts. - -**Tables:** -* `posts` — stores post data (`id`, `title`, `content`, etc.) -* `tags` — stores tag data (`id`, `name`, `slug`, etc.) -* `post_tags` — junction table storing associations - -**Relationship:** -``` -posts (1) ←→ (many) post_tags (many) ←→ (1) tags -``` - ---- - -## Defining a Junction Table - -Junction tables extend `Table` and follow a specific pattern: - -```php - $model->postId, - 'tag_id' => $model->tagId, - ]; - } - - public function toModel(array $array): DataModel - { - return new PostTag( - postId: Arr::get($array, 'post_id'), - tagId: Arr::get($array, 'tag_id') - ); - } -} -``` - ---- - -## Junction Table Datastore - -Define the datastore interface and implementation: - -### Interface - -```php -handler->get(['post_id' => $postId]); - } - - public function getPostsForTag(int $tagId): iterable - { - return $this->handler->get(['tag_id' => $tagId]); - } - - public function addTagToPost(int $postId, int $tagId): void - { - $association = new PostTag($postId, $tagId); - $this->handler->save($association); - } - - public function removeTagFromPost(int $postId, int $tagId): void - { - $association = new PostTag($postId, $tagId); - $this->handler->delete($association); - } - - public function postHasTag(int $postId, int $tagId): bool - { - $results = $this->handler->get([ - 'post_id' => $postId, - 'tag_id' => $tagId, - ]); - - return !empty($results); - } -} -``` - ---- - -## Working with Junction Tables - -### Adding Associations - -```php -// Add multiple tags to a post -$postTags = $container->get(PostTagDatastore::class); - -$tagIds = [1, 5, 12, 23]; -foreach ($tagIds as $tagId) { - $postTags->addTagToPost(postId: 42, tagId: $tagId); -} -``` - -### Querying Associations - -```php -// Get all tags for a post -$associations = $postTags->getTagsForPost(42); - -foreach ($associations as $association) { - echo "Post 42 has tag {$association->tagId}\n"; -} - -// Get all posts for a tag -$associations = $postTags->getPostsForTag(5); - -foreach ($associations as $association) { - echo "Tag 5 is on post {$association->postId}\n"; -} -``` - -### Removing Associations - -```php -// Remove a specific tag from a post -$postTags->removeTagFromPost(postId: 42, tagId: 5); - -// Remove all tags from a post -$associations = $postTags->getTagsForPost(42); -foreach ($associations as $association) { - $postTags->delete($association); -} -``` - -### Checking Association Existence - -```php -if ($postTags->postHasTag(postId: 42, tagId: 5)) { - echo "Post 42 has tag 5"; -} -``` - ---- - -## Loading Related Data - -Junction tables are often used with joins to load related entities: - -### Service Layer Pattern - -```php -posts->find($postId); - - // Get tag associations for this post - $associations = $this->postTags->getTagsForPost($postId); - - // Load actual tag models - $tags = []; - foreach ($associations as $association) { - $tags[] = $this->tags->find($association->tagId); - } - - return [ - 'post' => $post, - 'tags' => $tags, - ]; - } - - /** - * Get all posts for a tag - */ - public function getPostsForTag(int $tagId): array - { - $tag = $this->tags->find($tagId); - - // Get post associations for this tag - $associations = $this->postTags->getPostsForTag($tagId); - - // Load actual post models - $posts = []; - foreach ($associations as $association) { - $posts[] = $this->posts->find($association->postId); - } - - return [ - 'tag' => $tag, - 'posts' => $posts, - ]; - } -} -``` - ---- - -## Junction Tables with Additional Data - -Sometimes you need to store **metadata** about the relationship itself. For example, storing when a tag was added to a post, or who added it. - -### Extended Junction Table - -```php -class PostTagsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('post_id', 'BIGINT', null, 'NOT NULL'), - new Column('tag_id', 'BIGINT', null, 'NOT NULL'), - - // Additional metadata - new Column('added_by_user_id', 'BIGINT', null, 'NULL'), - (new DateCreatedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), - new Index(['post_id'], 'post_idx', 'INDEX'), - new Index(['tag_id'], 'tag_idx', 'INDEX'), - new Index(['added_by_user_id'], 'user_idx', 'INDEX'), - ]; - } -} -``` - -### Extended Model - -```php -class PostTag implements DataModel -{ - public function __construct( - public readonly int $postId, - public readonly int $tagId, - public readonly ?int $addedByUserId = null, - public readonly ?\DateTime $createdAt = null - ) {} -} -``` - ---- - -## Multiple Junction Tables - -Complex systems often have multiple many-to-many relationships: - -**Example: E-commerce system** - -```php -// Products and categories -class ProductCategoriesTable extends Table { ... } - -// Orders and products (with quantity, price at time of order) -class OrderProductsTable extends Table -{ - public function getColumns(): array - { - return [ - new Column('order_id', 'BIGINT', null, 'NOT NULL'), - new Column('product_id', 'BIGINT', null, 'NOT NULL'), - new Column('quantity', 'INT', null, 'NOT NULL'), - new Column('price_at_purchase', 'DECIMAL', [10, 2], 'NOT NULL'), - ]; - } -} - -// Users and roles -class UserRolesTable extends Table { ... } -``` - ---- - -## Best Practices - -### Always Use Compound Primary Keys - -```php -// ✅ GOOD: compound primary key prevents duplicates -new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY') - -// ❌ BAD: allows duplicate associations -new Column('id', 'INT', null, 'NOT NULL AUTO_INCREMENT PRIMARY KEY') -new Column('post_id', 'BIGINT', null, 'NOT NULL') -new Column('tag_id', 'BIGINT', null, 'NOT NULL') -``` - -### Index Both Directions - -```php -// ✅ GOOD: supports queries in both directions -new Index(['post_id'], 'post_idx', 'INDEX'), // "tags for post" -new Index(['tag_id'], 'tag_idx', 'INDEX'), // "posts for tag" - -// ❌ BAD: only one direction is fast -new Index(['post_id'], 'post_idx', 'INDEX'), -``` - -### Name Consistently - -```php -// ✅ GOOD: consistent naming -post_tags table, post_id and tag_id columns - -// ❌ BAD: inconsistent -posts_to_tags table, postId and tagId columns -``` - -### Keep Models Simple - -```php -// ✅ GOOD: minimal junction model -class PostTag -{ - public function __construct( - public readonly int $postId, - public readonly int $tagId - ) {} -} - -// ❌ BAD: junction model with business logic -class PostTag -{ - public function isActive(): bool { ... } - public function validate(): void { ... } -} -``` - -### Use Batch Operations - -```php -// ✅ GOOD: batch insert -foreach ($tagIds as $tagId) { - $postTags->addTagToPost($postId, $tagId); -} - -// ✅ EVEN BETTER: if your datastore supports bulk operations -$postTags->bulkAddTags($postId, $tagIds); -``` - ---- - -## What's Next - -* [Table Schema Definition](/packages/database/table-schema-definition) — detailed table definition reference -* [Database Handlers](/packages/database/handlers/introduction) — implementing handlers for junction tables -* [Model Adapters](/packages/datastore/model-adapters) — creating adapters for junction models diff --git a/public/docs/docs/packages/database/query-building.md b/public/docs/docs/packages/database/query-building.md deleted file mode 100644 index 1e0933e..0000000 --- a/public/docs/docs/packages/database/query-building.md +++ /dev/null @@ -1,578 +0,0 @@ -# Query Building - -PHPNomad's database package provides a **fluent query builder** for constructing safe, escaped SQL queries. The -`QueryBuilder` interface offers a chainable API for building SELECT queries with WHERE clauses, joins, grouping, -ordering, and pagination—without writing raw SQL. - -Query building is used primarily in [database handlers](/packages/database/handlers/introduction) to execute queries -against tables defined by [table schemas](/packages/database/table-schema-definition). - -## Core Components - -### QueryBuilder - -The main interface for building SELECT queries. Provides methods for: - -* Selecting fields -* Setting FROM clause -* Adding WHERE conditions via `ClauseBuilder` -* JOINs (LEFT, RIGHT) -* Grouping and aggregations (SUM, COUNT) -* Ordering and pagination (ORDER BY, LIMIT, OFFSET) - -### ClauseBuilder - -A specialized builder for constructing WHERE clauses with: - -* Multiple conditions (AND, OR) -* Comparison operators (=, <, >, IN, LIKE, BETWEEN, etc.) -* Grouped conditions (parentheses) -* Proper escaping and sanitization - ---- - -## Basic Query Building - -### Simple SELECT Query - -```php -queryBuilder - ->select('id', 'title', 'content') - ->from($this->table) - ->build(); - - // Execute query and return results - return $this->executeQuery($sql); - } -} -``` - -**Generated SQL:** - -```sql -SELECT id, title, content FROM wp_posts -``` - ---- - -### SELECT with WHERE Clause - -```php -public function getPostsByAuthor(int $authorId): array -{ - $clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', $authorId); - - $sql = $this->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause) - ->build(); - - return $this->executeQuery($sql); -} -``` - -**Generated SQL:** - -```sql -SELECT * FROM wp_posts WHERE author_id = 123 -``` - ---- - -## ClauseBuilder API - -The `ClauseBuilder` constructs WHERE clauses with proper escaping. - -### Comparison Operators - -**Equality:** - -```php -$clause->where('status', '=', 'published'); -// WHERE status = 'published' -``` - -**Inequality:** - -```php -$clause->where('view_count', '>', 100); -// WHERE view_count > 100 - -$clause->where('view_count', '>=', 50); -// WHERE view_count >= 50 - -$clause->where('view_count', '<', 1000); -// WHERE view_count < 1000 -``` - -**IN operator:** - -```php -$clause->where('status', 'IN', 'published', 'featured', 'archived'); -// WHERE status IN ('published', 'featured', 'archived') -``` - -**NOT IN:** - -```php -$clause->where('status', 'NOT IN', 'draft', 'pending'); -// WHERE status NOT IN ('draft', 'pending') -``` - -**LIKE operator:** - -```php -$clause->where('title', 'LIKE', '%wordpress%'); -// WHERE title LIKE '%wordpress%' -``` - -**BETWEEN:** - -```php -$clause->where('created_at', 'BETWEEN', '2024-01-01', '2024-12-31'); -// WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31' -``` - -**IS NULL / IS NOT NULL:** - -```php -$clause->where('published_date', 'IS NULL'); -// WHERE published_date IS NULL - -$clause->where('published_date', 'IS NOT NULL'); -// WHERE published_date IS NOT NULL -``` - ---- - -### Chaining Conditions - -**AND conditions:** - -```php -$clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', 123) - ->andWhere('status', '=', 'published') - ->andWhere('view_count', '>', 100); - -// WHERE author_id = 123 AND status = 'published' AND view_count > 100 -``` - -**OR conditions:** - -```php -$clause = $this->clauseBuilder - ->useTable($this->table) - ->where('status', '=', 'published') - ->orWhere('status', '=', 'featured'); - -// WHERE status = 'published' OR status = 'featured' -``` - -**Mixed AND/OR:** - -```php -$clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', 123) - ->andWhere('status', '=', 'published') - ->orWhere('status', '=', 'featured'); - -// WHERE author_id = 123 AND status = 'published' OR status = 'featured' -// Note: Operator precedence applies (AND before OR) -``` - ---- - -### Grouped Conditions - -For complex logic with parentheses, use `group()`: - -```php -// (status = 'published' OR status = 'featured') AND author_id = 123 - -$statusClause = $this->clauseBuilder - ->useTable($this->table) - ->where('status', '=', 'published') - ->orWhere('status', '=', 'featured'); - -$clause = $this->clauseBuilder - ->useTable($this->table) - ->group('AND', $statusClause) - ->andWhere('author_id', '=', 123); -``` - -**More complex grouping:** - -```php -// (author_id = 123 OR author_id = 456) AND (status = 'published' OR status = 'featured') - -$authorClause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', 123) - ->orWhere('author_id', '=', 456); - -$statusClause = $this->clauseBuilder - ->useTable($this->table) - ->where('status', '=', 'published') - ->orWhere('status', '=', 'featured'); - -$clause = $this->clauseBuilder - ->useTable($this->table) - ->group('AND', $authorClause) - ->andGroup('AND', $statusClause); -``` - ---- - -## QueryBuilder Methods - -### select() - -Specify columns to retrieve: - -```php -$queryBuilder->select('id', 'title', 'content'); -// SELECT id, title, content - -$queryBuilder->select('*'); -// SELECT * -``` - ---- - -### from() - -Set the table for the query: - -```php -$queryBuilder->from($this->table); -// FROM wp_posts (using table's prefixed name) -``` - ---- - -### where() - -Add a WHERE clause using a `ClauseBuilder`: - -```php -$clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', 123); - -$queryBuilder->where($clause); -// WHERE author_id = 123 -``` - -To remove a WHERE clause: - -```php -$queryBuilder->where(null); -``` - ---- - -### leftJoin() / rightJoin() - -Join tables: - -```php -$queryBuilder - ->select('posts.id', 'posts.title', 'users.name as author_name') - ->from($this->postsTable) - ->leftJoin($this->usersTable, 'posts.author_id', 'users.id'); - -// SELECT posts.id, posts.title, users.name as author_name -// FROM wp_posts -// LEFT JOIN wp_users ON posts.author_id = users.id -``` - ---- - -### groupBy() - -Group results: - -```php -$queryBuilder - ->select('author_id') - ->from($this->table) - ->groupBy('author_id'); - -// SELECT author_id FROM wp_posts GROUP BY author_id -``` - -Multiple columns: - -```php -$queryBuilder->groupBy('author_id', 'status'); -// GROUP BY author_id, status -``` - ---- - -### Aggregations: sum() and count() - -**COUNT:** - -```php -$queryBuilder - ->count('id', 'total_posts') - ->from($this->table); - -// SELECT COUNT(id) as total_posts FROM wp_posts -``` - -**SUM:** - -```php -$queryBuilder - ->sum('view_count', 'total_views') - ->from($this->table); - -// SELECT SUM(view_count) as total_views FROM wp_posts -``` - -**With GROUP BY:** - -```php -$queryBuilder - ->select('author_id') - ->count('id', 'post_count') - ->from($this->table) - ->groupBy('author_id'); - -// SELECT author_id, COUNT(id) as post_count FROM wp_posts GROUP BY author_id -``` - ---- - -### orderBy() - -Sort results: - -```php -$queryBuilder->orderBy('published_date', 'DESC'); -// ORDER BY published_date DESC - -$queryBuilder->orderBy('title', 'ASC'); -// ORDER BY title ASC -``` - ---- - -### limit() and offset() - -Pagination: - -```php -$queryBuilder - ->select('*') - ->from($this->table) - ->limit(10) - ->offset(20); - -// SELECT * FROM wp_posts LIMIT 10 OFFSET 20 -``` - ---- - -## Complete Query Example - -Here's a complex query demonstrating multiple features: - -```php -public function getPublishedPostsByAuthorsWithHighViews( - array $authorIds, - int $minViews, - int $page = 1, - int $perPage = 10 -): array { - // Build WHERE clause - $clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', 'IN', ...$authorIds) - ->andWhere('status', '=', 'published') - ->andWhere('view_count', '>=', $minViews) - ->andWhere('published_date', 'IS NOT NULL'); - - // Build full query - $sql = $this->queryBuilder - ->select('id', 'title', 'author_id', 'view_count', 'published_date') - ->from($this->table) - ->where($clause) - ->orderBy('view_count', 'DESC') - ->limit($perPage) - ->offset(($page - 1) * $perPage) - ->build(); - - return $this->executeQuery($sql); -} -``` - -**Generated SQL:** - -```sql -SELECT id, title, author_id, view_count, published_date -FROM wp_posts -WHERE author_id IN (123, 456, 789) - AND status = 'published' - AND view_count >= 100 - AND published_date IS NOT NULL -ORDER BY view_count DESC -LIMIT 10 OFFSET 20 -``` - ---- - -## Query Builder Reset - -Reuse a query builder instance by resetting it: - -```php -$queryBuilder->reset(); -// Clears all clauses and returns to default state - -$queryBuilder->resetClauses('where', 'limit', 'offset'); -// Clears specific clauses only -``` - ---- - -## Using QueryBuilder in Handlers - -Handlers receive `QueryBuilder` and `ClauseBuilder` from the `DatabaseServiceProvider`: - -```php -queryBuilder = $serviceProvider->queryBuilder; - $this->clauseBuilder = $serviceProvider->clauseBuilder; - $this->table = $table; - $this->adapter = $adapter; - } - - public function findPublished(): array - { - $clause = $this->clauseBuilder - ->useTable($this->table) - ->where('status', '=', 'published'); - - $sql = $this->queryBuilder - ->select('*') - ->from($this->table) - ->where($clause) - ->build(); - - $rows = $this->executeQuery($sql); - - return array_map( - fn($row) => $this->adapter->toModel($row), - $rows - ); - } -} -``` - ---- - -## Best Practices - -### Always Use ClauseBuilder for WHERE Clauses - -```php -// ✅ GOOD: proper escaping via ClauseBuilder -$clause = $this->clauseBuilder - ->useTable($this->table) - ->where('author_id', '=', $userInput); - -$queryBuilder->where($clause); - -// ❌ BAD: manual string concatenation (SQL injection risk!) -$sql = "WHERE author_id = " . $userInput; -``` - -### Build Queries, Don't Execute Raw SQL - -```php -// ✅ GOOD: use query builder -$sql = $this->queryBuilder - ->select('*') - ->from($this->table) - ->build(); - -// ❌ BAD: raw SQL strings -$sql = "SELECT * FROM wp_posts WHERE author_id = " . $id; -``` - -### Use Table Objects for FROM and JOIN - -```php -// ✅ GOOD: table object handles prefixes -$queryBuilder->from($this->postsTable); - -// ❌ BAD: hardcoded table name -$queryBuilder->from('wp_posts'); -``` - -### Reset Builders Between Queries - -```php -// ✅ GOOD: reset before reusing -$queryBuilder->reset(); -$queryBuilder->select('*')->from($this->table); - -// ❌ BAD: reusing without reset (accumulates clauses) -$queryBuilder->select('id'); // First query -$queryBuilder->select('*'); // Adds to first query! -``` - -### Validate User Input Before Queries - -```php -// ✅ GOOD: validate before building query -if (!in_array($status, ['draft', 'published', 'archived'])) { - throw new ValidationException("Invalid status"); -} - -$clause->where('status', '=', $status); - -// ClauseBuilder handles escaping, but validation prevents logic errors -``` - ---- - -## What's Next - -* [Database Handlers](/packages/database/handlers/introduction) — how handlers use query builders -* [Table Schema Definition](/packages/database/table-schema-definition) — defining tables for queries -* [Caching and Events](/packages/database/caching-and-events) — query result caching diff --git a/public/docs/docs/packages/database/table-schema-definition.md b/public/docs/docs/packages/database/table-schema-definition.md deleted file mode 100644 index db6f316..0000000 --- a/public/docs/docs/packages/database/table-schema-definition.md +++ /dev/null @@ -1,626 +0,0 @@ -# Table Schema Definition - -Table schema definitions describe the structure of your database tables in a database-agnostic way. They're PHP classes that extend `Table` and define columns, indexes, primary keys, and versioning information. Handlers use these definitions to create tables and generate queries. - -This document provides a complete reference for defining table schemas in PHPNomad. - -## Table Base Class - -All tables extend `PHPNomad\Database\Abstracts\Table` and must implement six methods: - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; -} -``` - -See **Column Definitions** section below for details. - ---- - -### `getIndices(): array` - -Returns an array of `Index` objects defining database indexes. - -```php -public function getIndices(): array -{ - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['slug'], 'slug_idx', 'UNIQUE'), - ]; -} -``` - -See **Index Definitions** section below for details. - ---- - -## Column Definitions - -Columns are defined using the `Column` class or specialized factories. - -### Using the Column Class - -```php -new Column( - string $name, // Column name - string $type, // SQL type (VARCHAR, INT, TEXT, etc.) - array|null $params, // Type parameters (e.g., [255] for VARCHAR) - string $constraint // Constraints (NOT NULL, NULL, DEFAULT, etc.) -) -``` - -### Column Types - -**String types:** -```php -new Column('title', 'VARCHAR', [255], 'NOT NULL') -new Column('content', 'TEXT', null, 'NOT NULL') -new Column('slug', 'VARCHAR', [255], 'NOT NULL') -``` - -**Integer types:** -```php -new Column('id', 'INT', [11], 'NOT NULL AUTO_INCREMENT') -new Column('author_id', 'BIGINT', null, 'NOT NULL') -new Column('view_count', 'INT', [11], 'DEFAULT 0') -new Column('is_active', 'TINYINT', [1], 'DEFAULT 1') // Boolean -``` - -**Date/Time types:** -```php -new Column('published_date', 'DATETIME', null, 'NULL') -new Column('created_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP') -new Column('updated_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP') -``` - -**Other types:** -```php -new Column('price', 'DECIMAL', [10, 2], 'NOT NULL') // 10 digits, 2 decimals -new Column('metadata', 'JSON', null, 'NULL') // MySQL 5.7.8+ -new Column('file_data', 'BLOB', null, 'NULL') -new Column('status', 'ENUM', ['draft', 'published', 'archived'], 'DEFAULT "draft"') -``` - -### Column Constraints - -**NOT NULL / NULL:** -```php -'NOT NULL' // Required field -'NULL' // Optional field -``` - -**DEFAULT values:** -```php -'DEFAULT 0' -'DEFAULT "draft"' -'DEFAULT CURRENT_TIMESTAMP' -``` - -**AUTO_INCREMENT:** -```php -'NOT NULL AUTO_INCREMENT' // For primary keys -``` - -**ON UPDATE:** -```php -'DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' // Auto-update timestamp -``` - -**UNIQUE:** -```php -'NOT NULL UNIQUE' // Unique constraint -``` - ---- - -## Column Factories - -PHPNomad provides specialized factories for common column patterns. See [Included Factories](/packages/database/included-factories/introduction) for full details. - -### PrimaryKeyFactory - -Creates standard auto-increment integer primary keys: - -```php -use PHPNomad\Database\Factories\Columns\PrimaryKeyFactory; - -public function getColumns(): array -{ - return [ - (new PrimaryKeyFactory())->toColumn(), // Creates 'id' INT AUTO_INCREMENT - // other columns... - ]; -} -``` - -**Generates:** -```sql -id INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY -``` - ---- - -### DateCreatedFactory - -Creates timestamp columns with automatic creation date: - -```php -use PHPNomad\Database\Factories\Columns\DateCreatedFactory; - -public function getColumns(): array -{ - return [ - (new DateCreatedFactory())->toColumn(), // Creates 'created_at' - ]; -} -``` - -**Generates:** -```sql -created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL -``` - ---- - -### DateModifiedFactory - -Creates timestamp columns that auto-update on record modification: - -```php -use PHPNomad\Database\Factories\Columns\DateModifiedFactory; - -public function getColumns(): array -{ - return [ - (new DateModifiedFactory())->toColumn(), // Creates 'updated_at' - ]; -} -``` - -**Generates:** -```sql -updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL -``` - ---- - -### ForeignKeyFactory - -Creates foreign key columns with optional constraints: - -```php -use PHPNomad\Database\Factories\Columns\ForeignKeyFactory; - -public function getColumns(): array -{ - return [ - (new ForeignKeyFactory( - 'author_id', // Column name - 'users', // Referenced table - 'id', // Referenced column - 'CASCADE', // ON DELETE action - 'CASCADE' // ON UPDATE action - ))->toColumn(), - ]; -} -``` - -**Common foreign key patterns:** -```php -// Required foreign key -(new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn() - -// Optional foreign key (NULL allowed) -(new ForeignKeyFactory('author_id', 'users', 'id', 'SET NULL'))->toColumn() - -// Cascade deletes -(new ForeignKeyFactory('post_id', 'posts', 'id', 'CASCADE'))->toColumn() -``` - ---- - -## Index Definitions - -Indexes improve query performance. Define them using the `Index` class: - -```php -new Index( - array $columns, // Column(s) to index - string $name, // Index name - string $type // Index type: 'INDEX', 'UNIQUE', 'PRIMARY KEY' -) -``` - -### Single-Column Indexes - -```php -public function getIndices(): array -{ - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['published_date'], 'published_idx', 'INDEX'), - ]; -} -``` - -**When to add:** -* Columns used in WHERE clauses -* Foreign key columns -* Columns used for sorting (ORDER BY) - ---- - -### Unique Indexes - -```php -public function getIndices(): array -{ - return [ - new Index(['email'], 'email_unique', 'UNIQUE'), - new Index(['slug'], 'slug_unique', 'UNIQUE'), - ]; -} -``` - -**When to add:** -* Fields that must be unique across records -* Natural keys (email, username, slug) - ---- - -### Composite Indexes - -Indexes spanning multiple columns for complex queries: - -```php -public function getIndices(): array -{ - return [ - new Index(['author_id', 'published_date'], 'author_published_idx', 'INDEX'), - new Index(['user_id', 'session_token'], 'user_session_idx', 'PRIMARY KEY'), - ]; -} -``` - -**When to add:** -* Queries filtering on multiple columns -* Compound primary keys (junction tables) -* Queries with sorting on filtered data - -**Column order matters:** -```php -new Index(['author_id', 'published_date'], 'idx', 'INDEX') -// Supports: WHERE author_id = 123 -// Supports: WHERE author_id = 123 ORDER BY published_date -// Does NOT support: WHERE published_date > '2024-01-01' (doesn't start with author_id) -``` - ---- - -### Primary Key Indexes - -For compound primary keys (typically junction tables): - -```php -public function getIndices(): array -{ - return [ - new Index(['post_id', 'tag_id'], 'primary', 'PRIMARY KEY'), - ]; -} -``` - ---- - -## Complete Example - -Here's a full table definition with all features: - -```php -toColumn(), - - // Regular columns - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('slug', 'VARCHAR', [255], 'NOT NULL UNIQUE'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('excerpt', 'VARCHAR', [500], 'NULL'), - - // Enum for status - new Column('status', 'ENUM', ['draft', 'published', 'archived'], 'DEFAULT "draft"'), - - // Numeric fields - new Column('view_count', 'INT', [11], 'DEFAULT 0'), - new Column('comment_count', 'INT', [11], 'DEFAULT 0'), - - // Foreign keys - (new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn(), - (new ForeignKeyFactory('category_id', 'categories', 'id', 'SET NULL'))->toColumn(), - - // Dates - new Column('published_date', 'DATETIME', null, 'NULL'), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - // Single column indexes - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['category_id'], 'category_idx', 'INDEX'), - new Index(['status'], 'status_idx', 'INDEX'), - new Index(['published_date'], 'published_idx', 'INDEX'), - new Index(['slug'], 'slug_unique', 'UNIQUE'), - - // Composite indexes - new Index(['author_id', 'published_date'], 'author_published_idx', 'INDEX'), - new Index(['status', 'published_date'], 'status_published_idx', 'INDEX'), - ]; - } -} -``` - ---- - -## Best Practices - -### Column Naming - -Use `snake_case` for column names to match database conventions: - -```php -// ✅ GOOD -new Column('author_id', 'BIGINT', null, 'NOT NULL') -new Column('published_date', 'DATETIME', null, 'NULL') - -// ❌ BAD -new Column('authorId', 'BIGINT', null, 'NOT NULL') -new Column('publishedDate', 'DATETIME', null, 'NULL') -``` - -### Index Foreign Keys - -Always index foreign key columns for join performance: - -```php -public function getColumns(): array -{ - return [ - (new ForeignKeyFactory('author_id', 'users', 'id'))->toColumn(), - ]; -} - -public function getIndices(): array -{ - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), // ✅ Always add - ]; -} -``` - -### Use Factories for Standard Patterns - -Don't manually define primary keys or timestamps: - -```php -// ✅ GOOD -(new PrimaryKeyFactory())->toColumn() -(new DateCreatedFactory())->toColumn() - -// ❌ BAD -new Column('id', 'INT', [11], 'NOT NULL AUTO_INCREMENT') -new Column('created_at', 'TIMESTAMP', null, 'DEFAULT CURRENT_TIMESTAMP') -``` - -### Version Your Schema - -Increment `getTableVersion()` whenever you change the schema: - -```php -// Initial version -public function getTableVersion(): string { return '1'; } - -// After adding a column -public function getTableVersion(): string { return '2'; } - -// After modifying an index -public function getTableVersion(): string { return '3'; } -``` - -### Nullable vs Required - -Be explicit about nullability: - -```php -// Required fields -new Column('title', 'VARCHAR', [255], 'NOT NULL') - -// Optional fields -new Column('published_date', 'DATETIME', null, 'NULL') -``` - -### Index Strategy - -Follow these guidelines: -1. **Always index:** Primary keys, foreign keys, unique fields -2. **Often index:** Columns in WHERE clauses, ORDER BY columns -3. **Consider composite indexes:** For multi-column queries -4. **Don't over-index:** Every index adds write overhead - ---- - -## What's Next - -* [Tables Introduction](/packages/database/tables/introduction) — overview of table definitions -* [Included Factories](/packages/database/included-factories/introduction) — column factory reference -* [Junction Tables](/packages/database/junction-tables) — many-to-many relationship tables -* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/tables/introduction.md b/public/docs/docs/packages/database/tables/introduction.md deleted file mode 100644 index 9e854b0..0000000 --- a/public/docs/docs/packages/database/tables/introduction.md +++ /dev/null @@ -1,269 +0,0 @@ -# Tables - -Tables in PHPNomad are **schema definitions** that describe the structure of your database tables. They define columns, indexes, primary keys, and constraints in a **database-agnostic** way, allowing handlers and query builders to generate the correct SQL for your target database. - -A table object is not a query builder or active record. It's a **metadata container** that describes what a table looks like, which [handlers](/packages/database/handlers/introduction) use to create schemas and [QueryBuilder](/packages/database/query-building) uses to generate queries. - -## What Tables Define - -A table definition specifies: - -* **Table name** — the name of the table in the database. -* **Columns** — each field's name, type, and constraints (nullable, default value, etc.). -* **Primary key** — which column(s) uniquely identify rows. -* **Indexes** — additional indexes for query performance. -* **Foreign keys** — relationships to other tables (optional). - -These definitions are created using **factory classes** from the `phpnomad/database` package, which provide a fluent API for building schema definitions. - -## Why Table Objects Exist - -In PHPNomad, **schema lives in code**, not in migration scripts or raw SQL. This has several benefits: - -* **Portability** — the same table definition works across MySQL, MariaDB, and other supported databases. -* **Versioning** — schema changes are tracked in version control alongside code. -* **Testability** — tables can be created in test databases programmatically. -* **Type safety** — column definitions are strongly typed and validated at runtime. - -Handlers use table definitions to: -* Create tables on first use (or during migrations). -* Validate that models match the schema. -* Generate queries that reference the correct columns. - -## The Base Table Class - -The `Table` class is the **standard base** for defining entity tables. You extend it and provide column definitions, a primary key, and optional indexes. - -### Basic example - -```php -columnFactory->int('id')->autoIncrement(), - $this->columnFactory->string('title', 255)->notNull(), - $this->columnFactory->text('content')->notNull(), - $this->columnFactory->int('author_id')->notNull(), - $this->columnFactory->datetime('published_date')->nullable(), - $this->columnFactory->datetime('created_at')->default('CURRENT_TIMESTAMP'), - $this->columnFactory->datetime('updated_at')->default('CURRENT_TIMESTAMP')->onUpdate('CURRENT_TIMESTAMP'), - ]; - } - - public function getPrimaryKey(): PrimaryKey - { - return $this->primaryKeyFactory->create('id'); - } - - public function getIndexes(): array - { - return [ - // Add index on author_id for faster lookups - $this->indexFactory->create('idx_author', ['author_id']), - ]; - } -} -``` - -This defines a `posts` table with: -* An auto-increment `id` primary key -* Required `title`, `content`, and `author_id` columns -* Optional `published_date` column -* Auto-managed `created_at` and `updated_at` timestamps - -## Column Factories - -Column definitions are created using factory methods that return `Column` objects. PHPNomad provides [several included factories](/packages/database/included-factories/introduction) for common patterns: - -* **`PrimaryKeyFactory`** — creates auto-increment integer primary keys -* **`DateCreatedFactory`** — creates `created_at` timestamp columns -* **`DateModifiedFactory`** — creates `updated_at` columns with auto-update -* **`ForeignKeyFactory`** — creates foreign key columns that reference other tables - -You can also use the base `Column` factory to define custom columns with full control over type, size, nullability, defaults, and constraints. - -## Junction Tables - -PHPNomad provides a specialized `JunctionTable` class for **many-to-many relationships**. Junction tables store associations between two entities (e.g., posts and tags) without additional data. - -A junction table: -* Has a **compound primary key** (both foreign keys together). -* Stores only the foreign keys (no additional columns). -* Uses composite indexes for efficient lookups in both directions. - -### Example: PostTag junction table - -```php -foreignKeyFactory->create('post_id', 'posts', 'id'), - $this->foreignKeyFactory->create('tag_id', 'tags', 'id'), - ]; - } - - public function getPrimaryKey(): array - { - return ['post_id', 'tag_id']; // Compound key - } - - public function getIndexes(): array - { - return [ - // Index for "which tags are on this post?" - $this->indexFactory->create('idx_post', ['post_id']), - // Index for "which posts have this tag?" - $this->indexFactory->create('idx_tag', ['tag_id']), - ]; - } -} -``` - -Junction tables are used with the [JunctionTable class](/packages/database/tables/junction-table-class) to manage many-to-many relationships efficiently. - -## Table Lifecycle - -Tables are: - -1. **Defined** — you create a class that implements `Table` and describes the schema. -2. **Injected** — your handler receives the table instance via constructor DI. -3. **Used** — handlers call `getTableName()`, `getColumns()`, etc. to generate queries. -4. **Created** — on first use (or during migrations), the handler ensures the table exists in the database. - -You don't "run" a table or call methods on it directly. It's a **passive descriptor** that other components consume. - -## Column Types and Constraints - -The `Column` factory supports these types: - -* `int(name, size)` — integers (various sizes: TINYINT, INT, BIGINT) -* `string(name, length)` — VARCHAR columns -* `text(name)` — TEXT columns (arbitrary length) -* `datetime(name)` — DATETIME columns -* `boolean(name)` — BOOLEAN columns -* `json(name)` — JSON columns (database-dependent) -* `decimal(name, precision, scale)` — DECIMAL columns - -And these constraints: - -* `notNull()` — column cannot be NULL -* `nullable()` — column can be NULL (default) -* `default(value)` — default value when not specified -* `autoIncrement()` — auto-incrementing integer (usually on primary keys) -* `onUpdate(value)` — value to set on UPDATE (e.g., `CURRENT_TIMESTAMP`) -* `unique()` — enforce uniqueness constraint - -Chaining these methods produces expressive column definitions: - -```php -$this->columnFactory - ->string('email', 255) - ->notNull() - ->unique(); -``` - -## Primary Keys - -Every table must define a primary key. Most tables use a **single auto-increment integer**: - -```php -public function getPrimaryKey(): PrimaryKey -{ - return $this->primaryKeyFactory->create('id'); -} -``` - -Tables with **compound primary keys** (like junction tables) return an array: - -```php -public function getPrimaryKey(): array -{ - return ['user_id', 'session_token']; -} -``` - -## Indexes - -Indexes improve query performance by allowing the database to find rows faster. Add indexes on: - -* Foreign keys (for joins) -* Columns used in WHERE clauses -* Columns used for sorting - -**Example: adding indexes** - -```php -public function getIndexes(): array -{ - return [ - $this->indexFactory->create('idx_author', ['author_id']), - $this->indexFactory->create('idx_published', ['published_date']), - $this->indexFactory->composite('idx_author_date', ['author_id', 'published_date']), - ]; -} -``` - -Composite indexes support queries that filter on multiple columns. - -## Best Practices - -When defining tables: - -* **Use factories** — don't construct `Column` objects manually; use the provided factories. -* **Name consistently** — use snake_case for column names to match database conventions. -* **Index foreign keys** — always add indexes on columns used in joins. -* **Use timestamps** — include `created_at` and `updated_at` for audit trails. -* **Keep tables focused** — each table should represent one entity or one relationship (junction tables). -* **Declare constraints** — use `notNull()`, `unique()`, etc. to enforce data integrity at the database level. - -## Schema Evolution - -When your schema changes (adding columns, indexes, etc.), update the table definition. The handler will detect changes and can update the database schema, though this depends on your migration strategy. - -For production systems, consider: -* **Versioned migrations** — track schema changes explicitly. -* **Backwards compatibility** — add columns as nullable first, backfill data, then mark as not-null. -* **Index creation** — add indexes separately from table creation if tables are large. - -## What's Next - -To understand how tables fit into the larger system, see: - -* [Table Class](/packages/database/tables/table-class) — detailed API reference for entity tables -* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many relationship tables -* [Included Factories](/packages/database/included-factories/introduction) — pre-built column factories -* [Database Handlers](/packages/database/handlers/introduction) — how handlers use table definitions diff --git a/public/docs/docs/packages/database/tables/junction-table-class.md b/public/docs/docs/packages/database/tables/junction-table-class.md deleted file mode 100644 index 58b7054..0000000 --- a/public/docs/docs/packages/database/tables/junction-table-class.md +++ /dev/null @@ -1,73 +0,0 @@ -# JunctionTable Class - -The `JunctionTable` class extends `Table` for defining **many-to-many relationship tables**. Junction tables store associations between two entities using foreign keys and compound primary keys. - -For conceptual overview and usage patterns, see [Junction Tables](/packages/database/junction-tables). - -## Key Differences from Table - -Junction tables differ from regular entity tables in that they: - -* Have **compound primary keys** (multiple columns) -* Store only **foreign keys** (no additional data by default) -* Use **composite indexes** for bidirectional lookups - -## Example - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - (new DateCreatedFactory())->toColumn(), - ]; -} -``` - -### `getIndices(): array` - -Returns array of Index definitions. - -**Example:** -```php -public function getIndices(): array -{ - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['slug'], 'slug_unique', 'UNIQUE'), - ]; -} -``` - -## Complete Example - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('author_id', 'BIGINT', null, 'NOT NULL'), - new Column('published_date', 'DATETIME', null, 'NULL'), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['published_date'], 'published_idx', 'INDEX'), - ]; - } -} -``` - -## What's Next - -* [Table Schema Definition](/packages/database/table-schema-definition) — complete schema reference -* [JunctionTable Class](/packages/database/tables/junction-table-class) — many-to-many tables -* [Tables Introduction](/packages/database/tables/introduction) — overview diff --git a/public/docs/docs/packages/datastore/core-implementation.md b/public/docs/docs/packages/datastore/core-implementation.md deleted file mode 100644 index 41f893c..0000000 --- a/public/docs/docs/packages/datastore/core-implementation.md +++ /dev/null @@ -1,604 +0,0 @@ -# Core Datastore Layer - -## What is the Core layer? - -The Core layer is where you define business-level data operations without any knowledge of how data is actually stored or retrieved. It contains interfaces that declare what operations are possible, and implementations that delegate standard operations while adding custom business logic. - -Core never depends on Service. It knows nothing about databases, REST APIs, GraphQL, or any concrete storage technology. This separation ensures your domain logic remains portable and independent of infrastructure. - -The Core layer contains: -- **Datastore interfaces** - Public API for your application code -- **DatastoreHandler interfaces** - Contract for storage implementations -- **Datastore implementations** - Delegation layer using decorator pattern -- **Models** - Domain entities (covered in [Models and Identity](models-and-identity)) - ---- - -## Directory structure - -The standard directory structure for Core datastores: - -``` -YourModule/ -└── Core/ - ├── Models/ - │ ├── Post.php - │ └── Adapters/ - │ └── PostAdapter.php - └── Datastores/ - └── Post/ - ├── Interfaces/ - │ ├── PostDatastore.php - │ └── PostDatastoreHandler.php - └── PostDatastore.php -``` - -**Key points:** -- Each entity gets its own directory under `Datastores/` -- Interfaces live in `Interfaces/` subdirectory -- Implementation lives at the entity directory level -- Models and adapters are separate from datastores - ---- - -## Naming conventions - -Consistent naming makes codebases predictable and maintainable: - -| Component | Pattern | Example | -|-----------|---------|---------| -| Datastore interface | `{Entity}Datastore` | `PostDatastore` | -| DatastoreHandler interface | `{Entity}DatastoreHandler` | `PostDatastoreHandler` | -| Datastore implementation | `{Entity}Datastore` | `PostDatastore` | -| Model | `{Entity}` | `Post` | -| Adapter | `{Entity}Adapter` | `PostAdapter` | - -**Important:** The Datastore interface and implementation share the same name. They are distinguished by namespace and the interface suffix in the interface file. - ---- - -## Datastore vs DatastoreHandler: The critical distinction - -This is the most confusing aspect of the datastore pattern. Understanding why both interfaces exist is essential to using the pattern effectively. - -### PostDatastore: Your public API - -The `Datastore` interface defines your **public API**—what operations your application code can perform. This interface includes: - -- Standard operations (if you choose to extend base interfaces) -- Custom business methods specific to this entity - -```php -datastoreHandler = $datastoreHandler; - } - - // Custom business method - implemented here, not in handler - public function getPublishedPosts(): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] - ] - ] - ]); - } - - public function getByAuthor(int $authorId): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] - ] - ] - ]); - } -} -``` - -The custom methods (`getPublishedPosts`, `getByAuthor`) are implemented in the Core datastore using the handler's `where()` method. The handler doesn't need to know about these business-specific queries—it just provides the building blocks. - -**This means:** -- Database handlers, REST handlers, GraphQL handlers only need to implement standard operations -- Business logic lives in the Core datastore, composed from handler primitives -- You can swap storage implementations without changing business methods -- Each handler implementation doesn't need to understand your specific business domain - ---- - -## When to extend base interfaces (and when not to) - -The base datastore interfaces (`DatastoreHasPrimaryKey`, `DatastoreHasWhere`, etc.) provide standard operations. **You are not required to extend them.** - -### Full standard interface (database-friendly) - -If your storage supports queries, filtering, and standard CRUD, extend the base interfaces: - -```php -interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere, DatastoreHasCounts -{ - public function getPublishedPosts(): array; -} - -interface PostDatastoreHandler extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere, DatastoreHasCounts -{ - // Standard operations -} -``` - -**Use when:** -- Storage is a database with full query support -- You want standard CRUD operations available -- Consumers benefit from generic query methods like `where()` - - -## The decorator pattern with traits - -When your `Datastore` and `DatastoreHandler` both extend the same base interfaces, use decorator traits to eliminate boilerplate delegation code. - -### Without decorator traits (manual delegation) - -Without traits, you'd write delegation methods for every standard operation: - -```php -class PostDatastore implements PostDatastoreInterface -{ - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - // Manual delegation for Datastore methods - public function create(array $attributes): Post - { - return $this->datastoreHandler->create($attributes); - } - - public function updateCompound(array $ids, array $attributes): void - { - $this->datastoreHandler->updateCompound($ids, $attributes); - } - - // Manual delegation for DatastoreHasPrimaryKey methods - public function find(int $id): Post - { - return $this->datastoreHandler->find($id); - } - - public function findMultiple(array $ids): array - { - return $this->datastoreHandler->findMultiple($ids); - } - - public function update(int $id, array $attributes): void - { - $this->datastoreHandler->update($id, $attributes); - } - - public function delete(int $id): void - { - $this->datastoreHandler->delete($id); - } - - // Manual delegation for DatastoreHasWhere methods - public function where(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array - { - return $this->datastoreHandler->where($conditions, $limit, $offset, $orderBy, $order); - } - - public function andWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array - { - return $this->datastoreHandler->andWhere($conditions, $limit, $offset, $orderBy, $order); - } - - public function orWhere(array $conditions, ?int $limit = null, ?int $offset = null, ?string $orderBy = null, string $order = 'ASC'): array - { - return $this->datastoreHandler->orWhere($conditions, $limit, $offset, $orderBy, $order); - } - - public function deleteWhere(array $conditions): void - { - $this->datastoreHandler->deleteWhere($conditions); - } - - public function findBy(string $field, $value): Post - { - return $this->datastoreHandler->findBy($field, $value); - } - - // Plus count methods, plus custom methods... -} -``` - -That's dozens of lines of boilerplate for a simple datastore. - -### With decorator traits (automatic delegation) - -Decorator traits handle all standard delegation automatically: - -```php -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; // Delegates: create, updateCompound - use WithDatastorePrimaryKeyDecorator; // Delegates: find, findMultiple, update, delete - use WithDatastoreWhereDecorator; // Delegates: where, andWhere, orWhere, deleteWhere, findBy - use WithDatastoreCountDecorator; // Delegates: count methods - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - // Only implement custom business methods - public function getPublishedPosts(): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'publishedDate', 'operator' => '<=', 'value' => date('Y-m-d H:i:s')] - ] - ] - ]); - } - - public function getByAuthor(int $authorId): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] - ] - ] - ]); - } -} -``` - -All standard operations automatically delegate to `$this->datastoreHandler`. You only write custom business methods. - -### Available decorator traits - -| Trait | Delegates Methods | -|-------|-------------------| -| `WithDatastoreDecorator` | `create()`, `updateCompound()` | -| `WithDatastorePrimaryKeyDecorator` | `find()`, `findMultiple()`, `update()`, `delete()` | -| `WithDatastoreWhereDecorator` | `where()`, `andWhere()`, `orWhere()`, `deleteWhere()`, `findBy()` | -| `WithDatastoreCountDecorator` | Count-related methods | - -Use the traits that match the interfaces your Datastore extends. If your `PostDatastore` extends `DatastoreHasPrimaryKey`, use `WithDatastorePrimaryKeyDecorator`. - -### When NOT to use decorator traits - -**Don't use decorator traits when:** - -1. **Your interfaces don't match** - If `PostDatastore` extends `DatastoreHasWhere` but `PostDatastoreHandler` doesn't, you can't delegate -2. **You want a minimal API** - If you're not extending base interfaces, don't use delegation traits -3. **You need custom behavior** - If standard operations need special handling, implement them manually - -```php -// Example: Minimal API, no delegation -interface PostDatastore extends Datastore -{ - public function getPublishedPosts(): array; -} - -interface PostDatastoreHandler extends Datastore -{ - // Minimal -} - -class PostDatastore implements PostDatastoreInterface -{ - // NO decorator traits - - public function __construct( - private PostDatastoreHandler $datastoreHandler - ) {} - - // Implement everything explicitly - public function create(array $attributes): Post - { - return $this->datastoreHandler->create($attributes); - } - - public function updateCompound(array $ids, array $attributes): void - { - $this->datastoreHandler->updateCompound($ids, $attributes); - } - - public function getPublishedPosts(): array - { - // Custom implementation - } -} -``` - ---- - -## Custom business methods - -Custom methods define domain-specific operations. They use handler primitives to implement business logic. - -### Pattern 1: Simple filtering - -```php -interface PostDatastore extends Datastore, DatastoreHasWhere -{ - public function getPublishedPosts(): array; - public function getDraftPosts(): array; -} - -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; - use WithDatastoreWhereDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - public function getPublishedPosts(): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'status', 'operator' => '=', 'value' => 'published'] - ] - ] - ]); - } - - public function getDraftPosts(): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'status', 'operator' => '=', 'value' => 'draft'] - ] - ] - ]); - } -} -``` - -### Pattern 2: Lookup by specific field - -```php -interface PostDatastore extends Datastore, DatastoreHasWhere -{ - public function getBySlug(string $slug): Post; - public function getByAuthor(int $authorId): array; -} - -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; - use WithDatastoreWhereDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - public function getBySlug(string $slug): Post - { - return $this->datastoreHandler->findBy('slug', $slug); - } - - public function getByAuthor(int $authorId): array - { - return $this->datastoreHandler->where([ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'authorId', 'operator' => '=', 'value' => $authorId] - ] - ] - ]); - } -} -``` - -### Pattern 3: Complex queries - -```php -interface PostDatastore extends Datastore, DatastoreHasWhere -{ - public function getRecentPublishedByAuthor(int $authorId, int $limit = 10): array; -} - -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; - use WithDatastoreWhereDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - public function getRecentPublishedByAuthor(int $authorId, int $limit = 10): array - { - return $this->datastoreHandler->where( - conditions: [ - [ - 'type' => 'AND', - 'clauses' => [ - ['column' => 'authorId', 'operator' => '=', 'value' => $authorId], - ['column' => 'status', 'operator' => '=', 'value' => 'published'] - ] - ] - ], - limit: $limit, - orderBy: 'publishedDate', - order: 'DESC' - ); - } -} -``` - -### Pattern 4: Combining multiple operations - -```php -interface PostDatastore extends Datastore, DatastoreHasPrimaryKey, DatastoreHasWhere -{ - public function publishPost(int $postId): Post; -} - -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; - use WithDatastorePrimaryKeyDecorator; - use WithDatastoreWhereDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - public function publishPost(int $postId): Post - { - $this->datastoreHandler->update($postId, [ - 'status' => 'published', - 'publishedDate' => date('Y-m-d H:i:s') - ]); - - return $this->datastoreHandler->find($postId); - } -} -``` - ---- - -## Design principles for Core datastores - -### Keep business logic in the Core implementation - -Custom methods implement business logic by composing handler primitives. The handler doesn't know about "published posts" or "recent posts"—it just provides query capabilities. The Core datastore interprets what "published" means. - -### Be intentional about your public API - -Every method you add to `PostDatastore` is a promise to consumers. If you add `where()` to your interface, consumers will use it. If you later switch to a REST API that doesn't support generic queries, you'll break consumers. - -Ask yourself: -- Will this storage always support this operation? -- Do I want consumers calling this directly? -- Is this operation stable long-term? - -If unsure, keep your interface minimal and add methods as needed. - -### Handler interfaces should be generic - -The `DatastoreHandler` interface should contain only operations that **any storage implementation** can reasonably provide. Don't add business-specific methods to the handler—those belong in the `Datastore` implementation. - ---- - -## Summary - -The Core datastore layer defines business-level data operations through interfaces and implementations. The critical distinction is between `Datastore` (public API for consumers) and `DatastoreHandler` (contract for storage implementations). Decorator traits eliminate boilerplate delegation when both interfaces extend the same base interfaces. For tighter control or limited storage capabilities, opt out of base interfaces and define only the operations you need. Custom business methods compose handler primitives to implement domain logic. Keep your public API intentional and your handler interfaces generic. diff --git a/public/docs/docs/packages/datastore/integration-guide.md b/public/docs/docs/packages/datastore/integration-guide.md deleted file mode 100644 index e8bed98..0000000 --- a/public/docs/docs/packages/datastore/integration-guide.md +++ /dev/null @@ -1,485 +0,0 @@ -# Datastore Integration Guide - -This guide shows you how to integrate the datastore package into your application by creating a complete datastore implementation from scratch. While the [Getting Started Tutorial](/core-concepts/getting-started-tutorial) walks through the basics, this guide covers **production patterns**, **dependency injection setup**, and **custom implementations** for different storage backends. - -## Integration Overview - -Integrating a datastore involves four steps: - -1. **Define your Core contracts** — interfaces for your datastore and handler -2. **Implement the Core datastore** — the public API layer -3. **Implement the Service handler** — the storage backend layer -4. **Register with DI** — wire everything together - -This guide demonstrates each step using a `Post` entity as an example. - ---- - -## Step 1: Define Core Contracts - -Start by defining **interfaces** for your datastore and handler in the Core layer. These are the contracts your application depends on. - -### Datastore Interface - -```php -handler - ->where() - ->equals('author_id', $authorId) - ->lessThanOrEqual('published_date', new \DateTime()) - ->orderBy('published_date', 'DESC') - ->getResults(); - } -} -``` - -**Key points:** -- Traits provide standard method implementations -- Trait conflicts are resolved with `insteadof` -- Custom methods are implemented manually -- Handler is injected via constructor - ---- - -## Step 3: Implement Service Handler - -The Service handler connects your datastore to actual storage. For database-backed datastores, extend `IdentifiableDatabaseDatastoreHandler`. - -### Database Handler - -```php -model = Post::class; - $this->table = $table; - $this->modelAdapter = $adapter; - $this->serviceProvider = $serviceProvider; - $this->tableSchemaService = $tableSchemaService; - } -} -``` - -**Required components:** -- `DatabaseServiceProvider` — provides query builder, cache, events -- `Table` — schema definition for the database table -- `ModelAdapter` — converts between models and arrays -- `TableSchemaService` — handles schema creation/updates - -### Table Definition - -```php -toColumn(), - new Column('title', 'VARCHAR', [255], 'NOT NULL'), - new Column('content', 'TEXT', null, 'NOT NULL'), - new Column('author_id', 'BIGINT', null, 'NOT NULL'), - new Column('published_date', 'DATETIME', null, 'NULL'), - (new DateCreatedFactory())->toColumn(), - (new DateModifiedFactory())->toColumn(), - ]; - } - - public function getIndices(): array - { - return [ - new Index(['author_id'], 'author_idx', 'INDEX'), - new Index(['published_date'], 'published_idx', 'INDEX'), - ]; - } - - public function getUnprefixedName(): string - { - return 'posts'; - } - - public function getSingularUnprefixedName(): string - { - return 'post'; - } -} -``` - ---- - -## Step 4: Register with Dependency Injection - -Wire everything together in your service provider: - -```php -set(PostAdapter::class, fn() => new PostAdapter()); - - // Register the table - $container->set(PostsTable::class, fn() => new PostsTable()); - - // Register the handler (Service layer) - $container->set(PostDatastoreHandler::class, function($c) { - return new PostDatabaseHandler( - $c->get(DatabaseServiceProvider::class), - $c->get(PostsTable::class), - $c->get(PostAdapter::class), - $c->get(TableSchemaService::class) - ); - }); - - // Register the datastore (Core layer) - $container->set(PostDatastore::class, function($c) { - return new CorePostDatastore( - $c->get(PostDatastoreHandler::class) - ); - }); - } -} -``` - -Now your application can inject `PostDatastore` anywhere it needs data access: - -```php -class PublishPostService -{ - public function __construct( - private PostDatastore $posts - ) {} - - public function publish(int $postId): void - { - $post = $this->posts->find($postId); - // ... business logic - } -} -``` - ---- - -## Alternative Backend: REST API Handler - -You can implement handlers for different storage backends. Here's a REST API example: - -```php -httpClient->get("{$this->apiBaseUrl}/posts/{$id}"); - - if ($response->getStatusCode() === 404) { - throw new RecordNotFoundException("Post {$id} not found"); - } - - $data = json_decode($response->getBody(), true); - return $this->adapter->toModel($data); - } - - public function get(array $args = []): iterable - { - $queryString = http_build_query($args); - $response = $this->httpClient->get("{$this->apiBaseUrl}/posts?{$queryString}"); - $data = json_decode($response->getBody(), true); - - return array_map( - fn($item) => $this->adapter->toModel($item), - $data['posts'] ?? [] - ); - } - - public function save(Post $item): Post - { - $data = $this->adapter->toArray($item); - - if ($item->getId()) { - // UPDATE - $response = $this->httpClient->put( - "{$this->apiBaseUrl}/posts/{$item->getId()}", - $data - ); - } else { - // CREATE - $response = $this->httpClient->post( - "{$this->apiBaseUrl}/posts", - $data - ); - } - - $responseData = json_decode($response->getBody(), true); - return $this->adapter->toModel($responseData); - } - - public function delete(Post $item): void - { - $this->httpClient->delete("{$this->apiBaseUrl}/posts/{$item->getId()}"); - } - - public function where(): DatastoreWhereQuery - { - // Return a REST-compatible query builder - return new RestWhereQuery($this->httpClient, $this->apiBaseUrl, $this->adapter); - } - - public function count(array $args = []): int - { - $queryString = http_build_query(array_merge($args, ['count_only' => true])); - $response = $this->httpClient->get("{$this->apiBaseUrl}/posts?{$queryString}"); - $data = json_decode($response->getBody(), true); - return $data['count'] ?? 0; - } -} -``` - -**Register the REST handler instead:** - -```php -$container->set(PostDatastoreHandler::class, function($c) { - return new PostRestHandler( - $c->get(Client::class), - $c->get(PostAdapter::class), - 'https://api.example.com/v1' - ); -}); -``` - -Your Core datastore and application code **don't change**—only the handler implementation. - ---- - -## Best Practices - -### Keep Core and Service Separate - -``` -Core/ - Datastores/ - Post/ - Interfaces/ - PostDatastore.php # Public interface - PostDatastoreHandler.php # Handler contract - PostDatastore.php # Implementation (delegates to handler) - Models/ - Post.php - Adapters/ - PostAdapter.php - -Service/ - Datastores/ - Post/ - PostDatabaseHandler.php # Database implementation - PostsTable.php # Schema definition -``` - -**Core** = business logic, storage-agnostic -**Service** = concrete storage implementations - -### Use Traits for Standard Implementations - -Don't write boilerplate delegation code: - -```php -// ❌ BAD: manual delegation -class PostDatastore implements IPostDatastore -{ - public function get(array $args = []): iterable - { - return $this->handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - // ... etc -} - -// ✅ GOOD: use traits -class PostDatastore implements IPostDatastore -{ - use WithDatastorePrimaryKeyDecorator; - use WithDatastoreWhereDecorator; - use WithDatastoreCountDecorator; -} -``` - -### Inject Interfaces, Not Implementations - -```php -// ✅ GOOD: depend on interface -class PostService -{ - public function __construct( - private PostDatastore $posts // Interface - ) {} -} - -// ❌ BAD: depend on implementation -class PostService -{ - public function __construct( - private CorePostDatastore $posts // Concrete class - ) {} -} -``` - -This allows swapping implementations (database → REST) without touching consumers. - ---- - -## What's Next - -* [Model Adapters](/packages/datastore/model-adapters) — converting between models and storage arrays -* [Database Handlers](/packages/database/handlers/introduction) — database-specific handler details -* [Table Definitions](/packages/database/tables/introduction) — defining database schemas -* [Core Implementation](/packages/datastore/core-implementation) — advanced Core datastore patterns diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md deleted file mode 100644 index 8ba120c..0000000 --- a/public/docs/docs/packages/datastore/interfaces/datastore-has-counts.md +++ /dev/null @@ -1,289 +0,0 @@ -# DatastoreHasCounts Interface - -The `DatastoreHasCounts` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **efficient counting operations**. It provides the `count()` method for determining how many records match given criteria without fetching and loading all the data. - -This interface is useful for **pagination** (knowing total pages), **dashboard metrics** (e.g., "23 unread messages"), and **existence checks** (e.g., "are there any drafts?"). - -## Interface Definition - -```php -interface DatastoreHasCounts extends Datastore -{ - /** - * Returns the total number of records matching the criteria. - * - * @param array $args Filtering criteria (same format as get()) - * @return int The count of matching records - */ - public function count(array $args = []): int; -} -``` - -## Method - -### `count(array $args = []): int` - -Counts records matching the provided criteria without fetching them. - -**Parameters:** -* `$args` — Filtering criteria as an associative array (same format as `get()`). - -**Returns:** -* An integer representing the number of matching records. - -**When to use:** -* Calculating pagination totals -* Dashboard metrics and statistics -* Checking if records exist (`count() > 0`) -* Avoiding memory overhead of loading large result sets - -**Example: total records** - -```php -$totalPosts = $postDatastore->count(); -echo "Total posts: {$totalPosts}"; -``` - -**Example: filtered count** - -```php -$publishedCount = $postDatastore->count(['status' => 'published']); -$draftCount = $postDatastore->count(['status' => 'draft']); - -echo "Published: {$publishedCount}, Drafts: {$draftCount}"; -``` - -**Example: existence check** - -```php -$hasDrafts = $postDatastore->count(['status' => 'draft']) > 0; - -if ($hasDrafts) { - echo "You have unpublished drafts"; -} -``` - ---- - -## Why This Interface Exists - -Without `count()`, you'd have to fetch all records and count them: - -```php -// ❌ BAD: loads all records into memory -$posts = $datastore->get(['author_id' => 123]); -$count = count($posts); // Expensive! -``` - -With `count()`, the operation happens at the storage layer: - -```php -// ✅ GOOD: efficient database COUNT query -$count = $datastore->count(['author_id' => 123]); -``` - -For databases, this translates to `SELECT COUNT(*) FROM ...`, which is far more efficient than fetching rows. - ---- - -## Usage Patterns - -### Pagination - -Counting is essential for calculating total pages: - -```php -final class PostPaginationService -{ - public function __construct( - private PostDatastore $posts - ) {} - - public function getPaginationInfo(array $filters, int $perPage): array - { - $total = $this->posts->count($filters); - $totalPages = (int) ceil($total / $perPage); - - return [ - 'total' => $total, - 'per_page' => $perPage, - 'total_pages' => $totalPages, - ]; - } - - public function getPage(array $filters, int $page, int $perPage): iterable - { - return $this->posts->get(array_merge($filters, [ - 'limit' => $perPage, - 'offset' => ($page - 1) * $perPage, - ])); - } -} -``` - -**Usage:** - -```php -$filters = ['author_id' => 123, 'status' => 'published']; - -$info = $service->getPaginationInfo($filters, perPage: 10); -// ['total' => 47, 'per_page' => 10, 'total_pages' => 5] - -$posts = $service->getPage($filters, page: 2, perPage: 10); -// Returns posts 11-20 -``` - -### Dashboard Metrics - -Counting is ideal for statistics dashboards: - -```php -final class DashboardService -{ - public function __construct( - private PostDatastore $posts - ) {} - - public function getStats(int $authorId): array - { - return [ - 'total' => $this->posts->count(['author_id' => $authorId]), - 'published' => $this->posts->count([ - 'author_id' => $authorId, - 'status' => 'published' - ]), - 'drafts' => $this->posts->count([ - 'author_id' => $authorId, - 'status' => 'draft' - ]), - ]; - } -} -``` - -**Returns:** - -```php -[ - 'total' => 52, - 'published' => 48, - 'drafts' => 4, -] -``` - -### Conditional Logic - -Use `count()` for existence checks or thresholds: - -```php -// Check if user has any posts before allowing account deletion -$postCount = $postDatastore->count(['author_id' => $userId]); - -if ($postCount > 0) { - throw new ValidationException("Cannot delete user with existing posts"); -} - -// Enforce post limits -$userPostCount = $postDatastore->count(['author_id' => $userId]); - -if ($userPostCount >= 100) { - throw new LimitExceededException("Post limit reached"); -} -``` - ---- - -## Combining with WHERE Queries - -If your datastore implements both `DatastoreHasCounts` and [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where), you can count complex queries: - -```php -$query = $postDatastore - ->where() - ->equals('author_id', 123) - ->greaterThan('published_date', '2024-01-01') - ->lessThan('view_count', 100); - -$count = $query->count(); // How many match? -$posts = $query->getResults(); // Fetch them if needed -``` - -This is more powerful than `count($args)` because you get the full query-builder API. - ---- - -## Relationship to Other Interfaces - -### vs. `get()` + `count()` - -| Method | Efficiency | Use Case | -|--------|-----------|----------| -| `count($args)` | High (storage-layer count) | Pagination, metrics, existence checks | -| `count(get($args))` | Low (loads all data) | Never do this | - -**Always use `count()` instead of loading and counting.** - -### Combining with Other Extensions - -```php -interface PostDatastore extends - Datastore, - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete() - // find() - // where() - // count() -} -``` - -This provides a complete query API. - ---- - -## Implementation with Decorator Traits - -Use [`WithDatastoreCountDecorator`](/packages/datastore/traits/with-datastore-count-decorator) to auto-implement: - -```php -final class PostDatastoreImpl implements DatastoreHasCounts -{ - use WithDatastoreCountDecorator; - - public function __construct( - private DatastoreHandlerHasCounts $handler - ) {} -} -``` - -The trait delegates `count()` to the handler. - ---- - -## Implementation Notes - -When implementing this interface: - -* **`count()` should be efficient** — execute a storage-layer count (e.g., `SELECT COUNT(*)`), not fetch-and-count. -* **Return 0 for no matches** — don't return `null` or throw exceptions. -* **`$args` format matches `get()`** — use the same filtering conventions. -* **Support empty args** — `count()` with no args returns the total record count. - ---- - -## When NOT to Implement This Interface - -Skip `DatastoreHasCounts` if: -* Your storage can't count efficiently (e.g., some APIs don't expose count endpoints). -* Counting isn't needed for your use case. -* You're prototyping and can add it later. - ---- - -## What's Next - -* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface -* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — query-builder counting -* [WithDatastoreCountDecorator](/packages/datastore/traits/with-datastore-count-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md deleted file mode 100644 index e4d98ae..0000000 --- a/public/docs/docs/packages/datastore/interfaces/datastore-has-primary-key.md +++ /dev/null @@ -1,254 +0,0 @@ -# DatastoreHasPrimaryKey Interface - -The `DatastoreHasPrimaryKey` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **primary key-based operations**. It provides the `find()` method for fast single-record lookups by ID—one of the most common operations in data access. - -This interface assumes your storage uses a **single integer primary key** (typically named `id`). If your model uses compound keys or non-integer identifiers, this interface may not apply. - -## Interface Definition - -```php -interface DatastoreHasPrimaryKey extends Datastore -{ - /** - * Finds a single record by its primary key. - * - * @param int $id The primary key value - * @return Model The matching model - * @throws RecordNotFoundException if no record exists with the given ID - */ - public function find(int $id): Model; -} -``` - -## Method - -### `find(int $id): Model` - -Retrieves a single model by its primary key value. - -**Parameters:** -* `$id` — The primary key (typically an auto-increment integer). - -**Returns:** -* A single `Model` instance. - -**Throws:** -* `RecordNotFoundException` — if no record exists with the given ID. - -**When to use:** -* Fetching a known record by ID -* Loading related entities (e.g., "get the author for this post") -* REST endpoints like `GET /posts/42` - -**Example: basic lookup** - -```php -try { - $post = $postDatastore->find(42); - echo $post->title; -} catch (RecordNotFoundException $e) { - echo "Post not found"; -} -``` - -**Example: loading related entity** - -```php -$post = $postDatastore->find(123); - -// Load the author using the foreign key -$author = $authorDatastore->find($post->authorId); - -echo "Post '{$post->title}' by {$author->name}"; -``` - ---- - -## Why This Interface Exists - -Primary key lookups are: -* **Fast** — indexed lookups are O(log n) or O(1) in most databases. -* **Common** — most business logic operates on single entities. -* **Predictable** — you know you'll get exactly one result (or an exception). - -By separating `find()` into its own interface, PHPNomad allows datastores to opt in or out based on their storage model. For example: -* **Database-backed datastores** → implement this (they have primary keys). -* **Log aggregators or event streams** → don't implement this (they don't have primary keys). - -## Usage Patterns - -### Service Layer Integration - -Services typically depend on `DatastoreHasPrimaryKey` when they need ID-based lookups: - -```php -final class PublishPostService -{ - public function __construct( - private PostDatastore $posts // Assumes DatastoreHasPrimaryKey - ) {} - - public function publish(int $postId): void - { - $post = $this->posts->find($postId); - - // Business logic: create new model with updated date - $publishedPost = new Post( - id: $post->id, - title: $post->title, - content: $post->content, - authorId: $post->authorId, - publishedDate: new DateTime() // Set publish date - ); - - $this->posts->save($publishedPost); - } -} -``` - -### REST Controller Example - -REST endpoints often map directly to `find()`: - -```php -final class GetPostController implements Controller -{ - public function __construct( - private PostDatastore $posts, - private Response $response - ) {} - - public function getEndpoint(): string - { - return '/posts/{id}'; - } - - public function getMethod(): string - { - return Method::Get; - } - - public function getResponse(Request $request): Response - { - $id = (int) $request->getParam('id'); - - try { - $post = $this->posts->find($id); - - return $this->response - ->setStatus(200) - ->setJson(['post' => $post]); - } catch (RecordNotFoundException $e) { - return $this->response - ->setStatus(404) - ->setJson(['error' => 'Post not found']); - } - } -} -``` - -### Error Handling - -Always handle `RecordNotFoundException` when calling `find()`: - -```php -// ✅ GOOD: explicit error handling -try { - $post = $postDatastore->find($id); - // ... use post -} catch (RecordNotFoundException $e) { - // Handle gracefully -} - -// ❌ BAD: unhandled exception crashes the application -$post = $postDatastore->find($id); // May throw! -``` - -## Relationship to Other Interfaces - -### vs. `get()` - -Both `find()` and `get()` can fetch records, but they serve different purposes: - -| Method | Returns | When Not Found | Use Case | -|--------|---------|----------------|----------| -| `find($id)` | Single model | Throws exception | Known ID, expect one result | -| `get(['id' => $id])` | Iterable (0 or 1 item) | Empty iterable | Query by criteria, may return none | - -**Example comparison:** - -```php -// Using find() - throws if not found -try { - $post = $datastore->find(42); -} catch (RecordNotFoundException $e) { - // Handle not found -} - -// Using get() - returns empty if not found -$posts = $datastore->get(['id' => 42]); -if (empty($posts)) { - // Handle not found -} -$post = $posts[0] ?? null; -``` - -Use `find()` when you **expect** the record to exist. Use `get()` when existence is uncertain. - -### Combining with Other Extensions - -Most datastores implement multiple interfaces: - -```php -interface PostDatastore extends - Datastore, - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete() from Datastore - // find() from DatastoreHasPrimaryKey - // where() from DatastoreHasWhere - // count() from DatastoreHasCounts -} -``` - -This gives consumers a full set of operations. - -## Implementation with Decorator Traits - -If your Core implementation just delegates to a handler, use [`WithDatastorePrimaryKeyDecorator`](/packages/datastore/traits/with-datastore-primary-key-decorator): - -```php -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; - - public function __construct( - private DatastoreHandlerHasPrimaryKey $handler - ) {} -} -``` - -The trait provides `find()`, `get()`, `save()`, and `delete()` automatically. - -## Implementation Notes - -When implementing this interface: - -* **`find()` must throw `RecordNotFoundException`** if the ID doesn't exist—do not return `null`. -* **Primary key should be indexed** in your storage layer for performance. -* **Thread safety** — `find()` should always return the latest data (no stale reads unless caching is explicit). - -## When NOT to Implement This Interface - -Skip `DatastoreHasPrimaryKey` if: -* Your storage doesn't have primary keys (e.g., logs, events). -* You use compound keys (use custom methods instead). -* You use non-integer IDs (e.g., UUIDs—extend the interface with `findByUuid()` or similar). - -## What's Next - -* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface this extends -* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — query-builder filtering -* [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md b/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md deleted file mode 100644 index 8c36584..0000000 --- a/public/docs/docs/packages/datastore/interfaces/datastore-has-where.md +++ /dev/null @@ -1,292 +0,0 @@ -# DatastoreHasWhere Interface - -The `DatastoreHasWhere` interface extends [`Datastore`](/packages/datastore/interfaces/datastore) to add **query-builder-style filtering**. It provides the `where()` method, which returns a fluent query interface for building complex queries with multiple conditions, comparisons, and sorting. - -This interface is for datastores that support **rich querying** beyond simple key-value filtering. If your storage supports SQL, this interface maps naturally to `WHERE` clauses. - -## Interface Definition - -```php -interface DatastoreHasWhere extends Datastore -{ - /** - * Returns a query interface for building WHERE clauses. - * - * @return DatastoreWhereQuery A fluent query builder - */ - public function where(): DatastoreWhereQuery; -} -``` - -## Method - -### `where(): DatastoreWhereQuery` - -Returns a query builder instance for constructing filtered queries. - -**Returns:** -* A `DatastoreWhereQuery` object that provides methods like `equals()`, `greaterThan()`, `lessThan()`, `in()`, `like()`, `orderBy()`, `limit()`, and `getResults()`. - -**When to use:** -* Complex filtering (multiple conditions, OR logic, comparisons) -* Sorting results -* Pagination with complex criteria -* Queries that don't map cleanly to `get(['key' => 'value'])` - -**Example: basic filtering** - -```php -$posts = $postDatastore - ->where() - ->equals('author_id', 123) - ->getResults(); -``` - -**Example: multiple conditions** - -```php -$posts = $postDatastore - ->where() - ->equals('author_id', 123) - ->greaterThan('published_date', '2024-01-01') - ->lessThanOrEqual('published_date', '2024-12-31') - ->getResults(); -``` - -**Example: OR conditions** - -```php -$posts = $postDatastore - ->where() - ->equals('status', 'published') - ->or() - ->equals('status', 'featured') - ->getResults(); -``` - -**Example: sorting and pagination** - -```php -$posts = $postDatastore - ->where() - ->equals('author_id', 123) - ->orderBy('published_date', 'DESC') - ->limit(10) - ->offset(20) - ->getResults(); -``` - ---- - -## DatastoreWhereQuery API - -The `DatastoreWhereQuery` interface provides a fluent API for building queries. Implementations typically support: - -### Comparison Methods - -* `equals(string $field, mixed $value)` — `field = value` -* `notEquals(string $field, mixed $value)` — `field != value` -* `greaterThan(string $field, mixed $value)` — `field > value` -* `greaterThanOrEqual(string $field, mixed $value)` — `field >= value` -* `lessThan(string $field, mixed $value)` — `field < value` -* `lessThanOrEqual(string $field, mixed $value)` — `field <= value` -* `in(string $field, array $values)` — `field IN (values)` -* `notIn(string $field, array $values)` — `field NOT IN (values)` -* `like(string $field, string $pattern)` — `field LIKE pattern` -* `isNull(string $field)` — `field IS NULL` -* `isNotNull(string $field)` — `field IS NOT NULL` - -### Logical Operators - -* `and()` — AND condition (default between chained methods) -* `or()` — OR condition - -### Ordering and Pagination - -* `orderBy(string $field, string $direction = 'ASC')` — Sort results -* `limit(int $count)` — Limit number of results -* `offset(int $count)` — Skip N results (for pagination) - -### Execution - -* `getResults(): iterable` — Execute the query and return matching models -* `count(): int` — Count matching records (if combined with `DatastoreHasCounts`) -* `delete(): void` — Delete matching records (if supported) - ---- - -## Usage Patterns - -### Service Layer Queries - -Services use `where()` for business queries: - -```php -final class PostService -{ - public function __construct( - private PostDatastore $posts - ) {} - - public function getRecentPublishedPosts(int $authorId, int $limit = 10): iterable - { - return $this->posts - ->where() - ->equals('author_id', $authorId) - ->lessThanOrEqual('published_date', new DateTime()) - ->orderBy('published_date', 'DESC') - ->limit($limit) - ->getResults(); - } - - public function getPostsByTag(string $tag): iterable - { - return $this->posts - ->where() - ->like('tags', "%{$tag}%") - ->getResults(); - } -} -``` - -### Complex Filtering Example - -```php -// Find posts that are: -// - By author 123 OR author 456 -// - Published in 2024 -// - Status is "published" or "featured" -// - Sorted by views (descending) - -$posts = $postDatastore - ->where() - ->in('author_id', [123, 456]) - ->greaterThanOrEqual('published_date', '2024-01-01') - ->lessThan('published_date', '2025-01-01') - ->in('status', ['published', 'featured']) - ->orderBy('view_count', 'DESC') - ->limit(20) - ->getResults(); -``` - -### Counting with Queries - -If your datastore also implements `DatastoreHasCounts`, you can count query results: - -```php -$query = $postDatastore - ->where() - ->equals('author_id', 123) - ->greaterThan('published_date', '2024-01-01'); - -$count = $query->count(); // How many match? -$posts = $query->getResults(); // Fetch them -``` - -### Deleting with Queries - -Some implementations support `delete()` on queries: - -```php -// Delete all draft posts older than 30 days -$postDatastore - ->where() - ->equals('status', 'draft') - ->lessThan('created_at', new DateTime('-30 days')) - ->delete(); -``` - -**Note:** Not all datastores support query-based deletion. Check your implementation. - ---- - -## Why This Interface Exists - -The base [`get()`](/packages/datastore/interfaces/datastore) method works for simple queries: - -```php -$posts = $datastore->get(['author_id' => 123]); -``` - -But it breaks down for: -* **Comparisons** — `get(['views >' => 1000])` is awkward and non-standard. -* **OR logic** — `get(['status' => 'published OR featured'])` doesn't work. -* **Sorting** — `get()` doesn't provide ordering control. - -`DatastoreHasWhere` solves this with a fluent, expressive API that maps cleanly to SQL and other query languages. - -## Relationship to Other Interfaces - -### vs. `get()` - -| Method | Use Case | Query Complexity | -|--------|----------|------------------| -| `get(['key' => 'value'])` | Simple key-value filtering | Low | -| `where()->equals()->getResults()` | Complex queries with comparisons, OR logic, sorting | High | - -**Rule of thumb:** If you can express it as `['key' => 'value']`, use `get()`. Otherwise, use `where()`. - -### Combining with Other Extensions - -```php -interface PostDatastore extends - Datastore, - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete() - // find() - // where() - // count() -} -``` - -This gives you all query capabilities. - ---- - -## Implementation with Decorator Traits - -Use [`WithDatastoreWhereDecorator`](/packages/datastore/traits/with-datastore-where-decorator) to auto-implement: - -```php -final class PostDatastoreImpl implements DatastoreHasWhere -{ - use WithDatastoreWhereDecorator; - - public function __construct( - private DatastoreHandlerHasWhere $handler - ) {} -} -``` - -The trait delegates `where()` to the handler, which returns its query builder. - ---- - -## Implementation Notes - -When implementing this interface: - -* **`where()` returns a new query instance** — don't modify shared state. -* **Queries are immutable** — each method call returns a new query object (or mutates and returns `$this` for chaining). -* **`getResults()` executes the query** — it's the only method that hits storage. -* **Support standard operators** — at minimum: `equals`, `in`, `greaterThan`, `lessThan`, `orderBy`, `limit`, `offset`. - ---- - -## When NOT to Implement This Interface - -Skip `DatastoreHasWhere` if: -* Your storage doesn't support filtering (e.g., simple key-value stores). -* Queries are always simple and `get()` suffices. -* You're wrapping an API that doesn't support complex queries. - ---- - -## What's Next - -* [Datastore Interface](/packages/datastore/interfaces/datastore) — the base interface -* [DatastoreHasCounts](/packages/datastore/interfaces/datastore-has-counts) — counting query results -* [WithDatastoreWhereDecorator](/packages/datastore/traits/with-datastore-where-decorator) — auto-implement this interface diff --git a/public/docs/docs/packages/datastore/interfaces/datastore.md b/public/docs/docs/packages/datastore/interfaces/datastore.md deleted file mode 100644 index d2ac433..0000000 --- a/public/docs/docs/packages/datastore/interfaces/datastore.md +++ /dev/null @@ -1,266 +0,0 @@ -# Datastore Interface - -The `Datastore` interface is the **foundational contract** for all data access in PHPNomad. It defines three core operations—`get()`, `save()`, and `delete()`—that form the basis of every datastore implementation. Every other datastore interface extends from this one. - -## Interface Definition - -```php -interface Datastore -{ - /** - * Retrieves a collection of models based on the provided criteria. - * - * @param array $args Filtering criteria (implementation-defined) - * @return iterable Collection of models matching the criteria - */ - public function get(array $args = []): iterable; - - /** - * Persists a model to storage (create or update). - * - * @param Model $item The model to save - * @return Model The saved model (may include generated IDs or timestamps) - */ - public function save(Model $item): Model; - - /** - * Removes a model from storage. - * - * @param Model $item The model to delete - * @return void - */ - public function delete(Model $item): void; -} -``` - -## Methods - -### `get(array $args = []): iterable` - -Retrieves a collection of models matching the provided criteria. - -**Parameters:** -* `$args` — Filtering criteria as an associative array. The structure is **implementation-defined**, meaning each datastore decides what keys are valid. - -**Returns:** -* An iterable collection of `Model` objects (typically an array or generator). - -**Common `$args` patterns:** - -```php -// Filter by a single field -$posts = $datastore->get(['author_id' => 123]); - -// Multiple conditions (AND logic, typically) -$posts = $datastore->get([ - 'author_id' => 123, - 'status' => 'published' -]); - -// Limit results -$posts = $datastore->get(['limit' => 10]); - -// Pagination -$posts = $datastore->get(['limit' => 10, 'offset' => 20]); - -// Empty args = all records -$posts = $datastore->get(); -``` - -**When to use:** -* Fetching multiple records with simple filtering -* When you don't need advanced query building (use `where()` for that) -* Quick lookups by known fields - -**Example:** - -```php -// Get all published posts by a specific author -$posts = $postDatastore->get([ - 'author_id' => 123, - 'status' => 'published' -]); - -foreach ($posts as $post) { - echo $post->title . "\n"; -} -``` - ---- - -### `save(Model $item): Model` - -Persists a model to storage. This method handles both **create** and **update** operations—implementations typically determine which based on whether the model has a primary key. - -**Parameters:** -* `$item` — The model to persist. - -**Returns:** -* The saved model, potentially with updated fields (e.g., auto-generated IDs, timestamps). - -**Behavior:** -* **Create:** If the model lacks a primary key (or it's `null`), a new record is created. -* **Update:** If the model has a primary key, the existing record is updated. - -**Example: Creating a new record** - -```php -$newPost = new Post( - id: null, // No ID yet - title: 'My First Post', - content: 'Hello world!', - authorId: 123, - publishedDate: new DateTime() -); - -$savedPost = $postDatastore->save($newPost); - -echo $savedPost->id; // Now has an auto-generated ID -``` - -**Example: Updating an existing record** - -```php -$existingPost = $postDatastore->find(42); - -// Models are immutable, so we create a new instance with updated fields -$updatedPost = new Post( - id: $existingPost->id, - title: 'Updated Title', - content: $existingPost->content, - authorId: $existingPost->authorId, - publishedDate: $existingPost->publishedDate -); - -$postDatastore->save($updatedPost); -``` - -**When to use:** -* Creating new records -* Updating existing records -* Persisting changes after business logic - ---- - -### `delete(Model $item): void` - -Removes a model from storage. - -**Parameters:** -* `$item` — The model to delete. Implementations typically extract the primary key from the model to perform the deletion. - -**Returns:** -* `void` — no return value. - -**Behavior:** -* The model is removed from storage. -* If the model doesn't exist, behavior is implementation-defined (may throw an exception or silently succeed). - -**Example:** - -```php -$post = $postDatastore->find(42); - -$postDatastore->delete($post); - -// Post 42 is now removed from storage -``` - -**When to use:** -* Removing records after business logic determines they should be deleted -* Cleanup operations -* Cascading deletes (if not handled by database constraints) - ---- - -## Usage Patterns - -### Basic CRUD Operations - -The `Datastore` interface provides everything needed for simple create-read-update-delete operations: - -```php -// CREATE -$newPost = new Post(null, 'Title', 'Content', 123, new DateTime()); -$savedPost = $datastore->save($newPost); - -// READ -$posts = $datastore->get(['author_id' => 123]); - -// UPDATE -$updatedPost = new Post( - $savedPost->id, - 'New Title', - $savedPost->content, - $savedPost->authorId, - $savedPost->publishedDate -); -$datastore->save($updatedPost); - -// DELETE -$datastore->delete($updatedPost); -``` - -### Working with Immutable Models - -PHPNomad models are **immutable**—once created, their properties cannot change. To "update" a model, you create a new instance with the changed values: - -```php -$post = $datastore->find(42); - -// Create new instance with updated title -$updatedPost = new Post( - id: $post->id, - title: 'New Title', // Changed - content: $post->content, - authorId: $post->authorId, - publishedDate: $post->publishedDate -); - -$datastore->save($updatedPost); -``` - -This ensures data consistency and makes debugging easier (you always know where state changes). - -### Filtering Semantics - -The `$args` parameter in `get()` is **implementation-defined**, but most implementations follow these conventions: - -* **Keys are column names** — `['author_id' => 123]` filters by `author_id`. -* **Multiple keys use AND logic** — `['author_id' => 123, 'status' => 'published']` means "author is 123 AND status is published". -* **Special keys control behavior** — `limit`, `offset`, `order_by` are common. - -For complex queries (OR conditions, comparisons like `>` or `LIKE`), use [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where) instead. - -## Extending the Base Interface - -Most datastores extend `Datastore` with additional capabilities: - -```php -interface PostDatastore extends Datastore, DatastoreHasPrimaryKey -{ - // Inherits get(), save(), delete() - // Adds find() from DatastoreHasPrimaryKey - - // Custom business methods can be added - public function findPublishedPosts(int $authorId): iterable; -} -``` - -See [Datastore Interfaces Overview](/packages/datastore/interfaces/introduction) for extension patterns. - -## Implementation Notes - -When implementing this interface: - -* **`get()` should return an empty iterable** if no matches are found (not `null`, not an exception). -* **`save()` should be idempotent** — calling it multiple times with the same model should produce the same result. -* **`delete()` should not throw** if the model doesn't exist (graceful degradation). -* **Models should be validated** before persistence (use validation services, not handler code). - -## What's Next - -* [DatastoreHasPrimaryKey](/packages/datastore/interfaces/datastore-has-primary-key) — adds `find(int $id)` for ID-based lookups -* [DatastoreHasWhere](/packages/datastore/interfaces/datastore-has-where) — adds query-builder-style filtering -* [DatastoreHasCounts](/packages/datastore/interfaces/datastore-has-counts) — adds `count()` for efficient counting -* [Core Implementation](/packages/datastore/core-implementation) — how to implement these interfaces diff --git a/public/docs/docs/packages/datastore/interfaces/introduction.md b/public/docs/docs/packages/datastore/interfaces/introduction.md deleted file mode 100644 index d884e08..0000000 --- a/public/docs/docs/packages/datastore/interfaces/introduction.md +++ /dev/null @@ -1,196 +0,0 @@ -# Datastore Interfaces - -Datastore interfaces define the **public API** for data access in PHPNomad. They describe what operations consumers can perform without tying them to any specific storage implementation. This separation is what makes datastores portable: the same interface works whether your data lives in a database, a REST API, in-memory cache, or a flat file. - -At the core, every datastore interface extends from **`Datastore`**, which provides basic operations like `get()`, `save()`, and `delete()`. From there, you can layer on additional capabilities through **extension interfaces** that add primary key lookups, querying, counting, and more. - -## Why Interfaces Matter - -In PHPNomad, **interfaces are contracts** that your application code depends on. By coding against `Datastore` or `DatastoreHasPrimaryKey`, you're expressing *what you need* without caring *how it's implemented*. - -This matters because: - -* **Portability** — swap implementations without touching application code (e.g., move from database to REST). -* **Testability** — mock or stub the interface in tests without spinning up real storage. -* **Clarity** — each interface declares exactly what operations it supports, making API boundaries obvious. - -When you write a service or controller that depends on a datastore, you inject the **interface**, not a concrete class. The DI container handles the rest. - -## The Base Interface: `Datastore` - -The `Datastore` interface is the **minimal contract** every datastore must implement. It provides three core operations: - -```php -interface Datastore -{ - /** - * Retrieves a collection of models based on the provided criteria. - */ - public function get(array $args = []): iterable; - - /** - * Persists a model to storage. - */ - public function save(Model $item): Model; - - /** - * Removes a model from storage. - */ - public function delete(Model $item): void; -} -``` - -### What this enables - -* **Fetch** — `get($args)` returns an iterable collection of models filtered by arbitrary criteria. -* **Persist** — `save($model)` writes a model to storage (create or update). -* **Remove** — `delete($model)` deletes a model from storage. - -This is enough to build most CRUD operations. When you need more specific operations (like fetching by ID or running WHERE clauses), you extend this base with additional interfaces. - -## Extension Interfaces - -PHPNomad provides several **extension interfaces** that add specific capabilities to the base `Datastore` contract. Each one is focused on a single concern, and you compose them as needed. - -### `DatastoreHasPrimaryKey` - -Adds the ability to **fetch by primary key** — a common pattern for single-record lookups. - -```php -interface DatastoreHasPrimaryKey extends Datastore -{ - /** - * Finds a single record by its primary key. - * - * @throws RecordNotFoundException if not found - */ - public function find(int $id): Model; -} -``` - -**When to use:** Your datastore has a single integer primary key (e.g., `id`), and you need fast lookups by ID. - -**Example:** -```php -$post = $postDatastore->find(42); -``` - ---- - -### `DatastoreHasWhere` - -Adds **query-builder-style filtering** with a `where()` method that returns a scoped query interface. - -```php -interface DatastoreHasWhere extends Datastore -{ - /** - * Returns a query interface for building WHERE clauses. - */ - public function where(): DatastoreWhereQuery; -} -``` - -**When to use:** You need to filter records by multiple criteria, and `get($args)` isn't expressive enough. - -**Example:** -```php -$posts = $postDatastore - ->where() - ->equals('authorId', 123) - ->greaterThan('publishedDate', '2024-01-01') - ->getResults(); -``` - ---- - -### `DatastoreHasCounts` - -Adds **counting operations** without fetching all records. - -```php -interface DatastoreHasCounts extends Datastore -{ - /** - * Returns the total number of records matching the criteria. - */ - public function count(array $args = []): int; -} -``` - -**When to use:** You need to know *how many* records exist without loading them all into memory (e.g., pagination totals). - -**Example:** -```php -$totalPosts = $postDatastore->count(['authorId' => 123]); -``` - ---- - -## Composing Interfaces - -In practice, most datastores implement **multiple interfaces** to provide a rich API. For example: - -```php -interface PostDatastore extends - Datastore, - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // Custom business methods can also be added here - public function findPublishedPosts(int $authorId): iterable; -} -``` - -This gives consumers: -* Basic operations via `Datastore` -* ID lookups via `DatastoreHasPrimaryKey` -* Complex queries via `DatastoreHasWhere` -* Efficient counting via `DatastoreHasCounts` -* Domain-specific methods like `findPublishedPosts()` - -## The Minimal API Approach - -Not every datastore needs all these capabilities. If your storage layer doesn't support primary keys (e.g., a log aggregator or event stream), you might only implement `Datastore`. - -**Example: minimal datastore** - -```php -interface AuditLogDatastore extends Datastore -{ - // Only needs get(), save(), delete() - // No primary keys, no WHERE queries -} -``` - -This is valid and often preferable. **Only add interfaces when you actually need the capability**, not because other datastores have them. - -## Working with DatastoreHandlers - -While datastore interfaces define the **public API**, `DatastoreHandler` interfaces define the **implementation contract** for storage backends. - -For example: -* `Datastore` is what your application depends on. -* `DatastoreHandler` is what the database/REST/file adapter implements. - -The [Core implementation](/packages/datastore/core-implementation) sits between these two, delegating calls from the public interface to the handler. This separation keeps business logic independent of storage details. - -See [DatastoreHandler interfaces](/packages/database/handlers/introduction) for the storage-side contracts. - -## Best Practices - -When designing or using datastore interfaces: - -* **Depend on interfaces, not implementations** — inject `PostDatastore`, not `DatabasePostDatastore`. -* **Only extend what you need** — don't add `DatastoreHasPrimaryKey` if you don't have primary keys. -* **Keep interfaces focused** — each extension adds one capability. Don't create bloated "god interfaces." -* **Use custom methods sparingly** — add domain-specific methods to your interface, but prefer composing standard operations when possible. - -## What's Next - -To understand how these interfaces are implemented, see: - -* [Core Implementation](/packages/datastore/core-implementation) — how to build the layer that implements these interfaces -* [Decorator Traits](/packages/datastore/traits/introduction) — eliminate boilerplate when delegating to handlers -* [Database Handlers](/packages/database/handlers/introduction) — how storage backends implement the handler contract diff --git a/public/docs/docs/packages/datastore/introduction.md b/public/docs/docs/packages/datastore/introduction.md deleted file mode 100644 index ec8bf7b..0000000 --- a/public/docs/docs/packages/datastore/introduction.md +++ /dev/null @@ -1,309 +0,0 @@ -# Datastore - -`phpnomad/datastore` is a **storage-agnostic data access layer** that separates **what data operations you need** from **how they're implemented**. It's designed to let you describe **models, interfaces, and operations** in a way that's **independent of the persistence backend** you plug into. - -At its core: - -* **Models** represent domain entities as immutable value objects with no persistence awareness. -* **Datastores** define business-level data operations through interfaces. -* **DatastoreHandlers** provide the contract for concrete implementations. -* **ModelAdapters** convert between models and storage representations. -* **Decorator traits** eliminate boilerplate delegation code. - -By separating data access **definition** (what operations exist, what they require, what they return) from **implementation** (database queries, API calls, cache lookups), you get datastores that can move between storage backends without rewriting business logic. - ---- - -## Key ideas at a glance - -* **Datastore** — Your public API defining business-level data operations for an entity. -* **DatastoreHandler** — The contract that concrete storage implementations fulfill. -* **Model** — An immutable value object representing a domain entity, independent of persistence. -* **ModelAdapter** — Converts between models and storage representations (arrays, JSON, etc.). -* **Decorator traits** — Automatically delegate standard operations to handlers, keeping code lean. - ---- - -## The data access lifecycle - -When your application performs a data operation through a datastore, it moves through a consistent sequence: - -``` -Application → Datastore → Handler → Storage (Database/API/Cache) → Adapter → Model -``` - -### Application layer - -Your controllers, services, or other application code depend on the `Datastore` interface. They call methods like `find()`, `create()`, `where()`, or custom business methods like `getPublishedPosts()`. - -```php -$post = $postDatastore->find(123); -$published = $postDatastore->getPublishedPosts(); -``` - -The application never knows whether posts come from a database, REST API, or cache. It only knows the operations available on the `PostDatastore` interface. - -### Datastore layer - -The **Datastore** is your public API. It extends base interfaces (optionally) and adds custom business methods. Standard operations are delegated to the handler using decorator traits. Custom methods compose handler primitives to implement business logic. - -```php -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; - use WithDatastorePrimaryKeyDecorator; - - protected Datastore $datastoreHandler; - - public function __construct(PostDatastoreHandler $datastoreHandler) - { - $this->datastoreHandler = $datastoreHandler; - } - - // Custom business method - public function getPublishedPosts(): array - { - return $this->datastoreHandler->where([ - ['column' => 'status', 'operator' => '=', 'value' => 'published'] - ]); - } -} -``` - -### Handler layer - -The **DatastoreHandler** is the contract for storage implementations. It extends the same base interfaces as the Datastore but typically contains no custom business methods. Handlers focus on the primitives: create, find, update, delete, query. - -Different implementations exist for different storage backends: -- `PostDatabaseDatastoreHandler` — queries a database -- `PostGraphQLDatastoreHandler` — calls a GraphQL API -- `PostRESTDatastoreHandler` — makes HTTP requests -- `PostCacheDatastoreHandler` — reads from cache - -### Storage layer - -The handler interacts with the actual storage mechanism. For databases, this means SQL queries. For APIs, this means HTTP requests. For caches, this means key-value lookups. - -The storage layer knows nothing about models or business logic. It works with raw data representations (arrays, JSON objects, database rows). - -### Adapter layer - -The **ModelAdapter** converts between storage representations and domain models. When reading, it takes raw data (arrays) and constructs model objects. When writing, it takes model objects and produces storable data. - -```php -class PostAdapter implements ModelAdapter -{ - public function toModel(array $data): Post - { - return new Post( - id: $data['id'], - title: $data['title'], - content: $data['content'] - ); - } - - public function toArray(Post $model): array - { - return [ - 'id' => $model->getId(), - 'title' => $model->title, - 'content' => $model->content - ]; - } -} -``` - -### Model layer - -The **Model** is the final result — a domain entity your application can use. Models are immutable value objects with public readonly properties. They contain no persistence logic and don't know where they came from. - -```php -class Post implements DataModel, HasSingleIntIdentity -{ - use WithSingleIntIdentity; - - public function __construct( - int $id, - public readonly string $title, - public readonly string $content - ) { - $this->id = $id; - } -} -``` - ---- - -## Why separation matters - -### Storage independence - -By depending only on the `Datastore` interface, your application code remains portable. If posts initially come from a database but later need to come from a CMS API, you swap the handler implementation. Application code doesn't change. - -```php -// Day 1: Database implementation -$container->bind(PostDatastoreHandler::class, PostDatabaseDatastoreHandler::class); - -// Day 90: Switch to REST API -$container->bind(PostDatastoreHandler::class, PostRESTDatastoreHandler::class); - -// Application code unchanged -$post = $postDatastore->find(123); -``` - -### Testability - -Datastores are easy to test. Mock the handler, inject it into the datastore, and verify business methods work correctly without touching a real database or API. - -### Clear contracts - -The separation between `Datastore` (what consumers need) and `DatastoreHandler` (what implementations provide) makes contracts explicit. Consumers depend on business operations. Implementations provide storage primitives. - ---- - -## Core interfaces - -The datastore package provides several base interfaces you can extend: - -### Datastore - -The foundational interface. All datastores extend `Datastore`, which defines basic create and update operations. - -```php -interface Datastore -{ - public function create(array $attributes): DataModel; - public function updateCompound(array $ids, array $attributes): void; -} -``` - -### DatastoreHasPrimaryKey - -Adds operations for entities with single integer IDs: find, update, delete by ID. - -```php -interface DatastoreHasPrimaryKey -{ - public function find(int $id): DataModel; - public function findMultiple(array $ids): array; - public function update(int $id, array $attributes): void; - public function delete(int $id): void; -} -``` - -### DatastoreHasWhere - -Adds query operations with conditions: where, andWhere, orWhere, deleteWhere, findBy. - -```php -interface DatastoreHasWhere -{ - public function where(array $conditions, ?int $limit = null, ...): array; - public function andWhere(array $conditions, ?int $limit = null, ...): array; - public function orWhere(array $conditions, ?int $limit = null, ...): array; - public function deleteWhere(array $conditions): void; - public function findBy(string $field, $value): DataModel; -} -``` - -### DatastoreHasCounts - -Adds counting operations for query results. - -You're not required to extend these interfaces. For APIs with limited operations, define only what you need. - -See [Datastore Interfaces](interfaces/introduction) for complete documentation. - ---- - -## Decorator traits - -When your `Datastore` and `DatastoreHandler` extend the same base interfaces, decorator traits eliminate boilerplate delegation code. - -```php -class PostDatastore implements PostDatastoreInterface -{ - use WithDatastoreDecorator; // Delegates: create, updateCompound - use WithDatastorePrimaryKeyDecorator; // Delegates: find, update, delete - use WithDatastoreWhereDecorator; // Delegates: where, andWhere, orWhere - - protected Datastore $datastoreHandler; - - // Only implement custom business methods - public function getPublishedPosts(): array - { - return $this->datastoreHandler->where([...]); - } -} -``` - -Without traits, you'd manually write dozens of delegation methods. Traits handle standard operations automatically. - -See [Decorator Traits](traits/introduction) for complete documentation. - ---- - -## When to use this package - -Use `phpnomad/datastore` when: - -- You need storage-agnostic data access -- Portability between storage backends is important -- You want strong separation between domain and infrastructure -- Multiple implementations of the same entity are anticipated (database today, API tomorrow) -- Testing domain logic independently of storage is critical - -For simple applications with a single, stable storage mechanism and no portability requirements, this abstraction may be overkill. The datastore pattern shines when flexibility and future adaptability matter. - ---- - -## Working with databases - -While the datastore package is storage-agnostic, most applications use databases. The `phpnomad/database` package provides concrete database implementations of the datastore interfaces, including table schema definitions, query builders, caching, and event broadcasting. - -See [Database Package](../database/introduction) for database-specific documentation. - ---- - -## Working with other backends - -The datastore package isn't limited to databases. You can implement handlers for: - -- **REST APIs** — Make HTTP requests, convert JSON to models -- **GraphQL APIs** — Execute GraphQL queries, map responses to models -- **Cache layers** — Read from Redis/Memcached with fallback to primary storage -- **In-memory stores** — Arrays or collections for testing -- **File systems** — JSON/XML files as simple persistence - -See [Integration Guide](integration-guide) for implementing custom handlers. - ---- - -## Package components - -### Required reading - -- **[Core Implementation](core-implementation)** — Directory structure, naming conventions, Datastore vs DatastoreHandler distinction, decorator pattern usage -- **[Datastore Interfaces](interfaces/introduction)** — Complete interface reference -- **[Model Adapters](model-adapters)** — How to create adapters - -### Reference - -- **[Decorator Traits](traits/introduction)** — All available traits and their delegated methods -- **[Integration Guide](integration-guide)** — Implementing custom storage backends - ---- - -## Relationship to other packages - -- **[phpnomad/database](../database/introduction)** — Concrete database implementations of datastore interfaces -- **phpnomad/models** — Provides `DataModel` interface and identity traits (covered in [Models and Identity](../../core-concepts/models-and-identity)) - ---- - -## Next steps - -- **New to datastores?** Start with [Getting Started Tutorial](../../core-concepts/getting-started-tutorial) -- **Understanding the architecture?** Read [Overview and Architecture](../../core-concepts/overview-and-architecture) -- **Ready to implement?** See [Core Implementation](core-implementation) -- **Need database persistence?** Check [Database Package](../database/introduction) diff --git a/public/docs/docs/packages/datastore/model-adapters.md b/public/docs/docs/packages/datastore/model-adapters.md deleted file mode 100644 index 15b2a2e..0000000 --- a/public/docs/docs/packages/datastore/model-adapters.md +++ /dev/null @@ -1,540 +0,0 @@ -# Model Adapters - -Model adapters are **bidirectional transformers** that convert between your immutable domain models and storage-friendly associative arrays. They sit at the boundary between your business logic (which works with strongly-typed models) and your storage layer (which works with raw data). - -Every handler needs an adapter to function. Without adapters, handlers wouldn't know how to convert database rows into models or how to extract data from models for persistence. - -## The Adapter Contract - -All adapters implement the `ModelAdapter` interface: - -```php -interface ModelAdapter -{ - /** - * Converts a model to an associative array for storage. - * - * @param DataModel $model The model to convert - * @return array Associative array with storage-friendly keys - */ - public function toArray(DataModel $model): array; - - /** - * Converts an associative array from storage to a model. - * - * @param array $array Data from storage - * @return DataModel The constructed model instance - */ - public function toModel(array $array): DataModel; -} -``` - -This contract defines two operations: - -* **`toArray()`** — Model → Array (for writes: `save()`, `update()`) -* **`toModel()`** — Array → Model (for reads: `get()`, `find()`) - ---- - -## Basic Adapter Example - -Here's a complete adapter for a `Post` model: - -```php - $model->id, - 'title' => $model->title, - 'content' => $model->content, - 'author_id' => $model->authorId, - 'published_date' => $model->publishedDate?->format('Y-m-d H:i:s'), - 'created_at' => $model->createdAt?->format('Y-m-d H:i:s'), - 'updated_at' => $model->updatedAt?->format('Y-m-d H:i:s'), - ]; - } - - /** - * Convert array from storage to Post model - */ - public function toModel(array $array): DataModel - { - return new Post( - id: Arr::get($array, 'id'), - title: Arr::get($array, 'title', ''), - content: Arr::get($array, 'content', ''), - authorId: Arr::get($array, 'author_id'), - publishedDate: Arr::get($array, 'published_date') - ? new \DateTime(Arr::get($array, 'published_date')) - : null, - createdAt: Arr::get($array, 'created_at') - ? new \DateTime(Arr::get($array, 'created_at')) - : null, - updatedAt: Arr::get($array, 'updated_at') - ? new \DateTime(Arr::get($array, 'updated_at')) - : null - ); - } -} -``` - -**Key responsibilities:** - -1. **Field mapping** — Convert property names (camelCase) to column names (snake_case) -2. **Type conversion** — Transform `DateTime` objects to strings and back -3. **Default values** — Provide fallbacks for missing data using `Arr::get()` -4. **Null handling** — Handle nullable fields gracefully - ---- - -## Why Adapters Exist - -Without adapters, your handler would need to know how to construct your models: - -```php -// ❌ BAD: handler knows model internals -class PostHandler -{ - public function find(int $id): Post - { - $row = $this->queryBuilder->select('*')->where('id', $id)->first(); - - // Handler is tightly coupled to Post constructor - return new Post( - $row['id'], - $row['title'] ?? '', - $row['content'] ?? '', - $row['author_id'], - $row['published_date'] ? new DateTime($row['published_date']) : null - ); - } -} -``` - -With adapters, handlers are **decoupled** from model details: - -```php -// ✅ GOOD: handler delegates to adapter -class PostHandler -{ - public function __construct( - private PostAdapter $adapter - ) {} - - public function find(int $id): Post - { - $row = $this->queryBuilder->select('*')->where('id', $id)->first(); - return $this->adapter->toModel($row); // Adapter handles construction - } -} -``` - -Now if the `Post` constructor changes, only the adapter needs updating. - ---- - -## Adapter Usage in Handlers - -Handlers use adapters in both directions: - -### Reading: Array → Model - -When fetching data from storage: - -```php -public function get(array $args = []): iterable -{ - $rows = $this->queryBuilder - ->select('*') - ->from($this->table->getTableName()) - ->where($args) - ->getResults(); - - // Convert each row to a model - return array_map( - fn($row) => $this->adapter->toModel($row), - $rows - ); -} -``` - -### Writing: Model → Array - -When persisting data to storage: - -```php -public function save(DataModel $model): DataModel -{ - // Convert model to array - $data = $this->adapter->toArray($model); - - if ($model->getId()) { - // UPDATE - $this->queryBuilder - ->update($this->table->getTableName()) - ->set($data) - ->where('id', $model->getId()) - ->execute(); - } else { - // INSERT - $id = $this->queryBuilder - ->insert($this->table->getTableName()) - ->values($data) - ->execute(); - - // Return model with new ID - $data['id'] = $id; - } - - return $this->adapter->toModel($data); -} -``` - ---- - -## Handling Complex Types - -Adapters often need to transform between domain types and storage primitives. - -### DateTime Conversion - -```php -public function toArray(DataModel $model): array -{ - return [ - 'published_date' => $model->publishedDate?->format('Y-m-d H:i:s'), - ]; -} - -public function toModel(array $array): DataModel -{ - return new Post( - publishedDate: Arr::get($array, 'published_date') - ? new \DateTime(Arr::get($array, 'published_date')) - : null - ); -} -``` - -### JSON Fields - -```php -public function toArray(DataModel $model): array -{ - return [ - 'metadata' => json_encode($model->metadata), - ]; -} - -public function toModel(array $array): DataModel -{ - return new Post( - metadata: json_decode(Arr::get($array, 'metadata', '{}'), true) - ); -} -``` - -### Enums (PHP 8.1+) - -```php -public function toArray(DataModel $model): array -{ - return [ - 'status' => $model->status->value, // Enum to string - ]; -} - -public function toModel(array $array): DataModel -{ - return new Post( - status: PostStatus::from(Arr::get($array, 'status', 'draft')) - ); -} -``` - ---- - -## Compound Identity Adapters - -For models with compound primary keys, adapters handle multiple identifying fields: - -```php - $model->userId, - 'session_token' => $model->sessionToken, - 'ip_address' => $model->ipAddress, - 'expires_at' => $model->expiresAt->format('Y-m-d H:i:s'), - 'created_at' => $model->createdAt->format('Y-m-d H:i:s'), - ]; - } - - public function toModel(array $array): DataModel - { - return new UserSession( - userId: Arr::get($array, 'user_id'), - sessionToken: Arr::get($array, 'session_token'), - ipAddress: Arr::get($array, 'ip_address', ''), - expiresAt: new \DateTime(Arr::get($array, 'expires_at')), - createdAt: new \DateTime(Arr::get($array, 'created_at')) - ); - } -} -``` - ---- - -## Field Name Mapping - -Adapters bridge naming conventions between your domain (camelCase) and storage (snake_case): - -**Domain model:** -```php -class Post -{ - public readonly int $authorId; - public readonly DateTime $publishedDate; -} -``` - -**Database columns:** -```sql -CREATE TABLE posts ( - author_id BIGINT NOT NULL, - published_date DATETIME -); -``` - -**Adapter mapping:** -```php -public function toArray(DataModel $model): array -{ - return [ - 'author_id' => $model->authorId, // camelCase → snake_case - 'published_date' => $model->publishedDate->format('Y-m-d H:i:s'), - ]; -} - -public function toModel(array $array): DataModel -{ - return new Post( - authorId: Arr::get($array, 'author_id'), // snake_case → camelCase - publishedDate: new DateTime(Arr::get($array, 'published_date')) - ); -} -``` - ---- - -## Default Values and Safety - -Use `Arr::get()` with defaults to handle missing or null data gracefully: - -```php -public function toModel(array $array): DataModel -{ - return new Post( - id: Arr::get($array, 'id'), // Required - title: Arr::get($array, 'title', ''), // Default to empty string - content: Arr::get($array, 'content', ''), - authorId: Arr::get($array, 'author_id', 0), - publishedDate: Arr::get($array, 'published_date') - ? new DateTime(Arr::get($array, 'published_date')) - : null, // Nullable field - ); -} -``` - -This prevents crashes when data is incomplete or malformed. - ---- - -## Adapters for Junction Tables - -Junction table adapters are simpler—they only handle foreign keys: - -```php - $model->postId, - 'tag_id' => $model->tagId, - ]; - } - - public function toModel(array $array): DataModel - { - return new PostTag( - postId: Arr::get($array, 'post_id'), - tagId: Arr::get($array, 'tag_id') - ); - } -} -``` - ---- - -## Testing Adapters - -Adapters should be tested independently to ensure correct transformations: - -```php -class PostAdapterTest extends TestCase -{ - private PostAdapter $adapter; - - protected function setUp(): void - { - $this->adapter = new PostAdapter(); - } - - public function test_toArray_converts_model_to_array(): void - { - $post = new Post( - id: 1, - title: 'Test Post', - content: 'Content', - authorId: 123, - publishedDate: new DateTime('2024-01-01 12:00:00') - ); - - $array = $this->adapter->toArray($post); - - $this->assertEquals(1, $array['id']); - $this->assertEquals('Test Post', $array['title']); - $this->assertEquals('2024-01-01 12:00:00', $array['published_date']); - } - - public function test_toModel_converts_array_to_model(): void - { - $array = [ - 'id' => 1, - 'title' => 'Test Post', - 'content' => 'Content', - 'author_id' => 123, - 'published_date' => '2024-01-01 12:00:00', - ]; - - $post = $this->adapter->toModel($array); - - $this->assertEquals(1, $post->id); - $this->assertEquals('Test Post', $post->title); - $this->assertEquals(123, $post->authorId); - $this->assertEquals('2024-01-01', $post->publishedDate->format('Y-m-d')); - } - - public function test_roundtrip_preserves_data(): void - { - $original = new Post( - id: 1, - title: 'Test', - content: 'Content', - authorId: 123, - publishedDate: new DateTime('2024-01-01') - ); - - $array = $this->adapter->toArray($original); - $restored = $this->adapter->toModel($array); - - $this->assertEquals($original->id, $restored->id); - $this->assertEquals($original->title, $restored->title); - } -} -``` - ---- - -## Best Practices - -### Use Arr::get() for Safe Access - -```php -// ✅ GOOD: safe with defaults -$title = Arr::get($array, 'title', ''); - -// ❌ BAD: crashes if key missing -$title = $array['title']; -``` - -### Handle Null Appropriately - -```php -// ✅ GOOD: null-safe -'published_date' => $model->publishedDate?->format('Y-m-d H:i:s') - -// ❌ BAD: crashes if null -'published_date' => $model->publishedDate->format('Y-m-d H:i:s') -``` - -### Keep Adapters Pure - -Adapters should only transform data—no business logic, no validation, no side effects: - -```php -// ❌ BAD: adapter has business logic -public function toModel(array $array): DataModel -{ - $post = new Post(...); - - if ($post->publishedDate < new DateTime()) { - throw new ValidationException("Cannot create past-dated post"); - } - - return $post; -} - -// ✅ GOOD: adapter only transforms -public function toModel(array $array): DataModel -{ - return new Post(...); -} -``` - -### One Adapter Per Model - -Each model should have exactly one adapter. Don't create multiple adapters for different serialization formats—use separate transformation services for that. - ---- - -## What's Next - -* [Integration Guide](/packages/datastore/integration-guide) — complete datastore setup with adapters -* [Models and Identity](/core-concepts/models-and-identity) — designing models for use with adapters -* [Database Handlers](/packages/database/handlers/introduction) — how handlers use adapters diff --git a/public/docs/docs/packages/datastore/traits/introduction.md b/public/docs/docs/packages/datastore/traits/introduction.md deleted file mode 100644 index 6a4bf7d..0000000 --- a/public/docs/docs/packages/datastore/traits/introduction.md +++ /dev/null @@ -1,267 +0,0 @@ -# Decorator Traits - -Decorator traits in PHPNomad are **code generators** that eliminate boilerplate when building datastore implementations. They automatically delegate method calls from your datastore class to the underlying handler, so you don't have to write repetitive pass-through methods by hand. - -When you implement a datastore interface that extends something like `DatastoreHasPrimaryKey`, you need to provide implementations for `get()`, `save()`, `delete()`, *and* `find()`. If your class is just delegating all those calls to a handler, that's a lot of mechanical code. Decorator traits collapse that down to a single `use` statement. - -## Why Decorator Traits Exist - -In the two-level datastore architecture, your **Core implementation** sits between the public `Datastore` interface and the `DatastoreHandler` that talks to storage. Most of the time, your Core class doesn't add logic—it just forwards calls: - -```php -final class PostDatastore implements DatastoreHasPrimaryKey -{ - public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} - - public function get(array $args = []): iterable - { - return $this->handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - public function delete(Model $item): void - { - $this->handler->delete($item); - } - - public function find(int $id): Model - { - return $this->handler->find($id); - } -} -``` - -Every method is identical: `return $this->handler->methodName(...)`. That's tedious to write and maintain. Decorator traits replace all of that with: - -```php -final class PostDatastore implements DatastoreHasPrimaryKey -{ - use WithDatastorePrimaryKeyDecorator; - - public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} -} -``` - -**That's it.** The trait provides all four methods automatically. - -## How They Work - -Each decorator trait corresponds to one of the [datastore interfaces](/packages/datastore/interfaces/introduction). The trait provides method implementations that delegate to a `$handler` property. - -When you `use` the trait, you must: -1. Store the handler in a property named `$handler`. -2. Ensure the handler implements the matching handler interface. - -The trait will generate the delegation code for every method in that interface. - -## Available Decorator Traits - -PHPNomad provides four decorator traits, one for each standard interface: - -### `WithDatastoreDecorator` - -Decorates the base **`Datastore`** interface. - -**Provides:** -- `get(array $args = []): iterable` -- `save(Model $item): Model` -- `delete(Model $item): void` - -**Requires handler type:** `DatastoreHandler` - -**Usage:** -```php -final class PostDatastore implements Datastore -{ - use WithDatastoreDecorator; - - public function __construct(private DatastoreHandler $handler) {} -} -``` - ---- - -### `WithDatastorePrimaryKeyDecorator` - -Decorates **`DatastoreHasPrimaryKey`** (which extends `Datastore`). - -**Provides:** -- All methods from `WithDatastoreDecorator` -- `find(int $id): Model` - -**Requires handler type:** `DatastoreHandlerHasPrimaryKey` - -**Usage:** -```php -final class PostDatastore implements DatastoreHasPrimaryKey -{ - use WithDatastorePrimaryKeyDecorator; - - public function __construct(private DatastoreHandlerHasPrimaryKey $handler) {} -} -``` - ---- - -### `WithDatastoreWhereDecorator` - -Decorates **`DatastoreHasWhere`** (which extends `Datastore`). - -**Provides:** -- All methods from `WithDatastoreDecorator` -- `where(): DatastoreWhereQuery` - -**Requires handler type:** `DatastoreHandlerHasWhere` - -**Usage:** -```php -final class PostDatastore implements DatastoreHasWhere -{ - use WithDatastoreWhereDecorator; - - public function __construct(private DatastoreHandlerHasWhere $handler) {} -} -``` - ---- - -### `WithDatastoreCountDecorator` - -Decorates **`DatastoreHasCounts`** (which extends `Datastore`). - -**Provides:** -- All methods from `WithDatastoreDecorator` -- `count(array $args = []): int` - -**Requires handler type:** `DatastoreHandlerHasCounts` - -**Usage:** -```php -final class PostDatastore implements DatastoreHasCounts -{ - use WithDatastoreCountDecorator; - - public function __construct(private DatastoreHandlerHasCounts $handler) {} -} -``` - ---- - -## Composing Multiple Traits - -If your datastore interface extends multiple capabilities, you can use multiple traits together. PHP allows this as long as there are no method name conflicts (and PHPNomad's traits are designed to compose cleanly). - -**Example: combining primary key and counting** - -```php -interface PostDatastore extends DatastoreHasPrimaryKey, DatastoreHasCounts -{ - // find(), get(), save(), delete(), count() -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; - use WithDatastoreCountDecorator; - - public function __construct( - private DatastoreHandlerHasPrimaryKey & DatastoreHandlerHasCounts $handler - ) {} -} -``` - -Both traits delegate to `$this->handler`, and the handler implements both interfaces. - -## When NOT to Use Decorator Traits - -Decorator traits are perfect for **pass-through implementations** where you don't need to add logic. But if you need to: - -- Transform data before or after handler calls -- Add caching, logging, or authorization checks -- Override specific methods with custom behavior - -Then you should **implement the methods manually** instead of using the trait. - -**Example: custom logic in `find()`** - -```php -final class PostDatastore implements DatastoreHasPrimaryKey -{ - use WithDatastoreDecorator; // Only for get/save/delete - - public function __construct( - private DatastoreHandlerHasPrimaryKey $handler, - private LoggerStrategy $logger - ) {} - - // Custom implementation with logging - public function find(int $id): Model - { - $this->logger->info("Fetching post {$id}"); - return $this->handler->find($id); - } -} -``` - -Here we use `WithDatastoreDecorator` for the basic methods, but implement `find()` ourselves to add logging. - -## Real-World Example: Full Composition - -Here's a realistic example showing how traits simplify a datastore with multiple capabilities and one custom method: - -```php -interface PostDatastore extends - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - public function findPublishedPosts(int $authorId): iterable; -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; - use WithDatastoreWhereDecorator; - use WithDatastoreCountDecorator; - - public function __construct( - private DatastoreHandlerHasPrimaryKey & - DatastoreHandlerHasWhere & - DatastoreHandlerHasCounts $handler - ) {} - - // Custom business method - not auto-generated - public function findPublishedPosts(int $authorId): iterable - { - return $this->where() - ->equals('authorId', $authorId) - ->lessThanOrEqual('publishedDate', new DateTime()) - ->getResults(); - } -} -``` - -The traits provide `get()`, `save()`, `delete()`, `find()`, `where()`, and `count()` automatically. You only write the custom `findPublishedPosts()` method by hand. - -## Best Practices - -When working with decorator traits: - -- **Use traits for delegation only** — if you need logic, implement methods manually. -- **Name the handler property `$handler`** — traits expect this name. -- **Match handler types to interfaces** — if you implement `DatastoreHasPrimaryKey`, use `DatastoreHandlerHasPrimaryKey`. -- **Compose traits freely** — multiple traits work together as long as interfaces align. -- **Override when needed** — you can always implement specific methods yourself instead of using the trait's version. - -## What's Next - -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/docs/packages/datastore/traits/with-datastore-count-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md deleted file mode 100644 index 4a23c88..0000000 --- a/public/docs/docs/packages/datastore/traits/with-datastore-count-decorator.md +++ /dev/null @@ -1,217 +0,0 @@ -# WithDatastoreCountDecorator Trait - -The `WithDatastoreCountDecorator` trait provides automatic implementations of the [`DatastoreHasCounts`](/packages/datastore/interfaces/datastore-has-counts) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `count()` method for efficient record counting. - -## What It Provides - -This trait implements four methods: - -* `get(array $args = []): iterable` — from `Datastore` -* `save(Model $item): Model` — from `Datastore` -* `delete(Model $item): void` — from `Datastore` -* `count(array $args = []): int` — from `DatastoreHasCounts` - -All methods delegate to `$this->handler`. - -## Requirements - -To use this trait, your class must: - -1. **Implement `DatastoreHasCounts`** — the trait provides the method bodies. -2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasCounts`. -3. **Initialize the handler** — typically via constructor injection. - -## Basic Usage - -```php -handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - public function delete(Model $item): void - { - $this->handler->delete($item); - } - - public function count(array $args = []): int - { - return $this->handler->count($args); - } -} -``` - -## When to Use This Trait - -Use `WithDatastoreCountDecorator` when: - -* Your datastore needs efficient counting operations. -* Your Core implementation doesn't add logic—it just delegates to the handler. -* You want to minimize boilerplate in standard implementations. - -## When NOT to Use This Trait - -Don't use this trait if you need to: - -* Add caching around count operations. -* Log or track count queries. -* Transform count criteria before delegating. - -In these cases, implement the methods manually. - -## Example: Custom Logic in `count()` - -If you need custom behavior in `count()`, implement it manually: - -```php -final class PostDatastore implements DatastoreHasCounts -{ - use WithDatastoreCountDecorator; - - public function __construct( - private DatastoreHandlerHasCounts $handler, - private CacheService $cache - ) {} - - // Override count() with caching - public function count(array $args = []): int - { - $cacheKey = 'post_count_' . md5(serialize($args)); - - return $this->cache->remember($cacheKey, 300, function() use ($args) { - return $this->handler->count($args); - }); - } - - // get(), save(), delete() provided by trait -} -``` - -## Combining with Other Decorator Traits - -Most datastores implement multiple interfaces. You can combine traits: - -```php -interface PostDatastore extends - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete(), find(), where(), count() -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() - use WithDatastoreCountDecorator { - // Resolve conflict: both traits provide get(), save(), delete() - WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreCountDecorator; - WithDatastorePrimaryKeyDecorator::save insteadof WithDatastoreCountDecorator; - WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreCountDecorator; - } - - public function __construct( - private DatastoreHandlerHasPrimaryKey & - DatastoreHandlerHasCounts $handler - ) {} - - // Manually implement where() - public function where(): DatastoreWhereQuery - { - return $this->handler->where(); - } -} -``` - -## Adding Custom Count Methods - -You can add domain-specific count methods alongside trait-provided ones: - -```php -interface PostDatastore extends DatastoreHasCounts -{ - public function countByAuthor(int $authorId): int; - public function countPublished(): int; -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastoreCountDecorator; - - public function __construct( - private DatastoreHandlerHasCounts $handler - ) {} - - // Custom count method - public function countByAuthor(int $authorId): int - { - return $this->count(['author_id' => $authorId]); - } - - // Another custom count method - public function countPublished(): int - { - return $this->count(['status' => 'published']); - } - - // get(), save(), delete(), count() provided by trait -} -``` - -## Handler Type Requirements - -The `$handler` property must implement `DatastoreHandlerHasCounts`: - -```php -interface DatastoreHandlerHasCounts extends DatastoreHandler -{ - public function count(array $args = []): int; -} -``` - -This ensures the handler supports efficient counting operations. - -## Best Practices - -* **Use for pure delegation** — if you're adding logic, implement manually. -* **Name the handler `$handler`** — the trait expects this property name. -* **Match handler and interface types** — if you implement `DatastoreHasCounts`, use `DatastoreHandlerHasCounts`. -* **Combine traits carefully** — resolve conflicts when multiple traits provide the same methods. - -## What's Next - -* [DatastoreHasCounts Interface](/packages/datastore/interfaces/datastore-has-counts) — the interface this trait implements -* [WithDatastorePrimaryKeyDecorator](/packages/datastore/traits/with-datastore-primary-key-decorator) — adds `find()` method -* [Database Handlers](/packages/database/handlers/introduction) — the handler side of the contract diff --git a/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md deleted file mode 100644 index f2c6dda..0000000 --- a/public/docs/docs/packages/datastore/traits/with-datastore-decorator.md +++ /dev/null @@ -1,199 +0,0 @@ -# WithDatastoreDecorator Trait - -The `WithDatastoreDecorator` trait provides automatic implementations of the base [`Datastore`](/packages/datastore/interfaces/datastore) interface methods by delegating to a `$handler` property. It eliminates boilerplate code when your Core datastore implementation is a pure pass-through to a handler. - -## What It Provides - -This trait implements three methods: - -* `get(array $args = []): iterable` -* `save(Model $item): Model` -* `delete(Model $item): void` - -Each method simply forwards the call to `$this->handler` with the same parameters. - -## Requirements - -To use this trait, your class must: - -1. **Implement `Datastore`** — the trait provides the method bodies. -2. **Have a `$handler` property** — must be of type `DatastoreHandler`. -3. **Initialize the handler** — typically via constructor injection. - -## Basic Usage - -```php -handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - public function delete(Model $item): void - { - $this->handler->delete($item); - } -} -``` - -By using the trait, you avoid writing this repetitive delegation code. - -## When to Use This Trait - -Use `WithDatastoreDecorator` when: - -* Your Core datastore doesn't add logic—it just passes calls to the handler. -* You want to reduce boilerplate in simple implementations. -* You're building a standard database-backed datastore. - -## When NOT to Use This Trait - -Don't use `WithDatastoreDecorator` if you need to: - -* Add logging, caching, or validation before delegating. -* Transform data between the public API and handler. -* Implement custom behavior in `get()`, `save()`, or `delete()`. - -In these cases, implement the methods manually. - -## Example: Custom Logic in `save()` - -If you need custom behavior in one method, implement it manually and use the trait for the others: - -```php -final class PostDatastore implements Datastore -{ - use WithDatastoreDecorator { - save as private traitSave; // Rename trait's save method - } - - public function __construct( - private DatastoreHandler $handler, - private LoggerStrategy $logger - ) {} - - // Custom save with logging - public function save(Model $item): Model - { - $this->logger->info("Saving post: {$item->getId()}"); - return $this->traitSave($item); // Delegate to trait - } - - // get() and delete() provided by trait -} -``` - -Alternatively, just implement `save()` yourself and let the trait handle `get()` and `delete()`: - -```php -final class PostDatastore implements Datastore -{ - use WithDatastoreDecorator; - - public function __construct( - private DatastoreHandler $handler, - private LoggerStrategy $logger - ) {} - - // Custom save with logging - public function save(Model $item): Model - { - $this->logger->info("Saving post: {$item->getId()}"); - return $this->handler->save($item); - } - - // get() and delete() provided by trait -} -``` - -## Combining with Other Decorator Traits - -You can use multiple decorator traits together when your interface extends multiple capabilities: - -```php -interface PostDatastore extends - Datastore, - DatastoreHasPrimaryKey, - DatastoreHasCounts -{ - // get(), save(), delete(), find(), count() -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastoreDecorator; // get(), save(), delete() - use WithDatastorePrimaryKeyDecorator { - // Resolve conflict: both traits provide get(), save(), delete() - WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreDecorator; - WithDatastorePrimaryKeyDecorator::save insteadof WithDatastoreDecorator; - WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreDecorator; - } - use WithDatastoreCountDecorator; // count() - - public function __construct( - private DatastoreHandlerHasPrimaryKey & DatastoreHandlerHasCounts $handler - ) {} -} -``` - -**Note:** In practice, you'd typically use **only** `WithDatastorePrimaryKeyDecorator` since it extends `WithDatastoreDecorator` and includes all base methods. The example above shows how to resolve conflicts if needed. - -## Handler Type Requirements - -The `$handler` property must implement `DatastoreHandler`: - -```php -interface DatastoreHandler -{ - public function get(array $args = []): iterable; - public function save(Model $item): Model; - public function delete(Model $item): void; -} -``` - -Most handlers extend this interface with additional capabilities (e.g., `DatastoreHandlerHasPrimaryKey`), which is fine—the trait only calls the base methods. - -## Best Practices - -* **Use traits for pure delegation** — if you're adding logic, implement manually. -* **Name the handler `$handler`** — the trait expects this property name. -* **Inject via constructor** — don't create handlers inside the datastore. -* **Combine with extension traits** — use `WithDatastorePrimaryKeyDecorator` for extended interfaces. - -## What's Next - -* [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/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md deleted file mode 100644 index 7c4b7a9..0000000 --- a/public/docs/docs/packages/datastore/traits/with-datastore-primary-key-decorator.md +++ /dev/null @@ -1,204 +0,0 @@ -# WithDatastorePrimaryKeyDecorator Trait - -The `WithDatastorePrimaryKeyDecorator` trait provides automatic implementations of the [`DatastoreHasPrimaryKey`](/packages/datastore/interfaces/datastore-has-primary-key) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `find()` method for primary key lookups. - -## What It Provides - -This trait implements four methods: - -* `get(array $args = []): iterable` — from `Datastore` -* `save(Model $item): Model` — from `Datastore` -* `delete(Model $item): void` — from `Datastore` -* `find(int $id): Model` — from `DatastoreHasPrimaryKey` - -All methods delegate to `$this->handler`. - -## Requirements - -To use this trait, your class must: - -1. **Implement `DatastoreHasPrimaryKey`** — the trait provides the method bodies. -2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasPrimaryKey`. -3. **Initialize the handler** — typically via constructor injection. - -## Basic Usage - -```php -handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - public function delete(Model $item): void - { - $this->handler->delete($item); - } - - public function find(int $id): Model - { - return $this->handler->find($id); - } -} -``` - -## When to Use This Trait - -Use `WithDatastorePrimaryKeyDecorator` when: - -* Your datastore has a single integer primary key. -* Your Core implementation doesn't add logic—it just delegates to the handler. -* You want to minimize boilerplate in standard implementations. - -## When NOT to Use This Trait - -Don't use this trait if you need to: - -* Add caching, logging, or validation before delegating. -* Transform data between the public API and handler. -* Implement custom behavior in `find()` or other methods. - -In these cases, implement the methods manually. - -## Example: Custom Logic in `find()` - -If you need custom behavior in `find()`, implement it manually and let the trait handle the rest: - -```php -final class PostDatastore implements DatastoreHasPrimaryKey -{ - use WithDatastorePrimaryKeyDecorator; - - public function __construct( - private DatastoreHandlerHasPrimaryKey $handler, - private LoggerStrategy $logger - ) {} - - // Override find() with logging - public function find(int $id): Model - { - $this->logger->info("Finding post: {$id}"); - return $this->handler->find($id); - } - - // get(), save(), delete() provided by trait -} -``` - -## Combining with Other Decorator Traits - -Most datastores implement multiple interfaces. You can combine traits: - -```php -interface PostDatastore extends - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete(), find(), where(), count() -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() - use WithDatastoreWhereDecorator; // where() - use WithDatastoreCountDecorator; // count() - - public function __construct( - private DatastoreHandlerHasPrimaryKey & - DatastoreHandlerHasWhere & - DatastoreHandlerHasCounts $handler - ) {} -} -``` - -All six methods are now auto-implemented via traits. - -## Adding Custom Business Methods - -You can add custom methods alongside trait-provided ones: - -```php -interface PostDatastore extends DatastoreHasPrimaryKey -{ - public function findPublishedPosts(int $authorId): iterable; -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; - - public function __construct( - private DatastoreHandlerHasPrimaryKey $handler - ) {} - - // Custom business method - public function findPublishedPosts(int $authorId): iterable - { - return $this->handler->get([ - 'author_id' => $authorId, - 'status' => 'published', - ]); - } - - // get(), save(), delete(), find() provided by trait -} -``` - -## Handler Type Requirements - -The `$handler` property must implement `DatastoreHandlerHasPrimaryKey`: - -```php -interface DatastoreHandlerHasPrimaryKey extends DatastoreHandler -{ - public function find(int $id): Model; -} -``` - -This ensures the handler supports primary key lookups. - -## Best Practices - -* **Use for pure delegation** — if you're adding logic, implement manually. -* **Name the handler `$handler`** — the trait expects this property name. -* **Match handler and interface types** — if you implement `DatastoreHasPrimaryKey`, use `DatastoreHandlerHasPrimaryKey`. -* **Combine traits freely** — traits compose cleanly for multiple capabilities. - -## What's Next - -* [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/docs/packages/datastore/traits/with-datastore-where-decorator.md b/public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md deleted file mode 100644 index 0775857..0000000 --- a/public/docs/docs/packages/datastore/traits/with-datastore-where-decorator.md +++ /dev/null @@ -1,230 +0,0 @@ -# WithDatastoreWhereDecorator Trait - -The `WithDatastoreWhereDecorator` trait provides automatic implementations of the [`DatastoreHasWhere`](/packages/datastore/interfaces/datastore-has-where) interface by delegating to a `$handler` property. It includes both the base `Datastore` methods and the `where()` method for query-builder-style filtering. - -## What It Provides - -This trait implements four methods: - -* `get(array $args = []): iterable` — from `Datastore` -* `save(Model $item): Model` — from `Datastore` -* `delete(Model $item): void` — from `Datastore` -* `where(): DatastoreWhereQuery` — from `DatastoreHasWhere` - -All methods delegate to `$this->handler`. - -## Requirements - -To use this trait, your class must: - -1. **Implement `DatastoreHasWhere`** — the trait provides the method bodies. -2. **Have a `$handler` property** — must be of type `DatastoreHandlerHasWhere`. -3. **Initialize the handler** — typically via constructor injection. - -## Basic Usage - -```php -handler->get($args); - } - - public function save(Model $item): Model - { - return $this->handler->save($item); - } - - public function delete(Model $item): void - { - $this->handler->delete($item); - } - - public function where(): DatastoreWhereQuery - { - return $this->handler->where(); - } -} -``` - -## When to Use This Trait - -Use `WithDatastoreWhereDecorator` when: - -* Your datastore supports complex querying. -* Your Core implementation doesn't add logic—it just delegates to the handler. -* You want to minimize boilerplate in standard implementations. - -## When NOT to Use This Trait - -Don't use this trait if you need to: - -* Wrap the query builder with additional logic. -* Add caching or logging around query execution. -* Transform queries before delegating to the handler. - -In these cases, implement the methods manually. - -## Example: Custom Logic in `where()` - -If you need to wrap the query builder, implement `where()` manually: - -```php -final class PostDatastore implements DatastoreHasWhere -{ - use WithDatastoreWhereDecorator; - - public function __construct( - private DatastoreHandlerHasWhere $handler, - private LoggerStrategy $logger - ) {} - - // Override where() to log query construction - public function where(): DatastoreWhereQuery - { - $this->logger->info("Building query for PostDatastore"); - return $this->handler->where(); - } - - // get(), save(), delete() provided by trait -} -``` - -## Combining with Other Decorator Traits - -Most datastores implement multiple interfaces. You can combine traits: - -```php -interface PostDatastore extends - DatastoreHasPrimaryKey, - DatastoreHasWhere, - DatastoreHasCounts -{ - // get(), save(), delete(), find(), where(), count() -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; // get(), save(), delete(), find() - use WithDatastoreWhereDecorator { - // Resolve conflict: both traits provide get(), save(), delete() - WithDatastorePrimaryKeyDecorator::get insteadof WithDatastoreWhereDecorator; - WithDatastorePrimaryKeyDecorator::save insteadof WithDatastorePrimaryKeyDecorator; - WithDatastorePrimaryKeyDecorator::delete insteadof WithDatastoreWhereDecorator; - } - use WithDatastoreCountDecorator; // count() - - public function __construct( - private DatastoreHandlerHasPrimaryKey & - DatastoreHandlerHasWhere & - DatastoreHandlerHasCounts $handler - ) {} -} -``` - -In practice, you'd typically choose **one** trait that provides the base methods (`get`, `save`, `delete`) and add others that don't conflict. For example: - -```php -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastorePrimaryKeyDecorator; // Provides all base + find() - // Manually implement where() if needed, or use trait - - public function where(): DatastoreWhereQuery - { - return $this->handler->where(); - } - - // count() - implement if needed - public function count(array $args = []): int - { - return $this->handler->count($args); - } -} -``` - -## Adding Custom Query Methods - -You can add domain-specific query methods alongside trait-provided ones: - -```php -interface PostDatastore extends DatastoreHasWhere -{ - public function findRecentPublished(int $limit = 10): iterable; -} - -final class PostDatastoreImpl implements PostDatastore -{ - use WithDatastoreWhereDecorator; - - public function __construct( - private DatastoreHandlerHasWhere $handler - ) {} - - // Custom query method - public function findRecentPublished(int $limit = 10): iterable - { - return $this->where() - ->equals('status', 'published') - ->lessThanOrEqual('published_date', new DateTime()) - ->orderBy('published_date', 'DESC') - ->limit($limit) - ->getResults(); - } - - // get(), save(), delete(), where() provided by trait -} -``` - -## Handler Type Requirements - -The `$handler` property must implement `DatastoreHandlerHasWhere`: - -```php -interface DatastoreHandlerHasWhere extends DatastoreHandler -{ - public function where(): DatastoreWhereQuery; -} -``` - -This ensures the handler supports query-builder operations. - -## Best Practices - -* **Use for pure delegation** — if you're adding logic, implement manually. -* **Name the handler `$handler`** — the trait expects this property name. -* **Match handler and interface types** — if you implement `DatastoreHasWhere`, use `DatastoreHandlerHasWhere`. -* **Combine traits carefully** — resolve conflicts when multiple traits provide the same methods. - -## What's Next - -* [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/docs/packages/rest/controllers.md b/public/docs/docs/packages/rest/controllers.md deleted file mode 100644 index dfd6d34..0000000 --- a/public/docs/docs/packages/rest/controllers.md +++ /dev/null @@ -1,363 +0,0 @@ -# Controllers - -Controllers are the **heart of a REST endpoint** in PHPNomad. They are where normalized, validated input is turned into -a response. By the time a controller runs, upstream middleware and validations have already shaped and checked the -request, so the controller can focus entirely on business logic. - -## Purpose of Controllers - -A controller should be **deterministic**: given a request and any injected dependencies, it computes a result and -returns a response with an explicit status. It doesn’t worry about enforcing defaults, validating input, or logging -side effects. Those belong to other phases of the lifecycle (middleware, validations, interceptors). Keeping controllers -focused in this way ensures predictability and portability across different integrations. - -## Controller Contract - -Every controller implements the `Controller` interface, which requires three methods: - -* `getEndpoint()` — returns the path where this controller is mounted (e.g., `/widgets`). -* `getMethod()` — returns the HTTP method (e.g., `Method::Get`, `Method::Post`). -* `getResponse(Request $request)` — the core logic: receives a normalized request, produces a response. - -The `Response` contract provides helpers like `setStatus()`, `setJson()`, and `setError()` to clearly shape the output. - -## Constructor Injection - -Controllers rarely work alone; they almost always call into services. In PHPNomad, you can declare those needs directly -in the constructor. Dependencies like repositories, domain services, or loggers are provided by -the [initializer](/core-concepts/bootstrapping/creating-and-managing-initializers) and -injected automatically. - -This makes controllers **testable and explicit**: they declare what they need, and the DI container provides it. No -service location, no global state. - -## Example: Basic Controller - -Here’s a simple controller that lists widgets with pagination: - -```php -widgets->list( - limit: (int) $request->getParam('number'), - offset: (int) $request->getParam('offset'), - ); - - return $this->response - ->setStatus(200) - ->setJson([ - 'data' => $items, - 'number' => $request->getParam('number'), - 'offset' => $request->getParam('offset'), - ]); - } -} -```` - -In this example, the constructor pulls in two dependencies: the response object and a repository. The controller -declares that it responds to `GET /widgets`, and its `getResponse` method simply queries the repository and returns -JSON. The -response is explicit — a `200 OK` with both the data and the paging parameters echoed back — and is shaped through the -`Response` contract’s helpers. - -## Signaling Errors - -Not every request succeeds. Controllers can throw a `RestException` when they need to signal an error condition. Systems -that implement the REST lifecycle know how to catch these exceptions and turn them into proper HTTP responses: - -* The exception’s **code** becomes the HTTP status. -* The **message** becomes the error message returned to the client. -* The **context** array is included in the error payload, allowing structured details about what went wrong. - -This means you don’t need to manually build an error `Response` — throwing a `RestException` is enough. - -### Example: Throwing a RestException - -```php -getParam('id'); - $widget = $this->widgets->find($id); - - if (!$widget) { - // 404 Not Found with structured error payload - throw new RestException( - code: 404, - message: "Widget $id was not found", - context: ['id' => $id] - ); - } - - return $this->response - ->setStatus(200) - ->setJson($widget); - } -} -```` - -In this example, if the repository returns nothing, the controller throws a `RestException`. The runtime will catch it -and return a **404 Not Found** with a body like: - -```json -{ - "error": { - "message": "Widget 42 was not found", - "context": { - "id": 42 - } - } -} -``` - -This keeps your controller code clean: you describe the error, and the framework guarantees consistent error responses -with a predictable structure. - -## Full Controller Example - -In phpnomad/rest, a controller can be as simple as returning a payload — but in most real systems you’ll need -validations, middleware, and interceptors. - -The following example (CreateWidget) demonstrates a fully composed controller that uses all the moving parts: - -* Validations define input contracts, ensuring required fields are present and correctly typed. -* Middleware transforms and enforces rules before logic runs (e.g., coercing types, checking authorization, validating). -* Interceptors handle side effects after the response is ready, like broadcasting events. -* Controller logic itself focuses only on the business operation (creating a widget). - -This pattern is typical for production endpoints: request → middleware → validations → controller → response → -interceptors. - -```php -widgets->create( - name: (string) $request->getParam('name'), - price: (float) $request->getParam('price'), - tags: (array) ($request->getParam('tags') ?? []) - ); - - // For creates, 201 is standard (with a body that includes the new resource ID). - return $this->response - ->setStatus(201) - ->setBody(['id' => $id, 'status' => 'created']); - } - - /** - * Validations define the *input contract* for this endpoint. - * Each field maps to a ValidationSet, which can: - * - be required or optional - * - enforce type, format, or custom rules - */ - public function getValidations(): array - { - return [ - // "name" is required and must be a string - 'name' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::String)), - - // "price" is required and must be a float - 'price' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Float)), - - // "tags" is optional, but if present must be an array - 'tags' => (new ValidationSet()) - ->addValidation(fn() => new IsType(BasicTypes::Array)), - ]; - } - - /** - * Middleware runs *before* the controller logic. - * Common uses: - * - Type coercion (force "price" into a float) - * - Authorization (policies based on user/session) - * - Running the validations defined above - */ - public function getMiddleware(Request $request): array - { - return [ - // Ensure "price" param is treated as a float before validation - new SetTypeMiddleware('price', BasicTypes::Float), - - // AuthorizationMiddleware checks if the current session - // is allowed to perform the "create widget" action. - new AuthorizationMiddleware( - evaluator: $this->auth, - context: SessionContexts::Web, - action: new Action(ActionTypes::Create, 'widget'), - policies: [ - // Example policies: require a valid web session - new SessionTypePolicy(SessionContexts::Web), - // and ensure the user can perform this action - new UserCanDoActionPolicy(), - ], - ), - - // ValidationMiddleware runs the ValidationSets defined in getValidations(). - new ValidationMiddleware($this), - ]; - } - - /** - * Interceptors run *after* the response has been created. - * They never modify the response, only handle side-effects. - * Here we broadcast a "WidgetCreatedEvent" for other systems to consume. - */ - public function getInterceptors(Request $request, Response $response): array - { - return [ - new EventInterceptor( - eventGetter: function () use ($request, $response) { - return new WidgetCreatedEvent( - name: (string) $request->getParam('name'), - price: (float) $request->getParam('price'), - id: (int) ($response->getBody()['id'] ?? 0), - ); - }, - eventStrategy: $this->events - ), - ]; - } -} -``` - -## Best Practices - -When in doubt, lean towards simplicity. These practices help controllers stay predictable: - -* Keep controllers lean: orchestrate the request/response, don’t embed business rules. -* Inject services via the constructor so controllers are easy to test and extend. -* Always set a status: don’t rely on defaults, be explicit about success and failure codes. -* Don’t validate or authorize here. Trust [middleware]/packages/rest/middleware/introduction) - and [validations](/packages/rest/validations/introduction) to handle that - before the controller runs. - -## When to Add Complexity - -In real applications, controllers often participate in a richer lifecycle. Middleware handles cross-cutting concerns -like authorization or pagination defaults. Validations define the input contract. Interceptors perform post-response -work like logging or publishing events. - -These are declared by implementing additional interfaces on the controller, but their logic lives outside it. This -separation keeps controllers focused on shaping the response while making each piece reusable across endpoints. For a -broader picture of how these phases work together, see -the [request lifecycle](/packages/rest/introduction#the-request-lifecycle). \ No newline at end of file diff --git a/public/docs/docs/packages/rest/integration-guide.md b/public/docs/docs/packages/rest/integration-guide.md deleted file mode 100644 index 7b0cd08..0000000 --- a/public/docs/docs/packages/rest/integration-guide.md +++ /dev/null @@ -1,175 +0,0 @@ -# REST Platform Integration Guide - -This guide is for engineers wiring PHPNomad controllers into a specific PHP platform with an HTTP runtime. If you’ve -never extended PHPNomad before, start here. - -By the end, you’ll have a thin adapter that lets controllers run unchanged on your platform. It will register -controllers with your router, translate your platform’s request into PHPNomad’s `Request`, convert controller `Response` -objects back into your platform’s response, and keep error handling consistent. - -You are responsible for a small contract: - -* RestStrategy implementation - to map a controller’s `(method, endpoint)` into your router and drive the request - lifecycle. -* Request implementation - to expose method, path, headers, params, body, and a safe attribute bag without leaking - platform - types. -* Response implementation - to set status, headers, and body while leaving serialization to the strategy. - *(Authentication can be added via middleware, but it’s optional.)* - -This is in great shape—clear, concrete, and the two integration styles (platform-led vs strategy-led) come through -nicely. What’s still missing or underspecified are a few “contract” rules that first-time extenders won’t intuit: - -* the lifecycle contract (stated explicitly, not just implied), -* the portable endpoint schema and how params get injected, -* deterministic param resolution (route → query → body), -* the error-mapping guarantee and envelope shape, -* who serializes responses (strategy vs adapter) and body-parsing rules, -* what “Auth via middleware” means when the platform already has permission hooks. - -Below are short, drop-in blocks you can add to the intro section (kept skimmable, with bullets only where they carry -weight). - -## Integration contract - -To integrate with PHPNomad, your adapter must follow these rules. - -### Lifecycle (required order) - -The request must flow in this order. Don’t skip or reorder steps. - -``` -Route → Middleware → Controller → Response → Interceptors -``` - -### Endpoint schema (portable) - -Controllers declare endpoints like `/widgets/{id}` using only literals and named params. Your adapter translates to the -platform’s DSL **and** injects captured values back into `Request` under the same keys. - -* Example: `/widgets/{id}` → WordPress `(?P[\d]+)` or FastRoute `{id:\w+}` -* Inside controllers, `$request->getParam('id')` works the same on every platform. - -## Examples - -See these existing adapters for reference implementations. - -* [FastRoute Integration](https://github.com/phpnomad/fastroute-integration) -* [WordPress Integration](https://github.com/phpnomad/wordpress-integration) - -## Approach - -The key to setting up PHPNomad's REST implementation with a platform is that you have to implement the Request, -Response, and RestStrategy, and usually Auth as well. - -These are all baked into existing platforms, so usually implementing these feels more like adapting the existing -platform into PHPNomad's syntax. - -For example in -the [WordPress integration's RestStrategy](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Strategies/RestStrategy.php), -it registers the route like this: - -```php -public function registerRoute(callable $controllerGetter) -{ - // Register the route with WordPress. - add_action('rest_api_init', function () use ($controllerGetter) { - /** @var Controller $controller */ - $controller = $controllerGetter(); - // Use WordPress's register_rest_route function to register the route. - register_rest_route( - $this->restNamespaceProvider->getRestNamespace(), - $this->convertEndpointFormat($controller->getEndpoint()), - [ - 'methods' => $controller->getMethod(), - 'callback' => fn(WP_REST_Request $request) => $this->handleRequest($controller, new WordPressRequest($request, $this->currentUserResolver->getCurrentUser())), - 'permission_callback' => '__return_true' - ] - ); - }); -} -``` - -The `handleRequest` method that actually does the work of adapting the PHPNomad request into a WordPress request. -It does this by passing -a [WordPressRequest](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Rest/Request.php) object, which -implements PHPNomad's `Request` interface. `handleRequest` then runs the controller and converts the response back -into a WordPress response. - -[You can see it in action here.](https://github.com/phpnomad/wordpress-integration/blob/main/lib/Strategies/RestStrategy.php#L77-L102), -but the guts of it are in this method, which runs middleware, gets the response, and runs interceptors: - -```php -private function wrapCallback(Controller $controller, Request $request): Response -{ - // Maybe process middleware. - if ($controller instanceof HasMiddleware) { - Arr::each($controller->getMiddleware($request), fn(Middleware $middleware) => $middleware->process($request)); - } - - - /** @var \PHPNomad\Integrations\WordPress\Rest\Response $response */ - $response = $controller->getResponse($request); - - - // Maybe process interceptors. - if ($controller instanceof HasInterceptors) { - Arr::each($controller->getInterceptors($request, $response), fn(Interceptor $interceptor) => $interceptor->process($request, $response)); - } - - - return $response; -} -``` - -In some cases, the integration is more-loosely built. For example, the fastroute integration specifically targets -making fastroute natively use PHPNomad to handle building a minimalistic REST API that uses PHPNomad. - -This requires a bit more code to accomplish, since Fastroute doesn't handle a lot of the necessary aspects for us. In -the case of Fastroute, we created our own registry to store the routes so that we can reference that and handle routing -ourselves. As shown above, other platforms like WordPress or Laravel would handle most of this for us. - -Fastroute's `registerRoute` method looks like this: - -```php -public function registerRoute(callable $controllerGetter) -{ - $this->registry->set(function () use ($controllerGetter) { - /** @var Controller $controller */ - $controller = $controllerGetter(); - - - return [ - $controller->getMethod(), - $controller->getEndpoint(), - function ($request) use ($controller) { - if ($controller instanceof HasMiddleware) { - $this->runMiddleware($controller, $request); - } - $response = $controller->getResponse($request); - $this->setRestHeaders($response); - - - if ($controller instanceof HasInterceptors) { - $this->runInterceptors($controller, $request, $response); - } - - - return $response; - } - ]; - - - }); -} -``` - -The main difference here is that we needed to provide our own routing registry, and we also needed to handle -setting the response headers, since Fastroute doesn't do that for us, however the rest of the logic is very similar to -the WordPress example above. - -* Run middleware if the controller has any. -* Get the response from the controller. -* Set the response headers. -* Run interceptors if the controller has any. -* Return the response. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md b/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md deleted file mode 100644 index fe93244..0000000 --- a/public/docs/docs/packages/rest/interceptors/included-interceptors/event-interceptor.md +++ /dev/null @@ -1,88 +0,0 @@ -# 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. - -## Purpose - -Controllers should remain deterministic: given valid input, return a response. But many actions in a system also trigger -**side effects**—for example, creating a resource may need to emit a `UserRegistered` or `OrderPlaced` event. - -Placing this responsibility inside controllers creates tight coupling and scattered event code. The `EventInterceptor` -moves this logic into the lifecycle boundary, where it can consistently fire after the response is prepared. - -## How It Works - -The interceptor accepts two things at construction: - -* **`$eventGetter`** — a callable that produces an `Event` instance, usually using data from the request or response. -* **`$eventStrategy`** — the broadcasting mechanism that knows how to publish events to your system (e.g., sync - dispatch, queue, message bus). - -When the interceptor runs, it calls the getter, builds the event, and uses the strategy to broadcast it. - -## Usage Example - -Here’s how you could use `EventInterceptor` to broadcast an event whenever a new widget is created: - -```php -widgets->create( - name: (string) $request->getParam('name') - ); - - return $this->response - ->setStatus(201) - ->setJson(['id' => $id, 'status' => 'created']); - } - - public function getInterceptors(Request $req, Response $res): array - { - return [ - new EventInterceptor( - eventGetter: fn () => new WidgetCreatedEvent( - id: $res->getBody()['id'], - name: $req->getParam('name') - ), - eventStrategy: $this->events - ), - ]; - } -} -``` - -In this example: - -* The controller only creates the widget and returns a response. -* The interceptor handles broadcasting a `WidgetCreatedEvent` after the response is finalized. -* Event emission is kept portable, reusable, and out of controller code. - -The `EventInterceptor` provides a clean way to emit domain events at the edge of the request lifecycle. By using a -simple getter and a broadcasting strategy, you keep controllers lean, avoid duplicated event logic, and ensure side -effects happen reliably after the main response is prepared. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/interceptors/introduction.md b/public/docs/docs/packages/rest/interceptors/introduction.md deleted file mode 100644 index 91ba946..0000000 --- a/public/docs/docs/packages/rest/interceptors/introduction.md +++ /dev/null @@ -1,125 +0,0 @@ -# Interceptors - -Interceptors allow you to extend and customize the final stage of the request lifecycle in PHPNomad. They run after the -controller has returned its response but before that response is sent back to the client. This makes them ideal for -adapting outputs and handling cross-cutting concerns without bloating controller logic. - -Unlike middleware, which runs before a controller executes, interceptors operate with full knowledge of the request and -response. This timing makes them uniquely suited for last-minute adjustments and side effects. - -Interceptors give you the power to modify responses or trigger side effects at the very edge of the lifecycle. They see -the full picture—the request, the controller result, and the prepared response—allowing them to: - -* Adapt responses into consistent formats -* Enforce output policies centrally -* Trigger events, logging, or metrics safely - -By keeping controllers lean and letting interceptors handle boundary concerns, your codebase remains portable, -maintainable, and easier to reason about. - -## Adapting Responses - -One common use case for interceptors is adapting a response into a format that is safe, portable, or consistent across -your API. Instead of forcing every controller to handle response shaping, you can offload that work to an interceptor -that runs at the boundary. - -```php -use PHPNomad\Rest\Interfaces\Interceptor; -use PHPNomad\Http\Interfaces\Request; -use PHPNomad\Http\Interfaces\Response; - -final class ModelAdapterInterceptor implements Interceptor -{ - public function process(Request $request, Response $response): void - { - $body = $response->getBody(); - - if (is_object($body) && method_exists($body, 'toArray')) { - $response->setJson($body->toArray()); - } - } -} -``` - -This interceptor ensures that domain models are consistently converted into JSON arrays without controllers needing to -repeat that logic. - -**Conclusion:** By using interceptors for response adaptation, you centralize formatting rules and keep controllers -focused only on core business logic. - -## Handling Side Effects - -Interceptors are also the right place for side effects that depend on the final result of a request. Since they execute -after the response has been prepared, they can safely trigger actions that should not interfere with the controller’s -outcome. - -Typical examples include: - -* Publishing domain events once a resource is created or updated -* Logging details of completed requests -* Recording metrics for observability - -```php -logger->info('API Request Completed', [ - 'path' => $request->getPath(), - 'method' => $request->getMethod(), - 'status' => $response->getStatus(), - ]); - } -} -``` - -This interceptor adds to the logger after a request succeeds, without polluting controller code. - -By isolating side effects inside interceptors, you keep your controllers deterministic and ensure cross-cutting actions -happen reliably at the right time. - -### Ordering and Execution - -When you define multiple interceptors for a controller, their order matters. They execute sequentially, and later -interceptors see the modifications made by earlier ones. - -This allows you to stack concerns: for example, one interceptor could adapt models into arrays, and another could wrap -all responses into a common envelope. - -* Interceptors run in the order returned from `getInterceptors()`. -* Each interceptor receives the final `Request` and `Response`. -* They may mutate the response body, headers, or status. - -By controlling interceptor ordering, you can compose response pipelines cleanly without entangling unrelated concerns. - -### Best Practices - -Interceptors are powerful, but like middleware, they work best when kept focused and predictable. - -Because they can mutate responses or trigger external systems, it’s important to apply consistent patterns to avoid -confusion and unintended side effects. - -* Keep interceptors **single-purpose** (e.g., one for adaptation, one for events). -* **Don’t hide failures**: catch exceptions from side effects, but log them for observability. -* 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. - ---- - -## 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/docs/packages/rest/introduction.md b/public/docs/docs/packages/rest/introduction.md deleted file mode 100644 index fc23c30..0000000 --- a/public/docs/docs/packages/rest/introduction.md +++ /dev/null @@ -1,79 +0,0 @@ -# Rest - -`phpnomad/rest` is an **MVC-driven methodology for defining REST APIs**. -It’s designed to let you describe **controllers, routes, validations, and policies** in a way that’s **agnostic to the -framework or runtime** you plug into. - -At its core: - -* **Controllers** express your business logic in a consistent contract. -* **RestStrategies** adapt those controllers to different environments (FastRoute, WordPress, custom). -* **Middleware** and **Validations** give you predictable, portable contracts around input handling and authorization. -* **Interceptors** capture side effects like events or logs after responses are sent. - -By separating API **definition** (what the endpoint is, what it requires, what it returns) from **integration** (how it -runs inside a host), you get REST endpoints that can move between stacks without rewrites. - -## Key ideas at a glance - -* **Controller** — your endpoint’s logic, returning the payload + status intent. -* **RestStrategy** — wires routes to controllers in your host framework. -* **Middleware** — pre-controller cross-cuts (auth, pagination defaults, projections). -* **Validations** — input contracts with a consistent error shape. -* **Interceptors** — post-response side effects (events, logs, metrics). - ---- - -## The Request Lifecycle - -When a request enters a system wired with `phpnomad/rest`, it moves through a consistent sequence of steps: - -``` -Route → Middleware → Controller → Response → Interceptors -``` - -### Route - -The **RestStrategy** matches an incoming request to a registered controller based on the HTTP method and path. - -* Integration-specific (FastRoute, WordPress, custom). -* Responsible only for dispatching into the portable REST flow. - -### Middleware - -[Middleware](/packages/rest/middleware/introduction) runs **before** your controller logic. - -* Can short-circuit (e.g., fail auth, block a bad request). -* Can enrich context (e.g., inject a current user, parse query filters). -* Runs in defined order, producing a clean setup for the controller. - -### Validations - -[Validation](/packages/rest/validations/introduction) sets define **input contracts** for the request. These are set using a -middleware, so you can control when -they run. - -* Ensure required fields are present. -* Enforce types and formats (e.g., integer IDs, valid emails). -* Failures are collected and returned in a predictable error payload. - -### Controller Handle Method - -The [controller](/packages/rest/controllers) is the **core of the endpoint**. - -* Business logic goes here: read, mutate, return. -* Sees a request context already shaped by middleware and validated inputs. -* Returns a response object, setting the status and payload. - -### Interceptors - -[Interceptors](/packages/rest/interceptors/introduction) run **after the controller has produced a response** and **before the -response leaves the pipeline**. - -They're usually used for two main purposes: - -1. **Adapt the response** — reshape or enrich the response object without touching controller code. -2. **Perform side effects** — emit events, write audit logs, push metrics, etc. - -Because interceptors sit at the boundary, they’re an ideal place to keep controllers lean while still achieving -consistent output formats and cross-cutting behavior. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md deleted file mode 100644 index 3011f94..0000000 --- a/public/docs/docs/packages/rest/middleware/included-middleware/callback-middleware.md +++ /dev/null @@ -1,83 +0,0 @@ -# CallbackMiddleware - -The `CallbackMiddleware` is the most minimal middleware provided in PHPNomad. It exists as a utility for injecting -arbitrary logic into the request lifecycle without creating a dedicated middleware class. - -It’s particularly useful for **simple one-off behaviors**, rapid prototyping, or cases where full-blown middleware is -unnecessary. - -## Purpose - -Middleware normally provides **reusable, named behaviors** that can be applied across multiple controllers. For example, -pagination defaults or record existence checks. - -However, sometimes you need lightweight logic that doesn’t justify creating and registering a new class. -The `CallbackMiddleware` makes this possible by letting you pass in any callable and have it run against the request. - -This trades **formality for flexibility**. Use it sparingly, but it can be a good tool for fast iteration. - -## Contract - -`CallbackMiddleware` implements the standard `Middleware` interface: - -```php -interface Middleware -{ - public function process(Request $request): void; -} -``` - -Instead of having its own logic, it simply wraps a user-supplied `callable` and invokes it during `process()`: - -```php -final class CallbackMiddleware implements Middleware -{ - public function __construct(callable $callback) { /* ... */ } - - public function process(Request $request): void - { - ($this->callback)($request); - } -} -``` - -The callback receives the **normalized request** object, allowing you to inspect or modify it before the controller -executes. - -## Example: Adding a Default Parameter - -Here’s how you could use `CallbackMiddleware` to inject a default `locale` if the request does not already have one: - -```php -use PHPNomad\Rest\Middleware\CallbackMiddleware; -use PHPNomad\Http\Interfaces\Request; - -$middleware = new CallbackMiddleware(function (Request $request) { - if (!$request->hasParam('locale')) { - $request->setParam('locale', 'en_US'); - } -}); -``` - -When included in a controller’s middleware chain, this ensures every request has a `locale` parameter available. - -## Example: Simple Audit Logging - -You could also log requests inline without creating a full logger middleware: - -```php -use PHPNomad\Rest\Middleware\CallbackMiddleware; -use PHPNomad\Http\Interfaces\Request; - -$middleware = new CallbackMiddleware(function (Request $request) { - error_log("Incoming request to: " . $request->getParam('endpoint')); -}); -``` - -This is useful for debugging or quick metrics collection. - -## Best Practices - -* **Prefer explicit middleware classes** for reusable or complex behaviors. -* **Use CallbackMiddleware only for simple, localized logic** where creating a full middleware class would be overkill. -* **Keep callbacks short and focused** — they should not contain business logic or validation rules. diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md deleted file mode 100644 index 5aa6b67..0000000 --- a/public/docs/docs/packages/rest/middleware/included-middleware/parse-jwt-middleware.md +++ /dev/null @@ -1,109 +0,0 @@ -# ParseJwtMiddleware - -The `ParseJwtMiddleware` is a built-in middleware in PHPNomad designed to handle **JSON Web Tokens (JWTs)**. -Its job is to read a JWT from the request, validate and decode it, and make the decoded token available -to downstream parts of the lifecycle (controllers, other middleware, etc.). - -## Purpose - -Authentication and authorization flows often require a token that represents the identity and claims of the current user. -The `ParseJwtMiddleware` ensures: - -- The token is present in the request (under a configurable key). -- It is decoded and validated using the configured `JwtService`. -- If valid, the decoded token is re-attached to the request for later use. -- If invalid, a `RestException` is thrown so the request ends early with a clear error response. - -This keeps your controllers and other components free from manual JWT parsing and error handling. - -## Usage - -In practice, you don’t call middleware directly — you declare it on a controller. -Here’s how to attach `ParseJwtMiddleware` to an endpoint that requires a valid token: - -```php -getParam('jwt'); - - return $this->response - ->setStatus(200) - ->setJson([ - 'userId' => $token['sub'], - 'roles' => $token['roles'] ?? [], - ]); - } - - public function getMiddleware(Request $request): array - { - return [ - $this->jwtMiddleware, - ]; - } -} -``` - -### Example request - -``` -GET /profile?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -``` - -### Example response - -```json -{ - "userId": 123, - "roles": ["editor", "admin"] -} -``` - -If the token is invalid, the response would instead be: - -```json -{ - "error": { - "message": "Invalid Token", - "context": {} - } -} -``` - -with status **400 Bad Request**. - ---- - -## Best Practices - -* **Chain this early**: Place `ParseJwtMiddleware` before any logic that relies on user identity. -* **Keep controllers lean**: Once decoded, controllers should just consume `$request->getParam('jwt')`. -* **Consistent key**: If your API passes the token under a different request key, configure `ParseJwtMiddleware` with that key. -* **Fail fast**: By throwing a `RestException` early, you prevent controllers from ever running with bad state. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md deleted file mode 100644 index a756b51..0000000 --- a/public/docs/docs/packages/rest/middleware/included-middleware/set-type-middleware.md +++ /dev/null @@ -1,73 +0,0 @@ -# SetTypeMiddleware - -The `SetTypeMiddleware` is a built-in middleware in PHPNomad for **type coercion**. -It ensures that request parameters have the expected PHP type before the controller sees them, so business logic can -trust values are already in the right form. - -HTTP parameters arrive as strings by default, regardless of whether they represent numbers, booleans, or arrays. -`SetTypeMiddleware` lets you declare that a specific parameter should always be treated as a certain type. For example, -if your endpoint expects a `float price` or an `int userId`, you can coerce it automatically rather than repeating -casts inside your controllers. - -## Usage Example - -Suppose you have an endpoint that accepts a `price` parameter, which should always be a float. -By attaching `SetTypeMiddleware`, you guarantee that `$request->getParam('price')` is already a float when the -controller runs. - -## Usage Example - -Suppose you have an endpoint that accepts a `userId` parameter, which should always be treated as an integer. -By attaching `SetTypeMiddleware`, you guarantee that `$request->getParam('userId')` is already an integer when the -controller runs. - -```php -users->findById($request->getParam('userId')); - - if (!$user) { - return $this->response - ->setStatus(404) - ->setJson(['error' => 'User not found']); - } - - return $this->response - ->setStatus(200) - ->setJson($user); - } - - public function getMiddleware(Request $request): array - { - return [ - new SetTypeMiddleware('userId', BasicTypes::Integer), - ]; - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md b/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md deleted file mode 100644 index aaed21e..0000000 --- a/public/docs/docs/packages/rest/middleware/included-middleware/validation-middleware.md +++ /dev/null @@ -1,136 +0,0 @@ -# ValidationMiddleware - -The `ValidationMiddleware` is the bridge between declared **input contracts** and incoming requests. -It runs before the controller to ensure that required parameters are present, correctly typed, and pass any -custom validation rules you’ve defined. - -If any validations fail, it throws a `ValidationException`, which the framework converts into a consistent HTTP error -response. - -## Purpose - -Controllers should never need to perform defensive checks like “is this field missing?” or “is this string actually an -email?”. That belongs in the validations phase. - -`ValidationMiddleware` ensures: - -- Every declared `ValidationSet` is checked against the request. -- Failures are collected into a structured error payload. -- Controllers only run if input passes validation. - -This keeps your controller code clean, predictable, and focused on business logic. - -## Usage Example - -Here’s a controller that requires `name` to be a non-empty string and `age` to be an integer ≥ 18. -It attaches `ValidationMiddleware` with its own validation rules. - -```php -getParam('name'); - $age = (int) $request->getParam('age'); - - return $this->response - ->setStatus(201) - ->setJson(['message' => "User $name registered."]); - } - - public function getMiddleware(Request $request): array - { - return [ - // Attach validation middleware to enforce rules. This allows you to choose when to validate. - new ValidationMiddleware($this), - ]; - } - - public function getValidations(): array - { - return [ - 'name' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::String)), - - 'age' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Integer)) - ->addValidation(fn() => new IsGreaterThan(17)), - ]; - } -} -``` - -### Example request - -``` -POST /users/register -Content-Type: application/json - -{ - "name": "Alice", - "age": 22 -} -``` - -### Example success response - -```json -{ - "message": "User Alice registered." -} -``` - -### Example failure response - -If `age` was `15`: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "age": [ - "Must be at least 18." - ] - } - } -} -``` - ---- - -## Best Practices - -* **Always attach ValidationMiddleware** to endpoints that require structured inputs. -* **Combine with type coercion** (e.g., `SetTypeMiddleware`) so values are in the right type before being validated. -* **Keep validations declarative**: don’t bury conditional logic in controllers—express it in `ValidationSet`s. -* **Make error messages user-friendly**: clients should be able to act on them without guesswork. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/middleware/introduction.md b/public/docs/docs/packages/rest/middleware/introduction.md deleted file mode 100644 index 3b04cba..0000000 --- a/public/docs/docs/packages/rest/middleware/introduction.md +++ /dev/null @@ -1,223 +0,0 @@ -# Middleware - -Middleware is the **pre-controller** layer of `phpnomad/rest`. It runs before your controller’s business logic, shaping -the request into something predictable and safe to operate on. Typical uses include setting sane defaults, coercing -types, enriching context, and—when necessary—stopping a request early. - -By the time a request reaches your controller, well-behaved middleware should have already done the boring work: -normalize parameters, apply limits, gate obvious failures, and authorize/authenticate the request. - -## What middleware is responsible for - -Middleware has two clear responsibilities: - -1) Prepare the request Normalize or enrich input so controllers can keep their focus. This might be pagination defaults, - converting a CSV - into an array, or attaching derived context for later phases. - -2) Short-circuit when appropriate If something is clearly wrong (unauthorized, missing resource, exceeded limit), - middleware can stop the request - *before* controller code runs. The recommended way to do this is to **throw a `RestException`** with an HTTP status - code, message, and structured context—the integration will catch it and format the HTTP response consistently. - -## What middleware is not - -1. It is not where you perform post-response side effects, such as triggering events. That's the job - of [interceptors](/packages/rest/middleware/interceptors/introduction) -2. It is not where you encode your domain’s validation rules. That's the job - of [validations](/packages/rest/middleware/validations/introduction). -3. It is not where you write business logic. That's the job of the controller. - -## The middleware contract - -A middleware class implements the `PHPNomad\Rest\Interfaces\Middleware` interface and defines a single method: - -```php -public function process(\PHPNomad\Http\Interfaces\Request $request): void; -```` - -* **Input:** a normalized `Request` object you can read and mutate - via `getParam`, `hasParam`, `setParam`, `removeParam`, and friends. -* **Output:** no return value. Either mutate the request in place and return, or throw a `RestException` to - short-circuit with an error. - -Like controllers, middleware can use **constructor injection**. Because instances are created via your initializer and -container, you can request collaborators (repositories, services, strategies) in the constructor without manual wiring. - -## Example: pagination defaults - -This middleware ensures that all list endpoints have sensible pagination. Controllers don’t need to know anything about -defaults or caps; they simply read `number` and `offset`. - -```php -hasParam('number')) { - $request->setParam('number', $this->defaultNumber); - } - - // Cap page size - if ((int) $request->getParam('number') > $this->maxNumber) { - $request->setParam('number', $this->maxNumber); - } - - // Default offset - if (!$request->hasParam('offset')) { - $request->setParam('offset', 0); - } - } -} -``` - -**Why this works well:** controllers can rely on `number` and `offset` existing and living within bounds, without -duplicating that code in every endpoint. - -## Example: resolving a user from the request - -This middleware looks up a record by ID and attaches the full record to the request. If the user doesn’t exist, it -throws a `RestException` with a `404` status code. - -This is a common pattern for endpoints that operate on a specific resource. By the time the controller runs, it can -assume the user exists and focus on the business logic. - -The power of this approach is that the logic to fetch the user and handle the "not found" case is isolated in one place, -and can be reused across multiple controllers, regardless of the datastore implementation. - -```php -getParam('id'); - - if(!$id) { - throw new RestException( - code: 400, - message: "Missing required 'id' parameter", - context: [] - ); - } - - try{ - // Fetch the record and attach it to the request. - $request->setParam('record', $this->datastore->find($id)); - } catch(RecordNotFoundException $e) { - // If the record doesn't exist, stop the request with a 404. - throw new RestException( - code: 404, - message: "User {$id} was not found", - context: ['id' => $id] - ); - } catch(DatastoreErrorException $e) { - // Log the error for internal tracking. - $this->logger->logException($e); - - // For other errors, throw a 500 with context. - // Note that the original exception is not exposed to the client. - throw new RestException( - code: 500, - message: "Error fetching user {$id}", - context: ['id' => $id] - ); - } - } -} -``` - -## Using middleware in your controllers - -To attach middleware to a controller, implement the `PHPNomad\Rest\Interfaces\HasMiddleware` interface and define -`getMiddleware()`. - -The example below shows a `GetUser` controller that uses the `GetRecordFromRequest` middleware to fetch a user by ID. -This uses the GetRecordFromRequest middleware defined above. - -Note that before it passes the request to the middleware, it also -uses [SetTypeMiddleware](/packages/rest/middleware/included-middleware/set-type-middleware) to ensure the `id` parameter is always an integer. - -```php -/** - * Example controller showing how to get a user - * - Middleware - * - * Each piece is isolated but works together in the request lifecycle. - */ -final class GetUser implements Controller, HasMiddleware -{ - public function __construct( - private Response $response, // Response object (DI-provided) - private UserDatastore $userDatastore, // User datastore for middleware - private UserAdapter $userAdapter, // User adapter for controller - private GetRecordFromRequest $getRecordMiddleware // Middleware instance - ) {} - - /** - * The HTTP endpoint path where this controller is mounted. - */ - public function getEndpoint(): string - { - return '/user/{id}'; - } - - /** - * The HTTP method used for this endpoint. - */ - public function getMethod(): string - { - return Method::Get; - } - - /** - * The core controller logic. This only runs if: - * - Middleware passed (auth, type coercion, etc.) - * - Validations succeeded - */ - public function getResponse(Request $request): Response - { - // The "record" param is set by GetRecordFromRequest middleware. - // Adapter converts it to a response-friendly format. - $user = $this->userAdapter->toResponse( - $request->getParam('record') // Set by middleware - ); - - // For gets, 200 is standard (with a body that includes the resource). - return $this->response - ->setStatus(200) - ->setBody($user); - } - - /** - * Middleware runs *before* the controller logic. - */ - public function getMiddleware(Request $request): array - { - return [ - new SetTypeMiddleware('id', BasicTypes::Integer), // Coerce 'id' to int - $this->getRecordMiddleware - ]; - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-any.md b/public/docs/docs/packages/rest/validations/included-validations/is-any.md deleted file mode 100644 index 6458b7c..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/is-any.md +++ /dev/null @@ -1,201 +0,0 @@ -# IsAny - -The `IsAny` validation ensures that a request parameter matches **one of a predefined list of acceptable values**. This -is particularly useful when restricting input to an enumeration of allowed options. - -## Usage - -```php -use PHPNomad\Rest\Validations\IsAny; - -$validation = new IsAny(['draft', 'published', 'archived']); -``` - -The above will only pass validation if the request parameter matches **exactly one** of `draft`, `published`, -or `archived`. - -## Constructor - -```php -public function __construct($validItems, $errorMessage = null) -``` - -* **`$validItems`** (`array`) – A list of allowed values. -* **`$errorMessage`** (`string|callable|null`) – An optional custom error message. If omitted, a default error message - is generated automatically. - -## Using IsAny in a Controller - -Below, the endpoint accepts a `status` parameter that must be one of -`draft`, `published`, or `archived`. The controller declares this rule -declaratively; `ValidationMiddleware` runs it before the handler. - -```php -getParam('id'); - $status = (string) $request->getParam('status'); - - $this->posts->setStatus($id, $status); - - return $this->response - ->setStatus(200) - ->setJson(['id' => $id, 'status' => $status]); - } - - // Declare validations for this endpoint. - public function getValidations(): array - { - return [ - 'status' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsAny(['draft', 'published', 'archived'])), - ]; - } - - // Attach the middleware that executes validations. - public function getMiddleware(Request $request): array - { - return [ new ValidationMiddleware($this) ]; - } -} -```` - -### Example (success) - -``` -POST /posts/42/status -Content-Type: application/json - -{ "status": "published" } -``` - -```json -{ - "id": 42, - "status": "published" -} -``` - -### Example (failure) - -When `status` is missing **or** not in the allowed set, the middleware throws a `ValidationException` and the framework -returns a structured error: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "status": [ - { - "field": "status", - "message": "status must be draft, published, or archived, but was given deleted", - "type": "REQUIRES_ANY", - "context": { - "validValues": [ - "draft", - "published", - "archived" - ] - } - } - ] - } - } -} -``` - -### Optional: custom message - -`IsAny` accepts an optional custom message (string or callable). For example: - -```php -'status' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsAny( - ['draft', 'published', 'archived'], - fn (string $key, Request $req) => sprintf( - 'Invalid %s: must be %s.', - $key, - implode(', ', ['draft', 'published', 'archived']) - ) - )), -``` - -## Behavior - -* Calls `$request->getParam($key)` and checks if the value exists in `$validItems`. -* If the value is missing or not in the list, validation fails. -* Returns a contextual error message, e.g.: - -``` -status must be draft, published, or archived, but was given deleted -``` - -If no value was provided: - -``` -status must be draft, published, or archived, but no value was given -``` - -## Error Type - -* **Type:** `REQUIRES_ANY` -* **Context:** - - ```json - { - "validValues": ["draft", "published", "archived"] - } - ``` - -## Example Failure Response - -For a missing or invalid `status`, the system may produce: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "status": [ - { - "field": "status", - "message": "status must be draft, published, or archived, but was given deleted", - "type": "REQUIRES_ANY", - "context": { - "validValues": [ - "draft", - "published", - "archived" - ] - } - } - ] - } - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-email.md b/public/docs/docs/packages/rest/validations/included-validations/is-email.md deleted file mode 100644 index 237d3ab..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/is-email.md +++ /dev/null @@ -1,96 +0,0 @@ -# IsEmail - -The `IsEmail` validation ensures that a request parameter is a **validly formatted email address**. It uses PHP’s -built-in functions under the hood, making it a lightweight but reliable validator for user input. - -## Usage in a Controller - -A typical scenario is validating that a `userEmail` field contains a valid email before processing the request. Here’s -an example controller that creates a user: - -```php -users->create( - email: (string) $request->getParam('userEmail') - ); - - return $this->response - ->setStatus(201) - ->setJson(['id' => $userId, 'status' => 'created']); - } - - public function getValidations(): array - { - return [ - 'userEmail' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsEmail()), - ]; - } -} -``` - -In this example: - -* The `userEmail` parameter is required. -* Validation ensures the value is a properly formatted email. -* If the validation fails, the request never reaches the `getResponse` method. - -## Error Type - -* **Type:** `INVALID_EMAIL` -* **Error Message:** - - ``` - userEmail must be a valid email address. - ``` -* **Context:** empty (this validator doesn’t provide extra context). - -## Example Failure Response - -If a request is made without a valid `userEmail`, `ValidationMiddleware` throws a `ValidationException`. The system -produces: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "userEmail": [ - { - "field": "userEmail", - "message": "userEmail must be a valid email address.", - "type": "INVALID_EMAIL" - } - ] - } - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md b/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md deleted file mode 100644 index ba5af2f..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/is-greater-than.md +++ /dev/null @@ -1,112 +0,0 @@ -# IsGreaterThan - -The `IsGreaterThan` validation ensures that a request parameter is **strictly greater than a specified numeric value**. -This is useful for enforcing minimum thresholds, such as “age must be greater than 18” or “quantity must be greater than -0.” - - -## Usage in a Controller - -Below, the endpoint accepts an `age` parameter that must be greater than **18**. The controller declares this rule -using `ValidationSet`, and `ValidationMiddleware` enforces it before the handler runs. - -```php - 18. - return $this->response - ->setStatus(201) - ->setJson([ - 'name' => $request->getParam('name'), - 'age' => $request->getParam('age'), - 'status' => 'registered' - ]); - } - - // Declare validations for this endpoint. - public function getValidations(): array - { - return [ - 'age' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsGreaterThan(17)), - ]; - } - - // Attach the middleware that executes validations. - public function getMiddleware(Request $request): array - { - return [ new ValidationMiddleware($this) ]; - } -} -``` - -### Example (success) - -``` -POST /adults/register -Content-Type: application/json - -{ "name": "Alice", "age": 25 } -``` - -```json -{ - "name": "Alice", - "age": 25, - "status": "registered" -} -``` - -### Example (failure) - -When `age` is `15`, the middleware throws a `ValidationException` and the framework returns: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "age": [ - { - "field": "age", - "message": "age must be greater than 18. Was given 15", - "type": "VALUE_TOO_SMALL", - "context": { - "minimumValue": 18 - } - } - ] - } - } -} -``` - -## Notes - -* If the field is **missing**, the default error message clarifies that a value was expected: - `"age must be greater than 18, but no value was given."` -* You can compose `IsGreaterThan` with other validations in the same `ValidationSet` (e.g., `IsType(Integer)`) for - stricter guarantees. -* The `context` always includes the required minimum value so clients can adjust their input programmatically. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md b/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md deleted file mode 100644 index 2b2b9ea..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/is-numeric.md +++ /dev/null @@ -1,121 +0,0 @@ -# IsNumeric - -The `IsNumeric` validation ensures that a request parameter is **numeric**. This includes integers, floats, and numeric -strings (anything PHP’s `is_numeric()` would accept). - -This validation is especially useful for parameters that arrive as strings via HTTP but need to represent numbers, such -as IDs, quantities, or counts. - -## Usage in a Controller - -Below, the endpoint accepts a `count` parameter that must be numeric. The controller declares this rule in -a `ValidationSet`, and `ValidationMiddleware` enforces it before the handler runs. - -```php -getParam('count'); - - $report = $this->reports->generate($count); - - return $this->response - ->setStatus(200) - ->setJson([ - 'count' => $count, - 'report' => $report - ]); - } - - // Declare validations for this endpoint. - public function getValidations(): array - { - return [ - 'count' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsNumeric()), - ]; - } - - // Attach the middleware that executes validations. - public function getMiddleware(Request $request): array - { - return [ new ValidationMiddleware($this) ]; - } -} -``` - -### Example (success) - -``` -POST /reports/generate -Content-Type: application/json - -{ "count": "10" } -``` - -```json -{ - "count": 10, - "report": { - /* report contents */ - } -} -``` - -### Example (failure) - -When `count` is not numeric: - -``` -POST /reports/generate -Content-Type: application/json - -{ "count": "ten" } -``` - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "count": [ - { - "field": "count", - "message": "The key count must be numeric.", - "type": "REQUIRES_NUMERIC", - "context": {} - } - ] - } - } -} -``` - -## Notes - -* `IsNumeric` does **not** coerce values — it only checks. Use it alongside `SetTypeMiddleware` if you want to ensure - the parameter is converted to an integer or float before controller code. -* For stricter cases (e.g., only integers allowed), combine `IsNumeric` with an `IsType(Integer)` validation. -* This validation pairs well with others, like `IsGreaterThan`, to enforce both type and range. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/included-validations/is-type.md b/public/docs/docs/packages/rest/validations/included-validations/is-type.md deleted file mode 100644 index 429de81..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/is-type.md +++ /dev/null @@ -1,271 +0,0 @@ -# IsType - -The `IsType` validation ensures that a request parameter matches a specific **basic type**. -It supports the built-in `BasicTypes` enumeration, covering common cases -like `Integer`, `Float`, `Boolean`, `String`, `Array`, `Object`, and `Null`. - -This validation is particularly useful for APIs that need to guarantee type safety before controller logic executes. - -## Class Definition - -```php -namespace PHPNomad\Rest\Validations; - -use PHPNomad\Rest\Interfaces\Validation; -use PHPNomad\Rest\Traits\WithProvidedErrorMessage; -use PHPNomad\Http\Interfaces\Request; -use PHPNomad\Rest\Enums\BasicTypes; - -class IsType implements Validation -``` - -## Usage in a Controller - -Here’s an example endpoint that requires an integer `userId` and a boolean `isActive` flag. Both fields are validated -before the controller runs: - -```php -getParam('userId'); - $isActive = (bool) $request->getParam('isActive'); - - $this->users->setActiveStatus($id, $isActive); - - return $this->response - ->setStatus(200) - ->setJson(['id' => $id, 'isActive' => $isActive]); - } - - /** Declare validations for each field */ - public function getValidations(): array - { - return [ - 'userId' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Integer)), - - // IMPORTANT: Do not coerce first; let IsType(Boolean) validate the literal input. - 'isActive' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Boolean)), - ]; - } - - /** Compose middleware: coerce userId to int, then run validations */ - public function getMiddleware(Request $request): array - { - return [ - new SetTypeMiddleware('userId', BasicTypes::Integer), - // Validation must come after any coercion middleware. - new ValidationMiddleware($this), - ]; - } -} -``` - -## Error Type and Context - -* **Type:** `INVALID_TYPE` -* **Error Message:** - - ``` - userId must be an Integer, was given abc - ``` -* **Context:** - - ```json - { - "requiredType": "Integer" - } - ``` - -## Example Failure Response - -If the client passes `"userId": "abc"` and `"isActive": "yes"`, the response might look like this: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "userId": [ - { - "field": "userId", - "message": "userId must be a Integer, was given abc", - "type": "INVALID_TYPE", - "context": { - "requiredType": "Integer" - } - } - ], - "isActive": [ - { - "field": "isActive", - "message": "isActive must be a Boolean, was given yes", - "type": "INVALID_TYPE", - "context": { - "requiredType": "Boolean" - } - } - ] - } - } -} -``` - -Absolutely — great idea to show **type coercion + validation together**. One nuance: coercing **booleans** -with `settype()` can accidentally turn any non-empty string into `true`. So in the example below we **coerce the integer -** (`userId`) with `SetTypeMiddleware`, but we **do not** coerce the boolean (`isActive`) before validation; we -let `IsType(Boolean)` validate strings like `"true"`, `"false"`, `"1"`, `"0"` strictly. - -Here’s an updated section you can drop into the **`IsType`** docs. - ---- - -## Using `IsType` with Middleware (coercion + validation) - -Below, the endpoint requires: - -* `userId`: an **integer** (coerced by middleware, then validated) -* `isActive`: a **boolean** (validated strictly, then cast in the controller) - -```php -getParam('userId'); - $isActive = (bool) $request->getParam('isActive'); - - $this->users->setActiveStatus($id, $isActive); - - return $this->response - ->setStatus(200) - ->setJson(['id' => $id, 'isActive' => $isActive]); - } - - /** Declare validations for each field */ - public function getValidations(): array - { - return [ - 'userId' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Integer)), - - // IMPORTANT: Do not coerce first; let IsType(Boolean) validate the literal input. - 'isActive' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsType(BasicTypes::Boolean)), - ]; - } - - /** Compose middleware: coerce userId to int, then run validations */ - public function getMiddleware(Request $request): array - { - return [ - new SetTypeMiddleware('userId', BasicTypes::Integer), - new ValidationMiddleware($this), - ]; - } -} -``` - -* `userId`: coercion is safe (`"42"` → `42`) and avoids repeating casts in your controller. -* `isActive`: coercing **before** validating would turn *any* non-empty string into `true`. By **validating first** - with `IsType(Boolean)`, values like `"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"` are accepted; nonsense - strings (e.g., `"perhaps"`) are rejected. After validation passes, casting to `(bool)` in the controller is safe and - intentional. - -### Example request (success) - -``` -POST /users/42/status -Content-Type: application/json - -{ "isActive": "true" } -``` - -```json -{ - "id": 42, - "isActive": true -} -``` - -### Example request (failure) - -``` -POST /users/42/status -Content-Type: application/json - -{ "isActive": "perhaps" } -``` - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "isActive": [ - { - "field": "isActive", - "message": "isActive must be a Boolean, was given perhaps", - "type": "INVALID_TYPE", - "context": { - "requiredType": "Boolean" - } - } - ] - } - } -} -``` diff --git a/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md b/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md deleted file mode 100644 index 76e089e..0000000 --- a/public/docs/docs/packages/rest/validations/included-validations/keys-are-any.md +++ /dev/null @@ -1,129 +0,0 @@ -# KeysAreAny - -The `KeysAreAny` validation ensures that **all keys of an input array parameter** are part of a predefined set of -allowed values. - -This is useful for cases where clients may send dynamic filters, attributes, or options, but you only want to allow a -specific whitelist of keys. - -## Usage in a Controller - -Below, the endpoint accepts a `filters` parameter, which must be an object (associative array) where the **keys** are -limited to `status` and `category`. - -If the request contains any other keys, validation fails. - -```php -getParam('filters'); - - $results = $this->posts->search($filters); - - return $this->response - ->setStatus(200) - ->setJson(['results' => $results]); - } - - public function getValidations(): array - { - return [ - 'filters' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new KeysAreAny(['status', 'category'])), - ]; - } - - public function getMiddleware(Request $request): array - { - return [ new ValidationMiddleware($this) ]; - } -} -``` - ---- - -### Example (success) - -``` -POST /posts/search -Content-Type: application/json - -{ - "filters": { - "status": "published", - "category": "tech" - } -} -``` - -```json -{ - "results": [ - { - "id": 1, - "title": "Scaling PHPNomad APIs", - "status": "published", - "category": "tech" - } - ] -} -``` - -### Example (failure) - -When the client sends a `filters` object with disallowed keys (e.g., `author`), the middleware throws -a `ValidationException`: - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "filters": [ - { - "field": "filters", - "message": "keys for filters must be status or category, but was given status,author", - "type": "REQUIRES_ANY", - "context": { - "validValues": [ - "status", - "category" - ] - } - } - ] - } - } -} -``` - -## Notes - -* **Checks keys only**: The values of the array are not validated here — use other validations for that. -* **Good for filters/attributes**: Useful when accepting flexible filter or metadata objects from clients. -* **Composability**: Combine `KeysAreAny` with other validations like `IsType(Array)` or custom rules to validate both - structure and content. -* **Custom error message**: You can override the default message by providing a string or callable to the constructor. \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/introduction.md b/public/docs/docs/packages/rest/validations/introduction.md deleted file mode 100644 index c84a634..0000000 --- a/public/docs/docs/packages/rest/validations/introduction.md +++ /dev/null @@ -1,182 +0,0 @@ -# Validations - -Validations in PHPNomad are designed to make **input expectations explicit and declarative**. Instead of scattering -checks throughout your controller code, you can attach a set of rules that describe what makes a request valid. This -makes endpoints easier to reason about and more portable across different contexts. - -## Declarative by Design - -A validation is a small class that implements the `Validation` interface. It defines three things: - -* **What to check** — via `isValid()`, given the current request. -* **What message to return** — via `getErrorMessage()`. -* **How to describe the failure** — via `getType()` and `getContext()` for machine-readable error handling. - -This means you can build reusable validation rules like “IDs must exist,” “this field must be unique,” or “value must -match a regex,” and apply them consistently wherever needed. - -## How Validations Run - -You don’t call validations directly. Instead, PHPNomad provides the `ValidationMiddleware`, a built-in middleware that -automatically runs the validations you’ve declared for a controller or another provider. - -This middleware iterates over each field’s [Validation Set](/packages/rest/validations/validation-set), checking if the field is required and -applying each validation rule in turn. - -If any rules fail, the middleware throws a `ValidationException`, and the system generates a structured error response. -This keeps controllers focused on business logic, while still ensuring strong input guarantees. - -## Why It Matters - -By keeping validations declarative: - -* **Controllers stay clean** — no inline `if` checks scattered around. -* **Errors are consistent** — all validation failures return the same error format. -* **Rules are reusable** — you can apply the same validation logic across multiple endpoints. - -## Example: Custom Validation with a Datastore - -Suppose you want to ensure that a record **does not already exist** before creating it. For example, you might prevent -creating a user with an `id` that’s already taken. You can implement this as a reusable validation that queries a -datastore. - -```php -getParam($key); - - try { - $this->datastore->find($id); - // If a record is found, this is a failure. - return false; - } catch (RecordNotFound $e) { - // Not found means it’s valid. - return true; - } - } - - public function getErrorMessage(): string - { - return 'A record with this identifier already exists.'; - } - - public function getType(): string - { - return 'record_exists'; - } - - public function getContext(string $key, Request $request): array - { - return [ - 'field' => $key, - 'value' => $request->getParam($key), - ]; - } -} -``` - -This validation: - -* Looks up the parameter value in a datastore. -* If the record exists, the validation fails. -* If the datastore throws `RecordNotFound`, the validation passes. -* Produces a structured error message and type when it fails. - ---- - -## Attaching the Validation - -Here’s how a controller might use it when creating a new record: - -```php -getParam('id'); - - $this->users->create(['id' => $id]); - - return $this->response - ->setStatus(201) - ->setJson(['id' => $id, 'status' => 'created']); - } - - public function getValidations(): array - { - return [ - 'id' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new DoesNotExist($this->users)), - ]; - } - - public function getMiddleware(Request $request): array - { - return [ new ValidationMiddleware($this) ]; - } -} -``` - ---- - -### Example request - -``` -POST /users -Content-Type: application/json - -{ - "id": "abc123" -} -``` - -### Example error response (if a record with `id=abc123` already exists) - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "id": [ - "A record with this identifier already exists." - ] - } - } -} -``` \ No newline at end of file diff --git a/public/docs/docs/packages/rest/validations/validation-set.md b/public/docs/docs/packages/rest/validations/validation-set.md deleted file mode 100644 index ddc2d80..0000000 --- a/public/docs/docs/packages/rest/validations/validation-set.md +++ /dev/null @@ -1,123 +0,0 @@ -# ValidationSet - -A `ValidationSet` is the way you declare validations for a single field in PHPNomad. -It acts as a container for one or more validation rules and knows whether the field is required. - -When `ValidationMiddleware` runs, it asks each `ValidationSet` to evaluate the incoming request and collect any -failures. This keeps validations **declarative and composable**: controllers don’t run checks inline, they simply return -an array of validation sets. - -## Purpose - -Instead of scattering `if` statements around your controller code, a `ValidationSet` lets you declare: - -- Whether the field is **required**. -- What rules must be applied to the field (via `addValidation`). -- How failures should be collected and described. - -This makes the input contract explicit, testable, and reusable. - -## API - -### `addValidation(Closure $validationGetter)` - -Adds a validation to the set. -The closure should return a `Validation` instance when called. - -```php -$set = (new ValidationSet()) - ->addValidation(fn() => new IsInteger()) - ->addValidation(fn() => new MinValue(1)); -```` - -### `setRequired(bool $isRequired = true)` - -Marks the field as required. -If the field is missing, the set automatically produces a “required” failure, without needing a separate validation. - -```php -$set = (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsString()); -``` - -## Example - -Here’s how you might declare validations for a `username` field in a controller: - -```php -use PHPNomad\Rest\Factories\ValidationSet; -use App\Validations\IsNotReservedUsername; - -public function getValidations(): array -{ - return [ - 'username' => (new ValidationSet()) - ->setRequired() - ->addValidation(fn() => new IsString()) - ->addValidation(fn() => new MinLength(3)) - ->addValidation(fn() => new IsNotReservedUsername()), - ]; -} -``` - -If the request payload is missing `username`, the set will automatically produce a “required” failure. -If it’s present but invalid, all failing rules will be collected and returned. - -## Example failure response - -When `ValidationMiddleware` runs and finds validation failures, it throws a `ValidationException`. The framework catches -this and generates a structured error response. Errors are grouped by field, with each failure including a message, -type, and context. - -This means that clients can see all problems at once and handle them intelligently. - -```json -{ - "error": { - "message": "Validations failed.", - "context": { - "username": [ - { - "field": "username", - "message": "username is required.", - "type": "REQUIRED" - } - ], - "email": [ - { - "field": "email", - "message": "Must be a valid email address.", - "type": "invalid_email", - "context": { - "value": "not-an-email" - } - } - ], - "password": [ - { - "field": "password", - "message": "Must be at least 12 characters.", - "type": "min_length", - "context": { - "min": 12, - "actual": 7 - } - }, - { - "field": "password", - "message": "Must include at least one number.", - "type": "pattern_missing_digit" - } - ] - } - } -} -``` - -## Best Practices - -* **Always use `setRequired()` for required fields** — don’t reinvent the “is required” check in a custom validation. -* **Compose multiple rules** in a single set; don’t build monolithic validators. -* **Favor closures over instances** when adding validations — they’re cached automatically and won’t bloat memory. -* **Keep error messages clear** — client developers should be able to act on them without guesswork. \ No newline at end of file diff --git a/public/docs/docs/packages/utils/array-helper.md b/public/docs/docs/packages/utils/array-helper.md deleted file mode 100644 index 31902c9..0000000 --- a/public/docs/docs/packages/utils/array-helper.md +++ /dev/null @@ -1,335 +0,0 @@ -# Array Helper - -The `ArrayHelper` class contains several helper methods that make working with native PHP arrays a little easier. These -methods are all statically accessible, and can be chained together using -the [Array Processor](/packages/utils/processors/array-processor). - -## Process - -Instantiates an [Array Processor](/packages/utils/processors/array-processor), which allows an array to have any method -in `ArrayHelper` used in a chain. - -```php -// Outputs "1, 3, bar, baz, foo" -echo ArrayHelper::process(['foo', 'bar', 'baz', null, 1, 3]) - ->whereNotNull() - ->sort(fn ($a, $b) => $a <=> $b) - ->setSeparator(', '); -``` - -## Where Not Null - -Helper method to filter out values that are `null` - -```php -//Returns ['foo','bar','baz'] -ArrayHelper::whereNotNull(['foo','bar','baz',null]); -``` - -## Each - -Calls the specified callback on each item in a foreach loop. If the array is associative, the key is retained. -Functional methods like this are particularly useful because they can require type safety in your callbacks. - -The example below converts an array of tag names keyed by their URL-friendly slug into hashtags. - -```php -//Returns ['outer-banks' => '#OuterBanks', 'north-carolina' => '#NorthCarolina', 'travel' => '#Travel'] -$hashtags = ArrayHelper::each([ - 'outer-banks' => 'Outer Banks', - 'north-carolina' => 'North Carolina', - 'travel' => 'Travel', -], fn(string $value, string $key) => '#' . StringHelper::pascalCase($value)); -``` - -## After - -Fetches items after the specified array position. - -```php -use PHPNomad\Helpers\ArrayHelper; - -//['bar','baz'] -ArrayHelper::after(['foo','bar','baz'],1); -``` - -## Before - -The opposite of `ArrayHelper::after`. Fetches items Before_ the specified array position. - -```php -use PHPNomad\Helpers\ArrayHelper; - -//['foo'] -ArrayHelper::before(['foo','bar','baz'],1); -``` - -## Dot - -Fetches an item from an array using a dot notation. Throws an `ItemNotFound` if the item provided could not be located -in the array. - -```php -use PHPNomad\Helpers\ArrayHelper; - -try{ - // baz - ArrayHelper::dot(['foo' => ['bar' => 'baz']], 'foo.bar') -}catch(ItemNotFound $e){ - // Handle cases where the item was not found. -} -``` - -## Remove - -Removes an item from the array, and returns the transformed array. - -```php -// ['peanut butter' => 'JIF', 'jelly' => 'Smucker\'s'] -ArrayHelper::remove(['milk' => 'Goshen Dairy','peanut butter' => 'JIF', 'jelly' => 'Smucker\'s'], 'milk'); -``` - -## Wrap - -Forces an item to be an array, even if it isn't an array. - -```php -// [123] -ArrayHelper::wrap(123); -``` - -## Hydrate - -Creates an array of new instances given the arguments to pass into the instance constructor. - -```php - -class Tag{ - - public function _Construct(public readonly string $slug, public readonly string $name){ - - } -} - -// [(Tag),(Tag),(Tag) -ArrayHelper::hydrate([ - ['rv-life', 'RVLife'], - ['travel','Travel'], - ['wordpress','WordPress'] - ],Tag::class) -``` - -## Flatten - -Flatten arrays of arrays into a single array where the parent array is embedded as an item keyed by the `$key`. - -```php -/** - * [ - * ['id' => 'group-1', 'key' => 'value', 'another' => 'value'], - * ['id' => 'group-1', 'key' => 'another-value', 'another' => 'value'], - * ['id' => 'group-2', 'key' => 'value', 'another' => 'value'], - * ['id' => 'group-2', 'key' => 'another-value', 'another' => 'value'] - * ] - */ -ArrayHelper::flatten([ - 'group-1' => [['key' => 'value', 'another' => 'value'], ['key' => 'another-value', 'another' => 'value']], - 'group-2' => [['key' => 'value', 'another' => 'value'], ['key' => 'another-value', 'another' => 'value']], -], 'id') -``` - -## To Indexed - -Updates the array to contain a key equal to the array's key value. - -```php -/** - * [ - * ['slug' => 'travel','name' => 'Travel'], - * ['slug' => 'rv-life','name' => 'RV Life'], - * ['slug' => 'wordpress','name' => 'WordPress'] - * ] - */ -ArrayHelper::toIndexed(['travel' => 'Travel','rv-life' => 'RVLife','wordpress' => 'Wordpress'], 'slug', 'name'); -``` - -## Sort - -Sorts array using the specified subject, sorting method, and direction. Transforms the array directly. - -```php -$items = ['bar','foo','baz']; - -// ['foo', 'baz', 'bar'] -ArrayHelper::sort($items,SORTREGULAR,Direction::Descending); -``` - -This also supports providing a callback for the sort, instead: - -```php -class Tag{ - - public function _Construct(public readonly string $slug, public readonly string $name){ - - } -} - -$items = [ - new Tag('rv-life','RV Life'), - new Tag('travel','Travel'), - new Tag('outer-banks', 'Outer Banks'), - new Tag('taos', 'Taos') -]; - -// [Tag(outer-banks), Tag(rv-life), Tag(taos), Tag(travel)] -ArrayHelper::sort($items,fn(Tag $a, Tag $b) => $a->slug <=> $b->slug); -``` - -## Pluck - -Plucks a single item from an array, given a key. Falls back to a default value if it is not set. - -```php -// 'bar' -ArrayHelper::pluck(['foo' => 'bar'],'foo','baz'); - -// 'baz' -ArrayHelper::pluck(['foo' => 'bar'],'invalid','baz'); -``` - -If the item is not an array, it will also provide the default value. - -```php -// 'baz' -ArrayHelper::pluck('This is clearly not an array...and yet.','invalid','baz'); -``` - -## Pluck Recursive - -Plucks a specific value from an array of items. - -```php -$items = [ - ['slug' => 'rv-life', 'name' => 'RVLife'], - ['slug' => 'travel', 'name' => 'Travel'], - ['slug' => 'wordpress', 'name' => 'WordPress'], - ['name' => 'Invalid'] -]; - -// ['rv-life','travel','outer-banks','taos', null] -ArrayHelper::PluckRecursive($items,'slug', null); -``` - -This also works with objects: - -```php -class Tag{ - - public function _Construct(public readonly string $slug, public readonly string $name){ - - } -} - -$items = [ - new Tag('rv-life','RV Life'), - new Tag('travel','Travel'), - new Tag('outer-banks', 'Outer Banks'), - new Tag('taos', 'Taos') -]; - -// ['rv-life','travel','outer-banks','taos'] -ArrayHelper::pluckRecursive($items, 'slug', null); -``` - -## Cast - -Cast all items in the array to the specified type. - -```php -// [1, 234,12,123,0,0] -ArrayHelper::cast(['1','234','12.34',123,'alex',false], 'int'); -``` - -## Append - -Adds the specified item(s) to the end of an array. - -```php -// ['foo','bar','baz'] -ArrayHelper::append(['foo'],'bar','baz'); -``` - -## Is Associative - -Returns true if this array is an associative array. - -```php -// true -ArrayHelper::isAssociative(['foo' => 'bar']); - -// false -ArrayHelper::isAssociative(['foo', 'bar', 'baz']); -``` - -## Normalize - -Recursively sorts, and optionally mutates an array of arrays. Useful when preparing for caching purposes because it -ensures that any array that is technically identical, although in a different order, is the same. This can also convert -a closure into a format that can be safely converted into a hash. - -Generally, this is used to prepare an array to be converted into a consistent hash, regardless of what order the items -in the array are stored. - -```php -$cachedQuery = [ - 'postType' => 'post', - 'postsPerPage' => -1, - 'metaQuery' => [ - 'relation' => 'OR', - [ - 'key' => 'likes', - 'value' => 50, - 'compare' => '>', - 'type' => 'numeric', - ], - ], -]; - -/** -* [ -* 'metaQuery' => [ -* 'relation' => 'OR' -* [ -* 'compare' => '>' -* 'key' => 'likes' -* 'type' => 'numeric' -* 'value' => 50 -* ] -* ] -* 'postType' => 'post' -* 'postsPerPage' => int -1 - * ] - */ -ArrayHelper::normalize($cachedQuery) -``` - -## Proxies - -There are also several methods that serve as direct proxies for `array_*` functions, with the only difference being that -the order of the arguments always put the input array as the first argument (haystack comes first). - -* map => arrayMap -* reduce => arrayReduce -* filter => arrayFilter -* values => arrayValues -* keys => arrayKeys -* unique => arrayUnique -* keySort => ksort -* merge => arrayMerge -* reverse => arrayReverse -* prepend => arrayUnshift -* intersect => arrayIntersect -* intersectKeys => arrayIntersectKeys -* diff => arrayDiff -* replaceRecursive => arrayReplaceRecursive -* replace => arrayReplace \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/array-processor.md b/public/docs/docs/packages/utils/processors/array-processor.md deleted file mode 100644 index f51c130..0000000 --- a/public/docs/docs/packages/utils/processors/array-processor.md +++ /dev/null @@ -1,76 +0,0 @@ -# Array Processor - -The Array Processor makes it possible to pass a single array through multiple chained mutation methods, and then output the result as either the transformed array, or a string. It supports all [Array Helper](/packages/utils/array-helper) methods in a chained form. - -## Extracting The Array - -When you've done all the processing necessary, you can get the array back by calling `toArray()`. - -```php -//['BAR','BAZ','FOO'] -(new ArrayProcessor(['foo','bar','baz'])) - ->sort() - ->map(fn(string $item) => strtoupper($item)) - ->toArray(); -``` - -## Converting a processed array into a string - -`ArrayProcessor` implements `CanConvertToString`, which means that it can be typecast into a string directly, or passed into any method or function that typehints a string, and it will automatically be converted into a string when passed through. - -By default, the array processor will convert the array into a comma-separated value, like so: - -```php -// 'foo,bar,baz' -(new ArrayProcessor(['foo','bar','baz']))->toString(); -``` - -However, if you provide a separator, it will use that instead of `,`: - -```php -// 'foo & bar & baz' -(new ArrayProcessor(['foo','bar','baz']))->setSeparator(' & ')->toString(); -``` - -You can directly echo the processor, or generally treat it like a string, too. - -```php -// "The following items are set in the array: foo,bar,baz -$result = "The following items are set in the array: " . (new ArrayProcessor(['foo','bar','baz'])); - -// Echos "foo and also bar and also baz" -echo (new ArrayProcessor(['foo','bar','baz']))->setSeparator('and also'); -``` - -## Gotchas - -The Array Processor assumes that you will always start, and finish with an array. This means that some implementations, such as `reduce` can cause unexpected errors when the accumulator is something other than an array: - -```php -class Tag implements CanConvertToString { - - public function _Construct(public readonly string $slug, public readonly string $name){ - - } -} - -// This won't work. -(new ArrayProcessor([new Tag('rv-life','RV Life'), new Tag('travel','Travel')])) - ->reduce(function(string $acc, Tag $value){ - $acc .= $value->name . ' ' . $value->slug; - - return $acc; - },'') - ->toString(); - -// But this would! And it would return the same result as what the reducer above would return. -//Note the accumulator is an array, which gets converted to a string. -(new ArrayProcessor([new Tag('rv-life','RV Life'), new Tag('travel','Travel')])) - ->reduce(function(array $acc, Tag $value){ - $acc[] = $value->name . ' ' . $value->slug; - - return $acc; - },[]) - ->setSeparator('') - ->toString(); -``` \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/list-filter.md b/public/docs/docs/packages/utils/processors/list-filter.md deleted file mode 100644 index 6210825..0000000 --- a/public/docs/docs/packages/utils/processors/list-filter.md +++ /dev/null @@ -1,235 +0,0 @@ -# List Filter - -A list filter makes it possible to filter items from an array of objects using a chain-able query syntax. This feature -is built-into [Object Registries](/reference/registries/object-registries) via the `ObjectRegistry::query` method, -however it can also be used on raw arrays, as long as each item in the array has the same getters and setters you're -filtering by. This can be done using fully-qualified objects, or by simply converting arrays to objects, as shown below. - -```php -use \PHPNomad\Helpers\Processors\ListFilter; - -$iceCreamOrderItems = [ - 'item_1' => (object) [ - 'customer' => (object)['name' => 'Alex', 'id' => 1], - 'scoops' => ['strawberry','chocolate'], - 'cone' => 'waffle', - 'price' => 629, - 'toppings' => ['cheese-crackers'] - ], - - 'item_2' => (object) [ - 'customer' => (object)['name' => 'Devin', 'id' => 2], - 'scoops' => ['chocolate'], - 'cone' => 'chocolateWaffle', - 'price' => 429, - 'toppings' => ['sprinkles'] - ], - - 'item_3' => (object) [ - 'customer' => (object)['name' => 'Kate', 'id' => 3], - 'scoops' => ['vanilla'], - 'cone' => 'basic', - 'price' => 429, - 'toppings' => [] - ], - - 'item_4' => (object) [ - 'customer' => (object)['name' => 'Ben', 'id' => 4], - 'scoops' => ['strawberry','chocolate', 'vanilla'], - 'cone' => 'bowl', - 'price' => 899, - 'toppings' => ['sprinkles'] - ], -]; -``` - -## Action - -An action applies the set of operations against the array, and provides a result in different ways based on the type of -action made. - -### Filter Action - -The filter action will return a filtered array of items, filtering the results based on the provided criteria in the -operations chained before the call. - -```php -// [1,2] -$filtered = (new ListFilter($iceCreamOrderItems))->lessThan('price', 600)->filter(); -``` - -### Find Action - -The find action will return the first item found based on the provided criteria in the operations chained before the -call. - -```php -// Returns item 1 in the array above -$filtered = (new ListFilter($iceCreamOrderItems))->equals('customer.id', 2)->find(); -``` - -## Operations - -An operation is a single specification on how to filter the items in the array. Operations can be chained together, and -will set multiple operations against the filter. - -Operations are not applied until either `filter` or `find` is called, and the operations run in the order they're -declared. - -```php -// [1] -$filtered = (new ListFilter($iceCreamOrderItems)) - ->greaterThan('price',629) - ->lessThan('price', 899) - ->in('toppings','sprinkles') - ->filter(); -``` - -### Numeric Operations - -It's possible to filter numbers based on their value using `lessThan`, `greaterThan`, `lessThanOrEqual`, -and `greaterThanOrEqual`. - -```php -// [1,2] -$filtered = (new ListFilter($iceCreamOrderItems))->lessThan('price', 600)->filter(); -// [1,2,3] -$filtered = (new ListFilter($iceCreamOrderItems))->greaterThan('price', 400)->filter(); -// [1,2,3] -$filtered = (new ListFilter($iceCreamOrderItems))->greaterThanOrEqual('price', 429)->filter(); -// [0,1,2] -$filtered = (new ListFilter($iceCreamOrderItems))->lessThanOrEqual('price', 429)->filter(); -``` - -### Instance Operations - -It's possible to filter values based on their instance type. Naturally, this requires that the items in-question are an -actual instance. These filters work with the class, as well as any class that they inherit. - -```php -interface Content{} - -interface Article{} - -class BlogPost implements Content, Article{ - /*..*/ -} - -class MicroPost implements Content, Article{ - /*..*/ -} - -class Comment implements Content{ - /*..*/ -} - -$posts = [new BlogPost(),new BlogPost(), new MicroPost(), new Comment()]; - -// [0,1] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->instanceOf(BlogPost::class); -// [0,1,2] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->instanceOf(Article::class); -// [0,1,3] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->notInstanceOf(MicroPost::class); -// [3] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->notInstanceOf(Article::class); -// [0,1,2] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->hasAllInstances(Content::class, Article::class); -// [2,3] -$filtered = (new \PHPNomad\Helpers\Processors\ListFilter($posts))->hasAnyInstances(MicroPost::class, Comment::class); -``` - -### Key Operations - -These filters work against the array key instead of the array value. - -```php -// [1,2] -$filtered = (new ListFilter($iceCreamOrderItems))->keyIn('item_2', 'item_3')->filter(); -// [0,3] -$filtered = (new ListFilter($iceCreamOrderItems))->keyNotIn('item_2', 'item_3')->filter(); -``` - -### Value Operations - -Value operations work directly against the various property values on the objects inside the array. In order for any -value operation to work, the property must either be `public` (`readonly` is okay!), or has an associated getter method -called `get_${property}` where `${property}` is the name of the property that must be fetched. The getter method takes -priority over the property value, so if you have a getter and a public property, it will use the getter method. - -All value operations support dot notation for fetching values nested in objects, and can work with both arrays of -values, and single values. - -#### In, Not-In - -`in` will set the query to filter out items whose field any of the provided values. `notIn` does the exact opposite. - -```php -// [0,2] -$filtered = (new ListFilter($iceCreamOrderItems))->in('cone', 'basic','waffle')->filter(); -// [1,3] -$filtered = (new ListFilter($iceCreamOrderItems))->in('toppings', 'sprinkles')->filter(); -// [0,1,3] -$filtered = (new ListFilter($iceCreamOrderItems))->in('toppings', 'sprinkles', 'cheese-crackers')->filter(); -// [0,2] -$filtered = (new ListFilter($iceCreamOrderItems))->notIn('toppings', 'sprinkles')->filter(); -// [2] -$filtered = (new ListFilter($iceCreamOrderItems))->notIn('toppings', 'sprinkles', 'cheese-crackers')->filter(); -// [3] -$filtered = (new ListFilter($iceCreamOrderItems))->in('customer.name','Ben')->filter(); -``` - -#### And - -Sets the query to filter out items whose field has all the provided values. - -```php -// [0,3] -$filtered = (new ListFilter($iceCreamOrderItems))->and('scoops', 'strawberry','chocolate')->filter(); -``` - -#### Equals - -Sets the query to filter out items whose value is not identical to the provided value. - -```php -// [1,2] -$filtered = (new ListFilter($iceCreamOrderItems))->and('price', 429)->filter(); -// [0] -$filtered = (new ListFilter($iceCreamOrderItems))->and('scoops', ['strawberry','chocolate'])->filter(); -``` - -### Callback - -If all-else fails, you can chain in a callback to filter items. The example below would filter out any item whose cone -type does not begin with the letter 'b': - -```php -// [2,3] -$filtered = (new ListFilter($iceCreamOrderItems)) - ->filterFromCallback('cone', fn(string $cone) => 0 === strpos($cone,'b')) - ->filter(); -``` - -## Seeding - -The concrete `ListFilter` class can be seeded directly, using an array. This can be done both with the enums, and -without. The enum is the preferred method, but if you're confident that the input is accurate, you can technically use -an array directly. This can be useful in scenarios such as directly querying a registry using a REST endpoint, or -something like that. - -```php -use PHPNomad\Enums\Filter; - -// Using Enums -ListFilter::seed($iceCreamOrderItems, [ - Filter::in->field('cone') => ['waffle', 'chocolateWaffle'], - Filter::greaterThan->field('price') => 429, -])->filter() - -// Raw query -ListFilter::seed($iceCreamOrderItems, [ - 'cone_In' => ['waffle', 'chocolateWaffle'], - 'price_GreaterThan' => 429, -])->filter() -``` \ No newline at end of file diff --git a/public/docs/docs/packages/utils/processors/list-sorter.md b/public/docs/docs/packages/utils/processors/list-sorter.md deleted file mode 100644 index dd636e9..0000000 --- a/public/docs/docs/packages/utils/processors/list-sorter.md +++ /dev/null @@ -1,93 +0,0 @@ -# List Sorter - -A list sorter makes it possible to sort items from an array of objects using a chain-able query syntax. This feature -is built-into [Object Registries](/reference/registries/object-registries) via the `ObjectRegistry::query` method, -however it can also be used on raw arrays, as long as each item in the array has the same getters and setters you're -sorting by. This can be done using fully-qualified objects, or by simply converting arrays to objects, as shown below. - -```php -use \PHPNomad\Helpers\Processors\ListFilter; - -$iceCreamOrderItems = [ - (object) [ - 'customer' => (object)['name' => 'Alex', 'id' => 1], - 'scoops' => ['strawberry','chocolate'], - 'cone' => 'waffle', - 'price' => 629, - 'toppings' => ['cheese-crackers'] - ], - - (object) [ - 'customer' => (object)['name' => 'Devin', 'id' => 2], - 'scoops' => ['chocolate'], - 'cone' => 'chocolateWaffle', - 'price' => 429, - 'toppings' => ['sprinkles'] - ], - - (object) [ - 'customer' => (object)['name' => 'Kate', 'id' => 3], - 'scoops' => ['vanilla'], - 'cone' => 'basic', - 'price' => 429, - 'toppings' => [] - ], - - (object) [ - 'customer' => (object)['name' => 'Ben', 'id' => 4], - 'scoops' => ['strawberry','chocolate', 'vanilla'], - 'cone' => 'bowl', - 'price' => 899, - 'toppings' => ['sprinkles'] - ], -]; -``` - -## Usage - -Sort items by price -```php -$sorted = (new ListSorter($iceCreamOrderItems))->sortBy('price')->sort() -$sortedReverse = (new ListSorter($iceCreamOrderItems))->sortBy('price', Direction::Descending)->sort() -``` - -Nested object values are supported. Sort items by customer name. -```php -$sorted = (new ListSorter($iceCreamOrderItems))->sortBy('customer.name')->sort() -$sortedReverse = (new ListSorter($iceCreamOrderItems))->sortBy('customer.name', Direction::Descending)->sort() -``` - -## Custom Sorting Method - -The default sorting method provided in `ListSorter` is sufficient for most cases, however, if you need to create a custom sorting algorithm for it, this can be done by extending the `SortMethod` class. - -The example below creates a custom sorting method that makes it possible to sort items based on the number of items in the array. This allows us to sort the ice cream orders by the number of scoops. - -```php -use PHPNomad\Abstracts\SortMethod; -use \PHPNomad\Helpers\ObjectHelper; - -// First create the sorter. -class ArrayCountSorter extends SortMethod{ - - // The sort method is called on each item, and works much like usort, except it also includes the field name and the direction. - public function sort( object $a, object $b, string $field, Direction $direction ): int - { - // The spaceship operator will return -1, 0, or 1 based on the result. See PHP docs. - $result = count(ObjectHelper::pluck($a, $field)) <=> count(ObjectHelper::pluck($b, $field)); - - // Invert the result if it's descending, otherwise simply return the result as-is. - return $direction === Direction::Descending ? $result * -1 : $result; - } -} - -// Use the custom sorter method with scoops. -$sorted = (new ListSorter($iceCreamOrderItems)) - ->sortBy(field: 'scoops', method: ArrayCountSorter::class) - ->sort(); - -// Use the custom sorter method with scoops, only this time reverse it, Missy Elliott style. -$sortedReverse = (new ListSorter($iceCreamOrderItems)) - ->sortBy(field: 'scoops', direction: Direction::Descending, method: ArrayCountSorter::class) - ->sort(); -``` \ No newline at end of file diff --git a/public/docs/docs/packages/config/exceptions/config-exception.md b/public/docs/packages/config/exceptions/config-exception.md similarity index 100% rename from public/docs/docs/packages/config/exceptions/config-exception.md rename to public/docs/packages/config/exceptions/config-exception.md diff --git a/public/docs/docs/packages/config/interfaces/config-file-loader-strategy.md b/public/docs/packages/config/interfaces/config-file-loader-strategy.md similarity index 100% rename from public/docs/docs/packages/config/interfaces/config-file-loader-strategy.md rename to public/docs/packages/config/interfaces/config-file-loader-strategy.md diff --git a/public/docs/docs/packages/config/interfaces/config-strategy.md b/public/docs/packages/config/interfaces/config-strategy.md similarity index 100% rename from public/docs/docs/packages/config/interfaces/config-strategy.md rename to public/docs/packages/config/interfaces/config-strategy.md diff --git a/public/docs/docs/packages/config/interfaces/introduction.md b/public/docs/packages/config/interfaces/introduction.md similarity index 100% rename from public/docs/docs/packages/config/interfaces/introduction.md rename to public/docs/packages/config/interfaces/introduction.md diff --git a/public/docs/docs/packages/config/introduction.md b/public/docs/packages/config/introduction.md similarity index 100% rename from public/docs/docs/packages/config/introduction.md rename to public/docs/packages/config/introduction.md diff --git a/public/docs/docs/packages/config/services/config-service.md b/public/docs/packages/config/services/config-service.md similarity index 100% rename from public/docs/docs/packages/config/services/config-service.md rename to public/docs/packages/config/services/config-service.md diff --git a/public/docs/docs/packages/config/services/introduction.md b/public/docs/packages/config/services/introduction.md similarity index 100% rename from public/docs/docs/packages/config/services/introduction.md rename to public/docs/packages/config/services/introduction.md 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/docs/packages/enum-polyfill/introduction.md b/public/docs/packages/enum-polyfill/introduction.md similarity index 100% rename from public/docs/docs/packages/enum-polyfill/introduction.md rename to public/docs/packages/enum-polyfill/introduction.md diff --git a/public/docs/docs/packages/enum-polyfill/traits/enum.md b/public/docs/packages/enum-polyfill/traits/enum.md similarity index 100% rename from public/docs/docs/packages/enum-polyfill/traits/enum.md rename to public/docs/packages/enum-polyfill/traits/enum.md diff --git a/public/docs/docs/packages/enum-polyfill/traits/introduction.md b/public/docs/packages/enum-polyfill/traits/introduction.md similarity index 100% rename from public/docs/docs/packages/enum-polyfill/traits/introduction.md rename to public/docs/packages/enum-polyfill/traits/introduction.md diff --git a/public/docs/docs/packages/event/interfaces/action-binding-strategy.md b/public/docs/packages/event/interfaces/action-binding-strategy.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/action-binding-strategy.md rename to public/docs/packages/event/interfaces/action-binding-strategy.md diff --git a/public/docs/docs/packages/event/interfaces/can-handle.md b/public/docs/packages/event/interfaces/can-handle.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/can-handle.md rename to public/docs/packages/event/interfaces/can-handle.md diff --git a/public/docs/docs/packages/event/interfaces/event-strategy.md b/public/docs/packages/event/interfaces/event-strategy.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/event-strategy.md rename to public/docs/packages/event/interfaces/event-strategy.md diff --git a/public/docs/docs/packages/event/interfaces/event.md b/public/docs/packages/event/interfaces/event.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/event.md rename to public/docs/packages/event/interfaces/event.md diff --git a/public/docs/docs/packages/event/interfaces/has-event-bindings.md b/public/docs/packages/event/interfaces/has-event-bindings.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/has-event-bindings.md rename to public/docs/packages/event/interfaces/has-event-bindings.md diff --git a/public/docs/docs/packages/event/interfaces/has-listeners.md b/public/docs/packages/event/interfaces/has-listeners.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/has-listeners.md rename to public/docs/packages/event/interfaces/has-listeners.md diff --git a/public/docs/docs/packages/event/interfaces/introduction.md b/public/docs/packages/event/interfaces/introduction.md similarity index 100% rename from public/docs/docs/packages/event/interfaces/introduction.md rename to public/docs/packages/event/interfaces/introduction.md diff --git a/public/docs/docs/packages/event/introduction.md b/public/docs/packages/event/introduction.md similarity index 100% rename from public/docs/docs/packages/event/introduction.md rename to public/docs/packages/event/introduction.md diff --git a/public/docs/docs/packages/event/patterns/best-practices.md b/public/docs/packages/event/patterns/best-practices.md similarity index 100% rename from public/docs/docs/packages/event/patterns/best-practices.md rename to public/docs/packages/event/patterns/best-practices.md diff --git a/public/docs/docs/packages/logger/interfaces/introduction.md b/public/docs/packages/logger/interfaces/introduction.md similarity index 100% rename from public/docs/docs/packages/logger/interfaces/introduction.md rename to public/docs/packages/logger/interfaces/introduction.md diff --git a/public/docs/docs/packages/logger/interfaces/logger-strategy.md b/public/docs/packages/logger/interfaces/logger-strategy.md similarity index 100% rename from public/docs/docs/packages/logger/interfaces/logger-strategy.md rename to public/docs/packages/logger/interfaces/logger-strategy.md diff --git a/public/docs/docs/packages/logger/introduction.md b/public/docs/packages/logger/introduction.md similarity index 100% rename from public/docs/docs/packages/logger/introduction.md rename to public/docs/packages/logger/introduction.md diff --git a/public/docs/docs/packages/logger/traits/can-log-exception.md b/public/docs/packages/logger/traits/can-log-exception.md similarity index 100% rename from public/docs/docs/packages/logger/traits/can-log-exception.md rename to public/docs/packages/logger/traits/can-log-exception.md diff --git a/public/docs/docs/packages/logger/traits/introduction.md b/public/docs/packages/logger/traits/introduction.md similarity index 100% rename from public/docs/docs/packages/logger/traits/introduction.md rename to public/docs/packages/logger/traits/introduction.md diff --git a/public/docs/docs/packages/mutator/interfaces/has-mutations.md b/public/docs/packages/mutator/interfaces/has-mutations.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/has-mutations.md rename to public/docs/packages/mutator/interfaces/has-mutations.md diff --git a/public/docs/docs/packages/mutator/interfaces/introduction.md b/public/docs/packages/mutator/interfaces/introduction.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/introduction.md rename to public/docs/packages/mutator/interfaces/introduction.md diff --git a/public/docs/docs/packages/mutator/interfaces/mutation-adapter.md b/public/docs/packages/mutator/interfaces/mutation-adapter.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/mutation-adapter.md rename to public/docs/packages/mutator/interfaces/mutation-adapter.md diff --git a/public/docs/docs/packages/mutator/interfaces/mutation-strategy.md b/public/docs/packages/mutator/interfaces/mutation-strategy.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/mutation-strategy.md rename to public/docs/packages/mutator/interfaces/mutation-strategy.md diff --git a/public/docs/docs/packages/mutator/interfaces/mutator-handler.md b/public/docs/packages/mutator/interfaces/mutator-handler.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/mutator-handler.md rename to public/docs/packages/mutator/interfaces/mutator-handler.md diff --git a/public/docs/docs/packages/mutator/interfaces/mutator.md b/public/docs/packages/mutator/interfaces/mutator.md similarity index 100% rename from public/docs/docs/packages/mutator/interfaces/mutator.md rename to public/docs/packages/mutator/interfaces/mutator.md diff --git a/public/docs/docs/packages/mutator/introduction.md b/public/docs/packages/mutator/introduction.md similarity index 100% rename from public/docs/docs/packages/mutator/introduction.md rename to public/docs/packages/mutator/introduction.md diff --git a/public/docs/docs/packages/mutator/traits/can-mutate-from-adapter.md b/public/docs/packages/mutator/traits/can-mutate-from-adapter.md similarity index 100% rename from public/docs/docs/packages/mutator/traits/can-mutate-from-adapter.md rename to public/docs/packages/mutator/traits/can-mutate-from-adapter.md diff --git a/public/docs/docs/packages/mutator/traits/introduction.md b/public/docs/packages/mutator/traits/introduction.md similarity index 100% rename from public/docs/docs/packages/mutator/traits/introduction.md rename to public/docs/packages/mutator/traits/introduction.md 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/docs/packages/singleton/introduction.md b/public/docs/packages/singleton/introduction.md similarity index 100% rename from public/docs/docs/packages/singleton/introduction.md rename to public/docs/packages/singleton/introduction.md