diff --git a/.gitignore b/.gitignore index 6b93f39c..b394cb87 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ composer.lock .phpunit.result.cache bob .phpunit.cache -.vscode/ +.vscode +.idea diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afdda0a..e12b2755 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,102 @@ 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 + +* 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 +270,16 @@ Database::transaction(fn() => $user->update(['name' => ''])); + + + + + + + + + + ``` Ref: #255 diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..fc5345a7 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,263 @@ +# BowPHP Framework Roadmap + +> Living document based on source code analysis (5.x branch) and the project manifesto. +> Last updated: May 2026 + +--- + +## Current Framework State + +### Existing Modules (`/src` Analysis) + +| 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 commands, generators, 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 | Validation rules, custom messages | +| **View** | ✅ Stable | Tintin (default), Twig support | + +### Current Dependencies + +**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) + +**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) + +--- + +## ✅ Recently Delivered (Spring 2026) + +Highlights from the latest iterations — already merged into `5.x`. Full details are available in the CHANGELOG. + +### Routing + +* 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 + +* `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 + +* 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). + +### Testing Infrastructure + +* `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 (vendored) + +* 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
+
+* 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 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
+
+| 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 to 6 Months (New Features)
+
+### Queue - Redis Adapter
+
+| 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 - PHP 8 Attributes ✅ Delivered
+
+| 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 - Memcached Adapter
+
+| Task                                      | Status    | Priority | Notes |
+| ----------------------------------------- | --------- | -------- | ----- |
+| Create `MemcachedAdapter`                 | ⏳ Planned | Medium   |       |
+| Improve Redis resiliency (auto-reconnect) | ⏳ Planned | Medium   |       |
+
+### Messaging - Push Notifications
+
+| 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
+
+| 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 to 12 Months (Long-Term Vision)
+
+### Performance and Modernization
+
+| 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 |
+
+### Ecosystem
+
+| 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      |
+
+### Observability
+
+| Task                           | Status    | Priority | Notes                           |
+| ------------------------------ | --------- | -------- | ------------------------------- |
+| Optional OpenTelemetry module  | ⏳ Planned | Medium   | Request, job, and query tracing |
+| Prometheus/Grafana integration | ⏳ Planned | Low      | Production metrics              |
+
+---
+
+## Legend
+
+* ✅ **Done**: Completed task
+* ⏳ **Planned**: Scheduled task
+* 🔄 **Ongoing**: Work in progress
+* ❌ **Cancelled**: Abandoned task
+
+---
+
+## How to Contribute
+
+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
+
+---
+
+## Important Notes
+
+### About Testing
+
+Current failures during `composer test` are mainly caused by:
+
+1. **Unavailable external services** (not framework bugs):
+
+   * MySQL: Connection refused / Access denied
+   * PostgreSQL: Connection refused
+   * FTP: Connection refused
+   * S3: Invalid endpoint
+   * Beanstalkd: Connection refused
+
+2. **SQLite test isolation issues**: Some tests share database state, causing intermittent failures.
+
+**Recommended solution**: Split tests into groups (`@group unit`, `@group integration`) and configure CI with Docker Compose for integration tests.
+
+### Project Philosophy
+
+Every contribution must respect the manifesto:
+
+* **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
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.
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/MigrationCommand.php b/src/Console/Command/MigrationCommand.php
index 12500071..01a60615 100644
--- a/src/Console/Command/MigrationCommand.php
+++ b/src/Console/Command/MigrationCommand.php
@@ -50,46 +50,46 @@ 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 = [];
+        $migrations = $this->collectMigrationFiles();
 
-        // We include all migrations files and collect it for make great manage
-        foreach ($this->getMigrationFiles() as $file) {
-            $migrations[$file] = explode('.', basename($file))[0];
-        }
+        $connection = $this->arg->getParameter("--connection", config("database.default"));
 
-        // We create the migration database status
-        $this->createMigrationTable();
+        try {
+            Database::connection($connection);
+        } catch (Exception $exception) {
+            throw new MigrationException($exception->getMessage(), (int)$exception->getCode());
+        }
 
-        $action = 'make' . strtoupper($type);
+        try {
+            // We create the migration database status
+            $this->createMigrationTable();
 
-        $this->$action($migrations);
+            $action = 'make' . ucfirst($type);
+            if (!method_exists($this, $action)) {
+                throw new MigrationException("Migration action '$action' not found.");
+            }
+            $this->$action($migrations);
+        } catch (Exception $exception) {
+            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 +121,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 +153,7 @@ protected function makeUp(array $migrations): void
                 (new $migration())->up();
             } catch (Exception $exception) {
                 $this->throwMigrationException($exception, $migration);
+                break;
             }
 
             // Create new migration status
