diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index abd5e193..7834613a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,12 +3,7 @@ name: bowphp on: [ push, pull_request ] env: - AWS_KEY: ${{ secrets.AWS_KEY }} - AWS_SECRET: ${{ secrets.AWS_SECRET }} - AWS_ENDPOINT: ${{ secrets.AWS_ENDPOINT }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} - AWS_REGION: ${{ secrets.AWS_REGION }} - AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }} jobs: lunix-tests: @@ -59,6 +54,11 @@ jobs: sleep 2 done + - name: Wait for MinIO bucket initialization + run: | + echo "Waiting for MinIO bucket initialization..." + docker wait bowphp_minio_init || true + - name: Cache Composer packages id: composer-cache uses: actions/cache@v4 diff --git a/.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..8fcaba53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,158 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 5.3.40 - 2026-06-10 + +### What's Changed + +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/413 +* feat(database): Add write/read database configuration by @papac in https://github.com/bowphp/framework/pull/414 +* chore(ci): Remove envs by @papac in https://github.com/bowphp/framework/pull/415 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.31...5.3.40 + +## 5.3.31 - 2026-06-07 + +### What's Changed + +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/411 +* feat(database): implement query execution performance metric mesurement by @papac in https://github.com/bowphp/framework/pull/412 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.30...5.3.31 + +## 5.3.30 - 2026-06-07 + +### What's Changed + +* Implementing the remember token by @papac in https://github.com/bowphp/framework/pull/409 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/410 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.21...5.3.30 + +## 5.3.21 - 2026-06-04 + +### What's Changed + +* fix(console): fix execute custom command prefexed by native command by @papac in https://github.com/bowphp/framework/pull/406 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/407 +* fix(session): fix session security by @papac in https://github.com/bowphp/framework/pull/408 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.20...5.3.21 + +## 5.3.20 - 2026-06-04 + +### What's Changed + +* feat(orm): add eager loading process by @papac in https://github.com/bowphp/framework/pull/404 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/405 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.12...5.3.20 + +## 5.3.12 - 2026-06-02 + +### What's Changed + +* fix(database): fix retrieve belongTo into loop execution by @papac in https://github.com/bowphp/framework/pull/402 +* Update CHANGELOG by @papac in https://github.com/bowphp/framework/pull/403 + +**Full Changelog**: https://github.com/bowphp/framework/compare/5.3.11...5.3.12 + +## 5.3.11 - 2026-05-28 + +### What's Changed + +* 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 @@ -171,6 +323,22 @@ Database::transaction(fn() => $user->update(['name' => ''])); + + + + + + + + + + + + + + + + diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 00000000..077d513e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,268 @@ +# BowPHP Framework Roadmap + +> Living document based on source code analysis (5.x branch) and the project manifesto. +> Last updated: June 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 + +* **Eager loading** via `Builder::eager(string|array $relations)` — batches related models in a single `WHERE IN` query (solves N+1), supports `hasOne` / `hasMany` / `belongsTo` / `belongsToMany`; the eager list is reset after each `get()` so it can't leak into the next query on the shared builder. +* Relations now **lazy-load once per model instance** and keep the result in memory (`Model::setRelation` + private `$relations`), replacing the previous file-based cache in `BelongsTo` / `HasOne` (fixes stale and cross-parent cache bugs). +* Fixed `belongsTo` resolution inside loops — each parent now resolves its own related model instead of always returning the first. +* Fixed `HasMany::addConstraints()` to filter on the foreign-key column (was incorrectly using the parent primary key). +* Deep casting now applied when `toArray()` / `toJson()` / `__toString()` are called. +* `SoftDelete` trait (`delete` → `deleted_at`, `restore`, `forceDelete`, `withTrashed` / `onlyTrashed` / `withoutTrashed`, events `model.restoring/restored/forceDeleting/forceDeleted`). +* Fixed `array` cast: no longer returns `stdClass`. +* Removed dead `$soft_delete` property (replaced by the trait). +* `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: eager loading (`EagerLoadingQueryTest`), SoftDelete, AttributeRouteRegistrar, new validation rules, Pagination |
+| Integrate PHPStan level 5+ | ⏳ Planned | Medium | Current constraint: `phpstan/phpstan: ^0.12.87` — upgrade to ^1.x before targeting higher levels |
+
+### Code Fixes
+
+| 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/docker-compose.yml b/docker-compose.yml
index baffd928..c7113c82 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -162,6 +162,21 @@ services:
interval: 10s
timeout: 5s
retries: 5
+ minio-init:
+ container_name: bowphp_minio_init
+ image: minio/mc
+ depends_on:
+ minio:
+ condition: service_healthy
+ entrypoint: >
+ /bin/sh -c "
+ mc alias set local http://minio:9000 minioadmin minioadmin;
+ mc mb --ignore-existing local/tests;
+ mc anonymous set public local/tests;
+ exit 0;
+ "
+ networks:
+ - bowphp_network
zookeeper:
container_name: bowphp_zookeeper
image: confluentinc/cp-zookeeper:7.5.0
diff --git a/readme.md b/readme.md
index 9c764913..33f44e19 100644
--- a/readme.md
+++ b/readme.md
@@ -2,8 +2,7 @@
[](https://github.com/bowphp/docs)
[](https://packagist.org/packages/bowphp/framework)
-[](https://github.com/bowphp/framework/blob/main/LICENSE)
-[](https://travis-ci.org/bowphp/framework)
+[](https://github.com/bowphp/framework/blob/main/LICENSE)

> 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/Application/Application.php b/src/Application/Application.php
index 153f76e8..a221efa6 100644
--- a/src/Application/Application.php
+++ b/src/Application/Application.php
@@ -178,7 +178,7 @@ public function run(): bool
// Error management
if ($resolved) {
- $this->send($response);
+ $this->sendResponse($response);
return true;
}
@@ -192,6 +192,20 @@ public function run(): bool
return false;
}
+ /**
+ * Launch the application and send the answer to the customer.
+ *
+ * Public alias of {@see Application::run()}.
+ *
+ * @return bool
+ * @throws ReflectionException
+ * @throws RouterException
+ */
+ public function send(): bool
+ {
+ return $this->run();
+ }
+
/**
* Send the answer to the customer
*
@@ -199,7 +213,7 @@ public function run(): bool
* @param int $code
* @return void
*/
- private function send(mixed $response, int $code = 200): void
+ private function sendResponse(mixed $response, int $code = 200): void
{
if ($response instanceof ResponseInterface) {
$response->sendContent();
diff --git a/src/Auth/Authentication.php b/src/Auth/Authentication.php
index e16e9a0a..cd10dd01 100644
--- a/src/Auth/Authentication.php
+++ b/src/Auth/Authentication.php
@@ -18,6 +18,45 @@ public function getAuthenticateUserId(): mixed
return $this->attributes[$this->primary_key];
}
+ /**
+ * The name of the column holding the remember-me token.
+ *
+ * Override in the model if your column is named differently. The application
+ * must add this column to its user table: `remember_token VARCHAR(100) NULL`.
+ *
+ * @return string
+ */
+ public function getRememberTokenName(): string
+ {
+ return 'remember_token';
+ }
+
+ /**
+ * Read the current remember-me token.
+ *
+ * @return ?string
+ */
+ public function getRememberToken(): ?string
+ {
+ $value = $this->attributes[$this->getRememberTokenName()] ?? null;
+
+ return is_null($value) ? null : (string) $value;
+ }
+
+ /**
+ * Store a new remember-me token and persist it.
+ *
+ * @param ?string $token
+ * @return void
+ */
+ public function setRememberToken(?string $token): void
+ {
+ $column = $this->getRememberTokenName();
+
+ $this->attributes[$column] = $token;
+ $this->update([$column => $token]);
+ }
+
/**
* Define the additional values
*
diff --git a/src/Auth/Guards/GuardContract.php b/src/Auth/Guards/GuardContract.php
index b2fb846c..8fc61ecd 100644
--- a/src/Auth/Guards/GuardContract.php
+++ b/src/Auth/Guards/GuardContract.php
@@ -49,12 +49,13 @@ abstract public function guest(): bool;
abstract public function logout(): bool;
/**
- * Logout
+ * Login
*
* @param Authentication $user
+ * @param bool $remember
* @return bool
*/
- abstract public function login(Authentication $user): bool;
+ abstract public function login(Authentication $user, bool $remember = false): bool;
/**
* Get authenticated user
@@ -67,9 +68,10 @@ abstract public function user(): ?Authentication;
* Check if user is authenticated
*
* @param array $credentials
+ * @param bool $remember
* @return bool
*/
- abstract public function attempts(array $credentials): bool;
+ abstract public function attempts(array $credentials, bool $remember = false): bool;
/**
* Get the guard name
diff --git a/src/Auth/Guards/JwtGuard.php b/src/Auth/Guards/JwtGuard.php
index ce48bff8..38755586 100644
--- a/src/Auth/Guards/JwtGuard.php
+++ b/src/Auth/Guards/JwtGuard.php
@@ -51,12 +51,14 @@ public function __construct(array $provider, string $guard)
* Check if user is authenticated
*
* @param array $credentials
+ * @param bool $remember
* @return bool
* @throws AuthenticationException
* @throws Exception
*/
- public function attempts(array $credentials): bool
+ public function attempts(array $credentials, bool $remember = false): bool
{
+ // $remember is ignored: JWT is stateless, remember-me is a session concept.
$user = $this->makeLogin($credentials);
$this->token = null;
@@ -143,11 +145,13 @@ private function getPolicier(): Policier
* Make direct login
*
* @param Authentication $user
+ * @param bool $remember
* @return bool
* @throws Exception
*/
- public function login(Authentication $user): bool
+ public function login(Authentication $user, bool $remember = false): bool
{
+ // $remember is ignored: JWT is stateless, remember-me is a session concept.
$attributes = array_merge(
$user->customJwtAttributes(),
[
diff --git a/src/Auth/Guards/SessionGuard.php b/src/Auth/Guards/SessionGuard.php
index 0096072d..29b357f6 100644
--- a/src/Auth/Guards/SessionGuard.php
+++ b/src/Auth/Guards/SessionGuard.php
@@ -8,6 +8,7 @@
use Bow\Auth\Exception\AuthenticationException;
use Bow\Auth\Traits\LoginUserTrait;
use Bow\Security\Hash;
+use Bow\Session\Cookie;
use Bow\Session\Exception\SessionException;
use Bow\Session\Session;
@@ -46,10 +47,11 @@ public function __construct(array $provider, string $guard)
* Check if user is authenticated
*
* @param array $credentials
+ * @param bool $remember
* @return bool
* @throws AuthenticationException|SessionException
*/
- public function attempts(array $credentials): bool
+ public function attempts(array $credentials, bool $remember = false): bool
{
$user = $this->makeLogin($credentials);
@@ -62,6 +64,11 @@ public function attempts(array $credentials): bool
if (Hash::check($password, $user->{$fields['password']})) {
$this->getSession()->put($this->session_key, $user);
+
+ if ($remember) {
+ $this->setRememberCookie($user);
+ }
+
return true;
}
@@ -76,7 +83,8 @@ public function attempts(array $credentials): bool
*/
public function check(): bool
{
- return $this->getSession()->exists($this->session_key);
+ return $this->getSession()->exists($this->session_key)
+ || $this->attemptRememberLogin();
}
/**
@@ -112,14 +120,19 @@ public function guest(): bool
/**
* Make direct login
*
- * @param mixed $user
+ * @param Authentication $user
+ * @param bool $remember
* @return bool
* @throws AuthenticationException|SessionException
*/
- public function login(Authentication $user): bool
+ public function login(Authentication $user, bool $remember = false): bool
{
$this->getSession()->add($this->session_key, $user);
+ if ($remember) {
+ $this->setRememberCookie($user);
+ }
+
return true;
}
@@ -131,6 +144,13 @@ public function login(Authentication $user): bool
*/
public function logout(): bool
{
+ $user = $this->getSession()->get($this->session_key);
+
+ if ($user instanceof Authentication) {
+ $user->setRememberToken($this->generateRememberToken());
+ }
+
+ $this->clearRememberCookie();
$this->getSession()->remove($this->session_key);
return true;
@@ -161,6 +181,109 @@ public function id(): mixed
*/
public function user(): ?Authentication
{
+ if (!$this->getSession()->exists($this->session_key)) {
+ $this->attemptRememberLogin();
+ }
+
return $this->getSession()->get($this->session_key);
}
+
+ /**
+ * Attempt to restore the session from a valid remember-me cookie.
+ *
+ * Never throws on malformed input: a bad cookie is simply cleared.
+ *
+ * @return bool
+ * @throws AuthenticationException|SessionException
+ */
+ private function attemptRememberLogin(): bool
+ {
+ $cookie = Cookie::get($this->rememberCookieName());
+
+ if (!is_string($cookie) || !str_contains($cookie, '|')) {
+ if (!is_null($cookie)) {
+ $this->clearRememberCookie();
+ }
+ return false;
+ }
+
+ [$id, $token] = explode('|', $cookie, 2);
+
+ $user = $this->getUserById($id);
+
+ if (is_null($user)) {
+ $this->clearRememberCookie();
+ return false;
+ }
+
+ $stored = $user->getRememberToken();
+
+ if (is_null($stored) || !hash_equals($stored, $token)) {
+ $this->clearRememberCookie();
+ return false;
+ }
+
+ $this->getSession()->put($this->session_key, $user);
+
+ return true;
+ }
+
+ /**
+ * Generate a fresh remember token and persist it on the user, then
+ * write the encrypted remember cookie.
+ *
+ * @param Authentication $user
+ * @return void
+ */
+ private function setRememberCookie(Authentication $user): void
+ {
+ $token = $this->generateRememberToken();
+ $user->setRememberToken($token);
+
+ Cookie::set(
+ $this->rememberCookieName(),
+ $user->getAuthenticateUserId() . '|' . $token,
+ $this->rememberLifetime()
+ );
+ }
+
+ /**
+ * Remove the remember cookie.
+ *
+ * @return void
+ */
+ private function clearRememberCookie(): void
+ {
+ Cookie::remove($this->rememberCookieName());
+ }
+
+ /**
+ * Get the remember cookie name for this guard.
+ *
+ * @return string
+ */
+ private function rememberCookieName(): string
+ {
+ return 'remember_' . $this->guard;
+ }
+
+ /**
+ * Generate a cryptographically strong remember token.
+ *
+ * @return string
+ */
+ private function generateRememberToken(): string
+ {
+ return bin2hex(random_bytes(30));
+ }
+
+ /**
+ * Get the configured remember-me cookie lifetime in seconds.
+ *
+ * @return int
+ */
+ private function rememberLifetime(): int
+ {
+ return (int) (config('auth.remember_lifetime') ?? 2592000);
+ }
}
diff --git a/src/Auth/README.md b/src/Auth/README.md
index 0d2a38f0..ae36254b 100644
--- a/src/Auth/README.md
+++ b/src/Auth/README.md
@@ -17,3 +17,64 @@ $user = $auth->user();
```
Enjoy!
+
+## Remember me
+
+The session guard supports a persistent "remember me" cookie so users stay authenticated across browser sessions.
+
+### Required migration
+
+The framework cannot modify your application's database, so you must add a nullable token column to your user table:
+
+```sql
+ALTER TABLE users ADD COLUMN remember_token VARCHAR(100) NULL;
+```
+
+The default column name is `remember_token`. Override it on your user model if needed:
+
+```php
+public function getRememberTokenName(): string
+{
+ return 'my_token_column';
+}
+```
+
+### Usage
+
+Pass `true` as the second argument to `attempts()` or `login()`:
+
+```php
+// Authenticate with credentials and remember the user
+Auth::attempts(['username' => $username, 'password' => $password], true);
+
+// Or, for an already-resolved user instance
+Auth::login($user, true);
+```
+
+### Automatic session restore
+
+When the session has expired, the next call to `Auth::check()` or `Auth::user()` transparently restores the session from the encrypted `remember_` cookie (e.g. `remember_web`). No changes are needed in `AuthMiddleware`.
+
+### Logout
+
+`Auth::logout()` regenerates the user's token (invalidating any outstanding remember cookie) and clears the cookie:
+
+```php
+Auth::logout();
+```
+
+Because all devices share the same `remember_token` column, logging out on one device invalidates remember-me for that user everywhere.
+
+### Configuration
+
+The cookie lifetime is read from `config('auth.remember_lifetime')` in seconds and defaults to 30 days. Add the key to your `config/auth.php` to override it:
+
+```php
+'remember_lifetime' => 2592000, // 30 days (default)
+```
+
+### Scope and security notes
+
+- Remember-me applies to the **session guard only**; the JWT guard ignores the flag.
+- The token is high-entropy (`bin2hex(random_bytes(30))`) and compared timing-safely with `hash_equals`; the cookie is encrypted.
+- A single shared `remember_token` column means logging out invalidates remember-me for that user on all devices.
diff --git a/src/Auth/Traits/LoginUserTrait.php b/src/Auth/Traits/LoginUserTrait.php
index ba86b883..a3dc777a 100644
--- a/src/Auth/Traits/LoginUserTrait.php
+++ b/src/Auth/Traits/LoginUserTrait.php
@@ -55,4 +55,17 @@ private function getUserBy(string $key, float|int|string $value): ?Authenticatio
return $model::where($key, $value)->first();
}
+
+ /**
+ * Get a user by primary key value.
+ *
+ * @param float|int|string $id
+ * @return ?Authentication
+ */
+ private function getUserById(float|int|string $id): ?Authentication
+ {
+ $model = $this->provider['model'];
+
+ return $this->getUserBy((new $model())->getKey(), $id);
+ }
}
diff --git a/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..a0809aaa 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,
+ ];
}
/**
@@ -240,14 +369,15 @@ public function call(?string $command): mixed
exit(0);
}
- // The built-in commands have priority
- $commands = $this->command->getCommands();
-
- if (!in_array($command, array_keys($commands))) {
- // Try to execute the custom command
- if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) {
- return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command);
+ // Try to execute the custom command
+ if (array_key_exists($this->arg->getRawCommand(), static::$registers) || array_key_exists($command, static::$registers)) {
+ // `php bow help` shows the registered help instead of running it.
+ if ($this->arg->getTarget() === 'help' && !$this->arg->getAction()) {
+ $this->help($command);
+ exit(0);
}
+
+ return $this->executeCustomCommand($this->arg->getRawCommand() ?? $command);
}
if (!in_array($command, static::COMMAND)) {
@@ -258,7 +388,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 +411,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 +439,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;
}
@@ -443,7 +584,15 @@ private function schedule(): void
$this->throwFailsCommand('Bad command usage', 'help schedule');
}
- $this->command->call("schedule:{$action}", $action, $this->arg->getTarget());
+ $target = $this->arg->getTarget();
+
+ // schedule:test expects a 0-based integer index; getTarget() returns a
+ // ?string, which would trip the strict_types signature of test(int).
+ if ($action === 'test') {
+ $target = (int) $target;
+ }
+
+ $this->command->call("schedule:{$action}", $action, $target);
}
/**
@@ -519,18 +668,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/Builder.php b/src/Database/Barry/Builder.php
index 1c75b2b7..08fad8f1 100644
--- a/src/Database/Barry/Builder.php
+++ b/src/Database/Barry/Builder.php
@@ -18,6 +18,26 @@ class Builder extends QueryBuilder
*/
protected ?string $model = null;
+ /**
+ * The relationships to eager load.
+ *
+ * @var array
+ */
+ protected array $eager_loads = [];
+
+ /**
+ * Register relationships to eager load on the query result.
+ *
+ * @param string|array $relations
+ * @return Builder
+ */
+ public function eager(string|array $relations): Builder
+ {
+ $this->eager_loads = array_merge($this->eager_loads, (array)$relations);
+
+ return $this;
+ }
+
/**
* Get information
*
@@ -28,6 +48,11 @@ public function get(array $columns = []): Model|Collection|null
{
$data = parent::get($columns);
+ // Read and reset the eager loads now: query() memoizes a shared Builder
+ // instance, so the list must not leak into the next query on this model.
+ $eager_loads = $this->eager_loads;
+ $this->eager_loads = [];
+
if (is_null($data)) {
return null;
}
@@ -41,9 +66,36 @@ public function get(array $columns = []): Model|Collection|null
$data[$key] = new $this->model((array)$value);
}
+ if (count($eager_loads) > 0) {
+ $this->eagerLoadRelations($data, $eager_loads);
+ }
+
return new Collection($data);
}
+ /**
+ * Eager load the given relationships onto a set of parent models.
+ *
+ * @param Model[] $models
+ * @param array $relations
+ * @return void
+ */
+ protected function eagerLoadRelations(array $models, array $relations): void
+ {
+ if (count($models) === 0) {
+ return;
+ }
+
+ foreach ($relations as $name) {
+ // Build the relation without the single parent constraint so it can
+ // be batched across every parent with one whereIn query.
+ $relation = Relation::noConstraints(fn () => $models[0]->$name());
+
+ $relation->addEagerConstraints($models);
+ $relation->match($models, $relation->getEager(), $name);
+ }
+ }
+
/**
* Check if rows exists
*
diff --git a/src/Database/Barry/Model.php b/src/Database/Barry/Model.php
index d41df871..8361d7f5 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
*
@@ -153,6 +191,13 @@ abstract class Model implements ArrayAccess, JsonSerializable
*/
private array $original = [];
+ /**
+ * The loaded relationships, resolved lazily once per model instance.
+ *
+ * @var array
+ */
+ private array $relations = [];
+
/**
* Model constructor.
*
@@ -188,7 +233,6 @@ public function getConnection(): ?string
* Initialize the connection
*
* @return Builder
- * @throws
*/
public static function query(): Builder
{
@@ -374,7 +418,6 @@ public static function retrieve(
* Delete a record
*
* @return int
- * @throws
*/
public function delete(): int
{
@@ -482,7 +525,6 @@ public static function create(array $data): Model
* persist aliases on insert action
*
* @return int
- * @throws
*/
public function persist(): int
{
@@ -601,7 +643,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 +707,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 +719,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 +731,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 +743,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 +755,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 +767,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 +795,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)
{
@@ -852,6 +887,20 @@ public function getAttribute(string $key): mixed
return $this->attributes[$key] ?? null;
}
+ /**
+ * Set a loaded relationship on the model's in-memory store.
+ *
+ * Used by eager loading to pre-populate a relation so a later access
+ * resolves from memory instead of issuing a query.
+ *
+ * @param string $name
+ * @param mixed $value
+ */
+ public function setRelation(string $name, mixed $value): void
+ {
+ $this->relations[$name] = $value;
+ }
+
/**
* Returns the data
*
@@ -859,9 +908,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 +926,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();
}
/**
@@ -889,10 +940,19 @@ public function __get(string $name): mixed
$attribute_exists = isset($this->attributes[$name]);
if (!$attribute_exists && method_exists($this, $name)) {
+ // Lazy-load once: a relation is resolved a single time per model
+ // instance and its result kept in memory. Repeated access returns
+ // the same loaded object instead of re-querying or re-hydrating.
+ if (array_key_exists($name, $this->relations)) {
+ return $this->relations[$name];
+ }
+
$result = $this->$name();
+
if ($result instanceof Relation) {
- return $result->getResults();
+ return $this->relations[$name] = $result->getResults();
}
+
return $result;
}
@@ -993,35 +1053,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 +1094,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..bc9ab610 100644
--- a/src/Database/Barry/Relation.php
+++ b/src/Database/Barry/Relation.php
@@ -4,6 +4,8 @@
namespace Bow\Database\Barry;
+use Closure;
+use Bow\Database\Collection;
use Bow\Database\QueryBuilder;
abstract class Relation
@@ -62,7 +64,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) {
@@ -134,4 +141,103 @@ public function create(array $attributes): Model
* @return mixed
*/
abstract public function getResults(): mixed;
+
+ /**
+ * The parent attribute whose value is matched against the related models.
+ *
+ * @return string
+ */
+ abstract protected function eagerParentKey(): string;
+
+ /**
+ * The related column queried when eager loading the relation.
+ *
+ * @return string
+ */
+ abstract protected function eagerRelatedKey(): string;
+
+ /**
+ * Whether the relation resolves to many related models.
+ *
+ * @return bool
+ */
+ abstract protected function eagerIsMany(): bool;
+
+ /**
+ * Run the given callback with relation constraints disabled.
+ *
+ * Lets the eager loader build a relation without the single parent WHERE
+ * clause so it can be replaced by a batched whereIn over every parent.
+ *
+ * @param Closure $callback
+ * @return mixed
+ */
+ public static function noConstraints(Closure $callback): mixed
+ {
+ $previous = static::$has_constraints;
+ static::$has_constraints = false;
+
+ try {
+ return $callback();
+ } finally {
+ static::$has_constraints = $previous;
+ }
+ }
+
+ /**
+ * Constrain the relation query to every parent key in a single whereIn.
+ *
+ * @param Model[] $parents
+ * @return void
+ */
+ public function addEagerConstraints(array $parents): void
+ {
+ $keys = array_values(array_unique(array_filter(
+ array_map(fn (Model $parent) => $parent->getAttribute($this->eagerParentKey()), $parents),
+ fn ($value) => !is_null($value)
+ )));
+
+ // Fall back to an impossible match when no parent exposes a key so the
+ // query stays well-formed and returns nothing.
+ $this->query->whereIn($this->eagerRelatedKey(), count($keys) > 0 ? $keys : [0]);
+ }
+
+ /**
+ * Execute the eager query and return the related models.
+ *
+ * @return Collection
+ */
+ public function getEager(): Collection
+ {
+ $results = $this->query->get();
+
+ return $results instanceof Collection ? $results : new Collection([]);
+ }
+
+ /**
+ * Match the eager loaded related models back onto their parents.
+ *
+ * @param Model[] $parents
+ * @param Collection $results
+ * @param string $name
+ * @return void
+ */
+ public function match(array $parents, Collection $results, string $name): void
+ {
+ $dictionary = [];
+
+ foreach ($results as $related) {
+ $dictionary[$related->getAttribute($this->eagerRelatedKey())][] = $related;
+ }
+
+ foreach ($parents as $parent) {
+ $key = $parent->getAttribute($this->eagerParentKey());
+ $matched = $dictionary[$key] ?? [];
+
+ $parent->setRelation(
+ $name,
+ $this->eagerIsMany() ? new Collection($matched) : ($matched[0] ?? null)
+ );
+ }
+ }
}
diff --git a/src/Database/Barry/Relations/BelongsTo.php b/src/Database/Barry/Relations/BelongsTo.php
index aa1cebd5..06f18f08 100644
--- a/src/Database/Barry/Relations/BelongsTo.php
+++ b/src/Database/Barry/Relations/BelongsTo.php
@@ -4,7 +4,6 @@
namespace Bow\Database\Barry\Relations;
-use Bow\Cache\Cache;
use Bow\Database\Barry\Model;
use Bow\Database\Barry\Relation;
use Bow\Database\Exception\QueryBuilderException;
@@ -38,23 +37,9 @@ public function __construct(
*/
public function getResults(): mixed
{
- $key = $this->query->getTable() . ":" . $this->local_key . ":belongsto:" . $this->related->getTable() . ":" . $this->foreign_key;
-
- $cache = Cache::store('file')->get($key);
-
- if (!is_null($cache)) {
- $related = new $this->related();
- $related->setAttributes($cache);
- return $related;
- }
-
- $result = $this->query->first();
-
- if (!is_null($result)) {
- Cache::store('file')->set($key, $result->toArray(), 500);
- }
-
- return $result;
+ // The result is lazy-loaded once per parent model instance and kept in
+ // memory by the model itself, so a plain query is enough here.
+ return $this->query->first();
}
/**
@@ -75,4 +60,28 @@ public function addConstraints(): void
$foreign_key_value = $this->parent->getAttribute($this->foreign_key);
$this->query->where($this->local_key, '=', $foreign_key_value);
}
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerParentKey(): string
+ {
+ return $this->foreign_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerRelatedKey(): string
+ {
+ return $this->local_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerIsMany(): bool
+ {
+ return false;
+ }
}
diff --git a/src/Database/Barry/Relations/BelongsToMany.php b/src/Database/Barry/Relations/BelongsToMany.php
index 7e4bf0b8..4b0ad159 100644
--- a/src/Database/Barry/Relations/BelongsToMany.php
+++ b/src/Database/Barry/Relations/BelongsToMany.php
@@ -55,4 +55,28 @@ public function addConstraints(): void
$foreign_key_value = $this->parent->getAttribute($this->foreign_key);
$this->query->where($this->local_key, '=', $foreign_key_value);
}
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerParentKey(): string
+ {
+ return $this->foreign_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerRelatedKey(): string
+ {
+ return $this->local_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerIsMany(): bool
+ {
+ return true;
+ }
}
diff --git a/src/Database/Barry/Relations/HasMany.php b/src/Database/Barry/Relations/HasMany.php
index 43ec1bda..357b7d6f 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)
{
@@ -44,6 +43,33 @@ public function getResults(): Collection
*/
public function addConstraints(): void
{
- $this->query = $this->query->where($this->foreign_key, $this->parent->getKeyValue());
+ // Match the related foreign key column against the parent's primary key.
+ // local_key holds the foreign key column name; foreign_key holds the
+ // parent primary key name, so filtering must use local_key here.
+ $this->query = $this->query->where($this->local_key, $this->parent->getKeyValue());
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerParentKey(): string
+ {
+ return $this->parent->getKey();
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerRelatedKey(): string
+ {
+ return $this->local_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerIsMany(): bool
+ {
+ return true;
}
}
diff --git a/src/Database/Barry/Relations/HasOne.php b/src/Database/Barry/Relations/HasOne.php
index cb47d921..60725b87 100644
--- a/src/Database/Barry/Relations/HasOne.php
+++ b/src/Database/Barry/Relations/HasOne.php
@@ -4,7 +4,6 @@
namespace Bow\Database\Barry\Relations;
-use Bow\Cache\Cache;
use Bow\Database\Barry\Model;
use Bow\Database\Barry\Relation;
@@ -33,23 +32,9 @@ 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;
-
- $cache = Cache::store('file')->get($key);
-
- if (!is_null($cache)) {
- $related = new $this->related();
- $related->setAttributes($cache);
- return $related;
- }
-
- $result = $this->query->first();
-
- if (!is_null($result)) {
- Cache::store('file')->add($key, $result->toArray(), 60);
- }
-
- return $result;
+ // The result is lazy-loaded once per parent model instance and kept in
+ // memory by the model itself, so a plain query is enough here.
+ return $this->query->first();
}
/**
@@ -65,4 +50,28 @@ public function addConstraints(): void
$this->query = $this->query->where($this->foreign_key, $this->parent->getAttribute($this->local_key));
}
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerParentKey(): string
+ {
+ return $this->local_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerRelatedKey(): string
+ {
+ return $this->foreign_key;
+ }
+
+ /**
+ * @inheritDoc
+ */
+ protected function eagerIsMany(): bool
+ {
+ return false;
+ }
}
diff --git a/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/Connection/AbstractConnection.php b/src/Database/Connection/AbstractConnection.php
index 5b3757aa..4f5984d5 100644
--- a/src/Database/Connection/AbstractConnection.php
+++ b/src/Database/Connection/AbstractConnection.php
@@ -31,37 +31,157 @@ abstract class AbstractConnection
protected int $fetch = PDO::FETCH_OBJ;
/**
- * The PDO instance
+ * The write (primary) PDO instance
*
- * @var PDO
+ * @var ?PDO
*/
- protected PDO $pdo;
+ protected ?PDO $write_pdo = null;
/**
- * Create an instance of the PDO
+ * The read (replica) PDO instance
+ *
+ * @var ?PDO
+ */
+ protected ?PDO $read_pdo = null;
+
+ /**
+ * The configuration used to build the write connection
+ *
+ * @var array
+ */
+ protected array $write_config = [];
+
+ /**
+ * The configuration used to build the read connection,
+ * or null when the connection is not split (reads use write).
+ *
+ * @var ?array
+ */
+ protected ?array $read_config = null;
+
+ /**
+ * AbstractConnection constructor.
+ *
+ * Splits the connection configuration into a write (primary)
+ * configuration and an optional read (replica) configuration.
+ *
+ * @param array $config
+ */
+ public function __construct(array $config)
+ {
+ $this->config = $config;
+
+ $this->write_config = $config;
+ unset($this->write_config['read']);
+
+ if (isset($config['read']) && is_array($config['read'])) {
+ $this->read_config = array_merge($this->write_config, $config['read']);
+ } else {
+ $this->read_config = null;
+ }
+
+ // Validate eagerly so misconfiguration fails fast, while the
+ // connection itself is still established lazily on first use.
+ $this->validateConfig($this->write_config);
+
+ if ($this->read_config !== null) {
+ $this->validateConfig($this->read_config);
+ }
+ }
+
+ /**
+ * Validate the connection configuration.
+ *
+ * @param array $config
+ * @return void
+ */
+ abstract protected function validateConfig(array $config): void;
+
+ /**
+ * Build a PDO instance from the given configuration.
+ *
+ * @param array $config
+ * @return PDO
+ */
+ abstract protected function makePdo(array $config): PDO;
+
+ /**
+ * Build (eagerly) the write connection.
+ *
+ * Kept for backward compatibility with callers that expect to
+ * (re)establish the connection explicitly.
*
* @return void
*/
- abstract public function connection(): void;
+ public function connection(): void
+ {
+ $this->write_pdo = $this->makePdo($this->write_config);
+ }
/**
- * Retrieves the connection
+ * Retrieves the connection (the write/primary connection)
*
* @return PDO
*/
public function getConnection(): PDO
{
- return $this->pdo;
+ return $this->getWriteConnection();
+ }
+
+ /**
+ * Retrieves the write (primary) connection, building it lazily
+ *
+ * @return PDO
+ */
+ public function getWriteConnection(): PDO
+ {
+ if ($this->write_pdo === null) {
+ $this->write_pdo = $this->makePdo($this->write_config);
+ }
+
+ return $this->write_pdo;
}
/**
- * Set the connection
+ * Retrieves the read (replica) connection, building it lazily.
+ *
+ * Falls back to the write connection when the connection is not split.
+ *
+ * @return PDO
+ */
+ public function getReadConnection(): PDO
+ {
+ if ($this->read_config === null) {
+ return $this->getWriteConnection();
+ }
+
+ if ($this->read_pdo === null) {
+ $this->read_pdo = $this->makePdo($this->read_config);
+ }
+
+ return $this->read_pdo;
+ }
+
+ /**
+ * Whether the write connection has already been established.
+ *
+ * Used to inspect transaction state without forcing a connection open.
+ *
+ * @return bool
+ */
+ public function hasWriteConnection(): bool
+ {
+ return $this->write_pdo instanceof PDO;
+ }
+
+ /**
+ * Set the connection (the write/primary connection)
*
* @param PDO $pdo
*/
public function setConnection(PDO $pdo): void
{
- $this->pdo = $pdo;
+ $this->write_pdo = $pdo;
}
/**
@@ -84,10 +204,14 @@ public function setFetchMode(int $fetch): void
{
$this->fetch = $fetch;
- $this->pdo->setAttribute(
- PDO::ATTR_DEFAULT_FETCH_MODE,
- $fetch
- );
+ foreach ([$this->write_pdo, $this->read_pdo] as $pdo) {
+ if ($pdo instanceof PDO) {
+ $pdo->setAttribute(
+ PDO::ATTR_DEFAULT_FETCH_MODE,
+ $fetch
+ );
+ }
+ }
}
/**
@@ -137,7 +261,7 @@ public function getCollation(): string
*/
public function getPdoDriver(): string
{
- return $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
+ return $this->getConnection()->getAttribute(PDO::ATTR_DRIVER_NAME);
}
/**
diff --git a/src/Database/Connection/Adapters/MysqlAdapter.php b/src/Database/Connection/Adapters/MysqlAdapter.php
index 2fb2cb10..b8bb4910 100644
--- a/src/Database/Connection/Adapters/MysqlAdapter.php
+++ b/src/Database/Connection/Adapters/MysqlAdapter.php
@@ -25,53 +25,51 @@ class MysqlAdapter extends AbstractConnection
protected ?string $name = 'mysql';
/**
- * MysqlAdapter constructor.
+ * Validate the connection configuration.
*
- * @param array $config
+ * @param array $config
+ * @return void
*/
- public function __construct(array $config)
+ protected function validateConfig(array $config): void
{
- $this->config = $config;
-
- $this->connection();
+ // Check the existence of database definition
+ if (!isset($config['database'])) {
+ throw new InvalidArgumentException("The database is not defined");
+ }
}
/**
- * Make connexion
+ * Build a PDO instance from the given configuration.
*
- * @return void
+ * @param array $config
+ * @return PDO
*/
- public function connection(): void
+ protected function makePdo(array $config): PDO
{
// Build of the mysql dsn
- if (isset($this->config['socket']) && !empty($this->config['socket'])) {
- $hostname = $this->config['socket'];
+ if (isset($config['socket']) && !empty($config['socket'])) {
+ $hostname = $config['socket'];
$port = '';
} else {
- $hostname = $this->config['hostname'] ?? null;
- $port = (string)($this->config['port'] ?? self::PORT);
- }
-
- // Check the existence of database definition
- if (!isset($this->config['database'])) {
- throw new InvalidArgumentException("The database is not defined");
+ $hostname = $config['hostname'] ?? null;
+ $port = (string)($config['port'] ?? self::PORT);
}
// Formatting connection parameters
- $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']);
+ $dsn = sprintf("mysql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']);
- $username = $this->config["username"];
- $password = $this->config["password"];
+ $username = $config["username"];
+ $password = $config["password"];
// Configuration the PDO attributes that we want to set
$options = [
- PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch,
+ PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
- PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($this->config["charset"]),
+ PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES " . Str::upper($config["charset"]),
PDO::ATTR_ORACLE_NULLS => PDO::NULL_EMPTY_STRING
];
// Build the PDO connection
- $this->pdo = new PDO($dsn, $username, $password, $options);
+ return new PDO($dsn, $username, $password, $options);
}
}
diff --git a/src/Database/Connection/Adapters/PostgreSQLAdapter.php b/src/Database/Connection/Adapters/PostgreSQLAdapter.php
index fe5ec75a..a6535c1a 100644
--- a/src/Database/Connection/Adapters/PostgreSQLAdapter.php
+++ b/src/Database/Connection/Adapters/PostgreSQLAdapter.php
@@ -24,79 +24,79 @@ class PostgreSQLAdapter extends AbstractConnection
protected ?string $name = 'pgsql';
/**
- * MysqlAdapter constructor.
+ * Validate the connection configuration.
*
- * @param array $config
+ * @param array $config
+ * @return void
*/
- public function __construct(array $config)
+ protected function validateConfig(array $config): void
{
- $this->config = $config;
-
- $this->connection();
+ // Check the existence of database definition
+ if (!isset($config['database'])) {
+ throw new InvalidArgumentException("The database is not defined");
+ }
}
/**
- * Make connexion
+ * Build a PDO instance from the given configuration.
*
- * @return void
+ * @param array $config
+ * @return PDO
*/
- public function connection(): void
+ protected function makePdo(array $config): PDO
{
- // Build of the mysql dsn
- if (isset($this->config['socket']) && !is_null($this->config['socket']) && !empty($this->config['socket'])) {
- $hostname = $this->config['socket'];
+ // Build of the pgsql dsn
+ if (isset($config['socket']) && !is_null($config['socket']) && !empty($config['socket'])) {
+ $hostname = $config['socket'];
$port = '';
} else {
- $hostname = $this->config['hostname'] ?? null;
- $port = (string)($this->config['port'] ?? self::PORT);
- }
-
- // Check the existence of database definition
- if (!isset($this->config['database'])) {
- throw new InvalidArgumentException("The database is not defined");
+ $hostname = $config['hostname'] ?? null;
+ $port = (string)($config['port'] ?? self::PORT);
}
// Formatting connection parameters
- $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $this->config['database']);
+ $dsn = sprintf("pgsql:host=%s;port=%s;dbname=%s", $hostname, $port, $config['database']);
- if (isset($this->config['sslmode'])) {
- $dsn .= ';sslmode=' . $this->config['sslmode'];
+ if (isset($config['sslmode'])) {
+ $dsn .= ';sslmode=' . $config['sslmode'];
}
- if (isset($this->config['sslrootcert'])) {
- $dsn .= ';sslrootcert=' . $this->config['sslrootcert'];
+ if (isset($config['sslrootcert'])) {
+ $dsn .= ';sslrootcert=' . $config['sslrootcert'];
}
- if (isset($this->config['sslcert'])) {
- $dsn .= ';sslcert=' . $this->config['sslcert'];
+ if (isset($config['sslcert'])) {
+ $dsn .= ';sslcert=' . $config['sslcert'];
}
- if (isset($this->config['sslkey'])) {
- $dsn .= ';sslkey=' . $this->config['sslkey'];
+ if (isset($config['sslkey'])) {
+ $dsn .= ';sslkey=' . $config['sslkey'];
}
- if (isset($this->config['sslcrl'])) {
- $dsn .= ';sslcrl=' . $this->config['sslcrl'];
+ if (isset($config['sslcrl'])) {
+ $dsn .= ';sslcrl=' . $config['sslcrl'];
}
- if (isset($this->config['application_name'])) {
- $dsn .= ';application_name=' . $this->config['application_name'];
+ if (isset($config['application_name'])) {
+ $dsn .= ';application_name=' . $config['application_name'];
}
- $username = $this->config["username"];
- $password = $this->config["password"];
+ $username = $config["username"];
+ $password = $config["password"];
// Configuration the PDO attributes that we want to set
$options = [
- PDO::ATTR_DEFAULT_FETCH_MODE => $this->config['fetch'] ?? $this->fetch,
+ PDO::ATTR_DEFAULT_FETCH_MODE => $config['fetch'] ?? $this->fetch,
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
];
// Build the PDO connection
- $this->pdo = new PDO($dsn, $username, $password, $options);
+ $pdo = new PDO($dsn, $username, $password, $options);
- if ($this->config["charset"]) {
- $this->pdo->query('SET NAMES \'' . $this->config["charset"] . '\'');
+ if ($config["charset"]) {
+ $pdo->query('SET NAMES \'' . $config["charset"] . '\'');
}
+
+ return $pdo;
}
}
diff --git a/src/Database/Connection/Adapters/SqliteAdapter.php b/src/Database/Connection/Adapters/SqliteAdapter.php
index b3997d70..3dbb5f47 100644
--- a/src/Database/Connection/Adapters/SqliteAdapter.php
+++ b/src/Database/Connection/Adapters/SqliteAdapter.php
@@ -18,38 +18,40 @@ class SqliteAdapter extends AbstractConnection
protected ?string $name = 'sqlite';
/**
- * SqliteAdapter constructor.
+ * Validate the connection configuration.
*
- * @param array $config
+ * @param array $config
+ * @return void
*/
- public function __construct(array $config)
+ protected function validateConfig(array $config): void
{
- $this->config = $config;
-
- $this->connection();
- }
-
- /**
- * @inheritDoc
- */
- public function connection(): void
- {
- if (!isset($this->config['driver'])) {
+ if (!isset($config['driver'])) {
throw new InvalidArgumentException("Please select the right sqlite driver");
}
- if (!isset($this->config['database'])) {
+ if (!isset($config['database'])) {
throw new InvalidArgumentException('The database is not defined');
}
+ }
+ /**
+ * Build a PDO instance from the given configuration.
+ *
+ * @param array $config
+ * @return PDO
+ */
+ protected function makePdo(array $config): PDO
+ {
// Build the PDO connection
- $this->pdo = new PDO('sqlite:' . $this->config['database']);
+ $pdo = new PDO('sqlite:' . $config['database']);
- $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
- $this->pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING);
- $this->pdo->setAttribute(
+ $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+ $pdo->setAttribute(PDO::ATTR_ORACLE_NULLS, PDO::NULL_EMPTY_STRING);
+ $pdo->setAttribute(
PDO::ATTR_DEFAULT_FETCH_MODE,
- $this->config['fetch'] ?? $this->fetch
+ $config['fetch'] ?? $this->fetch
);
+
+ return $pdo;
}
}
diff --git a/src/Database/Database.php b/src/Database/Database.php
index 22c9d254..d68d9c1f 100644
--- a/src/Database/Database.php
+++ b/src/Database/Database.php
@@ -102,11 +102,41 @@ public static function connection(?string $name = null): ?Database
static::$adapter->setFetchMode(static::$config['fetch']);
}
- if (static::$adapter->getConnection() instanceof PDO && $name == static::$name) {
- return static::getInstance();
+ return static::getInstance();
+ }
+
+ /**
+ * Resolve the write (primary) connection.
+ *
+ * @return PDO
+ */
+ private static function writeConnection(): PDO
+ {
+ static::ensureDatabaseConnection();
+
+ return static::$adapter->getWriteConnection();
+ }
+
+ /**
+ * Resolve the read (replica) connection.
+ *
+ * While a transaction is open on the primary, reads are routed to the
+ * primary so they observe their own uncommitted changes.
+ *
+ * @return PDO
+ */
+ private static function readConnection(): PDO
+ {
+ static::ensureDatabaseConnection();
+
+ if (
+ static::$adapter->hasWriteConnection()
+ && static::$adapter->getWriteConnection()->inTransaction()
+ ) {
+ return static::$adapter->getWriteConnection();
}
- return static::getInstance();
+ return static::$adapter->getReadConnection();
}
/**
@@ -182,8 +212,7 @@ public static function update(string $sql_statement, array $data = []): int
*/
private static function executePrepareQuery(string $sql_statement, array $data = []): int
{
- $pdo_statement = static::$adapter
- ->getConnection()
+ $pdo_statement = static::writeConnection()
->prepare($sql_statement);
static::$adapter->bind(
@@ -191,9 +220,11 @@ private static function executePrepareQuery(string $sql_statement, array $data =
Sanitize::make($data, true)
);
+ $start_at = microtime(true);
$pdo_statement->execute();
+ $ended_at = microtime(true);
- static::triggerQueryEvent($sql_statement, $data);
+ static::triggerQueryEvent($sql_statement, $ended_at - $start_at, $data);
return $pdo_statement->rowCount();
}
@@ -209,20 +240,14 @@ 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
);
}
- $pdo_statement = static::$adapter
- ->getConnection()
+ $pdo_statement = static::readConnection()
->prepare($sql_statement);
static::$adapter->bind(
@@ -246,16 +271,15 @@ 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
);
}
// Prepare query
- $pdo_statement = static::$adapter
- ->getConnection()
+ $pdo_statement = static::readConnection()
->prepare($sql_statement);
// Bind data
@@ -278,20 +302,15 @@ 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
);
}
if (empty($data)) {
- $pdo_statement = static::$adapter->getConnection()->prepare($sql_statement);
+ $pdo_statement = static::writeConnection()->prepare($sql_statement);
$pdo_statement->execute();
@@ -328,8 +347,7 @@ public static function statement(string $sql_statement): bool
$sql_statement = trim($sql_statement);
- return static::$adapter
- ->getConnection()
+ return static::writeConnection()
->exec($sql_statement) === 0;
}
@@ -344,7 +362,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
@@ -368,7 +386,7 @@ public static function table(string $table): QueryBuilder
return new QueryBuilder(
$table,
- static::$adapter->getConnection()
+ static::$adapter
);
}
@@ -404,8 +422,8 @@ public static function startTransaction(): void
{
static::ensureDatabaseConnection();
- if (!static::$adapter->getConnection()->inTransaction()) {
- static::$adapter->getConnection()->beginTransaction();
+ if (!static::writeConnection()->inTransaction()) {
+ static::writeConnection()->beginTransaction();
}
}
@@ -418,16 +436,24 @@ public static function inTransaction(): bool
{
static::ensureDatabaseConnection();
- return static::$adapter->getConnection()->inTransaction();
+ return static::writeConnection()->inTransaction();
}
/**
* 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();
+ static::writeConnection()->commit();
}
}
@@ -435,9 +461,17 @@ 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();
+ static::writeConnection()->rollBack();
}
}
@@ -452,10 +486,10 @@ public static function lastInsertId(?string $name = null): int|string|PDO
static::ensureDatabaseConnection();
if ($name === null) {
- return static::$adapter->getConnection();
+ return static::writeConnection();
}
- return static::$adapter->getConnection()->lastInsertId($name);
+ return static::writeConnection()->lastInsertId($name);
}
/**
@@ -465,9 +499,7 @@ public static function lastInsertId(?string $name = null): int|string|PDO
*/
public static function getPdo(): PDO
{
- static::ensureDatabaseConnection();
-
- return static::$adapter->getConnection();
+ return static::writeConnection();
}
/**
@@ -484,12 +516,13 @@ public static function setPdo(PDO $pdo): void
* Trigger the query executed event
*
* @param string $sql
+ * @param float $execution_time
* @param array $bindings
* @return void
*/
- public static function triggerQueryEvent(string $sql, array $bindings = []): void
+ public static function triggerQueryEvent(string $sql, float $execution_time = 0, array $bindings = []): void
{
- $event = new QueryEvent($sql, $bindings);
+ $event = new QueryEvent($sql, $execution_time, $bindings);
app_event($event);
}
diff --git a/src/Database/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..347e685d 100644
--- a/src/Database/QueryBuilder.php
+++ b/src/Database/QueryBuilder.php
@@ -85,12 +85,23 @@ class QueryBuilder implements JsonSerializable
protected ?string $as = null;
/**
- * The PDO instance
+ * The PDO instance.
+ *
+ * Only set when the builder is constructed from a raw PDO (no read/write
+ * splitting). When built from an adapter, the connection is resolved
+ * lazily through {@see QueryBuilder::$connection_adapter}.
*
* @var ?PDO
*/
protected ?PDO $connection = null;
+ /**
+ * The connection adapter, when read/write splitting is available.
+ *
+ * @var ?AbstractConnection
+ */
+ protected ?AbstractConnection $connection_adapter = null;
+
/**
* Define whether to retrieve information from the list
*
@@ -112,6 +123,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
*
@@ -121,16 +153,55 @@ class QueryBuilder implements JsonSerializable
public function __construct(string $table, AbstractConnection|PDO $connection)
{
if ($connection instanceof AbstractConnection) {
+ $this->connection_adapter = $connection;
$this->adapter = $connection->getName();
- $connection = $connection->getConnection();
} else {
+ $this->connection = $connection;
$this->adapter = $connection->getAttribute(PDO::ATTR_DRIVER_NAME);
}
- $this->connection = $connection;
$this->table = $table;
}
+ /**
+ * Resolve the write (primary) connection.
+ *
+ * @return PDO
+ */
+ private function writeConnection(): PDO
+ {
+ if ($this->connection_adapter !== null) {
+ return $this->connection_adapter->getWriteConnection();
+ }
+
+ return $this->connection;
+ }
+
+ /**
+ * Resolve the read (replica) connection.
+ *
+ * While a transaction is open on the primary, reads are routed to the
+ * primary so they observe their own uncommitted changes. Falls back to
+ * the single connection when read/write splitting is unavailable.
+ *
+ * @return PDO
+ */
+ private function readConnection(): PDO
+ {
+ if ($this->connection_adapter === null) {
+ return $this->connection;
+ }
+
+ if (
+ $this->connection_adapter->hasWriteConnection()
+ && $this->connection_adapter->getWriteConnection()->inTransaction()
+ ) {
+ return $this->connection_adapter->getWriteConnection();
+ }
+
+ return $this->connection_adapter->getReadConnection();
+ }
+
/**
* Get the connection adapter name
*
@@ -148,7 +219,7 @@ public function getAdapterName(): string
*/
public function getPdo(): PDO
{
- return $this->connection;
+ return $this->writeConnection();
}
/**
@@ -170,9 +241,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 +252,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 +265,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 +276,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 +298,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 +332,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 +370,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 +467,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 +812,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 +842,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 +863,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 +980,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, false);
- $this->triggerQueryEvent($sql, $this->where_data_binding);
$this->where_data_binding = [];
if ($statement->rowCount() > 1) {
@@ -899,12 +1012,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 +1033,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 +1160,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 +1224,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, false);
$data = $statement->fetchAll();
$statement->closeCursor();
- $this->triggerQueryEvent($sql, $this->where_data_binding);
$this->where_data_binding = [];
if (!$this->first) {
@@ -1180,20 +1324,14 @@ public function update(array $data = []): int
$sql .= ' where ' . $this->where;
$this->where = null;
-
- $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding);
}
- $statement = $this->connection->prepare($sql);
+ $this->where_data_binding = array_merge(array_values($data), $this->where_data_binding);
- $this->bind($statement, $this->where_data_binding);
-
- // Execution of the request
- $statement->execute();
+ $statement = $this->execute($sql, $this->where_data_binding);
$result = $statement->rowCount();
- $this->triggerQueryEvent($sql, $this->where_data_binding);
$this->where_data_binding = [];
return (int) $result;
@@ -1230,15 +1368,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 +1390,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 +1420,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
*
@@ -1327,18 +1456,26 @@ public function distinct(string $column)
*/
public function truncate(): bool
{
- if ($this->connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
+ $connection = $this->writeConnection();
+
+ if ($connection->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
$sql = 'delete from ' . $this->table . ';';
- if (!$this->connection->inTransaction()) {
+ if (!$connection->inTransaction()) {
$sql .= ' VACUUM;';
}
} else {
$sql = 'truncate table ' . $this->table . ';';
}
- $result = (bool) $this->connection->exec($sql);
+ $this->last_query = $sql;
+
+ $start_at = microtime(true);
+ $result = (bool) $connection->exec($sql);
+ $ended_at = microtime(true);
+
+ $this->triggerQueryEvent($sql, $ended_at - $start_at);
- $this->triggerQueryEvent($sql, []);
+ $this->last_query = $sql;
return $result;
}
@@ -1353,7 +1490,7 @@ public function insertAndGetLastId(array $values): string|int|bool
{
$this->insert($values);
- $result = $this->connection->lastInsertId();
+ $result = $this->writeConnection()->lastInsertId();
return is_numeric($result) ? (int)$result : $result;
}
@@ -1387,7 +1524,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 +1556,44 @@ private function insertOne(array $values): int
$sql .= '(' . implode(', ', $this->add2points($fields, true)) . ');';
- $statement = $this->connection->prepare($sql);
+ $statement = $this->execute($sql, $values);
- $this->bind($statement, $values);
+ return (int) $statement->rowCount();
+ }
+
+ /**
+ * Execute statement
+ *
+ * @param string $sql
+ * @param array $bindings
+ * @param bool $write Whether the statement mutates data (routes to the primary).
+ * @return PDOStatement
+ */
+ private function execute(string $sql, array $bindings = [], bool $write = true): PDOStatement
+ {
+ $this->last_query = $sql;
- $statement->execute();
+ $connection = $write ? $this->writeConnection() : $this->readConnection();
- $this->triggerQueryEvent($sql, $values);
+ $statement = $connection->prepare($sql);
- return (int) $statement->rowCount();
+ $this->bind($statement, $bindings);
+
+ try {
+ $start_at = microtime(true);
+ $statement->execute();
+ $ended_at = microtime(true);
+
+ $this->triggerQueryEvent($sql, $ended_at - $start_at, $bindings);
+ } catch (\Exception $e) {
+ throw new QueryBuilderException(
+ 'Error executing query: ' . $e->getMessage() . ' | Query: ' . $this->last_query,
+ $this->last_query,
+ E_ERROR,
+ );
+ }
+
+ return $statement;
}
/**
@@ -1440,9 +1605,13 @@ public function drop(): bool
{
$sql = 'drop table ' . $this->table;
- $result = (bool) $this->connection->exec($sql);
+ $this->last_query = $sql;
+
+ $start_at = microtime(true);
+ $result = (bool) $this->writeConnection()->exec($sql);
+ $ended_at = microtime(true);
- $this->triggerQueryEvent($sql, []);
+ $this->triggerQueryEvent($sql, $ended_at - $start_at);
return $result;
}
@@ -1450,12 +1619,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 +1634,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 +1643,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 +1655,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 +1666,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,
);
}
@@ -1529,7 +1700,7 @@ public function exists(?string $column = null, mixed $value = null): bool
*/
public function getLastInsertId(?string $name = null)
{
- return $this->connection->lastInsertId($name);
+ return $this->writeConnection()->lastInsertId($name);
}
/**
@@ -1558,12 +1729,13 @@ public function setWhereDataBinding(array $data_binding): void
* Trigger the query event
*
* @param string $sql
+ * @param float $execution_time
* @param array $bindings
* @return void
*/
- private function triggerQueryEvent(string $sql, array $bindings): void
+ private function triggerQueryEvent(string $sql, float $execution_time = 0, array $bindings = []): void
{
- Database::triggerQueryEvent($sql, $bindings);
+ Database::triggerQueryEvent($sql, $execution_time, $bindings);
}
/**
diff --git a/src/Database/QueryEvent.php b/src/Database/QueryEvent.php
index 0edbae8c..d0e4dbf4 100644
--- a/src/Database/QueryEvent.php
+++ b/src/Database/QueryEvent.php
@@ -18,6 +18,13 @@ final class QueryEvent implements AppEvent
*/
public string $sql;
+ /**
+ * Define the query execution time
+ *
+ * @var mixed
+ */
+ public float $execution_time;
+
/**
* The query bindings
*
@@ -28,11 +35,14 @@ final class QueryEvent implements AppEvent
/**
* QueryEvent constructor.
*
- * @param array $data
+ * @param string $sql
+ * @param float $execution_time
+ * @param array $bindings
*/
- public function __construct(string $sql, array $bindings = [])
+ public function __construct(string $sql, float $execution_time = 0, array $bindings = [])
{
$this->sql = $sql;
+ $this->execution_time = $execution_time;
$this->bindings = $bindings;
}
@@ -51,7 +61,7 @@ public function getName(): string
* @param mixed $value
* @throws \Exception
*/
- public function __set($name, $value)
+ final public function __set($name, $value)
{
throw new \Exception("Cannot set property $name on QueryEvent");
}
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..2d6edc2b 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
*/
@@ -346,7 +350,6 @@ public function middleware(array|string $middlewares): Router
* @param string $path
* @param callable|string|array $cb
* @return Route
- * @throws
*/
public function any(string $path, callable|string|array $cb): Route
{
@@ -518,4 +521,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/Security/Crypto.php b/src/Security/Crypto.php
index 5d19f9ca..27a2dd25 100644
--- a/src/Security/Crypto.php
+++ b/src/Security/Crypto.php
@@ -4,7 +4,7 @@
namespace Bow\Security;
-use Bow\Support\Str;
+use RuntimeException;
class Crypto
{
@@ -22,6 +22,19 @@ class Crypto
*/
private static string $cipher = 'AES-256-CBC';
+ /**
+ * Header tagging the authenticated (random-IV + HMAC) payload format.
+ *
+ * The ':' is not part of the base64 alphabet, so a value carrying this
+ * prefix can never be confused with a legacy (base64-only) ciphertext.
+ */
+ private const HEADER = 'BOW2:';
+
+ /**
+ * The authentication tag length in bytes (HMAC-SHA256).
+ */
+ private const MAC_LENGTH = 32;
+
/**
* Set the key
*
@@ -38,22 +51,46 @@ public static function setKey(string $key, ?string $cipher = null): void
}
/**
- * Encrypt data
+ * Encrypt data.
+ *
+ * Produces an authenticated payload: a fresh random IV is used for every
+ * call (so identical plaintexts yield different ciphertexts) and an
+ * encrypt-then-MAC HMAC-SHA256 tag protects against tampering.
*
* @param string $data
- * @return string|bool
+ * @return string
*/
- public static function encrypt(string $data): string|bool
+ public static function encrypt(string $data): string
{
- $iv_size = openssl_cipher_iv_length(static::$cipher);
+ $key = static::resolveKey();
+
+ $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+ $iv = random_bytes($iv_size);
+
+ $cipher_text = openssl_encrypt(
+ $data,
+ static::$cipher,
+ static::deriveKey('enc', $key),
+ OPENSSL_RAW_DATA,
+ $iv
+ );
+
+ if ($cipher_text === false) {
+ throw new RuntimeException('Unable to encrypt the given data.');
+ }
- $iv = Str::slice(sha1(static::$key), 0, $iv_size);
+ $mac = hash_hmac('sha256', $iv . $cipher_text, static::deriveKey('auth', $key), true);
- return openssl_encrypt($data, static::$cipher, static::$key, 0, $iv);
+ return self::HEADER . base64_encode($iv . $mac . $cipher_text);
}
/**
- * decrypt
+ * Decrypt data.
+ *
+ * Authenticated payloads are verified before decryption and fail closed
+ * (return false) on a bad tag, truncation or wrong key. Values produced by
+ * the previous unauthenticated format are still readable for backward
+ * compatibility.
*
* @param string $data
*
@@ -61,10 +98,90 @@ public static function encrypt(string $data): string|bool
*/
public static function decrypt(string $data): string|bool
{
- $iv_size = openssl_cipher_iv_length(static::$cipher);
+ $key = static::resolveKey();
+
+ if (!str_starts_with($data, self::HEADER)) {
+ return static::decryptLegacy($data, $key);
+ }
+
+ $raw = base64_decode(substr($data, strlen(self::HEADER)), true);
+
+ if ($raw === false) {
+ return false;
+ }
+
+ $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+
+ if (strlen($raw) <= $iv_size + self::MAC_LENGTH) {
+ return false;
+ }
+
+ $iv = substr($raw, 0, $iv_size);
+ $mac = substr($raw, $iv_size, self::MAC_LENGTH);
+ $cipher_text = substr($raw, $iv_size + self::MAC_LENGTH);
+
+ $calculated = hash_hmac('sha256', $iv . $cipher_text, static::deriveKey('auth', $key), true);
+
+ // Reject tampered or wrong-key payloads before touching the cipher.
+ if (!hash_equals($calculated, $mac)) {
+ return false;
+ }
+
+ return openssl_decrypt(
+ $cipher_text,
+ static::$cipher,
+ static::deriveKey('enc', $key),
+ OPENSSL_RAW_DATA,
+ $iv
+ );
+ }
+
+ /**
+ * Decrypt a value produced by the legacy (static IV, unauthenticated)
+ * format. Kept only so data encrypted before the upgrade keeps working.
+ *
+ * @param string $data
+ * @param string $key
+ * @return string|bool
+ */
+ private static function decryptLegacy(string $data, string $key): string|bool
+ {
+ $iv_size = (int) openssl_cipher_iv_length(static::$cipher);
+
+ $iv = substr(sha1($key), 0, $iv_size);
+
+ return openssl_decrypt($data, static::$cipher, $key, 0, $iv);
+ }
- $iv = Str::slice(sha1(static::$key), 0, $iv_size);
+ /**
+ * Derive a purpose-specific 256-bit subkey from the configured key.
+ *
+ * Separating the encryption key from the authentication key (domain
+ * separation) is required for encrypt-then-MAC to be sound, and normalises
+ * an arbitrary-length configured key to a fixed strong key.
+ *
+ * @param string $context
+ * @param string $key
+ * @return string
+ */
+ private static function deriveKey(string $context, string $key): string
+ {
+ return hash_hmac('sha256', 'BowCrypto|v2|' . $context, $key, true);
+ }
+
+ /**
+ * Resolve the configured key or fail loudly when it is missing.
+ *
+ * @return string
+ */
+ private static function resolveKey(): string
+ {
+ if (static::$key === null || static::$key === '') {
+ throw new RuntimeException(
+ 'The application security key is not set. Define security.key before using Crypto.'
+ );
+ }
- return openssl_decrypt($data, static::$cipher, static::$key, 0, $iv);
+ return static::$key;
}
}
diff --git a/src/Security/Tokenize.php b/src/Security/Tokenize.php
index ca67d079..a7b9b873 100644
--- a/src/Security/Tokenize.php
+++ b/src/Security/Tokenize.php
@@ -94,7 +94,8 @@ public static function verify(string $token, bool $strict = false): bool
$csrf = Session::getInstance()->get('__bow.csrf');
- if ($token !== $csrf['token']) {
+ // Constant-time comparison to avoid leaking the token via timing.
+ if (!hash_equals((string) $csrf['token'], $token)) {
return false;
}
diff --git a/src/Session/Adapters/FilesystemAdapter.php b/src/Session/Adapters/FilesystemAdapter.php
index 0db41053..9860a0c4 100644
--- a/src/Session/Adapters/FilesystemAdapter.php
+++ b/src/Session/Adapters/FilesystemAdapter.php
@@ -90,7 +90,9 @@ public function gc(int $maxlifetime): int|false
public function open(string $path, string $name): bool
{
if (!is_dir($this->save_path)) {
- mkdir($this->save_path);
+ // 0700: session files contain authentication state and must not be
+ // readable by other users on a shared host.
+ mkdir($this->save_path, 0700, true);
}
return true;
diff --git a/src/Session/Cookie.php b/src/Session/Cookie.php
index 9b6ba773..ad46ead7 100644
--- a/src/Session/Cookie.php
+++ b/src/Session/Cookie.php
@@ -35,7 +35,21 @@ public static function isEmpty(): bool
public static function get(string $key, mixed $default = null): mixed
{
if (static::has($key)) {
- return Crypto::decrypt($_COOKIE[$key]);
+ $value = Crypto::decrypt($_COOKIE[$key]);
+
+ // Cookie::set() json-encodes the payload before encrypting, so decode
+ // here to mirror it (and Cookie::all()). Fall back to the raw value
+ // when it is not the JSON we wrote — e.g. a tampered cookie decrypts
+ // to false, or a cookie was set outside the framework.
+ if (is_string($value)) {
+ $decoded = json_decode($value, true);
+
+ if (json_last_error() === JSON_ERROR_NONE) {
+ return $decoded;
+ }
+ }
+
+ return $value;
}
if (is_callable($default)) {
@@ -95,7 +109,7 @@ public static function remove(string $key): string|bool|null
return null;
}
- if (!static::$is_decrypt[$key]) {
+ if (!(static::$is_decrypt[$key] ?? false)) {
$old = Crypto::decrypt($_COOKIE[$key]);
unset(static::$is_decrypt[$key]);
@@ -123,14 +137,30 @@ public static function set(
): bool {
$data = Crypto::encrypt(json_encode($data));
- return setcookie(
- $key,
- $data,
- time() + $expiration,
- config('session.path'),
- config('session.domain'),
- config('session.secure'),
- config('session.httponly')
- );
+ return setcookie($key, $data, static::options($expiration));
+ }
+
+ /**
+ * Build the setcookie() options array from the session config.
+ *
+ * Every value is coerced to its declared type. config('session.domain') is
+ * null when SESSION_DOMAIN is unset; passing null straight to setcookie()
+ * is deprecated on PHP 8.x and a fatal TypeError on PHP 9, so cast here.
+ *
+ * @param int $expiration
+ * @return array
+ */
+ private static function options(int $expiration): array
+ {
+ // config() with a second argument is a setter, not a getter-with-default,
+ // so read each value first and apply the fallback in PHP.
+ return [
+ 'expires' => time() + $expiration,
+ 'path' => (string) (config('session.path') ?? '/'),
+ 'domain' => (string) (config('session.domain') ?? ''),
+ 'secure' => (bool) config('session.secure'),
+ 'httponly' => (bool) (config('session.httponly') ?? true),
+ 'samesite' => (string) (config('session.samesite') ?? 'Lax'),
+ ];
}
}
diff --git a/src/Session/Session.php b/src/Session/Session.php
index c4d8eff5..19348927 100644
--- a/src/Session/Session.php
+++ b/src/Session/Session.php
@@ -6,7 +6,6 @@
use BadMethodCallException;
use Bow\Contracts\CollectionInterface;
-use Bow\Security\Crypto;
use Bow\Session\Adapters\ArrayAdapter;
use Bow\Session\Adapters\DatabaseAdapter;
use Bow\Session\Adapters\FilesystemAdapter;
@@ -75,7 +74,11 @@ private function __construct(array $config)
'path' => '/',
'domain' => null,
'secure' => false,
- 'httponly' => false,
+ // Keep the session cookie out of reach of JavaScript by default
+ // (mitigates session theft via XSS) and constrain cross-site
+ // sending (mitigates CSRF).
+ 'httponly' => true,
+ 'samesite' => 'Lax',
'save_path' => null,
],
$config
@@ -115,6 +118,14 @@ public static function getInstance(): ?Session
*/
public function regenerate(): void
{
+ $this->start();
+
+ // Rotate the underlying session ID and delete the previous record so a
+ // fixated/leaked ID can no longer be reused, then clear the values.
+ if (PHP_SESSION_ACTIVE === session_status()) {
+ session_regenerate_id(true);
+ }
+
$this->flush();
$this->start();
}
@@ -162,11 +173,17 @@ public function start(): bool
*/
private function initializeDriver(): void
{
+ // Reject uninitialized session IDs (anti session-fixation) and never
+ // accept the session ID from the URL/query string. Must be set before
+ // session_start().
+ @ini_set('session.use_strict_mode', '1');
+ @ini_set('session.use_only_cookies', '1');
+
// We Apply session cookie name
@session_name($this->config['name']);
if (!isset($_COOKIE[$this->config['name']])) {
- @session_id(hash("sha256", $this->generateId()));
+ @session_id($this->generateId());
}
// We create get driver
@@ -204,13 +221,17 @@ private function initializeDriver(): void
}
/**
- * Generate session ID
+ * Generate a cryptographically secure session ID.
+ *
+ * Uses the CSPRNG (random_bytes) instead of time-based primitives such as
+ * uniqid()/microtime(), which are predictable and would let an attacker
+ * guess or fixate session identifiers.
*
* @return string
*/
private function generateId(): string
{
- return Crypto::encrypt(uniqid(microtime()));
+ return bin2hex(random_bytes(32));
}
/**
@@ -223,14 +244,16 @@ private function setCookieParameters(): void
$domain = $this->config['domain'] ?? null;
$secure = (bool)$this->config["secure"];
$httponly = (bool)$this->config["httponly"];
-
- session_set_cookie_params(
- (int)$this->config["lifetime"],
- $this->config["path"],
- $domain,
- $secure,
- $httponly
- );
+ $samesite = $this->config["samesite"] ?? 'Lax';
+
+ session_set_cookie_params([
+ 'lifetime' => (int)$this->config["lifetime"],
+ 'path' => $this->config["path"],
+ 'domain' => $domain,
+ 'secure' => $secure,
+ 'httponly' => $httponly,
+ 'samesite' => $samesite,
+ ]);
}
/**
diff --git a/src/Support/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..7e8ea351 100644
--- a/src/Support/helpers.php
+++ b/src/Support/helpers.php
@@ -44,12 +44,25 @@
use Carbon\Carbon;
use Monolog\Logger;
+/*
+ * Global helper functions.
+ *
+ * Each helper is wrapped in `if (!function_exists(...))` so an application may
+ * override any of them by declaring its own version before this file loads.
+ * Most helpers are thin shortcuts over a framework class; the section banners
+ * below mark where each topic begins.
+ */
+
if (!function_exists('app')) {
/**
- * Application container
+ * Resolve the service container, or a binding out of it.
+ *
+ * With no arguments the container instance itself is returned; with a key
+ * the matching binding is resolved (using `$setting` as constructor
+ * parameters when provided).
*
- * @param ?string $key
- * @param array $setting
+ * @param ?string $key Binding name to resolve, or null for the container
+ * @param array $setting Parameters passed to makeWith() when resolving
* @return mixed
*/
function app(?string $key = null, array $setting = []): mixed
@@ -60,6 +73,7 @@ function app(?string $key = null, array $setting = []): mixed
return $capsule;
}
+ // No extra parameters: a plain resolution is enough.
if (empty($setting)) {
return $capsule->make($key);
}
@@ -70,12 +84,15 @@ function app(?string $key = null, array $setting = []): mixed
if (!function_exists('config')) {
/**
- * Application configuration
+ * Read or write application configuration.
+ *
+ * No key returns the configuration loader; a key alone reads the value;
+ * a key with a value writes (and returns) it.
*
- * @param ?string|null $key
- * @param mixed|null $setting
+ * @param string|null $key Dotted configuration key
+ * @param mixed $setting Value to set, or null to read
* @return Loader|mixed
- * @throws
+ * @throws Exception
*/
function config(?string $key = null, mixed $setting = null): mixed
{
@@ -95,7 +112,7 @@ function config(?string $key = null, mixed $setting = null): mixed
if (!function_exists('response')) {
/**
- * Response object instance
+ * Get the shared Response instance from the container.
*
* @return Response
*/
@@ -112,7 +129,7 @@ function response(): Response
if (!function_exists('request')) {
/**
- * Represents the Request class
+ * Get the shared Request instance from the container.
*
* @return Request
*/
@@ -129,10 +146,16 @@ function request(): Request
if (!function_exists('db')) {
/**
- * Allows to connect to another database and return the instance of the DB
+ * Get the database manager, optionally on another connection.
*
- * @param string|null $name
- * @param callable|null $cb
+ * With no arguments the current connection is returned. When `$cb` is
+ * given it runs against `$name`, then the previous connection is restored.
+ *
+ * Note: registered under the `db` guard but the function is named
+ * `app_db()`; call it as `app_db(...)`.
+ *
+ * @param string|null $name Connection name to switch to
+ * @param callable|null $cb Work to run on that connection, then revert
* @return DB
* @throws ConnectionException
*/
@@ -163,15 +186,19 @@ function app_db(?string $name = null, ?callable $cb = null): DB
if (!function_exists('view')) {
/**
- * View alias of View::parse
+ * Render a view template through View::parse().
+ *
+ * `$data` may be passed as the status code directly (e.g. `view('404', 404)`),
+ * in which case it is treated as `$code` and the data set is left empty.
*
- * @param string $template
- * @param array|int $data
- * @param int $code
+ * @param string $template View name
+ * @param array|int $data View data, or the HTTP status code
+ * @param int $code HTTP status code
* @return View
*/
function view(string $template, int|array $data = [], int $code = 200): View
{
+ // Allow the status code to be supplied in the $data slot.
if (is_int($data)) {
$code = $data;
@@ -187,13 +214,14 @@ function view(string $template, int|array $data = [], int $code = 200): View
if (!function_exists('table')) {
/**
- * Table alias of DB::table
+ * Get a query builder for a table (optionally on another connection).
*
- * @param string $name
- * @param ?string $connexion
- * @return Bow\Database\QueryBuilder
+ * @param string $name Table name
+ * @param ?string $connexion Connection to switch to first
+ * @return QueryBuilder
* @throws ConnectionException
- * @deprecated
+ * @deprecated Use app_db_table() instead.
+ * @see app_db_table()
*/
function table(string $name, ?string $connexion = null): QueryBuilder
{
@@ -207,12 +235,12 @@ function table(string $name, ?string $connexion = null): QueryBuilder
if (!function_exists('app_db_table')) {
/**
- * Table alias of DB::table
+ * Get a query builder for a table (optionally on another connection).
*
- * @param string $name
- * @param ?string $connexion
- * @return Bow\Database\QueryBuilder
- * @throws ConnectionException
+ * @param string $name Table name
+ * @param ?string $connexion Connection to switch to first
+ * @return QueryBuilder
+ * @throws ConnectionException
*/
function app_db_table(string $name, ?string $connexion = null): QueryBuilder
{
@@ -229,7 +257,7 @@ function app_db_table(string $name, ?string $connexion = null): QueryBuilder
* Returns the last ID following an INSERT query
* on a table whose ID is auto_increment.
*
- * @param string|null $name
+ * @param string|null $name Sequence/connection name, if required
* @return int
*/
function get_last_insert_id(?string $name = null): int
@@ -240,12 +268,12 @@ function get_last_insert_id(?string $name = null): int
if (!function_exists('app_db_select')) {
/**
- * Launches SELECT SQL Queries
+ * Run a raw SELECT query.
*
* app_db_select('SELECT * FROM users');
*
- * @param string $sql
- * @param array $data
+ * @param string $sql SQL statement, may contain bindings
+ * @param array $data Values bound to the statement
* @return int|array|stdClass
*/
function app_db_select(string $sql, array $data = []): array|int|stdClass
@@ -256,10 +284,10 @@ function app_db_select(string $sql, array $data = []): array|int|stdClass
if (!function_exists('app_db_select_one')) {
/**
- * Launches SELECT SQL Queries
+ * Run a raw SELECT query and return a single row.
*
- * @param string $sql
- * @param array $data
+ * @param string $sql SQL statement, may contain bindings
+ * @param array $data Values bound to the statement
* @return int|array|StdClass
*/
function app_db_select_one(string $sql, array $data = []): array|int|StdClass
@@ -270,11 +298,11 @@ function app_db_select_one(string $sql, array $data = []): array|int|StdClass
if (!function_exists('app_db_insert')) {
/**
- * Launches INSERT SQL Queries
+ * Run a raw INSERT query.
*
- * @param string $sql
- * @param array $data
- * @return int
+ * @param string $sql SQL statement, may contain bindings
+ * @param array $data Values bound to the statement
+ * @return int Number of affected rows
*/
function app_db_insert(string $sql, array $data = []): int
{
@@ -284,11 +312,11 @@ function app_db_insert(string $sql, array $data = []): int
if (!function_exists('app_db_delete')) {
/**
- * Launches DELETE type SQL queries
+ * Run a raw DELETE query.
*
- * @param string $sql
- * @param array $data
- * @return int
+ * @param string $sql SQL statement, may contain bindings
+ * @param array $data Values bound to the statement
+ * @return int Number of affected rows
*/
function app_db_delete(string $sql, array $data = []): int
{
@@ -298,11 +326,11 @@ function app_db_delete(string $sql, array $data = []): int
if (!function_exists('app_db_update')) {
/**
- * Launches UPDATE SQL Queries
+ * Run a raw UPDATE query.
*
- * @param string $sql
- * @param array $data
- * @return int
+ * @param string $sql SQL statement, may contain bindings
+ * @param array $data Values bound to the statement
+ * @return int Number of affected rows
*/
function app_db_update(string $sql, array $data = []): int
{
@@ -312,9 +340,9 @@ function app_db_update(string $sql, array $data = []): int
if (!function_exists('app_db_statement')) {
/**
- * Launches CREATE TABLE, ALTER TABLE, RENAME, DROP TABLE SQL Query
+ * Run a schema/DDL statement (CREATE, ALTER, RENAME, DROP, ...).
*
- * @param string $sql
+ * @param string $sql SQL statement
* @return int
*/
function app_db_statement(string $sql): int
@@ -325,9 +353,10 @@ function app_db_statement(string $sql): int
if (!function_exists('debug')) {
/**
- * debug, variable debug function
- * it allows you to have a color
- * Synthetic data types.
+ * Dump one or more variables with colourised, typed output.
+ *
+ * Accepts any number of arguments; each is sanitised then handed to
+ * Util::debug().
*
* @return void
*/
@@ -344,7 +373,7 @@ function ($x) {
if (!function_exists("sep")) {
/**
- * Get the PHP OS separator
+ * Get the OS-specific directory separator.
*
* @return string
*/
@@ -356,10 +385,10 @@ function sep(): string
if (!function_exists('create_csrf_token')) {
/**
- * Create a new token
+ * Create (or fetch) the current CSRF token payload.
*
- * @param int|null $time
- * @return ?array
+ * @param int|null $time Lifetime in seconds for the generated token
+ * @return ?array The token data (token, field, expire_at), or null
* @throws SessionException
*/
function create_csrf_token(?int $time = null): ?array
@@ -370,10 +399,10 @@ function create_csrf_token(?int $time = null): ?array
if (!function_exists('csrf_token')) {
/**
- * Get the generate token
+ * Get the current CSRF token string.
*
* @return string
- * @throws HttpException
+ * @throws HttpException When no token could be generated
* @throws SessionException
*/
function csrf_token(): string
@@ -393,10 +422,11 @@ function csrf_token(): string
if (!function_exists('csrf_field')) {
/**
- * Get the input csrf field
+ * Get the ready-made hidden CSRF input field.
*
* @return string
- * @throws HttpException|SessionException
+ * @throws HttpException When no token could be generated
+ * @throws SessionException
*/
function csrf_field(): string
{
@@ -415,9 +445,9 @@ function csrf_field(): string
if (!function_exists('method_field')) {
/**
- * Create hidden http method field
+ * Build a hidden input that spoofs the HTTP method (PUT, PATCH, DELETE).
*
- * @param string $method
+ * @param string $method HTTP verb to spoof
* @return string
*/
function method_field(string $method): string
@@ -430,7 +460,7 @@ function method_field(string $method): string
if (!function_exists('gen_csrf_token')) {
/**
- * Generate token string
+ * Generate a fresh, standalone token string (not stored in the session).
*
* @return string
*/
@@ -442,10 +472,10 @@ function gen_csrf_token(): string
if (!function_exists('verify_csrf')) {
/**
- * Check the token value
+ * Verify a submitted CSRF token against the stored one.
*
- * @param string $token
- * @param bool $strict
+ * @param string $token Token received from the request
+ * @param bool $strict Also enforce token expiry when true
* @return bool
* @throws SessionException
*/
@@ -457,9 +487,9 @@ function verify_csrf(string $token, bool $strict = false): bool
if (!function_exists('csrf_time_is_expired')) {
/**
- * Check if token is expired by time
+ * Check whether the stored CSRF token has expired.
*
- * @param string|null $time
+ * @param string|null $time Reference time, defaults to now
* @return bool
* @throws SessionException
*/
@@ -471,11 +501,11 @@ function csrf_time_is_expired(?string $time = null): bool
if (!function_exists('response_json')) {
/**
- * Make json response
+ * Send a JSON response.
*
- * @param array|object $data
- * @param int $code
- * @param array $headers
+ * @param array|object $data Payload to encode
+ * @param int $code HTTP status code
+ * @param array $headers Extra response headers
* @return string
*/
function response_json(array|object $data, int $code = 200, array $headers = []): string
@@ -486,11 +516,11 @@ function response_json(array|object $data, int $code = 200, array $headers = [])
if (!function_exists('response_download')) {
/**
- * Download file
+ * Send a file as a download response.
*
- * @param string $file
- * @param null|string $filename
- * @param array $headers
+ * @param string $file Path to the file on disk
+ * @param null|string $filename Name presented to the client
+ * @param array $headers Extra response headers
* @return string
*/
function response_download(string $file, ?string $filename = null, array $headers = []): string
@@ -501,7 +531,7 @@ function response_download(string $file, ?string $filename = null, array $header
if (!function_exists('set_response_status_code')) {
/**
- * Set status code
+ * Set the HTTP response status code.
*
* @param int $code
* @return mixed
@@ -514,7 +544,7 @@ function set_response_status_code(int $code): mixed
if (!function_exists('sanitize')) {
/**
- * Sanitize data
+ * Sanitize a value (numeric values are returned untouched).
*
* @param mixed $data
* @return mixed
@@ -531,7 +561,7 @@ function sanitize(mixed $data): mixed
if (!function_exists('secure')) {
/**
- * Secure data with sanitize it
+ * Sanitize a value in strict/secure mode (numeric values pass through).
*
* @param mixed $data
* @return mixed
@@ -548,7 +578,7 @@ function secure(mixed $data): mixed
if (!function_exists('set_response_header')) {
/**
- * Update http headers
+ * Add a header to the outgoing response.
*
* @param string $key
* @param string $value
@@ -562,7 +592,7 @@ function set_response_header(string $key, string $value): void
if (!function_exists('get_response_header')) {
/**
- * Get http header
+ * Read a header from the incoming request.
*
* @param string $key
* @return string|null
@@ -575,9 +605,9 @@ function get_response_header(string $key): ?string
if (!function_exists('redirect')) {
/**
- * Make redirect response
+ * Get the redirector, optionally redirecting straight to a path.
*
- * @param string|null $path
+ * @param string|null $path Target to redirect to, or null for the instance
* @return Redirect
*/
function redirect(?string $path = null): Redirect
@@ -594,16 +624,20 @@ function redirect(?string $path = null): Redirect
if (!function_exists('url')) {
/**
- * Build url
+ * Build an absolute URL from the current request base.
+ *
+ * Passing an array as the first argument is treated as the query string
+ * parameters (the path is then the current URL).
*
- * @param string|array|null $url
- * @param array $parameters
+ * @param string|array $url Path to append, or query parameters
+ * @param array $parameters Query string parameters
* @return string
*/
function url(string|array $url = '', array $parameters = []): string
{
$current = trim(request()->url(), '/');
+ // First argument given as parameters: keep the current path.
if (is_array($url)) {
$parameters = $url;
@@ -624,7 +658,7 @@ function url(string|array $url = '', array $parameters = []): string
if (!function_exists('pdo')) {
/**
- * Get database PDO instance
+ * Get the underlying PDO instance.
*
* @return PDO
*/
@@ -636,10 +670,10 @@ function pdo(): PDO
if (!function_exists('set_pdo')) {
/**
- * Set PDO instance
+ * Replace the underlying PDO instance.
*
* @param PDO $pdo
- * @return PDO
+ * @return PDO The newly set instance
*/
function set_pdo(PDO $pdo): PDO
{
@@ -650,9 +684,8 @@ function set_pdo(PDO $pdo): PDO
}
if (!function_exists('collect')) {
-
/**
- * Create new Collection instance
+ * Wrap an array in a Collection.
*
* @param array $data
* @return Collection
@@ -665,7 +698,10 @@ function collect(array $data = []): Collection
if (!function_exists('encrypt')) {
/**
- * Encrypt data
+ * Encrypt data using the application security key.
+ *
+ * Returns an authenticated payload (random IV + HMAC), so encrypting the
+ * same value twice yields different ciphertexts.
*
* @param string $data
* @return string
@@ -678,20 +714,25 @@ function encrypt(string $data): string
if (!function_exists('decrypt')) {
/**
- * Decrypt data
+ * Decrypt a value previously produced by encrypt().
+ *
+ * Fails closed: returns false when the payload has been tampered with or
+ * was encrypted with a different key.
*
* @param string $data
- * @return string
+ * @return string|bool
*/
- function decrypt(string $data): string
+ function decrypt(string $data): string|bool
{
return Crypto::decrypt($data);
}
}
+// ===== Database: transactions =====
+
if (!function_exists('app_db_transaction')) {
/**
- * Start Database transaction
+ * Begin a database transaction.
*
* @return void
*/
@@ -703,7 +744,7 @@ function app_db_transaction(): void
if (!function_exists('app_db_transaction_started')) {
/**
- * Check if database transaction
+ * Check whether a database transaction is currently open.
*
* @return bool
*/
@@ -715,7 +756,7 @@ function app_db_transaction_started(): bool
if (!function_exists('app_db_rollback')) {
/**
- * Stop database transaction
+ * Roll back the current database transaction.
*
* @return void
*/
@@ -727,7 +768,7 @@ function app_db_rollback(): void
if (!function_exists('app_db_commit')) {
/**
- * Commit request after transaction
+ * Commit the current database transaction.
*
* @return void
*/
@@ -739,8 +780,12 @@ function app_db_commit(): void
if (!function_exists('event')) {
/**
- * Event
+ * Get the event dispatcher, or emit an event.
*
+ * Called with no arguments it returns the dispatcher; otherwise the first
+ * argument is the event name and the rest are passed to its listeners.
+ *
+ * @param mixed ...$args Event name followed by its payload
* @return mixed
*/
function event(): mixed
@@ -759,9 +804,11 @@ function event(): mixed
if (!function_exists('app_event')) {
/**
- * Event
+ * Get the event dispatcher, or emit an event.
*
+ * @param mixed ...$args Event name followed by its payload
* @return mixed
+ * @see event() Identical behaviour; event() is the preferred name.
*/
function app_event(): mixed
{
@@ -779,10 +826,10 @@ function app_event(): mixed
if (!function_exists('flash')) {
/**
- * Flash session
+ * Store a one-request flash message in the session.
*
- * @param string $key
- * @param string $message
+ * @param string $key Flash key
+ * @param string $message Message to store
* @return mixed
* @throws SessionException
*/
@@ -795,12 +842,13 @@ function flash(string $key, string $message): mixed
if (!function_exists('app_flash')) {
/**
- * Flash session
+ * Store a one-request flash message in the session.
*
- * @param string $key
- * @param string $message
+ * @param string $key Flash key
+ * @param string $message Message to store
* @return mixed
* @throws SessionException
+ * @see flash() Identical behaviour; flash() is the preferred name.
*/
function app_flash(string $key, string $message): mixed
{
@@ -811,11 +859,14 @@ function app_flash(string $key, string $message): mixed
if (!function_exists('email')) {
/**
- * Send email
+ * Send an email, or get the mailer instance.
+ *
+ * With no view the mailer instance is returned; otherwise the view is
+ * rendered and sent.
*
- * @param null|string $view
- * @param array $data
- * @param callable|null $cb
+ * @param null|string $view View name for the message body
+ * @param array $data Data bound to the view
+ * @param callable|null $cb Builder callback to configure the message
* @return MailAdapterInterface|bool
*/
function email(
@@ -833,12 +884,13 @@ function email(
if (!function_exists('app_email')) {
/**
- * Send email
+ * Send an email, or get the mailer instance.
*
- * @param null|string $view
- * @param array $data
- * @param callable|null $cb
+ * @param null|string $view View name for the message body
+ * @param array $data Data bound to the view
+ * @param callable|null $cb Builder callback to configure the message
* @return MailAdapterInterface|bool
+ * @see email() Identical behaviour; email() is the preferred name.
*/
function app_email(
?string $view = null,
@@ -855,12 +907,12 @@ function app_email(
if (!function_exists('raw_email')) {
/**
- * Send raw email
+ * Send a plain (non-templated) email.
*
- * @param string $to
- * @param string $subject
- * @param string $message
- * @param array $headers
+ * @param string $to Recipient address
+ * @param string $subject Subject line
+ * @param string $message Message body
+ * @param array $headers Extra mail headers
* @return bool
*/
function raw_email(string $to, string $subject, string $message, array $headers = []): bool
@@ -871,10 +923,10 @@ function raw_email(string $to, string $subject, string $message, array $headers
if (!function_exists('session')) {
/**
- * Session help
+ * Get the session manager, or read a session value.
*
- * @param array|string|null $value
- * @param mixed $default
+ * @param string|null $key Key to read, or null for the manager
+ * @param mixed $default Value returned when the key is absent
* @return mixed
* @throws SessionException
*/
@@ -890,11 +942,14 @@ function session(?string $key = null, mixed $default = null): mixed
if (!function_exists('cookie')) {
/**
- * Cooke alias
+ * Read or write cookies.
+ *
+ * No key returns all cookies; a key alone reads one; a key with data
+ * writes it.
*
- * @param string|null $key
- * @param mixed $data
- * @param int $expiration
+ * @param string|null $key Cookie name
+ * @param mixed $data Value to write, or null to read
+ * @param int $expiration Lifetime in seconds when writing
* @return string|array|object|null
*/
function cookie(
@@ -916,11 +971,11 @@ function cookie(
if (!function_exists('validator')) {
/**
- * Validate the information on the well-defined criterion
+ * Validate input against a set of rules.
*
- * @param array $inputs
- * @param array $rules
- * @param array $messages
+ * @param array $inputs Data to validate
+ * @param array $rules Validation rules keyed by field
+ * @param array $messages Custom error messages
* @return Validate
*/
function validator(array $inputs, array $rules, array $messages = []): Validate
@@ -931,15 +986,21 @@ function validator(array $inputs, array $rules, array $messages = []): Validate
if (!function_exists('route')) {
/**
- * Get Route by name
+ * Build a URL for a named route.
*
- * @param string $name
- * @param bool|array $data
- * @param bool $absolute
+ * Named placeholders in the route are filled from `$data`; leftover
+ * entries become the query string. Passing a bool as `$data` is treated
+ * as the `$absolute` flag.
+ *
+ * @param string $name Route name
+ * @param bool|array $data Placeholder values, or the absolute flag
+ * @param bool $absolute Prefix with APP_URL when true
* @return string
+ * @throws InvalidArgumentException When the route or a placeholder is missing
*/
function route(string $name, bool|array $data = [], bool $absolute = false): string
{
+ // Allow route('name', true) to mean "absolute, no parameters".
if (is_bool($data)) {
$absolute = $data;
$data = [];
@@ -954,6 +1015,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
);
}
+ // Substitute :placeholders (optional ones end with "?").
if (preg_match_all('/:([a-zA-Z0-9_]+\??)/', $url, $matches)) {
$keys = end($matches);
foreach ($keys as $key) {
@@ -973,6 +1035,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
}
}
+ // Remaining data becomes the query string.
if (count($data) > 0) {
$url = $url . '?' . http_build_query($data);
}
@@ -989,7 +1052,7 @@ function route(string $name, bool|array $data = [], bool $absolute = false): str
if (!function_exists('e')) {
/**
- * Escape the HTML tags in the chain.
+ * Escape HTML special characters in a string.
*
* @param ?string $value
* @return string
@@ -1002,9 +1065,9 @@ function e(?string $value = null): string
if (!function_exists('storage_service')) {
/**
- * Service loader
+ * Resolve a remote storage service (FTP, S3, ...).
*
- * @param string $service
+ * @param string $service Service name
* @return FTPService|S3Service
* @throws ServiceConfigurationNotFoundException
* @throws ServiceNotFoundException
@@ -1017,9 +1080,9 @@ function storage_service(string $service): S3Service|FTPService
if (!function_exists('app_storage')) {
/**
- * Alias on the mount method
+ * Get a local filesystem disk.
*
- * @param string $disk
+ * @param string $disk Disk name
* @return DiskFilesystemService
* @throws DiskNotFoundException
*/
@@ -1031,11 +1094,14 @@ function app_storage(string $disk): DiskFilesystemService
if (!function_exists('cache')) {
/**
- * Cache help
+ * Get the cache instance, or read/write a cache entry.
+ *
+ * No key returns the cache instance; a key alone reads it; a key with a
+ * value stores it for `$ttl` seconds.
*
- * @param ?string $key
- * @param mixed $value
- * @param ?int $ttl
+ * @param ?string $key Cache key
+ * @param mixed $value Value to store, or null to read
+ * @param ?int $ttl Time-to-live in seconds when writing
* @return mixed
* @throws ErrorException
*/
@@ -1057,9 +1123,9 @@ function cache(?string $key = null, mixed $value = null, ?int $ttl = null): mixe
if (!function_exists('redirect_back')) {
/**
- * Make redirection to back
+ * Redirect to the previous page.
*
- * @param int $status
+ * @param int $status HTTP status code
* @return Redirect
*/
function redirect_back(int $status = 302): Redirect
@@ -1070,7 +1136,7 @@ function redirect_back(int $status = 302): Redirect
if (!function_exists('app_now')) {
/**
- * Get the current carbon
+ * Get the current time as a Carbon instance.
*
* @return Carbon
*/
@@ -1082,11 +1148,14 @@ function app_now(): Carbon
if (!function_exists('app_hash')) {
/**
- * Alias on the class Hash.
+ * Hash a value, or verify one against an existing hash.
*
- * @param string $data
- * @param mixed $hash_value
- * @return bool|string
+ * With `$hash_value` it checks the value against the hash; otherwise it
+ * returns a new hash.
+ *
+ * @param string $data Value to hash or verify
+ * @param string|null $hash_value Existing hash to verify against
+ * @return bool|string Boolean when verifying, string when hashing
*/
function app_hash(string $data, ?string $hash_value = null): bool|string
{
@@ -1100,12 +1169,13 @@ function app_hash(string $data, ?string $hash_value = null): bool|string
if (!function_exists('bow_hash')) {
/**
- * Alias on the class Hash.
+ * Hash a value, or verify one against an existing hash.
*
- * @param string $data
- * @param mixed $hash_value
+ * @param string $data Value to hash or verify
+ * @param string|null $hash_value Existing hash to verify against
* @return bool|string
- * @deprecated
+ * @deprecated Use app_hash() instead.
+ * @see app_hash()
*/
function bow_hash(string $data, ?string $hash_value = null): bool|string
{
@@ -1115,11 +1185,14 @@ function bow_hash(string $data, ?string $hash_value = null): bool|string
if (!function_exists('app_trans')) {
/**
- * Make translation
+ * Translate a key, or get the translator instance.
+ *
+ * No key returns the translator. Passing a bool as `$data` is treated as
+ * the `$choose` (pluralisation) flag.
*
- * @param string|null $key
- * @param array $data
- * @param bool $choose
+ * @param string|null $key Translation key
+ * @param array $data Replacement values
+ * @param bool $choose Pluralisation flag
* @return string|Translator
*/
function app_trans(
@@ -1142,12 +1215,13 @@ function app_trans(
if (!function_exists('t')) {
/**
- * Alias of trans
+ * Translate a key.
*
- * @param string $key
- * @param array $data
- * @param bool $choose
+ * @param string $key Translation key
+ * @param array $data Replacement values
+ * @param bool $choose Pluralisation flag
* @return string|Translator
+ * @see app_trans()
*/
function t(
string $key,
@@ -1160,12 +1234,13 @@ function t(
if (!function_exists('__')) {
/**
- * Alias of trans
+ * Translate a key.
*
- * @param string $key
- * @param array $data
- * @param bool $choose
+ * @param string $key Translation key
+ * @param array $data Replacement values
+ * @param bool $choose Pluralisation flag
* @return string|Translator
+ * @see app_trans()
*/
function __(
string $key,
@@ -1178,18 +1253,24 @@ function __(
if (!function_exists('app_env')) {
/**
- * Gets the app environment variable
+ * Read an environment variable.
*
- * @param string $key
- * @param mixed $default
+ * Returns `$default` when the environment has not been loaded yet.
+ *
+ * @param string $key Variable name
+ * @param mixed $default Fallback value
* @return ?string
*/
function app_env(string $key, mixed $default = null): ?string
{
- $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;
@@ -1198,9 +1279,9 @@ function app_env(string $key, mixed $default = null): ?string
if (!function_exists('app_assets')) {
/**
- * Gets the app assets
+ * Build a public URL for an asset under the asset prefix.
*
- * @param string $filename
+ * @param string $filename Asset path relative to the asset root
* @return string
*/
function app_assets(string $filename): string
@@ -1211,12 +1292,14 @@ function app_assets(string $filename): string
if (!function_exists('app_abort')) {
/**
- * Abort bow execution
+ * Abort the request with an HTTP error.
+ *
+ * Falls back to the standard status message when none is given.
*
- * @param int $code
- * @param string $message
+ * @param int $code HTTP status code
+ * @param string $message Error message
* @return Response
- * @throws HttpException
+ * @throws HttpException Always thrown to interrupt execution
*/
function app_abort(int $code = 500, string $message = ''): Response
{
@@ -1230,13 +1313,13 @@ function app_abort(int $code = 500, string $message = ''): Response
if (!function_exists('app_abort_if')) {
/**
- * Abort bow execution if condition is true
+ * Abort the request only when the given condition is true.
*
- * @param boolean $boolean
- * @param int $code
- * @param string $message
- * @return Response|null
- * @throws HttpException
+ * @param bool $boolean Condition that triggers the abort
+ * @param int $code HTTP status code
+ * @param string $message Error message
+ * @return Response|null Null when the condition is false
+ * @throws HttpException When the condition is true
*/
function app_abort_if(
bool $boolean,
@@ -1253,7 +1336,7 @@ function app_abort_if(
if (!function_exists('app_mode')) {
/**
- * Get app environment mode
+ * Get the current application environment (lower-cased APP_ENV).
*
* @return string
*/
@@ -1265,7 +1348,7 @@ function app_mode(): string
if (!function_exists('app_in_debug')) {
/**
- * Get app environment mode
+ * Determine whether debug mode (APP_DEBUG) is enabled.
*
* @return bool
*/
@@ -1277,7 +1360,7 @@ function app_in_debug(): bool
if (!function_exists('client_locale')) {
/**
- * Get client request language
+ * Get the client's preferred request language.
*
* @return ?string
*/
@@ -1289,10 +1372,10 @@ function client_locale(): ?string
if (!function_exists('old')) {
/**
- * Get old request value
+ * Get a value submitted on the previous request.
*
- * @param string $key
- * @param mixed $fullback
+ * @param string $key Input field name
+ * @param mixed $fullback Value returned when the field is absent
* @return mixed
*/
function old(string $key, mixed $fullback = null): mixed
@@ -1303,12 +1386,13 @@ function old(string $key, mixed $fullback = null): mixed
if (!function_exists('auth')) {
/**
- * Recovery of the guard
+ * Get the auth manager, or a specific guard.
*
- * @param string|null $guard
+ * @param string|null $guard Guard name, or null for the manager
* @return GuardContract
* @throws AuthenticationException
- * @deprecated
+ * @deprecated Use app_auth() instead.
+ * @see app_auth()
*/
function auth(?string $guard = null): GuardContract
{
@@ -1324,9 +1408,9 @@ function auth(?string $guard = null): GuardContract
if (!function_exists('app_auth')) {
/**
- * Recovery of the guard
+ * Get the auth manager, or a specific guard.
*
- * @param string|null $guard
+ * @param string|null $guard Guard name, or null for the manager
* @return GuardContract
* @throws AuthenticationException
*/
@@ -1344,7 +1428,7 @@ function app_auth(?string $guard = null): GuardContract
if (!function_exists('logger')) {
/**
- * Log error message
+ * Get the application logger.
*
* @return Logger
*/
@@ -1356,9 +1440,10 @@ function logger(): Logger
if (!function_exists('app_logger')) {
/**
- * Log error message
+ * Get the application logger.
*
* @return Logger
+ * @see logger() Identical behaviour; logger() is the preferred name.
*/
function app_logger(): Logger
{
@@ -1369,10 +1454,10 @@ function app_logger(): Logger
if (!function_exists('str_slug')) {
/**
- * Slugify
+ * Convert a string into a URL-friendly slug.
*
- * @param string $str
- * @param string $sep
+ * @param string $str String to slugify
+ * @param string $sep Word separator
* @return string
*/
function str_slug(string $str, string $sep = '-'): string
@@ -1383,7 +1468,7 @@ function str_slug(string $str, string $sep = '-'): string
if (!function_exists('str_is_mail')) {
/**
- * Check if the email is valid
+ * Check whether a string is a valid email address.
*
* @param string $email
* @return bool
@@ -1396,7 +1481,7 @@ function str_is_mail(string $email): bool
if (!function_exists('str_uuid')) {
/**
- * Get str uuid
+ * Generate a UUID string.
*
* @return string
*/
@@ -1408,11 +1493,11 @@ function str_uuid(): string
if (!function_exists('str_is_domain')) {
/**
- * Check if the string is domain
+ * Check whether a string is a valid domain name.
*
* @param string $domain
* @return bool
- * @throws
+ * @throws Exception
*/
function str_is_domain(string $domain): bool
{
@@ -1422,7 +1507,7 @@ function str_is_domain(string $domain): bool
if (!function_exists('str_is_slug')) {
/**
- * Check if string is slug
+ * Check whether a string is a valid slug.
*
* @param string $slug
* @return string
@@ -1435,11 +1520,11 @@ function str_is_slug(string $slug): string
if (!function_exists('str_is_alpha')) {
/**
- * Check if the string is alpha
+ * Check whether a string contains only alphabetic characters.
*
* @param string $string
* @return bool
- * @throws
+ * @throws Exception
*/
function str_is_alpha(string $string): bool
{
@@ -1449,7 +1534,7 @@ function str_is_alpha(string $string): bool
if (!function_exists('str_is_lower')) {
/**
- * Check if the string is lower
+ * Check whether a string is entirely lower-case.
*
* @param string $string
* @return bool
@@ -1462,7 +1547,7 @@ function str_is_lower(string $string): bool
if (!function_exists('str_is_upper')) {
/**
- * Check if the string is upper
+ * Check whether a string is entirely upper-case.
*
* @param string $string
* @return bool
@@ -1475,11 +1560,11 @@ function str_is_upper(string $string): bool
if (!function_exists('str_is_alpha_num')) {
/**
- * Check if string is alphanumeric
+ * Check whether a string is alphanumeric.
*
* @param string $slug
* @return bool
- * @throws
+ * @throws Exception
*/
function str_is_alpha_num(string $slug): bool
{
@@ -1489,7 +1574,7 @@ function str_is_alpha_num(string $slug): bool
if (!function_exists('str_shuffle_words')) {
/**
- * Shuffle words
+ * Randomly shuffle the words of a string.
*
* @param string $words
* @return string
@@ -1502,10 +1587,10 @@ function str_shuffle_words(string $words): string
if (!function_exists('str_wordily')) {
/**
- * Return the array contains the word of the passed string
+ * Split a string into an array of its words.
*
- * @param string $words
- * @param string $sep
+ * @param string $words String to split
+ * @param string $sep Separator to split on
* @return array
*/
function str_wordily(string $words, string $sep = ''): array
@@ -1516,7 +1601,7 @@ function str_wordily(string $words, string $sep = ''): array
if (!function_exists('str_plural')) {
/**
- * Transform text to str_plural
+ * Pluralise a word.
*
* @param string $slug
* @return string
@@ -1529,7 +1614,7 @@ function str_plural(string $slug): string
if (!function_exists('str_camel')) {
/**
- * Transform text to camel case
+ * Convert a string to camelCase.
*
* @param string $slug
* @return string
@@ -1542,7 +1627,7 @@ function str_camel(string $slug): string
if (!function_exists('str_snake')) {
/**
- * Transform text to snake case
+ * Convert a string to snake_case.
*
* @param string $slug
* @return string
@@ -1555,10 +1640,10 @@ function str_snake(string $slug): string
if (!function_exists('str_contains')) {
/**
- * Check if string contain another string
+ * Check whether a string contains another string.
*
- * @param string $search
- * @param string $string
+ * @param string $search Needle to look for
+ * @param string $string Haystack to search in
* @return bool
*/
function str_contains(string $search, string $string): bool
@@ -1569,7 +1654,7 @@ function str_contains(string $search, string $string): bool
if (!function_exists('str_capitalize')) {
/**
- * Capitalize
+ * Capitalise a string.
*
* @param string $slug
* @return string
@@ -1582,9 +1667,9 @@ function str_capitalize(string $slug): string
if (!function_exists('str_random')) {
/**
- * Random string
+ * Generate a random string.
*
- * @param string $string
+ * @param string $string Length or seed forwarded to Str::random()
* @return string
*/
function str_random(string $string): string
@@ -1595,7 +1680,7 @@ function str_random(string $string): string
if (!function_exists('str_force_in_utf8')) {
/**
- * Force output string to utf8
+ * Force string output to UTF-8 globally.
*
* @return void
*/
@@ -1607,7 +1692,7 @@ function str_force_in_utf8(): void
if (!function_exists('str_fix_utf8')) {
/**
- * Force output string to utf8
+ * Repair a malformed UTF-8 string.
*
* @param string $string
* @return string
@@ -1620,15 +1705,20 @@ function str_fix_utf8(string $string): string
if (!function_exists('app_db_seed')) {
/**
- * Make programmatic seeding
+ * Seed data programmatically.
*
- * @param string $name
- * @param array $data
- * @return int|array
- * @throws ErrorException
+ * When `$name` is a Model class, `$data` is inserted into its table.
+ * Otherwise `$name` is resolved to a seeder file whose returned map of
+ * table => rows is inserted (each row merged with `$data`).
+ *
+ * @param string $name Model class name or seeder file name
+ * @param array $data Rows to insert, or values merged into each row
+ * @return int|array Affected rows, or one result per seeded table
+ * @throws ErrorException When the seeder file cannot be found
*/
function app_db_seed(string $name, array $data = []): int|array
{
+ // A model class: insert straight into its table.
if (class_exists($name)) {
$instance = app($name);
@@ -1648,6 +1738,7 @@ function app_db_seed(string $name, array $data = []): int|array
$seeds = array_merge($seeds, []);
$collections = [];
+ // Each entry maps a table (or model class) to its rows.
foreach ($seeds as $table => $payload) {
if (class_exists($table)) {
$instance = app($table);
@@ -1667,6 +1758,9 @@ function app_db_seed(string $name, array $data = []): int|array
/**
* Determine if the given value is "blank".
*
+ * Null, an empty/whitespace string, and an empty Countable are blank;
+ * numbers and booleans never are.
+ *
* @param mixed $value
* @return bool
*/
@@ -1694,9 +1788,10 @@ function is_blank(mixed $value): bool
if (!function_exists("queue")) {
/**
- * Push the producer on queue
+ * Push a task onto the queue.
*
- * @param QueueTask $producer
+ * @param QueueTask $producer Task to enqueue
+ * @return void
*/
function queue(QueueTask $producer): void
{
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/Auth/RememberMeTest.php b/tests/Auth/RememberMeTest.php
new file mode 100644
index 00000000..eb0e296b
--- /dev/null
+++ b/tests/Auth/RememberMeTest.php
@@ -0,0 +1,250 @@
+insert([
+ 'name' => 'Franck',
+ 'password' => Hash::make('password'),
+ 'username' => 'papac',
+ ]);
+ }
+
+ public static function tearDownAfterClass(): void
+ {
+ Database::statement("DROP TABLE IF EXISTS users");
+ }
+
+ protected function setUp(): void
+ {
+ ob_start();
+ $_COOKIE = [];
+ Database::table('users')->where('username', 'papac')->update(['remember_token' => null]);
+ }
+
+ protected function tearDown(): void
+ {
+ ob_get_clean();
+ }
+
+ public function test_remember_token_name_defaults_to_remember_token()
+ {
+ $user = UserModelStub::first();
+ $this->assertSame('remember_token', $user->getRememberTokenName());
+ }
+
+ public function test_set_and_get_remember_token_persists()
+ {
+ $user = UserModelStub::first();
+ $this->assertNull($user->getRememberToken());
+
+ $user->setRememberToken('abc123');
+
+ $this->assertSame('abc123', $user->getRememberToken());
+ $fresh = UserModelStub::first();
+ $this->assertSame('abc123', $fresh->getRememberToken());
+ }
+
+ public function test_remember_token_can_be_cleared_to_null()
+ {
+ $user = UserModelStub::first();
+ $user->setRememberToken('to-be-cleared');
+ $this->assertSame('to-be-cleared', UserModelStub::first()->getRememberToken());
+
+ $user->setRememberToken(null);
+
+ $this->assertNull($user->getRememberToken());
+ $this->assertNull(UserModelStub::first()->getRememberToken());
+ }
+
+ public function test_get_user_by_id_returns_the_user()
+ {
+ $auth = Auth::guard('web');
+ $expected = UserModelStub::first();
+
+ $method = new \ReflectionMethod($auth, 'getUserById');
+ $method->setAccessible(true);
+ $user = $method->invoke($auth, $expected->getAuthenticateUserId());
+
+ $this->assertNotNull($user);
+ $this->assertSame(
+ $expected->getAuthenticateUserId(),
+ $user->getAuthenticateUserId()
+ );
+ }
+
+ public function test_get_user_by_id_returns_null_for_unknown_id()
+ {
+ $auth = Auth::guard('web');
+
+ $method = new \ReflectionMethod($auth, 'getUserById');
+ $method->setAccessible(true);
+
+ $this->assertNull($method->invoke($auth, 999999));
+ }
+
+ public function test_jwt_guard_accepts_but_ignores_remember_flag()
+ {
+ $auth = Auth::guard('api');
+
+ $result = $auth->attempts([
+ 'username' => 'papac',
+ 'password' => 'password',
+ ], true);
+
+ $this->assertTrue($result);
+ // The JWT guard must not set any cookie when remember=true.
+ $this->assertEmpty($_COOKIE);
+ }
+
+ private function rememberCookieValue(int $id, string $token): string
+ {
+ // Mirror Cookie::set()'s encoding (json_encode then Crypto::encrypt) so
+ // tests exercise the same payload shape attemptRememberLogin() will read.
+ return Crypto::encrypt(json_encode($id . '|' . $token));
+ }
+
+ public function test_attempts_with_remember_persists_token()
+ {
+ $auth = Auth::guard('web');
+
+ $result = $auth->attempts([
+ 'username' => 'papac',
+ 'password' => 'password',
+ ], true);
+
+ $this->assertTrue($result);
+ $this->assertNotNull(UserModelStub::first()->getRememberToken());
+ }
+
+ public function test_attempts_without_remember_leaves_token_null()
+ {
+ $auth = Auth::guard('web');
+
+ $auth->attempts([
+ 'username' => 'papac',
+ 'password' => 'password',
+ ], false);
+
+ $this->assertNull(UserModelStub::first()->getRememberToken());
+ }
+
+ public function test_check_restores_session_from_valid_remember_cookie()
+ {
+ $auth = Auth::guard('web');
+ $user = UserModelStub::first();
+ $user->setRememberToken('valid-token-123');
+
+ Session::getInstance()->remove('_auth_web');
+ $_COOKIE['remember_web'] = $this->rememberCookieValue(
+ (int) $user->getAuthenticateUserId(),
+ 'valid-token-123'
+ );
+
+ $this->assertTrue($auth->check());
+ $this->assertSame('papac', $auth->user()->username);
+ }
+
+ public function test_check_rejects_tampered_token_and_clears_cookie()
+ {
+ $auth = Auth::guard('web');
+ $user = UserModelStub::first();
+ $user->setRememberToken('the-real-token');
+
+ Session::getInstance()->remove('_auth_web');
+ $_COOKIE['remember_web'] = $this->rememberCookieValue(
+ (int) $user->getAuthenticateUserId(),
+ 'WRONG-token'
+ );
+
+ $this->assertFalse($auth->check());
+ $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+ }
+
+ public function test_check_rejects_unknown_user_and_clears_cookie()
+ {
+ $auth = Auth::guard('web');
+
+ Session::getInstance()->remove('_auth_web');
+ $_COOKIE['remember_web'] = $this->rememberCookieValue(999999, 'whatever');
+
+ $this->assertFalse($auth->check());
+ $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+ }
+
+ public function test_check_rejects_malformed_cookie_without_delimiter()
+ {
+ $auth = Auth::guard('web');
+
+ Session::getInstance()->remove('_auth_web');
+ // A well-encrypted cookie whose payload has no "|" delimiter.
+ $_COOKIE['remember_web'] = Crypto::encrypt(json_encode('garbage-no-pipe'));
+
+ $this->assertFalse($auth->check());
+ $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+ }
+
+ public function test_logout_regenerates_token_and_removes_cookie()
+ {
+ $auth = Auth::guard('web');
+ $user = UserModelStub::first();
+ $user->setRememberToken('token-before');
+
+ Session::getInstance()->remove('_auth_web');
+ $_COOKIE['remember_web'] = $this->rememberCookieValue(
+ (int) $user->getAuthenticateUserId(),
+ 'token-before'
+ );
+ $this->assertTrue($auth->check());
+
+ $this->assertTrue($auth->logout());
+
+ $this->assertArrayNotHasKey('remember_web', $_COOKIE);
+ $this->assertNotSame('token-before', UserModelStub::first()->getRememberToken());
+ }
+}
diff --git a/tests/Config/stubs/config/storage.php b/tests/Config/stubs/config/storage.php
index 8108745a..e82d3836 100644
--- a/tests/Config/stubs/config/storage.php
+++ b/tests/Config/stubs/config/storage.php
@@ -39,14 +39,17 @@
's3' => [
"driver" => "s3",
'credentials' => [
- 'key' => getenv('AWS_KEY'),
- 'secret' => getenv('AWS_SECRET'),
+ // `?:` so an unset env (which CI exposes as an empty string)
+ // falls back to the local docker-compose MinIO defaults.
+ 'key' => app_env('AWS_KEY') ?: 'minioadmin',
+ 'secret' => app_env('AWS_SECRET') ?: 'minioadmin',
],
- 'bucket' => getenv('AWS_S3_BUCKET', 'tests'),
- 'region' => getenv('AWS_REGION', 'us-east-1'),
+ 'bucket' => app_env('AWS_S3_BUCKET') ?: 'tests',
+ 'region' => app_env('AWS_REGION') ?: 'us-east-1',
'version' => 'latest',
- // MinIO configuration (optional)
- 'endpoint' => getenv('AWS_ENDPOINT', false), // e.g., 'http://localhost:9000' for MinIO
+ // MinIO configuration. Defaults target the local docker-compose
+ // MinIO service; override via env for a real AWS S3 endpoint.
+ 'endpoint' => app_env('AWS_ENDPOINT') ?: 'http://127.0.0.1:9000',
'use_path_style_endpoint' => true, // Set to true for MinIO
]
],
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/ReadWriteConnectionTest.php b/tests/Database/Query/ReadWriteConnectionTest.php
new file mode 100644
index 00000000..7f828279
--- /dev/null
+++ b/tests/Database/Query/ReadWriteConnectionTest.php
@@ -0,0 +1,168 @@
+ 'sqlite',
+ 'database' => self::$write_db,
+ 'read' => [
+ 'database' => self::$read_db,
+ ],
+ ]);
+
+ $this->seed($adapter->getWriteConnection(), 'primary');
+ $this->seed($adapter->getReadConnection(), 'replica');
+
+ return $adapter;
+ }
+
+ private function seed(PDO $pdo, string $marker): void
+ {
+ $pdo->exec('DROP TABLE IF EXISTS pets');
+ $pdo->exec('CREATE TABLE pets (id INTEGER PRIMARY KEY, name VARCHAR(255))');
+ $pdo->exec("INSERT INTO pets (id, name) VALUES (1, '" . $marker . "')");
+ }
+
+ public function test_split_config_builds_distinct_connections(): void
+ {
+ $adapter = $this->makeSplitAdapter();
+
+ $this->assertNotSame(
+ $adapter->getWriteConnection(),
+ $adapter->getReadConnection(),
+ 'A split connection must expose two distinct PDO instances.'
+ );
+ }
+
+ public function test_connections_are_opened_lazily(): void
+ {
+ $adapter = new SqliteAdapter([
+ 'driver' => 'sqlite',
+ 'database' => self::$write_db,
+ 'read' => ['database' => self::$read_db],
+ ]);
+
+ $this->assertFalse(
+ $adapter->hasWriteConnection(),
+ 'No PDO should be opened until the connection is first used.'
+ );
+
+ // Touching only the read side must not open the write connection.
+ $adapter->getReadConnection();
+ $this->assertFalse(
+ $adapter->hasWriteConnection(),
+ 'A read-only access must not open the primary connection.'
+ );
+
+ $adapter->getWriteConnection();
+ $this->assertTrue($adapter->hasWriteConnection());
+ }
+
+ public function test_read_falls_back_to_write_without_read_config(): void
+ {
+ $adapter = new SqliteAdapter([
+ 'driver' => 'sqlite',
+ 'database' => self::$write_db,
+ ]);
+
+ $this->assertSame(
+ $adapter->getWriteConnection(),
+ $adapter->getReadConnection(),
+ 'Without a read block, reads must reuse the write connection.'
+ );
+ }
+
+ public function test_select_routes_to_read_replica(): void
+ {
+ $adapter = $this->makeSplitAdapter();
+ $builder = new QueryBuilder('pets', $adapter);
+
+ $row = $builder->where('id', 1)->first();
+
+ $this->assertSame('replica', $row->name);
+ }
+
+ public function test_write_routes_to_primary(): void
+ {
+ $adapter = $this->makeSplitAdapter();
+
+ (new QueryBuilder('pets', $adapter))
+ ->where('id', 1)
+ ->update(['name' => 'updated']);
+
+ // The write landed on the primary file...
+ $primary = $adapter->getWriteConnection()
+ ->query('SELECT name FROM pets WHERE id = 1')
+ ->fetchColumn();
+ $this->assertSame('updated', $primary);
+
+ // ...and the replica file is untouched.
+ $replica = $adapter->getReadConnection()
+ ->query('SELECT name FROM pets WHERE id = 1')
+ ->fetchColumn();
+ $this->assertSame('replica', $replica);
+ }
+
+ public function test_reads_route_to_primary_during_transaction(): void
+ {
+ $adapter = $this->makeSplitAdapter();
+
+ // Open a transaction on the primary; reads must now stick to it.
+ $adapter->getWriteConnection()->beginTransaction();
+
+ try {
+ $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first();
+
+ $this->assertSame(
+ 'primary',
+ $row->name,
+ 'While a transaction is open, reads must hit the primary.'
+ );
+ } finally {
+ $adapter->getWriteConnection()->rollBack();
+ }
+
+ // After the transaction closes, reads resume on the replica.
+ $row = (new QueryBuilder('pets', $adapter))->where('id', 1)->first();
+ $this->assertSame('replica', $row->name);
+ }
+}
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..ca6c3cec 100644
--- a/tests/Database/Relation/BelongsToRelationQueryTest.php
+++ b/tests/Database/Relation/BelongsToRelationQueryTest.php
@@ -172,6 +172,44 @@ public function test_multiple_relationship_accesses(string $name)
$this->assertInstanceOf(PetMasterModelStub::class, $master2);
$this->assertEquals($master1->id, $master2->id);
$this->assertEquals($master1->name, $master2->name);
+
+ // Lazy-load once: repeated access on the same instance must resolve the
+ // relation a single time and return the very same loaded object.
+ $this->assertSame($master1, $master2);
+ }
+
+ /**
+ * @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/Relation/EagerLoadingQueryTest.php b/tests/Database/Relation/EagerLoadingQueryTest.php
new file mode 100644
index 00000000..3cbd5abb
--- /dev/null
+++ b/tests/Database/Relation/EagerLoadingQueryTest.php
@@ -0,0 +1,299 @@
+connection($name)->dropIfExists("pets", false);
+ $migration->connection($name)->dropIfExists("pet_masters", false);
+ } catch (\Exception $e) {
+ // Ignore errors during cleanup
+ }
+ }
+ }
+
+ private function executeMigration(string $name): void
+ {
+ $migration = new MigrationExtendedStub();
+ $migration->connection($name)->dropIfExists("pets", false);
+ $migration->connection($name)->dropIfExists("pet_masters", false);
+
+ $migration->connection($name)->create("pet_masters", function (Table $table) {
+ $table->addIncrement("id");
+ $table->addString("name");
+ }, false);
+
+ $migration->connection($name)->create("pets", function (Table $table) {
+ $table->addIncrement("id");
+ $table->addString("name");
+ $table->addInteger("master_id");
+ $table->addForeign("master_id", [
+ "table" => "pet_masters",
+ "references" => "id",
+ "on" => "delete cascade"
+ ]);
+ }, false);
+ }
+
+ private function seedTestData(string $name): void
+ {
+ Database::connection($name)->statement("INSERT INTO pet_masters VALUES (1, 'didi'), (2, 'john'), (3, 'jane')");
+ Database::connection($name)->statement("INSERT INTO pets VALUES (1, 'fluffy', 1), (2, 'dolly', 1), (3, 'rex', 2), (4, 'max', 2), (5, 'bella', 3)");
+ }
+
+ // ===== belongsTo =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_belongs_to_matches_each_parent(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $expected = [1 => 'didi', 2 => 'didi', 3 => 'john', 4 => 'john', 5 => 'jane'];
+
+ $pets = PetModelStub::connection($name)->eager('master')->get();
+
+ $this->assertInstanceOf(Collection::class, $pets);
+
+ foreach ($pets as $pet) {
+ $master = $pet->master;
+ $this->assertInstanceOf(PetMasterModelStub::class, $master);
+ $this->assertEquals($expected[$pet->id], $master->name);
+ $this->assertEquals($pet->master_id, $master->id);
+ }
+ }
+
+ /**
+ * Eager loading must pre-populate the relation so accessing it issues no
+ * further query. We prove this by removing the related rows after the eager
+ * load: a lazy fetch would now find nothing, an eager one still has the data.
+ *
+ * @dataProvider connectionNames
+ */
+ public function test_eager_belongs_to_avoids_followup_query(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $pets = PetModelStub::connection($name)->eager('master')->get();
+
+ // Wipe the related table; an eager-loaded relation is unaffected.
+ Database::connection($name)->statement("DELETE FROM pets");
+ Database::connection($name)->statement("DELETE FROM pet_masters");
+
+ foreach ($pets as $pet) {
+ $master = $pet->master;
+ $this->assertInstanceOf(PetMasterModelStub::class, $master);
+ $this->assertEquals($pet->master_id, $master->id);
+ }
+ }
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_returns_same_instance_on_repeat_access(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $pets = PetModelStub::connection($name)->eager('master')->get();
+ $pet = $pets->first();
+
+ $this->assertSame($pet->master, $pet->master);
+ }
+
+ // ===== hasMany =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_has_many_matches_each_parent(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $expected = [1 => ['fluffy', 'dolly'], 2 => ['rex', 'max'], 3 => ['bella']];
+
+ $masters = PetMasterModelStub::connection($name)->eager('pets')->get();
+
+ Database::connection($name)->statement("DELETE FROM pets");
+
+ foreach ($masters as $master) {
+ $pets = $master->pets;
+ $this->assertInstanceOf(Collection::class, $pets);
+ $names = array_map(fn ($pet) => $pet->name, $pets->all());
+ sort($names);
+ $want = $expected[$master->id];
+ sort($want);
+ $this->assertEquals($want, $names);
+ }
+ }
+
+ // ===== hasOne =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_has_one_matches_each_parent(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $masters = PetMasterModelStub::connection($name)->eager('firstPet')->get();
+
+ Database::connection($name)->statement("DELETE FROM pets");
+
+ foreach ($masters as $master) {
+ $pet = $master->firstPet;
+ $this->assertInstanceOf(PetModelStub::class, $pet);
+ $this->assertEquals($master->id, $pet->master_id);
+ }
+ }
+
+ // ===== belongsToMany =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_belongs_to_many_matches_each_parent(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $expected = [1 => 2, 2 => 2, 3 => 1];
+
+ $masters = PetMasterModelStub::connection($name)->eager('manyPets')->get();
+
+ Database::connection($name)->statement("DELETE FROM pets");
+
+ foreach ($masters as $master) {
+ $pets = $master->manyPets;
+ $this->assertInstanceOf(Collection::class, $pets);
+ $this->assertCount($expected[$master->id], $pets->all());
+ }
+ }
+
+ // ===== multiple names =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_multiple_relations_in_one_call(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $masters = PetMasterModelStub::connection($name)->eager(['pets', 'firstPet'])->get();
+
+ Database::connection($name)->statement("DELETE FROM pets");
+
+ foreach ($masters as $master) {
+ $this->assertInstanceOf(Collection::class, $master->pets);
+ $this->assertInstanceOf(PetModelStub::class, $master->firstPet);
+ }
+ }
+
+ // ===== edge cases =====
+
+ /**
+ * @dataProvider connectionNames
+ */
+ public function test_eager_with_no_related_rows(string $name)
+ {
+ $this->executeMigration($name);
+ Database::connection($name)->statement("INSERT INTO pet_masters VALUES (1, 'didi')");
+
+ $masters = PetMasterModelStub::connection($name)->eager('pets')->get();
+
+ foreach ($masters as $master) {
+ $pets = $master->pets;
+ $this->assertInstanceOf(Collection::class, $pets);
+ $this->assertCount(0, $pets->all());
+ }
+ }
+
+ /**
+ * Guards the HasMany::addConstraints() fix: lazy access must filter on the
+ * foreign key column (master_id), not the parent primary key.
+ *
+ * @dataProvider connectionNames
+ */
+ public function test_lazy_has_many_filters_on_foreign_key(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ $master = PetMasterModelStub::connection($name)->retrieve(2);
+ $pets = $master->pets;
+
+ $names = array_map(fn ($pet) => $pet->name, $pets->all());
+ sort($names);
+
+ $this->assertEquals(['max', 'rex'], $names);
+ }
+
+ /**
+ * The eager list must not leak onto a subsequent query reusing the shared
+ * builder instance.
+ *
+ * @dataProvider connectionNames
+ */
+ public function test_eager_does_not_leak_into_next_query(string $name)
+ {
+ $this->executeMigration($name);
+ $this->seedTestData($name);
+
+ PetModelStub::connection($name)->eager('master')->get();
+
+ // A plain fetch afterwards must not attempt to eager load anything.
+ $pets = PetModelStub::connection($name)->all();
+
+ $this->assertInstanceOf(Collection::class, $pets);
+ $this->assertCount(5, $pets->all());
+ }
+}
diff --git a/tests/Database/Stubs/PetMasterModelStub.php b/tests/Database/Stubs/PetMasterModelStub.php
index 7ce52aec..fad409bd 100644
--- a/tests/Database/Stubs/PetMasterModelStub.php
+++ b/tests/Database/Stubs/PetMasterModelStub.php
@@ -2,7 +2,9 @@
namespace Bow\Tests\Database\Stubs;
+use Bow\Database\Barry\Relations\BelongsToMany;
use Bow\Database\Barry\Relations\HasMany;
+use Bow\Database\Barry\Relations\HasOne;
class PetMasterModelStub extends \Bow\Database\Barry\Model
{
@@ -28,6 +30,26 @@ class PetMasterModelStub extends \Bow\Database\Barry\Model
*/
public function pets(): HasMany
{
- return $this->hasMany(PetModelStub::class);
+ return $this->hasMany(PetModelStub::class, 'id', 'master_id');
+ }
+
+ /**
+ * Get a single owned pet
+ *
+ * @return HasOne
+ */
+ public function firstPet(): HasOne
+ {
+ return $this->hasOne(PetModelStub::class, 'master_id', 'id');
+ }
+
+ /**
+ * Get the list of pets through a belongs to many relation
+ *
+ * @return BelongsToMany
+ */
+ public function manyPets(): BelongsToMany
+ {
+ return $this->belongsToMany(PetModelStub::class, 'id', 'master_id');
}
}
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();
+
+ // The route collection is a global (static) registry that intentionally
+ // gathers routes across every Router instance. Clear it before each test
+ // so assertions are not polluted by routes registered by earlier tests.
+ $routes = new \ReflectionProperty(Router::class, 'routes');
+ $routes->setAccessible(true);
+ $routes->setValue(null, []);
+ }
+
+ public function test_registrar_registers_routes_from_controller(): void
+ {
+ $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/Session/CookieTest.php b/tests/Session/CookieTest.php
new file mode 100644
index 00000000..fefeea38
--- /dev/null
+++ b/tests/Session/CookieTest.php
@@ -0,0 +1,96 @@
+setAccessible(true);
+
+ return $method->invoke(null, $expiration);
+ }
+
+ /**
+ * The stub session config sets domain to null (SESSION_DOMAIN unset). It
+ * must reach setcookie() as a string, never null — that null is what would
+ * deprecate on PHP 8.x and fatally throw on PHP 9.
+ */
+ public function test_null_domain_is_coerced_to_empty_string()
+ {
+ $options = $this->options(3600);
+
+ $this->assertSame('', $options['domain']);
+ $this->assertIsString($options['domain']);
+ }
+
+ /**
+ * Every option must carry its declared scalar type so setcookie() never
+ * receives a null in a non-nullable slot.
+ */
+ public function test_options_are_strictly_typed()
+ {
+ $options = $this->options(3600);
+
+ $this->assertIsInt($options['expires']);
+ $this->assertIsString($options['path']);
+ $this->assertIsString($options['domain']);
+ $this->assertIsBool($options['secure']);
+ $this->assertIsBool($options['httponly']);
+ $this->assertIsString($options['samesite']);
+ }
+
+ /**
+ * Values come from the session config; SameSite falls back to Lax.
+ */
+ public function test_options_reflect_session_config()
+ {
+ $options = $this->options(3600);
+
+ $this->assertSame('/', $options['path']);
+ $this->assertFalse($options['secure']);
+ // The stub config explicitly sets httponly to false.
+ $this->assertFalse($options['httponly']);
+ $this->assertSame('Lax', $options['samesite']);
+ }
+
+ /**
+ * Cookie::remove() clears by delegating to set() with a negative lifetime.
+ * The clearing cookie must therefore carry the same path/domain/samesite
+ * attributes (browsers require a matching attribute set to overwrite), and
+ * its expiry must land in the past.
+ */
+ public function test_clearing_cookie_keeps_matching_attributes_and_past_expiry()
+ {
+ $live = $this->options(3600);
+ $clear = $this->options(-1000);
+
+ $this->assertLessThan($live['expires'], $clear['expires']);
+ $this->assertLessThan(time(), $clear['expires']);
+
+ $this->assertSame($live['path'], $clear['path']);
+ $this->assertSame($live['domain'], $clear['domain']);
+ $this->assertSame($live['samesite'], $clear['samesite']);
+ $this->assertSame($live['secure'], $clear['secure']);
+ $this->assertSame($live['httponly'], $clear['httponly']);
+ }
+}
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
+});