diff --git a/README.md b/README.md index 60f76af..4358e56 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Inspired by nature. Built for developers. Powered by AI Focused package documentation lives next to the relevant source folders: - [Auth](./src/Auth/README.md) +- [Cache](./src/Cache/README.md) - [Container](./src/Container/README.md) - [Console](./src/Console/README.md) - [Database Overview](./src/Database/README.md) @@ -20,11 +21,14 @@ Focused package documentation lives next to the relevant source folders: - [Events](./src/Events/README.md) - [HTTP](./src/Http/README.md) - [Logging](./src/Logging/README.md) +- [Mongo](./src/Mongo/README.md) - [Middleware](./src/Middleware/README.md) - [Rate Limiting](./src/RateLimit/README.md) +- [Redis](./src/Redis/README.md) - [Routing](./src/Routing/README.md) - [Storage](./src/Storage/README.md) - [Support and Facades](./src/Support/README.md) +- [Validation](./src/Validation/README.md) ## Docker Setup diff --git a/src/Auth/README.md b/src/Auth/README.md index 5cc5a0b..3137b20 100644 --- a/src/Auth/README.md +++ b/src/Auth/README.md @@ -15,7 +15,21 @@ use Myxa\Auth\AuthServiceProvider; $app->register(new AuthServiceProvider()); ``` -## Provide User Resolvers +`AuthServiceProvider` already registers the built-in auth services as singletons: + +- `AuthManager::class` +- `'auth'` +- `SessionGuard::class` +- `BearerTokenGuard::class` + +## Override the Default User Resolvers + +The provider also registers fallback null resolvers for: + +- `BearerTokenResolverInterface::class` +- `SessionUserResolverInterface::class` + +Bind these interfaces only when you want real authentication logic instead of the defaults: ```php use Myxa\Auth\BearerTokenResolverInterface; diff --git a/src/Cache/README.md b/src/Cache/README.md index bd22ee4..19036ab 100644 --- a/src/Cache/README.md +++ b/src/Cache/README.md @@ -1,12 +1,24 @@ # Cache -The cache layer supports named cache stores with a shared manager. +The cache layer supports named cache stores behind a shared manager. Available facade: - `Cache` -## Local File Cache +## What It Provides + +The cache manager gives you: + +- a default cache store plus named stores +- file-backed caching through `FileCacheStore` +- Redis-backed caching through `RedisCacheStore` +- direct manager usage or application/facade usage +- convenience helpers like `remember()` and `forever()` + +## Use CacheManager Directly + +If you do not want to register `CacheServiceProvider`, you can work with `CacheManager` directly: ```php use Myxa\Cache\CacheManager; @@ -18,53 +30,154 @@ $cache->put('users.count', 15); $count = $cache->get('users.count'); // 15 ``` -## Redis Cache +## Basic Operations + +```php +$cache->put('settings', ['theme' => 'light'], 60); +$settings = $cache->get('settings', []); + +$cache->forever('app.name', 'Myxa'); +$appName = $cache->get('app.name'); + +$remembered = $cache->remember('expensive', fn () => ['ready' => true], 300); + +$exists = $cache->has('settings'); +$cache->forget('settings'); +$cache->clear(); +``` + +TTL values are expressed in seconds: + +- `null` means store until manually removed +- a positive integer means expire after that many seconds +- expired values are treated as missing + +## Named Stores + +You can register multiple stores and target them by alias: ```php use Myxa\Cache\CacheManager; +use Myxa\Cache\Store\FileCacheStore; use Myxa\Cache\Store\RedisCacheStore; -use Myxa\Redis\Connection\PhpRedisStore; +use Myxa\Redis\Connection\InMemoryRedisStore; use Myxa\Redis\Connection\RedisConnection; -$cache = new CacheManager('redis', new RedisCacheStore( - new RedisConnection(new PhpRedisStore(host: 'redis', port: 6379)), +$cache = new CacheManager('local', new FileCacheStore('data/cache')); + +$cache->addStore('redis', new RedisCacheStore( + new RedisConnection(new InMemoryRedisStore()), )); + +$cache->put('token', 'abc', store: 'redis'); +$token = $cache->get('token', store: 'redis'); ``` -## Usage +`addStore()` also accepts a factory closure when you want lazy store creation: ```php -$cache->put('settings', ['theme' => 'light'], 60); -$settings = $cache->get('settings', []); -$remembered = $cache->remember('expensive', fn () => ['ready' => true], 300); -$cache->forget('settings'); +$cache->addStore('redis', fn (): RedisCacheStore => new RedisCacheStore( + new RedisConnection(new InMemoryRedisStore()), +)); ``` -## Facade Usage +## File Cache Store + +`FileCacheStore` writes serialized cache payloads into a directory on disk: ```php -use Myxa\Cache\CacheManager; use Myxa\Cache\Store\FileCacheStore; -use Myxa\Support\Facades\Cache; -$manager = new CacheManager('local', new FileCacheStore('data/cache')); +$store = new FileCacheStore('data/cache'); +``` -Cache::setManager($manager); +Useful when: -Cache::put('users.count', 15); -$count = Cache::get('users.count'); // 15 +- you want a simple local cache with no extra infrastructure +- you are running tests or development scripts +- you want the default store provided by `CacheServiceProvider` -Cache::put('settings', ['theme' => 'light'], 60); -$settings = Cache::get('settings', []); +## Redis Cache Store -$remembered = Cache::remember('expensive', fn () => ['ready' => true], 300); +`RedisCacheStore` adapts the Redis connection layer into a cache store: + +```php +use Myxa\Cache\Store\RedisCacheStore; +use Myxa\Redis\Connection\PhpRedisStore; +use Myxa\Redis\Connection\RedisConnection; + +$store = new RedisCacheStore( + new RedisConnection(new PhpRedisStore( + host: 'redis', + port: 6379, + database: 0, + )), + prefix: 'cache:', +); +``` + +Useful when: + +- cached values should be shared across processes or hosts +- you already use Redis in the application +- you want cache and Redis to share the same infrastructure + +## Use the Facade Through the Service Provider + +In application code, register `CacheServiceProvider` to expose the shared manager and initialize the facade: + +```php +use Myxa\Application; +use Myxa\Cache\CacheServiceProvider; +use Myxa\Cache\Store\FileCacheStore; +use Myxa\Cache\Store\RedisCacheStore; +use Myxa\Redis\Connection\InMemoryRedisStore; +use Myxa\Redis\Connection\RedisConnection; + +$app = new Application(); + +$app->register(new CacheServiceProvider( + stores: [ + 'local' => new FileCacheStore('data/cache'), + 'redis' => new RedisCacheStore(new RedisConnection(new InMemoryRedisStore())), + ], + defaultStore: 'local', +)); + +$app->boot(); +``` + +Then use the facade: + +```php +use Myxa\Support\Facades\Cache; + +Cache::put('users.count', 15); +$count = Cache::get('users.count'); Cache::put('token', 'abc', store: 'redis'); $token = Cache::get('token', store: 'redis'); ``` +## Store Selection + +The manager resolves stores in this order: + +1. the explicitly requested store alias +2. otherwise the configured default store + +You can inspect or change the default store: + +```php +$cache->getDefaultStore(); +$cache->setDefaultStore('redis'); +$store = $cache->store(); +``` + ## Notes +- `CacheManager` works without `CacheServiceProvider` - `CacheServiceProvider` registers the shared cache manager and initializes the facade -- `FileCacheStore` writes cache files under `data/cache` by default -- `RedisCacheStore` reuses the Redis connection layer +- when no explicit default store is provided through the provider, `FileCacheStore('data/cache')` is used for the default alias +- `remember()` only resolves the callback when the key is missing +- `RedisCacheStore` builds on the Redis connection layer documented in [Redis](../Redis/README.md) diff --git a/src/Console/README.md b/src/Console/README.md index 4fd8af0..b91a4f4 100644 --- a/src/Console/README.md +++ b/src/Console/README.md @@ -9,40 +9,170 @@ use Myxa\Console\Command; final class HelloCommand extends Command { - protected string $signature = 'hello'; - protected string $description = 'Say hello'; + public function name(): string + { + return 'hello'; + } + + public function description(): string + { + return 'Say hello'; + } protected function handle(): int { - $this->output->success('Hello from Myxa')->icon(); + $this->success('Hello from Myxa')->icon(); return self::SUCCESS; } } ``` -## Register Commands +## Arguments and Options + +Use `parameters()` for positional arguments and `options()` for long-form `--options`. ```php -use Myxa\Console\ConsoleKernel; +use Myxa\Console\Command; +use Myxa\Console\InputArgument; +use Myxa\Console\InputOption; + +final class GreetCommand extends Command +{ + public function name(): string + { + return 'greet'; + } -$kernel = new ConsoleKernel(commands: [ - HelloCommand::class, -]); + public function description(): string + { + return 'Greets a user with optional flags.'; + } -exit($kernel->handle($argv)); + public function parameters(): array + { + return [ + new InputArgument('name', 'Who should be greeted?', hint: 'Name'), + new InputArgument('team', 'Optional team name', required: false, default: 'Guests'), + ]; + } + + public function options(): array + { + return [ + new InputOption('title', 'Greeting title', acceptsValue: true, required: false, default: 'Friend'), + new InputOption('uppercase', 'Render the greeting in uppercase'), + ]; + } + + protected function handle(): int + { + $message = sprintf( + 'Hello %s %s from %s', + $this->option('title'), + $this->parameter('name'), + $this->parameter('team'), + ); + + if ($this->option('uppercase')) { + $message = strtoupper($message); + } + + $this->info($message)->icon(); + + return self::SUCCESS; + } +} +``` + +Example usage: + +```bash +php app.php greet Chevy +php app.php greet Chevy Core --title=Captain +php app.php greet Chevy Core --title=Captain --uppercase +``` + +## Reading Input + +Inside `handle()`, use: + +- `$this->parameter('name')` for positional values +- `$this->option('title')` for options +- `$this->input()->parameters()` to get all positional input +- `$this->input()->options()` to get all parsed options + +## Format Text + +Console messages use a fluent builder. + +```php +$this->output('Plain message'); +$this->info('Import started')->icon(); +$this->success('Import finished')->icon()->bold(); +$this->warning('Dry-run mode')->underline(); +$this->error('Import failed')->icon()->bold(); ``` -## Output Helpers +Available formatting helpers: + +- `success()` +- `warning()` +- `error()` +- `info()` +- `bold()` +- `underline()` +- `strike()` +- `icon()` +- `send()` + +Messages are rendered automatically when the pending message goes out of scope, but you can force immediate output with `->send()`. + +## Tables and Progress ```php -$this->output->info('Import started')->icon(); -$this->output->table(['ID', 'Email'], $rows); -$this->output->progressBar(25, 100); +$this->table( + ['ID', 'Email'], + [ + ['ID' => 1, 'Email' => 'john@example.com'], + ['ID' => 2, 'Email' => 'jane@example.com'], + ], +); + +$startedAt = microtime(true); + +foreach (range(1, 100) as $index) { + $this->progressBar($index, 100, startedAt: $startedAt); +} + +$this->progressText('Importing', 25, 100, startedAt: microtime(true)); +``` + +## Register Commands + +```php +use Myxa\Console\ConsoleKernel; + +final class Kernel extends ConsoleKernel +{ + protected function commands(): iterable + { + return [ + HelloCommand::class, + GreetCommand::class, + ]; + } +} + +$kernel = new Kernel(); + +exit($kernel->handle($argv)); ``` ## Notes -- command classes extend `Command` -- `ConsoleKernel` wires commands into the runner +- command classes implement `CommandInterface`; extending `Command` is the easiest path +- commands can be registered as instances or class names +- command constructors can use container-resolved dependencies +- options are long-form only, like `--title=value` - output helpers support formatted messages, tables, and progress bars diff --git a/src/Container/README.md b/src/Container/README.md index 33bcb63..a40ff2b 100644 --- a/src/Container/README.md +++ b/src/Container/README.md @@ -1,6 +1,103 @@ # Container -The container provides bindings, singletons, stored instances, autowiring, and callable invocation. +The container provides bindings, shared singletons, stored instances, autowiring, and callable invocation. + +## Register Services + +In practice, "registering a service" means telling the container how to resolve a class, interface, or named key. + +Common patterns: + +```php +use Myxa\Container\Container; + +$container = new Container(); + +// Register a concrete class as transient. +$container->bind(FooService::class); + +// Register an interface to a concrete implementation. +$container->bind(LoggerInterface::class, FileLogger::class); + +// Register a shared service. +$container->singleton(CacheManager::class); + +// Register a service with a factory closure. +$container->singleton(DatabaseManager::class, static function (): DatabaseManager { + return new DatabaseManager('main'); +}); + +// Register an already-built value. +$container->instance('config', [ + 'app.name' => 'Myxa', +]); +``` + +After registration, resolve services with `make()` or `get()`: + +```php +$logger = $container->make(LoggerInterface::class); +$cache = $container->make(CacheManager::class); +$config = $container->get('config'); +``` + +## Register Service Providers + +In full applications, services are often registered through `Application` service providers instead of calling `bind()` or `singleton()` manually in one file. + +```php +use Myxa\Application; +use Myxa\Auth\AuthServiceProvider; +use Myxa\Database\DatabaseServiceProvider; +use Myxa\Routing\RouteServiceProvider; + +$app = new Application(); + +$app->register(RouteServiceProvider::class); +$app->register(AuthServiceProvider::class); +$app->register(new DatabaseServiceProvider( + connections: [ + 'main' => $databaseConfig, + ], + defaultConnection: 'main', +)); + +$app->boot(); +``` + +What happens here: + +- `register(...)` attaches the provider to the application and runs its `register()` method +- provider `register()` is where bindings and singletons are added to the container +- `boot()` runs each provider's `boot()` method once registration is complete + +After that, you resolve the registered services from the application container: + +```php +$router = $app->make('router'); +$auth = $app->make('auth'); +$db = $app->make(\Myxa\Database\DatabaseManager::class); +``` + +Minimal example provider: + +```php +use Myxa\Support\ServiceProvider; + +final class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app()->singleton(MyService::class); + $this->app()->singleton('my-service', static fn ($app) => $app->make(MyService::class)); + } + + public function boot(): void + { + // optional runtime bootstrapping + } +} +``` ## Basic Usage @@ -15,15 +112,142 @@ $container->bind(Bar::class, static fn (Container $app) => new Bar($app->make(Fo $bar = $container->make(Bar::class); ``` -## Store an Existing Instance +## Main Methods + +### `bind()` + +Registers a transient binding. The value is rebuilt every time you resolve it. ```php -$container->instance('config', [ +$container->bind(LoggerInterface::class, FileLogger::class); + +$first = $container->make(LoggerInterface::class); +$second = $container->make(LoggerInterface::class); +``` + +`$first` and `$second` are different instances. + +### `singleton()` + +Registers a shared binding. The container builds it once, caches it, and returns the same instance on future resolutions. + +```php +$container->singleton(LoggerInterface::class, FileLogger::class); + +$first = $container->make(LoggerInterface::class); +$second = $container->make(LoggerInterface::class); +``` + +`$first` and `$second` are the same instance. + +### `instance()` + +Stores an already-built value directly in the container. + +```php +$config = [ 'app.name' => 'Myxa', +]; + +$container->instance('config', $config); +``` + +This is different from `singleton()`: + +- `instance()` stores the exact value immediately +- `singleton()` stores a recipe and builds the value lazily on first use + +## Difference Between `bind()`, `singleton()`, and `instance()` + +Use `bind()` when you want a fresh object every time: + +```php +$container->bind(RequestIdGenerator::class); +``` + +Use `singleton()` when one shared object should be reused: + +```php +$container->singleton(CacheManager::class); +``` + +Use `instance()` when you already have the final value: + +```php +$container->instance('config', $configArray); +$container->instance(PDO::class, $pdo); +``` + +Short version: + +- `bind()`: rebuild on every `make()` +- `singleton()`: build once, then reuse +- `instance()`: store a prebuilt value now + +## `make()` + +Resolves an entry from the container. + +```php +$service = $container->make(Service::class); +``` + +You can also pass named parameter overrides for constructor arguments: + +```php +$mailer = $container->make(Mailer::class, [ + 'dsn' => 'smtp://localhost', ]); ``` -## Call Through the Container +## `get()` + +PSR-11 alias for `make()`. + +```php +$service = $container->get(Service::class); +``` + +## `has()` + +Checks whether the container can resolve an entry. + +```php +if ($container->has(Service::class)) { + $service = $container->make(Service::class); +} +``` + +This includes: + +- explicitly bound entries +- stored instances +- autowirable concrete classes + +## Autowiring + +Concrete classes can often be resolved without manual registration. + +```php +final class Foo +{ +} + +final class Bar +{ + public function __construct(private Foo $foo) + { + } +} + +$bar = $container->make(Bar::class); +``` + +The container reflects the constructor and resolves class-typed dependencies automatically. + +## `call()` + +Invokes a callable and auto-resolves missing class-typed arguments. ```php $result = $container->call(function (Foo $foo) { @@ -31,7 +255,35 @@ $result = $container->call(function (Foo $foo) { }); ``` +Named parameter overrides also work here: + +```php +$result = $container->call( + function (Foo $foo, string $mode) { + return $foo->work($mode); + }, + ['mode' => 'safe'], +); +``` + +`call()` also supports object methods and class methods: + +```php +$container->call([$handler, 'handle']); +$container->call([UserController::class, 'show'], ['id' => 5]); +``` + +## Resolution Order + +When resolving an entry, the container checks: + +1. stored instances from `instance()` +2. registered bindings from `bind()` or `singleton()` +3. autowirable concrete classes + ## Notes - the container supports PSR-11 through `get()` and `has()` - `Application` extends the container and adds service-provider lifecycle support +- `singleton()` and `bind()` both accept a class name, closure, or `null` +- circular constructor dependencies throw a binding resolution exception diff --git a/src/Database/Connection/README.md b/src/Database/Connection/README.md new file mode 100644 index 0000000..72372b2 --- /dev/null +++ b/src/Database/Connection/README.md @@ -0,0 +1,98 @@ +# Connection + +The connection layer provides PDO-backed connection configuration and an alias-based registry. + +## Configuration + +Use `PdoConnectionConfig` when you want to describe a connection from structured values: + +```php +use Myxa\Database\Connection\PdoConnectionConfig; + +$config = new PdoConnectionConfig( + engine: 'mysql', + database: 'myxa', + host: '127.0.0.1', + port: 3306, + charset: 'utf8mb4', + username: 'root', + password: 'secret', +); +``` + +If you already have a DSN string, use `fromDsn()`: + +```php +use Myxa\Database\Connection\PdoConnectionConfig; + +$config = PdoConnectionConfig::fromDsn( + 'pgsql:dbname=myxa;host=127.0.0.1;port=5432', + 'postgres', + 'secret', +); +``` + +## Register a Connection Alias + +Register a connection directly in the global `PdoConnection` registry: + +```php +use Myxa\Database\Connection\PdoConnection; + +PdoConnection::registerNew('main', $config); +``` + +You can also register from a DSN in one step: + +```php +use Myxa\Database\Connection\PdoConnection; + +PdoConnection::registerFromDsn( + 'main', + 'sqlite:dbname=:memory:;host=localhost', +); +``` + +## Use with DatabaseManager + +`DatabaseManager` resolves connections by alias: + +```php +use Myxa\Database\DatabaseManager; + +$db = new DatabaseManager('main'); + +$rows = $db->select('SELECT 1 AS value'); +``` + +You can also register connections directly on the manager: + +```php +use Myxa\Database\DatabaseManager; + +$db = new DatabaseManager(); +$db->addConnection('main', $config); +$db->setDefaultConnection('main'); +``` + +## Access the PDO Connection + +```php +$connection = $db->connection('main'); +$pdo = $db->pdo('main'); +``` + +`PdoConnection` also exposes transaction helpers: + +```php +$connection->beginTransaction(); +$connection->commit(); +$connection->rollBack(); +``` + +## Notes + +- `PdoConnection::register()` stores an existing connection instance under an alias +- `PdoConnection::unregister()` removes an alias and disconnects it by default +- `PdoConnection::get()` throws when the alias is missing +- `connect()` is lazy, so the PDO instance is created only when first used diff --git a/src/Database/Model/README.md b/src/Database/Model/README.md index 3c40d96..0e37e5d 100644 --- a/src/Database/Model/README.md +++ b/src/Database/Model/README.md @@ -2,12 +2,12 @@ Models are active-record style classes built around declared properties. -## Example +For document-backed models with a similar declared-property style, see [Mongo](../../Mongo/README.md). + +## Basic Model ```php -use Myxa\Database\Attributes\Hook; use Myxa\Database\Model\HasTimestamps; -use Myxa\Database\Model\HookEvent; use Myxa\Database\Model\Model; final class User extends Model @@ -22,48 +22,288 @@ final class User extends Model } ``` -## Hooks +## Declared Properties Are Required -Use `#[Hook(...)]` on model methods to run code around persistence: +Model fields must be declared as real properties on the class. ```php -use Myxa\Database\Attributes\Hook; -use Myxa\Database\Model\HookEvent; +final class User extends Model +{ + protected string $table = 'users'; + + protected ?int $id = null; + protected string $email = ''; + protected string $status = ''; +} +``` + +This affects both mass assignment and direct writes: + +- `fill([...])` accepts only declared properties +- `setAttribute()` and `$model->property = ...` accept only declared properties +- unknown attributes throw an exception +- `#[Internal]` properties are excluded from model field handling + +## Metadata Properties + +These properties control how the model behaves: + +- `$table`: required table name +- `$primaryKey`: primary key column, defaults to `id` +- `$connection`: optional connection alias + +Example with a custom primary key: + +```php +final class ExternalUser extends Model +{ + protected string $table = 'external_users'; + protected string $primaryKey = 'uuid'; + + protected ?string $uuid = null; + protected string $email = ''; +} +``` + +## Basic Actions + +```php +$user = User::create([ + 'email' => 'john@example.com', + 'status' => 'active', +]); + +$found = User::find(1); +$required = User::findOrFail(1); + +$users = User::all(); + +$user->status = 'inactive'; +$user->save(); + +$user->delete(); +``` + +Useful query helpers: + +```php +$users = User::query() + ->where('status', '=', 'active') + ->orderBy('id', 'DESC') + ->limit(10) + ->get(); + +$first = User::query()->where('status', '=', 'active')->first(); +$exists = User::query()->where('status', '=', 'active')->exists(); +``` + +## Guarded, Hidden, and Internal Attributes + +```php +use Myxa\Database\Attributes\Guarded; +use Myxa\Database\Attributes\Hidden; +use Myxa\Database\Attributes\Internal; + +final class SecureUser extends Model +{ + protected string $table = 'users'; + + protected ?int $id = null; + protected string $email = ''; + protected string $status = ''; + + #[Guarded] + #[Hidden] + protected ?string $password_hash = null; + + #[Internal] + protected string $helperLabel = 'draft'; +} +``` + +- `#[Guarded]`: skipped by `fill([...])`, but trusted code can still set it directly +- `#[Hidden]`: omitted from `toArray()` and `toJson()` +- `#[Internal]`: not treated as a persisted model field at all + +## Casting + +The built-in casts currently support datetime values. + +```php +use DateTimeImmutable; +use Myxa\Database\Attributes\Cast; +use Myxa\Database\Model\CastType; final class User extends Model { - #[Hook(HookEvent::BeforeSave)] - protected function normalizeEmail(): void + protected string $table = 'users'; + + protected string $email = ''; + protected string $status = ''; + + #[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)] + protected ?DateTimeImmutable $created_at = null; + + #[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)] + protected ?DateTimeImmutable $updated_at = null; +} +``` + +Behavior: + +- hydrated string values are cast into `DateTime` or `DateTimeImmutable` +- serialized output converts them back to strings +- the cast format controls both parsing and serialization + +## Extra Attributes + +There is no public `extra()` API on models. + +The model is strict during normal writes: + +- `fill([...])` rejects unknown attributes +- `setAttribute()` rejects unknown attributes + +But hydrated rows may still contain additional storage columns. Those values are available through `getAttribute()` and are included in serialization unless hidden. + +```php +$user = ExternalUser::hydrate([ + 'uuid' => 'abc-1', + 'email' => 'external@example.com', + 'computed_label' => 'External', +]); + +$user->getAttribute('computed_label'); // 'External' +$user->toArray()['computed_label']; // 'External' +``` + +## Relationships + +Relationships are declared as methods returning `hasOne()`, `hasMany()`, or `belongsTo()`. + +```php +use Myxa\Database\Model\Model; +use Myxa\Database\Model\ModelQuery; + +final class User extends Model +{ + protected string $table = 'users'; + protected ?int $id = null; + protected string $email = ''; + protected string $status = ''; + + public function profile(): ModelQuery { - $this->email = strtolower(trim($this->email)); + return $this->hasOne(Profile::class); } - #[Hook(HookEvent::AfterSave)] - protected function rememberAuditEntry(): void + public function posts(): ModelQuery { - // custom post-save logic + return $this->hasMany(Post::class); + } +} + +final class Post extends Model +{ + protected string $table = 'posts'; + protected ?int $id = null; + protected ?int $user_id = null; + protected string $title = ''; + + public function user(): ModelQuery + { + return $this->belongsTo(User::class); } } ``` -Hook methods can live directly on the model, but traits are often the nicest way to keep models small while still attaching the callbacks to the model lifecycle: +Default keys are inferred from model names, but you can pass explicit key names into `hasOne()`, `hasMany()`, and `belongsTo()`. + +### Lazy Loading + +Relation methods return relation queries: ```php -use App\Models\Concerns\UserHooks; +$profile = $user->profile()->first(); +$posts = $user->posts()->orderBy('id')->get(); +$owner = $post->user()->first(); +``` -final class User extends Model -{ - use UserHooks; -} +### Eager Loading + +Use `with()` on the model query to preload relations, including nested paths: + +```php +$users = User::query() + ->with('profile', 'posts.comments') + ->orderBy('id') + ->get(); +``` + +Loaded relations can be checked or accessed with: + +```php +$user->relationLoaded('profile'); +$user->getRelation('profile'); +``` + +Eager-loaded relations are included automatically in `toArray()` and `toJson()`. + +## Array and JSON Serialization + +`toArray()` returns model attributes plus any loaded relations: + +```php +$payload = $user->toArray(); +``` + +Example output: + +```php +[ + 'id' => 1, + 'email' => 'john@example.com', + 'status' => 'active', + 'created_at' => '2026-04-01T10:00:00+00:00', + 'updated_at' => '2026-04-01T10:05:00+00:00', +] ``` +`toJson()` uses the same serializable payload: + ```php -namespace App\Models\Concerns; +$json = $user->toJson(); +``` + +The model also implements `JsonSerializable`, so `json_encode($user)` uses the same output. + +## Cloning + +Cloning a model creates a new unsaved copy: +```php +$user = User::findOrFail(1); + +$copy = clone $user; +$copy->email = 'copy@example.com'; +$copy->save(); +``` + +When cloned: + +- the model becomes non-persisted +- the primary key is cleared +- loaded relations are cleared + +## Hooks + +Use `#[Hook(...)]` on model methods to run code around persistence: + +```php use Myxa\Database\Attributes\Hook; use Myxa\Database\Model\HookEvent; -trait UserHooks +final class User extends Model { #[Hook(HookEvent::BeforeSave)] protected function normalizeEmail(): void @@ -79,8 +319,6 @@ trait UserHooks } ``` -This works because trait methods become part of the model class, so they are discovered the same way as methods declared directly on the model. - Available hook events: - `HookEvent::BeforeSave` @@ -90,11 +328,11 @@ Available hook events: - `HookEvent::BeforeDelete` - `HookEvent::AfterDelete` -`save()` handles both inserts and updates. For existing models, `save()` will run the save hooks and the update hooks. +`save()` handles both inserts and updates. For existing models, `save()` runs both the save hooks and the update hooks. ## Change Tracking -Models keep a lightweight snapshot of their last known persisted state so hooks and application code can inspect diffs. +Models keep a snapshot of their last known persisted state so hooks and application code can inspect diffs. Available helpers: @@ -124,33 +362,9 @@ protected function auditStatusChange(): void During `AfterSave`, `AfterUpdate`, and `AfterDelete` hooks, `getOriginal()` still exposes the pre-write values and `getChanges()` contains the values written or removed by the operation. After the hooks finish, the model syncs its original snapshot to the latest persisted state. -## Querying - -```php -$user = User::find(1); - -$users = User::query() - ->where('status', '=', 'active') - ->orderBy('id', 'DESC') - ->limit(10) - ->get(); -``` - -## Persisting - -```php -$user = User::create([ - 'email' => 'john@example.com', - 'status' => 'active', -]); - -$user->status = 'inactive'; -$user->save(); -``` - ## Notes -- only declared properties are accepted as model attributes -- `$table`, `$primaryKey`, and `$connection` are standard metadata properties +- only declared properties are accepted during normal model assignment - `HasTimestamps` manages `created_at` and `updated_at` -- date casting is available through the `#[Cast(...)]` attribute +- models can use a shared manager or a model-specific `$connection` +- relation methods must return a `Relation`/`ModelQuery` built from `hasOne()`, `hasMany()`, or `belongsTo()` diff --git a/src/Database/Query/README.md b/src/Database/Query/README.md index fcc9f31..2e3fbf2 100644 --- a/src/Database/Query/README.md +++ b/src/Database/Query/README.md @@ -21,8 +21,7 @@ $query = DB::query() ->orderBy('id', 'DESC') ->limit(10); -$sql = $query->toSql(); -$bindings = $query->getBindings(); +$rows = DB::select($query->toSql(), $query->getBindings()); ``` ## Joins diff --git a/src/Database/README.md b/src/Database/README.md index 21ccf2f..2e73135 100644 --- a/src/Database/README.md +++ b/src/Database/README.md @@ -2,12 +2,19 @@ The database layer is split into a few focused parts: -- [Connection](./Connection/): PDO-backed connection configuration and registry -- [Query](./Query/README.md): fluent query builder with driver-aware SQL grammars +- [Connection](./Connection/README.md): PDO-backed connection configuration and registry +- [Query Builder](./Query/README.md): fluent query builder with driver-aware SQL grammars - [Model](./Model/README.md): active-record style models with declared properties - [Schema](./Schema/README.md): schema builder, reverse engineering, snapshots, and diffing - [Migrations](./Migrations/README.md): migration base class and schema-first workflow +Related, but separate: + +- [Mongo](../Mongo/README.md): document collections and Mongo-backed models +- [Redis](../Redis/README.md): lightweight key-value infrastructure used directly or through cache + +The examples below show the basic `DatabaseManager` usage with direct SQL strings. If you prefer fluent query construction, you can build SQL and bindings with [`query()`](./Query/README.md) and then execute them through the same manager methods. + ## Quick Start ```php @@ -15,12 +22,114 @@ use Myxa\Database\DatabaseManager; $db = new DatabaseManager('main'); -$users = $db->query() - ->select('id', 'email') - ->from('users') - ->where('status', '=', 'active') - ->orderBy('id') - ->getBindings(); +$rows = $db->select( + 'SELECT id, email FROM users WHERE status = ? ORDER BY id', + ['active'], +); +``` + +## Insert + +```php +$userId = $db->insert( + 'INSERT INTO users (email, status) VALUES (?, ?)', + ['john@example.com', 'active'], +); +``` + +## Update + +```php +$updatedRows = $db->update( + 'UPDATE users SET status = ? WHERE id = ?', + ['inactive', 1], +); +``` + +## Delete + +```php +$deletedRows = $db->delete( + 'DELETE FROM users WHERE id = ?', + [1], +); +``` + +## Statement + +Use `statement()` for SQL that should be executed but does not need fetched rows or an affected-row count: + +```php +$executed = $db->statement( + 'CREATE INDEX idx_users_status ON users (status)', +); +``` + +## DB Facade + +In application code, after registering `DatabaseServiceProvider`, you can use the `DB` facade for the same operations: + +```php +use Myxa\Support\Facades\DB; + +$rows = DB::select( + 'SELECT id, email FROM users WHERE status = ? ORDER BY id', + ['active'], +); + +$userId = DB::insert( + 'INSERT INTO users (email, status) VALUES (?, ?)', + ['john@example.com', 'active'], +); + +$updatedRows = DB::update( + 'UPDATE users SET status = ? WHERE id = ?', + ['inactive', $userId], +); + +$deletedRows = DB::delete( + 'DELETE FROM users WHERE id = ?', + [$userId], +); +``` + +## Transactions + +Use `transaction()` when you want automatic commit and rollback handling: + +```php +$db->transaction(function () use ($db): void { + $userId = $db->insert( + 'INSERT INTO users (email, status) VALUES (?, ?)', + ['john@example.com', 'active'], + ); + + $db->insert( + 'INSERT INTO profiles (user_id, display_name) VALUES (?, ?)', + [$userId, 'John'], + ); +}); +``` + +If an exception is thrown inside the callback, the transaction is rolled back and the exception is rethrown. + +You can also manage transactions manually: + +```php +$db->beginTransaction(); + +try { + $db->update( + 'UPDATE users SET status = ? WHERE id = ?', + ['inactive', 1], + ); + + $db->commit(); +} catch (\Throwable $exception) { + $db->rollBack(); + + throw $exception; +} ``` -In application code, register `DatabaseServiceProvider` and use the shared manager or `DB` facade. +For fluent SQL construction, use [`query()`](./Query/README.md) to build SQL and bindings before passing them into `select()`, `insert()`, `update()`, or `delete()`. diff --git a/src/Database/Schema/README.md b/src/Database/Schema/README.md index 3a90149..e394270 100644 --- a/src/Database/Schema/README.md +++ b/src/Database/Schema/README.md @@ -43,19 +43,91 @@ DB::schema()->statement( ## Reverse Engineer a Table +Reverse engineering is the bridge from an existing database back into framework-managed code. + +It is useful when: + +- you are adopting Myxa in a project that already has tables +- you inherited a legacy database without clean migration history +- you want to create a baseline migration from a live schema +- you want to snapshot a known-good schema and later detect drift +- you want to scaffold models from an existing table definition + +In practice, reverse engineering lets you inspect the live database and turn that structure into framework-native artifacts such as migrations, snapshots, diffs, and models. + ```php $table = DB::schema()->reverseEngineer()->table('users'); $source = DB::schema()->reverseEngineer()->migration('users', 'CreateUsersTable'); ``` -## Save and Compare Snapshots +The most common workflow is: + +1. inspect a live table +2. generate a migration that recreates it +3. save a snapshot for later comparison +4. diff old and new versions when the live schema changes + +## Create a Migration from an Existing Table + +If you already have a live table and want to generate a migration class from it: ```php -$reverse = DB::schema()->reverseEngineer(); +use Myxa\Support\Facades\DB; + +$source = DB::schema() + ->reverseEngineer() + ->migration('users', 'CreateUsersTable'); + +file_put_contents( + database_path('migrations/2026_04_09_000000_create_users_table.php'), + $source, +); +``` + +This inspects the current `users` table and generates a migration source file that recreates it. + +This is especially useful when your database already exists and you want to bring it under version control through generated migration files instead of rewriting the schema by hand. + +## Reverse Engineering Workflow + +### 1. Inspect a Live Table + +Use `table()` when you want normalized metadata for one table: + +```php +$users = DB::schema()->reverseEngineer()->table('users'); +``` + +This gives you a structured representation of columns, indexes, and foreign keys. + +### 2. Generate a Baseline Migration + +Use `migration()` when you want a create-migration source file from a live table: + +```php +$source = DB::schema()->reverseEngineer()->migration('users', 'CreateUsersTable'); +``` + +This is the best starting point when a project has an existing schema but no reliable migration history. + +### 3. Capture a Snapshot + +Use `snapshot()` when you want to record the current schema state of the connection: + +```php +$snapshot = DB::schema()->reverseEngineer()->snapshot(); -$snapshot = $reverse->snapshot(); file_put_contents('database/schema/main.json', $snapshot->toJson()); +``` +Snapshots are useful as a baseline for future comparisons. + +### 4. Detect Changes and Generate an Alter Migration + +Later, after the live schema changes, compare the stored snapshot with the current table: + +```php +$reverse = DB::schema()->reverseEngineer(); $stored = $reverse->snapshotFromJson(file_get_contents('database/schema/main.json')); $live = $reverse->table('users'); @@ -63,13 +135,45 @@ $diff = $reverse->diff($stored->table('users'), $live); $migration = $reverse->alterMigration($stored->table('users'), $live, 'AlterUsersTable'); ``` -## Generate Models +This is useful when: + +- a database was changed manually outside the normal migration flow +- you need to understand what changed between two schema states +- you want Myxa to generate an alter migration from the detected differences + +### 5. Generate a Model from a Migration + +If you already have a migration class, you can generate a model skeleton from its create blueprint: ```php -$fromTable = DB::schema()->modelFromTable('posts', 'App\\Models\\Post'); -$fromMigration = DB::schema()->modelFromMigration($migration, 'App\\Models\\Post'); +$source = DB::schema()->modelFromMigration($migration, 'App\\Models\\User'); ``` +This is useful when your schema is already expressed as migrations and you want to scaffold model classes from that source of truth. + +### 6. Generate a Model from a Live Table + +You can also turn an existing table into a model skeleton directly: + +```php +$source = DB::schema()->modelFromTable('users', 'App\\Models\\User'); +``` + +That makes reverse engineering useful not only for schema recovery, but also for bootstrapping application code around an existing database. + +## Save and Compare Snapshots + +```php +$reverse = DB::schema()->reverseEngineer(); + +$snapshot = $reverse->snapshot(); +file_put_contents('database/schema/main.json', $snapshot->toJson()); + +$stored = $reverse->snapshotFromJson(file_get_contents('database/schema/main.json')); +``` + +Use snapshots when you want to preserve a known schema baseline for later comparison. + ## Notes - SQLite reverse engineering has type-affinity limits, so some original type intent may come back as `integer` or `text` diff --git a/src/Middleware/README.md b/src/Middleware/README.md index 3a6826a..43e6209 100644 --- a/src/Middleware/README.md +++ b/src/Middleware/README.md @@ -15,7 +15,7 @@ use Myxa\Support\Facades\Route; use Myxa\Middleware\AuthMiddleware; Route::get('/dashboard', [DashboardController::class, 'show']) - ->middleware(AuthMiddleware::for('web')); + ->middleware(AuthMiddleware::using('web')); ``` ## Group Usage diff --git a/src/Mongo/README.md b/src/Mongo/README.md index d359010..6d0d695 100644 --- a/src/Mongo/README.md +++ b/src/Mongo/README.md @@ -2,9 +2,88 @@ Mongo support is intentionally separate from the SQL database layer. -Use `MongoModel` when you want the same declared-property, attribute, hook, and dirty-tracking style as SQL models, but backed by document collections instead of tables. +Conceptually, this module sits closer to the model layer than to the SQL query builder: -## Example +- if you want SQL tables, query builder, and `Model`, see [Database](../Database/README.md) +- if you want document collections with declared-property models, use `MongoModel` + +Available facade: + +- `Mongo` + +## What It Provides + +The Mongo layer includes: + +- `MongoManager` for named connections +- `MongoConnection` for registered collections +- `MongoCollectionInterface` as the low-level collection contract +- `InMemoryMongoCollection` for tests and local experiments +- `MongoModel` for document-backed models with declared properties, hooks, casting, and dirty tracking + +## Use MongoManager Directly + +```php +use Myxa\Mongo\Connection\InMemoryMongoCollection; +use Myxa\Mongo\Connection\MongoConnection; +use Myxa\Mongo\MongoManager; + +$users = new InMemoryMongoCollection(); +$users->insertOne([ + '_id' => 1, + 'email' => 'john@example.com', + 'status' => 'active', +]); + +$mongo = new MongoManager('main'); +$mongo->addConnection('main', new MongoConnection([ + 'users' => $users, +])); + +$document = $mongo->collection('users')->findOne(['_id' => 1]); +``` + +## Connection and Collection Model + +`MongoManager` resolves named connections, and each `MongoConnection` resolves named collections: + +```php +$connection = $mongo->connection('main'); +$collection = $mongo->collection('users', 'main'); +``` + +You can register connections eagerly or lazily: + +```php +$mongo->addConnection('main', new MongoConnection([ + 'users' => new InMemoryMongoCollection(), +])); + +$mongo->addConnection('archive', fn (): MongoConnection => new MongoConnection([ + 'users' => new InMemoryMongoCollection(), +])); +``` + +## Collection Operations + +The low-level collection contract is intentionally small: + +```php +$collection->findOne(['_id' => 1]); +$collection->insertOne(['email' => 'jane@example.com']); +$collection->updateOne(['_id' => 1], ['_id' => 1, 'email' => 'updated@example.com']); +$collection->deleteOne(['_id' => 1]); +``` + +This is useful when: + +- you want direct document access without a model class +- you are building adapters around a custom collection implementation +- you are testing Mongo-backed logic with `InMemoryMongoCollection` + +## MongoModel + +`MongoModel` gives you a model-style API for document collections. ```php use Myxa\Mongo\MongoModel; @@ -13,15 +92,180 @@ final class UserDocument extends MongoModel { protected string $collection = 'users'; + // Mongo uses _id by default. protected string|int|null $_id = null; + protected string $email = ''; protected string $status = ''; } ``` +When you use `MongoModel` outside `MongoServiceProvider`, point it at a shared manager first: + +```php +UserDocument::setManager($mongo); +``` + +### Basic Actions + +```php +UserDocument::setManager($mongo); + +$user = UserDocument::create([ + 'email' => 'john@example.com', + 'status' => 'active', +]); + +$found = UserDocument::find($user->getKey()); + +$found->status = 'inactive'; +$found->save(); + +$found->delete(); +``` + +### Declared Properties Still Matter + +Like SQL `Model`, `MongoModel` is strict about declared fields: + +- `fill([...])` accepts only declared properties +- `setAttribute()` and `$model->property = ...` accept only declared properties +- unknown attributes throw an exception +- `#[Internal]` properties stay outside document persistence + +### Metadata Properties + +These properties control how the model behaves: + +- `$collection`: required collection name +- `$primaryKey`: defaults to `_id` +- `$connection`: optional connection alias + +Example: + +```php +final class ConnectedUserDocument extends MongoModel +{ + protected string $collection = 'users'; + protected ?string $connection = 'mongo-main'; + + protected string|int|null $_id = null; + protected string $email = ''; +} +``` + +### Casting, Hidden, Guarded, Hooks + +`MongoModel` reuses the same attribute metadata system as SQL models: + +- `#[Cast(...)]` +- `#[Hidden]` +- `#[Guarded]` +- `#[Internal]` +- `#[Hook(...)]` + +Example: + +```php +use DateTimeImmutable; +use Myxa\Database\Attributes\Cast; +use Myxa\Database\Attributes\Guarded; +use Myxa\Database\Attributes\Hidden; +use Myxa\Database\Model\CastType; + +final class SecureUserDocument extends MongoModel +{ + protected string $collection = 'users'; + protected string|int|null $_id = null; + protected string $email = ''; + + #[Guarded] + #[Hidden] + protected ?string $secret = null; + + #[Cast(CastType::DateTimeImmutable, format: DATE_ATOM)] + protected ?DateTimeImmutable $created_at = null; +} +``` + +### Dirty Tracking and Serialization + +`MongoModel` supports the same style of helpers as SQL models: + +- `getOriginal()` +- `getDirty()` +- `isDirty()` +- `getChanges()` +- `wasChanged()` +- `toArray()` +- `toJson()` + +Example: + +```php +$user = UserDocument::find(1); +$user->status = 'archived'; + +$dirty = $user->getDirty(); +$user->save(); +$changes = $user->getChanges(); +$json = $user->toJson(); +``` + +### Read-Only and Clone Behavior + +```php +$user->setReadOnly(); +$user->save(); // false +$user->delete(); // false + +$copy = clone $user; +``` + +When cloned: + +- the document becomes non-persisted +- the primary key is cleared +- the clone can be saved as a new document + +## Use the Facade Through the Service Provider + +In application code, register `MongoServiceProvider` to expose the shared manager and initialize the facade and `MongoModel` manager: + +```php +use Myxa\Application; +use Myxa\Mongo\Connection\InMemoryMongoCollection; +use Myxa\Mongo\Connection\MongoConnection; +use Myxa\Mongo\MongoServiceProvider; + +$app = new Application(); + +$app->register(new MongoServiceProvider( + connections: [ + 'main' => new MongoConnection([ + 'users' => new InMemoryMongoCollection(), + ]), + ], + defaultConnection: 'main', +)); + +$app->boot(); +``` + +Then use the facade: + +```php +use Myxa\Support\Facades\Mongo; + +$connection = Mongo::connection(); +$collection = Mongo::collection('users'); +$document = Mongo::collection('users')->findOne(['_id' => 1]); +``` + ## Notes -- `MongoModel` uses `$collection` instead of `$table` +- `MongoModel` is document-backed and does not provide SQL-style relations or a SQL query builder +- `find()` is the main lookup helper on the current implementation - the default primary key is `_id` -- hooks, casts, hidden fields, guarded fields, and dirty tracking work the same way as SQL models -- this initial implementation focuses on `find()`, `create()`, `save()`, and `delete()` +- `InMemoryMongoCollection` is a practical default for tests and local experiments +- for SQL-backed models, see [Database Model](../Database/Model/README.md) diff --git a/src/RateLimit/README.md b/src/RateLimit/README.md index d6a8b1c..de3a0b7 100644 --- a/src/RateLimit/README.md +++ b/src/RateLimit/README.md @@ -27,7 +27,7 @@ if (!$result->allowed) { use Myxa\Support\Facades\Route; Route::get('/api/users', [UserController::class, 'index']) - ->middleware(\Myxa\Middleware\RateLimitMiddleware::for(60, 60, 'api')); + ->middleware(\Myxa\Middleware\RateLimitMiddleware::using(60, 60, 'api')); ``` ## Notes diff --git a/src/Redis/README.md b/src/Redis/README.md index 3bffede..e3572c4 100644 --- a/src/Redis/README.md +++ b/src/Redis/README.md @@ -1,68 +1,179 @@ # Redis -Redis support is a lightweight key-value layer, separate from SQL and Mongo. +Redis support is a lightweight key-value layer. -## Setup +Conceptually, this module is infrastructure-oriented: -You need to create a `RedisManager`, register at least one connection, and then use that manager directly or through the facade. +- use it directly when you want simple Redis-style key/value access +- use [Cache](../Cache/README.md) when you want cache-store semantics on top of Redis +- use [Database](../Database/README.md) for SQL data access -### Real Redis connection +Available facade: + +- `Redis` + +## What It Provides + +The Redis layer includes: + +- `RedisManager` for named connections +- `RedisConnection` for one concrete backend +- `RedisStoreInterface` as the backend contract +- `InMemoryRedisStore` for tests and local development +- `PhpRedisStore` for real Redis servers through the `phpredis` extension + +## Use RedisManager Directly + +### In-Memory Redis + +Useful for tests, local scripts, and places where you do not want a real Redis server: + +```php +use Myxa\Redis\Connection\InMemoryRedisStore; +use Myxa\Redis\Connection\RedisConnection; +use Myxa\Redis\RedisManager; + +$redis = new RedisManager('main', new RedisConnection(new InMemoryRedisStore())); +``` + +### Real Redis with `phpredis` ```php use Myxa\Redis\Connection\PhpRedisStore; use Myxa\Redis\Connection\RedisConnection; use Myxa\Redis\RedisManager; -$manager = new RedisManager('main', new RedisConnection( +$redis = new RedisManager('main', new RedisConnection( new PhpRedisStore( - host: 'redis', + host: '127.0.0.1', port: 6379, + timeout: 2.0, database: 0, + password: null, ), )); ``` -### In-memory connection +## Basic Operations -This is useful for tests and local experiments when you do not want a real Redis server. +```php +$redis->set('visits', 1); +$redis->increment('visits'); + +$count = $redis->get('visits'); // 2 +$exists = $redis->has('visits'); +$redis->delete('visits'); +``` + +Supported value types are: + +- `string` +- `int` +- `float` +- `bool` +- `null` + +## Multiple Connections + +You can register multiple named Redis connections: ```php -use Myxa\Redis\RedisManager; -use Myxa\Redis\Connection\InMemoryRedisStore; -use Myxa\Redis\Connection\RedisConnection; +$redis = new RedisManager('main', new RedisConnection(new InMemoryRedisStore())); + +$redis->addConnection('sessions', new RedisConnection(new InMemoryRedisStore())); + +$redis->set('token', 'abc', connection: 'sessions'); +$token = $redis->get('token', 'sessions'); +``` + +Connections can also be registered lazily with a factory closure: + +```php +$redis->addConnection('cache', fn (): RedisConnection => new RedisConnection( + new InMemoryRedisStore(), +)); +``` + +You can inspect or change the default connection: -$manager = new RedisManager('main', new RedisConnection(new InMemoryRedisStore())); +```php +$redis->getDefaultConnection(); +$redis->setDefaultConnection('sessions'); +$connection = $redis->connection(); ``` -## Usage +## Connection and Store Layers -Once the connection is registered, you can read and write values through the manager. +`RedisManager` resolves `RedisConnection` instances, and each `RedisConnection` wraps one concrete store backend: ```php -$manager->set('visits', 1); -$manager->increment('visits'); -$count = $manager->get('visits'); // 2 +$connection = $redis->connection('main'); +$store = $connection->store(); ``` -## Facade +This is useful when: + +- you want low-level access to the underlying backend +- you want to build infrastructure on top of Redis +- you need backend-specific behavior like `PhpRedisStore::flush()` -If you prefer the facade, set the manager first and then call Redis operations through it. +## Provider and Facade Usage + +In application code, register `RedisServiceProvider` to expose the shared manager and initialize the facade: ```php -use Myxa\Support\Facades\Redis; +use Myxa\Application; +use Myxa\Redis\Connection\InMemoryRedisStore; +use Myxa\Redis\Connection\RedisConnection; +use Myxa\Redis\RedisServiceProvider; + +$app = new Application(); + +$app->register(new RedisServiceProvider( + connections: [ + 'main' => new RedisConnection(new InMemoryRedisStore()), + ], + defaultConnection: 'main', +)); + +$app->boot(); +``` -Redis::setManager($manager); +Then use the facade: + +```php +use Myxa\Support\Facades\Redis; Redis::set('visits', 1); -Redis::increment('visits'); -$count = Redis::get('visits'); // 2 +Redis::increment('visits', 2); +$count = Redis::get('visits'); // 3 + +$connection = Redis::connection(); ``` +## Registry Helpers + +`RedisConnection` also supports a small static alias registry: + +```php +use Myxa\Redis\Connection\InMemoryRedisStore; +use Myxa\Redis\Connection\RedisConnection; + +RedisConnection::register('main', new RedisConnection(new InMemoryRedisStore()), true); + +$connection = RedisConnection::get('main'); +$exists = RedisConnection::has('main'); + +RedisConnection::unregister('main'); +``` + +This is useful when you want process-wide fallback connection aliases. + ## Notes -- dedicated `RedisConnection` and `RedisManager` -- constructor can register the default connection directly -- facade support via `Myxa\Support\Facades\Redis` -- initial commands: `get`, `set`, `delete`, `has`, `increment` -- `PhpRedisStore` uses the `phpredis` extension -- includes an in-memory store for tests and local development experiments +- this is not a document or relational model layer; it is a small key-value abstraction +- `increment()` requires the key to contain an integer value +- `PhpRedisStore` requires the `phpredis` extension +- `PhpRedisStore` preserves primitive value types by encoding them before storage +- `InMemoryRedisStore` is ideal for tests and lightweight experiments +- for cache-store semantics built on Redis, see [Cache](../Cache/README.md) diff --git a/src/Routing/README.md b/src/Routing/README.md index 34f6978..c03f316 100644 --- a/src/Routing/README.md +++ b/src/Routing/README.md @@ -19,20 +19,106 @@ Route::get('/health', static function () { Route::post('/users', [UserController::class, 'store']); ``` +## HTTP Methods + +The router supports the common HTTP verbs directly: + +```php +Route::get('/posts', [PostController::class, 'index']); +Route::post('/posts', [PostController::class, 'store']); +Route::put('/posts/{id}', [PostController::class, 'replace']); +Route::patch('/posts/{id}', [PostController::class, 'update']); +Route::delete('/posts/{id}', [PostController::class, 'destroy']); +Route::options('/posts', [PostController::class, 'options']); +Route::head('/posts', [PostController::class, 'head']); +``` + +Typical use: + +- `put()` for full replacement updates +- `patch()` for partial updates +- `delete()` for record removal +- `options()` for capability/preflight responses +- `head()` for header-only responses + +## Match Multiple Methods + +Use `match()` when one route should accept several methods: + +```php +Route::match(['GET', 'POST'], '/search', [SearchController::class, 'handle']); +``` + +Use `any()` when a route should accept the common HTTP methods: + +```php +Route::any('/webhook', [WebhookController::class, 'handle']); +``` + +## Route Parameters + +Routes support named path parameters: + +```php +Route::get('/users/{id}', [UserController::class, 'show']); +Route::get('/posts/{postId}/comments/{commentId}', [CommentController::class, 'show']); +``` + +Handler arguments are resolved from the route parameters: + +```php +Route::get('/users/{id}', static function (string $id) { + return "User {$id}"; +}); +``` + +## Route Middleware + +Middleware can be attached directly to a route: + +```php +use Myxa\Middleware\AuthMiddleware; + +Route::delete('/posts/{id}', [PostController::class, 'destroy']) + ->middleware(AuthMiddleware::using('api')); + +Route::get('/dashboard', [DashboardController::class, 'show']) + ->middleware(AuthMiddleware::using('web')); +``` + ## Route Groups +Use groups to share a common path prefix: + ```php Route::group('/api', static function (): void { Route::get('/users', [UserController::class, 'index']); Route::get('/users/{id}', [UserController::class, 'show']); + Route::post('/users', [UserController::class, 'store']); }); ``` +You can also attach middleware to the whole group: + +```php +use Myxa\Middleware\AuthMiddleware; + +Route::group('/api', static function (): void { + Route::get('/me', [ProfileController::class, 'show']); + Route::patch('/me', [ProfileController::class, 'update']); +}, [AuthMiddleware::using('api')]); +``` + ## Middleware Groups +Use `middleware()` when you want shared middleware without adding a path prefix: + ```php -Route::middleware(['auth'], static function (): void { +use Myxa\Middleware\AuthMiddleware; + +Route::middleware([AuthMiddleware::using('web')], static function (): void { Route::get('/dashboard', [DashboardController::class, 'show']); + Route::get('/settings', [SettingsController::class, 'show']); }); ``` @@ -44,8 +130,19 @@ use Myxa\Support\Facades\Route; $result = Route::dispatch(); ``` +You can also inspect routes directly: + +```php +$exists = Route::has('GET', '/health'); +$route = Route::find('GET', '/health'); +$allRoutes = Route::routes(); +``` + ## Notes - routes support path parameters like `/users/{id}` +- handlers can be closures, object methods, or `[Controller::class, 'method']` - middleware can be attached per route or by group +- auth middleware can be attached with `AuthMiddleware::using('web')` or `AuthMiddleware::using('api')` +- `match()` and `any()` are useful for shared endpoints like search pages and webhooks - `RouteServiceProvider` registers the shared router and initializes the `Route` facade diff --git a/src/Storage/README.md b/src/Storage/README.md index 3c0fda2..d565fba 100644 --- a/src/Storage/README.md +++ b/src/Storage/README.md @@ -6,7 +6,46 @@ Available facade: - `Storage` -## Basic Usage +## Use StorageManager Directly + +If you do not want to register `StorageServiceProvider`, you can work with `StorageManager` directly: + +```php +use Myxa\Storage\StorageManager; +use Myxa\Storage\Local\LocalStorage; + +$storage = new StorageManager('local'); +$storage->addStorage('local', new LocalStorage(__DIR__ . '/storage')); + +$stored = $storage->put('avatars/john.txt', 'hello'); +$contents = $storage->read('avatars/john.txt'); +$exists = $storage->exists('avatars/john.txt'); +``` + +This is the simplest option for standalone usage, tests, or small scripts. + +## Use the Facade After Registering the Provider + +In application code, register `StorageServiceProvider` so the shared manager is available through the facade: + +```php +use Myxa\Application; +use Myxa\Storage\Local\LocalStorage; +use Myxa\Storage\StorageServiceProvider; + +$app = new Application(); + +$app->register(new StorageServiceProvider( + storages: [ + 'local' => new LocalStorage(__DIR__ . '/storage'), + ], + defaultStorage: 'local', +)); + +$app->boot(); +``` + +Then use the facade: ```php use Myxa\Support\Facades\Storage; @@ -19,9 +58,11 @@ $exists = Storage::exists('avatars/john.txt'); ## Upload Files +With the facade: + ```php -use Myxa\Support\Facades\Storage; use Myxa\Support\Facades\Request; +use Myxa\Support\Facades\Storage; $stored = Storage::upload( Request::file('avatar'), @@ -30,15 +71,56 @@ $stored = Storage::upload( ); ``` +With the manager directly: + +```php +$stored = $storage->upload( + $_FILES['avatar'], + 'avatars', + ['allowed_extensions' => ['jpg', 'png']], +); +``` + ## Named Drivers +You can register multiple storage drivers and target them by alias. + +Using the facade: + ```php $stored = Storage::put('logs/app.log', 'message', storage: 'local'); $meta = Storage::get('logs/app.log', 'local'); ``` +Using the manager: + +```php +$stored = $storage->put('logs/app.log', 'message', storage: 'local'); +$meta = $storage->get('logs/app.log', 'local'); +``` + +## Register Drivers + +Example with multiple drivers: + +```php +use Myxa\Database\DatabaseManager; +use Myxa\Storage\Db\DatabaseStorage; +use Myxa\Storage\Local\LocalStorage; +use Myxa\Storage\StorageManager; + +$storage = new StorageManager('local'); + +$storage->addStorage('local', new LocalStorage(__DIR__ . '/storage')); +$storage->addStorage('db', new DatabaseStorage(manager: new DatabaseManager('main'))); +``` + +`addStorage()` also accepts a factory closure when you want lazy driver creation. + ## Notes +- `StorageManager` works without `StorageServiceProvider` - `StorageServiceProvider` registers the shared manager and initializes the facade +- the facade can also be pointed at a manager manually with `Storage::setManager(...)` - local and database-backed storage drivers are available in the framework - `StoredFile` metadata is returned for successful writes and lookups diff --git a/src/Validation/README.md b/src/Validation/README.md index d3d089d..c6eda9e 100644 --- a/src/Validation/README.md +++ b/src/Validation/README.md @@ -6,7 +6,16 @@ Available facade: - `Validator` -## Basic Usage +## What It Provides + +The validation layer gives you: + +- `ValidationManager` to create validators +- `Validator` to coordinate multiple fields +- `FieldValidator` for fluent rule configuration per field +- `ValidationException` for throw-on-failure workflows + +## Use ValidationManager Directly ```php use Myxa\Validation\ValidationManager; @@ -15,7 +24,11 @@ $validator = (new ValidationManager())->make([ 'email' => 'john@example.com', 'user_id' => 1, ]); +``` +Then configure rules field by field: + +```php $validator->field('email') ->required('Please provide an email address.') ->string() @@ -24,55 +37,232 @@ $validator->field('email') $validator->field('user_id') ->required() - ->integer() - ->exists(User::class, message: fn (mixed $value, string $field) => sprintf( - 'The %s [%s] does not exist.', - $field, - (string) $value, - )); - -$validated = $validator->validate(); + ->integer(); ``` -## Facade Usage +## Common Flow ```php -use Myxa\Support\Facades\Validator; - -$validator = Validator::make([ +$validator = (new ValidationManager())->make([ 'name' => 'John', 'email' => 'john@example.com', + 'notes' => null, ]); $validator->field('name')->required()->string()->min(2)->max(50); -$validator->field('email')->required()->string()->email(); +$validator->field('email')->required()->string()->email()->max(255); +$validator->field('notes')->nullable()->string(); if ($validator->fails()) { $errors = $validator->errors(); +} else { + $validated = $validator->validated(); } ``` +Or throw on failure: + +```php +$validated = $validator->validate(); +``` + ## Supported Fluent Rules +### Presence and Nullability + - `required()` - `nullable()` + +Behavior: + +- missing fields fail only when `required()` is configured +- `nullable()` allows an explicit `null` value +- when a field is `null` and `nullable()` is set, the remaining rules for that field are skipped + +### Type and Format Rules + - `string()` - `integer()` - `numeric()` - `boolean()` - `array()` - `email()` + +### Size Rules + - `min($value)` - `max($value)` -- `exists($source, ?string $column = null)` -Each rule also accepts an optional custom message: +The size meaning depends on the value type: + +- strings use string length +- arrays use item count +- numeric values use their numeric value + +## Exists Validation + +`exists()` can validate against several sources: + +- a SQL model class +- a Mongo model class +- an array of allowed values +- a custom callable returning `true` or `false` + +### SQL Model Example + +```php +$validator->field('user_id') + ->required() + ->integer() + ->exists(User::class); +``` + +You can also validate against a specific SQL model column: + +```php +$validator->field('email') + ->required() + ->string() + ->exists(User::class, 'email'); +``` + +### Mongo Model Example + +```php +$validator->field('document_id') + ->required() + ->exists(UserDocument::class); +``` + +For Mongo models, `exists()` only supports the model primary key. + +### Allowed Values Example + +```php +$validator->field('role')->exists(['admin', 'editor']); +``` + +### Custom Callback Example + +```php +$validator->field('code')->exists( + static fn (mixed $value): bool => in_array($value, ['A', 'B'], true), +); +``` + +## Custom Error Messages + +Each rule accepts an optional custom message: + +- string message +- callable message + +String example: -- string: `->required('Email is required.')` -- callable: `->max(255, fn (mixed $value, string $field) => 'Too long.')` +```php +$validator->field('name')->required('Name is mandatory.'); +``` + +Callable example: + +```php +$validator->field('email')->email( + static fn (mixed $value, string $field): string => sprintf( + '%s "%s" is invalid.', + $field, + (string) $value, + ), +); +``` + +## Validated Output + +`validated()` and `validate()` return only the configured fields that are present in the input data. + +```php +$validator = (new ValidationManager())->make([ + 'name' => 'John', + 'email' => 'john@example.com', + 'ignored' => 'value', +]); + +$validator->field('name')->required()->string(); +$validator->field('email')->required()->string()->email(); + +$validated = $validator->validate(); +``` + +Result: + +```php +[ + 'name' => 'John', + 'email' => 'john@example.com', +] +``` + +## Accessing Errors + +```php +if ($validator->fails()) { + $errors = $validator->errors(); +} +``` + +Error format: + +```php +[ + 'email' => [ + 'The email field must be a valid email address.', + ], +] +``` + +## Facade and Service Provider + +In application code, register `ValidationServiceProvider` to expose the shared manager and initialize the facade: + +```php +use Myxa\Application; +use Myxa\Validation\ValidationServiceProvider; + +$app = new Application(); +$app->register(new ValidationServiceProvider()); +$app->boot(); +``` + +Then use the facade: + +```php +use Myxa\Support\Facades\Validator; + +$validator = Validator::make([ + 'name' => 'John', + 'email' => 'john@example.com', +]); + +$validator->field('name')->required()->string(); +$validator->field('email')->required()->string()->email(); +``` + +## Exception Handling + +`validate()` throws `ValidationException` when validation fails: + +```php +use Myxa\Validation\Exceptions\ValidationException; + +try { + $validated = $validator->validate(); +} catch (ValidationException $exception) { + $errors = $exception->errors(); +} +``` ## Notes -- `ValidationServiceProvider` registers the shared validation manager and initializes the facade -- `exists()` supports SQL model classes, Mongo model classes, arrays of allowed values, and custom callables -- `validate()` throws `ValidationException` when validation fails +- the API is fluent and field-oriented rather than string-rule oriented +- `passes()` and `fails()` evaluate all configured fields and collect grouped errors +- `validate()` throws, while `validated()` returns the validated subset after a successful validation pass +- `exists()` supports SQL models, Mongo models, arrays of allowed values, and custom callables