@@ -209,7 +210,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 +250,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 +289,7 @@ protected function makeRollback(array $migrations): void
                     (new $migration())->rollback();
                 } catch (Exception $exception) {
                     $this->throwMigrationException($exception, $migration);
+                    return;
                 }
 
                 break;
@@ -311,9 +313,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 +349,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 +361,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/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/Console/Command/SeederCommand.php b/src/Console/Command/SeederCommand.php
index 3d910071..25f3a1d0 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());
         }
     }
@@ -53,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
@@ -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/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/Console/Console.php b/src/Console/Console.php
index 94262e56..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,
+        ];
     }
 
     /**
@@ -246,6 +376,12 @@ 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)) {
+                // `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);
             }
         }
@@ -258,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);
             }
         }
 
@@ -280,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);
@@ -308,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;
     }
@@ -519,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 !');
     }
 }
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
      *
diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php
index d41df871..9ad2c72f 100644
--- a/src/Database/Barry/Model.php
+++ b/src/Database/Barry/Model.php
@@ -22,11 +22,56 @@
 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)
+ * 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
 {
@@ -70,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
      *
@@ -188,7 +226,6 @@ public function getConnection(): ?string
      * Initialize the connection
      *
      * @return Builder
-     * @throws
      */
     public static function query(): Builder
     {
@@ -374,7 +411,6 @@ public static function retrieve(
      * Delete a record
      *
      * @return int
-     * @throws
      */
     public function delete(): int
     {
@@ -482,7 +518,6 @@ public static function create(array $data): Model
      * persist aliases on insert action
      *
      * @return int
-     * @throws
      */
     public function persist(): int
     {
@@ -601,7 +636,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 +700,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 +712,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 +724,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 +736,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 +748,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 +760,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 +788,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)
     {
@@ -859,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
         );
     }
@@ -871,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();
     }
 
     /**
@@ -890,10 +920,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) {
@@ -993,35 +1020,38 @@ 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);
         }
 
         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,
-                false,
-                512,
-                JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE
-            );
+            return $this->parseToJson($value);
         }
 
         if ($type === "array") {
@@ -1031,14 +1061,28 @@ 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, assoc: true);
         }
 
         return $this->attributes[$name];
     }
+
+    /**
+     * 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  bool   $assoc
+     * @return mixed
+     */
+    private function parseToJson($value, bool $assoc = false): mixed
+    {
+        return json_decode(
+            $value,
+            $assoc,
+            512,
+            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_IGNORE
+        );
+    }
 }
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/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/Database.php b/src/Database/Database.php
index 22c9d254..4421036d 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
@@ -425,6 +415,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 +433,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/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/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/Database/QueryBuilder.php b/src/Database/QueryBuilder.php
index 5b1498c0..dddd8c73 100644
--- a/src/Database/QueryBuilder.php
+++ b/src/Database/QueryBuilder.php
@@ -112,6 +112,27 @@ class QueryBuilder implements JsonSerializable
      */
     protected string $adapter = '';
 
+    /**
+     * Determine the last sql query
+     *
+     * @var string|null
+     */
+    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
      *
@@ -170,9 +191,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 +202,10 @@ public function whereRaw(string $where): QueryBuilder
             $this->where .= ' and ' . $where;
         }
 
+        if (!empty($data)) {
+            $this->where_data_binding = array_merge($this->where_data_binding, array_values($data));
+        }
+
         return $this;
     }
 
@@ -189,9 +215,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 +226,10 @@ public function orWhereRaw(string $where): QueryBuilder
             $this->where .= ' or ' . $where;
         }
 
+        if (!empty($data)) {
+            $this->where_data_binding = array_merge($this->where_data_binding, array_values($data));
+        }
+
         return $this;
     }
 
@@ -217,8 +248,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 +282,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'
             );
         }
 
@@ -291,10 +320,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);
     }
 
@@ -361,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;
     }
 
@@ -692,8 +762,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.'
             );
         }
 
@@ -723,7 +792,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
             );
         }
 
@@ -745,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);
     }
@@ -862,13 +930,8 @@ private function aggregate($aggregate, $column): mixed
             }
         }
 
-        $statement = $this->connection->prepare($sql);
-
-        $this->bind($statement, $this->where_data_binding);
-
-        $statement->execute();
+        $statement = $this->execute($sql, $this->where_data_binding);
 
-        $this->triggerQueryEvent($sql, $this->where_data_binding);
         $this->where_data_binding = [];
 
         if ($statement->rowCount() > 1) {
@@ -899,12 +962,18 @@ 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;
                 } 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);
