From b5884fd04a8a68edd14ff169236cc033bb8ae69b Mon Sep 17 00:00:00 2001 From: Ken Date: Fri, 2 Jan 2026 17:12:53 +0100 Subject: [PATCH 01/60] fix: handle unloaded environment in app_env and replace deprecated double cast --- src/Database/Barry/Model.php | 2 +- src/Support/helpers.php | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 813ac981..f4b44271 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -921,7 +921,7 @@ public function __get(string $name): mixed return (float)$value; } if ($type === "double") { - return (double)$value; + return (float)$value; } if ($type === "json") { if (is_array($value)) { diff --git a/src/Support/helpers.php b/src/Support/helpers.php index b63ad27a..6d5e1b61 100644 --- a/src/Support/helpers.php +++ b/src/Support/helpers.php @@ -1186,10 +1186,14 @@ function __( */ function app_env(string $key, mixed $default = null): ?string { - $env = Env::getInstance(); + try { + $env = Env::getInstance(); - if ($env->isLoaded()) { - return $env->get($key, $default); + if ($env->isLoaded()) { + return $env->get($key, $default); + } + } catch (\Bow\Application\Exception\ApplicationException $e) { + // Environment not loaded, return default } return $default; From 11b8094a52377e767138f14ade58e76bf74d3d8e Mon Sep 17 00:00:00 2001 From: Ken Date: Fri, 2 Jan 2026 18:09:35 +0100 Subject: [PATCH 02/60] fix: resolve deprecation warnings and improve test stability --- .gitignore | 2 + ROADMAP.md | 207 +++++++++++++++++++++++++ src/Configuration/EnvConfiguration.php | 23 ++- src/Console/Console.php | 5 +- tests/Console/Stubs/CustomCommand.php | 6 +- 5 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 ROADMAP.md diff --git a/.gitignore b/.gitignore index 255f5fe6..b394cb87 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,5 @@ composer.lock .phpunit.result.cache bob .phpunit.cache +.vscode +.idea diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..6c5fbb60 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,207 @@ +# Roadmap BowPHP Framework + +> Document évolutif basé sur l'analyse du code source (branche 5.x) et le manifeste du projet. +> Dernière mise à jour : Janvier 2026 + +--- + +## État Actuel du Framework + +### Modules Existants (Analyse du `/src`) + +| Module | Statut | Description | +| ---------------------- | --------- | ---------------------------------------------- | +| **Application** | ✅ Stable | Bootstrap, exception handling, kernel | +| **Auth** | ✅ Stable | Guards (Session, JWT), Authentication | +| **Cache** | ✅ Stable | Adapters: Database, Filesystem, Redis | +| **Configuration** | ✅ Stable | Loader, Env, Logger configuration | +| **Console** | ✅ Stable | 26 commandes, générateurs, stubs | +| **Container** | ✅ Stable | DI container, middleware dispatcher | +| **Database/Barry ORM** | ✅ Stable | MySQL, PostgreSQL, SQLite + Relations | +| **Event** | ✅ Stable | Event dispatcher, listeners, queue integration | +| **Http** | ✅ Stable | Request, Response, Client, Exceptions | +| **Mail** | ✅ Stable | SMTP, Native adapters, queue support | +| **Messaging** | ✅ Stable | SMS, Mail, Slack, Telegram, Database channels | +| **Middleware** | ✅ Stable | Auth, CSRF, Base middleware | +| **Queue** | ✅ Stable | Beanstalkd, Database, SQS, Sync adapters | +| **Router** | ✅ Stable | REST methods, prefixes, middlewares, resources | +| **Security** | ✅ Stable | Crypto, Hash, Sanitize, Tokenize | +| **Session** | ✅ Stable | Cookie, File, Database, Redis adapters | +| **Storage** | ✅ Stable | Disk, FTP, S3 services | +| **Support** | ✅ Stable | Helpers, Collection, Str, Log, Env | +| **Testing** | ✅ Stable | TestCase, Assertions, KernelTesting | +| **Translate** | ✅ Stable | i18n support | +| **Validation** | ✅ Stable | Règles de validation, messages custom | +| **View** | ✅ Stable | Tintin (default), Twig support | + +### Dépendances Actuelles + +**Requises :** + +- PHP ^8.1 +- bowphp/tintin ^3.0 (template engine) +- filp/whoops ^2.1 (error handling) +- nesbot/carbon 3.8.4 (dates) +- fakerphp/faker ^1.20 (testing data) +- ramsey/uuid ^4.7 (UUIDs) + +**Dev/Suggérées :** + +- pda/pheanstalk ^5.0 (Beanstalkd) +- aws/aws-sdk-php ^3.87 (S3) +- bowphp/policier ^3.0 (JWT) +- predis/predis ^2.1 (Redis) +- twilio/sdk ^8.3 (SMS) +- bowphp/slack-webhook ^1.0 (Slack) + +--- + +## 🔴 NOW — 0 à 3 mois (Stabilisation & Consolidation) + +### Tests et CI/CD + +| Tâche | Statut | Priorité | Notes | +| --------------------------------------------------- | ---------- | -------- | ------------------------------------------------------ | +| Séparer les tests unitaires des tests d'intégration | ⏳ À faire | Haute | Les tests DB/FTP/S3 nécessitent des services externes | +| Ajouter `@group` PHPUnit pour isoler les tests | ⏳ À faire | Haute | `@group unit`, `@group integration`, `@group database` | +| Configurer GitHub Actions avec services Docker | ⏳ À faire | Haute | MySQL, PostgreSQL, Redis pour CI | +| Augmenter couverture tests unitaires > 80% | ⏳ À faire | Moyenne | Focus sur modules critiques | +| Intégrer PHPStan niveau 5+ | ⏳ À faire | Moyenne | Actuellement niveau 0.12.87 | + +### Corrections de Code + +| Tâche | Statut | Priorité | Notes | +| ----------------------------------------------- | ---------- | -------- | ------------------------------------- | +| Fixer les tests SQLite qui échouent (isolation) | ⏳ À faire | Haute | Problème de state partagé entre tests | +| Uniformiser les signatures de méthodes | ✅ Fait | - | PHP 8.1+ nullable types | +| Fixer le cast `(double)` → `(float)` | ✅ Fait | - | Model.php ligne 924 | +| Gérer `array_key_exists` avec clé null | ✅ Fait | - | Console.php | +| Créer le répertoire de test si inexistant | ✅ Fait | - | CustomCommand.php | + +### Documentation + +| Tâche | Statut | Priorité | Notes | +| -------------------------------------------- | ---------- | -------- | -------------------------- | +| Mettre à jour README avec exemples API-first | ⏳ À faire | Moyenne | Aligner avec le manifeste | +| Documenter les configurations requises | ⏳ À faire | Moyenne | Chaque module | +| Créer guide de contribution détaillé | ⏳ À faire | Basse | Au-delà du CONTRIBUTING.md | + +--- + +## 🟠 NEXT — 3 à 6 mois (Nouvelles Fonctionnalités) + +### Queue - Adapter Redis + +| Tâche | Statut | Priorité | Notes | +| ---------------------------------------- | ---------- | -------- | ------------------------------ | +| Créer `RedisAdapter` pour Queue | ⏳ À faire | Haute | predis/predis déjà en dev-deps | +| Implémenter delayed jobs avec Redis ZADD | ⏳ À faire | Haute | | +| Ajouter monitoring des queues via CLI | ⏳ À faire | Moyenne | `bow queue:status` | + +### Router - Attributs PHP 8 + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------------------------ | ---------- | -------- | --------------------- | +| Créer namespace `Bow\Router\Attributes` | ⏳ À faire | Haute | | +| Implémenter `#[Controller]` | ⏳ À faire | Haute | prefix, middleware | +| Implémenter `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ⏳ À faire | Haute | | +| Ajouter `$router->register(Controller::class)` | ⏳ À faire | Haute | Auto-discovery routes | + +### Cache - Adapter Memcached + +| Tâche | Statut | Priorité | Notes | +| --------------------------------------------- | ---------- | -------- | ----- | +| Créer `MemcachedAdapter` | ⏳ À faire | Moyenne | | +| Améliorer résilience Redis (reconnexion auto) | ⏳ À faire | Moyenne | | + +### Messaging - Push Notifications + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------ | ---------- | -------- | ------------- | +| Créer `FcmChannelAdapter` (Firebase) | ⏳ À faire | Moyenne | | +| Créer `ApnsChannelAdapter` (Apple) | ⏳ À faire | Moyenne | | +| Améliorer `TelegramChannelAdapter` | ⏳ À faire | Basse | Déjà existant | +| Améliorer `SlackChannelAdapter` | ⏳ À faire | Basse | Déjà existant | + +### Database + +| Tâche | Statut | Priorité | Notes | +| ----------------------------------------- | ---------- | -------- | ---------------------------- | +| Ajouter support SQL Server | ⏳ À faire | Moyenne | | +| Créer adapter Array/FileWriter pour tests | ⏳ À faire | Moyenne | Évite dépendance DB en tests | + +--- + +## 🟢 LATER — 6 à 12 mois (Vision Long Terme) + +### Performance et Modernisation + +| Tâche | Statut | Priorité | Notes | +| -------------------------------------------- | ---------- | -------- | -------------------------- | +| Support Swoole/FrankenPHP | ⏳ À faire | Moyenne | Serveurs non-bloquants | +| Images Docker officielles | ⏳ À faire | Moyenne | Optimisées pour production | +| Support serverless (Lambda, Cloud Functions) | ⏳ À faire | Basse | HTTP Handler adapté | + +### Écosystème + +| Tâche | Statut | Priorité | Notes | +| ------------------------------------------------ | ---------- | -------- | -------------------- | +| Package `bowphp/payment` | ⏳ À faire | Haute | Mobile money Afrique | +| Package `bowphp/logviewer` ou `bowphp/telescope` | ⏳ À faire | Moyenne | Observabilité | +| Adapter laravel-notify pour Bow | ⏳ À faire | Basse | UI notifications | + +### Observabilité + +| Tâche | Statut | Priorité | Notes | +| ------------------------------ | ---------- | -------- | ------------------------------- | +| Module OpenTelemetry optionnel | ⏳ À faire | Moyenne | Tracing requests, jobs, queries | +| Intégration Prometheus/Grafana | ⏳ À faire | Basse | Métriques production | + +--- + +## Légende + +- ✅ **Fait** : Tâche complétée +- ⏳ **À faire** : Tâche planifiée +- 🔄 **En cours** : Travail en progression +- ❌ **Annulé** : Tâche abandonnée + +--- + +## Comment Contribuer + +1. Choisir une tâche de la section **NOW** (priorité haute) +2. Ouvrir une issue pour discuter de l'implémentation +3. Créer une branche `feature/nom-de-la-tache` +4. Suivre les conventions du projet (voir CONTRIBUTING.md) +5. Soumettre une PR avec tests + +--- + +## Notes Importantes + +### Concernant les Tests + +Les erreurs actuelles lors de `composer test` sont principalement dues à : + +1. **Services externes non disponibles** (pas des bugs du framework) : + + - MySQL : Connection refused / Access denied + - PostgreSQL : Connection refused + - FTP : Connection refused + - S3 : Invalid endpoint + - Beanstalkd : Connection refused + +2. **Isolation des tests SQLite** : Certains tests partagent l'état de la base, causant des échecs intermittents. + +**Solution recommandée** : Séparer les tests en groupes (`@group unit`, `@group integration`) et configurer CI avec Docker Compose pour les tests d'intégration. + +### Philosophie du Projet + +Toute contribution doit respecter le manifeste : + +- **Simplicité** > Sophistication +- **Lisibilité** > Concision extrême +- **API-first** : Priorité aux backends JSON +- **Performance** : Bootstrap minimal, réponse rapide +- **Contrôle** : Le développeur garde le contrôle de son architecture diff --git a/src/Configuration/EnvConfiguration.php b/src/Configuration/EnvConfiguration.php index f4dd4d7d..e9693fb6 100644 --- a/src/Configuration/EnvConfiguration.php +++ b/src/Configuration/EnvConfiguration.php @@ -13,11 +13,24 @@ class EnvConfiguration extends Configuration */ public function create(Loader $config): void { - Env::configure(base_path('.env.json')); - - $event = Env::getInstance(); - - $this->container->instance('env', $event); + $envFile = $config->getBasePath() . '/.env.json'; + + // Only configure if file exists and environment is not already loaded + try { + $env = Env::getInstance(); + if ($env->isLoaded()) { + $this->container->instance('env', $env); + return; + } + } catch (\Bow\Application\Exception\ApplicationException $e) { + // Environment not loaded, continue to load it + } + + if (file_exists($envFile)) { + Env::configure($envFile); + $event = Env::getInstance(); + $this->container->instance('env', $event); + } } /** diff --git a/src/Console/Console.php b/src/Console/Console.php index 3b080b75..18e53734 100644 --- a/src/Console/Console.php +++ b/src/Console/Console.php @@ -243,8 +243,9 @@ public function call(?string $command): mixed if (!in_array($command, array_keys($commands))) { // Try to execute the custom command - if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) { - return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command); + $rawCommand = $this->arg->getRawCommand() ?? ''; + if (($rawCommand !== '' && array_key_exists($rawCommand, static::$registers)) || array_key_exists($command, static::$registers)) { + return $this->executeCustomCommand($rawCommand ?: $command); } } diff --git a/tests/Console/Stubs/CustomCommand.php b/tests/Console/Stubs/CustomCommand.php index 3053aad1..551d445d 100644 --- a/tests/Console/Stubs/CustomCommand.php +++ b/tests/Console/Stubs/CustomCommand.php @@ -8,6 +8,10 @@ class CustomCommand extends ConsoleCommand { public function process() { - file_put_contents(TESTING_RESOURCE_BASE_DIRECTORY . '/test_custom_command.txt', 'ok'); + $directory = TESTING_RESOURCE_BASE_DIRECTORY; + if (!is_dir($directory)) { + mkdir($directory, 0755, true); + } + file_put_contents($directory . '/test_custom_command.txt', 'ok'); } } From ada1bed73d92fb110ced1765039dabd5e43f3b57 Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 3 Jan 2026 00:22:50 +0100 Subject: [PATCH 03/60] feat(router): add php 8 attributes support for route definition --- src/Router/AttributeRouteRegistrar.php | 106 ++++++++++ src/Router/Attributes/Controller.php | 52 +++++ src/Router/Attributes/Delete.php | 27 +++ src/Router/Attributes/Get.php | 27 +++ src/Router/Attributes/Options.php | 27 +++ src/Router/Attributes/Patch.php | 27 +++ src/Router/Attributes/Post.php | 27 +++ src/Router/Attributes/Put.php | 27 +++ src/Router/Attributes/Route.php | 78 ++++++++ src/Router/Router.php | 14 ++ .../Routing/AttributeRouteIntegrationTest.php | 181 +++++++++++++++++ tests/Routing/AttributeRouteTest.php | 188 ++++++++++++++++++ tests/Routing/Stubs/SimpleControllerStub.php | 27 +++ tests/Routing/Stubs/UserControllerStub.php | 57 ++++++ 14 files changed, 865 insertions(+) create mode 100644 src/Router/AttributeRouteRegistrar.php create mode 100644 src/Router/Attributes/Controller.php create mode 100644 src/Router/Attributes/Delete.php create mode 100644 src/Router/Attributes/Get.php create mode 100644 src/Router/Attributes/Options.php create mode 100644 src/Router/Attributes/Patch.php create mode 100644 src/Router/Attributes/Post.php create mode 100644 src/Router/Attributes/Put.php create mode 100644 src/Router/Attributes/Route.php create mode 100644 tests/Routing/AttributeRouteIntegrationTest.php create mode 100644 tests/Routing/AttributeRouteTest.php create mode 100644 tests/Routing/Stubs/SimpleControllerStub.php create mode 100644 tests/Routing/Stubs/UserControllerStub.php diff --git a/src/Router/AttributeRouteRegistrar.php b/src/Router/AttributeRouteRegistrar.php new file mode 100644 index 00000000..814defb6 --- /dev/null +++ b/src/Router/AttributeRouteRegistrar.php @@ -0,0 +1,106 @@ +router = $router; + } + + /** + * Register routes from controller classes + * + * @param string|array $controllers + * @return void + */ + public function register(string|array $controllers): void + { + $controllers = is_array($controllers) ? $controllers : [$controllers]; + + foreach ($controllers as $controller) { + $this->registerController($controller); + } + } + + /** + * Register routes from controller + * + * @param string $controllerClass + * @return void + */ + private function registerController(string $controllerClass): void + { + $reflection = new ReflectionClass($controllerClass); + + // Get controller attribute + $controllerAttributes = $reflection->getAttributes(Controller::class); + $controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null; + + $prefix = $controllerAttribute?->getPrefix() ?? ''; + $controllerMiddleware = $controllerAttribute?->getMiddleware() ?? []; + + // Scan methods + foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if (str_starts_with($method->getName(), '__')) { + continue; + } + + // Get route attributes + $routeAttributes = $method->getAttributes( + RouteAttribute::class, + \ReflectionAttribute::IS_INSTANCEOF + ); + + foreach ($routeAttributes as $attribute) { + /** @var RouteAttribute $routeAttr */ + $routeAttr = $attribute->newInstance(); + + // Build path + $routePath = $routeAttr->getPath(); + $routePath = '/' . ltrim($routePath, '/'); + $fullPath = $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + + // Merge middleware + $middleware = array_merge($controllerMiddleware, $routeAttr->getMiddleware()); + + // Register route + $route = $this->router->match( + $routeAttr->getMethods(), + $fullPath, + [$controllerClass, $method->getName()] + ); + + if (!empty($middleware)) { + $route->middleware($middleware); + } + + if (!empty($routeAttr->getWhere())) { + $route->where($routeAttr->getWhere()); + } + + if ($routeAttr->getName() !== null) { + $route->name($routeAttr->getName()); + } + } + } + } +} diff --git a/src/Router/Attributes/Controller.php b/src/Router/Attributes/Controller.php new file mode 100644 index 00000000..f7f366d9 --- /dev/null +++ b/src/Router/Attributes/Controller.php @@ -0,0 +1,52 @@ +prefix; + } + + /** + * Get the middleware + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Get the route name + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } +} diff --git a/src/Router/Attributes/Delete.php b/src/Router/Attributes/Delete.php new file mode 100644 index 00000000..375cc5dd --- /dev/null +++ b/src/Router/Attributes/Delete.php @@ -0,0 +1,27 @@ +path; + } + + /** + * Get the http methods + * + * @return array + */ + public function getMethods(): array + { + return array_map('strtoupper', $this->methods); + } + + /** + * Get the middleware + * + * @return array + */ + public function getMiddleware(): array + { + return $this->middleware; + } + + /** + * Get the route constraints + * + * @return array + */ + public function getWhere(): array + { + return $this->where; + } + + /** + * Get the route name + * + * @return string|null + */ + public function getName(): ?string + { + return $this->name; + } +} + diff --git a/src/Router/Router.php b/src/Router/Router.php index 0e364b37..726268ca 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -491,4 +491,18 @@ public function setCurrentPath(string $path): void { $this->current['path'] = $path; } + + /** + * Register routes from controller classes + * + * @param string|array $controllers + * @return Router + */ + public function register(string|array $controllers): Router + { + $registrar = new AttributeRouteRegistrar($this); + $registrar->register($controllers); + + return $this; + } } diff --git a/tests/Routing/AttributeRouteIntegrationTest.php b/tests/Routing/AttributeRouteIntegrationTest.php new file mode 100644 index 00000000..ba9582ff --- /dev/null +++ b/tests/Routing/AttributeRouteIntegrationTest.php @@ -0,0 +1,181 @@ +router = Router::configure(); + } + + public function test_registrar_registers_routes_from_controller(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Check that routes were registered + $this->assertArrayHasKey('GET', $routes); + $this->assertArrayHasKey('POST', $routes); + $this->assertArrayHasKey('PUT', $routes); + $this->assertArrayHasKey('DELETE', $routes); + $this->assertArrayHasKey('PATCH', $routes); + } + + public function test_registrar_registers_routes_with_correct_paths(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Get the registered GET routes + $getRoutes = $routes['GET'] ?? []; + + // Check that we have at least the expected routes + $this->assertGreaterThanOrEqual(2, count($getRoutes)); + + // Get paths from routes + $paths = array_map(fn($route) => $route->getPath(), $getRoutes); + + // Check if the path starts with /api/users + $hasIndexRoute = false; + $hasShowRoute = false; + foreach ($paths as $path) { + if ($path === '/api/users/' || $path === '/api/users') { + $hasIndexRoute = true; + } + if (str_contains($path, '/api/users/:id') || str_contains($path, '/api/users/')) { + $hasShowRoute = true; + } + } + $this->assertTrue($hasIndexRoute, 'Index route should be registered'); + $this->assertTrue($hasShowRoute, 'Show route should be registered'); + } + + public function test_registrar_handles_controller_without_controller_attribute(): void + { + $registrar = new AttributeRouteRegistrar($this->router); + $registrar->register(SimpleControllerStub::class); + + $routes = $this->router->getRoutes(); + + // Should still register routes + $this->assertArrayHasKey('GET', $routes); + $this->assertArrayHasKey('POST', $routes); + } + + public function test_router_register_method_works(): void + { + $this->router->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + + $this->assertArrayHasKey('GET', $routes); + $this->assertNotEmpty($routes['GET']); + } + + public function test_router_register_accepts_array_of_controllers(): void + { + $this->router->register([ + UserControllerStub::class, + SimpleControllerStub::class + ]); + + $routes = $this->router->getRoutes(); + + // Get all registered paths + $allPaths = []; + foreach ($routes as $methodRoutes) { + foreach ($methodRoutes as $route) { + $allPaths[] = $route->getPath(); + } + } + + // Check that routes from both controllers are registered + $hasUserRoute = false; + $hasSimpleRoute = false; + foreach ($allPaths as $path) { + if (str_starts_with($path, '/api/users')) { + $hasUserRoute = true; + } + if (str_contains($path, '/simple')) { + $hasSimpleRoute = true; + } + } + $this->assertTrue($hasUserRoute, 'User controller routes should be registered'); + $this->assertTrue($hasSimpleRoute, 'Simple controller routes should be registered'); + } + + public function test_router_register_returns_router_for_chaining(): void + { + $result = $this->router->register(UserControllerStub::class); + + $this->assertInstanceOf(Router::class, $result); + } + + public function test_route_middleware_is_applied_correctly(): void + { + $this->router->register(UserControllerStub::class); + + $routes = $this->router->getRoutes(); + $postRoutes = $routes['POST'] ?? []; + + // Find the store route + $storeRoute = null; + foreach ($postRoutes as $route) { + if (str_contains($route->getPath(), '/api/users')) { + $storeRoute = $route; + break; + } + } + + $this->assertNotNull($storeRoute); + + // The action should contain middleware + $action = $storeRoute->getAction(); + $this->assertIsArray($action); + $this->assertArrayHasKey('middleware', $action); + + // Should have both controller and route middleware + $middleware = $action['middleware']; + $this->assertContains('auth', $middleware); + $this->assertContains('validate', $middleware); + } +} diff --git a/tests/Routing/AttributeRouteTest.php b/tests/Routing/AttributeRouteTest.php new file mode 100644 index 00000000..b6b3d01e --- /dev/null +++ b/tests/Routing/AttributeRouteTest.php @@ -0,0 +1,188 @@ + '[0-9]+'], name: 'users.index'); + + $this->assertEquals('/users', $get->getPath()); + $this->assertEquals(['GET'], $get->getMethods()); + $this->assertEquals(['auth'], $get->getMiddleware()); + $this->assertEquals(['id' => '[0-9]+'], $get->getWhere()); + $this->assertEquals('users.index', $get->getName()); + } + + public function test_post_attribute_creates_correct_route(): void + { + $post = new Post('/users'); + + $this->assertEquals('/users', $post->getPath()); + $this->assertEquals(['POST'], $post->getMethods()); + } + + public function test_put_attribute_creates_correct_route(): void + { + $put = new Put('/users/:id'); + + $this->assertEquals('/users/:id', $put->getPath()); + $this->assertEquals(['PUT'], $put->getMethods()); + } + + public function test_delete_attribute_creates_correct_route(): void + { + $delete = new Delete('/users/:id'); + + $this->assertEquals('/users/:id', $delete->getPath()); + $this->assertEquals(['DELETE'], $delete->getMethods()); + } + + public function test_patch_attribute_creates_correct_route(): void + { + $patch = new Patch('/users/:id'); + + $this->assertEquals('/users/:id', $patch->getPath()); + $this->assertEquals(['PATCH'], $patch->getMethods()); + } + + public function test_options_attribute_creates_correct_route(): void + { + $options = new Options('/users'); + + $this->assertEquals('/users', $options->getPath()); + $this->assertEquals(['OPTIONS'], $options->getMethods()); + } + + public function test_route_attribute_with_multiple_methods(): void + { + $route = new Route('/users', methods: ['GET', 'post', 'PUT']); + + $this->assertEquals('/users', $route->getPath()); + $this->assertEquals(['GET', 'POST', 'PUT'], $route->getMethods()); + } + + // ===== Controller Attribute Tests ===== + + public function test_controller_attribute_with_prefix_and_middleware(): void + { + $controller = new Controller(prefix: '/api/v1', middleware: ['auth', 'throttle'], name: 'api'); + + $this->assertEquals('/api/v1', $controller->getPrefix()); + $this->assertEquals(['auth', 'throttle'], $controller->getMiddleware()); + $this->assertEquals('api', $controller->getName()); + } + + public function test_controller_attribute_defaults(): void + { + $controller = new Controller(); + + $this->assertEquals('', $controller->getPrefix()); + $this->assertEquals([], $controller->getMiddleware()); + $this->assertNull($controller->getName()); + } + + // ===== Reflection Tests ===== + + public function test_user_controller_has_controller_attribute(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + $attributes = $reflection->getAttributes(Controller::class); + + $this->assertCount(1, $attributes); + + /** @var Controller $controller */ + $controller = $attributes[0]->newInstance(); + + $this->assertEquals('/api/users', $controller->getPrefix()); + $this->assertEquals(['auth'], $controller->getMiddleware()); + } + + public function test_user_controller_methods_have_route_attributes(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + + // Test index method + $indexMethod = $reflection->getMethod('index'); + $indexAttributes = $indexMethod->getAttributes(Get::class); + $this->assertCount(1, $indexAttributes); + + /** @var Get $getAttr */ + $getAttr = $indexAttributes[0]->newInstance(); + $this->assertEquals('/', $getAttr->getPath()); + + // Test store method + $storeMethod = $reflection->getMethod('store'); + $storeAttributes = $storeMethod->getAttributes(Post::class); + $this->assertCount(1, $storeAttributes); + + /** @var Post $postAttr */ + $postAttr = $storeAttributes[0]->newInstance(); + $this->assertEquals('/', $postAttr->getPath()); + $this->assertEquals(['validate'], $postAttr->getMiddleware()); + } + + public function test_can_get_all_route_attributes_using_instanceof(): void + { + $reflection = new ReflectionClass(UserControllerStub::class); + $indexMethod = $reflection->getMethod('index'); + + // Get all Route attributes (including subclasses like Get, Post, etc.) + $routeAttributes = $indexMethod->getAttributes( + Route::class, + \ReflectionAttribute::IS_INSTANCEOF + ); + + $this->assertCount(1, $routeAttributes); + } + + public function test_route_attribute_middleware_merges_correctly(): void + { + $route = new Get('/test', middleware: ['first', 'second']); + + $this->assertEquals(['first', 'second'], $route->getMiddleware()); + } + + public function test_route_attribute_where_constraints(): void + { + $route = new Get('/users/:id/:slug', where: ['id' => '[0-9]+', 'slug' => '[a-z-]+']); + + $this->assertEquals([ + 'id' => '[0-9]+', + 'slug' => '[a-z-]+' + ], $route->getWhere()); + } + + public function test_all_http_attributes_extend_route(): void + { + $this->assertInstanceOf(Route::class, new Get('/')); + $this->assertInstanceOf(Route::class, new Post('/')); + $this->assertInstanceOf(Route::class, new Put('/')); + $this->assertInstanceOf(Route::class, new Delete('/')); + $this->assertInstanceOf(Route::class, new Patch('/')); + $this->assertInstanceOf(Route::class, new Options('/')); + } +} diff --git a/tests/Routing/Stubs/SimpleControllerStub.php b/tests/Routing/Stubs/SimpleControllerStub.php new file mode 100644 index 00000000..41f508eb --- /dev/null +++ b/tests/Routing/Stubs/SimpleControllerStub.php @@ -0,0 +1,27 @@ + 'simple_index']; + } + + #[Post('/simple', name: 'simple.store')] + public function store(): array + { + return ['action' => 'simple_store']; + } +} + diff --git a/tests/Routing/Stubs/UserControllerStub.php b/tests/Routing/Stubs/UserControllerStub.php new file mode 100644 index 00000000..cb27ec85 --- /dev/null +++ b/tests/Routing/Stubs/UserControllerStub.php @@ -0,0 +1,57 @@ + 'index']; + } + + #[Get('/:id', where: ['id' => '[0-9]+'])] + public function show(Request $request): array + { + return ['action' => 'show', 'id' => $request->get('id')]; + } + + #[Post('/', middleware: ['validate'])] + public function store(Request $request): array + { + return ['action' => 'store']; + } + + #[Put('/:id')] + public function update(Request $request): array + { + return ['action' => 'update', 'id' => $request->get('id')]; + } + + #[Patch('/:id')] + public function patch(Request $request): array + { + return ['action' => 'patch', 'id' => $request->get('id')]; + } + + #[Delete('/:id', middleware: ['admin'])] + public function destroy(Request $request): array + { + return ['action' => 'destroy', 'id' => $request->get('id')]; + } +} + From ad04f5652286b85c33c7dea1cb783082759e2011 Mon Sep 17 00:00:00 2001 From: Ken Date: Sat, 3 Jan 2026 15:35:30 +0100 Subject: [PATCH 04/60] fix(config): restore env error visibility for observability --- src/Configuration/EnvConfiguration.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/Configuration/EnvConfiguration.php b/src/Configuration/EnvConfiguration.php index e9693fb6..e792bcc3 100644 --- a/src/Configuration/EnvConfiguration.php +++ b/src/Configuration/EnvConfiguration.php @@ -14,8 +14,8 @@ class EnvConfiguration extends Configuration public function create(Loader $config): void { $envFile = $config->getBasePath() . '/.env.json'; - - // Only configure if file exists and environment is not already loaded + + // Check if environment is already loaded try { $env = Env::getInstance(); if ($env->isLoaded()) { @@ -25,12 +25,13 @@ public function create(Loader $config): void } catch (\Bow\Application\Exception\ApplicationException $e) { // Environment not loaded, continue to load it } - - if (file_exists($envFile)) { - Env::configure($envFile); - $event = Env::getInstance(); - $this->container->instance('env', $event); - } + + // Load environment - will throw exception if file doesn't exist + Env::configure($envFile); + + $event = Env::getInstance(); + + $this->container->instance('env', $event); } /** From 457f97b5556b643267c6c5c78c38ce14eab175d0 Mon Sep 17 00:00:00 2001 From: papac Date: Sat, 4 Apr 2026 23:50:09 +0000 Subject: [PATCH 05/60] Update CHANGELOG --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afdda0a..589c1ddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.92 - 2026-04-04 + +### What's Changed + +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/374 +* Add query and post method to request and fix nullable validator by @papac in https://github.com/bowphp/framework/pull/375 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.91...5.2.92 + ## 5.2.91 - 2026-03-28 ### What's Changed @@ -174,6 +183,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From bebb23116d03ad51ababb8df2232fd619617aea4 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sun, 5 Apr 2026 12:55:40 +0000 Subject: [PATCH 06/60] Fix query builder --- src/Database/Barry/Model.php | 19 ++++++++-------- src/Database/QueryBuilder.php | 43 +++++++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index d41df871..39af76cd 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -890,10 +890,7 @@ public function __get(string $name): mixed if (!$attribute_exists && method_exists($this, $name)) { $result = $this->$name(); - if ($result instanceof Relation) { - return $result->getResults(); - } - return $result; + return $result instanceof Relation ? $result->getResults() : $result; } if (!$attribute_exists) { @@ -998,23 +995,27 @@ private function executeDataCasting(string $name): mixed } if ($type === "int") { - return (int)$value; + return (int) $value; } if ($type === "float") { - return (float)$value; + return (float) $value; } if ($type === "double") { - return (float)$value; + return (float) $value; + } + + if ($type === "boolean" || $type === "bool") { + return (bool) $value; } if ($type === "json") { if (is_array($value)) { - return (object)$value; + return (object) $value; } if (is_object($value)) { - return (object)$value; + return (object) $value; } return json_decode( $value, diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 5b1498c0..e2e657a3 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -291,10 +291,37 @@ private static function isComparisonOperator(mixed $comparator): bool } return in_array(Str::upper($comparator), [ - '=', '>', '<', '>=', '=<', '<>', '!=', 'LIKE', 'NOT', 'IS NOT', "IN", "NOT IN", - 'ILIKE', '&', '|', '<<', '>>', 'NOT LIKE', - '&&', '@>', '<@', '?', '?|', '?&', '||', '-', '@?', '@@', '#-', - 'IS DISTINCT FROM', 'IS NOT DISTINCT FROM', + '=', + '>', + '<', + '>=', + '=<', + '<>', + '!=', + 'LIKE', + 'NOT', + 'IS NOT', + "IN", + "NOT IN", + 'ILIKE', + '&', + '|', + '<<', + '>>', + 'NOT LIKE', + '&&', + '@>', + '<@', + '?', + '?|', + '?&', + '||', + '-', + '@?', + '@@', + '#-', + 'IS DISTINCT FROM', + 'IS NOT DISTINCT FROM', ], true); } @@ -905,6 +932,10 @@ private function bind(PDOStatement $pdo_statement, array $bindings = []): void $param = PDO::PARAM_INT; } elseif (is_resource($value)) { $param = PDO::PARAM_LOB; + } elseif (is_bool($value)) { + $param = PDO::PARAM_BOOL; + } elseif (is_string($value)) { + $param = PDO::PARAM_STR; } $key_binding = is_string($key) ? ":$key" : $key + 1; $pdo_statement->bindValue($key_binding, $value, $param); @@ -920,6 +951,10 @@ private function bind(PDOStatement $pdo_statement, array $bindings = []): void $param = PDO::PARAM_INT; } elseif (is_resource($value)) { $param = PDO::PARAM_LOB; + } elseif (is_bool($value)) { + $param = PDO::PARAM_BOOL; + } elseif (is_string($value)) { + $param = PDO::PARAM_STR; } $pdo_statement->bindValue($i, $value, $param); $i++; From 7530ca732c003c1666735a71563e5baaac1fa3e1 Mon Sep 17 00:00:00 2001 From: papac Date: Sun, 5 Apr 2026 12:58:41 +0000 Subject: [PATCH 07/60] Update CHANGELOG --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 589c1ddd..49b4df56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.93 - 2026-04-05 + +### What's Changed + +* Fix query builder by @papac in https://github.com/bowphp/framework/pull/377 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/378 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.92...5.2.93 + ## 5.2.92 - 2026-04-04 ### What's Changed @@ -184,6 +193,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From 3135e75086f502fde09e8f6f047549ba1691c322 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 7 Apr 2026 16:40:07 +0000 Subject: [PATCH 08/60] Resolve confict --- src/Database/Barry/Model.php | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 39af76cd..86f71c82 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -1017,12 +1017,7 @@ private function executeDataCasting(string $name): mixed if (is_object($value)) { return (object) $value; } - return json_decode( - $value, - false, - 512, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ); + return $this->parseToJson($value); } if ($type === "array") { @@ -1032,14 +1027,25 @@ private function executeDataCasting(string $name): mixed if (is_object($value)) { return (array) $value; } - return json_decode( - $value, - true, - 512, - JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE - ); + return $this->parseToJson($value); } return $this->attributes[$name]; } + + /** + * Parse value to json + * + * @param string $value + * @return void + */ + private function parseToJson($value) + { + return json_decode( + $value, + false, + 512, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE + ); + } } From c6932c9bf3d1d8f9f6e1b520732c8303e8b91a46 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 7 Apr 2026 16:44:54 +0000 Subject: [PATCH 09/60] Fix many bugs --- .../Exception/QueryBuilderException.php | 14 +- src/Database/QueryBuilder.php | 142 ++++++++++-------- src/Http/Request.php | 4 + src/Http/UploadedFile.php | 22 ++- 4 files changed, 118 insertions(+), 64 deletions(-) diff --git a/src/Database/Exception/QueryBuilderException.php b/src/Database/Exception/QueryBuilderException.php index ea11365e..658bb306 100644 --- a/src/Database/Exception/QueryBuilderException.php +++ b/src/Database/Exception/QueryBuilderException.php @@ -8,5 +8,17 @@ class QueryBuilderException extends ErrorException { - // Empty + protected string $query; + + public function __construct( + string $message, + string $query = '', + int $code = 0, + int $severity = E_ERROR, + ?string $filename = null, + ?int $line = null + ) { + parent::__construct($message, $code, $severity, $filename, $line); + $this->query = $query; + } } diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index e2e657a3..562705df 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -10,6 +10,7 @@ use Bow\Support\Str; use JsonSerializable; use PDO; +use PDOException; use PDOStatement; class QueryBuilder implements JsonSerializable @@ -112,6 +113,13 @@ class QueryBuilder implements JsonSerializable */ protected string $adapter = ''; + /** + * Determine the last sql query + * + * @var string|null + */ + protected ?string $last_query = null; + /** * QueryBuilder Constructor * @@ -170,9 +178,10 @@ public function as(string $as): QueryBuilder * WHERE column1 $comparator $value|column * * @param string $where + * @param array $data * @return QueryBuilder */ - public function whereRaw(string $where): QueryBuilder + public function whereRaw(string $where, array $data = []): QueryBuilder { if ($this->where == null) { $this->where = $where; @@ -180,6 +189,10 @@ public function whereRaw(string $where): QueryBuilder $this->where .= ' and ' . $where; } + if (!empty($data)) { + $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); + } + return $this; } @@ -189,9 +202,10 @@ public function whereRaw(string $where): QueryBuilder * WHERE column1 $comparator $value|column * * @param string $where + * @param array $data * @return QueryBuilder */ - public function orWhereRaw(string $where): QueryBuilder + public function orWhereRaw(string $where, array $data = []): QueryBuilder { if ($this->where == null) { $this->where = $where; @@ -199,6 +213,10 @@ public function orWhereRaw(string $where): QueryBuilder $this->where .= ' or ' . $where; } + if (!empty($data)) { + $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); + } + return $this; } @@ -217,8 +235,7 @@ public function orWhere(string $column, mixed $comparator = '=', mixed $value = { if (is_null($this->where)) { throw new QueryBuilderException( - 'This function can not be used without a where before.', - E_ERROR + 'This function can not be used without a where before.' ); } @@ -252,13 +269,12 @@ public function where( } if ($value === null) { - throw new QueryBuilderException('Unresolved comparison value', E_ERROR); + throw new QueryBuilderException('Unresolved comparison value'); } if (!in_array(Str::lower($boolean), ['and', 'or'])) { throw new QueryBuilderException( - 'The bool ' . $boolean . ' not accepted', - E_ERROR + 'The bool ' . $boolean . ' not accepted' ); } @@ -719,8 +735,7 @@ public function andOn(string $first, $comparator = '=', $second = null): QueryBu { if (is_null($this->join)) { throw new QueryBuilderException( - 'The inner join clause is already initialized.', - E_ERROR + 'The inner join clause is already initialized.' ); } @@ -750,7 +765,6 @@ public function orOn(string $first, $comparator = '=', $second = null): QueryBui if (is_null($this->join)) { throw new QueryBuilderException( 'The inner join clause is already initialized.', - E_ERROR ); } @@ -889,13 +903,8 @@ private function aggregate($aggregate, $column): mixed } } - $statement = $this->connection->prepare($sql); - - $this->bind($statement, $this->where_data_binding); + $statement = $this->execute($sql, $this->where_data_binding); - $statement->execute(); - - $this->triggerQueryEvent($sql, $this->where_data_binding); $this->where_data_binding = []; if ($statement->rowCount() > 1) { @@ -926,7 +935,9 @@ private function bind(PDOStatement $pdo_statement, array $bindings = []): void // Named placeholders foreach ($bindings as $key => $value) { $param = PDO::PARAM_STR; - if (is_null($value) || strtolower((string) $value) === 'null') { + if (is_array($value) || is_object($value)) { + $value = json_encode($value); + } elseif (is_null($value) || strtolower((string) $value) === 'null') { $param = PDO::PARAM_NULL; } elseif (is_int($value)) { $param = PDO::PARAM_INT; @@ -945,7 +956,9 @@ private function bind(PDOStatement $pdo_statement, array $bindings = []): void $i = 1; foreach ($bindings as $value) { $param = PDO::PARAM_STR; - if (is_null($value) || strtolower((string) $value) === 'null') { + if (is_array($value) || is_object($value)) { + $value = json_encode($value); + } elseif (is_null($value) || strtolower((string) $value) === 'null') { $param = PDO::PARAM_NULL; } elseif (is_int($value)) { $param = PDO::PARAM_INT; @@ -1110,17 +1123,12 @@ public function get(array $columns = []): array|object|null // Execution of request. $sql = $this->toSql(); - $statement = $this->connection->prepare($sql); - - $this->bind($statement, $this->where_data_binding); - - $statement->execute(); + $statement = $this->execute($sql, $this->where_data_binding); $data = $statement->fetchAll(); $statement->closeCursor(); - $this->triggerQueryEvent($sql, $this->where_data_binding); $this->where_data_binding = []; if (!$this->first) { @@ -1215,20 +1223,14 @@ public function update(array $data = []): int $sql .= ' where ' . $this->where; $this->where = null; - - $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); } - $statement = $this->connection->prepare($sql); + $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); - $this->bind($statement, $this->where_data_binding); - - // Execution of the request - $statement->execute(); + $statement = $this->execute($sql, $this->where_data_binding); $result = $statement->rowCount(); - $this->triggerQueryEvent($sql, $this->where_data_binding); $this->where_data_binding = []; return (int) $result; @@ -1265,15 +1267,10 @@ public function delete(): int $this->where = null; } - $statement = $this->connection->prepare($sql); - - $this->bind($statement, $this->where_data_binding); - - $statement->execute(); + $statement = $this->execute($sql, $this->where_data_binding); $result = $statement->rowCount(); - $this->triggerQueryEvent($sql, $this->where_data_binding); $this->where_data_binding = []; return (int) $result; @@ -1292,6 +1289,18 @@ public function increment(string $column, int $step = 1): int return $this->incrementAction($column, $step); } + /** + * Decrement column + * + * @param string $column + * @param int $step + * @return int + */ + public function decrement(string $column, int $step = 1): int + { + return $this->incrementAction($column, $step, '-'); + } + /** * Method to customize the increment and decrement methods * @@ -1310,27 +1319,11 @@ private function incrementAction(string $column, int $step = 1, string $directio $this->where = null; } - $statement = $this->connection->prepare($sql); - - $this->bind($statement, $this->where_data_binding); - - $statement->execute(); + $statement = $this->execute($sql, $this->where_data_binding); return (int)$statement->rowCount(); } - /** - * Decrement column - * - * @param string $column - * @param int $step - * @return int - */ - public function decrement(string $column, int $step = 1): int - { - return $this->incrementAction($column, $step, '-'); - } - /** * Allows a query with the DISTINCT clause * @@ -1371,10 +1364,14 @@ public function truncate(): bool $sql = 'truncate table ' . $this->table . ';'; } + $this->last_query = $sql; + $result = (bool) $this->connection->exec($sql); $this->triggerQueryEvent($sql, []); + $this->last_query = $sql; + return $result; } @@ -1422,7 +1419,6 @@ public function insert(array $values): int if ($single_item_structure_detected && $mixture_item_structure_detected) { throw new QueryBuilderException( 'Mixed structure detected in insert data. Cannot mix single and multiple row inserts.', - E_ERROR ); } @@ -1455,15 +1451,39 @@ private function insertOne(array $values): int $sql .= '(' . implode(', ', $this->add2points($fields, true)) . ');'; + $statement = $this->execute($sql, $values); + + return (int) $statement->rowCount(); + } + + /** + * Execute statement + * + * @param string $sql + * @param array $bindings + * @return PDOStatement + */ + private function execute(string $sql, array $bindings = []): PDOStatement + { + $this->last_query = $sql; + $statement = $this->connection->prepare($sql); - $this->bind($statement, $values); + $this->bind($statement, $bindings); - $statement->execute(); + try { + $statement->execute(); - $this->triggerQueryEvent($sql, $values); + $this->triggerQueryEvent($sql, $bindings); + } catch (\Exception $e) { + throw new QueryBuilderException( + 'Error executing query: ' . $e->getMessage(), + $this->last_query, + E_ERROR, + ); + } - return (int) $statement->rowCount(); + return $statement; } /** @@ -1475,6 +1495,8 @@ public function drop(): bool { $sql = 'drop table ' . $this->table; + $this->last_query = $sql; + $result = (bool) $this->connection->exec($sql); $this->triggerQueryEvent($sql, []); diff --git a/src/Http/Request.php b/src/Http/Request.php index 97813caa..d581aa50 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -402,6 +402,10 @@ public function file(string $key): UploadedFile|Collection|null return null; } + if (!is_uploaded_file($_FILES[$key]['tmp_name']) === UPLOAD_ERR_OK) { + return null; + } + if (!is_array($_FILES[$key]['name'])) { return new UploadedFile($_FILES[$key]); } diff --git a/src/Http/UploadedFile.php b/src/Http/UploadedFile.php index 01a59546..4ebfdcc4 100644 --- a/src/Http/UploadedFile.php +++ b/src/Http/UploadedFile.php @@ -38,6 +38,10 @@ public function extension(): string */ public function getExtension(): ?string { + if (!$this->isUploaded()) { + return null; + } + if (!isset($this->file['name'])) { return null; } @@ -71,7 +75,7 @@ public function getFilesize(): ?int } /** - * Check if the file is uploader + * Check if the file is uploaded * * @return bool */ @@ -91,6 +95,10 @@ public function isUploaded(): bool */ public function getFilename(): ?string { + if (!$this->isUploaded()) { + return null; + } + return $this->file['name'] ?? null; } @@ -101,6 +109,10 @@ public function getFilename(): ?string */ public function getContent(): ?string { + if (!$this->isUploaded()) { + return null; + } + if (!isset($this->file['tmp_name'])) { return null; } @@ -109,15 +121,19 @@ public function getContent(): ?string } /** - * Move the uploader file to a directory. + * Move the uploaded file to a directory. * * @param string $to * @param ?string $filename * @return bool - * @throws + * @throws \RuntimeException */ public function moveTo(string $to, ?string $filename = null): bool { + if (!$this->isUploaded()) { + return false; + } + if (!isset($this->file['tmp_name'])) { return false; } From d28741840bff4d2150e9c6fb04cc06b7e4c26932 Mon Sep 17 00:00:00 2001 From: papac Date: Tue, 7 Apr 2026 16:47:52 +0000 Subject: [PATCH 10/60] Update CHANGELOG --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b4df56..293d2ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.94 - 2026-04-07 + +### What's Changed + +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/379 +* Fix many issues by @papac in https://github.com/bowphp/framework/pull/380 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.93...5.2.94 + ## 5.2.93 - 2026-04-05 ### What's Changed @@ -194,6 +203,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From fab5efe97acdf405c785ad0bd96198478787b0de Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 7 Apr 2026 17:25:53 +0000 Subject: [PATCH 11/60] Fix data binding --- src/Database/QueryBuilder.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 562705df..6c07a4f3 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -190,7 +190,7 @@ public function whereRaw(string $where, array $data = []): QueryBuilder } if (!empty($data)) { - $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); + $this->where_data_binding = array_merge($this->where_data_binding, array_values($data)); } return $this; @@ -214,7 +214,7 @@ public function orWhereRaw(string $where, array $data = []): QueryBuilder } if (!empty($data)) { - $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding); + $this->where_data_binding = array_merge($this->where_data_binding, array_values($data)); } return $this; @@ -1477,7 +1477,7 @@ private function execute(string $sql, array $bindings = []): PDOStatement $this->triggerQueryEvent($sql, $bindings); } catch (\Exception $e) { throw new QueryBuilderException( - 'Error executing query: ' . $e->getMessage(), + 'Error executing query: ' . $e->getMessage() . ' | Query: ' . $this->last_query, $this->last_query, E_ERROR, ); From 21c085d6c86a8db72732366e4989a7de67ea4fae Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Thu, 9 Apr 2026 12:20:19 +0000 Subject: [PATCH 12/60] Add transactional migration --- src/Console/Command/MigrationCommand.php | 83 +++++++++++++++--------- src/Database/Database.php | 16 +++++ src/Support/Env.php | 6 +- 3 files changed, 71 insertions(+), 34 deletions(-) diff --git a/src/Console/Command/MigrationCommand.php b/src/Console/Command/MigrationCommand.php index 12500071..be27d1c0 100644 --- a/src/Console/Command/MigrationCommand.php +++ b/src/Console/Command/MigrationCommand.php @@ -50,46 +50,52 @@ public function reset(): void } /** - * Create a migration in both directions + * Run migration action (up, rollback, reset) * - * @param string $type + * @param string $type * @return void * @throws Exception */ private function factory(string $type): void { - $migrations = []; - // We include all migrations files and collect it for make great manage - foreach ($this->getMigrationFiles() as $file) { - $migrations[$file] = explode('.', basename($file))[0]; - } + $migrations = $this->collectMigrationFiles(); - // We create the migration database status - $this->createMigrationTable(); - $action = 'make' . strtoupper($type); + $connection = $this->arg->getParameter("--connection", config("database.default")); + - $this->$action($migrations); + try { + Database::connection($connection); + } catch (Exception $exception) { + throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); + } + + try { + Database::startTransaction(); + // We create the migration database status + $this->createMigrationTable($connection); + + $action = 'make' . ucfirst($type); + if (!method_exists($this, $action)) { + throw new MigrationException("Migration action '$action' not found."); + } + $this->$action($migrations); + Database::commitTransaction(); + } catch (Exception $exception) { + Database::rollbackTransaction(); + throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); + } } /** - * Create the migration status table + * Create the migration status table if it does not exist * * @return void * @throws ConnectionException */ private function createMigrationTable(): void { - $connection = $this->arg->getParameter("--connection", config("database.default")); - - try { - Database::connection($connection); - } catch (Exception $exception) { - echo Color::red("▶ Please check your database configuration on .env.json file\n"); - throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); - } - $adapter = Database::getConnectionAdapter(); $table = $adapter->getTablePrefix() . config('database.migration', 'migrations'); @@ -121,9 +127,9 @@ private function createMigrationTable(): void } /** - * Up migration + * Run all up migrations * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -153,6 +159,7 @@ protected function makeUp(array $migrations): void (new $migration())->up(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + break; } // Create new migration status @@ -209,7 +216,7 @@ private function printExceptionMessage(string $message, string $migration): void $message = Color::red($message); $migration = Color::yellow($migration); - exit(sprintf("\nOn %s\n\n%s\n\n", $migration, $message)); + echo sprintf("\nOn %s\n\n%s\n\n", $migration, $message); } /** @@ -249,9 +256,9 @@ private function updateMigrationStatus(string $migration, int $batch): void } /** - * Rollback migration + * Rollback all migrations in batch 1 * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -288,6 +295,7 @@ protected function makeRollback(array $migrations): void (new $migration())->rollback(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + return; } break; @@ -311,9 +319,9 @@ protected function makeRollback(array $migrations): void } /** - * Reset migration + * Reset all migrations * - * @param array $migrations + * @param array $migrations * @return void * @throws ConnectionException * @throws QueryBuilderException @@ -347,6 +355,7 @@ protected function makeReset(array $migrations): void (new $migration())->rollback(); } catch (Exception $exception) { $this->throwMigrationException($exception, $migration); + break; } $this->getMigrationTable()->where('migration', $migration)->delete(); @@ -358,17 +367,31 @@ protected function makeReset(array $migrations): void } /** - * Get migration pattern + * Get migration file paths * * @return array */ private function getMigrationFiles(): array { $file_pattern = $this->setting->getMigrationDirectory() . strtolower("/*.php"); - return glob($file_pattern); } + /** + * Collect migration files as [file => className] + * + * @return array + */ + private function collectMigrationFiles(): array + { + $files = $this->getMigrationFiles(); + $migrations = []; + foreach ($files as $file) { + $migrations[$file] = explode('.', basename($file))[0]; + } + return $migrations; + } + /** * Get migration table * diff --git a/src/Database/Database.php b/src/Database/Database.php index 22c9d254..791cb78b 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -425,6 +425,14 @@ public static function inTransaction(): bool * Validate a transaction */ public static function commit(): void + { + static::commitTransaction(); + } + + /** + * Validate a transaction + */ + public static function commitTransaction(): void { if (static::inTransaction()) { static::$adapter->getConnection()->commit(); @@ -435,6 +443,14 @@ public static function commit(): void * Cancel a transaction */ public static function rollback(): void + { + static::rollbackTransaction(); + } + + /** + * Cancel a transaction + */ + public static function rollbackTransaction(): void { if (static::inTransaction()) { static::$adapter->getConnection()->rollBack(); diff --git a/src/Support/Env.php b/src/Support/Env.php index 572eaa51..f02e14bc 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -4,9 +4,7 @@ namespace Bow\Support; -use Bow\Application\Exception\ApplicationException; use ErrorException; -use InvalidArgumentException; /** * Class Env @@ -82,11 +80,11 @@ public function __construct(?string $filename = null) /** * Load env file * - * @param string $filename + * @param ?string $filename * @return void * @throws */ - public static function configure(string $filename) + public static function configure(?string $filename = null): void { if (static::$instance !== null) { return; From e0a8f063710869c323fb51844f6b78a319216756 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 7 Apr 2026 18:08:25 +0000 Subject: [PATCH 13/60] Code formatting --- src/Database/QueryBuilder.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 6c07a4f3..859aa372 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -10,7 +10,6 @@ use Bow\Support\Str; use JsonSerializable; use PDO; -use PDOException; use PDOStatement; class QueryBuilder implements JsonSerializable From 99dbc85153b5747f0b6390a4d3017b01bc78a0be Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Fri, 8 May 2026 11:28:17 +0000 Subject: [PATCH 14/60] feat(database): Add lock for update and shared for update --- src/Database/Barry/Model.php | 26 ++--- src/Database/QueryBuilder.php | 52 ++++++++++ src/Http/Request.php | 4 +- tests/Database/Query/QueryBuilderTest.php | 117 ++++++++++++++++++++++ 4 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 86f71c82..93b4a389 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -22,11 +22,11 @@ use ReflectionClass; /** - * @method select(array|string[] $select) - * @method whereIn(string $primary_key, array $id) - * @method get() - * @method where(string $column, mixed $value) - * @method orderBy(string $latest, string $string) + * @method static select(array|string[] $select): Builder + * @method static whereIn(string $primary_key, array $id): Builder + * @method static all(): Collection + * @method static where(string $column, mixed $value): Builder + * @method static orderBy(string $latest, string $string): Builder */ abstract class Model implements ArrayAccess, JsonSerializable { @@ -188,7 +188,6 @@ public function getConnection(): ?string * Initialize the connection * * @return Builder - * @throws */ public static function query(): Builder { @@ -374,7 +373,6 @@ public static function retrieve( * Delete a record * * @return int - * @throws */ public function delete(): int { @@ -482,7 +480,6 @@ public static function create(array $data): Model * persist aliases on insert action * * @return int - * @throws */ public function persist(): int { @@ -601,7 +598,6 @@ private function transtypeKeyValue(mixed $primary_key_value): string|int|float * * @param array $attributes * @return int|bool - * @throws */ public function update(array $attributes): int|bool { @@ -666,7 +662,6 @@ public static function paginate(int $page_number, int $current = 0, ?int $chunk * Allows to associate listener * * @param callable $cb - * @throws */ public static function deleted(callable $cb): void { @@ -679,7 +674,6 @@ public static function deleted(callable $cb): void * Allows to associate listener * * @param callable $cb - * @throws */ public static function deleting(callable $cb): void { @@ -692,7 +686,6 @@ public static function deleting(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function creating(callable $cb): void { @@ -705,7 +698,6 @@ public static function creating(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function created(callable $cb): void { @@ -718,7 +710,6 @@ public static function created(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function updating(callable $cb): void { @@ -731,7 +722,6 @@ public static function updating(callable $cb): void * Allows to associate a listener * * @param callable $cb - * @throws */ public static function updated(callable $cb): void { @@ -760,7 +750,7 @@ public static function deleteBy(string $column, mixed $value): int * * @param string $name * @param array $arguments - * @return mixed + * @return Builder|Collection|Model|mixed */ public static function __callStatic(string $name, array $arguments) { @@ -1037,9 +1027,9 @@ private function executeDataCasting(string $name): mixed * Parse value to json * * @param string $value - * @return void + * @return mixed */ - private function parseToJson($value) + private function parseToJson($value): mixed { return json_decode( $value, diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 859aa372..c5f53bed 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -119,6 +119,20 @@ class QueryBuilder implements JsonSerializable */ protected ?string $last_query = null; + /** + * Lock rows for update + * + * @var bool + */ + protected bool $lock_for_update = false; + + /** + * Lock rows in share mode + * + * @var bool + */ + protected bool $shared_lock = false; + /** * QueryBuilder Constructor * @@ -403,6 +417,20 @@ public function toSql(): string } } + // Adding the lock for update clause + if ($this->lock_for_update) { + $sql .= ' for update'; + + $this->lock_for_update = false; + } + + // Adding the shared lock clause + if ($this->shared_lock) { + $sql .= $this->adapter === 'pgsql' ? ' for share' : ' lock in share mode'; + + $this->shared_lock = false; + } + return $sql; } @@ -1082,6 +1110,30 @@ public function first(): ?object return $this->get(); } + /** + * Lock the selected rows for update + * + * @return QueryBuilder + */ + public function lockForUpdate(): QueryBuilder + { + $this->lock_for_update = true; + + return $this; + } + + /** + * Lock the selected rows in share mode + * + * @return QueryBuilder + */ + public function sharedLock(): QueryBuilder + { + $this->shared_lock = true; + + return $this; + } + /** * Take = Limit * diff --git a/src/Http/Request.php b/src/Http/Request.php index d581aa50..33bf2aad 100644 --- a/src/Http/Request.php +++ b/src/Http/Request.php @@ -500,7 +500,7 @@ public function wantsJson(): bool */ public function is(string $match): bool { - return (bool)preg_match('@' . addcslashes($match, "/*{()}[]$^") . '@', $this->path()); + return (bool) preg_match('@' . addcslashes($match, "/{()}[]$^") . '@', $this->path()); } /** @@ -511,7 +511,7 @@ public function is(string $match): bool */ public function isReferer(string $match): bool { - return (bool)preg_match('@' . addcslashes($match, "/*{()}[]$^") . '@', $this->referer()); + return (bool) preg_match('@' . addcslashes($match, "/{()}[]$^") . '@', $this->referer()); } /** diff --git a/tests/Database/Query/QueryBuilderTest.php b/tests/Database/Query/QueryBuilderTest.php index 7b2bd0c9..b5231406 100644 --- a/tests/Database/Query/QueryBuilderTest.php +++ b/tests/Database/Query/QueryBuilderTest.php @@ -242,6 +242,123 @@ public function test_where_chain_rows(string $name) $this->assertEquals(is_array($pets), true); } + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_generates_correct_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->lockForUpdate(); + $sql = $table->toSql(); + + $this->assertStringEndsWith('for update', $sql); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_executes_query(string $name) + { + if ($name === 'sqlite') { + $this->markTestSkipped('SQLite does not support FOR UPDATE locking.'); + } + + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + $table->insert([ + ['id' => 1, 'name' => 'Milou'], + ['id' => 2, 'name' => 'Foli'], + ]); + + Database::connection($name)->startTransaction(); + + $pets = Database::connection($name)->table('pets')->lockForUpdate()->get(); + + Database::connection($name)->rollback(); + + $this->assertIsArray($pets); + $this->assertCount(2, $pets); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_lock_for_update_flag_resets_after_to_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->lockForUpdate(); + $table->toSql(); + + $sql = $table->toSql(); + + $this->assertStringNotContainsString('for update', $sql); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_generates_correct_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->sharedLock(); + $sql = $table->toSql(); + + if ($name === 'pgsql') { + $this->assertStringEndsWith('for share', $sql); + } else { + $this->assertStringEndsWith('lock in share mode', $sql); + } + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_executes_query(string $name) + { + if ($name === 'sqlite') { + $this->markTestSkipped('SQLite does not support shared locking.'); + } + + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + $table->insert([ + ['id' => 1, 'name' => 'Milou'], + ['id' => 2, 'name' => 'Foli'], + ]); + + Database::connection($name)->startTransaction(); + + $pets = Database::connection($name)->table('pets')->sharedLock()->get(); + + Database::connection($name)->rollback(); + + $this->assertIsArray($pets); + $this->assertCount(2, $pets); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_shared_lock_flag_resets_after_to_sql(string $name) + { + $this->createTestingTable($name); + $table = Database::connection($name)->table('pets'); + + $table->sharedLock(); + $table->toSql(); + + $sql = $table->toSql(); + + $this->assertStringNotContainsString('for share', $sql); + $this->assertStringNotContainsString('lock in share mode', $sql); + } + /** * @return array */ From b674ccced656a36c8d05e775f9de0a0ef7806190 Mon Sep 17 00:00:00 2001 From: papac Date: Fri, 8 May 2026 11:32:58 +0000 Subject: [PATCH 15/60] Update CHANGELOG --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 293d2ee6..88293045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.95 - 2026-05-08 + +### What's Changed + +* Fix data binding by @papac in https://github.com/bowphp/framework/pull/381 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/382 +* Optimize database query performance by @papac in https://github.com/bowphp/framework/pull/383 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.94...5.2.95 + ## 5.2.94 - 2026-04-07 ### What's Changed @@ -204,6 +214,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From 40a3645a0324f7cc56fa7ad6b6395078a830616e Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 12 May 2026 16:58:17 +0000 Subject: [PATCH 16/60] Fix migration --- src/Console/Command/MigrationCommand.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Console/Command/MigrationCommand.php b/src/Console/Command/MigrationCommand.php index be27d1c0..ccc1c362 100644 --- a/src/Console/Command/MigrationCommand.php +++ b/src/Console/Command/MigrationCommand.php @@ -72,18 +72,15 @@ private function factory(string $type): void } try { - Database::startTransaction(); // We create the migration database status - $this->createMigrationTable($connection); + $this->createMigrationTable(); $action = 'make' . ucfirst($type); if (!method_exists($this, $action)) { throw new MigrationException("Migration action '$action' not found."); } $this->$action($migrations); - Database::commitTransaction(); } catch (Exception $exception) { - Database::rollbackTransaction(); throw new MigrationException($exception->getMessage(), (int)$exception->getCode()); } } From 6643e9074611fd4e55d520e625fad5ebe36c88b2 Mon Sep 17 00:00:00 2001 From: papac Date: Tue, 12 May 2026 17:01:46 +0000 Subject: [PATCH 17/60] Update CHANGELOG --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88293045..48471018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.96 - 2026-05-12 + +### What's Changed + +* Fix migration by @papac in https://github.com/bowphp/framework/pull/384 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.95...5.2.96 + +### What's Changed + +* Fix migration by @papac in https://github.com/bowphp/framework/pull/384 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/385 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.95...5.2.96 + ## 5.2.95 - 2026-05-08 ### What's Changed @@ -215,6 +230,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From 892fb24b59ad16aea86ee9b4fb4fac6c8f7d08d8 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Wed, 13 May 2026 16:57:52 +0000 Subject: [PATCH 18/60] Fix pagination --- src/Database/QueryBuilder.php | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index c5f53bed..4de4c469 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -1558,12 +1558,12 @@ public function drop(): bool /** * Paginate, make pagination system * - * @param int $number_of_page + * @param int $per_page * @param int $current * @param int $chunk * @return Pagination */ - public function paginate(int $number_of_page, int $current = 0, ?int $chunk = null): Pagination + public function paginate(int $per_page, int $current = 0, ?int $chunk = null): Pagination { // We go to back page --$current; @@ -1573,7 +1573,7 @@ public function paginate(int $number_of_page, int $current = 0, ?int $chunk = nu $jump = 0; $current = 1; } else { - $jump = $number_of_page * $current; + $jump = $per_page * $current; $current++; } @@ -1582,7 +1582,7 @@ public function paginate(int $number_of_page, int $current = 0, ?int $chunk = nu $join = $this->join; $data_bind = $this->where_data_binding; - $data = $this->jump($jump)->take($number_of_page)->get(); + $data = $this->jump($jump)->take($per_page)->get(); if (is_array($data)) { $data = collect($data); @@ -1594,7 +1594,9 @@ public function paginate(int $number_of_page, int $current = 0, ?int $chunk = nu $this->where_data_binding = $data_bind; // We count the number of pages that remain - $rest_of_page = ceil($this->count() / $number_of_page) - $current; + $total = $this->count(); + $total_of_page = (int) ceil($total / $per_page); + $rest_of_page = $total_of_page - $current; // Grouped data if (is_int($chunk)) { @@ -1603,12 +1605,12 @@ public function paginate(int $number_of_page, int $current = 0, ?int $chunk = nu // Enables automatic paging. return new Pagination( - $current >= 1 && $rest_of_page > 0 ? $current + 1 : 0, - ($current - 1) <= 0 ? 1 : ($current - 1), - (int)($rest_of_page + $current), - $number_of_page, - $current, - $data + next: $current >= 1 && $rest_of_page > 0 ? $current + 1 : 0, + previous: ($current - 1) <= 0 ? 1 : ($current - 1), + total: $total, + perPage: $per_page, + current: $current, + data: $data, ); } From f940f691944a5af6145dfe91283e549bb5051b36 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sat, 16 May 2026 22:40:08 +0000 Subject: [PATCH 19/60] Fix push the right queue name --- src/Console/Command/SeederCommand.php | 14 +++++------ src/Database/Barry/Model.php | 8 +++++- src/Database/QueryBuilder.php | 2 +- src/Queue/Adapters/DatabaseAdapter.php | 35 +++++++++++++------------- src/Queue/Adapters/RabbitMQAdapter.php | 2 +- src/Support/Log.php | 20 +++++++++++++++ 6 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/Console/Command/SeederCommand.php b/src/Console/Command/SeederCommand.php index 3d910071..8e78bddb 100644 --- a/src/Console/Command/SeederCommand.php +++ b/src/Console/Command/SeederCommand.php @@ -40,12 +40,14 @@ public function all(): void */ private function make(string $seed_filename, string $seeder_class_name): void { + $file_basename = basename($seed_filename); try { + echo Color::green("Seeding: seeders/$file_basename\n"); include_once $seed_filename; (new $seeder_class_name())->run(); - echo Color::green("Seeding completed: $seed_filename\n"); + echo Color::green("Seeded: seeders/$file_basename\n"); } catch (Exception $e) { - echo Color::red("Seeding failed for: $seed_filename"); + echo Color::red("Seeding failed for: seeders/$file_basename"); echo Color::red("\n" . $e->getMessage()); } } @@ -73,12 +75,8 @@ public function file(?string $seeder_class_name = null): void break; } - foreach ($seeder_files as $file => $seeder_class_name) { - echo Color::green("Seeding: $file"); - - $this->make($file, $seeder_class_name); - - echo Color::green("Seeding completed: $file"); + foreach ($seeder_files as $file => $_seeder_class_name) { + $this->make($file, $_seeder_class_name); } } diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 93b4a389..09ddf4c2 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -22,10 +22,16 @@ use ReflectionClass; /** + * @method static as(string $as): Builder + * @method static whereRaw(string $where, array $data = []): Builder + * @method static join(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder + * @method static leftJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder + * @method static rightJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder + * @method static innerJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder * @method static select(array|string[] $select): Builder * @method static whereIn(string $primary_key, array $id): Builder * @method static all(): Collection - * @method static where(string $column, mixed $value): Builder + * @method static where(string $column, mixed $comparator = '=', mixed $value = null): Builder * @method static orderBy(string $latest, string $string): Builder */ abstract class Model implements ArrayAccess, JsonSerializable diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php index 4de4c469..dddd8c73 100644 --- a/src/Database/QueryBuilder.php +++ b/src/Database/QueryBuilder.php @@ -813,7 +813,7 @@ public function orOn(string $first, $comparator = '=', $second = null): QueryBui * @return QueryBuilder * @deprecated */ - public function group($column) + public function group(string $column) { return $this->groupBy($column); } diff --git a/src/Queue/Adapters/DatabaseAdapter.php b/src/Queue/Adapters/DatabaseAdapter.php index 5ebe9832..36db9093 100644 --- a/src/Queue/Adapters/DatabaseAdapter.php +++ b/src/Queue/Adapters/DatabaseAdapter.php @@ -9,7 +9,6 @@ use Bow\Database\QueryBuilder; use Bow\Queue\QueueTask; use ErrorException; -use stdClass; use Throwable; class DatabaseAdapter extends QueueAdapter @@ -69,9 +68,9 @@ public function push(QueueTask $task): bool $payload = [ "id" => $task->getId(), - "queue" => $this->getQueue(), + "queue" => $task->getQueue(), "payload" => base64_encode($this->serializeProducer($task)), - "attempts" => $this->tries, + "attempts" => $task->getRetry(), "status" => self::STATUS_WAITING, "available_at" => date("Y-m-d H:i:s", time() + (method_exists($task, 'getDelay') ? $task->getDelay() : 0)), "reserved_at" => null, @@ -122,10 +121,10 @@ private function fetchPendingJobs(string $queueName): array /** * Process a single task from the queue * - * @param stdClass $task + * @param \stdClass $task * @return void */ - private function processJob(stdClass $task): void + private function processJob(\stdClass $task): void { $producer = null; @@ -146,10 +145,10 @@ private function processJob(stdClass $task): void /** * Check if the task is ready to be processed * - * @param stdClass $task + * @param \stdClass $task * @return bool */ - private function isJobReady(stdClass $task): bool + private function isJobReady(\stdClass $task): bool { // Check if the task is available for processing if (strtotime($task->available_at) > time()) { @@ -168,11 +167,11 @@ private function isJobReady(stdClass $task): bool * Execute the task * * @param QueueTask $task - * @param stdClass $item + * @param \stdClass $item * @return void * @throws QueryBuilderException */ - private function executeTask(QueueTask $task, stdClass $item): void + private function executeTask(QueueTask $task, \stdClass $item): void { $this->logProcessingTask($task); if (!method_exists($task, 'process')) { @@ -187,12 +186,12 @@ private function executeTask(QueueTask $task, stdClass $item): void /** * Handle task failure * - * @param stdClass $task + * @param \stdClass $task * @param QueueTask|null $producer * @param Throwable $exception * @return void */ - private function handleJobFailure(stdClass $task, ?QueueTask $producer, Throwable $exception): void + private function handleJobFailure(\stdClass $task, ?QueueTask $producer, Throwable $exception): void { $this->logError($exception); @@ -222,10 +221,10 @@ private function handleJobFailure(stdClass $task, ?QueueTask $producer, Throwabl * Determine if the task should be marked as failed * * @param QueueTask $producer - * @param stdClass $task + * @param \stdClass $task * @return bool */ - private function shouldMarkJobAsFailed(QueueTask $producer, stdClass $task): bool + private function shouldMarkJobAsFailed(QueueTask $producer, \stdClass $task): bool { return $producer->taskShouldBeDelete() || $task->attempts <= 0; } @@ -233,18 +232,18 @@ private function shouldMarkJobAsFailed(QueueTask $producer, stdClass $task): boo /** * Schedule a task for retry * - * @param stdClass $task - * @param QueueTask $producer + * @param \stdClass $task + * @param QueueTask $queueTask * @return void * @throws QueryBuilderException */ - private function scheduleJobRetry(stdClass $task, QueueTask $producer): void + private function scheduleJobRetry(\stdClass $task, QueueTask $queueTask): void { $this->table->where("id", $task->id)->update([ "status" => self::STATUS_RESERVED, "attempts" => $task->attempts - 1, - "available_at" => date("Y-m-d H:i:s", time() + $producer->getDelay()), - "reserved_at" => date("Y-m-d H:i:s", time() + $producer->getRetry()), + "available_at" => date("Y-m-d H:i:s", time() + $queueTask->getDelay()), + "reserved_at" => date("Y-m-d H:i:s", time() + $queueTask->getRetry()), ]); } diff --git a/src/Queue/Adapters/RabbitMQAdapter.php b/src/Queue/Adapters/RabbitMQAdapter.php index dcc83cd0..86d0c63b 100644 --- a/src/Queue/Adapters/RabbitMQAdapter.php +++ b/src/Queue/Adapters/RabbitMQAdapter.php @@ -66,7 +66,7 @@ public function push(QueueTask $task): bool $msg = new AMQPMessage($body, [ 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT ]); - $this->channel->basic_publish($msg, '', $this->queue); + $this->channel->basic_publish($msg, '', $task->getQueue()); return true; } diff --git a/src/Support/Log.php b/src/Support/Log.php index bc599707..52b5984a 100644 --- a/src/Support/Log.php +++ b/src/Support/Log.php @@ -9,9 +9,29 @@ * @method static void alert(string $message, array $context = []) * @method static void critical(string $message, array $context = []) * @method static void emergency(string $message, array $context = []) + * @method void error(string $message, array $context = []) + * @method void info(string $message, array $context = []) + * @method void warning(string $message, array $context = []) + * @method void alert(string $message, array $context = []) + * @method void critical(string $message, array $context = []) + * @method void emergency(string $message, array $context = []) */ class Log { + /** + * Log + * + * @param string $name + * @param array $arguments + * @return void + */ + public function __call(string $name, array $arguments = []) + { + $instance = app("logger"); + + call_user_func_array([$instance, $name], $arguments); + } + /** * Log * From e6c0ae86ec5c296b1eb9c72bc9b1d0ce2d3e48c3 Mon Sep 17 00:00:00 2001 From: papac Date: Sat, 16 May 2026 22:43:19 +0000 Subject: [PATCH 20/60] Update CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48471018..00308ba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.98 - 2026-05-16 + +### What's Changed + +* Fix push the right queue name by @papac in https://github.com/bowphp/framework/pull/388 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.97...5.2.98 + ## 5.2.96 - 2026-05-12 ### What's Changed @@ -231,6 +239,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From 115ec7f9b36887181dbced3a0c871c3aff99c607 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Sun, 17 May 2026 17:22:48 +0000 Subject: [PATCH 21/60] Fix retrieve data by queue --- src/Console/Command/WorkerCommand.php | 6 ++--- src/Http/Client/HttpClient.php | 32 +++++++++++++++++++++++++-- src/Http/UploadedFile.php | 2 +- src/Queue/Adapters/QueueAdapter.php | 4 ++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/src/Console/Command/WorkerCommand.php b/src/Console/Command/WorkerCommand.php index b7d978e1..a9652d01 100644 --- a/src/Console/Command/WorkerCommand.php +++ b/src/Console/Command/WorkerCommand.php @@ -18,10 +18,10 @@ class WorkerCommand extends AbstractCommand public function run(?string $connection = null): void { $tries = (int) $this->arg->getParameter('--tries', 3); - $default = $this->arg->getParameter('--queue', "default"); + $queue_name = $this->arg->getParameter('--queue', "default"); $memory = (int) $this->arg->getParameter('--memory', 126); $timout = (int) $this->arg->getParameter('--timout', 3); - $sleep = (int) $this->arg->getParameter('--sleep', 60); + $sleep = (int) $this->arg->getParameter('--sleep', 3); $queue = app("queue"); @@ -31,7 +31,7 @@ public function run(?string $connection = null): void $worker = $this->getWorderService(); $worker->setConnection($queue->getAdapter()); - $worker->run($default, $tries, $sleep, $timout, $memory); + $worker->run($queue_name, $tries, $sleep, $timout, $memory); } /** diff --git a/src/Http/Client/HttpClient.php b/src/Http/Client/HttpClient.php index d1852598..c905043d 100644 --- a/src/Http/Client/HttpClient.php +++ b/src/Http/Client/HttpClient.php @@ -260,7 +260,7 @@ private function addFields(array $data): void return; } - if ($this->accept_json) { + if ($this->accept_json || $this->hasHeader('content-type', 'application/json')) { $payload = json_encode($data); } else { $payload = http_build_query($data); @@ -377,12 +377,28 @@ public function setUserAgent(string $user_agent): HttpClient /** * Configure client to accept and send JSON data * + * @deprecated 5.2.99 * @return HttpClient */ public function acceptJson(): HttpClient { $this->accept_json = true; + $this->withHeaders(["Content-Type" => "application/json"]); + $this->withHeaders(["Accept" => "application/json"]); + + return $this; + } + + /** + * Configure client to accept and send JSON data + * + * @return HttpClient + */ + public function withJson(): HttpClient + { + $this->accept_json = true; + $this->withHeaders(["Content-Type" => "application/json"]); return $this; @@ -398,13 +414,25 @@ public function withHeaders(array $headers): HttpClient { foreach ($headers as $key => $value) { if (!in_array(strtolower($key . ': ' . $value), array_map('strtolower', $this->headers))) { - $this->headers[] = $key . ': ' . $value; + $this->headers[] = trim($key) . ': ' . $value; } } return $this; } + /** + * Check if header exists + * + * @param string $key + * @param string $value + * @return boolean + */ + public function hasHeader(string $key, string $value): bool + { + return in_array(strtolower($key . ': ' . $value), array_map('strtolower', $this->headers)); + } + /** * Set HTTP authentication credentials * diff --git a/src/Http/UploadedFile.php b/src/Http/UploadedFile.php index 4ebfdcc4..bfd4ad51 100644 --- a/src/Http/UploadedFile.php +++ b/src/Http/UploadedFile.php @@ -41,7 +41,7 @@ public function getExtension(): ?string if (!$this->isUploaded()) { return null; } - + if (!isset($this->file['name'])) { return null; } diff --git a/src/Queue/Adapters/QueueAdapter.php b/src/Queue/Adapters/QueueAdapter.php index 68777212..32ca6e81 100644 --- a/src/Queue/Adapters/QueueAdapter.php +++ b/src/Queue/Adapters/QueueAdapter.php @@ -297,13 +297,13 @@ public function getQueue(?string $queue = null): string } /** - * Watch the queue name + * Set the queue name * * @param string $queue */ public function setQueue(string $queue): void { - // + $this->queue = $queue; } /** From 21b369e041507023d9d163c71b51a5838767fce6 Mon Sep 17 00:00:00 2001 From: papac Date: Sun, 17 May 2026 17:26:46 +0000 Subject: [PATCH 22/60] Update CHANGELOG --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00308ba5..729fb882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.2.990 - 2026-05-17 + +### What's Changed + +* Fix retrieve data by queue by @papac in https://github.com/bowphp/framework/pull/390 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.98...5.2.990 + ## 5.2.98 - 2026-05-16 ### What's Changed @@ -240,6 +248,7 @@ Database::transaction(fn() => $user->update(['name' => ''])); + ``` Ref: #255 From b96bf472fd19864db40f308f54c020901c146083 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 19 May 2026 13:50:14 +0000 Subject: [PATCH 23/60] Fix database direct statement --- src/Database/Database.php | 24 +++++++----------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 791cb78b..7e971416 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -209,14 +209,9 @@ public static function select(string $sql_statement, array $data = []): mixed { static::ensureDatabaseConnection(); - if ( - !preg_match( - "/^(select\s.+?\sfrom\s.+;?|desc\s.+;?)$/i", - $sql_statement - ) - ) { + if (!preg_match("/^\s*select\b/i", $sql_statement)) { throw new DatabaseException( - 'Syntax Error on the Request', + 'Syntax Error on the Request: ' . $sql_statement, E_USER_ERROR ); } @@ -246,9 +241,9 @@ public static function selectOne(string $sql_statement, array $data = []): mixed { static::ensureDatabaseConnection(); - if (!preg_match("/^select\s.+?\sfrom\s.+;?$/i", $sql_statement)) { + if (!preg_match("/^\s*select\b/i", $sql_statement)) { throw new DatabaseException( - 'Syntax Error on the Request', + 'Syntax Error on the Request: ' . $sql_statement, E_USER_ERROR ); } @@ -278,14 +273,9 @@ public static function insert(string $sql_statement, array $data = []): int { static::ensureDatabaseConnection(); - if ( - !preg_match( - "/^insert\s+into\s+[\w\d_-`]+\s*(\(.+\))?\s+(values\s*(\(.+\),?)+|\s?set\s+(.+)+);?$/ism", - $sql_statement - ) - ) { + if (!preg_match("/^\s*insert\b/i", $sql_statement)) { throw new DatabaseException( - 'Syntax Error on the Request', + 'Syntax Error on the Request: ' . $sql_statement, E_USER_ERROR ); } @@ -344,7 +334,7 @@ public static function delete(string $sql_statement, array $data = []): int { static::ensureDatabaseConnection(); - if (!preg_match("/^delete\s+from\s+[\w\d_`]+\s+where\s+.+;?$/i", $sql_statement)) { + if (!preg_match(""/^\s*delete\b/i"", $sql_statement)) { throw new DatabaseException( 'Syntax Error on the Request', E_USER_ERROR From 1be84dd6663a1a73e4c58a32bdbe06080df12559 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Tue, 19 May 2026 13:54:03 +0000 Subject: [PATCH 24/60] Fix regex --- src/Database/Database.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 7e971416..4421036d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -334,7 +334,7 @@ public static function delete(string $sql_statement, array $data = []): int { static::ensureDatabaseConnection(); - if (!preg_match(""/^\s*delete\b/i"", $sql_statement)) { + if (!preg_match("/^\s*delete\b/i", $sql_statement)) { throw new DatabaseException( 'Syntax Error on the Request', E_USER_ERROR From e8d06a5223d1053e93026b44622cf00475f6cdd6 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Wed, 20 May 2026 20:48:50 +0000 Subject: [PATCH 25/60] feat(console): refactoring console help messages --- src/Console/Console.php | 374 ++++++++++++++++++++++++---------------- 1 file changed, 226 insertions(+), 148 deletions(-) diff --git a/src/Console/Console.php b/src/Console/Console.php index 6eaa58d5..6f278fc4 100644 --- a/src/Console/Console.php +++ b/src/Console/Console.php @@ -24,6 +24,126 @@ class Console */ private const VERSION = '5.x'; + /** + * Command aliases that share another topic's help body. + */ + private const HELP_TOPIC_ALIASES = [ + 'gen' => 'generate', + ]; + + /** + * Per-topic help bodies, keyed by command (or alias). The "gen" alias + * shares the "generate" body via the aliases map below. + * + * Bodies use raw ANSI escape codes — they are pre-formatted templates + * rather than messages composed through Color::*. + */ + private const HELP_TOPICS = [ + 'add' => << << << << << << << <<` * @return void */ - public static function register(string $command, callable|string $cb): void - { - static::$registers[$command] = $cb; + public static function register( + string $command, + callable|string $cb, + ?string $description = null, + ?string $help = null, + ): void { + static::$registers[$command] = [ + 'cb' => $cb, + 'description' => $description, + 'help' => $help, + ]; } /** @@ -245,9 +375,14 @@ public function call(?string $command): mixed if (!in_array($command, array_keys($commands))) { // Try to execute the custom command - $rawCommand = $this->arg->getRawCommand() ?? ''; - if (($rawCommand !== '' && array_key_exists($rawCommand, static::$registers)) || array_key_exists($command, static::$registers)) { - return $this->executeCustomCommand($rawCommand ?: $command); + if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) { + // `php bow help` shows the registered help instead of running it. + if ($this->arg->getTarget() === 'help' && !$this->arg->getAction()) { + $this->help($command); + exit(0); + } + + return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command); } } @@ -259,7 +394,8 @@ public function call(?string $command): mixed if (!$this->arg->getAction()) { if ($target == 'help') { - return $this->help($command); + $this->help($command); + exit(0); } } @@ -281,7 +417,7 @@ public function call(?string $command): mixed private function executeCustomCommand(string $command): mixed { try { - $classname = static::$registers[$command]; + $classname = static::$registers[$command]['cb']; if (is_callable($classname)) { return $classname($this->arg, $this->setting); @@ -309,11 +445,21 @@ private function executeCustomCommand(string $command): mixed * * @param string $command * @param callable|string $cb + * @param string|null $description One-liner shown in the global help index + * @param string|null $help Full body shown by `php bow help ` * @return Console */ - public function addCommand(string $command, callable|string $cb): Console - { - static::$registers[$command] = $cb; + public function addCommand( + string $command, + callable|string $cb, + ?string $description = null, + ?string $help = null, + ): Console { + static::$registers[$command] = [ + 'cb' => $cb, + 'description' => $description, + 'help' => $help, + ]; return $this; } @@ -520,18 +666,26 @@ private function getVersion(): void } /** - * Display global help or helper command. - * - * @param string|null $command - * @return int + * Display global help or a single topic's help. */ - private function help(?string $command = null): int + private function help(?string $command = null): void { - // Display the framework and php version $this->getVersion(); - if ($command === null || $command == 'help') { - $usage = <<printGlobalHelp(); + return; + } + + $this->printTopicHelp($command); + } + + /** + * Print the top-level command index. + */ + private function printGlobalHelp(): void + { + echo <<printCustomCommandsSection(); + } - \033[0;33m$\033[00m php \033[0;34mbow\033[00m seed:all\033[00m Make seeding for all - \033[0;33m$\033[00m php \033[0;34mbow\033[00m seed:file\033[00m class_name Make seeding for one file + /** + * Append the CUSTOM section listing application-registered commands. + * + * Each entry shows the command name in yellow and, when available, the + * description supplied to register() / addCommand(). Pad the name column + * to the widest entry so descriptions align in the terminal. + */ + private function printCustomCommandsSection(): void + { + if (static::$registers === []) { + return; + } -U; - break; + $names = array_keys(static::$registers); + $width = max(array_map('strlen', $names)); - case 'flush': - echo << $entry) { + $description = (string) ($entry['description'] ?? ''); + echo sprintf( + " \033[0;33m%s\033[00m %s\n", + str_pad($name, $width), + $description, + ); + } -U; - break; + echo "\n"; + } - case 'schedule': - echo <<throwFailsCommand("Please make php bow help for show whole docs !"); - exit(1); + if (is_array($registered) && is_string($registered['help'] ?? null)) { + echo $registered['help']; + return; } - exit(0); + $this->throwFailsCommand('Please make php bow help for show whole docs !'); } } From 90bb0cb3eb0c8a6de983887282ac1b7cbf534101 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Wed, 20 May 2026 20:49:16 +0000 Subject: [PATCH 26/60] feat(console): Add customer command --- src/Console/Command.php | 24 +++++++++++++++++++++--- src/Console/Command/SeederCommand.php | 2 +- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Console/Command.php b/src/Console/Command.php index 32299e81..7eab367c 100644 --- a/src/Console/Command.php +++ b/src/Console/Command.php @@ -32,6 +32,7 @@ use Bow\Console\Command\Generator\GenerateEventListenerCommand; use Bow\Console\Command\Generator\GenerateTaskCommand; use Bow\Console\Command\Generator\GenerateRouterResourceCommand; +use Bow\Console\Exception\ConsoleException; class Command extends AbstractCommand { @@ -40,7 +41,7 @@ class Command extends AbstractCommand * * @var array */ - private array $commands = [ + protected static array $commands = [ "clear" => ClearCommand::class, "seed:file" => SeederCommand::class, "seed:all" => SeederCommand::class, @@ -85,7 +86,24 @@ class Command extends AbstractCommand */ public function getCommands(): array { - return $this->commands; + return static::$commands; + } + + /** + * Push new command + * + * @param array $commands + * @return void + */ + public static function pushCommand(array $commands) + { + foreach ($commands as $key => $command) { + if (isset(static::$commands[$key])) { + throw new ConsoleException("$key command already exists"); + } + + static::$commands[$key] = $command; + } } /** @@ -99,7 +117,7 @@ public function getCommands(): array */ public function call(string $command, string $action, ...$rest): mixed { - $class = $this->commands[$command] ?? null; + $class = static::$commands[$command] ?? null; if (is_null($class)) { $this->throwFailsCommand("The command $command not found !"); diff --git a/src/Console/Command/SeederCommand.php b/src/Console/Command/SeederCommand.php index 8e78bddb..25f3a1d0 100644 --- a/src/Console/Command/SeederCommand.php +++ b/src/Console/Command/SeederCommand.php @@ -55,7 +55,7 @@ private function make(string $seed_filename, string $seeder_class_name): void /** * Launch targeted seeding * - * @param string|null $seeder_name + * @param string|null $seeder_class_name * @return void */ public function file(?string $seeder_class_name = null): void From 64c30099ae289c364f7218b124429ab5af463fae Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Wed, 20 May 2026 20:49:42 +0000 Subject: [PATCH 27/60] feat(console): write generated customet stub --- src/Console/Generator.php | 86 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 81 insertions(+), 5 deletions(-) diff --git a/src/Console/Generator.php b/src/Console/Generator.php index 3641967f..8762a206 100644 --- a/src/Console/Generator.php +++ b/src/Console/Generator.php @@ -18,6 +18,13 @@ class Generator */ private string $base_directory; + /** + * Define the stub path + * + * @var string + */ + private string $stub_path; + /** * The generate name * @@ -90,9 +97,10 @@ public function exists(): bool * * @param string $type * @param array $data + * @param bool $using_path * @return bool */ - public function write(string $type, array $data = []): bool + public function write(string $type, array $data = [], bool $using_path = false): bool { $dirname = dirname($this->name); @@ -114,17 +122,57 @@ public function write(string $type, array $data = []): bool ); // Create the stub parsed content + $template_data = array_merge([ + 'namespace' => $namespace, + 'className' => $classname + ], $data); + $template = $this->makeStubContent( $type, - array_merge([ - 'namespace' => $namespace, - 'className' => $classname - ], $data) + $template_data ); return (bool) file_put_contents($this->getPath(), $template); } + /** + * Write file + * + * @param array $data + * @return bool + */ + public function writeFromDefineStubeFile(array $data = []): bool + { + $dirname = dirname($this->name); + + if (!is_dir($this->base_directory)) { + @mkdir($this->base_directory, 0777, true); + } + + if ($dirname != '.') { + @mkdir($this->base_directory . '/' . trim($dirname, '/'), 0777, true); + + $namespace = '\\' . str_replace('/', '\\', ucfirst(trim($dirname, '/'))); + } else { + $namespace = ''; + } + + // Transform class to match the PSR-2 standard + $classname = ucfirst( + Str::camel(basename($this->name)) + ); + + // Create the stub parsed content + $template_data = array_merge([ + 'namespace' => $namespace, + 'className' => $classname + ], $data); + + $template = $this->makeUsingStubPathContent($template_data); + + return (bool) file_put_contents($this->getPath(), $template); + } + /** * Stub render * @@ -143,6 +191,34 @@ public function makeStubContent(string $type, array $data = []): string return $content; } + /** + * Set the stub path + * + * @param string $path + * @return void + */ + public function setStubPath(string $path) + { + $this->stub_path = $path; + } + + /** + * Make stub using path + * + * @param array $data + * @return string + */ + public function makeUsingStubPathContent(array $data = []): string + { + $content = file_get_contents($this->stub_path); + + foreach ($data as $key => $value) { + $content = str_replace('{' . $key . '}', (string)$value, $content); + } + + return $content; + } + /** * Set writing filename * From 41e9f5ea597b579d85e07e15c5b7dfdb48e5f084 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Thu, 21 May 2026 00:23:05 +0000 Subject: [PATCH 28/60] Many bugs fixed and new features added --- src/Console/Command/SchedulerCommand.php | 23 +- src/Database/Barry/Model.php | 81 +++++-- src/Database/Barry/Traits/EventTrait.php | 4 +- src/Database/Barry/Traits/SoftDelete.php | 205 ++++++++++++++++++ .../Migration/Shortcut/MixedColumn.php | 12 +- src/Router/AttributeRouteRegistrar.php | 155 ++++++++----- src/Router/README.md | 125 ++++++++--- src/Router/Router.php | 12 +- src/Support/Env.php | 10 + src/Testing/TestCase.php | 159 +++++++------- src/Validation/Rules/BetweenRule.php | 57 +++++ src/Validation/Rules/BooleanRule.php | 41 ++++ src/Validation/Rules/ConfirmedRule.php | 42 ++++ src/Validation/Rules/DifferentRule.php | 46 ++++ src/Validation/Rules/IpRule.php | 44 ++++ src/Validation/Rules/JsonRule.php | 43 ++++ src/Validation/Rules/UrlRule.php | 37 ++++ src/Validation/Rules/UuidRule.php | 41 ++++ src/Validation/Validator.php | 40 +++- src/Validation/stubs/lexical.php | 8 + tests/Database/Query/PaginationTest.php | 16 +- tests/Database/Query/SoftDeleteTest.php | 181 ++++++++++++++++ .../Database/Stubs/SoftDeletePetModelStub.php | 23 ++ .../Routing/AttributeRouteIntegrationTest.php | 43 ++++ tests/Routing/Stubs/ChildControllerStub.php | 17 ++ .../Routing/Stubs/NamedUserControllerStub.php | 22 ++ tests/Routing/Stubs/ParentControllerStub.php | 15 ++ tests/Support/EnvTest.php | 3 + tests/Validation/ValidationTest.php | 195 +++++++++++++++++ tests/bootstrap.php | 18 ++ 30 files changed, 1489 insertions(+), 229 deletions(-) create mode 100644 src/Database/Barry/Traits/SoftDelete.php create mode 100644 src/Validation/Rules/BetweenRule.php create mode 100644 src/Validation/Rules/BooleanRule.php create mode 100644 src/Validation/Rules/ConfirmedRule.php create mode 100644 src/Validation/Rules/DifferentRule.php create mode 100644 src/Validation/Rules/IpRule.php create mode 100644 src/Validation/Rules/JsonRule.php create mode 100644 src/Validation/Rules/UrlRule.php create mode 100644 src/Validation/Rules/UuidRule.php create mode 100644 tests/Database/Query/SoftDeleteTest.php create mode 100644 tests/Database/Stubs/SoftDeletePetModelStub.php create mode 100644 tests/Routing/Stubs/ChildControllerStub.php create mode 100644 tests/Routing/Stubs/NamedUserControllerStub.php create mode 100644 tests/Routing/Stubs/ParentControllerStub.php diff --git a/src/Console/Command/SchedulerCommand.php b/src/Console/Command/SchedulerCommand.php index 2c8565a7..1c1266bc 100644 --- a/src/Console/Command/SchedulerCommand.php +++ b/src/Console/Command/SchedulerCommand.php @@ -206,16 +206,33 @@ private function getScheduler(): Scheduler } /** - * Load the scheduler from kernel + * Load schedules from two sources: + * + * 1. The host app's Kernel::schedules() method (always called). + * 2. A routes/scheduler.php file relative to the app's base directory, + * if present. The file is included so any code it runs against + * Scheduler::getInstance() registers events. * * @param Scheduler $scheduler * @return void */ private function loadSchedulerFile(Scheduler $scheduler): void { - $kernel = Loader::getInstance(); + // The Kernel's schedules() hook is optional — only call it if a Loader + // has been configured (e.g. host app booted, integration test). When + // the command is exercised in isolation (unit tests) we still want the + // routes/scheduler.php auto-include below to work. + try { + $kernel = Loader::getInstance(); + $kernel->schedules($scheduler); + } catch (\Throwable) { + // No Loader configured; skip the Kernel hook and continue. + } - $kernel->schedules($scheduler); + $routes_file = $this->setting->getBaseDirectory() . '/routes/scheduler.php'; + if (is_file($routes_file)) { + require $routes_file; + } } /** diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php index 09ddf4c2..8af58548 100644 --- a/src/Database/Barry/Model.php +++ b/src/Database/Barry/Model.php @@ -22,17 +22,56 @@ use ReflectionClass; /** - * @method static as(string $as): Builder - * @method static whereRaw(string $where, array $data = []): Builder - * @method static join(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static leftJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static rightJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static innerJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null): Builder - * @method static select(array|string[] $select): Builder - * @method static whereIn(string $primary_key, array $id): Builder - * @method static all(): Collection - * @method static where(string $column, mixed $comparator = '=', mixed $value = null): Builder - * @method static orderBy(string $latest, string $string): Builder + * Static method hints for calls dispatched through __callStatic() to the + * underlying Builder (and its parent QueryBuilder). They let IDEs and + * static analysers type-check fluent chains such as + * `User::where('active', true)->orderBy('id')->paginate(15)`. + * + * Selection & aliasing + * @method static Builder as(string $as) + * @method static Builder select(array $select = []) + * @method static Builder distinct(string $column) + * + * WHERE clauses + * @method static Builder where(string $column, mixed $comparator = '=', mixed $value = null) + * @method static Builder whereRaw(string $where, array $data = []) + * @method static Builder whereNull(string $column) + * @method static Builder whereNotNull(string $column) + * @method static Builder whereBetween(string $column, array $range) + * @method static Builder whereNotBetween(string $column, array $range) + * @method static Builder whereDifferent(string $column, mixed $value) + * @method static Builder whereIn(string $column, array $range) + * @method static Builder whereNotIn(string $column, array $range) + * + * Joins + * @method static Builder join(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * @method static Builder leftJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * @method static Builder rightJoin(string $table, string $first, mixed $comparator = '=', ?string $second = null) + * + * Grouping, ordering, limiting, locking + * @method static Builder orderBy(string $column, string $type = 'asc') + * @method static Builder take(int $limit) + * @method static Builder jump(int $offset = 0) + * @method static Builder lockForUpdate() + * @method static Builder sharedLock() + * + * Aggregates & terminal reads + * @method static int count(string $column = '*') + * @method static int|float max(string $column) + * @method static int|float min(string $column) + * @method static int|float avg(string $column) + * @method static int|float sum(string $column) + * @method static ?object last() + * @method static Model|Collection|null get(array $columns = []) + * @method static bool exists(?string $column = null, mixed $value = null) + * @method static string toSql() + * + * Write actions + * @method static int delete() + * @method static int remove(string $column, mixed $comparator = '=', mixed $value = null) + * @method static int increment(string $column, int $step = 1) + * @method static int decrement(string $column, int $step = 1) + * @method static bool truncate() */ abstract class Model implements ArrayAccess, JsonSerializable { @@ -76,13 +115,6 @@ abstract class Model implements ArrayAccess, JsonSerializable */ protected bool $auto_increment = true; - /** - * Enable the soft deletion - * - * @var bool - */ - protected bool $soft_delete = false; - /** * Defines the column where the query construct will use for the last query * @@ -1023,23 +1055,26 @@ private function executeDataCasting(string $name): mixed if (is_object($value)) { return (array) $value; } - return $this->parseToJson($value); + return $this->parseToJson($value, assoc: true); } return $this->attributes[$name]; } /** - * Parse value to json + * Decode a JSON string. When $assoc is true the result is an associative + * array (used by the `array` cast); otherwise it is a stdClass (used by + * the `json` cast). * - * @param string $value + * @param string $value + * @param bool $assoc * @return mixed */ - private function parseToJson($value): mixed + private function parseToJson($value, bool $assoc = false): mixed { return json_decode( $value, - false, + $assoc, 512, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE ); diff --git a/src/Database/Barry/Traits/EventTrait.php b/src/Database/Barry/Traits/EventTrait.php index 68e50470..b702765f 100644 --- a/src/Database/Barry/Traits/EventTrait.php +++ b/src/Database/Barry/Traits/EventTrait.php @@ -13,7 +13,7 @@ trait EventTrait * * @param string $event */ - private function fireEvent(string $event): void + protected function fireEvent(string $event): void { $env = static::formatEventName($event); @@ -26,7 +26,7 @@ private function fireEvent(string $event): void * @param string $event * @return string */ - private static function formatEventName(string $event): string + protected static function formatEventName(string $event): string { $class_name = str_replace('\\', '', strtolower(Str::snake(static::class))); diff --git a/src/Database/Barry/Traits/SoftDelete.php b/src/Database/Barry/Traits/SoftDelete.php new file mode 100644 index 00000000..810128a9 --- /dev/null +++ b/src/Database/Barry/Traits/SoftDelete.php @@ -0,0 +1,205 @@ +delete()` writes the current timestamp into the `deleted_at` + * column instead of physically removing the row. + * - `$model->restore()` clears `deleted_at`. + * - `$model->forceDelete()` performs a real DELETE. + * - Use the static query helpers `withTrashed()`, `withoutTrashed()`, and + * `onlyTrashed()` to scope your queries. + * + * Schema requirement: the table must carry a nullable `deleted_at` TIMESTAMP + * column. Bow's migration helper `$table->addSoftDelete()` adds it. + * + * The column name can be customised by declaring + * `protected string $deleted_at = 'archived_on';` + * on the model. + */ +trait SoftDelete +{ + /** + * Soft-delete this record by stamping the `deleted_at` column. + * + * Fires the standard `model.deleting` / `model.deleted` events so existing + * listeners keep working. Returns the number of affected rows (0 if the + * record had no primary-key value, was missing from the table, or is + * already trashed). + * + * @return int + */ + public function delete(): int + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return 0; + } + + $builder = static::query(); + + if (!$builder->exists($this->primary_key, $primary_key_value)) { + return 0; + } + + $this->fireEvent('model.deleting'); + + $now = date('Y-m-d H:i:s'); + + $updated = $builder->where($this->primary_key, $primary_key_value) + ->update([$this->getDeletedAtColumn() => $now]); + + if ($updated) { + $this->attributes[$this->getDeletedAtColumn()] = $now; + $this->fireEvent('model.deleted'); + } + + return $updated; + } + + /** + * Restore a soft-deleted record by clearing its `deleted_at` column. + * + * Fires `model.restoring` / `model.restored` events. Returns true on + * success. + */ + public function restore(): bool + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return false; + } + + $this->fireEvent('model.restoring'); + + $restored = static::query() + ->where($this->primary_key, $primary_key_value) + ->update([$this->getDeletedAtColumn() => null]); + + if ($restored) { + $this->attributes[$this->getDeletedAtColumn()] = null; + $this->fireEvent('model.restored'); + } + + return (bool) $restored; + } + + /** + * Force a physical DELETE that bypasses soft delete entirely. + * + * Fires `model.forceDeleting` / `model.forceDeleted` (the standard + * `model.deleting` / `model.deleted` are NOT fired by this method — + * subscribe to the force-delete events when you need to react to it). + */ + public function forceDelete(): int + { + $primary_key_value = $this->getKeyValue(); + + if ($primary_key_value === null) { + return 0; + } + + $this->fireEvent('model.forceDeleting'); + + $deleted = static::query() + ->where($this->primary_key, $primary_key_value) + ->delete(); + + if ($deleted) { + $this->fireEvent('model.forceDeleted'); + } + + return $deleted; + } + + /** + * Whether this instance has been soft-deleted. + */ + public function trashed(): bool + { + return !is_null($this->attributes[$this->getDeletedAtColumn()] ?? null); + } + + /** + * Resolve the `deleted_at` column name, honouring an optional + * `protected string $deleted_at = '...';` override on the model. + */ + public function getDeletedAtColumn(): string + { + return property_exists($this, 'deleted_at') && is_string($this->deleted_at) + ? $this->deleted_at + : 'deleted_at'; + } + + /** + * Start a query that excludes soft-deleted rows. + * + * User::withoutTrashed()->where('active', true)->get(); + */ + public static function withoutTrashed(): Builder + { + $instance = new static(); + return static::query()->whereNull($instance->getDeletedAtColumn()); + } + + /** + * Start a query that only returns soft-deleted rows. + */ + public static function onlyTrashed(): Builder + { + $instance = new static(); + return static::query()->whereNotNull($instance->getDeletedAtColumn()); + } + + /** + * Start a query that includes both active and soft-deleted rows. + * + * This is equivalent to `static::query()` and is provided as a readable + * intent marker. + */ + public static function withTrashed(): Builder + { + return static::query(); + } + + /** + * Register a `model.restoring` listener. + */ + public static function restoring(callable $cb): void + { + event()->once(static::formatEventName('model.restoring'), $cb); + } + + /** + * Register a `model.restored` listener. + */ + public static function restored(callable $cb): void + { + event()->once(static::formatEventName('model.restored'), $cb); + } + + /** + * Register a `model.forceDeleting` listener. + */ + public static function forceDeleting(callable $cb): void + { + event()->once(static::formatEventName('model.forceDeleting'), $cb); + } + + /** + * Register a `model.forceDeleted` listener. + */ + public static function forceDeleted(callable $cb): void + { + event()->once(static::formatEventName('model.forceDeleted'), $cb); + } +} diff --git a/src/Database/Migration/Shortcut/MixedColumn.php b/src/Database/Migration/Shortcut/MixedColumn.php index c1b9df03..5dc7d5ab 100644 --- a/src/Database/Migration/Shortcut/MixedColumn.php +++ b/src/Database/Migration/Shortcut/MixedColumn.php @@ -166,15 +166,15 @@ public function addMacAddress(string $column, array $attribute = []): Table public function addEnum(string $column, array $attribute = []): Table { if (!isset($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be define!"); + throw new SQLGeneratorException("Enum values are required: pass them under the 'size' key, e.g. ['size' => ['draft', 'published']]."); } if (!is_array($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be array"); + throw new SQLGeneratorException("Enum values under 'size' must be an array."); } if (count($attribute['size']) === 0) { - throw new SQLGeneratorException("The enum values cannot be empty."); + throw new SQLGeneratorException("Enum values under 'size' cannot be empty."); } return $this->addColumn($column, 'enum', $attribute); @@ -345,15 +345,15 @@ public function changeMacAddress(string $column, array $attribute = []): Table public function changeEnum(string $column, array $attribute = []): Table { if (!isset($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be define!"); + throw new SQLGeneratorException("Enum values are required: pass them under the 'size' key, e.g. ['size' => ['draft', 'published']]."); } if (!is_array($attribute['size'])) { - throw new SQLGeneratorException("The enum values should be array"); + throw new SQLGeneratorException("Enum values under 'size' must be an array."); } if (count($attribute['size']) === 0) { - throw new SQLGeneratorException("The enum values cannot be empty."); + throw new SQLGeneratorException("Enum values under 'size' cannot be empty."); } return $this->changeColumn($column, 'enum', $attribute); diff --git a/src/Router/AttributeRouteRegistrar.php b/src/Router/AttributeRouteRegistrar.php index 814defb6..43655599 100644 --- a/src/Router/AttributeRouteRegistrar.php +++ b/src/Router/AttributeRouteRegistrar.php @@ -6,101 +6,138 @@ use Bow\Router\Attributes\Controller; use Bow\Router\Attributes\Route as RouteAttribute; +use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; class AttributeRouteRegistrar { - /** - * The router instance - * - * @var Router - */ - private Router $router; - /** * @param Router $router */ - public function __construct(Router $router) + public function __construct(private readonly Router $router) { - $this->router = $router; } /** - * Register routes from controller classes + * Register routes from one or many controller classes. * - * @param string|array $controllers - * @return void + * @param class-string|list $controllers */ public function register(string|array $controllers): void { - $controllers = is_array($controllers) ? $controllers : [$controllers]; - - foreach ($controllers as $controller) { - $this->registerController($controller); + foreach ((array) $controllers as $controllerClass) { + $this->registerController($controllerClass); } } /** - * Register routes from controller - * - * @param string $controllerClass - * @return void + * Scan a single controller class and register all of its attribute routes. */ private function registerController(string $controllerClass): void { $reflection = new ReflectionClass($controllerClass); + $controllerAttribute = $this->resolveControllerAttribute($reflection); - // Get controller attribute - $controllerAttributes = $reflection->getAttributes(Controller::class); - $controllerAttribute = !empty($controllerAttributes) ? $controllerAttributes[0]->newInstance() : null; - - $prefix = $controllerAttribute?->getPrefix() ?? ''; - $controllerMiddleware = $controllerAttribute?->getMiddleware() ?? []; - - // Scan methods foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if (str_starts_with($method->getName(), '__')) { + if ($this->shouldSkipMethod($method, $reflection)) { continue; } - // Get route attributes - $routeAttributes = $method->getAttributes( - RouteAttribute::class, - \ReflectionAttribute::IS_INSTANCEOF - ); + $this->registerMethodRoutes($method, $controllerClass, $controllerAttribute); + } + } + + /** + * Resolve the `#[Controller]` attribute on the class, if present. Accepts + * subclasses of `Controller` via IS_INSTANCEOF. + */ + private function resolveControllerAttribute(ReflectionClass $reflection): ?Controller + { + $attributes = $reflection->getAttributes(Controller::class, ReflectionAttribute::IS_INSTANCEOF); + + return $attributes !== [] ? $attributes[0]->newInstance() : null; + } + + /** + * Skip magic methods and methods inherited from parent classes (those + * belong to whichever parent declared them, not this controller). + */ + private function shouldSkipMethod(ReflectionMethod $method, ReflectionClass $reflection): bool + { + if (str_starts_with($method->getName(), '__')) { + return true; + } + + return $method->getDeclaringClass()->getName() !== $reflection->getName(); + } - foreach ($routeAttributes as $attribute) { - /** @var RouteAttribute $routeAttr */ - $routeAttr = $attribute->newInstance(); + /** + * Register every `#[Route]`-derived attribute on a single controller method. + */ + private function registerMethodRoutes( + ReflectionMethod $method, + string $controllerClass, + ?Controller $controllerAttribute, + ): void { + $routeAttributes = $method->getAttributes( + RouteAttribute::class, + ReflectionAttribute::IS_INSTANCEOF, + ); + + foreach ($routeAttributes as $attribute) { + /** @var RouteAttribute $routeAttr */ + $routeAttr = $attribute->newInstance(); + + $route = $this->router->match( + $routeAttr->getMethods(), + $this->composePath($controllerAttribute, $routeAttr->getPath()), + [$controllerClass, $method->getName()], + ); - // Build path - $routePath = $routeAttr->getPath(); - $routePath = '/' . ltrim($routePath, '/'); - $fullPath = $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + $this->applyRouteOptions($route, $routeAttr, $controllerAttribute); + } + } - // Merge middleware - $middleware = array_merge($controllerMiddleware, $routeAttr->getMiddleware()); + /** + * Prepend the controller-level prefix to the route path, normalising + * leading/trailing slashes. + */ + private function composePath(?Controller $controllerAttribute, string $routePath): string + { + $routePath = '/' . ltrim($routePath, '/'); + $prefix = $controllerAttribute?->getPrefix() ?? ''; - // Register route - $route = $this->router->match( - $routeAttr->getMethods(), - $fullPath, - [$controllerClass, $method->getName()] - ); + return $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath; + } - if (!empty($middleware)) { - $route->middleware($middleware); - } + /** + * Apply middleware, parameter constraints, and route name from both the + * controller-level and route-level attributes. The controller's name + * acts as a prefix and is concatenated verbatim — callers control the + * separator (e.g. `name: 'users.'` + `name: 'index'` => `users.index`). + */ + private function applyRouteOptions( + Route $route, + RouteAttribute $routeAttr, + ?Controller $controllerAttribute, + ): void { + $middleware = array_merge( + $controllerAttribute?->getMiddleware() ?? [], + $routeAttr->getMiddleware(), + ); + + if ($middleware !== []) { + $route->middleware($middleware); + } - if (!empty($routeAttr->getWhere())) { - $route->where($routeAttr->getWhere()); - } + if ($routeAttr->getWhere() !== []) { + $route->where($routeAttr->getWhere()); + } - if ($routeAttr->getName() !== null) { - $route->name($routeAttr->getName()); - } - } + if ($routeAttr->getName() !== null) { + $namePrefix = $controllerAttribute?->getName() ?? ''; + $route->name($namePrefix . $routeAttr->getName()); } } } diff --git a/src/Router/README.md b/src/Router/README.md index 545655e8..6dd8770d 100644 --- a/src/Router/README.md +++ b/src/Router/README.md @@ -1,58 +1,111 @@ # Bow Router -Bow Framework's routing system is very simple with: +Bow Framework's routing system is small, expressive, and PHP 8 attribute-aware: -- Route naming support -- Route prefix support -- Route parameter catcher support +- HTTP verb helpers (`get`, `post`, `put`, `patch`, `delete`, `options`, `any`, `match`) +- Named routes, URL parameters and `where` constraints +- Route groups via `prefix()` and `domain()` +- Per-route and per-group middleware +- Attribute-driven controllers (`#[Controller]`, `#[Get]`, `#[Post]`, ...) +- Custom HTTP error handlers via `code()` -Let's show a little exemple: +## Quick start ```php -$app->get('/', function () { - return "Hello guy!"; +$app->get('/', fn() => 'Hello guy!'); + +$app->get('/users/:id', fn(int $id) => User::find($id)) + ->where('id', '\d+') + ->name('users.show'); + +$app->post('/users', [UserController::class, 'store']) + ->middleware(['auth', 'throttle:60,1']); +``` + +## Groups + +Share a prefix, middleware, or domain across many routes: + +```php +$app->prefix('/admin', function () use ($app) { + $app->get('/dashboard', [AdminController::class, 'index'])->name('admin.dashboard'); + $app->get('/users', [AdminController::class, 'users']); +})->middleware('admin'); + +$app->domain('{tenant}.example.com', function () use ($app) { + $app->get('/', [TenantController::class, 'show']); }); ``` -## Diagramme de flux du routage +## Attribute-based controllers + +Declare routing directly on the controller class — no central route file required: + +```php +use Bow\Router\Attributes\{Controller, Get, Post}; + +#[Controller(prefix: '/api/users', middleware: ['auth'], name: 'users.')] +final class UserController +{ + #[Get('/', name: 'index')] + public function index() { /* ... */ } + + #[Get('/:id', name: 'show')] + public function show(int $id) { /* ... */ } + + #[Post('/', name: 'store')] + public function store(Request $request) { /* ... */ } +} + +$app->register(UserController::class); +// — or pass an array of controllers to register a batch. +``` + +`#[Get]`, `#[Post]`, `#[Put]`, `#[Patch]`, `#[Delete]`, `#[Options]`, and the +generic `#[Route(methods: [...])]` are all available and repeatable, so a single +method can serve multiple verbs / paths. + +## Custom error handlers + +```php +$app->code(404, fn() => view('errors.404')); +$app->code(500, [ErrorController::class, 'serverError']); +``` + +## Request flow ```mermaid sequenceDiagram - participant Client as Client HTTP - participant Router as Router - participant Route as Route - participant Middleware as Middleware - participant Controller as Controller/Callback - participant Response as Response - - Note over Client,Response: Traitement d'une requête HTTP - - Client->>Router: Requête HTTP (GET /users) - - Router->>Router: match(uri) - - alt Route trouvée - Router->>Route: match(uri) - Route->>Route: checkRequestUri() - - alt Avec Middleware + participant Client as HTTP Client + participant Router + participant Route + participant Middleware + participant Handler as Controller / Callback + participant Response + + Note over Client,Response: HTTP request lifecycle + + Client->>Router: HTTP request (GET /users/42) + Router->>Router: match(uri, host) + + alt Route matched + Router->>Route: checkRequestUri() + opt Route has middleware Route->>Middleware: process(request) Middleware-->>Route: next(request) end - Route->>Route: getParameters() - Route->>Controller: call(parameters) - Controller-->>Response: return response - Response-->>Client: Envoie réponse HTTP - else Route non trouvée + Route->>Handler: call(parameters) + Handler-->>Response: returned value + Response-->>Client: HTTP response + else No route matched + Router->>Router: lookup code(404) handler Router-->>Response: 404 Not Found - Response-->>Client: Erreur 404 + Response-->>Client: 404 response end - Note over Client,Response: Exemple de définition de route - - Note right of Router: $app->get('/users/:id', function($id) { ... }) + Note right of Router: $app->get('/users/:id', fn($id) => ...) Note right of Router: $app->post('/users', [UserController::class, 'store']) ``` -Is very joyful api +Is very joyful api. diff --git a/src/Router/Router.php b/src/Router/Router.php index 8565a82a..31ee6b8d 100644 --- a/src/Router/Router.php +++ b/src/Router/Router.php @@ -9,11 +9,15 @@ class Router { /** - * Route collection. + * Route collection (per Router instance). + * + * Was `protected static` before — that caused routes from one Router to + * leak into the next when `Router::configure()` was called multiple times + * (e.g. between tests), since the static array outlived instance recreation. * * @var array */ - protected static array $routes = []; + protected array $routes = []; /** * Define the functions related to a http @@ -303,7 +307,7 @@ private function push(string|array $methods, string $path, callable|string|array $route->middleware($this->middlewares); foreach ($methods as $method) { - static::$routes[$method][] = $route; + $this->routes[$method][] = $route; // We define the current route and current method $this->current = ['path' => $path, 'method' => $method]; @@ -486,7 +490,7 @@ public function match(array $methods, string $path, callable|string|array $cb): */ public function getRoutes(): array { - return static::$routes; + return $this->routes; } /** diff --git a/src/Support/Env.php b/src/Support/Env.php index f02e14bc..858ed7ba 100644 --- a/src/Support/Env.php +++ b/src/Support/Env.php @@ -93,6 +93,16 @@ public static function configure(?string $filename = null): void static::$instance = new Env($filename); } + /** + * Reset the singleton state. Intended for test setup/teardown so a fresh + * configure() can load a different env file; not meant for production code. + */ + public static function reset(): void + { + static::$instance = null; + static::$loaded = false; + } + /** * Check if env is load * diff --git a/src/Testing/TestCase.php b/src/Testing/TestCase.php index 134d4eb7..5d5374fe 100644 --- a/src/Testing/TestCase.php +++ b/src/Testing/TestCase.php @@ -12,29 +12,32 @@ class TestCase extends PHPUnitTestCase { /** - * The base url + * The base url. If null, resolves to APP_URL env var, then to + * http://127.0.0.1:8080 (the default of `php bow run:server`). * * @var ?string */ protected ?string $url = null; + /** - * The request attachment collection + * Attachments to send with the next request. Cleared after each call. * * @var array */ private array $attach = []; + /** - * The list of additional header + * Headers applied to every request made by this test instance. + * Use withHeader() / withHeaders() to populate. Persists until the + * test ends or you reset it manually. * * @var array */ private array $headers = []; /** - * Add attachment - * - * @param array $attach - * @return TestCase + * Add files / multipart attachments to the next request. + * Cleared automatically after the request is sent. */ public function attach(array $attach): TestCase { @@ -44,10 +47,7 @@ public function attach(array $attach): TestCase } /** - * Specify the additional headers - * - * @param array $headers - * @return TestCase + * Replace the header map applied to every request. */ public function withHeaders(array $headers): TestCase { @@ -57,11 +57,7 @@ public function withHeaders(array $headers): TestCase } /** - * Specify the additional header - * - * @param string $key - * @param string $value - * @return TestCase + * Add (or override) a single header. */ public function withHeader(string $key, string $value): TestCase { @@ -71,128 +67,123 @@ public function withHeader(string $key, string $value): TestCase } /** - * Get request + * GET request. * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ public function get(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - $http->withHeaders($this->headers); - - return new Response($http->get($url, $param)); + return new Response($this->newHttpClient()->get($url, $param)); } /** - * Get the base url + * POST request. * - * @return string + * @throws Exception */ - private function getBaseUrl(): string + public function post(string $url, array $param = []): Response { - return $this->url ?? rtrim(app_env('APP_URL', 'http://127.0.0.1:5000')); + return new Response($this->newHttpClient()->post($url, $param)); } /** - * Post Request + * PUT request. * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function post(string $url, array $param = []): Response + public function put(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - if (!empty($this->attach)) { - $http->addAttach($this->attach); - } - - $http->withHeaders($this->headers); + return new Response($this->newHttpClient()->put($url, $param)); + } - return new Response($http->post($url, $param)); + /** + * PATCH request (real HTTP PATCH — no _method POST hack). + * + * @throws Exception + */ + public function patch(string $url, array $param = []): Response + { + return new Response($this->newHttpClient()->patch($url, $param)); } /** - * Delete Request + * DELETE request (real HTTP DELETE — no _method POST hack). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ public function delete(string $url, array $param = []): Response { - $param = array_merge( - [ - '_method' => 'DELETE' - ], - $param - ); - - return $this->put($url, $param); + return new Response($this->newHttpClient()->delete($url, $param)); } /** - * Put Request + * HEAD request (headers only, no body). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function put(string $url, array $param = []): Response + public function head(string $url, array $param = []): Response { - $http = new HttpClient($this->getBaseUrl()); - - $http->withHeaders($this->headers); - - return new Response($http->put($url, $param)); + return new Response($this->newHttpClient()->head($url, $param)); } /** - * Patch Request + * OPTIONS request (typically for CORS preflight). * - * @param string $url - * @param array $param - * @return Response * @throws Exception */ - public function patch(string $url, array $param = []): Response + public function options(string $url): Response { - $param = array_merge( - [ - '_method' => 'PATCH' - ], - $param - ); - - return $this->put($url, $param); + return new Response($this->newHttpClient()->options($url)); } /** - * Initialize Response action + * Dispatch a request by HTTP verb name. * - * @param string $method - * @param string $url - * @param array $params - * @return Response + * @throws BadMethodCallException */ public function visit(string $method, string $url, array $params = []): Response { $method = strtolower($method); + $allowed = ['get', 'post', 'put', 'patch', 'delete', 'head', 'options']; - if (!method_exists($this, $method)) { + if (!in_array($method, $allowed, true)) { throw new BadMethodCallException( 'The HTTP [' . $method . '] method does not exists.' ); } - return $this->$method($url, $params); + return $method === 'options' + ? $this->options($url) + : $this->$method($url, $params); + } + + /** + * Build a fresh HttpClient pre-configured with the current headers and + * pending attachments. Attachments are consumed (reset) after this call; + * headers persist for the lifetime of the test instance. + */ + protected function newHttpClient(): HttpClient + { + $http = new HttpClient($this->getBaseUrl()); + + if ($this->headers !== []) { + $http->withHeaders($this->headers); + } + + if ($this->attach !== []) { + $http->addAttach($this->attach); + $this->attach = []; // consume — don't leak into the next call + } + + return $http; + } + + /** + * Resolve the base URL. Override this in a subclass for more elaborate + * setups (per-test base URLs, computed from env, etc.). + */ + protected function getBaseUrl(): string + { + return rtrim($this->url ?? app_env('APP_URL', 'http://127.0.0.1:8080'), '/'); } } diff --git a/src/Validation/Rules/BetweenRule.php b/src/Validation/Rules/BetweenRule.php new file mode 100644 index 00000000..8475025d --- /dev/null +++ b/src/Validation/Rules/BetweenRule.php @@ -0,0 +1,57 @@ +inputs[$key] ?? null; + + $size = match (true) { + is_int($value) || is_float($value) => $value, + is_numeric($value) => +$value, + is_string($value) => Str::len($value), + default => null, + }; + + if ($size !== null && $size >= $min && $size <= $max) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('between', [ + 'attribute' => $key, + 'min' => $min, + 'max' => $max, + ]); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/BooleanRule.php b/src/Validation/Rules/BooleanRule.php new file mode 100644 index 00000000..ce2711c9 --- /dev/null +++ b/src/Validation/Rules/BooleanRule.php @@ -0,0 +1,41 @@ +inputs[$key] ?? null; + $accepted = [true, false, 0, 1, '0', '1', 'true', 'false']; + + if (in_array($value, $accepted, true)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('boolean', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/ConfirmedRule.php b/src/Validation/Rules/ConfirmedRule.php new file mode 100644 index 00000000..20abbc30 --- /dev/null +++ b/src/Validation/Rules/ConfirmedRule.php @@ -0,0 +1,42 @@ +_confirmation`. Common pattern for password / email confirmation. + * + * @param string $key + * @param string $masque + * @return void + */ + protected function compileConfirmed(string $key, string $masque): void + { + if (!preg_match("/^confirmed$/", $masque)) { + return; + } + + $confirmation_key = $key . '_confirmation'; + $value = $this->inputs[$key] ?? null; + $confirmation = $this->inputs[$confirmation_key] ?? null; + + if ($value === $confirmation) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('confirmed', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/DifferentRule.php b/src/Validation/Rules/DifferentRule.php new file mode 100644 index 00000000..91de8d14 --- /dev/null +++ b/src/Validation/Rules/DifferentRule.php @@ -0,0 +1,46 @@ +inputs[$key] ?? null; + $other = $this->inputs[$other_key] ?? null; + + if ($value !== $other) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('different', [ + 'attribute' => $key, + 'other' => $other_key, + ]); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/IpRule.php b/src/Validation/Rules/IpRule.php new file mode 100644 index 00000000..62abe6a2 --- /dev/null +++ b/src/Validation/Rules/IpRule.php @@ -0,0 +1,44 @@ + FILTER_FLAG_IPV4, + 'v6' => FILTER_FLAG_IPV6, + default => 0, + }; + + if (filter_var($this->inputs[$key] ?? '', FILTER_VALIDATE_IP, $flags)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('ip', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/JsonRule.php b/src/Validation/Rules/JsonRule.php new file mode 100644 index 00000000..780c91db --- /dev/null +++ b/src/Validation/Rules/JsonRule.php @@ -0,0 +1,43 @@ +inputs[$key] ?? null; + + if (is_string($value) && $value !== '') { + json_decode($value); + if (json_last_error() === JSON_ERROR_NONE) { + return; + } + } + + $this->fails = true; + + $this->last_message = $this->lexical('json', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/UrlRule.php b/src/Validation/Rules/UrlRule.php new file mode 100644 index 00000000..550a8b0b --- /dev/null +++ b/src/Validation/Rules/UrlRule.php @@ -0,0 +1,37 @@ +inputs[$key] ?? '', FILTER_VALIDATE_URL)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('url', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Rules/UuidRule.php b/src/Validation/Rules/UuidRule.php new file mode 100644 index 00000000..01cefade --- /dev/null +++ b/src/Validation/Rules/UuidRule.php @@ -0,0 +1,41 @@ +inputs[$key] ?? ''); + $pattern = '/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i'; + + if (preg_match($pattern, $value)) { + return; + } + + $this->fails = true; + + $this->last_message = $this->lexical('uuid', $key); + + $this->errors[$key][] = [ + "masque" => $masque, + "message" => $this->last_message, + ]; + } +} diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php index 687acdb5..27fe600a 100644 --- a/src/Validation/Validator.php +++ b/src/Validation/Validator.php @@ -5,13 +5,21 @@ namespace Bow\Validation; use Bow\Support\Str; +use Bow\Validation\Rules\BetweenRule; +use Bow\Validation\Rules\BooleanRule; +use Bow\Validation\Rules\ConfirmedRule; use Bow\Validation\Rules\DatabaseRule; use Bow\Validation\Rules\DatetimeRule; +use Bow\Validation\Rules\DifferentRule; use Bow\Validation\Rules\EmailRule; +use Bow\Validation\Rules\IpRule; +use Bow\Validation\Rules\JsonRule; use Bow\Validation\Rules\NullableRule; use Bow\Validation\Rules\NumericRule; use Bow\Validation\Rules\RegexRule; use Bow\Validation\Rules\StringRule; +use Bow\Validation\Rules\UrlRule; +use Bow\Validation\Rules\UuidRule; class Validator { @@ -23,6 +31,14 @@ class Validator use StringRule; use RegexRule; use NullableRule; + use UrlRule; + use IpRule; + use BooleanRule; + use JsonRule; + use UuidRule; + use ConfirmedRule; + use DifferentRule; + use BetweenRule; /** * The Fails flag @@ -87,6 +103,14 @@ class Validator 'NotExists', 'Unique', 'Exists', + 'Url', + 'Ip', + 'Boolean', + 'Json', + 'Uuid', + 'Confirmed', + 'Different', + 'Between', ]; /** @@ -170,20 +194,28 @@ public function validate(array $inputs, array $rules): Validate */ private function checkRule(string $rule, string $field): void { - foreach (explode("|", $rule) as $masque) { + $masques = explode("|", $rule); + // `required` always runs, even when `nullable` matched — an explicit + // `required` is an unconditional contract. + $required_declared = in_array('required', $masques, true); + + foreach ($masques as $masque) { // In the box there is a | super flux. if (is_int($masque) || Str::len($masque) == "") { continue; } if ($masque == "nullable" && $this->compileNullable($field, $masque)) { + if ($required_declared) { + continue; + } break; } // Mask on the required rule - foreach ($this->rules as $rule) { - $this->{'compile' . $rule}($field, $masque); - if ($rule == 'Required' && $this->fails) { + foreach ($this->rules as $rule_item) { + $this->{'compile' . $rule_item}($field, $masque); + if ($rule_item == 'Required' && $this->fails) { break; } } diff --git a/src/Validation/stubs/lexical.php b/src/Validation/stubs/lexical.php index b0fc4fb2..ba9e9da6 100644 --- a/src/Validation/stubs/lexical.php +++ b/src/Validation/stubs/lexical.php @@ -22,4 +22,12 @@ 'date' => 'The {attribute} field must use the format: yyyy-mm-dd', 'datetime' => 'The {attribute} field must use the format: yyyy-mm-dd hh:mm:ss', 'regex' => 'The {attribute} field does not match the pattern', + 'url' => 'The {attribute} field must be a valid URL.', + 'ip' => 'The {attribute} field must be a valid IP address.', + 'boolean' => 'The {attribute} field must be true or false.', + 'json' => 'The {attribute} field must be a valid JSON string.', + 'uuid' => 'The {attribute} field must be a valid UUID.', + 'confirmed' => 'The {attribute} field confirmation does not match.', + 'different' => 'The {attribute} field must be different from {other}.', + 'between' => 'The {attribute} field must be between {min} and {max}.', ]; diff --git a/tests/Database/Query/PaginationTest.php b/tests/Database/Query/PaginationTest.php index bcbdd4d5..9c2fe656 100644 --- a/tests/Database/Query/PaginationTest.php +++ b/tests/Database/Query/PaginationTest.php @@ -67,7 +67,7 @@ public function test_go_current_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(1, $result->current()); $this->assertEquals(1, $result->previous()); $this->assertEquals(2, $result->next()); @@ -118,7 +118,7 @@ public function test_go_next_2_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(2, $result->current()); $this->assertEquals(1, $result->previous()); $this->assertEquals(3, $result->next()); @@ -154,7 +154,7 @@ public function test_go_next_3_pagination(string $name) $this->assertInstanceOf(Pagination::class, $result); $this->assertCount(10, $result->items()); $this->assertEquals(10, $result->perPage()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); $this->assertEquals(3, $result->current()); $this->assertEquals(2, $result->previous()); $this->assertEquals(0, $result->next()); // No next page = 0 @@ -196,7 +196,7 @@ public function test_pagination_with_different_per_page(string $name) $this->assertCount(5, $result->items()); $this->assertEquals(5, $result->perPage()); - $this->assertEquals(6, $result->total()); // 30 / 5 = 6 pages + $this->assertEquals(6, $result->totalPages()); // 30 / 5 = 6 pages } /** @@ -209,7 +209,7 @@ public function test_pagination_with_large_per_page(string $name) $this->assertCount(30, $result->items()); // Only 30 items total $this->assertEquals(50, $result->perPage()); - $this->assertEquals(1, $result->total()); // Only 1 page + $this->assertEquals(1, $result->totalPages()); // Only 1 page $this->assertFalse($result->hasNext()); } @@ -221,7 +221,7 @@ public function test_pagination_with_exact_division(string $name) $this->createTestingTable($name, 20); // Exactly 20 items $result = Database::connection($name)->table("pets")->paginate(10); - $this->assertEquals(2, $result->total()); // Exactly 2 pages + $this->assertEquals(2, $result->totalPages()); // Exactly 2 pages // Navigate to page 2 $page2 = Database::connection($name)->table("pets")->paginate(10, 2); @@ -281,7 +281,7 @@ public function test_single_page_pagination(string $name) $result = Database::connection($name)->table("pets")->paginate(10); $this->assertCount(5, $result->items()); - $this->assertEquals(1, $result->total()); + $this->assertEquals(1, $result->totalPages()); $this->assertEquals(1, $result->current()); $this->assertFalse($result->hasNext()); // hasPrevious() is true if previous != 0, and previous is 1 on page 1 @@ -317,7 +317,7 @@ public function test_pagination_with_where_clause(string $name) // Just verify pagination works with WHERE clause $this->assertCount(10, $result->items()); - $this->assertEquals(3, $result->total()); + $this->assertEquals(3, $result->totalPages()); } /** diff --git a/tests/Database/Query/SoftDeleteTest.php b/tests/Database/Query/SoftDeleteTest.php new file mode 100644 index 00000000..1f398e98 --- /dev/null +++ b/tests/Database/Query/SoftDeleteTest.php @@ -0,0 +1,181 @@ +statement('DROP TABLE IF EXISTS pets'); + } catch (\Exception $e) { + // ignore + } + } + parent::tearDown(); + } + + public function connectionNameProvider(): array + { + return [['mysql'], ['sqlite'], ['pgsql']]; + } + + private function createTestingTable(string $name): void + { + $connection = Database::connection($name); + + $sql = match ($name) { + 'pgsql' => 'CREATE TABLE pets (id SERIAL PRIMARY KEY, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + 'sqlite' => 'CREATE TABLE pets (id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + 'mysql' => 'CREATE TABLE pets (id INT NOT NULL PRIMARY KEY AUTO_INCREMENT, name VARCHAR(255), deleted_at TIMESTAMP NULL)', + default => throw new \InvalidArgumentException("Unsupported database: $name"), + }; + + $connection->statement('DROP TABLE IF EXISTS pets'); + $connection->statement($sql); + $connection->insert('INSERT INTO pets(name) VALUES(:name)', [ + ['name' => 'Milou'], + ['name' => 'Couli'], + ['name' => 'Bobi'], + ]); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_delete_writes_deleted_at_instead_of_removing_row(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first(); + $this->assertNotNull($pet); + + $affected = $pet->delete(); + $this->assertSame(1, $affected); + + // Row is still in the table but marked as deleted + $total = (int) Database::connection($name) + ->select('SELECT COUNT(*) AS n FROM pets')[0]->n; + $this->assertSame(3, $total); + + $reloaded = SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first(); + $this->assertNotNull($reloaded->deleted_at); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_trashed_reports_state(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $this->assertFalse($pet->trashed()); + + $pet->delete(); + + $this->assertTrue($pet->trashed()); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_withoutTrashed_excludes_soft_deleted(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Bobi')->first()->delete(); + + $active = SoftDeletePetModelStub::withoutTrashed()->get(); + + $this->assertCount(2, $active); + foreach ($active as $row) { + $this->assertNotEquals('Bobi', $row->name); + } + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_onlyTrashed_returns_only_soft_deleted(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first()->delete(); + + $trashed = SoftDeletePetModelStub::onlyTrashed()->get(); + + $this->assertCount(1, $trashed); + $this->assertSame('Milou', $trashed->first()->name); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_restore_clears_deleted_at(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $pet->delete(); + $this->assertTrue($pet->trashed()); + + $restored = $pet->restore(); + $this->assertTrue($restored); + $this->assertFalse($pet->trashed()); + + // Confirms the row also reads back as un-trashed from the DB + $reloaded = SoftDeletePetModelStub::withTrashed()->where('name', 'Couli')->first(); + $this->assertNull($reloaded->deleted_at); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_forceDelete_removes_row_physically(string $name): void + { + $this->createTestingTable($name); + + $pet = SoftDeletePetModelStub::withTrashed()->where('name', 'Bobi')->first(); + + $affected = $pet->forceDelete(); + $this->assertSame(1, $affected); + + $total = (int) Database::connection($name) + ->select('SELECT COUNT(*) AS n FROM pets')[0]->n; + $this->assertSame(2, $total); + } + + /** + * @dataProvider connectionNameProvider + */ + public function test_withTrashed_returns_all_rows(string $name): void + { + $this->createTestingTable($name); + + SoftDeletePetModelStub::withTrashed()->where('name', 'Milou')->first()->delete(); + + $all = SoftDeletePetModelStub::withTrashed()->get(); + + $this->assertCount(3, $all); // active + trashed + } +} diff --git a/tests/Database/Stubs/SoftDeletePetModelStub.php b/tests/Database/Stubs/SoftDeletePetModelStub.php new file mode 100644 index 00000000..16666e64 --- /dev/null +++ b/tests/Database/Stubs/SoftDeletePetModelStub.php @@ -0,0 +1,23 @@ +assertInstanceOf(Router::class, $result); } + public function test_controller_name_prefixes_route_names(): void + { + $this->router->register(NamedUserControllerStub::class); + + $names = []; + foreach ($this->router->getRoutes()['GET'] ?? [] as $route) { + if ($route->getName() !== null) { + $names[] = $route->getName(); + } + } + + $this->assertContains('users.index', $names); + $this->assertContains('users.show', $names); + } + + public function test_inherited_methods_are_not_registered(): void + { + $this->router->register(ChildControllerStub::class); + + $paths = array_map( + fn($route) => $route->getPath(), + $this->router->getRoutes()['GET'] ?? [], + ); + + $childPaths = array_filter( + $paths, + fn(string $path) => str_starts_with($path, '/child'), + ); + + // The parent's #[Get('/inherited')] must not be registered for the child. + foreach ($childPaths as $path) { + $this->assertStringNotContainsString('/inherited', $path); + } + + // The child's own route must still be there. + $this->assertNotEmpty(array_filter( + $childPaths, + fn(string $path) => str_contains($path, '/own'), + )); + } + public function test_route_middleware_is_applied_correctly(): void { $this->router->register(UserControllerStub::class); diff --git a/tests/Routing/Stubs/ChildControllerStub.php b/tests/Routing/Stubs/ChildControllerStub.php new file mode 100644 index 00000000..eb3d3ad4 --- /dev/null +++ b/tests/Routing/Stubs/ChildControllerStub.php @@ -0,0 +1,17 @@ +assertFalse($validation->fails()); } + + // ==================== Url Rule ==================== + + public function test_url_rule_passes_with_valid_url() + { + $validation = Validator::make(['site' => 'https://example.com/path?x=1'], ['site' => 'url']); + $this->assertFalse($validation->fails()); + } + + public function test_url_rule_fails_with_invalid_url() + { + $validation = Validator::make(['site' => 'not-a-url'], ['site' => 'url']); + $this->assertTrue($validation->fails()); + } + + // ==================== Ip Rule ==================== + + public function test_ip_rule_passes_with_ipv4() + { + $validation = Validator::make(['addr' => '192.168.1.1'], ['addr' => 'ip']); + $this->assertFalse($validation->fails()); + } + + public function test_ip_rule_passes_with_ipv6() + { + $validation = Validator::make(['addr' => '::1'], ['addr' => 'ip']); + $this->assertFalse($validation->fails()); + } + + public function test_ip_rule_v4_rejects_ipv6() + { + $validation = Validator::make(['addr' => '::1'], ['addr' => 'ip:v4']); + $this->assertTrue($validation->fails()); + } + + public function test_ip_rule_v6_rejects_ipv4() + { + $validation = Validator::make(['addr' => '192.168.1.1'], ['addr' => 'ip:v6']); + $this->assertTrue($validation->fails()); + } + + public function test_ip_rule_fails_with_garbage() + { + $validation = Validator::make(['addr' => '999.999.999.999'], ['addr' => 'ip']); + $this->assertTrue($validation->fails()); + } + + // ==================== Boolean Rule ==================== + + public function test_boolean_rule_passes_with_boolean_values() + { + foreach ([true, false, 0, 1, '0', '1', 'true', 'false'] as $value) { + $validation = Validator::make(['flag' => $value], ['flag' => 'boolean']); + $this->assertFalse($validation->fails(), 'Failed for ' . var_export($value, true)); + } + } + + public function test_boolean_rule_accepts_bool_alias() + { + $validation = Validator::make(['flag' => true], ['flag' => 'bool']); + $this->assertFalse($validation->fails()); + } + + public function test_boolean_rule_fails_with_non_boolean() + { + $validation = Validator::make(['flag' => 'yes'], ['flag' => 'boolean']); + $this->assertTrue($validation->fails()); + } + + // ==================== Json Rule ==================== + + public function test_json_rule_passes_with_valid_json() + { + $validation = Validator::make(['payload' => '{"a":1,"b":[2,3]}'], ['payload' => 'json']); + $this->assertFalse($validation->fails()); + } + + public function test_json_rule_fails_with_invalid_json() + { + $validation = Validator::make(['payload' => '{not json}'], ['payload' => 'json']); + $this->assertTrue($validation->fails()); + } + + public function test_json_rule_fails_with_empty_string() + { + $validation = Validator::make(['payload' => ''], ['payload' => 'json']); + $this->assertTrue($validation->fails()); + } + + // ==================== Uuid Rule ==================== + + public function test_uuid_rule_passes_with_valid_uuid_v4() + { + $validation = Validator::make( + ['id' => '550e8400-e29b-41d4-a716-446655440000'], + ['id' => 'uuid'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_uuid_rule_fails_with_invalid_uuid() + { + $validation = Validator::make(['id' => 'not-a-uuid'], ['id' => 'uuid']); + $this->assertTrue($validation->fails()); + } + + public function test_uuid_rule_fails_with_wrong_format() + { + $validation = Validator::make( + ['id' => '550e8400e29b41d4a716446655440000'], // no dashes + ['id' => 'uuid'] + ); + $this->assertTrue($validation->fails()); + } + + // ==================== Confirmed Rule ==================== + + public function test_confirmed_rule_passes_when_matching() + { + $validation = Validator::make( + ['password' => 'secret', 'password_confirmation' => 'secret'], + ['password' => 'confirmed'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_confirmed_rule_fails_when_mismatched() + { + $validation = Validator::make( + ['password' => 'secret', 'password_confirmation' => 'other'], + ['password' => 'confirmed'] + ); + $this->assertTrue($validation->fails()); + } + + public function test_confirmed_rule_fails_when_confirmation_missing() + { + $validation = Validator::make(['password' => 'secret'], ['password' => 'confirmed']); + $this->assertTrue($validation->fails()); + } + + // ==================== Different Rule ==================== + + public function test_different_rule_passes_when_values_differ() + { + $validation = Validator::make( + ['username' => 'alice', 'email' => 'alice@example.com'], + ['username' => 'different:email'] + ); + $this->assertFalse($validation->fails()); + } + + public function test_different_rule_fails_when_values_match() + { + $validation = Validator::make( + ['old_password' => 'secret', 'new_password' => 'secret'], + ['new_password' => 'different:old_password'] + ); + $this->assertTrue($validation->fails()); + } + + // ==================== Between Rule ==================== + + public function test_between_rule_passes_for_string_length_in_range() + { + $validation = Validator::make(['name' => 'Milou'], ['name' => 'between:3,10']); + $this->assertFalse($validation->fails()); + } + + public function test_between_rule_fails_for_string_length_too_short() + { + $validation = Validator::make(['name' => 'Mi'], ['name' => 'between:3,10']); + $this->assertTrue($validation->fails()); + } + + public function test_between_rule_fails_for_string_length_too_long() + { + $validation = Validator::make( + ['name' => 'A very, very long name indeed'], + ['name' => 'between:3,10'] + ); + $this->assertTrue($validation->fails()); + } + + public function test_between_rule_passes_for_numeric_value_in_range() + { + $validation = Validator::make(['age' => 25], ['age' => 'between:18,65']); + $this->assertFalse($validation->fails()); + } + + public function test_between_rule_fails_for_numeric_value_out_of_range() + { + $validation = Validator::make(['age' => 5], ['age' => 'between:18,65']); + $this->assertTrue($validation->fails()); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 2e7235a6..8d3dd910 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -7,3 +7,21 @@ } require __DIR__ . "/../vendor/autoload.php"; + +/* +| Silence PHP 8.4's "implicitly nullable parameter" deprecations that +| originate in third-party vendor code we cannot upgrade: +| +| - spatie/phpunit-snapshot-assertions 4.2.17 (last of the 4.x line; +| 5.x needs PHPUnit 10) +| - lcobucci/jwt 3.2.5 (pinned by bowphp/policier) +| +| Framework-code deprecations are NOT silenced — they fall through to PHP's +| default handler so we still see anything that needs fixing in src/. +*/ +set_error_handler(static function (int $severity, string $message, string $file): bool { + $vendor_deprecation = ($severity === E_DEPRECATED || $severity === E_USER_DEPRECATED) + && str_contains($file, '/vendor/'); + + return $vendor_deprecation; // true = swallow; false = let PHP handle it +}); From 35eb6e0e3afa54bd50af8a3c161e54fa8d662b87 Mon Sep 17 00:00:00 2001 From: Franck DAKIA Date: Thu, 21 May 2026 00:47:36 +0000 Subject: [PATCH 29/60] Update readme --- ROADMAP.md | 110 ++++++++++++++++++++++++++++++++++++++++------------- readme.md | 56 ++++++++++++++++----------- 2 files changed, 116 insertions(+), 50 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 6c5fbb60..c49facf7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # Roadmap BowPHP Framework > Document évolutif basé sur l'analyse du code source (branche 5.x) et le manifeste du projet. -> Dernière mise à jour : Janvier 2026 +> Dernière mise à jour : Mai 2026 --- @@ -56,35 +56,89 @@ --- +## ✅ Récemment livré (printemps 2026) + +Faits saillants des dernières itérations — déjà mergés sur `5.x`. Tous les détails dans le CHANGELOG. + +### Routing + +- Routage par attributs PHP 8 (cf. section dédiée plus bas). +- `Router::$routes` rendu instance (corrige les fuites d'état entre tests). +- Préfixe de nom du `#[Controller]` appliqué aux routes filles ; méthodes héritées ignorées au scan. + +### Barry ORM + +- Trait `SoftDelete` (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, événements `model.restoring/restored/forceDeleting/forceDeleted`). +- Cast `array` réparé : ne renvoie plus un `stdClass`. +- Propriété morte `$soft_delete` supprimée (remplacée par le trait). +- Visibilité `EventTrait::fireEvent` / `formatEventName` élargie à `protected` pour les traits enfants. + +### Validation + +- Nouvelles règles : `url`, `ip` (+ `ip:v4`, `ip:v6`), `boolean`, `json`, `uuid`, `confirmed`, `different:field`, `between:min,max`. +- Priorité corrigée : `nullable|required` laisse `required` s'exécuter (et l'inner-loop break utilise enfin la bonne variable). + +### Infrastructure de test + +- `TestCase` refactoré : vrai DELETE/PATCH (plus de hack `_method`), `head()` / `options()`, factorisation via `newHttpClient()`, reset auto des attachements, port par défaut 8080. +- `Env::reset()` ajouté pour la propreté entre suites de tests. +- `SchedulerCommand` charge automatiquement `routes/scheduler.php` et tolère un Loader manquant. +- `addEnum` / `changeEnum` : messages d'erreur explicites (mentionnent la clé `size`). +- Bootstrap des tests filtre les `E_DEPRECATED` issus de `vendor/` (lcobucci/jwt v3.2.5, spatie 4.x). +- Pagination : tests appelaient `total()` au lieu de `totalPages()` — 24 cas corrigés. + +### Tintin (vendoré) + +- Cache atomique (`rename`), `mkdir` récursif, invalidation par `filemtime` (au lieu de `fileatime`). +- `Compiler::compile` ne perd plus les lignes vides ; ajout d'un post-pass `?>\n\n` pour préserver l'indentation des snippets `
/`.
+-   `Tintin::renderString` utilise `tempnam()` + `try/finally` ; suppression du `trim()` destructif.
+-   Heuristique d'échappement `{{ ... }}` resserrée mais compatible Vue/Angular.
+-   `directivesProtected` enrichi (csrf, macro/endmacro, lang, flash, notempty…).
+
+### Documentation & READMEs
+
+-   Audit complet de `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Mail, Storage, Messaging, Container, Pagination, Scheduler, Task, Testing, Configuration, Concept, Controller, CQRS, Database, Policier, Service, Session, SoAuth, Structure, Upload, View, Package, Contribution).
+-   README mis à jour (badges, compteurs de tests, soft delete, attribut routing, helpers de commande).
+-   `microservice` (sous-projet) : refactor de `MicroserviceConfiguration` (extends `Configuration`, PSR-4 propre), `microservice.php` Bow-intégré, namespace `Bow\Console\Command\Generator` corrigé.
+
+---
+
 ## 🔴 NOW — 0 à 3 mois (Stabilisation & Consolidation)
 
 ### Tests et CI/CD
 
-| Tâche                                               | Statut     | Priorité | Notes                                                  |
-| --------------------------------------------------- | ---------- | -------- | ------------------------------------------------------ |
-| Séparer les tests unitaires des tests d'intégration | ⏳ À faire | Haute    | Les tests DB/FTP/S3 nécessitent des services externes  |
-| Ajouter `@group` PHPUnit pour isoler les tests      | ⏳ À faire | Haute    | `@group unit`, `@group integration`, `@group database` |
-| Configurer GitHub Actions avec services Docker      | ⏳ À faire | Haute    | MySQL, PostgreSQL, Redis pour CI                       |
-| Augmenter couverture tests unitaires > 80%          | ⏳ À faire | Moyenne  | Focus sur modules critiques                            |
-| Intégrer PHPStan niveau 5+                          | ⏳ À faire | Moyenne  | Actuellement niveau 0.12.87                            |
+| Tâche                                               | Statut       | Priorité | Notes                                                  |
+| --------------------------------------------------- | ------------ | -------- | ------------------------------------------------------ |
+| Séparer les tests unitaires des tests d'intégration | ⏳ À faire   | Haute    | Les tests DB/FTP/S3 nécessitent des services externes  |
+| Ajouter `@group` PHPUnit pour isoler les tests      | ⏳ À faire   | Haute    | `@group unit`, `@group integration`, `@group database` |
+| Configurer GitHub Actions avec services Docker      | ⏳ À faire   | Haute    | MySQL, PostgreSQL, Redis pour CI                       |
+| Augmenter couverture tests unitaires                | 🔄 En cours  | Moyenne  | 1 600+ tests, 0 échec logique. Ajouts récents : SoftDelete, AttributeRouteRegistrar, nouvelles règles de validation, Pagination. |
+| Intégrer PHPStan niveau 5+                          | ⏳ À faire   | Moyenne  | Constraint actuel : `phpstan/phpstan: ^0.12.87` — bumper vers ^1.x avant de cibler un niveau plus élevé |
 
 ### Corrections de Code
 
-| Tâche                                           | Statut     | Priorité | Notes                                 |
-| ----------------------------------------------- | ---------- | -------- | ------------------------------------- |
-| Fixer les tests SQLite qui échouent (isolation) | ⏳ À faire | Haute    | Problème de state partagé entre tests |
-| Uniformiser les signatures de méthodes          | ✅ Fait    | -        | PHP 8.1+ nullable types               |
-| Fixer le cast `(double)` → `(float)`            | ✅ Fait    | -        | Model.php ligne 924                   |
-| Gérer `array_key_exists` avec clé null          | ✅ Fait    | -        | Console.php                           |
-| Créer le répertoire de test si inexistant       | ✅ Fait    | -        | CustomCommand.php                     |
+| Tâche                                                                | Statut     | Priorité | Notes                                                                  |
+| -------------------------------------------------------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
+| Fixer le test d'attribut middleware (state partagé entre tests)      | ✅ Fait    | -        | `Router::$routes` rendue instance (n'était plus partagée entre tests)  |
+| Fixer les tests Pagination qui appelaient `total()` au lieu de `totalPages()` | ✅ Fait    | -        | 24 tests corrigés                                                      |
+| Fixer le cast `array` du modèle Barry qui renvoyait `stdClass`       | ✅ Fait    | -        | `Model::executeDataCasting` + `parseToJson($value, assoc: true)`       |
+| Fixer la priorité `nullable\|required` du Validator                  | ✅ Fait    | -        | `nullable` ne court-circuite plus `required`                           |
+| Fixer `EnvTest` (pollution du singleton entre tests)                 | ✅ Fait    | -        | `Env::reset()` ajouté                                                  |
+| Fixer `SchedulerCommand` (chargement de `routes/scheduler.php`)      | ✅ Fait    | -        | `loadSchedulerFile()` mis à jour, tolère un Loader manquant            |
+| Retirer la propriété morte `Model::$soft_delete`                     | ✅ Fait    | -        | Remplacée par un trait fonctionnel (cf. Soft delete plus bas)          |
+| Améliorer les messages d'erreur de `addEnum` / `changeEnum`          | ✅ Fait    | -        | Mentionnent explicitement la clé `size`                                |
+| Uniformiser les signatures de méthodes                               | ✅ Fait    | -        | PHP 8.1+ nullable types                                                |
+| Fixer le cast `(double)` → `(float)`                                 | ✅ Fait    | -        | Model.php                                                              |
+| Gérer `array_key_exists` avec clé null                               | ✅ Fait    | -        | Console.php                                                            |
+| Créer le répertoire de test si inexistant                            | ✅ Fait    | -        | CustomCommand.php                                                      |
 
 ### Documentation
 
-| Tâche                                        | Statut     | Priorité | Notes                      |
-| -------------------------------------------- | ---------- | -------- | -------------------------- |
-| Mettre à jour README avec exemples API-first | ⏳ À faire | Moyenne  | Aligner avec le manifeste  |
-| Documenter les configurations requises       | ⏳ À faire | Moyenne  | Chaque module              |
-| Créer guide de contribution détaillé         | ⏳ À faire | Basse    | Au-delà du CONTRIBUTING.md |
+| Tâche                                        | Statut       | Priorité | Notes                                                       |
+| -------------------------------------------- | ------------ | -------- | ----------------------------------------------------------- |
+| Mettre à jour README avec exemples API-first | ✅ Fait      | -        | Compteurs de tests, exemples corrigés (`User::retrieve`, `persist()`, `$app`), attribut routing et soft delete mis en avant |
+| Documenter les configurations requises       | ✅ Fait      | -        | Audit complet de `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Storage, Mail, Notifier, Container, Pagination, Scheduler, Task, etc.) |
+| Créer guide de contribution détaillé         | ⏳ À faire   | Basse    | Au-delà du CONTRIBUTING.md                                  |
 
 ---
 
@@ -98,14 +152,16 @@
 | Implémenter delayed jobs avec Redis ZADD | ⏳ À faire | Haute    |                                |
 | Ajouter monitoring des queues via CLI    | ⏳ À faire | Moyenne  | `bow queue:status`             |
 
-### Router - Attributs PHP 8
+### Router - Attributs PHP 8 ✅ Livré
 
-| Tâche                                                  | Statut     | Priorité | Notes                 |
-| ------------------------------------------------------ | ---------- | -------- | --------------------- |
-| Créer namespace `Bow\Router\Attributes`                | ⏳ À faire | Haute    |                       |
-| Implémenter `#[Controller]`                            | ⏳ À faire | Haute    | prefix, middleware    |
-| Implémenter `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ⏳ À faire | Haute    |                       |
-| Ajouter `$router->register(Controller::class)`         | ⏳ À faire | Haute    | Auto-discovery routes |
+| Tâche                                                  | Statut  | Priorité | Notes                                                                |
+| ------------------------------------------------------ | ------- | -------- | -------------------------------------------------------------------- |
+| Créer namespace `Bow\Router\Attributes`                | ✅ Fait | -        | `src/Router/Attributes/`                                              |
+| Implémenter `#[Controller]`                            | ✅ Fait | -        | `prefix`, `middleware`, `name` (préfixe de nom de route)              |
+| Implémenter `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ✅ Fait | -        | + `#[Patch]`, `#[Options]`, `#[Route]` (multi-verbes), tous répétables |
+| Ajouter `$app->register(Controller::class)`            | ✅ Fait | -        | Accepte aussi un tableau de contrôleurs                              |
+| `AttributeRouteRegistrar`                              | ✅ Fait | -        | Refactoré : préfixe de nom appliqué, méthodes héritées ignorées, sous-classe d'attribut acceptée |
+| Tests + stubs                                          | ✅ Fait | -        | `tests/Routing/AttributeRouteIntegrationTest.php`                    |
 
 ### Cache - Adapter Memcached
 
diff --git a/readme.md b/readme.md
index 9c764913..33f44e19 100644
--- a/readme.md
+++ b/readme.md
@@ -2,8 +2,7 @@
 
 [![docs](https://img.shields.io/badge/docs-read%20docs-blue.svg?style=flat-square)](https://github.com/bowphp/docs)
 [![version](https://img.shields.io/packagist/v/bowphp/framework.svg?style=flat-square)](https://packagist.org/packages/bowphp/framework)
-[![license](https://img.shields.io/github/license/mashape/apistatus.svg?style=flat-square)](https://github.com/bowphp/framework/blob/main/LICENSE)
-[![Build Status](https://img.shields.io/travis/bowphp/framework/main.svg?style=flat-square)](https://travis-ci.org/bowphp/framework)
+[![license](https://img.shields.io/github/license/bowphp/framework.svg?style=flat-square)](https://github.com/bowphp/framework/blob/main/LICENSE)
 ![Build Status](https://github.com/bowphp/framework/actions/workflows/tests.yml/badge.svg)
 
 > A lightweight, modern PHP framework designed for building web applications with clean architecture and modular design.
@@ -26,7 +25,7 @@ Bow Framework is a lightweight PHP framework created by Franck DAKIA that emphas
 - Modular architecture with 20+ independent components
 - Lightweight and fast with minimal dependencies
 - Full-stack framework with everything you need
-- Well-tested with 1,110+ tests and 94% success rate
+- Well-tested: 1,600+ tests, 3,300+ assertions, zero logical failures
 - Active development with regular updates
 
 ## Core Features
@@ -37,17 +36,20 @@ Bow Framework is a lightweight PHP framework created by Franck DAKIA that emphas
 - **Query Builder**: Fluent, expressive database queries
 - **Multi-database**: MySQL, PostgreSQL, SQLite support
 - **Migrations**: Version control for database schema
-- **Relationships**: BelongsTo, HasMany, ManyToMany
+- **Relationships**: HasOne, HasMany, BelongsTo, BelongsToMany
+- **Soft delete**: `SoftDelete` trait with `delete`/`restore`/`forceDelete` and `withTrashed`/`onlyTrashed` query scopes
 - **Pagination**: Built-in pagination support
 
 ### Routing System
 
-- Simple, expressive routing syntax
+- Simple, expressive routing syntax (`$app->get`, `$app->post`, ...)
+- **PHP 8 attribute routing**: `#[Controller]`, `#[Get]`, `#[Post]`, `#[Put]`, `#[Patch]`, `#[Delete]`, `#[Options]`, `#[Route]`
 - RESTful resource routing with automatic CRUD operations
 - Route naming for easy URL generation
 - Route parameters with regex constraints
-- Middleware support per route or route group
-- Route prefix support for grouping
+- Middleware support per route or route group, with `name:arg` parameter syntax
+- Route prefix and domain grouping
+- Custom HTTP error handlers via `code()`
 
 ### Mail System
 
@@ -186,16 +188,19 @@ php bow serve
 
 ```php
 // routes/app.php
-$route->get('/', function () {
+$app->get('/', function () {
     return 'Hello World!';
 });
 
-$route->get('/users/:id', function ($id) {
+$app->get('/users/:id', function ($id) {
     return "User ID: $id";
 });
 
 // RESTful resource routing
-$route->rest('/api/posts', PostController::class);
+$app->rest('/api/posts', 'PostController');
+
+// Attribute-based controllers (no central route file required)
+$app->register(\App\Controllers\PostController::class);
 ```
 
 **Create a Controller:**
@@ -215,7 +220,10 @@ class PostController
 
     public function store(Request $request)
     {
-        return Post::create($request->all());
+        $post = Post::create($request->all());
+        $post->persist();
+
+        return $post;
     }
 }
 ```
@@ -224,12 +232,13 @@ class PostController
 
 ```php
 use App\Models\User;
+use Bow\Database\Database;
 
 // Using Barry ORM
-$user = User::find(1);
+$user = User::retrieve(1);
 $users = User::where('active', true)->get();
 
-// Using Query Builder
+// Using the Query Builder
 $users = Database::table('users')
     ->where('role', 'admin')
     ->orderBy('created_at', 'desc')
@@ -238,21 +247,22 @@ $users = Database::table('users')
 
 ## Code Quality & Testing
 
-### Current Status (v5.1.7)
+### Current Status
 
-- **Test Suite**: 1,110+ tests with 2,498+ assertions
-- **Success Rate**: 94% (remaining failures are external service dependencies)
-- **Code Style**: PSR-12 compliant
+- **Test Suite**: 1,600+ tests with 3,300+ assertions
+- **Logical failures**: 0 — the only remaining errors require external services (FTP server, S3 endpoint) and are skipped by default
+- **Code Style**: PSR-12 (`composer phpcs` to check, `composer phpcbf` to fix)
+- **Static analysis**: PHPStan in `require-dev` (`vendor/bin/phpstan analyse src`)
 - **PHP Version**: 8.1+ with modern features
 
 ### Recent Improvements
 
-The framework is actively maintained with recent major refactoring:
-
-- **SMTP Adapter**: Complete rewrite (8 → 21 methods, RFC-compliant)  
-- **FTP Service**: Enhanced with retry logic and better error handling  
-- **Queue System**: Graceful logger fallback  
-- **Test Quality**: 39% fewer errors, 70% fewer failures  
+- **SMTP Adapter**: Complete rewrite (8 → 21 methods, RFC-compliant)
+- **FTP Service**: Enhanced with retry logic and better error handling
+- **Queue System**: Graceful logger fallback
+- **Attribute routing**: PHP 8 `#[Controller]` / `#[Get]` / `#[Post]` / ... wiring via `$app->register(...)`
+- **Barry soft delete**: trait + query scopes (`withTrashed`, `onlyTrashed`, `withoutTrashed`)
+- **Router**: instance-level route storage (no more cross-test leakage)
 - **PHP 8.x**: Modernized code style (arrow functions, union types)
 
 See [CHANGELOG.md](CHANGELOG.md) for full details.

From 3c3c134b345b61f0579903b395bdca57821c132e Mon Sep 17 00:00:00 2001
From: papac 
Date: Thu, 21 May 2026 11:20:36 +0000
Subject: [PATCH 30/60] Update CHANGELOG

---
 CHANGELOG.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 729fb882..e3846681 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.0 - 2026-05-21
+
+### What's Changed
+
+* feat(router): add php 8 attributes support for route definition by @gessyken in https://github.com/bowphp/framework/pull/349
+* Update console and adding new features and fix many issues by @papac in https://github.com/bowphp/framework/pull/394
+* Update readme by @papac in https://github.com/bowphp/framework/pull/395
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.2.991...5.3.0
+
 ## 5.2.990 - 2026-05-17
 
 ### What's Changed
@@ -249,6 +259,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From ecfbb324fca2fbf4d227b475f0da4eecea2d6e50 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 21 May 2026 13:30:23 +0000
Subject: [PATCH 31/60] fix(route) when load the middlware we lose the route
 chain

---
 src/Router/Router.php | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/src/Router/Router.php b/src/Router/Router.php
index 31ee6b8d..0610cfd5 100644
--- a/src/Router/Router.php
+++ b/src/Router/Router.php
@@ -17,7 +17,7 @@ class Router
      *
      * @var array
      */
-    protected array $routes = [];
+    protected static array $routes = [];
 
     /**
      * Define the functions related to a http
@@ -307,7 +307,7 @@ private function push(string|array $methods, string $path, callable|string|array
         $route->middleware($this->middlewares);
 
         foreach ($methods as $method) {
-            $this->routes[$method][] = $route;
+            static::$routes[$method][] = $route;
 
             // We define the current route and current method
             $this->current = ['path' => $path, 'method' => $method];
@@ -490,7 +490,7 @@ public function match(array $methods, string $path, callable|string|array $cb):
      */
     public function getRoutes(): array
     {
-        return $this->routes;
+        return static::$routes;
     }
 
     /**

From aacf57b46d3d6b95036e0f9616124b9e7b3c5977 Mon Sep 17 00:00:00 2001
From: papac 
Date: Thu, 21 May 2026 13:33:56 +0000
Subject: [PATCH 32/60] Update CHANGELOG

---
 CHANGELOG.md | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3846681..458503ca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.1 - 2026-05-21
+
+### What's Changed
+
+* fix(route) when load the middlware we lose the route chain by @papac in https://github.com/bowphp/framework/pull/397
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.0...5.3.1
+
 ## 5.3.0 - 2026-05-21
 
 ### What's Changed
@@ -260,6 +268,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From 4b94d0f76e7cecdf098f9e776ee1915ecb26ec6c Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Mon, 25 May 2026 15:16:23 +0000
Subject: [PATCH 33/60] Update ROADMAP.md

---
 ROADMAP.md | 324 ++++++++++++++++++++++++++---------------------------
 1 file changed, 162 insertions(+), 162 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md
index c49facf7..fc5345a7 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,21 +1,21 @@
-# Roadmap BowPHP Framework
+# BowPHP Framework Roadmap
 
-> Document évolutif basé sur l'analyse du code source (branche 5.x) et le manifeste du projet.
-> Dernière mise à jour : Mai 2026
+> Living document based on source code analysis (5.x branch) and the project manifesto.
+> Last updated: May 2026
 
 ---
 
-## État Actuel du Framework
+## Current Framework State
 
-### Modules Existants (Analyse du `/src`)
+### Existing Modules (`/src` Analysis)
 
-| Module                 | Statut    | Description                                    |
-| ---------------------- | --------- | ---------------------------------------------- |
+| Module                 | Status   | Description                                    |
+| ---------------------- | -------- | ---------------------------------------------- |
 | **Application**        | ✅ Stable | Bootstrap, exception handling, kernel          |
 | **Auth**               | ✅ Stable | Guards (Session, JWT), Authentication          |
 | **Cache**              | ✅ Stable | Adapters: Database, Filesystem, Redis          |
 | **Configuration**      | ✅ Stable | Loader, Env, Logger configuration              |
-| **Console**            | ✅ Stable | 26 commandes, générateurs, stubs               |
+| **Console**            | ✅ Stable | 26 commands, generators, stubs                 |
 | **Container**          | ✅ Stable | DI container, middleware dispatcher            |
 | **Database/Barry ORM** | ✅ Stable | MySQL, PostgreSQL, SQLite + Relations          |
 | **Event**              | ✅ Stable | Event dispatcher, listeners, queue integration |
@@ -31,233 +31,233 @@
 | **Support**            | ✅ Stable | Helpers, Collection, Str, Log, Env             |
 | **Testing**            | ✅ Stable | TestCase, Assertions, KernelTesting            |
 | **Translate**          | ✅ Stable | i18n support                                   |
-| **Validation**         | ✅ Stable | Règles de validation, messages custom          |
+| **Validation**         | ✅ Stable | Validation rules, custom messages              |
 | **View**               | ✅ Stable | Tintin (default), Twig support                 |
 
-### Dépendances Actuelles
+### Current Dependencies
 
-**Requises :**
+**Required:**
 
--   PHP ^8.1
--   bowphp/tintin ^3.0 (template engine)
--   filp/whoops ^2.1 (error handling)
--   nesbot/carbon 3.8.4 (dates)
--   fakerphp/faker ^1.20 (testing data)
--   ramsey/uuid ^4.7 (UUIDs)
+* PHP ^8.1
+* bowphp/tintin ^3.0 (template engine)
+* filp/whoops ^2.1 (error handling)
+* nesbot/carbon 3.8.4 (dates)
+* fakerphp/faker ^1.20 (testing data)
+* ramsey/uuid ^4.7 (UUIDs)
 
-**Dev/Suggérées :**
+**Dev/Suggested:**
 
--   pda/pheanstalk ^5.0 (Beanstalkd)
--   aws/aws-sdk-php ^3.87 (S3)
--   bowphp/policier ^3.0 (JWT)
--   predis/predis ^2.1 (Redis)
--   twilio/sdk ^8.3 (SMS)
--   bowphp/slack-webhook ^1.0 (Slack)
+* pda/pheanstalk ^5.0 (Beanstalkd)
+* aws/aws-sdk-php ^3.87 (S3)
+* bowphp/policier ^3.0 (JWT)
+* predis/predis ^2.1 (Redis)
+* twilio/sdk ^8.3 (SMS)
+* bowphp/slack-webhook ^1.0 (Slack)
 
 ---
 
-## ✅ Récemment livré (printemps 2026)
+## ✅ Recently Delivered (Spring 2026)
 
-Faits saillants des dernières itérations — déjà mergés sur `5.x`. Tous les détails dans le CHANGELOG.
+Highlights from the latest iterations — already merged into `5.x`. Full details are available in the CHANGELOG.
 
 ### Routing
 
--   Routage par attributs PHP 8 (cf. section dédiée plus bas).
--   `Router::$routes` rendu instance (corrige les fuites d'état entre tests).
--   Préfixe de nom du `#[Controller]` appliqué aux routes filles ; méthodes héritées ignorées au scan.
+* PHP 8 attribute routing support (see dedicated section below).
+* `Router::$routes` converted to instance state (fixes shared state leaks between tests).
+* `#[Controller]` name prefix applied to child routes; inherited methods ignored during scanning.
 
 ### Barry ORM
 
--   Trait `SoftDelete` (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, événements `model.restoring/restored/forceDeleting/forceDeleted`).
--   Cast `array` réparé : ne renvoie plus un `stdClass`.
--   Propriété morte `$soft_delete` supprimée (remplacée par le trait).
--   Visibilité `EventTrait::fireEvent` / `formatEventName` élargie à `protected` pour les traits enfants.
+* `SoftDelete` trait (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, events `model.restoring/restored/forceDeleting/forceDeleted`).
+* Fixed `array` cast: no longer returns `stdClass`.
+* Removed dead `$soft_delete` property (replaced by the trait).
+* `EventTrait::fireEvent` / `formatEventName` visibility expanded to `protected` for child traits.
 
 ### Validation
 
--   Nouvelles règles : `url`, `ip` (+ `ip:v4`, `ip:v6`), `boolean`, `json`, `uuid`, `confirmed`, `different:field`, `between:min,max`.
--   Priorité corrigée : `nullable|required` laisse `required` s'exécuter (et l'inner-loop break utilise enfin la bonne variable).
+* New rules: `url`, `ip` (+ `ip:v4`, `ip:v6`), `boolean`, `json`, `uuid`, `confirmed`, `different:field`, `between:min,max`.
+* Fixed priority handling: `nullable|required` now allows `required` to execute properly (and the inner-loop break now uses the correct variable).
 
-### Infrastructure de test
+### Testing Infrastructure
 
--   `TestCase` refactoré : vrai DELETE/PATCH (plus de hack `_method`), `head()` / `options()`, factorisation via `newHttpClient()`, reset auto des attachements, port par défaut 8080.
--   `Env::reset()` ajouté pour la propreté entre suites de tests.
--   `SchedulerCommand` charge automatiquement `routes/scheduler.php` et tolère un Loader manquant.
--   `addEnum` / `changeEnum` : messages d'erreur explicites (mentionnent la clé `size`).
--   Bootstrap des tests filtre les `E_DEPRECATED` issus de `vendor/` (lcobucci/jwt v3.2.5, spatie 4.x).
--   Pagination : tests appelaient `total()` au lieu de `totalPages()` — 24 cas corrigés.
+* `TestCase` refactored: real DELETE/PATCH support (no more `_method` hack), `head()` / `options()`, shared logic through `newHttpClient()`, automatic attachment reset, default port 8080.
+* `Env::reset()` added for cleaner test isolation.
+* `SchedulerCommand` now automatically loads `routes/scheduler.php` and tolerates a missing Loader.
+* `addEnum` / `changeEnum`: explicit error messages (mention the `size` key).
+* Test bootstrap now filters `E_DEPRECATED` coming from `vendor/` (`lcobucci/jwt v3.2.5`, spatie 4.x).
+* Pagination: tests were calling `total()` instead of `totalPages()` — 24 test cases fixed.
 
-### Tintin (vendoré)
+### Tintin (vendored)
 
--   Cache atomique (`rename`), `mkdir` récursif, invalidation par `filemtime` (au lieu de `fileatime`).
--   `Compiler::compile` ne perd plus les lignes vides ; ajout d'un post-pass `?>\n\n` pour préserver l'indentation des snippets `
/`.
--   `Tintin::renderString` utilise `tempnam()` + `try/finally` ; suppression du `trim()` destructif.
--   Heuristique d'échappement `{{ ... }}` resserrée mais compatible Vue/Angular.
--   `directivesProtected` enrichi (csrf, macro/endmacro, lang, flash, notempty…).
+* Atomic cache (`rename`), recursive `mkdir`, invalidation based on `filemtime` (instead of `fileatime`).
+* `Compiler::compile` no longer removes empty lines; added `?>\n\n` post-pass to preserve indentation in `
/` snippets.
+* `Tintin::renderString` now uses `tempnam()` + `try/finally`; removed destructive `trim()`.
+* Tightened `{{ ... }}` escaping heuristic while remaining compatible with Vue/Angular.
+* Extended `directivesProtected` (`csrf`, `macro/endmacro`, `lang`, `flash`, `notempty`, etc.).
 
 ### Documentation & READMEs
 
--   Audit complet de `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Mail, Storage, Messaging, Container, Pagination, Scheduler, Task, Testing, Configuration, Concept, Controller, CQRS, Database, Policier, Service, Session, SoAuth, Structure, Upload, View, Package, Contribution).
--   README mis à jour (badges, compteurs de tests, soft delete, attribut routing, helpers de commande).
--   `microservice` (sous-projet) : refactor de `MicroserviceConfiguration` (extends `Configuration`, PSR-4 propre), `microservice.php` Bow-intégré, namespace `Bow\Console\Command\Generator` corrigé.
+* Full audit of `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Mail, Storage, Messaging, Container, Pagination, Scheduler, Task, Testing, Configuration, Concept, Controller, CQRS, Database, Policier, Service, Session, SoAuth, Structure, Upload, View, Package, Contribution).
+* Updated README (badges, test counters, soft delete, attribute routing, command helpers).
+* `microservice` (subproject): `MicroserviceConfiguration` refactor (`extends Configuration`, clean PSR-4), Bow-integrated `microservice.php`, fixed `Bow\Console\Command\Generator` namespace.
 
 ---
 
-## 🔴 NOW — 0 à 3 mois (Stabilisation & Consolidation)
-
-### Tests et CI/CD
-
-| Tâche                                               | Statut       | Priorité | Notes                                                  |
-| --------------------------------------------------- | ------------ | -------- | ------------------------------------------------------ |
-| Séparer les tests unitaires des tests d'intégration | ⏳ À faire   | Haute    | Les tests DB/FTP/S3 nécessitent des services externes  |
-| Ajouter `@group` PHPUnit pour isoler les tests      | ⏳ À faire   | Haute    | `@group unit`, `@group integration`, `@group database` |
-| Configurer GitHub Actions avec services Docker      | ⏳ À faire   | Haute    | MySQL, PostgreSQL, Redis pour CI                       |
-| Augmenter couverture tests unitaires                | 🔄 En cours  | Moyenne  | 1 600+ tests, 0 échec logique. Ajouts récents : SoftDelete, AttributeRouteRegistrar, nouvelles règles de validation, Pagination. |
-| Intégrer PHPStan niveau 5+                          | ⏳ À faire   | Moyenne  | Constraint actuel : `phpstan/phpstan: ^0.12.87` — bumper vers ^1.x avant de cibler un niveau plus élevé |
-
-### Corrections de Code
-
-| Tâche                                                                | Statut     | Priorité | Notes                                                                  |
-| -------------------------------------------------------------------- | ---------- | -------- | ---------------------------------------------------------------------- |
-| Fixer le test d'attribut middleware (state partagé entre tests)      | ✅ Fait    | -        | `Router::$routes` rendue instance (n'était plus partagée entre tests)  |
-| Fixer les tests Pagination qui appelaient `total()` au lieu de `totalPages()` | ✅ Fait    | -        | 24 tests corrigés                                                      |
-| Fixer le cast `array` du modèle Barry qui renvoyait `stdClass`       | ✅ Fait    | -        | `Model::executeDataCasting` + `parseToJson($value, assoc: true)`       |
-| Fixer la priorité `nullable\|required` du Validator                  | ✅ Fait    | -        | `nullable` ne court-circuite plus `required`                           |
-| Fixer `EnvTest` (pollution du singleton entre tests)                 | ✅ Fait    | -        | `Env::reset()` ajouté                                                  |
-| Fixer `SchedulerCommand` (chargement de `routes/scheduler.php`)      | ✅ Fait    | -        | `loadSchedulerFile()` mis à jour, tolère un Loader manquant            |
-| Retirer la propriété morte `Model::$soft_delete`                     | ✅ Fait    | -        | Remplacée par un trait fonctionnel (cf. Soft delete plus bas)          |
-| Améliorer les messages d'erreur de `addEnum` / `changeEnum`          | ✅ Fait    | -        | Mentionnent explicitement la clé `size`                                |
-| Uniformiser les signatures de méthodes                               | ✅ Fait    | -        | PHP 8.1+ nullable types                                                |
-| Fixer le cast `(double)` → `(float)`                                 | ✅ Fait    | -        | Model.php                                                              |
-| Gérer `array_key_exists` avec clé null                               | ✅ Fait    | -        | Console.php                                                            |
-| Créer le répertoire de test si inexistant                            | ✅ Fait    | -        | CustomCommand.php                                                      |
+## 🔴 NOW — 0 to 3 Months (Stabilization & Consolidation)
+
+### Testing and CI/CD
+
+| Task                                          | Status     | Priority | Notes                                                                                                                     |
+| --------------------------------------------- | ---------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
+| Separate unit tests from integration tests    | ⏳ Planned  | High     | DB/FTP/S3 tests require external services                                                                                 |
+| Add PHPUnit `@group` annotations              | ⏳ Planned  | High     | `@group unit`, `@group integration`, `@group database`                                                                    |
+| Configure GitHub Actions with Docker services | ⏳ Planned  | High     | MySQL, PostgreSQL, Redis for CI                                                                                           |
+| Increase unit test coverage                   | 🔄 Ongoing | Medium   | 1,600+ tests, 0 logical failures. Recent additions: SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
+| Integrate PHPStan level 5+                    | ⏳ Planned  | Medium   | Current constraint: `phpstan/phpstan: ^0.12.87` — upgrade to ^1.x before targeting higher levels                          |
+
+### Code Fixes
+
+| Task                                                             | Status | Priority | Notes                                                            |
+| ---------------------------------------------------------------- | ------ | -------- | ---------------------------------------------------------------- |
+| Fix middleware attribute test (shared state between tests)       | ✅ Done | -        | `Router::$routes` converted to instance state                    |
+| Fix Pagination tests calling `total()` instead of `totalPages()` | ✅ Done | -        | 24 tests fixed                                                   |
+| Fix Barry model `array` cast returning `stdClass`                | ✅ Done | -        | `Model::executeDataCasting` + `parseToJson($value, assoc: true)` |
+| Fix Validator `nullable\|required` priority                      | ✅ Done | -        | `nullable` no longer short-circuits `required`                   |
+| Fix `EnvTest` singleton pollution between tests                  | ✅ Done | -        | `Env::reset()` added                                             |
+| Fix `SchedulerCommand` (`routes/scheduler.php` loading)          | ✅ Done | -        | `loadSchedulerFile()` updated, tolerates missing Loader          |
+| Remove dead `Model::$soft_delete` property                       | ✅ Done | -        | Replaced with a fully functional trait                           |
+| Improve `addEnum` / `changeEnum` error messages                  | ✅ Done | -        | Explicitly mention the `size` key                                |
+| Standardize method signatures                                    | ✅ Done | -        | PHP 8.1+ nullable types                                          |
+| Fix `(double)` → `(float)` cast                                  | ✅ Done | -        | `Model.php`                                                      |
+| Handle `array_key_exists` with null key                          | ✅ Done | -        | `Console.php`                                                    |
+| Create test directory if missing                                 | ✅ Done | -        | `CustomCommand.php`                                              |
 
 ### Documentation
 
-| Tâche                                        | Statut       | Priorité | Notes                                                       |
-| -------------------------------------------- | ------------ | -------- | ----------------------------------------------------------- |
-| Mettre à jour README avec exemples API-first | ✅ Fait      | -        | Compteurs de tests, exemples corrigés (`User::retrieve`, `persist()`, `$app`), attribut routing et soft delete mis en avant |
-| Documenter les configurations requises       | ✅ Fait      | -        | Audit complet de `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Storage, Mail, Notifier, Container, Pagination, Scheduler, Task, etc.) |
-| Créer guide de contribution détaillé         | ⏳ À faire   | Basse    | Au-delà du CONTRIBUTING.md                                  |
+| Task                                  | Status    | Priority | Notes                                                                                                                                       |
+| ------------------------------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| Update README with API-first examples | ✅ Done    | -        | Test counters, corrected examples (`User::retrieve`, `persist()`, `$app`), attribute routing and soft delete highlighted                    |
+| Document required configurations      | ✅ Done    | -        | Full audit of `docs/docs/*.mdx` (ORM, Router, Validation, Migration, Storage, Mail, Notifier, Container, Pagination, Scheduler, Task, etc.) |
+| Create a detailed contribution guide  | ⏳ Planned | Low      | Beyond the current `CONTRIBUTING.md`                                                                                                        |
 
 ---
 
-## 🟠 NEXT — 3 à 6 mois (Nouvelles Fonctionnalités)
+## 🟠 NEXT — 3 to 6 Months (New Features)
 
-### Queue - Adapter Redis
+### Queue - Redis Adapter
 
-| Tâche                                    | Statut     | Priorité | Notes                          |
-| ---------------------------------------- | ---------- | -------- | ------------------------------ |
-| Créer `RedisAdapter` pour Queue          | ⏳ À faire | Haute    | predis/predis déjà en dev-deps |
-| Implémenter delayed jobs avec Redis ZADD | ⏳ À faire | Haute    |                                |
-| Ajouter monitoring des queues via CLI    | ⏳ À faire | Moyenne  | `bow queue:status`             |
+| Task                                   | Status    | Priority | Notes                                       |
+| -------------------------------------- | --------- | -------- | ------------------------------------------- |
+| Create `RedisAdapter` for Queue        | ⏳ Planned | High     | `predis/predis` already in dev dependencies |
+| Implement delayed jobs with Redis ZADD | ⏳ Planned | High     |                                             |
+| Add queue monitoring through CLI       | ⏳ Planned | Medium   | `bow queue:status`                          |
 
-### Router - Attributs PHP 8 ✅ Livré
+### Router - PHP 8 Attributes ✅ Delivered
 
-| Tâche                                                  | Statut  | Priorité | Notes                                                                |
-| ------------------------------------------------------ | ------- | -------- | -------------------------------------------------------------------- |
-| Créer namespace `Bow\Router\Attributes`                | ✅ Fait | -        | `src/Router/Attributes/`                                              |
-| Implémenter `#[Controller]`                            | ✅ Fait | -        | `prefix`, `middleware`, `name` (préfixe de nom de route)              |
-| Implémenter `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ✅ Fait | -        | + `#[Patch]`, `#[Options]`, `#[Route]` (multi-verbes), tous répétables |
-| Ajouter `$app->register(Controller::class)`            | ✅ Fait | -        | Accepte aussi un tableau de contrôleurs                              |
-| `AttributeRouteRegistrar`                              | ✅ Fait | -        | Refactoré : préfixe de nom appliqué, méthodes héritées ignorées, sous-classe d'attribut acceptée |
-| Tests + stubs                                          | ✅ Fait | -        | `tests/Routing/AttributeRouteIntegrationTest.php`                    |
+| Task                                                 | Status | Priority | Notes                                                                                  |
+| ---------------------------------------------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
+| Create namespace `Bow\Router\Attributes`             | ✅ Done | -        | `src/Router/Attributes/`                                                               |
+| Implement `#[Controller]`                            | ✅ Done | -        | `prefix`, `middleware`, `name` (route name prefix)                                     |
+| Implement `#[Get]`, `#[Post]`, `#[Put]`, `#[Delete]` | ✅ Done | -        | + `#[Patch]`, `#[Options]`, `#[Route]` (multi-verb), all repeatable                    |
+| Add `$app->register(Controller::class)`              | ✅ Done | -        | Also accepts an array of controllers                                                   |
+| `AttributeRouteRegistrar`                            | ✅ Done | -        | Refactored: name prefix applied, inherited methods ignored, attribute subclass support |
+| Tests + stubs                                        | ✅ Done | -        | `tests/Routing/AttributeRouteIntegrationTest.php`                                      |
 
-### Cache - Adapter Memcached
+### Cache - Memcached Adapter
 
-| Tâche                                         | Statut     | Priorité | Notes |
-| --------------------------------------------- | ---------- | -------- | ----- |
-| Créer `MemcachedAdapter`                      | ⏳ À faire | Moyenne  |       |
-| Améliorer résilience Redis (reconnexion auto) | ⏳ À faire | Moyenne  |       |
+| Task                                      | Status    | Priority | Notes |
+| ----------------------------------------- | --------- | -------- | ----- |
+| Create `MemcachedAdapter`                 | ⏳ Planned | Medium   |       |
+| Improve Redis resiliency (auto-reconnect) | ⏳ Planned | Medium   |       |
 
 ### Messaging - Push Notifications
 
-| Tâche                                | Statut     | Priorité | Notes         |
-| ------------------------------------ | ---------- | -------- | ------------- |
-| Créer `FcmChannelAdapter` (Firebase) | ⏳ À faire | Moyenne  |               |
-| Créer `ApnsChannelAdapter` (Apple)   | ⏳ À faire | Moyenne  |               |
-| Améliorer `TelegramChannelAdapter`   | ⏳ À faire | Basse    | Déjà existant |
-| Améliorer `SlackChannelAdapter`      | ⏳ À faire | Basse    | Déjà existant |
+| Task                                  | Status    | Priority | Notes          |
+| ------------------------------------- | --------- | -------- | -------------- |
+| Create `FcmChannelAdapter` (Firebase) | ⏳ Planned | Medium   |                |
+| Create `ApnsChannelAdapter` (Apple)   | ⏳ Planned | Medium   |                |
+| Improve `TelegramChannelAdapter`      | ⏳ Planned | Low      | Already exists |
+| Improve `SlackChannelAdapter`         | ⏳ Planned | Low      | Already exists |
 
 ### Database
 
-| Tâche                                     | Statut     | Priorité | Notes                        |
-| ----------------------------------------- | ---------- | -------- | ---------------------------- |
-| Ajouter support SQL Server                | ⏳ À faire | Moyenne  |                              |
-| Créer adapter Array/FileWriter pour tests | ⏳ À faire | Moyenne  | Évite dépendance DB en tests |
+| Task                                      | Status    | Priority | Notes                          |
+| ----------------------------------------- | --------- | -------- | ------------------------------ |
+| Add SQL Server support                    | ⏳ Planned | Medium   |                                |
+| Create Array/FileWriter adapter for tests | ⏳ Planned | Medium   | Removes DB dependency in tests |
 
 ---
 
-## 🟢 LATER — 6 à 12 mois (Vision Long Terme)
+## 🟢 LATER — 6 to 12 Months (Long-Term Vision)
 
-### Performance et Modernisation
+### Performance and Modernization
 
-| Tâche                                        | Statut     | Priorité | Notes                      |
-| -------------------------------------------- | ---------- | -------- | -------------------------- |
-| Support Swoole/FrankenPHP                    | ⏳ À faire | Moyenne  | Serveurs non-bloquants     |
-| Images Docker officielles                    | ⏳ À faire | Moyenne  | Optimisées pour production |
-| Support serverless (Lambda, Cloud Functions) | ⏳ À faire | Basse    | HTTP Handler adapté        |
+| Task                                         | Status    | Priority | Notes                  |
+| -------------------------------------------- | --------- | -------- | ---------------------- |
+| Swoole/FrankenPHP support                    | ⏳ Planned | Medium   | Non-blocking servers   |
+| Official Docker images                       | ⏳ Planned | Medium   | Production-optimized   |
+| Serverless support (Lambda, Cloud Functions) | ⏳ Planned | Low      | Dedicated HTTP handler |
 
-### Écosystème
+### Ecosystem
 
-| Tâche                                            | Statut     | Priorité | Notes                |
-| ------------------------------------------------ | ---------- | -------- | -------------------- |
-| Package `bowphp/payment`                         | ⏳ À faire | Haute    | Mobile money Afrique |
-| Package `bowphp/logviewer` ou `bowphp/telescope` | ⏳ À faire | Moyenne  | Observabilité        |
-| Adapter laravel-notify pour Bow                  | ⏳ À faire | Basse    | UI notifications     |
+| Task                                             | Status    | Priority | Notes                |
+| ------------------------------------------------ | --------- | -------- | -------------------- |
+| Package `bowphp/payment`                         | ⏳ Planned | High     | African mobile money |
+| Package `bowphp/logviewer` or `bowphp/telescope` | ⏳ Planned | Medium   | Observability        |
+| Adapt `laravel-notify` for Bow                   | ⏳ Planned | Low      | Notification UI      |
 
-### Observabilité
+### Observability
 
-| Tâche                          | Statut     | Priorité | Notes                           |
-| ------------------------------ | ---------- | -------- | ------------------------------- |
-| Module OpenTelemetry optionnel | ⏳ À faire | Moyenne  | Tracing requests, jobs, queries |
-| Intégration Prometheus/Grafana | ⏳ À faire | Basse    | Métriques production            |
+| Task                           | Status    | Priority | Notes                           |
+| ------------------------------ | --------- | -------- | ------------------------------- |
+| Optional OpenTelemetry module  | ⏳ Planned | Medium   | Request, job, and query tracing |
+| Prometheus/Grafana integration | ⏳ Planned | Low      | Production metrics              |
 
 ---
 
-## Légende
+## Legend
 
--   ✅ **Fait** : Tâche complétée
--   ⏳ **À faire** : Tâche planifiée
--   🔄 **En cours** : Travail en progression
--   ❌ **Annulé** : Tâche abandonnée
+* ✅ **Done**: Completed task
+* ⏳ **Planned**: Scheduled task
+* 🔄 **Ongoing**: Work in progress
+* ❌ **Cancelled**: Abandoned task
 
 ---
 
-## Comment Contribuer
+## How to Contribute
 
-1. Choisir une tâche de la section **NOW** (priorité haute)
-2. Ouvrir une issue pour discuter de l'implémentation
-3. Créer une branche `feature/nom-de-la-tache`
-4. Suivre les conventions du projet (voir CONTRIBUTING.md)
-5. Soumettre une PR avec tests
+1. Pick a task from the **NOW** section (high priority)
+2. Open an issue to discuss the implementation
+3. Create a branch named `feature/task-name`
+4. Follow project conventions (see `CONTRIBUTING.md`)
+5. Submit a PR with tests
 
 ---
 
-## Notes Importantes
+## Important Notes
 
-### Concernant les Tests
+### About Testing
 
-Les erreurs actuelles lors de `composer test` sont principalement dues à :
+Current failures during `composer test` are mainly caused by:
 
-1. **Services externes non disponibles** (pas des bugs du framework) :
+1. **Unavailable external services** (not framework bugs):
 
-    - MySQL : Connection refused / Access denied
-    - PostgreSQL : Connection refused
-    - FTP : Connection refused
-    - S3 : Invalid endpoint
-    - Beanstalkd : Connection refused
+   * MySQL: Connection refused / Access denied
+   * PostgreSQL: Connection refused
+   * FTP: Connection refused
+   * S3: Invalid endpoint
+   * Beanstalkd: Connection refused
 
-2. **Isolation des tests SQLite** : Certains tests partagent l'état de la base, causant des échecs intermittents.
+2. **SQLite test isolation issues**: Some tests share database state, causing intermittent failures.
 
-**Solution recommandée** : Séparer les tests en groupes (`@group unit`, `@group integration`) et configurer CI avec Docker Compose pour les tests d'intégration.
+**Recommended solution**: Split tests into groups (`@group unit`, `@group integration`) and configure CI with Docker Compose for integration tests.
 
-### Philosophie du Projet
+### Project Philosophy
 
-Toute contribution doit respecter le manifeste :
+Every contribution must respect the manifesto:
 
--   **Simplicité** > Sophistication
--   **Lisibilité** > Concision extrême
--   **API-first** : Priorité aux backends JSON
--   **Performance** : Bootstrap minimal, réponse rapide
--   **Contrôle** : Le développeur garde le contrôle de son architecture
+* **Simplicity** > Sophistication
+* **Readability** > Extreme conciseness
+* **API-first**: JSON backends are the priority
+* **Performance**: Minimal bootstrap, fast response times
+* **Control**: Developers retain full control over their architecture

From b2ccd5e4b981720e6004a53929ec8d7d294c6c67 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 28 May 2026 10:16:52 +0000
Subject: [PATCH 34/60] fix(db): make deep casting when
 toArray/toJson/_toString is called

---
 src/Database/Barry/Model.php | 16 +++++++++-------
 1 file changed, 9 insertions(+), 7 deletions(-)

diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php
index 8af58548..6102128b 100644
--- a/src/Database/Barry/Model.php
+++ b/src/Database/Barry/Model.php
@@ -887,9 +887,15 @@ public function getAttribute(string $key): mixed
      */
     public function toArray(): array
     {
+        $attributes = $this->attributes;
+
+        foreach ($attributes as $name => $value) {
+            $attributes[$name] = $this->executeDataCasting($name);
+        }
+
         return array_filter(
-            $this->attributes,
-            fn ($key) => !in_array($key, $this->hidden),
+            $attributes,
+            fn($key) => !in_array($key, $this->hidden),
             ARRAY_FILTER_USE_KEY
         );
     }
@@ -899,11 +905,7 @@ public function toArray(): array
      */
     public function jsonSerialize(): array
     {
-        return array_filter(
-            $this->attributes,
-            fn ($key) => !in_array($key, $this->hidden),
-            ARRAY_FILTER_USE_KEY
-        );
+        return $this->toArray();
     }
 
     /**

From 983028ff1df615b1a5211d019a2cfd797ab5b818 Mon Sep 17 00:00:00 2001
From: papac 
Date: Thu, 28 May 2026 10:22:16 +0000
Subject: [PATCH 35/60] Update CHANGELOG

---
 CHANGELOG.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 458503ca..e12b2755 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.11 - 2026-05-28
+
+### What's Changed
+
+* Update ROADMAP.md by @papac in https://github.com/bowphp/framework/pull/399
+* fix(db): make deep casting when toArray/toJson/_toString is called by @papac in https://github.com/bowphp/framework/pull/400
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/401
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.1...5.3.11
+
 ## 5.3.1 - 2026-05-21
 
 ### What's Changed
@@ -269,6 +279,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From 43bc16512836bbb06c97d8d814b762c58d25dec6 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Tue, 2 Jun 2026 14:09:02 +0000
Subject: [PATCH 36/60] fix(database): fix retrieve belongTo into loop
 execution

---
 src/Console/Command/MigrationCommand.php      |  3 --
 src/Database/Barry/Model.php                  |  4 +++
 src/Database/Barry/Relation.php               |  7 +++-
 src/Database/Barry/Relations/BelongsTo.php    |  7 +++-
 src/Database/Barry/Relations/HasMany.php      |  1 -
 src/Database/Barry/Relations/HasOne.php       |  9 +++--
 .../Relation/BelongsToRelationQueryTest.php   | 34 +++++++++++++++++++
 7 files changed, 57 insertions(+), 8 deletions(-)

diff --git a/src/Console/Command/MigrationCommand.php b/src/Console/Command/MigrationCommand.php
index ccc1c362..01a60615 100644
--- a/src/Console/Command/MigrationCommand.php
+++ b/src/Console/Command/MigrationCommand.php
@@ -58,13 +58,10 @@ public function reset(): void
      */
     private function factory(string $type): void
     {
-
         $migrations = $this->collectMigrationFiles();
 
-
         $connection = $this->arg->getParameter("--connection", config("database.default"));
 
-
         try {
             Database::connection($connection);
         } catch (Exception $exception) {
diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php
index 6102128b..9ad2c72f 100644
--- a/src/Database/Barry/Model.php
+++ b/src/Database/Barry/Model.php
@@ -1020,6 +1020,10 @@ private function executeDataCasting(string $name): mixed
         $type = $this->casts[$name];
         $value = $this->attributes[$name];
 
+        if (is_null($value)) {
+            return $value;
+        }
+
         if ($type === "date") {
             return new Carbon($value);
         }
diff --git a/src/Database/Barry/Relation.php b/src/Database/Barry/Relation.php
index 2b1bfa92..2a7e97b8 100644
--- a/src/Database/Barry/Relation.php
+++ b/src/Database/Barry/Relation.php
@@ -62,7 +62,12 @@ public function __construct(Model $related, Model $parent)
         $this->parent = $parent;
         $this->related = $related;
 
-        $this->query = $this->related::query();
+        // Clone the model's shared static query builder so the constraints we
+        // apply below stay local to this relation. Without the clone, a relation
+        // that builds constraints but does not execute the query (e.g. a cache
+        // hit in BelongsTo/HasOne) would leave a pending WHERE clause on the
+        // shared builder and corrupt the next relation query on the same model.
+        $this->query = clone $this->related::query();
 
         // Build the constraint effect
         if (static::$has_constraints) {
diff --git a/src/Database/Barry/Relations/BelongsTo.php b/src/Database/Barry/Relations/BelongsTo.php
index aa1cebd5..3bf8b83e 100644
--- a/src/Database/Barry/Relations/BelongsTo.php
+++ b/src/Database/Barry/Relations/BelongsTo.php
@@ -38,7 +38,12 @@ public function __construct(
      */
     public function getResults(): mixed
     {
-        $key = $this->query->getTable() . ":" . $this->local_key . ":belongsto:" . $this->related->getTable() . ":" . $this->foreign_key;
+        // Include the parent's foreign key value in the cache key so each parent
+        // resolves to its own related model. Without it the key is identical for
+        // every parent and a loop would always return the first cached result.
+        $foreign_key_value = $this->parent->getAttribute($this->foreign_key);
+        $key = $this->query->getTable() . ":" . $this->local_key . ":belongsto:"
+            . $this->related->getTable() . ":" . $this->foreign_key . ":" . $foreign_key_value;
 
         $cache = Cache::store('file')->get($key);
 
diff --git a/src/Database/Barry/Relations/HasMany.php b/src/Database/Barry/Relations/HasMany.php
index 43ec1bda..1ce65b77 100644
--- a/src/Database/Barry/Relations/HasMany.php
+++ b/src/Database/Barry/Relations/HasMany.php
@@ -17,7 +17,6 @@ class HasMany extends Relation
      * @param Model $parent
      * @param string   $foreign_key
      * @param string   $local_key
-     * @param string   $relation
      */
     public function __construct(Model $related, Model $parent, string $foreign_key, string $local_key)
     {
diff --git a/src/Database/Barry/Relations/HasOne.php b/src/Database/Barry/Relations/HasOne.php
index cb47d921..850b481e 100644
--- a/src/Database/Barry/Relations/HasOne.php
+++ b/src/Database/Barry/Relations/HasOne.php
@@ -33,7 +33,12 @@ public function __construct(Model $related, Model $parent, string $foreign_key,
      */
     public function getResults(): ?Model
     {
-        $key = $this->query->getTable() . ":" . $this->local_key . ":hasone:" . $this->related->getTable() . ":" . $this->foreign_key;
+        // Include the parent's local key value in the cache key so each parent
+        // resolves to its own related model. Without it the key is identical for
+        // every parent and a loop would always return the first cached result.
+        $local_key_value = $this->parent->getAttribute($this->local_key);
+        $key = $this->query->getTable() . ":" . $this->local_key . ":hasone:"
+            . $this->related->getTable() . ":" . $this->foreign_key . ":" . $local_key_value;
 
         $cache = Cache::store('file')->get($key);
 
@@ -46,7 +51,7 @@ public function getResults(): ?Model
         $result = $this->query->first();
 
         if (!is_null($result)) {
-            Cache::store('file')->add($key, $result->toArray(), 60);
+            Cache::store('file')->set($key, $result->toArray(), 60);
         }
 
         return $result;
diff --git a/tests/Database/Relation/BelongsToRelationQueryTest.php b/tests/Database/Relation/BelongsToRelationQueryTest.php
index ab1114d8..7da176a9 100644
--- a/tests/Database/Relation/BelongsToRelationQueryTest.php
+++ b/tests/Database/Relation/BelongsToRelationQueryTest.php
@@ -174,6 +174,40 @@ public function test_multiple_relationship_accesses(string $name)
         $this->assertEquals($master1->name, $master2->name);
     }
 
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_relationship_in_loop_returns_correct_owner_per_pet(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        // Ensure no stale relation cache leaks between pets
+        Cache::store('file')->clear();
+
+        $expected = [
+            1 => 'didi', // fluffy -> master 1
+            2 => 'didi', // dolly  -> master 1
+            3 => 'john', // rex    -> master 2
+            4 => 'john', // max    -> master 2
+            5 => 'jane', // bella  -> master 3
+        ];
+
+        $pets = PetModelStub::connection($name)->all();
+
+        foreach ($pets as $pet) {
+            $master = $pet->master;
+
+            $this->assertInstanceOf(PetMasterModelStub::class, $master);
+            $this->assertEquals(
+                $expected[$pet->id],
+                $master->name,
+                "Pet #{$pet->id} should belong to master '{$expected[$pet->id]}'"
+            );
+            $this->assertEquals($pet->master_id, $master->id);
+        }
+    }
+
     // ===== Relationship Data Integrity Tests =====
 
     /**

From 7302b4a0bb8e5f33ae31e255f08e0803c8535054 Mon Sep 17 00:00:00 2001
From: papac 
Date: Tue, 2 Jun 2026 14:16:17 +0000
Subject: [PATCH 37/60] Update CHANGELOG

---
 CHANGELOG.md | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e12b2755..39fd1109 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.12 - 2026-06-02
+
+### What's Changed
+
+* fix(database): fix retrieve belongTo into loop execution by @papac in https://github.com/bowphp/framework/pull/402
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/403
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.11...5.3.12
+
 ## 5.3.11 - 2026-05-28
 
 ### What's Changed
@@ -280,6 +289,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From 29eca4c1e9a229299debb501734600bdd627945c Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 4 Jun 2026 14:17:08 +0000
Subject: [PATCH 38/60] feat(orm): add eager loading process

---
 src/Database/Barry/Builder.php                |  52 +++
 src/Database/Barry/Model.php                  |  35 +-
 src/Database/Barry/Relation.php               | 101 ++++++
 src/Database/Barry/Relations/BelongsTo.php    |  50 +--
 .../Barry/Relations/BelongsToMany.php         |  24 ++
 src/Database/Barry/Relations/HasMany.php      |  29 +-
 src/Database/Barry/Relations/HasOne.php       |  50 +--
 .../Relation/BelongsToRelationQueryTest.php   |   4 +
 .../Relation/EagerLoadingQueryTest.php        | 299 ++++++++++++++++++
 tests/Database/Stubs/PetMasterModelStub.php   |  24 +-
 10 files changed, 619 insertions(+), 49 deletions(-)
 create mode 100644 tests/Database/Relation/EagerLoadingQueryTest.php

diff --git a/src/Database/Barry/Builder.php b/src/Database/Barry/Builder.php
index 1c75b2b7..08fad8f1 100644
--- a/src/Database/Barry/Builder.php
+++ b/src/Database/Barry/Builder.php
@@ -18,6 +18,26 @@ class Builder extends QueryBuilder
      */
     protected ?string $model = null;
 
+    /**
+     * The relationships to eager load.
+     *
+     * @var array
+     */
+    protected array $eager_loads = [];
+
+    /**
+     * Register relationships to eager load on the query result.
+     *
+     * @param  string|array $relations
+     * @return Builder
+     */
+    public function eager(string|array $relations): Builder
+    {
+        $this->eager_loads = array_merge($this->eager_loads, (array)$relations);
+
+        return $this;
+    }
+
     /**
      * Get information
      *
@@ -28,6 +48,11 @@ public function get(array $columns = []): Model|Collection|null
     {
         $data = parent::get($columns);
 
+        // Read and reset the eager loads now: query() memoizes a shared Builder
+        // instance, so the list must not leak into the next query on this model.
+        $eager_loads = $this->eager_loads;
+        $this->eager_loads = [];
+
         if (is_null($data)) {
             return null;
         }
@@ -41,9 +66,36 @@ public function get(array $columns = []): Model|Collection|null
             $data[$key] = new $this->model((array)$value);
         }
 
+        if (count($eager_loads) > 0) {
+            $this->eagerLoadRelations($data, $eager_loads);
+        }
+
         return new Collection($data);
     }
 
+    /**
+     * Eager load the given relationships onto a set of parent models.
+     *
+     * @param  Model[] $models
+     * @param  array   $relations
+     * @return void
+     */
+    protected function eagerLoadRelations(array $models, array $relations): void
+    {
+        if (count($models) === 0) {
+            return;
+        }
+
+        foreach ($relations as $name) {
+            // Build the relation without the single parent constraint so it can
+            // be batched across every parent with one whereIn query.
+            $relation = Relation::noConstraints(fn () => $models[0]->$name());
+
+            $relation->addEagerConstraints($models);
+            $relation->match($models, $relation->getEager(), $name);
+        }
+    }
+
     /**
      * Check if rows exists
      *
diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php
index 9ad2c72f..8361d7f5 100644
--- a/src/Database/Barry/Model.php
+++ b/src/Database/Barry/Model.php
@@ -191,6 +191,13 @@ abstract class Model implements ArrayAccess, JsonSerializable
      */
     private array $original = [];
 
+    /**
+     * The loaded relationships, resolved lazily once per model instance.
+     *
+     * @var array
+     */
+    private array $relations = [];
+
     /**
      * Model constructor.
      *
@@ -880,6 +887,20 @@ public function getAttribute(string $key): mixed
         return $this->attributes[$key] ?? null;
     }
 
+    /**
+     * Set a loaded relationship on the model's in-memory store.
+     *
+     * Used by eager loading to pre-populate a relation so a later access
+     * resolves from memory instead of issuing a query.
+     *
+     * @param string $name
+     * @param mixed  $value
+     */
+    public function setRelation(string $name, mixed $value): void
+    {
+        $this->relations[$name] = $value;
+    }
+
     /**
      * Returns the data
      *
@@ -919,8 +940,20 @@ public function __get(string $name): mixed
         $attribute_exists = isset($this->attributes[$name]);
 
         if (!$attribute_exists && method_exists($this, $name)) {
+            // Lazy-load once: a relation is resolved a single time per model
+            // instance and its result kept in memory. Repeated access returns
+            // the same loaded object instead of re-querying or re-hydrating.
+            if (array_key_exists($name, $this->relations)) {
+                return $this->relations[$name];
+            }
+
             $result = $this->$name();
-            return $result instanceof Relation ? $result->getResults() : $result;
+
+            if ($result instanceof Relation) {
+                return $this->relations[$name] = $result->getResults();
+            }
+
+            return $result;
         }
 
         if (!$attribute_exists) {
diff --git a/src/Database/Barry/Relation.php b/src/Database/Barry/Relation.php
index 2a7e97b8..bc9ab610 100644
--- a/src/Database/Barry/Relation.php
+++ b/src/Database/Barry/Relation.php
@@ -4,6 +4,8 @@
 
 namespace Bow\Database\Barry;
 
+use Closure;
+use Bow\Database\Collection;
 use Bow\Database\QueryBuilder;
 
 abstract class Relation
@@ -139,4 +141,103 @@ public function create(array $attributes): Model
      * @return mixed
      */
     abstract public function getResults(): mixed;
+
+    /**
+     * The parent attribute whose value is matched against the related models.
+     *
+     * @return string
+     */
+    abstract protected function eagerParentKey(): string;
+
+    /**
+     * The related column queried when eager loading the relation.
+     *
+     * @return string
+     */
+    abstract protected function eagerRelatedKey(): string;
+
+    /**
+     * Whether the relation resolves to many related models.
+     *
+     * @return bool
+     */
+    abstract protected function eagerIsMany(): bool;
+
+    /**
+     * Run the given callback with relation constraints disabled.
+     *
+     * Lets the eager loader build a relation without the single parent WHERE
+     * clause so it can be replaced by a batched whereIn over every parent.
+     *
+     * @param  Closure $callback
+     * @return mixed
+     */
+    public static function noConstraints(Closure $callback): mixed
+    {
+        $previous = static::$has_constraints;
+        static::$has_constraints = false;
+
+        try {
+            return $callback();
+        } finally {
+            static::$has_constraints = $previous;
+        }
+    }
+
+    /**
+     * Constrain the relation query to every parent key in a single whereIn.
+     *
+     * @param  Model[] $parents
+     * @return void
+     */
+    public function addEagerConstraints(array $parents): void
+    {
+        $keys = array_values(array_unique(array_filter(
+            array_map(fn (Model $parent) => $parent->getAttribute($this->eagerParentKey()), $parents),
+            fn ($value) => !is_null($value)
+        )));
+
+        // Fall back to an impossible match when no parent exposes a key so the
+        // query stays well-formed and returns nothing.
+        $this->query->whereIn($this->eagerRelatedKey(), count($keys) > 0 ? $keys : [0]);
+    }
+
+    /**
+     * Execute the eager query and return the related models.
+     *
+     * @return Collection
+     */
+    public function getEager(): Collection
+    {
+        $results = $this->query->get();
+
+        return $results instanceof Collection ? $results : new Collection([]);
+    }
+
+    /**
+     * Match the eager loaded related models back onto their parents.
+     *
+     * @param  Model[]    $parents
+     * @param  Collection $results
+     * @param  string     $name
+     * @return void
+     */
+    public function match(array $parents, Collection $results, string $name): void
+    {
+        $dictionary = [];
+
+        foreach ($results as $related) {
+            $dictionary[$related->getAttribute($this->eagerRelatedKey())][] = $related;
+        }
+
+        foreach ($parents as $parent) {
+            $key = $parent->getAttribute($this->eagerParentKey());
+            $matched = $dictionary[$key] ?? [];
+
+            $parent->setRelation(
+                $name,
+                $this->eagerIsMany() ? new Collection($matched) : ($matched[0] ?? null)
+            );
+        }
+    }
 }
diff --git a/src/Database/Barry/Relations/BelongsTo.php b/src/Database/Barry/Relations/BelongsTo.php
index 3bf8b83e..06f18f08 100644
--- a/src/Database/Barry/Relations/BelongsTo.php
+++ b/src/Database/Barry/Relations/BelongsTo.php
@@ -4,7 +4,6 @@
 
 namespace Bow\Database\Barry\Relations;
 
-use Bow\Cache\Cache;
 use Bow\Database\Barry\Model;
 use Bow\Database\Barry\Relation;
 use Bow\Database\Exception\QueryBuilderException;
@@ -38,28 +37,9 @@ public function __construct(
      */
     public function getResults(): mixed
     {
-        // Include the parent's foreign key value in the cache key so each parent
-        // resolves to its own related model. Without it the key is identical for
-        // every parent and a loop would always return the first cached result.
-        $foreign_key_value = $this->parent->getAttribute($this->foreign_key);
-        $key = $this->query->getTable() . ":" . $this->local_key . ":belongsto:"
-            . $this->related->getTable() . ":" . $this->foreign_key . ":" . $foreign_key_value;
-
-        $cache = Cache::store('file')->get($key);
-
-        if (!is_null($cache)) {
-            $related = new $this->related();
-            $related->setAttributes($cache);
-            return $related;
-        }
-
-        $result = $this->query->first();
-
-        if (!is_null($result)) {
-            Cache::store('file')->set($key, $result->toArray(), 500);
-        }
-
-        return $result;
+        // The result is lazy-loaded once per parent model instance and kept in
+        // memory by the model itself, so a plain query is enough here.
+        return $this->query->first();
     }
 
     /**
@@ -80,4 +60,28 @@ public function addConstraints(): void
         $foreign_key_value = $this->parent->getAttribute($this->foreign_key);
         $this->query->where($this->local_key, '=', $foreign_key_value);
     }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerParentKey(): string
+    {
+        return $this->foreign_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerRelatedKey(): string
+    {
+        return $this->local_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerIsMany(): bool
+    {
+        return false;
+    }
 }
diff --git a/src/Database/Barry/Relations/BelongsToMany.php b/src/Database/Barry/Relations/BelongsToMany.php
index 7e4bf0b8..4b0ad159 100644
--- a/src/Database/Barry/Relations/BelongsToMany.php
+++ b/src/Database/Barry/Relations/BelongsToMany.php
@@ -55,4 +55,28 @@ public function addConstraints(): void
         $foreign_key_value = $this->parent->getAttribute($this->foreign_key);
         $this->query->where($this->local_key, '=', $foreign_key_value);
     }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerParentKey(): string
+    {
+        return $this->foreign_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerRelatedKey(): string
+    {
+        return $this->local_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerIsMany(): bool
+    {
+        return true;
+    }
 }
diff --git a/src/Database/Barry/Relations/HasMany.php b/src/Database/Barry/Relations/HasMany.php
index 1ce65b77..357b7d6f 100644
--- a/src/Database/Barry/Relations/HasMany.php
+++ b/src/Database/Barry/Relations/HasMany.php
@@ -43,6 +43,33 @@ public function getResults(): Collection
      */
     public function addConstraints(): void
     {
-        $this->query = $this->query->where($this->foreign_key, $this->parent->getKeyValue());
+        // Match the related foreign key column against the parent's primary key.
+        // local_key holds the foreign key column name; foreign_key holds the
+        // parent primary key name, so filtering must use local_key here.
+        $this->query = $this->query->where($this->local_key, $this->parent->getKeyValue());
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerParentKey(): string
+    {
+        return $this->parent->getKey();
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerRelatedKey(): string
+    {
+        return $this->local_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerIsMany(): bool
+    {
+        return true;
     }
 }
diff --git a/src/Database/Barry/Relations/HasOne.php b/src/Database/Barry/Relations/HasOne.php
index 850b481e..60725b87 100644
--- a/src/Database/Barry/Relations/HasOne.php
+++ b/src/Database/Barry/Relations/HasOne.php
@@ -4,7 +4,6 @@
 
 namespace Bow\Database\Barry\Relations;
 
-use Bow\Cache\Cache;
 use Bow\Database\Barry\Model;
 use Bow\Database\Barry\Relation;
 
@@ -33,28 +32,9 @@ public function __construct(Model $related, Model $parent, string $foreign_key,
      */
     public function getResults(): ?Model
     {
-        // Include the parent's local key value in the cache key so each parent
-        // resolves to its own related model. Without it the key is identical for
-        // every parent and a loop would always return the first cached result.
-        $local_key_value = $this->parent->getAttribute($this->local_key);
-        $key = $this->query->getTable() . ":" . $this->local_key . ":hasone:"
-            . $this->related->getTable() . ":" . $this->foreign_key . ":" . $local_key_value;
-
-        $cache = Cache::store('file')->get($key);
-
-        if (!is_null($cache)) {
-            $related = new $this->related();
-            $related->setAttributes($cache);
-            return $related;
-        }
-
-        $result = $this->query->first();
-
-        if (!is_null($result)) {
-            Cache::store('file')->set($key, $result->toArray(), 60);
-        }
-
-        return $result;
+        // The result is lazy-loaded once per parent model instance and kept in
+        // memory by the model itself, so a plain query is enough here.
+        return $this->query->first();
     }
 
     /**
@@ -70,4 +50,28 @@ public function addConstraints(): void
 
         $this->query = $this->query->where($this->foreign_key, $this->parent->getAttribute($this->local_key));
     }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerParentKey(): string
+    {
+        return $this->local_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerRelatedKey(): string
+    {
+        return $this->foreign_key;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    protected function eagerIsMany(): bool
+    {
+        return false;
+    }
 }
diff --git a/tests/Database/Relation/BelongsToRelationQueryTest.php b/tests/Database/Relation/BelongsToRelationQueryTest.php
index 7da176a9..ca6c3cec 100644
--- a/tests/Database/Relation/BelongsToRelationQueryTest.php
+++ b/tests/Database/Relation/BelongsToRelationQueryTest.php
@@ -172,6 +172,10 @@ public function test_multiple_relationship_accesses(string $name)
         $this->assertInstanceOf(PetMasterModelStub::class, $master2);
         $this->assertEquals($master1->id, $master2->id);
         $this->assertEquals($master1->name, $master2->name);
+
+        // Lazy-load once: repeated access on the same instance must resolve the
+        // relation a single time and return the very same loaded object.
+        $this->assertSame($master1, $master2);
     }
 
     /**
diff --git a/tests/Database/Relation/EagerLoadingQueryTest.php b/tests/Database/Relation/EagerLoadingQueryTest.php
new file mode 100644
index 00000000..3cbd5abb
--- /dev/null
+++ b/tests/Database/Relation/EagerLoadingQueryTest.php
@@ -0,0 +1,299 @@
+connection($name)->dropIfExists("pets", false);
+                $migration->connection($name)->dropIfExists("pet_masters", false);
+            } catch (\Exception $e) {
+                // Ignore errors during cleanup
+            }
+        }
+    }
+
+    private function executeMigration(string $name): void
+    {
+        $migration = new MigrationExtendedStub();
+        $migration->connection($name)->dropIfExists("pets", false);
+        $migration->connection($name)->dropIfExists("pet_masters", false);
+
+        $migration->connection($name)->create("pet_masters", function (Table $table) {
+            $table->addIncrement("id");
+            $table->addString("name");
+        }, false);
+
+        $migration->connection($name)->create("pets", function (Table $table) {
+            $table->addIncrement("id");
+            $table->addString("name");
+            $table->addInteger("master_id");
+            $table->addForeign("master_id", [
+                "table" => "pet_masters",
+                "references" => "id",
+                "on" => "delete cascade"
+            ]);
+        }, false);
+    }
+
+    private function seedTestData(string $name): void
+    {
+        Database::connection($name)->statement("INSERT INTO pet_masters VALUES (1, 'didi'), (2, 'john'), (3, 'jane')");
+        Database::connection($name)->statement("INSERT INTO pets VALUES (1, 'fluffy', 1), (2, 'dolly', 1), (3, 'rex', 2), (4, 'max', 2), (5, 'bella', 3)");
+    }
+
+    // ===== belongsTo =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_belongs_to_matches_each_parent(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $expected = [1 => 'didi', 2 => 'didi', 3 => 'john', 4 => 'john', 5 => 'jane'];
+
+        $pets = PetModelStub::connection($name)->eager('master')->get();
+
+        $this->assertInstanceOf(Collection::class, $pets);
+
+        foreach ($pets as $pet) {
+            $master = $pet->master;
+            $this->assertInstanceOf(PetMasterModelStub::class, $master);
+            $this->assertEquals($expected[$pet->id], $master->name);
+            $this->assertEquals($pet->master_id, $master->id);
+        }
+    }
+
+    /**
+     * Eager loading must pre-populate the relation so accessing it issues no
+     * further query. We prove this by removing the related rows after the eager
+     * load: a lazy fetch would now find nothing, an eager one still has the data.
+     *
+     * @dataProvider connectionNames
+     */
+    public function test_eager_belongs_to_avoids_followup_query(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $pets = PetModelStub::connection($name)->eager('master')->get();
+
+        // Wipe the related table; an eager-loaded relation is unaffected.
+        Database::connection($name)->statement("DELETE FROM pets");
+        Database::connection($name)->statement("DELETE FROM pet_masters");
+
+        foreach ($pets as $pet) {
+            $master = $pet->master;
+            $this->assertInstanceOf(PetMasterModelStub::class, $master);
+            $this->assertEquals($pet->master_id, $master->id);
+        }
+    }
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_returns_same_instance_on_repeat_access(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $pets = PetModelStub::connection($name)->eager('master')->get();
+        $pet = $pets->first();
+
+        $this->assertSame($pet->master, $pet->master);
+    }
+
+    // ===== hasMany =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_has_many_matches_each_parent(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $expected = [1 => ['fluffy', 'dolly'], 2 => ['rex', 'max'], 3 => ['bella']];
+
+        $masters = PetMasterModelStub::connection($name)->eager('pets')->get();
+
+        Database::connection($name)->statement("DELETE FROM pets");
+
+        foreach ($masters as $master) {
+            $pets = $master->pets;
+            $this->assertInstanceOf(Collection::class, $pets);
+            $names = array_map(fn ($pet) => $pet->name, $pets->all());
+            sort($names);
+            $want = $expected[$master->id];
+            sort($want);
+            $this->assertEquals($want, $names);
+        }
+    }
+
+    // ===== hasOne =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_has_one_matches_each_parent(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $masters = PetMasterModelStub::connection($name)->eager('firstPet')->get();
+
+        Database::connection($name)->statement("DELETE FROM pets");
+
+        foreach ($masters as $master) {
+            $pet = $master->firstPet;
+            $this->assertInstanceOf(PetModelStub::class, $pet);
+            $this->assertEquals($master->id, $pet->master_id);
+        }
+    }
+
+    // ===== belongsToMany =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_belongs_to_many_matches_each_parent(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $expected = [1 => 2, 2 => 2, 3 => 1];
+
+        $masters = PetMasterModelStub::connection($name)->eager('manyPets')->get();
+
+        Database::connection($name)->statement("DELETE FROM pets");
+
+        foreach ($masters as $master) {
+            $pets = $master->manyPets;
+            $this->assertInstanceOf(Collection::class, $pets);
+            $this->assertCount($expected[$master->id], $pets->all());
+        }
+    }
+
+    // ===== multiple names =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_multiple_relations_in_one_call(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $masters = PetMasterModelStub::connection($name)->eager(['pets', 'firstPet'])->get();
+
+        Database::connection($name)->statement("DELETE FROM pets");
+
+        foreach ($masters as $master) {
+            $this->assertInstanceOf(Collection::class, $master->pets);
+            $this->assertInstanceOf(PetModelStub::class, $master->firstPet);
+        }
+    }
+
+    // ===== edge cases =====
+
+    /**
+     * @dataProvider connectionNames
+     */
+    public function test_eager_with_no_related_rows(string $name)
+    {
+        $this->executeMigration($name);
+        Database::connection($name)->statement("INSERT INTO pet_masters VALUES (1, 'didi')");
+
+        $masters = PetMasterModelStub::connection($name)->eager('pets')->get();
+
+        foreach ($masters as $master) {
+            $pets = $master->pets;
+            $this->assertInstanceOf(Collection::class, $pets);
+            $this->assertCount(0, $pets->all());
+        }
+    }
+
+    /**
+     * Guards the HasMany::addConstraints() fix: lazy access must filter on the
+     * foreign key column (master_id), not the parent primary key.
+     *
+     * @dataProvider connectionNames
+     */
+    public function test_lazy_has_many_filters_on_foreign_key(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        $master = PetMasterModelStub::connection($name)->retrieve(2);
+        $pets = $master->pets;
+
+        $names = array_map(fn ($pet) => $pet->name, $pets->all());
+        sort($names);
+
+        $this->assertEquals(['max', 'rex'], $names);
+    }
+
+    /**
+     * The eager list must not leak onto a subsequent query reusing the shared
+     * builder instance.
+     *
+     * @dataProvider connectionNames
+     */
+    public function test_eager_does_not_leak_into_next_query(string $name)
+    {
+        $this->executeMigration($name);
+        $this->seedTestData($name);
+
+        PetModelStub::connection($name)->eager('master')->get();
+
+        // A plain fetch afterwards must not attempt to eager load anything.
+        $pets = PetModelStub::connection($name)->all();
+
+        $this->assertInstanceOf(Collection::class, $pets);
+        $this->assertCount(5, $pets->all());
+    }
+}
diff --git a/tests/Database/Stubs/PetMasterModelStub.php b/tests/Database/Stubs/PetMasterModelStub.php
index 7ce52aec..fad409bd 100644
--- a/tests/Database/Stubs/PetMasterModelStub.php
+++ b/tests/Database/Stubs/PetMasterModelStub.php
@@ -2,7 +2,9 @@
 
 namespace Bow\Tests\Database\Stubs;
 
+use Bow\Database\Barry\Relations\BelongsToMany;
 use Bow\Database\Barry\Relations\HasMany;
+use Bow\Database\Barry\Relations\HasOne;
 
 class PetMasterModelStub extends \Bow\Database\Barry\Model
 {
@@ -28,6 +30,26 @@ class PetMasterModelStub extends \Bow\Database\Barry\Model
      */
     public function pets(): HasMany
     {
-        return $this->hasMany(PetModelStub::class);
+        return $this->hasMany(PetModelStub::class, 'id', 'master_id');
+    }
+
+    /**
+     * Get a single owned pet
+     *
+     * @return HasOne
+     */
+    public function firstPet(): HasOne
+    {
+        return $this->hasOne(PetModelStub::class, 'master_id', 'id');
+    }
+
+    /**
+     * Get the list of pets through a belongs to many relation
+     *
+     * @return BelongsToMany
+     */
+    public function manyPets(): BelongsToMany
+    {
+        return $this->belongsToMany(PetModelStub::class, 'id', 'master_id');
     }
 }

From 9dac46a1899abbb4d43306b747e85fd72d13d28d Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 4 Jun 2026 14:21:21 +0000
Subject: [PATCH 39/60] Update roadmap

---
 ROADMAP.md | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md
index fc5345a7..077d513e 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -1,7 +1,7 @@
 # BowPHP Framework Roadmap
 
 > Living document based on source code analysis (5.x branch) and the project manifesto.
-> Last updated: May 2026
+> Last updated: June 2026
 
 ---
 
@@ -68,6 +68,11 @@ Highlights from the latest iterations — already merged into `5.x`. Full detail
 
 ### Barry ORM
 
+* **Eager loading** via `Builder::eager(string|array $relations)` — batches related models in a single `WHERE IN` query (solves N+1), supports `hasOne` / `hasMany` / `belongsTo` / `belongsToMany`; the eager list is reset after each `get()` so it can't leak into the next query on the shared builder.
+* Relations now **lazy-load once per model instance** and keep the result in memory (`Model::setRelation` + private `$relations`), replacing the previous file-based cache in `BelongsTo` / `HasOne` (fixes stale and cross-parent cache bugs).
+* Fixed `belongsTo` resolution inside loops — each parent now resolves its own related model instead of always returning the first.
+* Fixed `HasMany::addConstraints()` to filter on the foreign-key column (was incorrectly using the parent primary key).
+* Deep casting now applied when `toArray()` / `toJson()` / `__toString()` are called.
 * `SoftDelete` trait (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, events `model.restoring/restored/forceDeleting/forceDeleted`).
 * Fixed `array` cast: no longer returns `stdClass`.
 * Removed dead `$soft_delete` property (replaced by the trait).
@@ -112,7 +117,7 @@ Highlights from the latest iterations — already merged into `5.x`. Full detail
 | Separate unit tests from integration tests    | ⏳ Planned  | High     | DB/FTP/S3 tests require external services                                                                                 |
 | Add PHPUnit `@group` annotations              | ⏳ Planned  | High     | `@group unit`, `@group integration`, `@group database`                                                                    |
 | Configure GitHub Actions with Docker services | ⏳ Planned  | High     | MySQL, PostgreSQL, Redis for CI                                                                                           |
-| Increase unit test coverage                   | 🔄 Ongoing | Medium   | 1,600+ tests, 0 logical failures. Recent additions: SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
+| Increase unit test coverage                   | 🔄 Ongoing | Medium   | 1,600+ tests, 0 logical failures. Recent additions: eager loading (`EagerLoadingQueryTest`), SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
 | Integrate PHPStan level 5+                    | ⏳ Planned  | Medium   | Current constraint: `phpstan/phpstan: ^0.12.87` — upgrade to ^1.x before targeting higher levels                          |
 
 ### Code Fixes

From a7472e8a463d8e23478f1ef015ffd5ab8fb21f17 Mon Sep 17 00:00:00 2001
From: papac 
Date: Thu, 4 Jun 2026 14:52:03 +0000
Subject: [PATCH 40/60] Update CHANGELOG

---
 CHANGELOG.md | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 39fd1109..a4cea6a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.20 - 2026-06-04
+
+### What's Changed
+
+* feat(orm): add eager loading process by @papac in https://github.com/bowphp/framework/pull/404
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/405
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.12...5.3.20
+
 ## 5.3.12 - 2026-06-02
 
 ### What's Changed
@@ -290,6 +299,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From 639866d268b3b1a3aa8d4ed5e1aee34d06de40b2 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 4 Jun 2026 23:38:32 +0000
Subject: [PATCH 41/60] fix(console): fix execute custom command prefexed by
 native command

---
 src/Console/Console.php | 32 +++++++++++++++++---------------
 1 file changed, 17 insertions(+), 15 deletions(-)

diff --git a/src/Console/Console.php b/src/Console/Console.php
index 6f278fc4..a0809aaa 100644
--- a/src/Console/Console.php
+++ b/src/Console/Console.php
@@ -182,7 +182,6 @@ class Console
         'exception',
         'event',
         'task',
-        'scheduler',
         'command',
         'listener',
         'notifier'
@@ -370,20 +369,15 @@ public function call(?string $command): mixed
             exit(0);
         }
 
-        // The built-in commands have priority
-        $commands = $this->command->getCommands();
-
-        if (!in_array($command, array_keys($commands))) {
-            // Try to execute the custom command
-            if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) {
-                // `php bow  help` shows the registered help instead of running it.
-                if ($this->arg->getTarget() === 'help' && !$this->arg->getAction()) {
-                    $this->help($command);
-                    exit(0);
-                }
-
-                return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command);
+        // Try to execute the custom command
+        if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) {
+            // `php bow  help` shows the registered help instead of running it.
+            if ($this->arg->getTarget() === 'help' && !$this->arg->getAction()) {
+                $this->help($command);
+                exit(0);
             }
+
+            return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command);
         }
 
         if (!in_array($command, static::COMMAND)) {
@@ -590,7 +584,15 @@ private function schedule(): void
             $this->throwFailsCommand('Bad command usage', 'help schedule');
         }
 
-        $this->command->call("schedule:{$action}", $action, $this->arg->getTarget());
+        $target = $this->arg->getTarget();
+
+        // schedule:test expects a 0-based integer index; getTarget() returns a
+        // ?string, which would trip the strict_types signature of test(int).
+        if ($action === 'test') {
+            $target = (int) $target;
+        }
+
+        $this->command->call("schedule:{$action}", $action, $target);
     }
 
     /**

From 03f6055802771663321bd6685e8d944150673f61 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Thu, 4 Jun 2026 23:54:44 +0000
Subject: [PATCH 42/60] fix(session): fix session security

---
 src/Security/Crypto.php                    | 139 +++++++++++++++++++--
 src/Security/Tokenize.php                  |   3 +-
 src/Session/Adapters/FilesystemAdapter.php |   4 +-
 src/Session/Session.php                    |  49 ++++++--
 src/Support/helpers.php                    |  14 ++-
 5 files changed, 179 insertions(+), 30 deletions(-)

diff --git a/src/Security/Crypto.php b/src/Security/Crypto.php
index 5d19f9ca..27a2dd25 100644
--- a/src/Security/Crypto.php
+++ b/src/Security/Crypto.php
@@ -4,7 +4,7 @@
 
 namespace Bow\Security;
 
-use Bow\Support\Str;
+use RuntimeException;
 
 class Crypto
 {
@@ -22,6 +22,19 @@ class Crypto
      */
     private static string $cipher = 'AES-256-CBC';
 
+    /**
+     * Header tagging the authenticated (random-IV + HMAC) payload format.
+     *
+     * The ':' is not part of the base64 alphabet, so a value carrying this
+     * prefix can never be confused with a legacy (base64-only) ciphertext.
+     */
+    private const HEADER = 'BOW2:';
+
+    /**
+     * The authentication tag length in bytes (HMAC-SHA256).
+     */
+    private const MAC_LENGTH = 32;
+
     /**
      * Set the key
      *
@@ -38,22 +51,46 @@ public static function setKey(string $key, ?string $cipher = null): void
     }
 
     /**
-     * Encrypt data
+     * Encrypt data.
+     *
+     * Produces an authenticated payload: a fresh random IV is used for every
+     * call (so identical plaintexts yield different ciphertexts) and an
+     * encrypt-then-MAC HMAC-SHA256 tag protects against tampering.
      *
      * @param  string $data
-     * @return string|bool
+     * @return string
      */
-    public static function encrypt(string $data): string|bool
+    public static function encrypt(string $data): string
     {
-        $iv_size = openssl_cipher_iv_length(static::$cipher);
+        $key = static::resolveKey();
+
+        $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+        $iv = random_bytes($iv_size);
+
+        $cipher_text = openssl_encrypt(
+            $data,
+            static::$cipher,
+            static::deriveKey('enc', $key),
+            OPENSSL_RAW_DATA,
+            $iv
+        );
+
+        if ($cipher_text === false) {
+            throw new RuntimeException('Unable to encrypt the given data.');
+        }
 
-        $iv = Str::slice(sha1(static::$key), 0, $iv_size);
+        $mac = hash_hmac('sha256', $iv . $cipher_text, static::deriveKey('auth', $key), true);
 
-        return openssl_encrypt($data, static::$cipher, static::$key, 0, $iv);
+        return self::HEADER . base64_encode($iv . $mac . $cipher_text);
     }
 
     /**
-     * decrypt
+     * Decrypt data.
+     *
+     * Authenticated payloads are verified before decryption and fail closed
+     * (return false) on a bad tag, truncation or wrong key. Values produced by
+     * the previous unauthenticated format are still readable for backward
+     * compatibility.
      *
      * @param string $data
      *
@@ -61,10 +98,90 @@ public static function encrypt(string $data): string|bool
      */
     public static function decrypt(string $data): string|bool
     {
-        $iv_size = openssl_cipher_iv_length(static::$cipher);
+        $key = static::resolveKey();
+
+        if (!str_starts_with($data, self::HEADER)) {
+            return static::decryptLegacy($data, $key);
+        }
+
+        $raw = base64_decode(substr($data, strlen(self::HEADER)), true);
+
+        if ($raw === false) {
+            return false;
+        }
+
+        $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+
+        if (strlen($raw) <= $iv_size + self::MAC_LENGTH) {
+            return false;
+        }
+
+        $iv = substr($raw, 0, $iv_size);
+        $mac = substr($raw, $iv_size, self::MAC_LENGTH);
+        $cipher_text = substr($raw, $iv_size + self::MAC_LENGTH);
+
+        $calculated = hash_hmac('sha256', $iv . $cipher_text, static::deriveKey('auth', $key), true);
+
+        // Reject tampered or wrong-key payloads before touching the cipher.
+        if (!hash_equals($calculated, $mac)) {
+            return false;
+        }
+
+        return openssl_decrypt(
+            $cipher_text,
+            static::$cipher,
+            static::deriveKey('enc', $key),
+            OPENSSL_RAW_DATA,
+            $iv
+        );
+    }
+
+    /**
+     * Decrypt a value produced by the legacy (static IV, unauthenticated)
+     * format. Kept only so data encrypted before the upgrade keeps working.
+     *
+     * @param  string $data
+     * @param  string $key
+     * @return string|bool
+     */
+    private static function decryptLegacy(string $data, string $key): string|bool
+    {
+        $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+
+        $iv = substr(sha1($key), 0, $iv_size);
+
+        return openssl_decrypt($data, static::$cipher, $key, 0, $iv);
+    }
 
-        $iv = Str::slice(sha1(static::$key), 0, $iv_size);
+    /**
+     * Derive a purpose-specific 256-bit subkey from the configured key.
+     *
+     * Separating the encryption key from the authentication key (domain
+     * separation) is required for encrypt-then-MAC to be sound, and normalises
+     * an arbitrary-length configured key to a fixed strong key.
+     *
+     * @param  string $context
+     * @param  string $key
+     * @return string
+     */
+    private static function deriveKey(string $context, string $key): string
+    {
+        return hash_hmac('sha256', 'BowCrypto|v2|' . $context, $key, true);
+    }
+
+    /**
+     * Resolve the configured key or fail loudly when it is missing.
+     *
+     * @return string
+     */
+    private static function resolveKey(): string
+    {
+        if (static::$key === null || static::$key === '') {
+            throw new RuntimeException(
+                'The application security key is not set. Define security.key before using Crypto.'
+            );
+        }
 
-        return openssl_decrypt($data, static::$cipher, static::$key, 0, $iv);
+        return static::$key;
     }
 }
diff --git a/src/Security/Tokenize.php b/src/Security/Tokenize.php
index ca67d079..a7b9b873 100644
--- a/src/Security/Tokenize.php
+++ b/src/Security/Tokenize.php
@@ -94,7 +94,8 @@ public static function verify(string $token, bool $strict = false): bool
 
         $csrf = Session::getInstance()->get('__bow.csrf');
 
-        if ($token !== $csrf['token']) {
+        // Constant-time comparison to avoid leaking the token via timing.
+        if (!hash_equals((string) $csrf['token'], $token)) {
             return false;
         }
 
diff --git a/src/Session/Adapters/FilesystemAdapter.php b/src/Session/Adapters/FilesystemAdapter.php
index 0db41053..9860a0c4 100644
--- a/src/Session/Adapters/FilesystemAdapter.php
+++ b/src/Session/Adapters/FilesystemAdapter.php
@@ -90,7 +90,9 @@ public function gc(int $maxlifetime): int|false
     public function open(string $path, string $name): bool
     {
         if (!is_dir($this->save_path)) {
-            mkdir($this->save_path);
+            // 0700: session files contain authentication state and must not be
+            // readable by other users on a shared host.
+            mkdir($this->save_path, 0700, true);
         }
 
         return true;
diff --git a/src/Session/Session.php b/src/Session/Session.php
index c4d8eff5..19348927 100644
--- a/src/Session/Session.php
+++ b/src/Session/Session.php
@@ -6,7 +6,6 @@
 
 use BadMethodCallException;
 use Bow\Contracts\CollectionInterface;
-use Bow\Security\Crypto;
 use Bow\Session\Adapters\ArrayAdapter;
 use Bow\Session\Adapters\DatabaseAdapter;
 use Bow\Session\Adapters\FilesystemAdapter;
@@ -75,7 +74,11 @@ private function __construct(array $config)
                 'path' => '/',
                 'domain' => null,
                 'secure' => false,
-                'httponly' => false,
+                // Keep the session cookie out of reach of JavaScript by default
+                // (mitigates session theft via XSS) and constrain cross-site
+                // sending (mitigates CSRF).
+                'httponly' => true,
+                'samesite' => 'Lax',
                 'save_path' => null,
             ],
             $config
@@ -115,6 +118,14 @@ public static function getInstance(): ?Session
      */
     public function regenerate(): void
     {
+        $this->start();
+
+        // Rotate the underlying session ID and delete the previous record so a
+        // fixated/leaked ID can no longer be reused, then clear the values.
+        if (PHP_SESSION_ACTIVE === session_status()) {
+            session_regenerate_id(true);
+        }
+
         $this->flush();
         $this->start();
     }
@@ -162,11 +173,17 @@ public function start(): bool
      */
     private function initializeDriver(): void
     {
+        // Reject uninitialized session IDs (anti session-fixation) and never
+        // accept the session ID from the URL/query string. Must be set before
+        // session_start().
+        @ini_set('session.use_strict_mode', '1');
+        @ini_set('session.use_only_cookies', '1');
+
         // We Apply session cookie name
         @session_name($this->config['name']);
 
         if (!isset($_COOKIE[$this->config['name']])) {
-            @session_id(hash("sha256", $this->generateId()));
+            @session_id($this->generateId());
         }
 
         // We create get driver
@@ -204,13 +221,17 @@ private function initializeDriver(): void
     }
 
     /**
-     * Generate session ID
+     * Generate a cryptographically secure session ID.
+     *
+     * Uses the CSPRNG (random_bytes) instead of time-based primitives such as
+     * uniqid()/microtime(), which are predictable and would let an attacker
+     * guess or fixate session identifiers.
      *
      * @return string
      */
     private function generateId(): string
     {
-        return Crypto::encrypt(uniqid(microtime()));
+        return bin2hex(random_bytes(32));
     }
 
     /**
@@ -223,14 +244,16 @@ private function setCookieParameters(): void
         $domain = $this->config['domain'] ?? null;
         $secure = (bool)$this->config["secure"];
         $httponly = (bool)$this->config["httponly"];
-
-        session_set_cookie_params(
-            (int)$this->config["lifetime"],
-            $this->config["path"],
-            $domain,
-            $secure,
-            $httponly
-        );
+        $samesite = $this->config["samesite"] ?? 'Lax';
+
+        session_set_cookie_params([
+            'lifetime' => (int)$this->config["lifetime"],
+            'path' => $this->config["path"],
+            'domain' => $domain,
+            'secure' => $secure,
+            'httponly' => $httponly,
+            'samesite' => $samesite,
+        ]);
     }
 
     /**
diff --git a/src/Support/helpers.php b/src/Support/helpers.php
index 0225cccd..4126b117 100644
--- a/src/Support/helpers.php
+++ b/src/Support/helpers.php
@@ -665,7 +665,10 @@ function collect(array $data = []): Collection
 
 if (!function_exists('encrypt')) {
     /**
-     * Encrypt data
+     * Encrypt data using the application security key.
+     *
+     * Returns an authenticated payload (random IV + HMAC), so encrypting the
+     * same value twice yields different ciphertexts.
      *
      * @param  string $data
      * @return string
@@ -678,12 +681,15 @@ function encrypt(string $data): string
 
 if (!function_exists('decrypt')) {
     /**
-     * Decrypt data
+     * Decrypt a value previously produced by encrypt().
+     *
+     * Fails closed: returns false when the payload has been tampered with or
+     * was encrypted with a different key.
      *
      * @param  string $data
-     * @return string
+     * @return string|bool
      */
-    function decrypt(string $data): string
+    function decrypt(string $data): string|bool
     {
         return Crypto::decrypt($data);
     }

From ccc862eecc842554db0887c1c7b0ae84c10111c1 Mon Sep 17 00:00:00 2001
From: papac 
Date: Fri, 5 Jun 2026 00:00:40 +0000
Subject: [PATCH 43/60] Update CHANGELOG

---
 CHANGELOG.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a4cea6a5..dbc8a34b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.21 - 2026-06-04
+
+### What's Changed
+
+* fix(console): fix execute custom command prefexed by native command by @papac in https://github.com/bowphp/framework/pull/406
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/407
+* fix(session): fix session security by @papac in https://github.com/bowphp/framework/pull/408
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.20...5.3.21
+
 ## 5.3.20 - 2026-06-04
 
 ### What's Changed
@@ -300,6 +310,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 ```
 Ref: #255
 

From 61c4188ff315126e2210639428bfa8b75b7b0cec Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Fri, 5 Jun 2026 18:57:19 +0000
Subject: [PATCH 44/60] chore(helpers): add more meaning comments

---
 src/Support/helpers.php | 535 +++++++++++++++++++++++-----------------
 1 file changed, 310 insertions(+), 225 deletions(-)

diff --git a/src/Support/helpers.php b/src/Support/helpers.php
index 4126b117..7e8ea351 100644
--- a/src/Support/helpers.php
+++ b/src/Support/helpers.php
@@ -44,12 +44,25 @@
 use Carbon\Carbon;
 use Monolog\Logger;
 
+/*
+ * Global helper functions.
+ *
+ * Each helper is wrapped in `if (!function_exists(...))` so an application may
+ * override any of them by declaring its own version before this file loads.
+ * Most helpers are thin shortcuts over a framework class; the section banners
+ * below mark where each topic begins.
+ */
+
 if (!function_exists('app')) {
     /**
-     * Application container
+     * Resolve the service container, or a binding out of it.
+     *
+     * With no arguments the container instance itself is returned; with a key
+     * the matching binding is resolved (using `$setting` as constructor
+     * parameters when provided).
      *
-     * @param  ?string $key
-     * @param  array   $setting
+     * @param  ?string $key     Binding name to resolve, or null for the container
+     * @param  array   $setting Parameters passed to makeWith() when resolving
      * @return mixed
      */
     function app(?string $key = null, array $setting = []): mixed
@@ -60,6 +73,7 @@ function app(?string $key = null, array $setting = []): mixed
             return $capsule;
         }
 
+        // No extra parameters: a plain resolution is enough.
         if (empty($setting)) {
             return $capsule->make($key);
         }
@@ -70,12 +84,15 @@ function app(?string $key = null, array $setting = []): mixed
 
 if (!function_exists('config')) {
     /**
-     * Application configuration
+     * Read or write application configuration.
+     *
+     * No key returns the configuration loader; a key alone reads the value;
+     * a key with a value writes (and returns) it.
      *
-     * @param  ?string|null $key
-     * @param  mixed|null   $setting
+     * @param  string|null $key     Dotted configuration key
+     * @param  mixed       $setting Value to set, or null to read
      * @return Loader|mixed
-     * @throws
+     * @throws Exception
      */
     function config(?string $key = null, mixed $setting = null): mixed
     {
@@ -95,7 +112,7 @@ function config(?string $key = null, mixed $setting = null): mixed
 
 if (!function_exists('response')) {
     /**
-     * Response object instance
+     * Get the shared Response instance from the container.
      *
      * @return Response
      */
@@ -112,7 +129,7 @@ function response(): Response
 
 if (!function_exists('request')) {
     /**
-     * Represents the Request class
+     * Get the shared Request instance from the container.
      *
      * @return Request
      */
@@ -129,10 +146,16 @@ function request(): Request
 
 if (!function_exists('db')) {
     /**
-     * Allows to connect to another database and return the instance of the DB
+     * Get the database manager, optionally on another connection.
+     *
+     * With no arguments the current connection is returned. When `$cb` is
+     * given it runs against `$name`, then the previous connection is restored.
+     *
+     * Note: registered under the `db` guard but the function is named
+     * `app_db()`; call it as `app_db(...)`.
      *
-     * @param  string|null   $name
-     * @param  callable|null $cb
+     * @param  string|null   $name Connection name to switch to
+     * @param  callable|null $cb   Work to run on that connection, then revert
      * @return DB
      * @throws ConnectionException
      */
@@ -163,15 +186,19 @@ function app_db(?string $name = null, ?callable $cb = null): DB
 
 if (!function_exists('view')) {
     /**
-     * View alias of View::parse
+     * Render a view template through View::parse().
      *
-     * @param  string    $template
-     * @param  array|int $data
-     * @param  int       $code
+     * `$data` may be passed as the status code directly (e.g. `view('404', 404)`),
+     * in which case it is treated as `$code` and the data set is left empty.
+     *
+     * @param  string    $template View name
+     * @param  array|int $data     View data, or the HTTP status code
+     * @param  int       $code     HTTP status code
      * @return View
      */
     function view(string $template, int|array $data = [], int $code = 200): View
     {
+        // Allow the status code to be supplied in the $data slot.
         if (is_int($data)) {
             $code = $data;
 
@@ -187,13 +214,14 @@ function view(string $template, int|array $data = [], int $code = 200): View
 
 if (!function_exists('table')) {
     /**
-     * Table alias of DB::table
+     * Get a query builder for a table (optionally on another connection).
      *
-     * @param      string  $name
-     * @param      ?string $connexion
-     * @return     Bow\Database\QueryBuilder
+     * @param      string  $name      Table name
+     * @param      ?string $connexion Connection to switch to first
+     * @return     QueryBuilder
      * @throws     ConnectionException
-     * @deprecated
+     * @deprecated Use app_db_table() instead.
+     * @see        app_db_table()
      */
     function table(string $name, ?string $connexion = null): QueryBuilder
     {
@@ -207,12 +235,12 @@ function table(string $name, ?string $connexion = null): QueryBuilder
 
 if (!function_exists('app_db_table')) {
     /**
-     * Table alias of DB::table
+     * Get a query builder for a table (optionally on another connection).
      *
-     * @param      string  $name
-     * @param      ?string $connexion
-     * @return     Bow\Database\QueryBuilder
-     * @throws     ConnectionException
+     * @param  string  $name      Table name
+     * @param  ?string $connexion Connection to switch to first
+     * @return QueryBuilder
+     * @throws ConnectionException
      */
     function app_db_table(string $name, ?string $connexion = null): QueryBuilder
     {
@@ -229,7 +257,7 @@ function app_db_table(string $name, ?string $connexion = null): QueryBuilder
      * Returns the last ID following an INSERT query
      * on a table whose ID is auto_increment.
      *
-     * @param  string|null $name
+     * @param  string|null $name Sequence/connection name, if required
      * @return int
      */
     function get_last_insert_id(?string $name = null): int
@@ -240,12 +268,12 @@ function get_last_insert_id(?string $name = null): int
 
 if (!function_exists('app_db_select')) {
     /**
-     * Launches SELECT SQL Queries
+     * Run a raw SELECT query.
      *
      * app_db_select('SELECT * FROM users');
      *
-     * @param  string $sql
-     * @param  array  $data
+     * @param  string $sql  SQL statement, may contain bindings
+     * @param  array  $data Values bound to the statement
      * @return int|array|stdClass
      */
     function app_db_select(string $sql, array $data = []): array|int|stdClass
@@ -256,10 +284,10 @@ function app_db_select(string $sql, array $data = []): array|int|stdClass
 
 if (!function_exists('app_db_select_one')) {
     /**
-     * Launches SELECT SQL Queries
+     * Run a raw SELECT query and return a single row.
      *
-     * @param  string $sql
-     * @param  array  $data
+     * @param  string $sql  SQL statement, may contain bindings
+     * @param  array  $data Values bound to the statement
      * @return int|array|StdClass
      */
     function app_db_select_one(string $sql, array $data = []): array|int|StdClass
@@ -270,11 +298,11 @@ function app_db_select_one(string $sql, array $data = []): array|int|StdClass
 
 if (!function_exists('app_db_insert')) {
     /**
-     * Launches INSERT SQL Queries
+     * Run a raw INSERT query.
      *
-     * @param  string $sql
-     * @param  array  $data
-     * @return int
+     * @param  string $sql  SQL statement, may contain bindings
+     * @param  array  $data Values bound to the statement
+     * @return int Number of affected rows
      */
     function app_db_insert(string $sql, array $data = []): int
     {
@@ -284,11 +312,11 @@ function app_db_insert(string $sql, array $data = []): int
 
 if (!function_exists('app_db_delete')) {
     /**
-     * Launches DELETE type SQL queries
+     * Run a raw DELETE query.
      *
-     * @param  string $sql
-     * @param  array  $data
-     * @return int
+     * @param  string $sql  SQL statement, may contain bindings
+     * @param  array  $data Values bound to the statement
+     * @return int Number of affected rows
      */
     function app_db_delete(string $sql, array $data = []): int
     {
@@ -298,11 +326,11 @@ function app_db_delete(string $sql, array $data = []): int
 
 if (!function_exists('app_db_update')) {
     /**
-     * Launches UPDATE SQL Queries
+     * Run a raw UPDATE query.
      *
-     * @param  string $sql
-     * @param  array  $data
-     * @return int
+     * @param  string $sql  SQL statement, may contain bindings
+     * @param  array  $data Values bound to the statement
+     * @return int Number of affected rows
      */
     function app_db_update(string $sql, array $data = []): int
     {
@@ -312,9 +340,9 @@ function app_db_update(string $sql, array $data = []): int
 
 if (!function_exists('app_db_statement')) {
     /**
-     * Launches CREATE TABLE, ALTER TABLE, RENAME, DROP TABLE SQL Query
+     * Run a schema/DDL statement (CREATE, ALTER, RENAME, DROP, ...).
      *
-     * @param  string $sql
+     * @param  string $sql SQL statement
      * @return int
      */
     function app_db_statement(string $sql): int
@@ -325,9 +353,10 @@ function app_db_statement(string $sql): int
 
 if (!function_exists('debug')) {
     /**
-     * debug, variable debug function
-     * it allows you to have a color
-     * Synthetic data types.
+     * Dump one or more variables with colourised, typed output.
+     *
+     * Accepts any number of arguments; each is sanitised then handed to
+     * Util::debug().
      *
      * @return void
      */
@@ -344,7 +373,7 @@ function ($x) {
 
 if (!function_exists("sep")) {
     /**
-     * Get the PHP OS separator
+     * Get the OS-specific directory separator.
      *
      * @return string
      */
@@ -356,10 +385,10 @@ function sep(): string
 
 if (!function_exists('create_csrf_token')) {
     /**
-     * Create a new token
+     * Create (or fetch) the current CSRF token payload.
      *
-     * @param  int|null $time
-     * @return ?array
+     * @param  int|null $time Lifetime in seconds for the generated token
+     * @return ?array The token data (token, field, expire_at), or null
      * @throws SessionException
      */
     function create_csrf_token(?int $time = null): ?array
@@ -370,10 +399,10 @@ function create_csrf_token(?int $time = null): ?array
 
 if (!function_exists('csrf_token')) {
     /**
-     * Get the generate token
+     * Get the current CSRF token string.
      *
      * @return string
-     * @throws HttpException
+     * @throws HttpException When no token could be generated
      * @throws SessionException
      */
     function csrf_token(): string
@@ -393,10 +422,11 @@ function csrf_token(): string
 
 if (!function_exists('csrf_field')) {
     /**
-     * Get the input csrf field
+     * Get the ready-made hidden CSRF input field.
      *
      * @return string
-     * @throws HttpException|SessionException
+     * @throws HttpException When no token could be generated
+     * @throws SessionException
      */
     function csrf_field(): string
     {
@@ -415,9 +445,9 @@ function csrf_field(): string
 
 if (!function_exists('method_field')) {
     /**
-     * Create hidden http method field
+     * Build a hidden input that spoofs the HTTP method (PUT, PATCH, DELETE).
      *
-     * @param  string $method
+     * @param  string $method HTTP verb to spoof
      * @return string
      */
     function method_field(string $method): string
@@ -430,7 +460,7 @@ function method_field(string $method): string
 
 if (!function_exists('gen_csrf_token')) {
     /**
-     * Generate token string
+     * Generate a fresh, standalone token string (not stored in the session).
      *
      * @return string
      */
@@ -442,10 +472,10 @@ function gen_csrf_token(): string
 
 if (!function_exists('verify_csrf')) {
     /**
-     * Check the token value
+     * Verify a submitted CSRF token against the stored one.
      *
-     * @param  string $token
-     * @param  bool   $strict
+     * @param  string $token  Token received from the request
+     * @param  bool   $strict Also enforce token expiry when true
      * @return bool
      * @throws SessionException
      */
@@ -457,9 +487,9 @@ function verify_csrf(string $token, bool $strict = false): bool
 
 if (!function_exists('csrf_time_is_expired')) {
     /**
-     * Check if token is expired by time
+     * Check whether the stored CSRF token has expired.
      *
-     * @param  string|null $time
+     * @param  string|null $time Reference time, defaults to now
      * @return bool
      * @throws SessionException
      */
@@ -471,11 +501,11 @@ function csrf_time_is_expired(?string $time = null): bool
 
 if (!function_exists('response_json')) {
     /**
-     * Make json response
+     * Send a JSON response.
      *
-     * @param  array|object $data
-     * @param  int          $code
-     * @param  array        $headers
+     * @param  array|object $data    Payload to encode
+     * @param  int          $code    HTTP status code
+     * @param  array        $headers Extra response headers
      * @return string
      */
     function response_json(array|object $data, int $code = 200, array $headers = []): string
@@ -486,11 +516,11 @@ function response_json(array|object $data, int $code = 200, array $headers = [])
 
 if (!function_exists('response_download')) {
     /**
-     * Download file
+     * Send a file as a download response.
      *
-     * @param  string      $file
-     * @param  null|string $filename
-     * @param  array       $headers
+     * @param  string      $file     Path to the file on disk
+     * @param  null|string $filename Name presented to the client
+     * @param  array       $headers  Extra response headers
      * @return string
      */
     function response_download(string $file, ?string $filename = null, array $headers = []): string
@@ -501,7 +531,7 @@ function response_download(string $file, ?string $filename = null, array $header
 
 if (!function_exists('set_response_status_code')) {
     /**
-     * Set status code
+     * Set the HTTP response status code.
      *
      * @param  int $code
      * @return mixed
@@ -514,7 +544,7 @@ function set_response_status_code(int $code): mixed
 
 if (!function_exists('sanitize')) {
     /**
-     * Sanitize data
+     * Sanitize a value (numeric values are returned untouched).
      *
      * @param  mixed $data
      * @return mixed
@@ -531,7 +561,7 @@ function sanitize(mixed $data): mixed
 
 if (!function_exists('secure')) {
     /**
-     * Secure data with sanitize it
+     * Sanitize a value in strict/secure mode (numeric values pass through).
      *
      * @param  mixed $data
      * @return mixed
@@ -548,7 +578,7 @@ function secure(mixed $data): mixed
 
 if (!function_exists('set_response_header')) {
     /**
-     * Update http headers
+     * Add a header to the outgoing response.
      *
      * @param  string $key
      * @param  string $value
@@ -562,7 +592,7 @@ function set_response_header(string $key, string $value): void
 
 if (!function_exists('get_response_header')) {
     /**
-     * Get http header
+     * Read a header from the incoming request.
      *
      * @param  string $key
      * @return string|null
@@ -575,9 +605,9 @@ function get_response_header(string $key): ?string
 
 if (!function_exists('redirect')) {
     /**
-     * Make redirect response
+     * Get the redirector, optionally redirecting straight to a path.
      *
-     * @param  string|null $path
+     * @param  string|null $path Target to redirect to, or null for the instance
      * @return Redirect
      */
     function redirect(?string $path = null): Redirect
@@ -594,16 +624,20 @@ function redirect(?string $path = null): Redirect
 
 if (!function_exists('url')) {
     /**
-     * Build url
+     * Build an absolute URL from the current request base.
      *
-     * @param  string|array|null $url
-     * @param  array             $parameters
+     * Passing an array as the first argument is treated as the query string
+     * parameters (the path is then the current URL).
+     *
+     * @param  string|array $url        Path to append, or query parameters
+     * @param  array        $parameters Query string parameters
      * @return string
      */
     function url(string|array $url = '', array $parameters = []): string
     {
         $current = trim(request()->url(), '/');
 
+        // First argument given as parameters: keep the current path.
         if (is_array($url)) {
             $parameters = $url;
 
@@ -624,7 +658,7 @@ function url(string|array $url = '', array $parameters = []): string
 
 if (!function_exists('pdo')) {
     /**
-     * Get database PDO instance
+     * Get the underlying PDO instance.
      *
      * @return PDO
      */
@@ -636,10 +670,10 @@ function pdo(): PDO
 
 if (!function_exists('set_pdo')) {
     /**
-     * Set PDO instance
+     * Replace the underlying PDO instance.
      *
      * @param  PDO $pdo
-     * @return PDO
+     * @return PDO The newly set instance
      */
     function set_pdo(PDO $pdo): PDO
     {
@@ -650,9 +684,8 @@ function set_pdo(PDO $pdo): PDO
 }
 
 if (!function_exists('collect')) {
-
     /**
-     * Create new Collection instance
+     * Wrap an array in a Collection.
      *
      * @param  array $data
      * @return Collection
@@ -695,9 +728,11 @@ function decrypt(string $data): string|bool
     }
 }
 
+// ===== Database: transactions =====
+
 if (!function_exists('app_db_transaction')) {
     /**
-     * Start Database transaction
+     * Begin a database transaction.
      *
      * @return void
      */
@@ -709,7 +744,7 @@ function app_db_transaction(): void
 
 if (!function_exists('app_db_transaction_started')) {
     /**
-     * Check if database transaction
+     * Check whether a database transaction is currently open.
      *
      * @return bool
      */
@@ -721,7 +756,7 @@ function app_db_transaction_started(): bool
 
 if (!function_exists('app_db_rollback')) {
     /**
-     * Stop database transaction
+     * Roll back the current database transaction.
      *
      * @return void
      */
@@ -733,7 +768,7 @@ function app_db_rollback(): void
 
 if (!function_exists('app_db_commit')) {
     /**
-     * Commit request after transaction
+     * Commit the current database transaction.
      *
      * @return void
      */
@@ -745,8 +780,12 @@ function app_db_commit(): void
 
 if (!function_exists('event')) {
     /**
-     * Event
+     * Get the event dispatcher, or emit an event.
+     *
+     * Called with no arguments it returns the dispatcher; otherwise the first
+     * argument is the event name and the rest are passed to its listeners.
      *
+     * @param  mixed ...$args Event name followed by its payload
      * @return mixed
      */
     function event(): mixed
@@ -765,9 +804,11 @@ function event(): mixed
 
 if (!function_exists('app_event')) {
     /**
-     * Event
+     * Get the event dispatcher, or emit an event.
      *
+     * @param  mixed ...$args Event name followed by its payload
      * @return mixed
+     * @see    event() Identical behaviour; event() is the preferred name.
      */
     function app_event(): mixed
     {
@@ -785,10 +826,10 @@ function app_event(): mixed
 
 if (!function_exists('flash')) {
     /**
-     * Flash session
+     * Store a one-request flash message in the session.
      *
-     * @param  string $key
-     * @param  string $message
+     * @param  string $key     Flash key
+     * @param  string $message Message to store
      * @return mixed
      * @throws SessionException
      */
@@ -801,12 +842,13 @@ function flash(string $key, string $message): mixed
 
 if (!function_exists('app_flash')) {
     /**
-     * Flash session
+     * Store a one-request flash message in the session.
      *
-     * @param  string $key
-     * @param  string $message
+     * @param  string $key     Flash key
+     * @param  string $message Message to store
      * @return mixed
      * @throws SessionException
+     * @see    flash() Identical behaviour; flash() is the preferred name.
      */
     function app_flash(string $key, string $message): mixed
     {
@@ -817,11 +859,14 @@ function app_flash(string $key, string $message): mixed
 
 if (!function_exists('email')) {
     /**
-     * Send email
+     * Send an email, or get the mailer instance.
      *
-     * @param  null|string   $view
-     * @param  array         $data
-     * @param  callable|null $cb
+     * With no view the mailer instance is returned; otherwise the view is
+     * rendered and sent.
+     *
+     * @param  null|string   $view View name for the message body
+     * @param  array         $data Data bound to the view
+     * @param  callable|null $cb   Builder callback to configure the message
      * @return MailAdapterInterface|bool
      */
     function email(
@@ -839,12 +884,13 @@ function email(
 
 if (!function_exists('app_email')) {
     /**
-     * Send email
+     * Send an email, or get the mailer instance.
      *
-     * @param  null|string   $view
-     * @param  array         $data
-     * @param  callable|null $cb
+     * @param  null|string   $view View name for the message body
+     * @param  array         $data Data bound to the view
+     * @param  callable|null $cb   Builder callback to configure the message
      * @return MailAdapterInterface|bool
+     * @see    email() Identical behaviour; email() is the preferred name.
      */
     function app_email(
         ?string $view = null,
@@ -861,12 +907,12 @@ function app_email(
 
 if (!function_exists('raw_email')) {
     /**
-     * Send raw email
+     * Send a plain (non-templated) email.
      *
-     * @param  string $to
-     * @param  string $subject
-     * @param  string $message
-     * @param  array  $headers
+     * @param  string $to      Recipient address
+     * @param  string $subject Subject line
+     * @param  string $message Message body
+     * @param  array  $headers Extra mail headers
      * @return bool
      */
     function raw_email(string $to, string $subject, string $message, array $headers = []): bool
@@ -877,10 +923,10 @@ function raw_email(string $to, string $subject, string $message, array $headers
 
 if (!function_exists('session')) {
     /**
-     * Session help
+     * Get the session manager, or read a session value.
      *
-     * @param  array|string|null $value
-     * @param  mixed             $default
+     * @param  string|null $key     Key to read, or null for the manager
+     * @param  mixed       $default Value returned when the key is absent
      * @return mixed
      * @throws SessionException
      */
@@ -896,11 +942,14 @@ function session(?string $key = null, mixed $default = null): mixed
 
 if (!function_exists('cookie')) {
     /**
-     * Cooke alias
+     * Read or write cookies.
+     *
+     * No key returns all cookies; a key alone reads one; a key with data
+     * writes it.
      *
-     * @param  string|null $key
-     * @param  mixed       $data
-     * @param  int         $expiration
+     * @param  string|null $key        Cookie name
+     * @param  mixed       $data       Value to write, or null to read
+     * @param  int         $expiration Lifetime in seconds when writing
      * @return string|array|object|null
      */
     function cookie(
@@ -922,11 +971,11 @@ function cookie(
 
 if (!function_exists('validator')) {
     /**
-     * Validate the information on the well-defined criterion
+     * Validate input against a set of rules.
      *
-     * @param  array $inputs
-     * @param  array $rules
-     * @param  array $messages
+     * @param  array $inputs   Data to validate
+     * @param  array $rules    Validation rules keyed by field
+     * @param  array $messages Custom error messages
      * @return Validate
      */
     function validator(array $inputs, array $rules, array $messages = []): Validate
@@ -937,15 +986,21 @@ function validator(array $inputs, array $rules, array $messages = []): Validate
 
 if (!function_exists('route')) {
     /**
-     * Get Route by name
+     * Build a URL for a named route.
+     *
+     * Named placeholders in the route are filled from `$data`; leftover
+     * entries become the query string. Passing a bool as `$data` is treated
+     * as the `$absolute` flag.
      *
-     * @param  string     $name
-     * @param  bool|array $data
-     * @param  bool       $absolute
+     * @param  string     $name     Route name
+     * @param  bool|array $data     Placeholder values, or the absolute flag
+     * @param  bool       $absolute Prefix with APP_URL when true
      * @return string
+     * @throws InvalidArgumentException When the route or a placeholder is missing
      */
     function route(string $name, bool|array $data = [], bool $absolute = false): string
     {
+        // Allow route('name', true) to mean "absolute, no parameters".
         if (is_bool($data)) {
             $absolute = $data;
             $data = [];
@@ -960,6 +1015,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
             );
         }
 
+        // Substitute :placeholders (optional ones end with "?").
         if (preg_match_all('/:([a-zA-Z0-9_]+\??)/', $url, $matches)) {
             $keys = end($matches);
             foreach ($keys as $key) {
@@ -979,6 +1035,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
             }
         }
 
+        // Remaining data becomes the query string.
         if (count($data) > 0) {
             $url = $url . '?' . http_build_query($data);
         }
@@ -995,7 +1052,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
 
 if (!function_exists('e')) {
     /**
-     * Escape the HTML tags in the chain.
+     * Escape HTML special characters in a string.
      *
      * @param  ?string $value
      * @return string
@@ -1008,9 +1065,9 @@ function e(?string $value = null): string
 
 if (!function_exists('storage_service')) {
     /**
-     * Service loader
+     * Resolve a remote storage service (FTP, S3, ...).
      *
-     * @param  string $service
+     * @param  string $service Service name
      * @return FTPService|S3Service
      * @throws ServiceConfigurationNotFoundException
      * @throws ServiceNotFoundException
@@ -1023,9 +1080,9 @@ function storage_service(string $service): S3Service|FTPService
 
 if (!function_exists('app_storage')) {
     /**
-     * Alias on the mount method
+     * Get a local filesystem disk.
      *
-     * @param  string $disk
+     * @param  string $disk Disk name
      * @return DiskFilesystemService
      * @throws DiskNotFoundException
      */
@@ -1037,11 +1094,14 @@ function app_storage(string $disk): DiskFilesystemService
 
 if (!function_exists('cache')) {
     /**
-     * Cache help
+     * Get the cache instance, or read/write a cache entry.
      *
-     * @param  ?string $key
-     * @param  mixed  $value
-     * @param  ?int    $ttl
+     * No key returns the cache instance; a key alone reads it; a key with a
+     * value stores it for `$ttl` seconds.
+     *
+     * @param  ?string $key   Cache key
+     * @param  mixed   $value Value to store, or null to read
+     * @param  ?int    $ttl   Time-to-live in seconds when writing
      * @return mixed
      * @throws ErrorException
      */
@@ -1063,9 +1123,9 @@ function cache(?string $key = null, mixed $value = null, ?int $ttl = null): mixe
 
 if (!function_exists('redirect_back')) {
     /**
-     * Make redirection to back
+     * Redirect to the previous page.
      *
-     * @param  int $status
+     * @param  int $status HTTP status code
      * @return Redirect
      */
     function redirect_back(int $status = 302): Redirect
@@ -1076,7 +1136,7 @@ function redirect_back(int $status = 302): Redirect
 
 if (!function_exists('app_now')) {
     /**
-     * Get the current carbon
+     * Get the current time as a Carbon instance.
      *
      * @return Carbon
      */
@@ -1088,11 +1148,14 @@ function app_now(): Carbon
 
 if (!function_exists('app_hash')) {
     /**
-     * Alias on the class Hash.
+     * Hash a value, or verify one against an existing hash.
      *
-     * @param  string $data
-     * @param  mixed  $hash_value
-     * @return bool|string
+     * With `$hash_value` it checks the value against the hash; otherwise it
+     * returns a new hash.
+     *
+     * @param  string $data       Value to hash or verify
+     * @param  string|null $hash_value Existing hash to verify against
+     * @return bool|string Boolean when verifying, string when hashing
      */
     function app_hash(string $data, ?string $hash_value = null): bool|string
     {
@@ -1106,12 +1169,13 @@ function app_hash(string $data, ?string $hash_value = null): bool|string
 
 if (!function_exists('bow_hash')) {
     /**
-     * Alias on the class Hash.
+     * Hash a value, or verify one against an existing hash.
      *
-     * @param      string $data
-     * @param      mixed  $hash_value
+     * @param      string $data       Value to hash or verify
+     * @param      string|null $hash_value Existing hash to verify against
      * @return     bool|string
-     * @deprecated
+     * @deprecated Use app_hash() instead.
+     * @see        app_hash()
      */
     function bow_hash(string $data, ?string $hash_value = null): bool|string
     {
@@ -1121,11 +1185,14 @@ function bow_hash(string $data, ?string $hash_value = null): bool|string
 
 if (!function_exists('app_trans')) {
     /**
-     * Make translation
+     * Translate a key, or get the translator instance.
+     *
+     * No key returns the translator. Passing a bool as `$data` is treated as
+     * the `$choose` (pluralisation) flag.
      *
-     * @param  string|null $key
-     * @param  array       $data
-     * @param  bool        $choose
+     * @param  string|null $key    Translation key
+     * @param  array       $data   Replacement values
+     * @param  bool        $choose Pluralisation flag
      * @return string|Translator
      */
     function app_trans(
@@ -1148,12 +1215,13 @@ function app_trans(
 
 if (!function_exists('t')) {
     /**
-     * Alias of trans
+     * Translate a key.
      *
-     * @param  string $key
-     * @param  array  $data
-     * @param  bool   $choose
+     * @param  string $key    Translation key
+     * @param  array  $data   Replacement values
+     * @param  bool   $choose Pluralisation flag
      * @return string|Translator
+     * @see    app_trans()
      */
     function t(
         string $key,
@@ -1166,12 +1234,13 @@ function t(
 
 if (!function_exists('__')) {
     /**
-     * Alias of trans
+     * Translate a key.
      *
-     * @param  string $key
-     * @param  array  $data
-     * @param  bool   $choose
+     * @param  string $key    Translation key
+     * @param  array  $data   Replacement values
+     * @param  bool   $choose Pluralisation flag
      * @return string|Translator
+     * @see    app_trans()
      */
     function __(
         string $key,
@@ -1184,10 +1253,12 @@ function __(
 
 if (!function_exists('app_env')) {
     /**
-     * Gets the app environment variable
+     * Read an environment variable.
      *
-     * @param  string $key
-     * @param  mixed  $default
+     * Returns `$default` when the environment has not been loaded yet.
+     *
+     * @param  string $key     Variable name
+     * @param  mixed  $default Fallback value
      * @return ?string
      */
     function app_env(string $key, mixed $default = null): ?string
@@ -1208,9 +1279,9 @@ function app_env(string $key, mixed $default = null): ?string
 
 if (!function_exists('app_assets')) {
     /**
-     * Gets the app assets
+     * Build a public URL for an asset under the asset prefix.
      *
-     * @param  string $filename
+     * @param  string $filename Asset path relative to the asset root
      * @return string
      */
     function app_assets(string $filename): string
@@ -1221,12 +1292,14 @@ function app_assets(string $filename): string
 
 if (!function_exists('app_abort')) {
     /**
-     * Abort bow execution
+     * Abort the request with an HTTP error.
+     *
+     * Falls back to the standard status message when none is given.
      *
-     * @param  int    $code
-     * @param  string $message
+     * @param  int    $code    HTTP status code
+     * @param  string $message Error message
      * @return Response
-     * @throws HttpException
+     * @throws HttpException Always thrown to interrupt execution
      */
     function app_abort(int $code = 500, string $message = ''): Response
     {
@@ -1240,13 +1313,13 @@ function app_abort(int $code = 500, string $message = ''): Response
 
 if (!function_exists('app_abort_if')) {
     /**
-     * Abort bow execution if condition is true
+     * Abort the request only when the given condition is true.
      *
-     * @param  boolean $boolean
-     * @param  int     $code
-     * @param  string  $message
-     * @return Response|null
-     * @throws HttpException
+     * @param  bool   $boolean Condition that triggers the abort
+     * @param  int    $code    HTTP status code
+     * @param  string $message Error message
+     * @return Response|null Null when the condition is false
+     * @throws HttpException When the condition is true
      */
     function app_abort_if(
         bool $boolean,
@@ -1263,7 +1336,7 @@ function app_abort_if(
 
 if (!function_exists('app_mode')) {
     /**
-     * Get app environment mode
+     * Get the current application environment (lower-cased APP_ENV).
      *
      * @return string
      */
@@ -1275,7 +1348,7 @@ function app_mode(): string
 
 if (!function_exists('app_in_debug')) {
     /**
-     * Get app environment mode
+     * Determine whether debug mode (APP_DEBUG) is enabled.
      *
      * @return bool
      */
@@ -1287,7 +1360,7 @@ function app_in_debug(): bool
 
 if (!function_exists('client_locale')) {
     /**
-     * Get client request language
+     * Get the client's preferred request language.
      *
      * @return ?string
      */
@@ -1299,10 +1372,10 @@ function client_locale(): ?string
 
 if (!function_exists('old')) {
     /**
-     * Get old request value
+     * Get a value submitted on the previous request.
      *
-     * @param  string $key
-     * @param  mixed  $fullback
+     * @param  string $key      Input field name
+     * @param  mixed  $fullback Value returned when the field is absent
      * @return mixed
      */
     function old(string $key, mixed $fullback = null): mixed
@@ -1313,12 +1386,13 @@ function old(string $key, mixed $fullback = null): mixed
 
 if (!function_exists('auth')) {
     /**
-     * Recovery of the guard
+     * Get the auth manager, or a specific guard.
      *
-     * @param      string|null $guard
+     * @param      string|null $guard Guard name, or null for the manager
      * @return     GuardContract
      * @throws     AuthenticationException
-     * @deprecated
+     * @deprecated Use app_auth() instead.
+     * @see        app_auth()
      */
     function auth(?string $guard = null): GuardContract
     {
@@ -1334,9 +1408,9 @@ function auth(?string $guard = null): GuardContract
 
 if (!function_exists('app_auth')) {
     /**
-     * Recovery of the guard
+     * Get the auth manager, or a specific guard.
      *
-     * @param  string|null $guard
+     * @param  string|null $guard Guard name, or null for the manager
      * @return GuardContract
      * @throws AuthenticationException
      */
@@ -1354,7 +1428,7 @@ function app_auth(?string $guard = null): GuardContract
 
 if (!function_exists('logger')) {
     /**
-     * Log error message
+     * Get the application logger.
      *
      * @return Logger
      */
@@ -1366,9 +1440,10 @@ function logger(): Logger
 
 if (!function_exists('app_logger')) {
     /**
-     * Log error message
+     * Get the application logger.
      *
      * @return Logger
+     * @see    logger() Identical behaviour; logger() is the preferred name.
      */
     function app_logger(): Logger
     {
@@ -1379,10 +1454,10 @@ function app_logger(): Logger
 
 if (!function_exists('str_slug')) {
     /**
-     * Slugify
+     * Convert a string into a URL-friendly slug.
      *
-     * @param  string $str
-     * @param  string $sep
+     * @param  string $str String to slugify
+     * @param  string $sep Word separator
      * @return string
      */
     function str_slug(string $str, string $sep = '-'): string
@@ -1393,7 +1468,7 @@ function str_slug(string $str, string $sep = '-'): string
 
 if (!function_exists('str_is_mail')) {
     /**
-     * Check if the email is valid
+     * Check whether a string is a valid email address.
      *
      * @param  string $email
      * @return bool
@@ -1406,7 +1481,7 @@ function str_is_mail(string $email): bool
 
 if (!function_exists('str_uuid')) {
     /**
-     * Get str uuid
+     * Generate a UUID string.
      *
      * @return string
      */
@@ -1418,11 +1493,11 @@ function str_uuid(): string
 
 if (!function_exists('str_is_domain')) {
     /**
-     * Check if the string is domain
+     * Check whether a string is a valid domain name.
      *
      * @param  string $domain
      * @return bool
-     * @throws
+     * @throws Exception
      */
     function str_is_domain(string $domain): bool
     {
@@ -1432,7 +1507,7 @@ function str_is_domain(string $domain): bool
 
 if (!function_exists('str_is_slug')) {
     /**
-     * Check if string is slug
+     * Check whether a string is a valid slug.
      *
      * @param  string $slug
      * @return string
@@ -1445,11 +1520,11 @@ function str_is_slug(string $slug): string
 
 if (!function_exists('str_is_alpha')) {
     /**
-     * Check if the string is alpha
+     * Check whether a string contains only alphabetic characters.
      *
      * @param  string $string
      * @return bool
-     * @throws
+     * @throws Exception
      */
     function str_is_alpha(string $string): bool
     {
@@ -1459,7 +1534,7 @@ function str_is_alpha(string $string): bool
 
 if (!function_exists('str_is_lower')) {
     /**
-     * Check if the string is lower
+     * Check whether a string is entirely lower-case.
      *
      * @param  string $string
      * @return bool
@@ -1472,7 +1547,7 @@ function str_is_lower(string $string): bool
 
 if (!function_exists('str_is_upper')) {
     /**
-     * Check if the string is upper
+     * Check whether a string is entirely upper-case.
      *
      * @param  string $string
      * @return bool
@@ -1485,11 +1560,11 @@ function str_is_upper(string $string): bool
 
 if (!function_exists('str_is_alpha_num')) {
     /**
-     * Check if string is alphanumeric
+     * Check whether a string is alphanumeric.
      *
      * @param  string $slug
      * @return bool
-     * @throws
+     * @throws Exception
      */
     function str_is_alpha_num(string $slug): bool
     {
@@ -1499,7 +1574,7 @@ function str_is_alpha_num(string $slug): bool
 
 if (!function_exists('str_shuffle_words')) {
     /**
-     * Shuffle words
+     * Randomly shuffle the words of a string.
      *
      * @param  string $words
      * @return string
@@ -1512,10 +1587,10 @@ function str_shuffle_words(string $words): string
 
 if (!function_exists('str_wordily')) {
     /**
-     * Return the array contains the word of the passed string
+     * Split a string into an array of its words.
      *
-     * @param  string $words
-     * @param  string $sep
+     * @param  string $words String to split
+     * @param  string $sep   Separator to split on
      * @return array
      */
     function str_wordily(string $words, string $sep = ''): array
@@ -1526,7 +1601,7 @@ function str_wordily(string $words, string $sep = ''): array
 
 if (!function_exists('str_plural')) {
     /**
-     * Transform text to str_plural
+     * Pluralise a word.
      *
      * @param  string $slug
      * @return string
@@ -1539,7 +1614,7 @@ function str_plural(string $slug): string
 
 if (!function_exists('str_camel')) {
     /**
-     * Transform text to camel case
+     * Convert a string to camelCase.
      *
      * @param  string $slug
      * @return string
@@ -1552,7 +1627,7 @@ function str_camel(string $slug): string
 
 if (!function_exists('str_snake')) {
     /**
-     * Transform text to snake case
+     * Convert a string to snake_case.
      *
      * @param  string $slug
      * @return string
@@ -1565,10 +1640,10 @@ function str_snake(string $slug): string
 
 if (!function_exists('str_contains')) {
     /**
-     * Check if string contain another string
+     * Check whether a string contains another string.
      *
-     * @param  string $search
-     * @param  string $string
+     * @param  string $search Needle to look for
+     * @param  string $string Haystack to search in
      * @return bool
      */
     function str_contains(string $search, string $string): bool
@@ -1579,7 +1654,7 @@ function str_contains(string $search, string $string): bool
 
 if (!function_exists('str_capitalize')) {
     /**
-     * Capitalize
+     * Capitalise a string.
      *
      * @param  string $slug
      * @return string
@@ -1592,9 +1667,9 @@ function str_capitalize(string $slug): string
 
 if (!function_exists('str_random')) {
     /**
-     * Random string
+     * Generate a random string.
      *
-     * @param  string $string
+     * @param  string $string Length or seed forwarded to Str::random()
      * @return string
      */
     function str_random(string $string): string
@@ -1605,7 +1680,7 @@ function str_random(string $string): string
 
 if (!function_exists('str_force_in_utf8')) {
     /**
-     * Force output string to utf8
+     * Force string output to UTF-8 globally.
      *
      * @return void
      */
@@ -1617,7 +1692,7 @@ function str_force_in_utf8(): void
 
 if (!function_exists('str_fix_utf8')) {
     /**
-     * Force output string to utf8
+     * Repair a malformed UTF-8 string.
      *
      * @param  string $string
      * @return string
@@ -1630,15 +1705,20 @@ function str_fix_utf8(string $string): string
 
 if (!function_exists('app_db_seed')) {
     /**
-     * Make programmatic seeding
+     * Seed data programmatically.
      *
-     * @param  string $name
-     * @param  array  $data
-     * @return int|array
-     * @throws ErrorException
+     * When `$name` is a Model class, `$data` is inserted into its table.
+     * Otherwise `$name` is resolved to a seeder file whose returned map of
+     * table => rows is inserted (each row merged with `$data`).
+     *
+     * @param  string $name Model class name or seeder file name
+     * @param  array  $data Rows to insert, or values merged into each row
+     * @return int|array Affected rows, or one result per seeded table
+     * @throws ErrorException When the seeder file cannot be found
      */
     function app_db_seed(string $name, array $data = []): int|array
     {
+        // A model class: insert straight into its table.
         if (class_exists($name)) {
             $instance = app($name);
 
@@ -1658,6 +1738,7 @@ function app_db_seed(string $name, array $data = []): int|array
         $seeds = array_merge($seeds, []);
         $collections = [];
 
+        // Each entry maps a table (or model class) to its rows.
         foreach ($seeds as $table => $payload) {
             if (class_exists($table)) {
                 $instance = app($table);
@@ -1677,6 +1758,9 @@ function app_db_seed(string $name, array $data = []): int|array
     /**
      * Determine if the given value is "blank".
      *
+     * Null, an empty/whitespace string, and an empty Countable are blank;
+     * numbers and booleans never are.
+     *
      * @param  mixed $value
      * @return bool
      */
@@ -1704,9 +1788,10 @@ function is_blank(mixed $value): bool
 
 if (!function_exists("queue")) {
     /**
-     * Push the producer on queue
+     * Push a task onto the queue.
      *
-     * @param QueueTask $producer
+     * @param  QueueTask $producer Task to enqueue
+     * @return void
      */
     function queue(QueueTask $producer): void
     {

From 68d71483c26e469b6f728d8707f844d8e6f7bef5 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 15:42:11 +0000
Subject: [PATCH 45/60] Fix cookie session

---
 src/Session/Cookie.php       | 34 +++++++++----
 tests/Session/CookieTest.php | 96 ++++++++++++++++++++++++++++++++++++
 2 files changed, 121 insertions(+), 9 deletions(-)
 create mode 100644 tests/Session/CookieTest.php

diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php
index 9b6ba773..02af2452 100644
--- a/src/Session/Cookie.php
+++ b/src/Session/Cookie.php
@@ -123,14 +123,30 @@ public static function set(
     ): bool {
         $data = Crypto::encrypt(json_encode($data));
 
-        return setcookie(
-            $key,
-            $data,
-            time() + $expiration,
-            config('session.path'),
-            config('session.domain'),
-            config('session.secure'),
-            config('session.httponly')
-        );
+        return setcookie($key, $data, static::options($expiration));
+    }
+
+    /**
+     * Build the setcookie() options array from the session config.
+     *
+     * Every value is coerced to its declared type. config('session.domain') is
+     * null when SESSION_DOMAIN is unset; passing null straight to setcookie()
+     * is deprecated on PHP 8.x and a fatal TypeError on PHP 9, so cast here.
+     *
+     * @param  int $expiration
+     * @return array
+     */
+    private static function options(int $expiration): array
+    {
+        // config() with a second argument is a setter, not a getter-with-default,
+        // so read each value first and apply the fallback in PHP.
+        return [
+            'expires'  => time() + $expiration,
+            'path'     => (string) (config('session.path') ?? '/'),
+            'domain'   => (string) (config('session.domain') ?? ''),
+            'secure'   => (bool) config('session.secure'),
+            'httponly' => (bool) (config('session.httponly') ?? true),
+            'samesite' => (string) (config('session.samesite') ?? 'Lax'),
+        ];
     }
 }
diff --git a/tests/Session/CookieTest.php b/tests/Session/CookieTest.php
new file mode 100644
index 00000000..fefeea38
--- /dev/null
+++ b/tests/Session/CookieTest.php
@@ -0,0 +1,96 @@
+setAccessible(true);
+
+        return $method->invoke(null, $expiration);
+    }
+
+    /**
+     * The stub session config sets domain to null (SESSION_DOMAIN unset). It
+     * must reach setcookie() as a string, never null — that null is what would
+     * deprecate on PHP 8.x and fatally throw on PHP 9.
+     */
+    public function test_null_domain_is_coerced_to_empty_string()
+    {
+        $options = $this->options(3600);
+
+        $this->assertSame('', $options['domain']);
+        $this->assertIsString($options['domain']);
+    }
+
+    /**
+     * Every option must carry its declared scalar type so setcookie() never
+     * receives a null in a non-nullable slot.
+     */
+    public function test_options_are_strictly_typed()
+    {
+        $options = $this->options(3600);
+
+        $this->assertIsInt($options['expires']);
+        $this->assertIsString($options['path']);
+        $this->assertIsString($options['domain']);
+        $this->assertIsBool($options['secure']);
+        $this->assertIsBool($options['httponly']);
+        $this->assertIsString($options['samesite']);
+    }
+
+    /**
+     * Values come from the session config; SameSite falls back to Lax.
+     */
+    public function test_options_reflect_session_config()
+    {
+        $options = $this->options(3600);
+
+        $this->assertSame('/', $options['path']);
+        $this->assertFalse($options['secure']);
+        // The stub config explicitly sets httponly to false.
+        $this->assertFalse($options['httponly']);
+        $this->assertSame('Lax', $options['samesite']);
+    }
+
+    /**
+     * Cookie::remove() clears by delegating to set() with a negative lifetime.
+     * The clearing cookie must therefore carry the same path/domain/samesite
+     * attributes (browsers require a matching attribute set to overwrite), and
+     * its expiry must land in the past.
+     */
+    public function test_clearing_cookie_keeps_matching_attributes_and_past_expiry()
+    {
+        $live = $this->options(3600);
+        $clear = $this->options(-1000);
+
+        $this->assertLessThan($live['expires'], $clear['expires']);
+        $this->assertLessThan(time(), $clear['expires']);
+
+        $this->assertSame($live['path'], $clear['path']);
+        $this->assertSame($live['domain'], $clear['domain']);
+        $this->assertSame($live['samesite'], $clear['samesite']);
+        $this->assertSame($live['secure'], $clear['secure']);
+        $this->assertSame($live['httponly'], $clear['httponly']);
+    }
+}

From 91be32b2f732c66dbc388ed348214d24f2a5a54b Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:12:00 +0000
Subject: [PATCH 46/60] feat(auth): add remember-me token accessors to
 Authentication

---
 src/Auth/Authentication.php   | 39 +++++++++++++++
 tests/Auth/RememberMeTest.php | 90 +++++++++++++++++++++++++++++++++++
 2 files changed, 129 insertions(+)
 create mode 100644 tests/Auth/RememberMeTest.php

diff --git a/src/Auth/Authentication.php b/src/Auth/Authentication.php
index e16e9a0a..cd10dd01 100644
--- a/src/Auth/Authentication.php
+++ b/src/Auth/Authentication.php
@@ -18,6 +18,45 @@ public function getAuthenticateUserId(): mixed
         return $this->attributes[$this->primary_key];
     }
 
+    /**
+     * The name of the column holding the remember-me token.
+     *
+     * Override in the model if your column is named differently. The application
+     * must add this column to its user table: `remember_token VARCHAR(100) NULL`.
+     *
+     * @return string
+     */
+    public function getRememberTokenName(): string
+    {
+        return 'remember_token';
+    }
+
+    /**
+     * Read the current remember-me token.
+     *
+     * @return ?string
+     */
+    public function getRememberToken(): ?string
+    {
+        $value = $this->attributes[$this->getRememberTokenName()] ?? null;
+
+        return is_null($value) ? null : (string) $value;
+    }
+
+    /**
+     * Store a new remember-me token and persist it.
+     *
+     * @param  ?string $token
+     * @return void
+     */
+    public function setRememberToken(?string $token): void
+    {
+        $column = $this->getRememberTokenName();
+
+        $this->attributes[$column] = $token;
+        $this->update([$column => $token]);
+    }
+
     /**
      * Define the additional values
      *
diff --git a/tests/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php
new file mode 100644
index 00000000..258d1800
--- /dev/null
+++ b/tests/Auth/RememberMeTest.php
@@ -0,0 +1,90 @@
+insert([
+            'name' => 'Franck',
+            'password' => Hash::make('password'),
+            'username' => 'papac',
+        ]);
+    }
+
+    public static function tearDownAfterClass(): void
+    {
+        Database::statement("DROP TABLE IF EXISTS users");
+    }
+
+    protected function setUp(): void
+    {
+        ob_start();
+        $_COOKIE = [];
+        Database::table('users')->where('username', 'papac')->update(['remember_token' => null]);
+    }
+
+    protected function tearDown(): void
+    {
+        ob_get_clean();
+    }
+
+    public function test_remember_token_name_defaults_to_remember_token()
+    {
+        $user = UserModelStub::first();
+        $this->assertSame('remember_token', $user->getRememberTokenName());
+    }
+
+    public function test_set_and_get_remember_token_persists()
+    {
+        $user = UserModelStub::first();
+        $this->assertNull($user->getRememberToken());
+
+        $user->setRememberToken('abc123');
+
+        $this->assertSame('abc123', $user->getRememberToken());
+        $fresh = UserModelStub::first();
+        $this->assertSame('abc123', $fresh->getRememberToken());
+    }
+
+    public function test_remember_token_can_be_cleared_to_null()
+    {
+        $user = UserModelStub::first();
+        $user->setRememberToken('to-be-cleared');
+        $this->assertSame('to-be-cleared', UserModelStub::first()->getRememberToken());
+
+        $user->setRememberToken(null);
+
+        $this->assertNull($user->getRememberToken());
+        $this->assertNull(UserModelStub::first()->getRememberToken());
+    }
+}

From f43d835d9be519d2332c6af84e32799757b0f8b3 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:19:42 +0000
Subject: [PATCH 47/60] feat(auth): add getUserById helper for remember-me
 restore

---
 src/Auth/Traits/LoginUserTrait.php | 13 +++++++++++++
 tests/Auth/RememberMeTest.php      | 26 ++++++++++++++++++++++++++
 2 files changed, 39 insertions(+)

diff --git a/src/Auth/Traits/LoginUserTrait.php b/src/Auth/Traits/LoginUserTrait.php
index ba86b883..a3dc777a 100644
--- a/src/Auth/Traits/LoginUserTrait.php
+++ b/src/Auth/Traits/LoginUserTrait.php
@@ -55,4 +55,17 @@ private function getUserBy(string $key, float|int|string $value): ?Authenticatio
 
         return $model::where($key, $value)->first();
     }
+
+    /**
+     * Get a user by primary key value.
+     *
+     * @param  float|int|string $id
+     * @return ?Authentication
+     */
+    private function getUserById(float|int|string $id): ?Authentication
+    {
+        $model = $this->provider['model'];
+
+        return $this->getUserBy((new $model())->getKey(), $id);
+    }
 }
diff --git a/tests/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php
index 258d1800..8bcfc272 100644
--- a/tests/Auth/RememberMeTest.php
+++ b/tests/Auth/RememberMeTest.php
@@ -87,4 +87,30 @@ public function test_remember_token_can_be_cleared_to_null()
         $this->assertNull($user->getRememberToken());
         $this->assertNull(UserModelStub::first()->getRememberToken());
     }
+
+    public function test_get_user_by_id_returns_the_user()
+    {
+        $auth = Auth::guard('web');
+        $expected = UserModelStub::first();
+
+        $method = new \ReflectionMethod($auth, 'getUserById');
+        $method->setAccessible(true);
+        $user = $method->invoke($auth, $expected->getAuthenticateUserId());
+
+        $this->assertNotNull($user);
+        $this->assertSame(
+            $expected->getAuthenticateUserId(),
+            $user->getAuthenticateUserId()
+        );
+    }
+
+    public function test_get_user_by_id_returns_null_for_unknown_id()
+    {
+        $auth = Auth::guard('web');
+
+        $method = new \ReflectionMethod($auth, 'getUserById');
+        $method->setAccessible(true);
+
+        $this->assertNull($method->invoke($auth, 999999));
+    }
 }

From 2142c4599bcf15bd73d701eaf98e32283c7aa764 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:26:29 +0000
Subject: [PATCH 48/60] feat(auth): widen guard signatures with remember flag

Add optional bool $remember = false to attempts() and login() on
GuardContract, JwtGuard, and SessionGuard. JwtGuard accepts but ignores
the flag (stateless). SessionGuard signature-only update; behavior
comes in Task 4. Adds JWT guard test for the ignored remember flag.
Also configures Policier in RememberMeTest setup so JWT guard works.
---
 src/Auth/Guards/GuardContract.php |  8 +++++---
 src/Auth/Guards/JwtGuard.php      |  8 ++++++--
 src/Auth/Guards/SessionGuard.php  |  8 +++++---
 tests/Auth/RememberMeTest.php     | 16 ++++++++++++++++
 4 files changed, 32 insertions(+), 8 deletions(-)

diff --git a/src/Auth/Guards/GuardContract.php b/src/Auth/Guards/GuardContract.php
index b2fb846c..8fc61ecd 100644
--- a/src/Auth/Guards/GuardContract.php
+++ b/src/Auth/Guards/GuardContract.php
@@ -49,12 +49,13 @@ abstract public function guest(): bool;
     abstract public function logout(): bool;
 
     /**
-     * Logout
+     * Login
      *
      * @param  Authentication $user
+     * @param  bool           $remember
      * @return bool
      */
-    abstract public function login(Authentication $user): bool;
+    abstract public function login(Authentication $user, bool $remember = false): bool;
 
     /**
      * Get authenticated user
@@ -67,9 +68,10 @@ abstract public function user(): ?Authentication;
      * Check if user is authenticated
      *
      * @param  array $credentials
+     * @param  bool  $remember
      * @return bool
      */
-    abstract public function attempts(array $credentials): bool;
+    abstract public function attempts(array $credentials, bool $remember = false): bool;
 
     /**
      * Get the guard name
diff --git a/src/Auth/Guards/JwtGuard.php b/src/Auth/Guards/JwtGuard.php
index ce48bff8..38755586 100644
--- a/src/Auth/Guards/JwtGuard.php
+++ b/src/Auth/Guards/JwtGuard.php
@@ -51,12 +51,14 @@ public function __construct(array $provider, string $guard)
      * Check if user is authenticated
      *
      * @param  array $credentials
+     * @param  bool  $remember
      * @return bool
      * @throws AuthenticationException
      * @throws Exception
      */
-    public function attempts(array $credentials): bool
+    public function attempts(array $credentials, bool $remember = false): bool
     {
+        // $remember is ignored: JWT is stateless, remember-me is a session concept.
         $user = $this->makeLogin($credentials);
 
         $this->token = null;
@@ -143,11 +145,13 @@ private function getPolicier(): Policier
      * Make direct login
      *
      * @param  Authentication $user
+     * @param  bool           $remember
      * @return bool
      * @throws Exception
      */
-    public function login(Authentication $user): bool
+    public function login(Authentication $user, bool $remember = false): bool
     {
+        // $remember is ignored: JWT is stateless, remember-me is a session concept.
         $attributes = array_merge(
             $user->customJwtAttributes(),
             [
diff --git a/src/Auth/Guards/SessionGuard.php b/src/Auth/Guards/SessionGuard.php
index 0096072d..49f6d659 100644
--- a/src/Auth/Guards/SessionGuard.php
+++ b/src/Auth/Guards/SessionGuard.php
@@ -46,10 +46,11 @@ public function __construct(array $provider, string $guard)
      * Check if user is authenticated
      *
      * @param  array $credentials
+     * @param  bool  $remember
      * @return bool
      * @throws AuthenticationException|SessionException
      */
-    public function attempts(array $credentials): bool
+    public function attempts(array $credentials, bool $remember = false): bool
     {
         $user = $this->makeLogin($credentials);
 
@@ -112,11 +113,12 @@ public function guest(): bool
     /**
      * Make direct login
      *
-     * @param  mixed $user
+     * @param  Authentication $user
+     * @param  bool  $remember
      * @return bool
      * @throws AuthenticationException|SessionException
      */
-    public function login(Authentication $user): bool
+    public function login(Authentication $user, bool $remember = false): bool
     {
         $this->getSession()->add($this->session_key, $user);
 
diff --git a/tests/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php
index 8bcfc272..a2fc7682 100644
--- a/tests/Auth/RememberMeTest.php
+++ b/tests/Auth/RememberMeTest.php
@@ -9,6 +9,7 @@
 use Bow\Tests\Auth\Stubs\UserModelStub;
 use Bow\Tests\Config\TestingConfiguration;
 use PHPUnit\Framework\TestCase;
+use Policier\Policier;
 
 class RememberMeTest extends TestCase
 {
@@ -17,6 +18,7 @@ public static function setUpBeforeClass(): void
         $config = TestingConfiguration::getConfig();
 
         Auth::configure($config["auth"]);
+        Policier::configure($config["policier"]);
         Database::configure($config["database"]);
         Session::configure((array) $config["session"]);
 
@@ -113,4 +115,18 @@ public function test_get_user_by_id_returns_null_for_unknown_id()
 
         $this->assertNull($method->invoke($auth, 999999));
     }
+
+    public function test_jwt_guard_accepts_but_ignores_remember_flag()
+    {
+        $auth = Auth::guard('api');
+
+        $result = $auth->attempts([
+            'username' => 'papac',
+            'password' => 'password',
+        ], true);
+
+        $this->assertTrue($result);
+        // The JWT guard must not set any cookie when remember=true.
+        $this->assertEmpty($_COOKIE);
+    }
 }

From 888a4d0e7b13e2b7dc64c5a19fa98e48ffe2ecc8 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:37:05 +0000
Subject: [PATCH 49/60] fix(session): guard undefined remember-token flag in
 Cookie::remove

---
 src/Session/Cookie.php | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php
index 02af2452..71a44fd1 100644
--- a/src/Session/Cookie.php
+++ b/src/Session/Cookie.php
@@ -95,7 +95,7 @@ public static function remove(string $key): string|bool|null
             return null;
         }
 
-        if (!static::$is_decrypt[$key]) {
+        if (!(static::$is_decrypt[$key] ?? false)) {
             $old = Crypto::decrypt($_COOKIE[$key]);
 
             unset(static::$is_decrypt[$key]);

From 4121d48d3aa7032c3cfe4da7c8df0e577bc67db2 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:44:29 +0000
Subject: [PATCH 50/60] feat(auth): remember-me support in SessionGuard

---
 src/Auth/Guards/SessionGuard.php | 133 ++++++++++++++++++++++++++++++-
 tests/Auth/RememberMeTest.php    | 118 +++++++++++++++++++++++++++
 2 files changed, 250 insertions(+), 1 deletion(-)

diff --git a/src/Auth/Guards/SessionGuard.php b/src/Auth/Guards/SessionGuard.php
index 49f6d659..60b72a51 100644
--- a/src/Auth/Guards/SessionGuard.php
+++ b/src/Auth/Guards/SessionGuard.php
@@ -8,6 +8,7 @@
 use Bow\Auth\Exception\AuthenticationException;
 use Bow\Auth\Traits\LoginUserTrait;
 use Bow\Security\Hash;
+use Bow\Session\Cookie;
 use Bow\Session\Exception\SessionException;
 use Bow\Session\Session;
 
@@ -63,6 +64,11 @@ public function attempts(array $credentials, bool $remember = false): bool
 
         if (Hash::check($password, $user->{$fields['password']})) {
             $this->getSession()->put($this->session_key, $user);
+
+            if ($remember) {
+                $this->setRememberCookie($user);
+            }
+
             return true;
         }
 
@@ -77,7 +83,8 @@ public function attempts(array $credentials, bool $remember = false): bool
      */
     public function check(): bool
     {
-        return $this->getSession()->exists($this->session_key);
+        return $this->getSession()->exists($this->session_key)
+            || $this->attemptRememberLogin();
     }
 
     /**
@@ -122,6 +129,10 @@ public function login(Authentication $user, bool $remember = false): bool
     {
         $this->getSession()->add($this->session_key, $user);
 
+        if ($remember) {
+            $this->setRememberCookie($user);
+        }
+
         return true;
     }
 
@@ -133,6 +144,13 @@ public function login(Authentication $user, bool $remember = false): bool
      */
     public function logout(): bool
     {
+        $user = $this->getSession()->get($this->session_key);
+
+        if ($user instanceof Authentication) {
+            $user->setRememberToken($this->generateRememberToken());
+        }
+
+        $this->clearRememberCookie();
         $this->getSession()->remove($this->session_key);
 
         return true;
@@ -163,6 +181,119 @@ public function id(): mixed
      */
     public function user(): ?Authentication
     {
+        if (!$this->getSession()->exists($this->session_key)) {
+            $this->attemptRememberLogin();
+        }
+
         return $this->getSession()->get($this->session_key);
     }
+
+    /**
+     * Attempt to restore the session from a valid remember-me cookie.
+     *
+     * Never throws on malformed input: a bad cookie is simply cleared.
+     *
+     * @return bool
+     * @throws AuthenticationException|SessionException
+     */
+    private function attemptRememberLogin(): bool
+    {
+        $cookie = Cookie::get($this->rememberCookieName());
+
+        // Cookie::set() json-encodes its payload but Cookie::get() does not
+        // json-decode it, so recover the original "|" string here.
+        if (is_string($cookie)) {
+            $decoded = json_decode($cookie, true);
+
+            if (is_string($decoded)) {
+                $cookie = $decoded;
+            }
+        }
+
+        if (!is_string($cookie) || !str_contains($cookie, '|')) {
+            if (!is_null($cookie)) {
+                $this->clearRememberCookie();
+            }
+            return false;
+        }
+
+        [$id, $token] = explode('|', $cookie, 2);
+
+        $user = $this->getUserById($id);
+
+        if (is_null($user)) {
+            $this->clearRememberCookie();
+            return false;
+        }
+
+        $stored = $user->getRememberToken();
+
+        if (is_null($stored) || !hash_equals($stored, $token)) {
+            $this->clearRememberCookie();
+            return false;
+        }
+
+        $this->getSession()->put($this->session_key, $user);
+
+        return true;
+    }
+
+    /**
+     * Generate a fresh remember token and persist it on the user, then
+     * write the encrypted remember cookie.
+     *
+     * @param  Authentication $user
+     * @return void
+     */
+    private function setRememberCookie(Authentication $user): void
+    {
+        $token = $this->generateRememberToken();
+        $user->setRememberToken($token);
+
+        Cookie::set(
+            $this->rememberCookieName(),
+            $user->getAuthenticateUserId() . '|' . $token,
+            $this->rememberLifetime()
+        );
+    }
+
+    /**
+     * Remove the remember cookie.
+     *
+     * @return void
+     */
+    private function clearRememberCookie(): void
+    {
+        Cookie::remove($this->rememberCookieName());
+    }
+
+    /**
+     * Get the remember cookie name for this guard.
+     *
+     * @return string
+     */
+    private function rememberCookieName(): string
+    {
+        return 'remember_' . $this->guard;
+    }
+
+    /**
+     * Generate a cryptographically strong remember token.
+     *
+     * @return string
+     */
+    private function generateRememberToken(): string
+    {
+        return bin2hex(random_bytes(30));
+    }
+
+    /**
+     * Get the configured remember-me cookie lifetime in seconds.
+     *
+     * @return int
+     */
+    private function rememberLifetime(): int
+    {
+        return (int) (config('auth.remember_lifetime') ?? 2592000);
+    }
 }
diff --git a/tests/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php
index a2fc7682..eb0e296b 100644
--- a/tests/Auth/RememberMeTest.php
+++ b/tests/Auth/RememberMeTest.php
@@ -4,6 +4,7 @@
 
 use Bow\Auth\Auth;
 use Bow\Database\Database;
+use Bow\Security\Crypto;
 use Bow\Security\Hash;
 use Bow\Session\Session;
 use Bow\Tests\Auth\Stubs\UserModelStub;
@@ -11,12 +12,23 @@
 use PHPUnit\Framework\TestCase;
 use Policier\Policier;
 
+/**
+ * Bow's native session relies on session_set_save_handler()/session_start(),
+ * which both fail once PHPUnit's default runner has emitted output
+ * (headers_sent() === true). Running the whole class in a dedicated process
+ * lets the session boot cleanly before any output is produced.
+ *
+ * @runTestsInSeparateProcesses
+ * @preserveGlobalState disabled
+ */
 class RememberMeTest extends TestCase
 {
     public static function setUpBeforeClass(): void
     {
         $config = TestingConfiguration::getConfig();
 
+        Crypto::setKey($config['security']['key'], $config['security']['cipher']);
+
         Auth::configure($config["auth"]);
         Policier::configure($config["policier"]);
         Database::configure($config["database"]);
@@ -129,4 +141,110 @@ public function test_jwt_guard_accepts_but_ignores_remember_flag()
         // The JWT guard must not set any cookie when remember=true.
         $this->assertEmpty($_COOKIE);
     }
+
+    private function rememberCookieValue(int $id, string $token): string
+    {
+        // Mirror Cookie::set()'s encoding (json_encode then Crypto::encrypt) so
+        // tests exercise the same payload shape attemptRememberLogin() will read.
+        return Crypto::encrypt(json_encode($id . '|' . $token));
+    }
+
+    public function test_attempts_with_remember_persists_token()
+    {
+        $auth = Auth::guard('web');
+
+        $result = $auth->attempts([
+            'username' => 'papac',
+            'password' => 'password',
+        ], true);
+
+        $this->assertTrue($result);
+        $this->assertNotNull(UserModelStub::first()->getRememberToken());
+    }
+
+    public function test_attempts_without_remember_leaves_token_null()
+    {
+        $auth = Auth::guard('web');
+
+        $auth->attempts([
+            'username' => 'papac',
+            'password' => 'password',
+        ], false);
+
+        $this->assertNull(UserModelStub::first()->getRememberToken());
+    }
+
+    public function test_check_restores_session_from_valid_remember_cookie()
+    {
+        $auth = Auth::guard('web');
+        $user = UserModelStub::first();
+        $user->setRememberToken('valid-token-123');
+
+        Session::getInstance()->remove('_auth_web');
+        $_COOKIE['remember_web'] = $this->rememberCookieValue(
+            (int) $user->getAuthenticateUserId(),
+            'valid-token-123'
+        );
+
+        $this->assertTrue($auth->check());
+        $this->assertSame('papac', $auth->user()->username);
+    }
+
+    public function test_check_rejects_tampered_token_and_clears_cookie()
+    {
+        $auth = Auth::guard('web');
+        $user = UserModelStub::first();
+        $user->setRememberToken('the-real-token');
+
+        Session::getInstance()->remove('_auth_web');
+        $_COOKIE['remember_web'] = $this->rememberCookieValue(
+            (int) $user->getAuthenticateUserId(),
+            'WRONG-token'
+        );
+
+        $this->assertFalse($auth->check());
+        $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+    }
+
+    public function test_check_rejects_unknown_user_and_clears_cookie()
+    {
+        $auth = Auth::guard('web');
+
+        Session::getInstance()->remove('_auth_web');
+        $_COOKIE['remember_web'] = $this->rememberCookieValue(999999, 'whatever');
+
+        $this->assertFalse($auth->check());
+        $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+    }
+
+    public function test_check_rejects_malformed_cookie_without_delimiter()
+    {
+        $auth = Auth::guard('web');
+
+        Session::getInstance()->remove('_auth_web');
+        // A well-encrypted cookie whose payload has no "|" delimiter.
+        $_COOKIE['remember_web'] = Crypto::encrypt(json_encode('garbage-no-pipe'));
+
+        $this->assertFalse($auth->check());
+        $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+    }
+
+    public function test_logout_regenerates_token_and_removes_cookie()
+    {
+        $auth = Auth::guard('web');
+        $user = UserModelStub::first();
+        $user->setRememberToken('token-before');
+
+        Session::getInstance()->remove('_auth_web');
+        $_COOKIE['remember_web'] = $this->rememberCookieValue(
+            (int) $user->getAuthenticateUserId(),
+            'token-before'
+        );
+        $this->assertTrue($auth->check());
+
+        $this->assertTrue($auth->logout());
+
+        $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+        $this->assertNotSame('token-before', UserModelStub::first()->getRememberToken());
+    }
 }

From 4a362041bd6a75aa2f9b31ec23cc7f35570ead28 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 18:56:24 +0000
Subject: [PATCH 51/60] docs(auth): document remember-me usage and required
 column

---
 src/Auth/README.md | 61 ++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 61 insertions(+)

diff --git a/src/Auth/README.md b/src/Auth/README.md
index 0d2a38f0..ae36254b 100644
--- a/src/Auth/README.md
+++ b/src/Auth/README.md
@@ -17,3 +17,64 @@ $user = $auth->user();
 ```
 
 Enjoy!
+
+## Remember me
+
+The session guard supports a persistent "remember me" cookie so users stay authenticated across browser sessions.
+
+### Required migration
+
+The framework cannot modify your application's database, so you must add a nullable token column to your user table:
+
+```sql
+ALTER TABLE users ADD COLUMN remember_token VARCHAR(100) NULL;
+```
+
+The default column name is `remember_token`. Override it on your user model if needed:
+
+```php
+public function getRememberTokenName(): string
+{
+    return 'my_token_column';
+}
+```
+
+### Usage
+
+Pass `true` as the second argument to `attempts()` or `login()`:
+
+```php
+// Authenticate with credentials and remember the user
+Auth::attempts(['username' => $username, 'password' => $password], true);
+
+// Or, for an already-resolved user instance
+Auth::login($user, true);
+```
+
+### Automatic session restore
+
+When the session has expired, the next call to `Auth::check()` or `Auth::user()` transparently restores the session from the encrypted `remember_` cookie (e.g. `remember_web`). No changes are needed in `AuthMiddleware`.
+
+### Logout
+
+`Auth::logout()` regenerates the user's token (invalidating any outstanding remember cookie) and clears the cookie:
+
+```php
+Auth::logout();
+```
+
+Because all devices share the same `remember_token` column, logging out on one device invalidates remember-me for that user everywhere.
+
+### Configuration
+
+The cookie lifetime is read from `config('auth.remember_lifetime')` in seconds and defaults to 30 days. Add the key to your `config/auth.php` to override it:
+
+```php
+'remember_lifetime' => 2592000, // 30 days (default)
+```
+
+### Scope and security notes
+
+- Remember-me applies to the **session guard only**; the JWT guard ignores the flag.
+- The token is high-entropy (`bin2hex(random_bytes(30))`) and compared timing-safely with `hash_equals`; the cookie is encrypted.
+- A single shared `remember_token` column means logging out invalidates remember-me for that user on all devices.

From 68651717aa74af09219a909e902645c724be0024 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 19:53:25 +0000
Subject: [PATCH 52/60] Refactorig cookie

---
 src/Auth/Guards/SessionGuard.php | 10 ----------
 src/Session/Cookie.php           | 16 +++++++++++++++-
 2 files changed, 15 insertions(+), 11 deletions(-)

diff --git a/src/Auth/Guards/SessionGuard.php b/src/Auth/Guards/SessionGuard.php
index 60b72a51..29b357f6 100644
--- a/src/Auth/Guards/SessionGuard.php
+++ b/src/Auth/Guards/SessionGuard.php
@@ -200,16 +200,6 @@ private function attemptRememberLogin(): bool
     {
         $cookie = Cookie::get($this->rememberCookieName());
 
-        // Cookie::set() json-encodes its payload but Cookie::get() does not
-        // json-decode it, so recover the original "|" string here.
-        if (is_string($cookie)) {
-            $decoded = json_decode($cookie, true);
-
-            if (is_string($decoded)) {
-                $cookie = $decoded;
-            }
-        }
-
         if (!is_string($cookie) || !str_contains($cookie, '|')) {
             if (!is_null($cookie)) {
                 $this->clearRememberCookie();
diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php
index 71a44fd1..ad46ead7 100644
--- a/src/Session/Cookie.php
+++ b/src/Session/Cookie.php
@@ -35,7 +35,21 @@ public static function isEmpty(): bool
     public static function get(string $key, mixed $default = null): mixed
     {
         if (static::has($key)) {
-            return Crypto::decrypt($_COOKIE[$key]);
+            $value = Crypto::decrypt($_COOKIE[$key]);
+
+            // Cookie::set() json-encodes the payload before encrypting, so decode
+            // here to mirror it (and Cookie::all()). Fall back to the raw value
+            // when it is not the JSON we wrote — e.g. a tampered cookie decrypts
+            // to false, or a cookie was set outside the framework.
+            if (is_string($value)) {
+                $decoded = json_decode($value, true);
+
+                if (json_last_error() === JSON_ERROR_NONE) {
+                    return $decoded;
+                }
+            }
+
+            return $value;
         }
 
         if (is_callable($default)) {

From 8c76b7a5298001707b5d4d4f2629512419847cbf Mon Sep 17 00:00:00 2001
From: papac 
Date: Sun, 7 Jun 2026 20:18:52 +0000
Subject: [PATCH 53/60] Update CHANGELOG

---
 CHANGELOG.md | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index dbc8a34b..2e576215 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.30 - 2026-06-07
+
+### What's Changed
+
+* Implementing the remember token by @papac in https://github.com/bowphp/framework/pull/409
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/410
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.21...5.3.30
+
 ## 5.3.21 - 2026-06-04
 
 ### What's Changed
@@ -310,6 +319,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 
 ```
 Ref: #255

From 33e4a5d9dfe9c8a54d6f03737c452bb249f5099a Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Sun, 7 Jun 2026 21:47:36 +0000
Subject: [PATCH 54/60] feat(database): implement query execution performance
 metric mesurement

---
 src/Database/Database.php     |  9 ++++++---
 src/Database/QueryBuilder.php | 17 ++++++++++++-----
 src/Database/QueryEvent.php   | 16 +++++++++++++---
 3 files changed, 31 insertions(+), 11 deletions(-)

diff --git a/src/Database/Database.php b/src/Database/Database.php
index 4421036d..0bcdc1f7 100644
--- a/src/Database/Database.php
+++ b/src/Database/Database.php
@@ -191,9 +191,11 @@ private static function executePrepareQuery(string $sql_statement, array $data =
             Sanitize::make($data, true)
         );
 
+        $start_at = microtime(true);
         $pdo_statement->execute();
+        $ended_at = microtime(true);
 
-        static::triggerQueryEvent($sql_statement, $data);
+        static::triggerQueryEvent($sql_statement, $ended_at - $start_at, $data);
 
         return $pdo_statement->rowCount();
     }
@@ -490,12 +492,13 @@ public static function setPdo(PDO $pdo): void
      * Trigger the query executed event
      *
      * @param  string $sql
+     * @param  float $execution_time
      * @param  array  $bindings
      * @return void
      */
-    public static function triggerQueryEvent(string $sql, array $bindings = []): void
+    public static function triggerQueryEvent(string $sql, float $execution_time = 0, array $bindings = []): void
     {
-        $event = new QueryEvent($sql, $bindings);
+        $event = new QueryEvent($sql, $execution_time, $bindings);
 
         app_event($event);
     }
diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php
index dddd8c73..24ee5a54 100644
--- a/src/Database/QueryBuilder.php
+++ b/src/Database/QueryBuilder.php
@@ -1417,9 +1417,11 @@ public function truncate(): bool
 
         $this->last_query = $sql;
 
+        $start_at = microtime(true);
         $result = (bool) $this->connection->exec($sql);
+        $ended_at = microtime(true);
 
-        $this->triggerQueryEvent($sql, []);
+        $this->triggerQueryEvent($sql, $ended_at - $start_at);
 
         $this->last_query = $sql;
 
@@ -1523,9 +1525,11 @@ private function execute(string $sql, array $bindings = []): PDOStatement
         $this->bind($statement, $bindings);
 
         try {
+            $start_at = microtime(true);
             $statement->execute();
+            $ended_at = microtime(true);
 
-            $this->triggerQueryEvent($sql, $bindings);
+            $this->triggerQueryEvent($sql, $ended_at - $start_at, $bindings);
         } catch (\Exception $e) {
             throw new QueryBuilderException(
                 'Error executing query: ' . $e->getMessage() . ' | Query: ' . $this->last_query,
@@ -1548,9 +1552,11 @@ public function drop(): bool
 
         $this->last_query = $sql;
 
+        $start_at = microtime(true);
         $result = (bool) $this->connection->exec($sql);
+        $ended_at = microtime(true);
 
-        $this->triggerQueryEvent($sql, []);
+        $this->triggerQueryEvent($sql, $ended_at - $start_at);
 
         return $result;
     }
@@ -1668,12 +1674,13 @@ public function setWhereDataBinding(array $data_binding): void
      * Trigger the query event
      *
      * @param  string $sql
+     * @param  float $execution_time
      * @param  array  $bindings
      * @return void
      */
-    private function triggerQueryEvent(string $sql, array $bindings): void
+    private function triggerQueryEvent(string $sql, float $execution_time = 0, array $bindings = []): void
     {
-        Database::triggerQueryEvent($sql, $bindings);
+        Database::triggerQueryEvent($sql, $execution_time, $bindings);
     }
 
     /**
diff --git a/src/Database/QueryEvent.php b/src/Database/QueryEvent.php
index 0edbae8c..d0e4dbf4 100644
--- a/src/Database/QueryEvent.php
+++ b/src/Database/QueryEvent.php
@@ -18,6 +18,13 @@ final class QueryEvent implements AppEvent
      */
     public string $sql;
 
+    /**
+     * Define the query execution time
+     *
+     * @var mixed
+     */
+    public float $execution_time;
+
     /**
      * The query bindings
      *
@@ -28,11 +35,14 @@ final class QueryEvent implements AppEvent
     /**
      * QueryEvent constructor.
      *
-     * @param array $data
+     * @param string $sql
+     * @param float $execution_time
+     * @param array $bindings
      */
-    public function __construct(string $sql, array $bindings = [])
+    public function __construct(string $sql, float $execution_time = 0, array $bindings = [])
     {
         $this->sql = $sql;
+        $this->execution_time = $execution_time;
         $this->bindings = $bindings;
     }
 
@@ -51,7 +61,7 @@ public function getName(): string
      * @param  mixed  $value
      * @throws \Exception
      */
-    public function __set($name, $value)
+    final public function __set($name, $value)
     {
         throw new \Exception("Cannot set property $name on QueryEvent");
     }

From cf8fa1619064508c32e82a1925026dba53d10b6d Mon Sep 17 00:00:00 2001
From: papac 
Date: Sun, 7 Jun 2026 21:50:23 +0000
Subject: [PATCH 55/60] Update CHANGELOG

---
 CHANGELOG.md | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e576215..9edea07b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.31 - 2026-06-07
+
+### What's Changed
+
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/411
+* feat(database): implement query execution performance metric mesurement by @papac in https://github.com/bowphp/framework/pull/412
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.30...5.3.31
+
 ## 5.3.30 - 2026-06-07
 
 ### What's Changed
@@ -319,6 +328,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 
 
 ```

From aab2ae94d03ea6f933093395897d64b14bedf3eb Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Wed, 10 Jun 2026 04:27:32 +0000
Subject: [PATCH 56/60] feat(database): Add write/read database configuration

---
 .../Connection/AbstractConnection.php         | 152 ++++++++++++++--
 .../Connection/Adapters/MysqlAdapter.php      |  46 +++--
 .../Connection/Adapters/PostgreSQLAdapter.php |  76 ++++----
 .../Connection/Adapters/SqliteAdapter.php     |  42 ++---
 src/Database/Database.php                     |  70 +++++---
 src/Database/QueryBuilder.php                 |  79 ++++++--
 .../Query/ReadWriteConnectionTest.php         | 168 ++++++++++++++++++
 7 files changed, 502 insertions(+), 131 deletions(-)
 create mode 100644 tests/Database/Query/ReadWriteConnectionTest.php

diff --git a/src/Database/Connection/AbstractConnection.php b/src/Database/Connection/AbstractConnection.php
index 5b3757aa..4f5984d5 100644
--- a/src/Database/Connection/AbstractConnection.php
+++ b/src/Database/Connection/AbstractConnection.php
@@ -31,37 +31,157 @@ abstract class AbstractConnection
     protected int $fetch = PDO::FETCH_OBJ;
 
     /**
-     * The PDO instance
+     * The write (primary) PDO instance
      *
-     * @var PDO
+     * @var ?PDO
      */
-    protected PDO $pdo;
+    protected ?PDO $write_pdo = null;
 
     /**
-     * Create an instance of the PDO
+     * The read (replica) PDO instance
+     *
+     * @var ?PDO
+     */
+    protected ?PDO $read_pdo = null;
+
+    /**
+     * The configuration used to build the write connection
+     *
+     * @var array
+     */
+    protected array $write_config = [];
+
+    /**
+     * The configuration used to build the read connection,
+     * or null when the connection is not split (reads use write).
+     *
+     * @var ?array
+     */
+    protected ?array $read_config = null;
+
+    /**
+     * AbstractConnection constructor.
+     *
+     * Splits the connection configuration into a write (primary)
+     * configuration and an optional read (replica) configuration.
+     *
+     * @param array $config
+     */
+    public function __construct(array $config)
+    {
+        $this->config = $config;
+
+        $this->write_config = $config;
+        unset($this->write_config['read']);
+
+        if (isset($config['read']) && is_array($config['read'])) {
+            $this->read_config = array_merge($this->write_config, $config['read']);
+        } else {
+            $this->read_config = null;
+        }
+
+        // Validate eagerly so misconfiguration fails fast, while the
+        // connection itself is still established lazily on first use.
+        $this->validateConfig($this->write_config);
+
+        if ($this->read_config !== null) {
+            $this->validateConfig($this->read_config);
+        }
+    }
+
+    /**
+     * Validate the connection configuration.
+     *
+     * @param  array $config
+     * @return void
+     */
+    abstract protected function validateConfig(array $config): void;
+
+    /**
+     * Build a PDO instance from the given configuration.
+     *
+     * @param  array $config
+     * @return PDO
+     */
+    abstract protected function makePdo(array $config): PDO;
+
+    /**
+     * Build (eagerly) the write connection.
+     *
+     * Kept for backward compatibility with callers that expect to
+     * (re)establish the connection explicitly.
      *
      * @return void
      */
-    abstract public function connection(): void;
+    public function connection(): void
+    {
+        $this->write_pdo = $this->makePdo($this->write_config);
+    }
 
     /**
-     * Retrieves the connection
+     * Retrieves the connection (the write/primary connection)
      *
      * @return PDO
      */
     public function getConnection(): PDO
     {
-        return $this->pdo;
+        return $this->getWriteConnection();
+    }
+
+    /**
+     * Retrieves the write (primary) connection, building it lazily
+     *
+     * @return PDO
+     */
+    public function getWriteConnection(): PDO
+    {
+        if ($this->write_pdo === null) {
+            $this->write_pdo = $this->makePdo($this->write_config);
+        }
+
+        return $this->write_pdo;
     }
 
     /**
-     * Set the connection
+     * Retrieves the read (replica) connection, building it lazily.
+     *
+     * Falls back to the write connection when the connection is not split.
+     *
+     * @return PDO
+     */
+    public function getReadConnection(): PDO
+    {
+        if ($this->read_config === null) {
+            return $this->getWriteConnection();
+        }
+
+        if ($this->read_pdo === null) {
+            $this->read_pdo = $this->makePdo($this->read_config);
+        }
+
+        return $this->read_pdo;
+    }
+
+    /**
+     * Whether the write connection has already been established.
+     *
+     * Used to inspect transaction state without forcing a connection open.
+     *
+     * @return bool
+     */
+    public function hasWriteConnection(): bool
+    {
+        return $this->write_pdo instanceof PDO;
+    }
+
+    /**
+     * Set the connection (the write/primary connection)
      *
      * @param PDO $pdo
      */
     public function setConnection(PDO $pdo): void
     {
-        $this->pdo = $pdo;
+        $this->write_pdo = $pdo;
     }
 
     /**
@@ -84,10 +204,14 @@ public function setFetchMode(int $fetch): void
     {
         $this->fetch = $fetch;
 
-        $this->pdo->setAttribute(
-            PDO::ATTR_DEFAULT_FETCH_MODE,
-            $fetch
-        );
+        foreach ([$this->write_pdo, $this->read_pdo] as $pdo) {
+            if ($pdo instanceof PDO) {
+                $pdo->setAttribute(
+                    PDO::ATTR_DEFAULT_FETCH_MODE,
+                    $fetch
+                );
+            }
+        }
     }
 
     /**
@@ -137,7 +261,7 @@ public function getCollation(): string
      */
     public function getPdoDriver(): string
     {
-        return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+        return $this->getConnection()->getAttribute(PDO::ATTR_DRIVER_NAME);
     }
 
     /**
diff --git a/src/Database/Connection/Adapters/MysqlAdapter.php b/src/Database/Connection/Adapters/MysqlAdapter.php
index 2fb2cb10..b8bb4910 100644
--- a/src/Database/Connection/Adapters/MysqlAdapter.php
+++ b/src/Database/Connection/Adapters/MysqlAdapter.php
@@ -25,53 +25,51 @@ class MysqlAdapter extends AbstractConnection
     protected ?string $name = 'mysql';
 
     /**
-     * MysqlAdapter constructor.
+     * Validate the connection configuration.
      *
-     * @param array $config
+     * @param  array $config
+     * @return void
      */
-    public function __construct(array $config)
+    protected function validateConfig(array $config): void
     {
-        $this->config = $config;
-
-        $this->connection();
+        // Check the existence of database definition
+        if (!isset($config['database'])) {
+            throw new InvalidArgumentException("The database is not defined");
+        }
     }
 
     /**
-     * Make connexion
+     * Build a PDO instance from the given configuration.
      *
-     * @return void
+     * @param  array $config
+     * @return PDO
      */
-    public function connection(): void
+    protected function makePdo(array $config): PDO
     {
         // Build of the mysql dsn
-        if (isset($this->config['socket']) && !empty($this->config['socket'])) {
-            $hostname = $this->config['socket'];
+        if (isset($config['socket']) && !empty($config['socket'])) {
+            $hostname = $config['socket'];
             $port = '';
         } else {
-            $hostname = $this->config['hostname'] ?? null;
-            $port = (string)($this->config['port'] ?? self::PORT);
-        }
-
-        // Check the existence of database definition
-        if (!isset($this->config['database'])) {
-            throw new InvalidArgumentException("The database is not defined");
+            $hostname = $config['hostname'] ?? null;
+            $port = (string)($config['port'] ?? self::PORT);
         }
 
         // Formatting connection parameters
-        $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']);
+        $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']);
 
-        $username = $this->config["username"];
-        $password = $this->config["password"];
+        $username = $config["username"];
+        $password = $config["password"];
 
         // Configuration the PDO attributes that we want to set
         $options = [
-            PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch,
+            PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch,
             PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
-            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($this->config["charset"]),
+            PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($config["charset"]),
             PDO::ATTR_ORACLE_NULLS => PDO::NULL_EMPTY_STRING
         ];
 
         // Build the PDO connection
-        $this->pdo = new PDO($dsn, $username, $password, $options);
+        return new PDO($dsn, $username, $password, $options);
     }
 }
diff --git a/src/Database/Connection/Adapters/PostgreSQLAdapter.php b/src/Database/Connection/Adapters/PostgreSQLAdapter.php
index fe5ec75a..a6535c1a 100644
--- a/src/Database/Connection/Adapters/PostgreSQLAdapter.php
+++ b/src/Database/Connection/Adapters/PostgreSQLAdapter.php
@@ -24,79 +24,79 @@ class PostgreSQLAdapter extends AbstractConnection
     protected ?string $name = 'pgsql';
 
     /**
-     * MysqlAdapter constructor.
+     * Validate the connection configuration.
      *
-     * @param array $config
+     * @param  array $config
+     * @return void
      */
-    public function __construct(array $config)
+    protected function validateConfig(array $config): void
     {
-        $this->config = $config;
-
-        $this->connection();
+        // Check the existence of database definition
+        if (!isset($config['database'])) {
+            throw new InvalidArgumentException("The database is not defined");
+        }
     }
 
     /**
-     * Make connexion
+     * Build a PDO instance from the given configuration.
      *
-     * @return void
+     * @param  array $config
+     * @return PDO
      */
-    public function connection(): void
+    protected function makePdo(array $config): PDO
     {
-        // Build of the mysql dsn
-        if (isset($this->config['socket']) && !is_null($this->config['socket']) && !empty($this->config['socket'])) {
-            $hostname = $this->config['socket'];
+        // Build of the pgsql dsn
+        if (isset($config['socket']) && !is_null($config['socket']) && !empty($config['socket'])) {
+            $hostname = $config['socket'];
             $port = '';
         } else {
-            $hostname = $this->config['hostname'] ?? null;
-            $port = (string)($this->config['port'] ?? self::PORT);
-        }
-
-        // Check the existence of database definition
-        if (!isset($this->config['database'])) {
-            throw new InvalidArgumentException("The database is not defined");
+            $hostname = $config['hostname'] ?? null;
+            $port = (string)($config['port'] ?? self::PORT);
         }
 
         // Formatting connection parameters
-        $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']);
+        $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']);
 
-        if (isset($this->config['sslmode'])) {
-            $dsn .= ';sslmode=' . $this->config['sslmode'];
+        if (isset($config['sslmode'])) {
+            $dsn .= ';sslmode=' . $config['sslmode'];
         }
 
-        if (isset($this->config['sslrootcert'])) {
-            $dsn .= ';sslrootcert=' . $this->config['sslrootcert'];
+        if (isset($config['sslrootcert'])) {
+            $dsn .= ';sslrootcert=' . $config['sslrootcert'];
         }
 
-        if (isset($this->config['sslcert'])) {
-            $dsn .= ';sslcert=' . $this->config['sslcert'];
+        if (isset($config['sslcert'])) {
+            $dsn .= ';sslcert=' . $config['sslcert'];
         }
 
-        if (isset($this->config['sslkey'])) {
-            $dsn .= ';sslkey=' . $this->config['sslkey'];
+        if (isset($config['sslkey'])) {
+            $dsn .= ';sslkey=' . $config['sslkey'];
         }
 
-        if (isset($this->config['sslcrl'])) {
-            $dsn .= ';sslcrl=' . $this->config['sslcrl'];
+        if (isset($config['sslcrl'])) {
+            $dsn .= ';sslcrl=' . $config['sslcrl'];
         }
 
-        if (isset($this->config['application_name'])) {
-            $dsn .= ';application_name=' . $this->config['application_name'];
+        if (isset($config['application_name'])) {
+            $dsn .= ';application_name=' . $config['application_name'];
         }
 
-        $username = $this->config["username"];
-        $password = $this->config["password"];
+        $username = $config["username"];
+        $password = $config["password"];
 
         // Configuration the PDO attributes that we want to set
         $options = [
-            PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch,
+            PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch,
             PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
         ];
 
         // Build the PDO connection
-        $this->pdo = new PDO($dsn, $username, $password, $options);
+        $pdo = new PDO($dsn, $username, $password, $options);
 
-        if ($this->config["charset"]) {
-            $this->pdo->query('SET NAMES \'' . $this->config["charset"] . '\'');
+        if ($config["charset"]) {
+            $pdo->query('SET NAMES \'' . $config["charset"] . '\'');
         }
+
+        return $pdo;
     }
 }
diff --git a/src/Database/Connection/Adapters/SqliteAdapter.php b/src/Database/Connection/Adapters/SqliteAdapter.php
index b3997d70..3dbb5f47 100644
--- a/src/Database/Connection/Adapters/SqliteAdapter.php
+++ b/src/Database/Connection/Adapters/SqliteAdapter.php
@@ -18,38 +18,40 @@ class SqliteAdapter extends AbstractConnection
     protected ?string $name = 'sqlite';
 
     /**
-     * SqliteAdapter constructor.
+     * Validate the connection configuration.
      *
-     * @param array $config
+     * @param  array $config
+     * @return void
      */
-    public function __construct(array $config)
+    protected function validateConfig(array $config): void
     {
-        $this->config = $config;
-
-        $this->connection();
-    }
-
-    /**
-     * @inheritDoc
-     */
-    public function connection(): void
-    {
-        if (!isset($this->config['driver'])) {
+        if (!isset($config['driver'])) {
             throw new InvalidArgumentException("Please select the right sqlite driver");
         }
 
-        if (!isset($this->config['database'])) {
+        if (!isset($config['database'])) {
             throw new InvalidArgumentException('The database is not defined');
         }
+    }
 
+    /**
+     * Build a PDO instance from the given configuration.
+     *
+     * @param  array $config
+     * @return PDO
+     */
+    protected function makePdo(array $config): PDO
+    {
         // Build the PDO connection
-        $this->pdo = new PDO('sqlite:' . $this->config['database']);
+        $pdo = new PDO('sqlite:' . $config['database']);
 
-        $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
-        $this->pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING);
-        $this->pdo->setAttribute(
+        $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+        $pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING);
+        $pdo->setAttribute(
             PDO::ATTR_DEFAULT_FETCH_MODE,
-            $this->config['fetch'] ?? $this->fetch
+            $config['fetch'] ?? $this->fetch
         );
+
+        return $pdo;
     }
 }
diff --git a/src/Database/Database.php b/src/Database/Database.php
index 0bcdc1f7..d68d9c1f 100644
--- a/src/Database/Database.php
+++ b/src/Database/Database.php
@@ -102,11 +102,41 @@ public static function connection(?string $name = null): ?Database
             static::$adapter->setFetchMode(static::$config['fetch']);
         }
 
-        if (static::$adapter->getConnection() instanceof PDO && $name == static::$name) {
-            return static::getInstance();
+        return static::getInstance();
+    }
+
+    /**
+     * Resolve the write (primary) connection.
+     *
+     * @return PDO
+     */
+    private static function writeConnection(): PDO
+    {
+        static::ensureDatabaseConnection();
+
+        return static::$adapter->getWriteConnection();
+    }
+
+    /**
+     * Resolve the read (replica) connection.
+     *
+     * While a transaction is open on the primary, reads are routed to the
+     * primary so they observe their own uncommitted changes.
+     *
+     * @return PDO
+     */
+    private static function readConnection(): PDO
+    {
+        static::ensureDatabaseConnection();
+
+        if (
+            static::$adapter->hasWriteConnection()
+            && static::$adapter->getWriteConnection()->inTransaction()
+        ) {
+            return static::$adapter->getWriteConnection();
         }
 
-        return static::getInstance();
+        return static::$adapter->getReadConnection();
     }
 
     /**
@@ -182,8 +212,7 @@ public static function update(string $sql_statement, array $data = []): int
      */
     private static function executePrepareQuery(string $sql_statement, array $data = []): int
     {
-        $pdo_statement = static::$adapter
-            ->getConnection()
+        $pdo_statement = static::writeConnection()
             ->prepare($sql_statement);
 
         static::$adapter->bind(
@@ -218,8 +247,7 @@ public static function select(string $sql_statement, array $data = []): mixed
             );
         }
 
-        $pdo_statement = static::$adapter
-            ->getConnection()
+        $pdo_statement = static::readConnection()
             ->prepare($sql_statement);
 
         static::$adapter->bind(
@@ -251,8 +279,7 @@ public static function selectOne(string $sql_statement, array $data = []): mixed
         }
 
         // Prepare query
-        $pdo_statement = static::$adapter
-            ->getConnection()
+        $pdo_statement = static::readConnection()
             ->prepare($sql_statement);
 
         // Bind data
@@ -283,7 +310,7 @@ public static function insert(string $sql_statement, array $data = []): int
         }
 
         if (empty($data)) {
-            $pdo_statement = static::$adapter->getConnection()->prepare($sql_statement);
+            $pdo_statement = static::writeConnection()->prepare($sql_statement);
 
             $pdo_statement->execute();
 
@@ -320,8 +347,7 @@ public static function statement(string $sql_statement): bool
 
         $sql_statement = trim($sql_statement);
 
-        return static::$adapter
-            ->getConnection()
+        return static::writeConnection()
             ->exec($sql_statement) === 0;
     }
 
@@ -360,7 +386,7 @@ public static function table(string $table): QueryBuilder
 
         return new QueryBuilder(
             $table,
-            static::$adapter->getConnection()
+            static::$adapter
         );
     }
 
@@ -396,8 +422,8 @@ public static function startTransaction(): void
     {
         static::ensureDatabaseConnection();
 
-        if (!static::$adapter->getConnection()->inTransaction()) {
-            static::$adapter->getConnection()->beginTransaction();
+        if (!static::writeConnection()->inTransaction()) {
+            static::writeConnection()->beginTransaction();
         }
     }
 
@@ -410,7 +436,7 @@ public static function inTransaction(): bool
     {
         static::ensureDatabaseConnection();
 
-        return static::$adapter->getConnection()->inTransaction();
+        return static::writeConnection()->inTransaction();
     }
 
     /**
@@ -427,7 +453,7 @@ public static function commit(): void
     public static function commitTransaction(): void
     {
         if (static::inTransaction()) {
-            static::$adapter->getConnection()->commit();
+            static::writeConnection()->commit();
         }
     }
 
@@ -445,7 +471,7 @@ public static function rollback(): void
     public static function rollbackTransaction(): void
     {
         if (static::inTransaction()) {
-            static::$adapter->getConnection()->rollBack();
+            static::writeConnection()->rollBack();
         }
     }
 
@@ -460,10 +486,10 @@ public static function lastInsertId(?string $name = null): int|string|PDO
         static::ensureDatabaseConnection();
 
         if ($name === null) {
-            return static::$adapter->getConnection();
+            return static::writeConnection();
         }
 
-        return static::$adapter->getConnection()->lastInsertId($name);
+        return static::writeConnection()->lastInsertId($name);
     }
 
     /**
@@ -473,9 +499,7 @@ public static function lastInsertId(?string $name = null): int|string|PDO
      */
     public static function getPdo(): PDO
     {
-        static::ensureDatabaseConnection();
-
-        return static::$adapter->getConnection();
+        return static::writeConnection();
     }
 
     /**
diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php
index 24ee5a54..5394652c 100644
--- a/src/Database/QueryBuilder.php
+++ b/src/Database/QueryBuilder.php
@@ -85,12 +85,23 @@ class QueryBuilder implements JsonSerializable
     protected ?string $as = null;
 
     /**
-     * The PDO instance
+     * The PDO instance.
+     *
+     * Only set when the builder is constructed from a raw PDO (no read/write
+     * splitting). When built from an adapter, the connection is resolved
+     * lazily through {@see QueryBuilder::$connection_adapter}.
      *
      * @var ?PDO
      */
     protected ?PDO $connection = null;
 
+    /**
+     * The connection adapter, when read/write splitting is available.
+     *
+     * @var ?AbstractConnection
+     */
+    protected ?AbstractConnection $connection_adapter = null;
+
     /**
      * Define whether to retrieve information from the list
      *
@@ -142,16 +153,55 @@ class QueryBuilder implements JsonSerializable
     public function __construct(string $table, AbstractConnection|PDO $connection)
     {
         if ($connection instanceof AbstractConnection) {
+            $this->connection_adapter = $connection;
             $this->adapter = $connection->getName();
-            $connection = $connection->getConnection();
         } else {
+            $this->connection = $connection;
             $this->adapter = $connection->getAttribute(PDO::ATTR_DRIVER_NAME);
         }
 
-        $this->connection = $connection;
         $this->table = $table;
     }
 
+    /**
+     * Resolve the write (primary) connection.
+     *
+     * @return PDO
+     */
+    private function writeConnection(): PDO
+    {
+        if ($this->connection_adapter !== null) {
+            return $this->connection_adapter->getWriteConnection();
+        }
+
+        return $this->connection;
+    }
+
+    /**
+     * Resolve the read (replica) connection.
+     *
+     * While a transaction is open on the primary, reads are routed to the
+     * primary so they observe their own uncommitted changes. Falls back to
+     * the single connection when read/write splitting is unavailable.
+     *
+     * @return PDO
+     */
+    private function readConnection(): PDO
+    {
+        if ($this->connection_adapter === null) {
+            return $this->connection;
+        }
+
+        if (
+            $this->connection_adapter->hasWriteConnection()
+            && $this->connection_adapter->getWriteConnection()->inTransaction()
+        ) {
+            return $this->connection_adapter->getWriteConnection();
+        }
+
+        return $this->connection_adapter->getReadConnection();
+    }
+
     /**
      * Get the connection adapter name
      *
@@ -169,7 +219,7 @@ public function getAdapterName(): string
      */
     public function getPdo(): PDO
     {
-        return $this->connection;
+        return $this->writeConnection();
     }
 
     /**
@@ -930,7 +980,7 @@ private function aggregate($aggregate, $column): mixed
             }
         }
 
-        $statement = $this->execute($sql, $this->where_data_binding);
+        $statement = $this->execute($sql, $this->where_data_binding, false);
 
         $this->where_data_binding = [];
 
@@ -1174,7 +1224,7 @@ public function get(array $columns = []): array|object|null
         // Execution of request.
         $sql = $this->toSql();
 
-        $statement = $this->execute($sql, $this->where_data_binding);
+        $statement = $this->execute($sql, $this->where_data_binding, false);
 
         $data = $statement->fetchAll();
 
@@ -1406,9 +1456,11 @@ public function distinct(string $column)
      */
     public function truncate(): bool
     {
-        if ($this->connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
+        $connection = $this->writeConnection();
+
+        if ($connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
             $sql = 'delete from ' . $this->table . ';';
-            if (!$this->connection->inTransaction()) {
+            if (!$connection->inTransaction()) {
                 $sql .= ' VACUUM;';
             }
         } else {
@@ -1438,7 +1490,7 @@ public function insertAndGetLastId(array $values): string|int|bool
     {
         $this->insert($values);
 
-        $result = $this->connection->lastInsertId();
+        $result = $this->writeConnection()->lastInsertId();
 
         return is_numeric($result) ? (int)$result : $result;
     }
@@ -1514,13 +1566,16 @@ private function insertOne(array $values): int
      *
      * @param string $sql
      * @param array $bindings
+     * @param bool $write Whether the statement mutates data (routes to the primary).
      * @return PDOStatement
      */
-    private function execute(string $sql, array $bindings = []): PDOStatement
+    private function execute(string $sql, array $bindings = [], bool $write = true): PDOStatement
     {
         $this->last_query = $sql;
 
-        $statement = $this->connection->prepare($sql);
+        $connection = $write ? $this->writeConnection() : $this->readConnection();
+
+        $statement = $connection->prepare($sql);
 
         $this->bind($statement, $bindings);
 
@@ -1645,7 +1700,7 @@ public function exists(?string $column = null, mixed $value = null): bool
      */
     public function getLastInsertId(?string $name = null)
     {
-        return $this->connection->lastInsertId($name);
+        return $this->writeConnection()->lastInsertId($name);
     }
 
     /**
diff --git a/tests/Database/Query/ReadWriteConnectionTest.php b/tests/Database/Query/ReadWriteConnectionTest.php
new file mode 100644
index 00000000..7f828279
--- /dev/null
+++ b/tests/Database/Query/ReadWriteConnectionTest.php
@@ -0,0 +1,168 @@
+ 'sqlite',
+            'database' => self::$write_db,
+            'read' => [
+                'database' => self::$read_db,
+            ],
+        ]);
+
+        $this->seed($adapter->getWriteConnection(), 'primary');
+        $this->seed($adapter->getReadConnection(), 'replica');
+
+        return $adapter;
+    }
+
+    private function seed(PDO $pdo, string $marker): void
+    {
+        $pdo->exec('DROP TABLE IF EXISTS pets');
+        $pdo->exec('CREATE TABLE pets (id INTEGER PRIMARY KEY, name VARCHAR(255))');
+        $pdo->exec("INSERT INTO pets (id, name) VALUES (1, '" . $marker . "')");
+    }
+
+    public function test_split_config_builds_distinct_connections(): void
+    {
+        $adapter = $this->makeSplitAdapter();
+
+        $this->assertNotSame(
+            $adapter->getWriteConnection(),
+            $adapter->getReadConnection(),
+            'A split connection must expose two distinct PDO instances.'
+        );
+    }
+
+    public function test_connections_are_opened_lazily(): void
+    {
+        $adapter = new SqliteAdapter([
+            'driver' => 'sqlite',
+            'database' => self::$write_db,
+            'read' => ['database' => self::$read_db],
+        ]);
+
+        $this->assertFalse(
+            $adapter->hasWriteConnection(),
+            'No PDO should be opened until the connection is first used.'
+        );
+
+        // Touching only the read side must not open the write connection.
+        $adapter->getReadConnection();
+        $this->assertFalse(
+            $adapter->hasWriteConnection(),
+            'A read-only access must not open the primary connection.'
+        );
+
+        $adapter->getWriteConnection();
+        $this->assertTrue($adapter->hasWriteConnection());
+    }
+
+    public function test_read_falls_back_to_write_without_read_config(): void
+    {
+        $adapter = new SqliteAdapter([
+            'driver' => 'sqlite',
+            'database' => self::$write_db,
+        ]);
+
+        $this->assertSame(
+            $adapter->getWriteConnection(),
+            $adapter->getReadConnection(),
+            'Without a read block, reads must reuse the write connection.'
+        );
+    }
+
+    public function test_select_routes_to_read_replica(): void
+    {
+        $adapter = $this->makeSplitAdapter();
+        $builder = new QueryBuilder('pets', $adapter);
+
+        $row = $builder->where('id', 1)->first();
+
+        $this->assertSame('replica', $row->name);
+    }
+
+    public function test_write_routes_to_primary(): void
+    {
+        $adapter = $this->makeSplitAdapter();
+
+        (new QueryBuilder('pets', $adapter))
+            ->where('id', 1)
+            ->update(['name' => 'updated']);
+
+        // The write landed on the primary file...
+        $primary = $adapter->getWriteConnection()
+            ->query('SELECT name FROM pets WHERE id = 1')
+            ->fetchColumn();
+        $this->assertSame('updated', $primary);
+
+        // ...and the replica file is untouched.
+        $replica = $adapter->getReadConnection()
+            ->query('SELECT name FROM pets WHERE id = 1')
+            ->fetchColumn();
+        $this->assertSame('replica', $replica);
+    }
+
+    public function test_reads_route_to_primary_during_transaction(): void
+    {
+        $adapter = $this->makeSplitAdapter();
+
+        // Open a transaction on the primary; reads must now stick to it.
+        $adapter->getWriteConnection()->beginTransaction();
+
+        try {
+            $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first();
+
+            $this->assertSame(
+                'primary',
+                $row->name,
+                'While a transaction is open, reads must hit the primary.'
+            );
+        } finally {
+            $adapter->getWriteConnection()->rollBack();
+        }
+
+        // After the transaction closes, reads resume on the replica.
+        $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first();
+        $this->assertSame('replica', $row->name);
+    }
+}

From cadfb85669a4b11a9b53530df0d81f720db68eb2 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Wed, 10 Jun 2026 15:25:37 +0000
Subject: [PATCH 57/60] fix(tests): add database unity tests

---
 docker-compose.yml                    | 15 +++++++++++++++
 src/Application/Application.php       | 18 ++++++++++++++++--
 src/Database/QueryBuilder.php         |  4 ++--
 src/Router/Router.php                 |  1 -
 tests/Config/stubs/config/storage.php | 13 +++++++------
 5 files changed, 40 insertions(+), 11 deletions(-)

diff --git a/docker-compose.yml b/docker-compose.yml
index baffd928..c7113c82 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -162,6 +162,21 @@ services:
             interval: 10s
             timeout: 5s
             retries: 5
+    minio-init:
+        container_name: bowphp_minio_init
+        image: minio/mc
+        depends_on:
+            minio:
+                condition: service_healthy
+        entrypoint: >
+            /bin/sh -c "
+            mc alias set local http://minio:9000 minioadmin minioadmin;
+            mc mb --ignore-existing local/tests;
+            mc anonymous set public local/tests;
+            exit 0;
+            "
+        networks:
+            - bowphp_network
     zookeeper:
         container_name: bowphp_zookeeper
         image: confluentinc/cp-zookeeper:7.5.0
diff --git a/src/Application/Application.php b/src/Application/Application.php
index 153f76e8..a221efa6 100644
--- a/src/Application/Application.php
+++ b/src/Application/Application.php
@@ -178,7 +178,7 @@ public function run(): bool
 
         // Error management
         if ($resolved) {
-            $this->send($response);
+            $this->sendResponse($response);
             return true;
         }
 
@@ -192,6 +192,20 @@ public function run(): bool
         return false;
     }
 
+    /**
+     * Launch the application and send the answer to the customer.
+     *
+     * Public alias of {@see Application::run()}.
+     *
+     * @return bool
+     * @throws ReflectionException
+     * @throws RouterException
+     */
+    public function send(): bool
+    {
+        return $this->run();
+    }
+
     /**
      * Send the answer to the customer
      *
@@ -199,7 +213,7 @@ public function run(): bool
      * @param  int   $code
      * @return void
      */
-    private function send(mixed $response, int $code = 200): void
+    private function sendResponse(mixed $response, int $code = 200): void
     {
         if ($response instanceof ResponseInterface) {
             $response->sendContent();
diff --git a/src/Database/QueryBuilder.php b/src/Database/QueryBuilder.php
index 5394652c..347e685d 100644
--- a/src/Database/QueryBuilder.php
+++ b/src/Database/QueryBuilder.php
@@ -1470,7 +1470,7 @@ public function truncate(): bool
         $this->last_query = $sql;
 
         $start_at = microtime(true);
-        $result = (bool) $this->connection->exec($sql);
+        $result = (bool) $connection->exec($sql);
         $ended_at = microtime(true);
 
         $this->triggerQueryEvent($sql, $ended_at - $start_at);
@@ -1608,7 +1608,7 @@ public function drop(): bool
         $this->last_query = $sql;
 
         $start_at = microtime(true);
-        $result = (bool) $this->connection->exec($sql);
+        $result = (bool) $this->writeConnection()->exec($sql);
         $ended_at = microtime(true);
 
         $this->triggerQueryEvent($sql, $ended_at - $start_at);
diff --git a/src/Router/Router.php b/src/Router/Router.php
index 0610cfd5..2d6edc2b 100644
--- a/src/Router/Router.php
+++ b/src/Router/Router.php
@@ -350,7 +350,6 @@ public function middleware(array|string $middlewares): Router
      * @param  string                $path
      * @param  callable|string|array $cb
      * @return Route
-     * @throws
      */
     public function any(string $path, callable|string|array $cb): Route
     {
diff --git a/tests/Config/stubs/config/storage.php b/tests/Config/stubs/config/storage.php
index 8108745a..f2008e5c 100644
--- a/tests/Config/stubs/config/storage.php
+++ b/tests/Config/stubs/config/storage.php
@@ -39,14 +39,15 @@
         's3' => [
             "driver" => "s3",
             'credentials' => [
-                'key' => getenv('AWS_KEY'),
-                'secret' => getenv('AWS_SECRET'),
+                'key' => app_env('AWS_KEY', 'minioadmin'),
+                'secret' => app_env('AWS_SECRET', 'minioadmin'),
             ],
-            'bucket' => getenv('AWS_S3_BUCKET', 'tests'),
-            'region' => getenv('AWS_REGION', 'us-east-1'),
+            'bucket' => app_env('AWS_S3_BUCKET', 'tests'),
+            'region' => app_env('AWS_REGION', 'us-east-1'),
             'version' => 'latest',
-            // MinIO configuration (optional)
-            'endpoint' => getenv('AWS_ENDPOINT', false), // e.g., 'http://localhost:9000' for MinIO
+            // MinIO configuration. Defaults target the local docker-compose
+            // MinIO service; override via env for a real AWS S3 endpoint.
+            'endpoint' => app_env('AWS_ENDPOINT', 'http://127.0.0.1:9000'),
             'use_path_style_endpoint' => true, // Set to true for MinIO
         ]
     ],

From 6c99f3466a24b04134b30cf5d0e592b94b876a38 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Wed, 10 Jun 2026 16:38:39 +0000
Subject: [PATCH 58/60] fix(tests): fix storage configuration

---
 .github/workflows/tests.yml                     |  5 +++++
 tests/Config/stubs/config/storage.php           | 12 +++++++-----
 tests/Routing/AttributeRouteIntegrationTest.php |  8 +++++++-
 3 files changed, 19 insertions(+), 6 deletions(-)

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index abd5e193..a97f03dd 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -59,6 +59,11 @@ jobs:
             sleep 2
           done
 
+      - name: Wait for MinIO bucket initialization
+        run: |
+          echo "Waiting for MinIO bucket initialization..."
+          docker wait bowphp_minio_init || true
+
       - name: Cache Composer packages
         id: composer-cache
         uses: actions/cache@v4
diff --git a/tests/Config/stubs/config/storage.php b/tests/Config/stubs/config/storage.php
index f2008e5c..e82d3836 100644
--- a/tests/Config/stubs/config/storage.php
+++ b/tests/Config/stubs/config/storage.php
@@ -39,15 +39,17 @@
         's3' => [
             "driver" => "s3",
             'credentials' => [
-                'key' => app_env('AWS_KEY', 'minioadmin'),
-                'secret' => app_env('AWS_SECRET', 'minioadmin'),
+                // `?:` so an unset env (which CI exposes as an empty string)
+                // falls back to the local docker-compose MinIO defaults.
+                'key' => app_env('AWS_KEY') ?: 'minioadmin',
+                'secret' => app_env('AWS_SECRET') ?: 'minioadmin',
             ],
-            'bucket' => app_env('AWS_S3_BUCKET', 'tests'),
-            'region' => app_env('AWS_REGION', 'us-east-1'),
+            'bucket' => app_env('AWS_S3_BUCKET') ?: 'tests',
+            'region' => app_env('AWS_REGION') ?: 'us-east-1',
             'version' => 'latest',
             // MinIO configuration. Defaults target the local docker-compose
             // MinIO service; override via env for a real AWS S3 endpoint.
-            'endpoint' => app_env('AWS_ENDPOINT', 'http://127.0.0.1:9000'),
+            'endpoint' => app_env('AWS_ENDPOINT') ?: 'http://127.0.0.1:9000',
             'use_path_style_endpoint' => true, // Set to true for MinIO
         ]
     ],
diff --git a/tests/Routing/AttributeRouteIntegrationTest.php b/tests/Routing/AttributeRouteIntegrationTest.php
index 62f07453..c81ce178 100644
--- a/tests/Routing/AttributeRouteIntegrationTest.php
+++ b/tests/Routing/AttributeRouteIntegrationTest.php
@@ -41,8 +41,14 @@ protected function setUp(): void
     {
         parent::setUp();
 
-        // Reset the router for each test
         $this->router = Router::configure();
+
+        // The route collection is a global (static) registry that intentionally
+        // gathers routes across every Router instance. Clear it before each test
+        // so assertions are not polluted by routes registered by earlier tests.
+        $routes = new \ReflectionProperty(Router::class, 'routes');
+        $routes->setAccessible(true);
+        $routes->setValue(null, []);
     }
 
     public function test_registrar_registers_routes_from_controller(): void

From aef9a81518c0db182ef5dc83231ece93223aa989 Mon Sep 17 00:00:00 2001
From: Franck DAKIA 
Date: Wed, 10 Jun 2026 16:55:02 +0000
Subject: [PATCH 59/60] chore(ci): Remove envs

---
 .github/workflows/tests.yml | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index a97f03dd..7834613a 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -3,12 +3,7 @@ name: bowphp
 on: [ push, pull_request ]
 
 env:
-  AWS_KEY: ${{ secrets.AWS_KEY }}
-  AWS_SECRET: ${{ secrets.AWS_SECRET }}
-  AWS_ENDPOINT: ${{ secrets.AWS_ENDPOINT }}
   SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
-  AWS_REGION: ${{ secrets.AWS_REGION }}
-  AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
 
 jobs:
   lunix-tests:

From 8059d7d27f477f34dfa40e1c33ccb8339ddf2da4 Mon Sep 17 00:00:00 2001
From: papac 
Date: Wed, 10 Jun 2026 16:59:12 +0000
Subject: [PATCH 60/60] Update CHANGELOG

---
 CHANGELOG.md | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9edea07b..8fcaba53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## 5.3.40 - 2026-06-10
+
+### What's Changed
+
+* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/413
+* feat(database): Add write/read database configuration by @papac in https://github.com/bowphp/framework/pull/414
+* chore(ci): Remove envs by @papac in https://github.com/bowphp/framework/pull/415
+
+**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.31...5.3.40
+
 ## 5.3.31 - 2026-06-07
 
 ### What's Changed
@@ -329,6 +339,7 @@ Database::transaction(fn() => $user->update(['name' => '']));
 
 
 
+
 
 
 ```