@@ -914,12 +983,18 @@ 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;
                 } 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++;
@@ -1035,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
      *
@@ -1075,17 +1174,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) {
@@ -1180,20 +1274,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->bind($statement, $this->where_data_binding);
+        $this->where_data_binding = array_merge(array_values($data), $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;
@@ -1230,15 +1318,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;
@@ -1257,6 +1340,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
      *
@@ -1275,27 +1370,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
      *
@@ -1336,10 +1415,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;
     }
 
@@ -1387,7 +1470,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
             );
         }
 
@@ -1420,15 +1502,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() . ' | Query: ' . $this->last_query,
+                $this->last_query,
+                E_ERROR,
+            );
+        }
 
-        return (int) $statement->rowCount();
+        return $statement;
     }
 
     /**
@@ -1440,6 +1546,8 @@ public function drop(): bool
     {
         $sql = 'drop table ' . $this->table;
 
+        $this->last_query = $sql;
+
         $result = (bool) $this->connection->exec($sql);
 
         $this->triggerQueryEvent($sql, []);
@@ -1450,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;
@@ -1465,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++;
         }
 
@@ -1474,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);
@@ -1486,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)) {
@@ -1495,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,
         );
     }
 
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/Request.php b/src/Http/Request.php
index 97813caa..33bf2aad 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]);
         }
@@ -496,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());
     }
 
     /**
@@ -507,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/src/Http/UploadedFile.php b/src/Http/UploadedFile.php
index 01a59546..bfd4ad51 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;
         }
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/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;
     }
 
     /**
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/Router/AttributeRouteRegistrar.php b/src/Router/AttributeRouteRegistrar.php
new file mode 100644
index 00000000..43655599
--- /dev/null
+++ b/src/Router/AttributeRouteRegistrar.php
@@ -0,0 +1,143 @@
+ $controllers
+     */
+    public function register(string|array $controllers): void
+    {
+        foreach ((array) $controllers as $controllerClass) {
+            $this->registerController($controllerClass);
+        }
+    }
+
+    /**
+     * 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);
+
+        foreach ($reflection->getMethods(ReflectionMethod::IS_PUBLIC) as $method) {
+            if ($this->shouldSkipMethod($method, $reflection)) {
+                continue;
+            }
+
+            $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();
+    }
+
+    /**
+     * 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()],
+            );
+
+            $this->applyRouteOptions($route, $routeAttr, $controllerAttribute);
+        }
+    }
+
+    /**
+     * 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() ?? '';
+
+        return $prefix !== '' ? rtrim($prefix, '/') . $routePath : $routePath;
+    }
+
+    /**
+     * 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 ($routeAttr->getWhere() !== []) {
+            $route->where($routeAttr->getWhere());
+        }
+
+        if ($routeAttr->getName() !== null) {
+            $namePrefix = $controllerAttribute?->getName() ?? '';
+            $route->name($namePrefix . $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/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 70a97d9f..0610cfd5 100644
--- a/src/Router/Router.php
+++ b/src/Router/Router.php
@@ -9,7 +9,11 @@
 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
      */
@@ -518,4 +522,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/src/Support/Env.php b/src/Support/Env.php
index 572eaa51..858ed7ba 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;
@@ -95,6 +93,16 @@ public static function configure(string $filename)
         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/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
      *
diff --git a/src/Support/helpers.php b/src/Support/helpers.php
index 84bbe309..0225cccd 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;
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/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');
     }
 }
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/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
      */
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/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 =====
 
     /**
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 @@
+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_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);
+
+        $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/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 @@
+ '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')];
+    }
+}
+
diff --git a/tests/Support/EnvTest.php b/tests/Support/EnvTest.php
index 83aeeb54..975a7904 100644
--- a/tests/Support/EnvTest.php
+++ b/tests/Support/EnvTest.php
@@ -11,6 +11,9 @@ class EnvTest extends \PHPUnit\Framework\TestCase
 
     public static function setUpBeforeClass(): void
     {
+        // Other test classes may have already booted Env with a different
+        // (or empty) config; reset so this suite's env.json actually loads.
+        Env::reset();
         Env::configure(__DIR__ . '/../Config/stubs/env.json');
     }
 
diff --git a/tests/Validation/ValidationTest.php b/tests/Validation/ValidationTest.php
index 3bb87aa9..dc395d6a 100644
--- a/tests/Validation/ValidationTest.php
+++ b/tests/Validation/ValidationTest.php
@@ -508,4 +508,199 @@ public function test_nullable_and_required_rule_passes_with_value()
 
         $this->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
+});